Skip to main content

oxihuman_mesh/
normal_map_bake.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4#![allow(dead_code)]
5#![allow(clippy::too_many_arguments)]
6
7use crate::mesh::MeshBuffers;
8use std::io::Write;
9
10// ---------------------------------------------------------------------------
11// Internal math helpers
12// ---------------------------------------------------------------------------
13
14#[inline]
15fn dot3(a: [f32; 3], b: [f32; 3]) -> f32 {
16    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
17}
18
19#[inline]
20fn sub3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
21    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
22}
23
24#[inline]
25fn cross3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
26    [
27        a[1] * b[2] - a[2] * b[1],
28        a[2] * b[0] - a[0] * b[2],
29        a[0] * b[1] - a[1] * b[0],
30    ]
31}
32
33#[inline]
34fn add3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
35    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
36}
37
38#[inline]
39fn scale3(v: [f32; 3], s: f32) -> [f32; 3] {
40    [v[0] * s, v[1] * s, v[2] * s]
41}
42
43#[inline]
44fn len3(v: [f32; 3]) -> f32 {
45    (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt()
46}
47
48#[inline]
49fn normalize3(v: [f32; 3]) -> [f32; 3] {
50    let l = len3(v);
51    if l > 1e-10 {
52        scale3(v, 1.0 / l)
53    } else {
54        [0.0, 0.0, 1.0]
55    }
56}
57
58#[inline]
59fn lerp3(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] {
60    [
61        a[0] + (b[0] - a[0]) * t,
62        a[1] + (b[1] - a[1]) * t,
63        a[2] + (b[2] - a[2]) * t,
64    ]
65}
66
67// Clamp scalar to [lo, hi]
68#[inline]
69fn clamp_f32(v: f32, lo: f32, hi: f32) -> f32 {
70    if v < lo {
71        lo
72    } else if v > hi {
73        hi
74    } else {
75        v
76    }
77}
78
79// ---------------------------------------------------------------------------
80// NormalMapBakeParams
81// ---------------------------------------------------------------------------
82
83/// Parameters for normal map baking.
84#[derive(Debug, Clone)]
85pub struct NormalMapBakeParams {
86    /// Texture width in pixels (default 512).
87    pub width: usize,
88    /// Texture height in pixels (default 512).
89    pub height: usize,
90    /// Ray origin offset along low-poly normal (default 0.01).
91    pub cage_offset: f32,
92    /// true = tangent-space normal map, false = object-space.
93    pub tangent_space: bool,
94    /// Background color for unmapped pixels (default [0.5, 0.5, 1.0]).
95    pub background: [f32; 3],
96}
97
98impl Default for NormalMapBakeParams {
99    fn default() -> Self {
100        Self {
101            width: 512,
102            height: 512,
103            cage_offset: 0.01,
104            tangent_space: true,
105            background: [0.5, 0.5, 1.0],
106        }
107    }
108}
109
110// ---------------------------------------------------------------------------
111// NormalMapTexture
112// ---------------------------------------------------------------------------
113
114/// Baked normal map texture with RGB pixels in [0, 1].
115#[derive(Debug, Clone)]
116pub struct NormalMapTexture {
117    /// RGB pixels in row-major order, values in [0, 1].
118    pub pixels: Vec<[f32; 3]>,
119    pub width: usize,
120    pub height: usize,
121    pub filled_pixels: usize,
122    /// filled_pixels / (width * height)
123    pub coverage: f32,
124}
125
126impl NormalMapTexture {
127    /// Create a new texture filled with the background color.
128    pub fn new(width: usize, height: usize, background: [f32; 3]) -> Self {
129        let total = width * height;
130        Self {
131            pixels: vec![background; total],
132            width,
133            height,
134            filled_pixels: 0,
135            coverage: 0.0,
136        }
137    }
138
139    /// Get pixel color at (x, y).
140    pub fn get(&self, x: usize, y: usize) -> [f32; 3] {
141        self.pixels[y * self.width + x]
142    }
143
144    /// Set pixel color at (x, y).
145    pub fn set(&mut self, x: usize, y: usize, color: [f32; 3]) {
146        self.pixels[y * self.width + x] = color;
147    }
148
149    /// Convert to u8 RGB (values clamped to [0, 255]).
150    pub fn to_rgb_u8(&self) -> Vec<[u8; 3]> {
151        self.pixels
152            .iter()
153            .map(|p| {
154                [
155                    (clamp_f32(p[0], 0.0, 1.0) * 255.0).round() as u8,
156                    (clamp_f32(p[1], 0.0, 1.0) * 255.0).round() as u8,
157                    (clamp_f32(p[2], 0.0, 1.0) * 255.0).round() as u8,
158                ]
159            })
160            .collect()
161    }
162
163    /// Export as PPM P6 binary format.
164    pub fn save_ppm(&self, path: &std::path::Path) -> anyhow::Result<()> {
165        let mut file = std::fs::File::create(path)?;
166        // Write PPM header
167        write!(file, "P6\n{} {}\n255\n", self.width, self.height)?;
168        // Write binary RGB bytes
169        let rgb_u8 = self.to_rgb_u8();
170        for pixel in &rgb_u8 {
171            file.write_all(pixel)?;
172        }
173        file.flush()?;
174        Ok(())
175    }
176}
177
178// ---------------------------------------------------------------------------
179// Normal encoding / decoding
180// ---------------------------------------------------------------------------
181
182/// Encode a normal vector to RGB (normal map convention: `[0,1]` range).
183/// out = (n + 1.0) / 2.0
184pub fn normal_to_rgb(normal: [f32; 3]) -> [f32; 3] {
185    [
186        (normal[0] + 1.0) * 0.5,
187        (normal[1] + 1.0) * 0.5,
188        (normal[2] + 1.0) * 0.5,
189    ]
190}
191
192/// Decode RGB to normal vector.
193pub fn rgb_to_normal(rgb: [f32; 3]) -> [f32; 3] {
194    [rgb[0] * 2.0 - 1.0, rgb[1] * 2.0 - 1.0, rgb[2] * 2.0 - 1.0]
195}
196
197// ---------------------------------------------------------------------------
198// UV triangle rasterizer
199// ---------------------------------------------------------------------------
200
201/// Rasterize a UV triangle into texture pixels (fill-rule: center-point test).
202/// Returns list of (pixel_x, pixel_y, barycentric_u, barycentric_v, barycentric_w).
203pub fn rasterize_uv_triangle(
204    uv0: [f32; 2],
205    uv1: [f32; 2],
206    uv2: [f32; 2],
207    tex_w: usize,
208    tex_h: usize,
209) -> Vec<(usize, usize, f32, f32, f32)> {
210    // Convert UV to pixel coords: px = u * w, py = (1 - v) * h (V flip)
211    let tw = tex_w as f32;
212    let th = tex_h as f32;
213
214    let px0 = uv0[0] * tw;
215    let py0 = (1.0 - uv0[1]) * th;
216    let px1 = uv1[0] * tw;
217    let py1 = (1.0 - uv1[1]) * th;
218    let px2 = uv2[0] * tw;
219    let py2 = (1.0 - uv2[1]) * th;
220
221    // Bounding box
222    let min_x = px0.min(px1).min(px2).floor().max(0.0) as usize;
223    let max_x = px0.max(px1).max(px2).ceil().min(tw - 1.0) as usize;
224    let min_y = py0.min(py1).min(py2).floor().max(0.0) as usize;
225    let max_y = py0.max(py1).max(py2).ceil().min(th - 1.0) as usize;
226
227    if min_x > max_x || min_y > max_y {
228        return Vec::new();
229    }
230
231    // Triangle area (signed) for barycentric computation
232    let denom = (py1 - py2) * (px0 - px2) + (px2 - px1) * (py0 - py2);
233    if denom.abs() < 1e-10 {
234        return Vec::new();
235    }
236
237    let mut result = Vec::new();
238
239    for py in min_y..=max_y {
240        for px in min_x..=max_x {
241            // Test pixel center
242            let cx = px as f32 + 0.5;
243            let cy = py as f32 + 0.5;
244
245            // Barycentric coordinates
246            let w0 = ((py1 - py2) * (cx - px2) + (px2 - px1) * (cy - py2)) / denom;
247            let w1 = ((py2 - py0) * (cx - px2) + (px0 - px2) * (cy - py2)) / denom;
248            let w2 = 1.0 - w0 - w1;
249
250            // Inside test: all coords >= 0 (with small epsilon for robustness)
251            if w0 >= -1e-5 && w1 >= -1e-5 && w2 >= -1e-5 {
252                result.push((px, py, w0, w1, w2));
253            }
254        }
255    }
256
257    result
258}
259
260// ---------------------------------------------------------------------------
261// Closest surface point on mesh
262// ---------------------------------------------------------------------------
263
264/// Find the closest point on the high-poly mesh surface to a world-space point.
265/// Returns (closest_position, interpolated_normal).
266pub fn closest_surface_point(mesh: &MeshBuffers, point: [f32; 3]) -> ([f32; 3], [f32; 3]) {
267    let mut best_dist_sq = f32::MAX;
268    let mut best_pos = point;
269    let mut best_normal = [0.0f32, 0.0, 1.0];
270
271    let num_faces = mesh.indices.len() / 3;
272
273    for face_idx in 0..num_faces {
274        let i0 = mesh.indices[face_idx * 3] as usize;
275        let i1 = mesh.indices[face_idx * 3 + 1] as usize;
276        let i2 = mesh.indices[face_idx * 3 + 2] as usize;
277
278        let v0 = mesh.positions[i0];
279        let v1 = mesh.positions[i1];
280        let v2 = mesh.positions[i2];
281
282        let n0 = mesh.normals[i0];
283        let n1 = mesh.normals[i1];
284        let n2 = mesh.normals[i2];
285
286        let (closest, bary) = closest_point_on_triangle(point, v0, v1, v2);
287
288        let dx = closest[0] - point[0];
289        let dy = closest[1] - point[1];
290        let dz = closest[2] - point[2];
291        let dist_sq = dx * dx + dy * dy + dz * dz;
292
293        if dist_sq < best_dist_sq {
294            best_dist_sq = dist_sq;
295            best_pos = closest;
296            // Interpolate normal using barycentric coords
297            let interp = add3(
298                add3(scale3(n0, bary[0]), scale3(n1, bary[1])),
299                scale3(n2, bary[2]),
300            );
301            best_normal = normalize3(interp);
302        }
303    }
304
305    (best_pos, best_normal)
306}
307
308/// Find the closest point on a triangle to a query point.
309/// Returns (closest_point, barycentric_coords).
310fn closest_point_on_triangle(
311    p: [f32; 3],
312    a: [f32; 3],
313    b: [f32; 3],
314    c: [f32; 3],
315) -> ([f32; 3], [f32; 3]) {
316    let ab = sub3(b, a);
317    let ac = sub3(c, a);
318    let ap = sub3(p, a);
319
320    let d1 = dot3(ab, ap);
321    let d2 = dot3(ac, ap);
322
323    // Check if P is in vertex region of A
324    if d1 <= 0.0 && d2 <= 0.0 {
325        return (a, [1.0, 0.0, 0.0]);
326    }
327
328    let bp = sub3(p, b);
329    let d3 = dot3(ab, bp);
330    let d4 = dot3(ac, bp);
331
332    // Check if P is in vertex region of B
333    if d3 >= 0.0 && d4 <= d3 {
334        return (b, [0.0, 1.0, 0.0]);
335    }
336
337    // Check if P is in edge region of AB
338    let vc = d1 * d4 - d3 * d2;
339    if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
340        let v = d1 / (d1 - d3);
341        let pt = add3(a, scale3(ab, v));
342        return (pt, [1.0 - v, v, 0.0]);
343    }
344
345    let cp = sub3(p, c);
346    let d5 = dot3(ab, cp);
347    let d6 = dot3(ac, cp);
348
349    // Check if P is in vertex region of C
350    if d6 >= 0.0 && d5 <= d6 {
351        return (c, [0.0, 0.0, 1.0]);
352    }
353
354    // Check if P is in edge region of AC
355    let vb = d5 * d2 - d1 * d6;
356    if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 {
357        let w = d2 / (d2 - d6);
358        let pt = add3(a, scale3(ac, w));
359        return (pt, [1.0 - w, 0.0, w]);
360    }
361
362    // Check if P is in edge region of BC
363    let va = d3 * d6 - d5 * d4;
364    if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
365        let w = (d4 - d3) / ((d4 - d3) + (d5 - d6));
366        let pt = add3(b, scale3(sub3(c, b), w));
367        return (pt, [0.0, 1.0 - w, w]);
368    }
369
370    // P is inside the triangle
371    let denom = 1.0 / (va + vb + vc);
372    let v = vb * denom;
373    let w = vc * denom;
374    let u = 1.0 - v - w;
375    let pt = add3(add3(scale3(a, u), scale3(b, v)), scale3(c, w));
376    (pt, [u, v, w])
377}
378
379// ---------------------------------------------------------------------------
380// TBN matrix computation
381// ---------------------------------------------------------------------------
382
383/// Compute TBN matrix for a low-poly triangle given positions, normals, uvs.
384/// Returns (tangent, bitangent, normal) — all normalized.
385fn compute_tbn(
386    p0: [f32; 3],
387    p1: [f32; 3],
388    p2: [f32; 3],
389    uv0: [f32; 2],
390    uv1: [f32; 2],
391    uv2: [f32; 2],
392    n_interp: [f32; 3],
393) -> ([f32; 3], [f32; 3], [f32; 3]) {
394    let edge1 = sub3(p1, p0);
395    let edge2 = sub3(p2, p0);
396
397    let delta_uv1 = [uv1[0] - uv0[0], uv1[1] - uv0[1]];
398    let delta_uv2 = [uv2[0] - uv0[0], uv2[1] - uv0[1]];
399
400    let denom = delta_uv1[0] * delta_uv2[1] - delta_uv2[0] * delta_uv1[1];
401
402    let tangent = if denom.abs() > 1e-10 {
403        let inv = 1.0 / denom;
404        normalize3([
405            inv * (delta_uv2[1] * edge1[0] - delta_uv1[1] * edge2[0]),
406            inv * (delta_uv2[1] * edge1[1] - delta_uv1[1] * edge2[1]),
407            inv * (delta_uv2[1] * edge1[2] - delta_uv1[1] * edge2[2]),
408        ])
409    } else {
410        // Fallback tangent if UV degenerate
411        let fallback = if edge1[0].abs() > 0.9 {
412            [0.0, 1.0, 0.0]
413        } else {
414            [1.0, 0.0, 0.0]
415        };
416        normalize3(fallback)
417    };
418
419    let n = normalize3(n_interp);
420    // Re-orthogonalize tangent against normal (Gram-Schmidt)
421    let t_proj = dot3(tangent, n);
422    let tangent = normalize3(sub3(tangent, scale3(n, t_proj)));
423    let bitangent = cross3(n, tangent);
424
425    (tangent, bitangent, n)
426}
427
428// ---------------------------------------------------------------------------
429// Main bake function
430// ---------------------------------------------------------------------------
431
432/// Bake normal map from high-poly onto low-poly UV space.
433pub fn bake_normal_map(
434    low_poly: &MeshBuffers,
435    high_poly: &MeshBuffers,
436    params: &NormalMapBakeParams,
437) -> NormalMapTexture {
438    let mut texture = NormalMapTexture::new(params.width, params.height, params.background);
439
440    // Track which pixels have been written
441    let total_pixels = params.width * params.height;
442    let mut written = vec![false; total_pixels];
443
444    let num_faces = low_poly.indices.len() / 3;
445
446    for face_idx in 0..num_faces {
447        let i0 = low_poly.indices[face_idx * 3] as usize;
448        let i1 = low_poly.indices[face_idx * 3 + 1] as usize;
449        let i2 = low_poly.indices[face_idx * 3 + 2] as usize;
450
451        let p0 = low_poly.positions[i0];
452        let p1 = low_poly.positions[i1];
453        let p2 = low_poly.positions[i2];
454
455        let n0 = low_poly.normals[i0];
456        let n1 = low_poly.normals[i1];
457        let n2 = low_poly.normals[i2];
458
459        let uv0 = low_poly.uvs[i0];
460        let uv1 = low_poly.uvs[i1];
461        let uv2 = low_poly.uvs[i2];
462
463        // Rasterize UV triangle
464        let pixels = rasterize_uv_triangle(uv0, uv1, uv2, params.width, params.height);
465
466        for (px, py, w0, w1, w2) in pixels {
467            // Interpolate 3D position using barycentric weights
468            let world_pos = add3(add3(scale3(p0, w0), scale3(p1, w1)), scale3(p2, w2));
469
470            // Offset along interpolated low-poly normal to avoid self-intersection
471            let low_n_interp =
472                normalize3(add3(add3(scale3(n0, w0), scale3(n1, w1)), scale3(n2, w2)));
473            let ray_origin = add3(world_pos, scale3(low_n_interp, params.cage_offset));
474
475            // Find closest point on high-poly mesh
476            let (_closest_pos, high_normal) = closest_surface_point(high_poly, ray_origin);
477
478            let final_normal = if params.tangent_space {
479                // Transform high-poly normal into tangent space of low-poly triangle
480                let (tangent, bitangent, face_normal) =
481                    compute_tbn(p0, p1, p2, uv0, uv1, uv2, low_n_interp);
482
483                let ts_x = dot3(high_normal, tangent);
484                let ts_y = dot3(high_normal, bitangent);
485                let ts_z = dot3(high_normal, face_normal);
486
487                normalize3([ts_x, ts_y, ts_z])
488            } else {
489                high_normal
490            };
491
492            let rgb = normal_to_rgb(final_normal);
493            texture.set(px, py, rgb);
494
495            let pixel_idx = py * params.width + px;
496            if !written[pixel_idx] {
497                written[pixel_idx] = true;
498            }
499        }
500    }
501
502    // Count filled pixels using the written bitmap
503    let filled = written.iter().filter(|&&w| w).count();
504    texture.filled_pixels = filled;
505    texture.coverage = filled as f32 / (params.width * params.height) as f32;
506
507    texture
508}
509
510// ---------------------------------------------------------------------------
511// Interpolation helper (used in tests)
512// ---------------------------------------------------------------------------
513
514fn lerp_normal(n0: [f32; 3], n1: [f32; 3], t: f32) -> [f32; 3] {
515    normalize3(lerp3(n0, n1, t))
516}
517
518// ---------------------------------------------------------------------------
519// Tests
520// ---------------------------------------------------------------------------
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525    use oxihuman_morph::engine::MeshBuffers as MorphMB;
526
527    fn make_mesh(
528        positions: Vec<[f32; 3]>,
529        normals: Vec<[f32; 3]>,
530        uvs: Vec<[f32; 2]>,
531        indices: Vec<u32>,
532    ) -> MeshBuffers {
533        MeshBuffers::from_morph(MorphMB {
534            positions,
535            normals,
536            uvs,
537            indices,
538            has_suit: false,
539        })
540    }
541
542    /// Simple unit quad mesh (2 triangles) covering UV [0,1]x[0,1]
543    fn quad_mesh() -> MeshBuffers {
544        make_mesh(
545            vec![
546                [0.0, 0.0, 0.0],
547                [1.0, 0.0, 0.0],
548                [1.0, 1.0, 0.0],
549                [0.0, 1.0, 0.0],
550            ],
551            vec![[0.0, 0.0, 1.0]; 4],
552            vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]],
553            vec![0, 1, 2, 0, 2, 3],
554        )
555    }
556
557    /// Simple single triangle mesh
558    fn single_tri_mesh() -> MeshBuffers {
559        make_mesh(
560            vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
561            vec![[0.0, 0.0, 1.0]; 3],
562            vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]],
563            vec![0, 1, 2],
564        )
565    }
566
567    // -----------------------------------------------------------------------
568    // Texture construction
569    // -----------------------------------------------------------------------
570
571    #[test]
572    fn test_normal_map_texture_new() {
573        let bg = [0.5f32, 0.5, 1.0];
574        let tex = NormalMapTexture::new(4, 4, bg);
575        assert_eq!(tex.width, 4);
576        assert_eq!(tex.height, 4);
577        assert_eq!(tex.pixels.len(), 16);
578        assert_eq!(tex.filled_pixels, 0);
579        assert!((tex.coverage - 0.0).abs() < 1e-6);
580        for p in &tex.pixels {
581            assert!((p[0] - bg[0]).abs() < 1e-6);
582        }
583    }
584
585    #[test]
586    fn test_normal_map_get_set() {
587        let mut tex = NormalMapTexture::new(8, 8, [0.5, 0.5, 1.0]);
588        let color = [0.1f32, 0.9, 0.5];
589        tex.set(3, 5, color);
590        let got = tex.get(3, 5);
591        assert!((got[0] - color[0]).abs() < 1e-6);
592        assert!((got[1] - color[1]).abs() < 1e-6);
593        assert!((got[2] - color[2]).abs() < 1e-6);
594    }
595
596    // -----------------------------------------------------------------------
597    // Normal encoding/decoding
598    // -----------------------------------------------------------------------
599
600    #[test]
601    fn test_normal_to_rgb_encoding() {
602        // (0, 0, 1) -> (0.5, 0.5, 1.0)
603        let n = [0.0f32, 0.0, 1.0];
604        let rgb = normal_to_rgb(n);
605        assert!((rgb[0] - 0.5).abs() < 1e-6);
606        assert!((rgb[1] - 0.5).abs() < 1e-6);
607        assert!((rgb[2] - 1.0).abs() < 1e-6);
608
609        // (-1, 0, 0) -> (0, 0.5, 0.5)
610        let n2 = [-1.0f32, 0.0, 0.0];
611        let rgb2 = normal_to_rgb(n2);
612        assert!((rgb2[0] - 0.0).abs() < 1e-6);
613        assert!((rgb2[1] - 0.5).abs() < 1e-6);
614        assert!((rgb2[2] - 0.5).abs() < 1e-6);
615    }
616
617    #[test]
618    fn test_rgb_to_normal_decoding() {
619        // (0.5, 0.5, 1.0) -> (0, 0, 1)
620        let rgb = [0.5f32, 0.5, 1.0];
621        let n = rgb_to_normal(rgb);
622        assert!((n[0] - 0.0).abs() < 1e-6);
623        assert!((n[1] - 0.0).abs() < 1e-6);
624        assert!((n[2] - 1.0).abs() < 1e-6);
625    }
626
627    #[test]
628    fn test_normal_roundtrip() {
629        let normals = [
630            [0.0f32, 0.0, 1.0],
631            [1.0, 0.0, 0.0],
632            [0.0, 1.0, 0.0],
633            [-1.0, 0.0, 0.0],
634            [0.0, -1.0, 0.0],
635            [0.0, 0.0, -1.0],
636        ];
637        for n in &normals {
638            let rgb = normal_to_rgb(*n);
639            let decoded = rgb_to_normal(rgb);
640            assert!((decoded[0] - n[0]).abs() < 1e-5, "x mismatch for {:?}", n);
641            assert!((decoded[1] - n[1]).abs() < 1e-5, "y mismatch for {:?}", n);
642            assert!((decoded[2] - n[2]).abs() < 1e-5, "z mismatch for {:?}", n);
643        }
644    }
645
646    // -----------------------------------------------------------------------
647    // Rasterization
648    // -----------------------------------------------------------------------
649
650    #[test]
651    fn test_rasterize_uv_triangle_basic() {
652        // Full texture-covering triangle
653        let uv0 = [0.0f32, 0.0];
654        let uv1 = [1.0, 0.0];
655        let uv2 = [0.5, 1.0];
656        let pixels = rasterize_uv_triangle(uv0, uv1, uv2, 8, 8);
657        // Should cover some pixels
658        assert!(!pixels.is_empty(), "Should rasterize at least one pixel");
659
660        // All barycentric coords should sum to ~1
661        for (_, _, w0, w1, w2) in &pixels {
662            let sum = w0 + w1 + w2;
663            assert!((sum - 1.0).abs() < 1e-4, "bary sum = {}", sum);
664        }
665    }
666
667    #[test]
668    fn test_rasterize_uv_triangle_empty() {
669        // Degenerate (zero-area) triangle
670        let uv0 = [0.5f32, 0.5];
671        let uv1 = [0.5, 0.5];
672        let uv2 = [0.5, 0.5];
673        let pixels = rasterize_uv_triangle(uv0, uv1, uv2, 8, 8);
674        assert!(
675            pixels.is_empty(),
676            "Degenerate triangle should produce no pixels"
677        );
678    }
679
680    // -----------------------------------------------------------------------
681    // PPM export
682    // -----------------------------------------------------------------------
683
684    #[test]
685    fn test_save_ppm() {
686        let tex = NormalMapTexture::new(4, 4, [0.5, 0.5, 1.0]);
687        let path = std::path::Path::new("/tmp/test_normal_map.ppm");
688        let result = tex.save_ppm(path);
689        assert!(result.is_ok(), "save_ppm failed: {:?}", result.err());
690        // Verify file exists and has non-zero size
691        let meta = std::fs::metadata(path).expect("should succeed");
692        assert!(meta.len() > 0);
693        // Verify PPM header by reading first bytes
694        let data = std::fs::read(path).expect("should succeed");
695        assert_eq!(&data[0..2], b"P6");
696    }
697
698    // -----------------------------------------------------------------------
699    // Closest surface point
700    // -----------------------------------------------------------------------
701
702    #[test]
703    fn test_closest_surface_point() {
704        let mesh = single_tri_mesh();
705        // Query point directly above the triangle centroid
706        let centroid = [1.0 / 3.0, 1.0 / 3.0, 1.0];
707        let (pos, normal) = closest_surface_point(&mesh, centroid);
708
709        // Closest point should be on the triangle (z=0 plane)
710        assert!((pos[2] - 0.0).abs() < 0.01, "Closest point z = {}", pos[2]);
711
712        // Normal should point roughly in +Z direction
713        assert!(normal[2] > 0.5, "Normal z = {}", normal[2]);
714
715        // x,y should be in triangle region
716        assert!(pos[0] >= -0.01 && pos[0] <= 1.01);
717        assert!(pos[1] >= -0.01 && pos[1] <= 1.01);
718    }
719
720    // -----------------------------------------------------------------------
721    // Bake
722    // -----------------------------------------------------------------------
723
724    #[test]
725    fn test_bake_normal_map_basic() {
726        let low = quad_mesh();
727        let high = quad_mesh();
728        let params = NormalMapBakeParams {
729            width: 16,
730            height: 16,
731            cage_offset: 0.01,
732            tangent_space: false, // object-space for simplicity
733            background: [0.5, 0.5, 1.0],
734        };
735        let result = bake_normal_map(&low, &high, &params);
736        assert_eq!(result.width, 16);
737        assert_eq!(result.height, 16);
738        assert_eq!(result.pixels.len(), 256);
739        // Should fill at least some pixels
740        assert!(result.filled_pixels > 0, "No pixels were filled");
741    }
742
743    #[test]
744    fn test_coverage() {
745        let low = quad_mesh();
746        let high = quad_mesh();
747        let params = NormalMapBakeParams {
748            width: 8,
749            height: 8,
750            cage_offset: 0.01,
751            tangent_space: false,
752            background: [0.5, 0.5, 1.0],
753        };
754        let result = bake_normal_map(&low, &high, &params);
755        // Coverage should be between 0 and 1
756        assert!(result.coverage >= 0.0 && result.coverage <= 1.0);
757        // For a quad covering the full UV space, coverage should be significant
758        assert!(
759            result.coverage > 0.3,
760            "Coverage too low: {}",
761            result.coverage
762        );
763        // Verify coverage == filled_pixels / total_pixels
764        let expected = result.filled_pixels as f32 / (8 * 8) as f32;
765        assert!((result.coverage - expected).abs() < 1e-5);
766    }
767
768    // -----------------------------------------------------------------------
769    // to_rgb_u8
770    // -----------------------------------------------------------------------
771
772    #[test]
773    fn test_to_rgb_u8() {
774        let mut tex = NormalMapTexture::new(2, 2, [0.5, 0.5, 1.0]);
775        tex.set(0, 0, [0.0, 0.0, 0.0]);
776        tex.set(1, 0, [1.0, 1.0, 1.0]);
777        let u8_pixels = tex.to_rgb_u8();
778        assert_eq!(u8_pixels.len(), 4);
779        // Black pixel
780        assert_eq!(u8_pixels[0], [0, 0, 0]);
781        // White pixel
782        assert_eq!(u8_pixels[1], [255, 255, 255]);
783    }
784
785    // -----------------------------------------------------------------------
786    // Tangent-space bake
787    // -----------------------------------------------------------------------
788
789    #[test]
790    fn test_bake_tangent_space() {
791        let low = single_tri_mesh();
792        let high = single_tri_mesh();
793        let params = NormalMapBakeParams {
794            width: 8,
795            height: 8,
796            cage_offset: 0.005,
797            tangent_space: true,
798            background: [0.5, 0.5, 1.0],
799        };
800        let result = bake_normal_map(&low, &high, &params);
801        assert_eq!(result.width, 8);
802        assert_eq!(result.height, 8);
803        // In tangent space, a flat mesh should produce ~(0.5, 0.5, 1.0) normals
804        // (pointing straight up in tangent space)
805        for pixel in &result.pixels {
806            // All pixels should be in [0, 1]
807            assert!(pixel[0] >= 0.0 && pixel[0] <= 1.0);
808            assert!(pixel[1] >= 0.0 && pixel[1] <= 1.0);
809            assert!(pixel[2] >= 0.0 && pixel[2] <= 1.0);
810        }
811    }
812
813    // -----------------------------------------------------------------------
814    // lerp_normal helper (internal)
815    // -----------------------------------------------------------------------
816
817    #[test]
818    fn test_lerp_normal_helper() {
819        let n0 = [1.0f32, 0.0, 0.0];
820        let n1 = [0.0f32, 1.0, 0.0];
821        let mid = lerp_normal(n0, n1, 0.5);
822        let expected_len = (mid[0] * mid[0] + mid[1] * mid[1] + mid[2] * mid[2]).sqrt();
823        assert!(
824            (expected_len - 1.0).abs() < 1e-5,
825            "lerp_normal not normalized"
826        );
827    }
828}