feat(math): Étape 15.A primitives — cube, plane, uv_sphere, icosphere, cylinder, cone, torus + tests ; factorisation cube_geometry (cube, spot_test)

This commit is contained in:
Jérôme Bousquié
2026-09-20 07:42:27 +02:00
parent 84ceffc755
commit 4da89c7178
4 changed files with 535 additions and 118 deletions
+3 -64
View File
@@ -13,7 +13,8 @@
use glam::{Quat, Vec3};
use wsg_lib::AppHandler;
use wsg_lib::app::AppBuilder;
use wsg_lib::resources::{Geometry, Texture};
use wsg_lib::math::cube;
use wsg_lib::resources::Texture;
use wsg_lib::utils::WsgError;
/// Handler de démonstration : fait tourner le cube texturé dans `update`.
@@ -22,68 +23,6 @@ struct Cube {
angle: f32,
}
/// Construit la `Geometry` d'un cube unitaire centré à l'origine (arête de 1), une normale et des
/// coordonnées UV par face. 24 sommets (4 par face) + 36 indices ; la couleur est absente (défaut
/// blanc opaque via `Geometry::to_vertices`). Depuis l'Étape 10, chaque face reçoit des UV [0,1]² pour
/// que la texture diffuse soit proprement projetée sur le cube.
fn cube_geometry() -> Geometry {
let s = 0.5; // demi-arête
// Chaque face : (normale sortante, 4 coins). Le culling est désactivé par défaut (PrimitiveState
// par défaut), donc l'ordre d'enroulement n'affecte pas la visibilité ; seules les normales comptent
// pour l'éclairage.
let faces: [([f32; 3], [[f32; 3]; 4]); 6] = [
(
[0.0, 0.0, 1.0],
[[-s, -s, s], [s, -s, s], [s, s, s], [-s, s, s]],
), // +Z
(
[0.0, 0.0, -1.0],
[[s, -s, -s], [-s, -s, -s], [-s, s, -s], [s, s, -s]],
), // -Z
(
[1.0, 0.0, 0.0],
[[s, -s, -s], [s, s, -s], [s, s, s], [s, -s, s]],
), // +X
(
[-1.0, 0.0, 0.0],
[[-s, -s, s], [-s, s, s], [-s, s, -s], [-s, -s, -s]],
), // -X
(
[0.0, 1.0, 0.0],
[[-s, s, -s], [s, s, -s], [s, s, s], [-s, s, s]],
), // +Y
(
[0.0, -1.0, 0.0],
[[-s, -s, s], [s, -s, s], [s, -s, -s], [-s, -s, -s]],
), // -Y
];
let mut positions = Vec::with_capacity(24);
let mut normals = Vec::with_capacity(24);
let mut uvs = Vec::with_capacity(24);
// Mapping UV canonique d'un carré : BL(0,0) BR(1,0) TR(1,1) TL(0,1).
let quad_uvs = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
for (normal, corners) in faces {
for (i, corner) in corners.iter().enumerate() {
positions.push(*corner);
normals.push(normal);
uvs.push(quad_uvs[i]);
}
}
// 2 triangles par face, 36 indices.
let mut indices = Vec::with_capacity(36);
for face in 0..6u16 {
let b = face * 4;
indices.extend_from_slice(&[b, b + 1, b + 2, b, b + 2, b + 3]);
}
Geometry::new(positions)
.with_normals(normals)
.with_uvs(uvs)
.with_indices(indices)
}
/// Génère un damier RGBA 8×8 (blanc/brique) *procédural*, sans asset sur disque, pour texturer le
/// cube (Étape 10, D3/D4). Renvoyé en `Vec<u8>` brut RGBA8, chargeable via `Texture::from_rgba8`.
fn checkerboard_rgba() -> Vec<u8> {
@@ -120,7 +59,7 @@ impl AppHandler for Cube {
.unwrap();
app.scene
.create_mesh("cube_mesh", cube_geometry(), Some("cube_material"))
.create_mesh("cube_mesh", cube(1.0), Some("cube_material"))
.unwrap();
app.scene.add_entity("cube", "cube_mesh").unwrap();
+2 -54
View File
@@ -12,7 +12,7 @@
use glam::{Quat, Vec3};
use wsg_lib::AppHandler;
use wsg_lib::app::AppBuilder;
use wsg_lib::resources::Geometry;
use wsg_lib::math::cube;
use wsg_lib::utils::WsgError;
/// Handler de test : cube qui tourne lentement sur deux axes, éclairé **uniquement** par une spot.
@@ -21,58 +21,6 @@ struct SpotTest {
angle_y: f32,
}
/// Cube unitaire centré à l'origine (mêmes 24 sommets / 36 indices que l'exemple `cube`).
fn cube_geometry() -> Geometry {
let s = 0.5;
let faces: [([f32; 3], [[f32; 3]; 4]); 6] = [
(
[0.0, 0.0, 1.0],
[[-s, -s, s], [s, -s, s], [s, s, s], [-s, s, s]],
), // +Z
(
[0.0, 0.0, -1.0],
[[s, -s, -s], [-s, -s, -s], [-s, s, -s], [s, s, -s]],
), // -Z
(
[1.0, 0.0, 0.0],
[[s, -s, -s], [s, s, -s], [s, s, s], [s, -s, s]],
), // +X
(
[-1.0, 0.0, 0.0],
[[-s, -s, s], [-s, s, s], [-s, s, -s], [-s, -s, -s]],
), // -X
(
[0.0, 1.0, 0.0],
[[-s, s, -s], [s, s, -s], [s, s, s], [-s, s, s]],
), // +Y
(
[0.0, -1.0, 0.0],
[[-s, -s, s], [s, -s, s], [s, -s, -s], [-s, -s, -s]],
), // -Y
];
let mut positions = Vec::with_capacity(24);
let mut normals = Vec::with_capacity(24);
let mut uvs = Vec::with_capacity(24);
let quad_uvs = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
for (normal, corners) in faces {
for (i, corner) in corners.iter().enumerate() {
positions.push(*corner);
normals.push(normal);
uvs.push(quad_uvs[i]);
}
}
let mut indices = Vec::with_capacity(36);
for face in 0..6u16 {
let b = face * 4;
indices.extend_from_slice(&[b, b + 1, b + 2, b, b + 2, b + 3]);
}
Geometry::new(positions)
.with_normals(normals)
.with_uvs(uvs)
.with_indices(indices)
}
impl AppHandler for SpotTest {
fn setup(&mut self, app: &mut wsg_lib::App) {
app.scene
@@ -80,7 +28,7 @@ impl AppHandler for SpotTest {
.unwrap();
app.scene.add_material_shader("mat", "standard").unwrap();
app.scene
.create_mesh("cube_mesh", cube_geometry(), Some("mat"))
.create_mesh("cube_mesh", cube(1.0), Some("mat"))
.unwrap();
app.scene.add_entity("cube", "cube_mesh").unwrap();
+3
View File
@@ -14,10 +14,13 @@
//! - `transform.rs`: Defines the `Transform` struct and its conversion to matrix form
//! - `geometry.rs`: Defines the `Geometry` struct for mesh data storage
//! - `camera.rs`: Defines the `Camera` struct and view/projection matrix calculations
//! - `primitives.rs`: Procedural mesh generators (cube, sphere, cylinder, cone, torus…) returning `Geometry`
pub mod geometry;
pub mod primitives;
pub mod transform;
// Re-exports
pub use geometry::{Geometry, GeometryError};
pub use primitives::{cone, cube, cylinder, icosphere, plane, torus, uv_sphere};
pub use transform::Transform;
+527
View File
@@ -0,0 +1,527 @@
//! # Primitives Module — Meshes géométriques prêts à l'emploi (Étape 15, ROADMAP 2.2)
//!
//! Générateurs de `Geometry` procédurales pour les formes 3D courantes, utilisables
//! directement dans WSGL sans import wgpu : `cube`, `plane`, `uv_sphere`, `icosphere`,
//! `cylinder`, `cone` (et `torus` en bonus).
//!
//! ## Conventions
//! - Axe **Y vers le haut**, origine centrée (sauf `plane`, ancré dans le plan XZ autour de 0).
//! - Normales **orientées vers l'extérieur** (pertinentes pour l'éclairage Phong, le culling
//! restant désactivé par défaut).
//! - UVs dans [0,1]², aussi continus que possible ; `uv_sphere`/`icosphere` projettent depuis
//! des coordonnées sphériques.
//! - Chaque générateur renvoie une `Geometry` **complète** (positions + normales + UVs +
//! indices, pas de couleurs → défaut blanc opaque via `Geometry::to_vertices`).
//!
//! ## Invariant
//! Toute géométrie produite passe `Geometry::validate()` sans erreur (vérifié par les tests).
use crate::math::Geometry;
use glam::Vec3;
use std::collections::HashMap;
/// Génére un cube centré à l'origine, d'arête `size`, avec une normale et des UVs par face.
/// 24 sommets (4 par face) + 36 indices. Reproduit exactement le `cube_geometry` historique des
/// exemples (Étape 5/10) pour assurer la non-régression.
pub fn cube(size: f32) -> Geometry {
let s = size * 0.5; // demi-arête
let faces: [([f32; 3], [[f32; 3]; 4]); 6] = [
(
[0.0, 0.0, 1.0],
[[-s, -s, s], [s, -s, s], [s, s, s], [-s, s, s]],
), // +Z
(
[0.0, 0.0, -1.0],
[[s, -s, -s], [-s, -s, -s], [-s, s, -s], [s, s, -s]],
), // -Z
(
[1.0, 0.0, 0.0],
[[s, -s, -s], [s, s, -s], [s, s, s], [s, -s, s]],
), // +X
(
[-1.0, 0.0, 0.0],
[[-s, -s, s], [-s, s, s], [-s, s, -s], [-s, -s, -s]],
), // -X
(
[0.0, 1.0, 0.0],
[[-s, s, -s], [s, s, -s], [s, s, s], [-s, s, s]],
), // +Y
(
[0.0, -1.0, 0.0],
[[-s, -s, s], [s, -s, s], [s, -s, -s], [-s, -s, -s]],
), // -Y
];
let mut positions = Vec::with_capacity(24);
let mut normals = Vec::with_capacity(24);
let mut uvs = Vec::with_capacity(24);
let quad_uvs = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
for (normal, corners) in faces {
for (i, corner) in corners.iter().enumerate() {
positions.push(*corner);
normals.push(normal);
uvs.push(quad_uvs[i]);
}
}
let mut indices = Vec::with_capacity(36);
for face in 0..6u16 {
let b = face * 4;
indices.extend_from_slice(&[b, b + 1, b + 2, b, b + 2, b + 3]);
}
Geometry::new(positions)
.with_normals(normals)
.with_uvs(uvs)
.with_indices(indices)
}
/// Génére un plan horizontal dans le plan XZ (normale +Y), centré en (0, 0, 0), de dimensions
/// `width` × `depth`, subdivisé en `seg_x` × `seg_z` cellules. UVs étirées sur [0,1]².
pub fn plane(width: f32, depth: f32, seg_x: u32, seg_z: u32) -> Geometry {
let sx = seg_x.max(1);
let sz = seg_z.max(1);
let (mut positions, mut normals, mut uvs, mut indices) = (
Vec::<[f32; 3]>::new(),
Vec::<[f32; 3]>::new(),
Vec::<[f32; 2]>::new(),
Vec::<u16>::new(),
);
for z in 0..=sz {
let vz = z as f32 / sz as f32;
for x in 0..=sx {
let vx = x as f32 / sx as f32;
positions.push([(vx - 0.5) * width, 0.0, (vz - 0.5) * depth]);
normals.push([0.0, 1.0, 0.0]);
uvs.push([vx, vz]);
}
}
for z in 0..sz {
for x in 0..sx {
let a = z * (sx + 1) + x;
let b = a + 1;
let c = (z + 1) * (sx + 1) + x;
let d = c + 1;
indices
.extend_from_slice(&[a as u16, c as u16, b as u16, b as u16, c as u16, d as u16]);
}
}
Geometry::new(positions)
.with_normals(normals)
.with_uvs(uvs)
.with_indices(indices)
}
/// Génére une sphère UV (latitude/longitude) de rayon `radius`, avec `sectors` segments autour et
/// `stacks` cercles verticaux. Normales lisses = position normalisée ; UVs sphériques.
pub fn uv_sphere(radius: f32, sectors: u32, stacks: u32) -> Geometry {
let si = sectors.max(3);
let st = stacks.max(3);
let (mut positions, mut normals, mut uvs, mut indices) = (
Vec::<[f32; 3]>::new(),
Vec::<[f32; 3]>::new(),
Vec::<[f32; 2]>::new(),
Vec::<u16>::new(),
);
for stack in 0..=st {
let v = stack as f32 / st as f32;
let phi = v * std::f32::consts::PI;
for sector in 0..=si {
let u = sector as f32 / si as f32;
let theta = u * 2.0 * std::f32::consts::PI;
let (sin_p, cos_p) = phi.sin_cos();
let (sin_t, cos_t) = theta.sin_cos();
let pos = Vec3::new(
radius * sin_p * cos_t,
radius * cos_p,
radius * sin_p * sin_t,
);
positions.push(pos.to_array());
normals.push(pos.normalize().to_array());
uvs.push([u, v]);
}
}
for stack in 0..st {
for sector in 0..si {
let k1 = stack * (si + 1) + sector;
let k2 = k1 + si + 1;
let (k1, k2) = (k1 as u16, k2 as u16);
indices.extend_from_slice(&[k1, k2, k1 + 1, k1 + 1, k2, k2 + 1]);
}
}
Geometry::new(positions)
.with_normals(normals)
.with_uvs(uvs)
.with_indices(indices)
}
/// Génére une icosphère (icosaèdre subdivisé) de rayon `radius`. `subdivisions = 0` donne un
/// icosaèdre (12 sommets / 20 faces / 60 indices) ; chaque subdivision raffine les faces en 4.
/// Normales lisses = direction de la position ; UVs sphériques (une couture est inévitable sans UV
/// atlas).
pub fn icosphere(radius: f32, subdivisions: u32) -> Geometry {
let t = (1.0 + 5.0_f32.sqrt()) * 0.5;
// 12 sommets unitaires (icosaèdre canonique).
let mut positions: Vec<Vec3> = [
[-1.0, t, 0.0],
[1.0, t, 0.0],
[-1.0, -t, 0.0],
[1.0, -t, 0.0],
[0.0, -1.0, t],
[0.0, 1.0, t],
[0.0, -1.0, -t],
[0.0, 1.0, -t],
[t, 0.0, -1.0],
[t, 0.0, 1.0],
[-t, 0.0, -1.0],
[-t, 0.0, 1.0],
]
.iter()
.map(|v| Vec3::from_array(*v).normalize())
.collect();
let mut faces: Vec<[u32; 3]> = [
[0, 11, 5],
[0, 5, 1],
[0, 1, 7],
[0, 7, 10],
[0, 10, 11],
[1, 5, 9],
[5, 11, 4],
[11, 10, 2],
[10, 7, 6],
[7, 1, 8],
[3, 9, 4],
[3, 4, 2],
[3, 2, 6],
[3, 6, 8],
[3, 8, 9],
[4, 9, 5],
[2, 4, 11],
[6, 2, 10],
[8, 6, 7],
[9, 8, 1],
]
.into_iter()
.map(|[a, b, c]| [a, b, c])
.collect();
for _ in 0..subdivisions {
let mut midpoint = HashMap::new();
let old_faces = std::mem::take(&mut faces);
for [a, b, c] in old_faces {
let ab = subdiv_midpoint(&mut positions, &mut midpoint, a, b);
let bc = subdiv_midpoint(&mut positions, &mut midpoint, b, c);
let ca = subdiv_midpoint(&mut positions, &mut midpoint, c, a);
faces.push([a, ab, ca]);
faces.push([ab, b, bc]);
faces.push([ca, bc, c]);
faces.push([ab, bc, ca]);
}
}
// Échelle au rayon + normales (direction unitaire) + UVs sphériques.
let mut normals = Vec::with_capacity(positions.len());
let mut uvs = Vec::with_capacity(positions.len());
for p in &positions {
let dir = p.normalize();
normals.push(dir.to_array());
uvs.push(spherical_uv(dir));
}
let scaled: Vec<[f32; 3]> = positions.iter().map(|p| (*p * radius).to_array()).collect();
let mut indices = Vec::with_capacity(faces.len() * 3);
for [a, b, c] in &faces {
indices.extend_from_slice(&[*a as u16, *b as u16, *c as u16]);
}
Geometry::new(scaled)
.with_normals(normals)
.with_uvs(uvs)
.with_indices(indices)
}
/// Crée (ou retrouve) le point milieu normalisé entre `a` et `b`, poussé sur la sphère unitaire.
fn subdiv_midpoint(
positions: &mut Vec<Vec3>,
cache: &mut HashMap<(u32, u32), u32>,
a: u32,
b: u32,
) -> u32 {
let key = if a < b { (a, b) } else { (b, a) };
if let Some(&i) = cache.get(&key) {
return i;
}
let mid = (positions[a as usize] + positions[b as usize]).normalize();
positions.push(mid);
let i = (positions.len() - 1) as u32;
cache.insert(key, i);
i
}
/// UV sphérique à partir d'une direction unitaire, dans [0,1]².
fn spherical_uv(dir: Vec3) -> [f32; 2] {
let u = 0.5 + (dir.z.atan2(dir.x) / (2.0 * std::f32::consts::PI));
let v = 0.5 - (dir.y.asin() / std::f32::consts::PI);
[u, v]
}
/// Génére un cylindre de rayon `radius` et hauteur `height` (le long de Y, centré), avec `sectors`
/// segments. Parties : flanc (normales radiales lisses), couvercle supérieur (+Y), base inférieure
/// (-Y). UVs sur le flanc étirées [0,1]², anneaux concentriques fusionnés sur les caps.
pub fn cylinder(radius: f32, height: f32, sectors: u32) -> Geometry {
let si = sectors.max(3);
let h = height * 0.5;
let (mut positions, mut normals, mut uvs, mut indices) = (
Vec::<[f32; 3]>::new(),
Vec::<[f32; 3]>::new(),
Vec::<[f32; 2]>::new(),
Vec::<u16>::new(),
);
// Flanc : colonnes radiales × 2 rangs (bas/haut).
let side_base = 0u16;
for row in 0..=1 {
let y = if row == 0 { -h } else { h };
for s in 0..=si {
let u = s as f32 / si as f32;
let theta = u * 2.0 * std::f32::consts::PI;
let (sin_t, cos_t) = theta.sin_cos();
let radial = Vec3::new(cos_t, 0.0, sin_t);
positions.push((radial * radius + Vec3::new(0.0, y, 0.0)).to_array());
normals.push(radial.to_array());
uvs.push([u, row as f32]);
}
}
for s in 0..si {
let a = side_base + s as u16;
let b = a + 1;
let c = side_base + (si as u16) + 1 + s as u16;
let d = c + 1;
indices.extend_from_slice(&[a, c, b, b, c, d]);
}
// Caps : centre + anneau à chaque extrémité.
for (y, normal) in [(h, [0.0, 1.0, 0.0]), (-h, [0.0, -1.0, 0.0])] {
let center = positions.len() as u16;
positions.push([0.0, y, 0.0]);
normals.push(normal);
uvs.push([0.5, 0.5]);
let ring_start = positions.len() as u16;
for s in 0..=si {
let u = s as f32 / si as f32;
let theta = u * 2.0 * std::f32::consts::PI;
let (sin_t, cos_t) = theta.sin_cos();
positions.push([radius * cos_t, y, radius * sin_t]);
normals.push(normal);
uvs.push([0.5 + 0.5 * cos_t, 0.5 + 0.5 * sin_t]);
}
for s in 0..si {
let a = ring_start + s as u16;
indices.extend_from_slice(&[center, a + 1, a]);
}
}
Geometry::new(positions)
.with_normals(normals)
.with_uvs(uvs)
.with_indices(indices)
}
/// Génére un cône de rayon `radius` et hauteur `height` (sommet en +h/2, base en -h/2), fermé par une
/// base, avec `sectors` segments. Normales latérales analytiques (inclinées vers l'extérieur) ;
/// normale de la base −Y.
pub fn cone(radius: f32, height: f32, sectors: u32) -> Geometry {
let si = sectors.max(3);
let h = height * 0.5;
let (mut positions, mut normals, mut uvs, mut indices) = (
Vec::<[f32; 3]>::new(),
Vec::<[f32; 3]>::new(),
Vec::<[f32; 2]>::new(),
Vec::<u16>::new(),
);
// Éléments latéraux : sommet + anneau de base.
let apex = 0u16;
positions.push([0.0, h, 0.0]);
normals.push([0.0, 1.0, 0.0]); // sommet partagé ; normal proche +Y par défaut
uvs.push([0.5, 1.0]);
let base_start = 1u16;
for s in 0..=si {
let u = s as f32 / si as f32;
let theta = u * 2.0 * std::f32::consts::PI;
let (sin_t, cos_t) = theta.sin_cos();
positions.push([radius * cos_t, -h, radius * sin_t]);
// Normale latérale : normalize(h·cosθ, r, h·sinθ).
let n = Vec3::new(h * cos_t, radius, h * sin_t).normalize();
normals.push(n.to_array());
uvs.push([u, 0.0]);
}
for s in 0..si {
indices.extend_from_slice(&[apex, base_start + s as u16 + 1, base_start + s as u16]);
}
// Base fermée (cercle en -h/2, normale -Y).
let center = positions.len() as u16;
positions.push([0.0, -h, 0.0]);
normals.push([0.0, -1.0, 0.0]);
uvs.push([0.5, 0.5]);
let ring = positions.len() as u16;
for s in 0..=si {
let u = s as f32 / si as f32;
let theta = u * 2.0 * std::f32::consts::PI;
let (sin_t, cos_t) = theta.sin_cos();
positions.push([radius * cos_t, -h, radius * sin_t]);
normals.push([0.0, -1.0, 0.0]);
uvs.push([0.5 + 0.5 * cos_t, 0.5 + 0.5 * sin_t]);
}
for s in 0..si {
let r = ring + s as u16;
indices.extend_from_slice(&[center, r, r + 1]);
}
Geometry::new(positions)
.with_normals(normals)
.with_uvs(uvs)
.with_indices(indices)
}
/// Génére un tore (anneau) de rayon majeur `major` (centre du tube) et rayon mineur `minor`
/// (rayon du tube), subdivisé en `major_segments` × `minor_segments`. Normales lisses (direction du
/// tube) ; UVs [0,1]² (couture le long du méridien et de l'équateur du tube).
pub fn torus(major: f32, minor: f32, major_segments: u32, minor_segments: u32) -> Geometry {
let mj = major_segments.max(3);
let mn = minor_segments.max(3);
let (mut positions, mut normals, mut uvs, mut indices) = (
Vec::<[f32; 3]>::new(),
Vec::<[f32; 3]>::new(),
Vec::<[f32; 2]>::new(),
Vec::<u16>::new(),
);
for i in 0..=mj {
let u = i as f32 / mj as f32;
let ua = u * 2.0 * std::f32::consts::PI;
let (sin_u, cos_u) = ua.sin_cos();
for j in 0..=mn {
let v = j as f32 / mn as f32;
let va = v * 2.0 * std::f32::consts::PI;
let (sin_v, cos_v) = va.sin_cos();
let ring = Vec3::new(
(major + minor * cos_v) * cos_u,
minor * sin_v,
(major + minor * cos_v) * sin_u,
);
positions.push(ring.to_array());
let n = Vec3::new(cos_v * cos_u, sin_v, cos_v * sin_u).normalize();
normals.push(n.to_array());
uvs.push([u, v]);
}
}
for i in 0..mj {
for j in 0..mn {
let a = i * (mn + 1) + j;
let b = a + 1;
let c = a + mn + 1;
let d = c + 1;
indices
.extend_from_slice(&[a as u16, c as u16, b as u16, b as u16, c as u16, d as u16]);
}
}
Geometry::new(positions)
.with_normals(normals)
.with_uvs(uvs)
.with_indices(indices)
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_valid(geo: &Geometry) {
geo.validate().expect("generated geometry must validate");
let positions = &geo.positions;
let normals = geo.normals.as_ref().expect("normals present");
let uvs = geo.uvs.as_ref().expect("uvs present");
let indices = geo.indices.as_ref().expect("indices present");
assert_eq!(normals.len(), positions.len(), "normals/positions count");
assert_eq!(uvs.len(), positions.len(), "uvs/positions count");
for n in normals {
let len = Vec3::from_array(*n).length();
assert!((len - 1.0).abs() < 1e-3, "unit normal, got {len}");
}
for &i in indices {
assert!((i as usize) < positions.len(), "index {i} in bounds");
}
}
#[test]
fn cube_counts() {
let g = cube(1.0);
assert_eq!(g.positions.len(), 24);
assert_eq!(g.indices.as_ref().unwrap().len(), 36);
assert_valid(&g);
let g2 = cube(2.0);
assert_eq!(
g2.positions,
g.positions
.iter()
.map(|p| [p[0] * 2.0, p[1] * 2.0, p[2] * 2.0])
.collect::<Vec<_>>()
);
}
#[test]
fn plane_counts() {
let g = plane(2.0, 3.0, 1, 1);
assert_eq!(g.positions.len(), 4);
assert_eq!(g.indices.as_ref().unwrap().len(), 6);
assert_valid(&g);
assert!(g.positions.iter().all(|p| p[1] == 0.0));
let g2 = plane(2.0, 3.0, 4, 5);
assert_eq!(g2.positions.len(), (4 + 1) * (5 + 1));
assert_valid(&g2);
}
#[test]
fn uv_sphere_counts_and_normals() {
let g = uv_sphere(1.0, 12, 8);
assert_eq!(g.positions.len(), (12 + 1) * (8 + 1));
assert_valid(&g);
// Normales pointent vers l'extérieur (position/rayon).
for (p, n) in g.positions.iter().zip(g.normals.as_ref().unwrap()) {
let diff = (Vec3::from_array(*p) / 1.0 - Vec3::from_array(*n)).length();
assert!(diff < 1e-4, "normal ~ position/radius, got diff {diff}");
}
}
#[test]
fn icosphere_grows_with_subdivision() {
let base = icosphere(1.0, 0);
assert_eq!(base.positions.len(), 12);
assert_eq!(base.indices.as_ref().unwrap().len(), 60);
assert_valid(&base);
let once = icosphere(1.0, 1);
assert!(once.positions.len() > base.positions.len());
assert_valid(&once);
for (p, n) in once.positions.iter().zip(once.normals.as_ref().unwrap()) {
let r = Vec3::from_array(*p).length();
assert!((r - 1.0).abs() < 1e-3, "on sphere radius, got {r}");
let diff = (Vec3::from_array(*p).normalize() - Vec3::from_array(*n)).length();
assert!(diff < 1e-4, "normal ~ direction, got {diff}");
}
}
#[test]
fn cylinder_and_cone_validate() {
assert_valid(&cylinder(0.5, 1.0, 16));
assert_valid(&cone(0.5, 1.0, 16));
let c = cylinder(0.5, 1.0, 8);
assert!(c.positions.iter().all(|p| p[1].abs() <= 0.5 + 1e-5));
}
#[test]
fn torus_validate() {
let g = torus(1.0, 0.25, 24, 12);
assert_valid(&g);
assert_eq!(g.positions.len(), (24 + 1) * (12 + 1));
}
}