1#![allow(dead_code)]
5#![allow(clippy::too_many_arguments)]
6
7use crate::mesh::MeshBuffers;
8use std::io::Write;
9
10#[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#[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#[derive(Debug, Clone)]
85pub struct NormalMapBakeParams {
86 pub width: usize,
88 pub height: usize,
90 pub cage_offset: f32,
92 pub tangent_space: bool,
94 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#[derive(Debug, Clone)]
116pub struct NormalMapTexture {
117 pub pixels: Vec<[f32; 3]>,
119 pub width: usize,
120 pub height: usize,
121 pub filled_pixels: usize,
122 pub coverage: f32,
124}
125
126impl NormalMapTexture {
127 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 pub fn get(&self, x: usize, y: usize) -> [f32; 3] {
141 self.pixels[y * self.width + x]
142 }
143
144 pub fn set(&mut self, x: usize, y: usize, color: [f32; 3]) {
146 self.pixels[y * self.width + x] = color;
147 }
148
149 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 pub fn save_ppm(&self, path: &std::path::Path) -> anyhow::Result<()> {
165 let mut file = std::fs::File::create(path)?;
166 write!(file, "P6\n{} {}\n255\n", self.width, self.height)?;
168 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
178pub 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
192pub 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
197pub 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 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 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 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 let cx = px as f32 + 0.5;
243 let cy = py as f32 + 0.5;
244
245 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 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
260pub 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 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
308fn 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 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 if d3 >= 0.0 && d4 <= d3 {
334 return (b, [0.0, 1.0, 0.0]);
335 }
336
337 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 if d6 >= 0.0 && d5 <= d6 {
351 return (c, [0.0, 0.0, 1.0]);
352 }
353
354 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 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 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
379fn 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 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 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
428pub 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 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 let pixels = rasterize_uv_triangle(uv0, uv1, uv2, params.width, params.height);
465
466 for (px, py, w0, w1, w2) in pixels {
467 let world_pos = add3(add3(scale3(p0, w0), scale3(p1, w1)), scale3(p2, w2));
469
470 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 let (_closest_pos, high_normal) = closest_surface_point(high_poly, ray_origin);
477
478 let final_normal = if params.tangent_space {
479 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 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
510fn lerp_normal(n0: [f32; 3], n1: [f32; 3], t: f32) -> [f32; 3] {
515 normalize3(lerp3(n0, n1, t))
516}
517
518#[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 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 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 #[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 #[test]
601 fn test_normal_to_rgb_encoding() {
602 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 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 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 #[test]
651 fn test_rasterize_uv_triangle_basic() {
652 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 assert!(!pixels.is_empty(), "Should rasterize at least one pixel");
659
660 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 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 #[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 let meta = std::fs::metadata(path).expect("should succeed");
692 assert!(meta.len() > 0);
693 let data = std::fs::read(path).expect("should succeed");
695 assert_eq!(&data[0..2], b"P6");
696 }
697
698 #[test]
703 fn test_closest_surface_point() {
704 let mesh = single_tri_mesh();
705 let centroid = [1.0 / 3.0, 1.0 / 3.0, 1.0];
707 let (pos, normal) = closest_surface_point(&mesh, centroid);
708
709 assert!((pos[2] - 0.0).abs() < 0.01, "Closest point z = {}", pos[2]);
711
712 assert!(normal[2] > 0.5, "Normal z = {}", normal[2]);
714
715 assert!(pos[0] >= -0.01 && pos[0] <= 1.01);
717 assert!(pos[1] >= -0.01 && pos[1] <= 1.01);
718 }
719
720 #[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, background: [0.5, 0.5, 1.0],
734 };
735 let result = bake_normal_map(&low, &high, ¶ms);
736 assert_eq!(result.width, 16);
737 assert_eq!(result.height, 16);
738 assert_eq!(result.pixels.len(), 256);
739 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, ¶ms);
755 assert!(result.coverage >= 0.0 && result.coverage <= 1.0);
757 assert!(
759 result.coverage > 0.3,
760 "Coverage too low: {}",
761 result.coverage
762 );
763 let expected = result.filled_pixels as f32 / (8 * 8) as f32;
765 assert!((result.coverage - expected).abs() < 1e-5);
766 }
767
768 #[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 assert_eq!(u8_pixels[0], [0, 0, 0]);
781 assert_eq!(u8_pixels[1], [255, 255, 255]);
783 }
784
785 #[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, ¶ms);
801 assert_eq!(result.width, 8);
802 assert_eq!(result.height, 8);
803 for pixel in &result.pixels {
806 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 #[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}