diff --git a/docs/DRAFT.md b/docs/DRAFT.md index 4aba5d3..e31502f 100644 --- a/docs/DRAFT.md +++ b/docs/DRAFT.md @@ -118,22 +118,29 @@ ou **à chaud** (toggle de debug au runtime). Aucun changement de signature exis L'utilisateur déclare sa géométrie comme aujourd'hui (`positions`/`indices`/`normals`/`uvs`, y compris générée procéduralement par lui) et la bibliothèque **calcule les niveaux LOD sous le -capot**. Mécanisme : décimation gloutonne du maillage indexé — +capot**. Mécanisme : **quadric edge collapse** (Garland–Heckbert) sur le maillage indexé — ``` Geometry::decimated(&self, target_triangles: u32) -> Geometry (pure, sans GPU) ``` -- Triangles (non dégénérés) triés par aire croissante ; on retire le plus petit jusqu'à la - cible — **sans carte de topologie** (« Règle A ») : tout sous-ensemble de faces d'un mesh valide - est un mesh valide (pas de trou à boucher, pas de flip). La cible est donc toujours atteignable - (clamp `[1, T]`). -- Sortie : re-indexation (weld par position exacte), **normales lisses recalculées** sur les faces +- Weld préalable (tolérance relative 1e-6 — grille + 27 voisins : les seams trigonométriques + diffèrent d'environ 1e-16, l'égalité exacte ne suffit pas), triangles dégénérés jetés, puis + **quadric edge collapse** : file de priorité des arêtes classées par coût (erreur quadrique de + l'arête + longueur d'arête) ; on replie la plus bon marché jusqu'à la cible. Un repli **interne** + fusionne les 2 triangles incidents (ils dégénèrent — −2 faces) et re-mappe les voisins : + **pas de nouvelle face** — caractéristique d'Euler et **clôture préservées** (un mesh fermé + reste fermé : pas de trous, pas de « books ») ; un repli de **bordure** retire 1 face. + Garde-fous : arête non-manifold (≥ 3 faces) ou face en double (pli) → repli rejeté. La cible + est clampée à `[1, T]` et atteinte au mieux (best effort : la granularité −2/−1 peut s'en + écarter d'un ou deux triangles — jamais de géométrie corrompue). +- Sortie : re-indexation (le weld ci-dessus), **normales lisses recalculées** sur les faces survivantes (normales de faces accumulées par coin soudé, puis normalisées), UVs/couleurs = valeur du premier vertex de chaque groupe soudé (documenté : évite le bleeding entre seams UV ; le LOD sacrifie la précision UV au profit de la silhouette). - Non indexé à l'entrée → weld par position préalable (la sortie est **toujours indexée**). Triangles dégénérés jetés. -- **Déterministe** (tri stable, tie-break par index d'origine) → counts reproductibles en tests. +- **Déterministe** (file de priorité, tie-break par identifiants de vertex, aucun aléatoire) → + counts reproductibles en tests. - Fallback : la cible est clampée à `[1, T]` — un mesh n'est jamais décimé sous un triangle, et le count **réel** va dans le tableau LOD (`validate()` OK, jamais de géométrie corrompue). @@ -251,9 +258,9 @@ pub fn lod_level(radius_px: f32, last: u32, max_level: u32, thresholds: &[f32]) /// `depth <= eps` (objet dans la caméra) → f32::INFINITY (niveau 0). pub fn projected_radius_px(center_world: Vec3, radius: f32, view: Mat4, proj: Mat4, height_px: f32) -> f32; -/// Décimation gloutonne (D10, Règle A) : renvoie un `Geometry` indexé avec `target_triangles` -/// triangles (clamp `[1, T]`) — les plus petits partent en premier (faible impact visuel), -/// normales lisses recalculées. Déterministe. +/// Quadric edge collapse (D10, Garland–Heckbert) : renvoie un `Geometry` indexé avec +/// ~`target_triangles` triangles (best effort ±1–2, clamp `[1, T]`) — les arêtes au coût +/// quadrique le plus faible partent en premier, normales lisses recalculées. Déterministe. pub fn decimated(&self, target_triangles: u32) -> Geometry; // sur Geometry ``` diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d5a8ae2..1ec8589 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -162,7 +162,7 @@ generated: { by: human:jerome, at: 2026-07-31T00:00:00Z } ### 4.3 Optimisations - [x] Batching par Material (réduction des state changes GPU) — 2026-09-22 (Étape 18 : draws groupés par `Arc` dans la passe principale, 1 `set_pipeline` par matériau distinct — le démo passe de 7 à 3 ; pass d'ombre inchangé) -- [x] Level of Detail (LOD) — 2026-09-23 (Étape 19 : ≤ 4 niveaux/mesh — L0 exacte, L1–L3 par décimation gloutonne au setup (`Geometry::decimated`/`generate_lod_levels`, Règle A : retrait des plus petits triangles, weld par position exacte, rebase u16), packés dans les buffers vertex/index du mesh (offsets en unités d'élément, plafond 65 535 sommets) ; décision par frame **côté CPU** (sphère bounding projetée en pixels + hystérésis asymétrique ×0.8 — `math/lod.rs` pure, unit-testée), exécution **côté GPU** (le pass `cull` mappe niveau → ligne de la table LOD → args indirects) ; **activé par défaut**, `set_lod_enabled(false)` → rendu bit-à-bit identique au pré-LOD. Vérifié par readback GPU : zoom 4,6× → tous les meshes multi-niveaux passent au niveau 1 avec exactement leurs lignes L1 (ex. sphère 3840 → 1824 indices), stable frame à frame) +- [x] Level of Detail (LOD) — 2026-09-23 (Étape 19 : ≤ 4 niveaux/mesh — L0 exacte, L1–L3 par **quadric edge collapse** (Garland–Heckbert) au setup (`Geometry::decimated`/`generate_lod_levels` : arêtes classées par coût quadrique, repli interne −2 faces / bordure −1, un mesh fermé reste fermé, weld tolérance 1e-6, rebase u16), packés dans les buffers vertex/index du mesh (offsets en unités d'élément, plafond 65 535 sommets) ; décision par frame **côté CPU** (sphère bounding projetée en pixels + hystérésis asymétrique ×0.8 — `math/lod.rs` pure, unit-testée), exécution **côté GPU** (le pass `cull` mappe niveau → ligne de la table LOD → args indirects) ; **activé par défaut**, `set_lod_enabled(false)` → rendu bit-à-bit identique au pré-LOD. Vérifié par readback GPU : zoom 4,6× → tous les meshes multi-niveaux passent au niveau 1 avec exactement leurs lignes L1 (ex. sphère 3840 → 1824 indices), stable frame à frame) - [ ] HDR + Tone Mapping (optionnel) ### 4.4 Gestion du Resize (cycle de vie Surface + Depth) diff --git a/docs/tech/ARCHI_CPU_GPU.md b/docs/tech/ARCHI_CPU_GPU.md index 481b02b..01c3708 100644 --- a/docs/tech/ARCHI_CPU_GPU.md +++ b/docs/tech/ARCHI_CPU_GPU.md @@ -41,8 +41,9 @@ Ce document sert de spécification technique et de trame d'implémentation pour > du **niveau de détail** du slot, et non d'un seul jeu de comptes. Le choix du niveau est fait **côté CPU** > (rayon de la sphère bounding projeté en pixels + hystérésis asymétrique — `math/lod.rs`, pur et unit-testé) ; > le GPU n'effectue que le mappage niveau → ligne de la table LOD du mesh. Les niveaux d'un mesh sont -> générés par décimation gloutonne au setup (`Geometry::decimated` : suppression des plus petits triangles, -> soudure par position exacte, rebase u16) et **empilés dans les buffers vertex/index du mesh** (offsets en +> générés par **quadric edge collapse** (Garland–Heckbert) au setup (`Geometry::decimated` : les +> arêtes au coût quadrique minimal sont repliées en premier, un mesh fermé reste fermé — pas de +> trous, pas de « books » ; soudure tolérance 1e-6, rebase u16) et **empilés dans les buffers vertex/index du mesh** (offsets en > unités d'élément, pas d'octet — c'est ce qu'exigent les arguments `drawIndirect*` de WebGPU ; plafond u16 : > 65 535 sommets/mesh, 4 niveaux max). LOD activé par défaut ; `set_lod_enabled(false)` restaure un rendu > bit-à-bit identique au pré-LOD (niveau 0 partout = comptes complets). Détail : `docs/user/gpu-driven.md` diff --git a/docs/user/gpu-driven.md b/docs/user/gpu-driven.md index 4a9b884..5498c0b 100644 --- a/docs/user/gpu-driven.md +++ b/docs/user/gpu-driven.md @@ -93,9 +93,12 @@ LOD is a **CPU-decided, GPU-executed** split (the one deliberate per-entity deci CPU): 1. **Setup (once per mesh).** Each mesh can carry up to 4 levels. Levels 1..3 are generated - automatically from level 0 by greedy decimation (`Geometry::generate_lod_levels`): the smallest - triangles are removed first (no edge map, no crease handling — a subset of the faces of a valid - mesh is valid), duplicate corners are welded, and the levels are **packed into the mesh's single + automatically from level 0 by **quadric edge collapse** (Garland–Heckbert, + `Geometry::generate_lod_levels`): edges are ranked by quadric error and collapsed + cheapest-first; an interior collapse merges both incident triangles (−2 faces) and remaps the + neighbours — no new face, so a **closed mesh stays closed** (no holes, no non-manifold + "books"), a boundary collapse removes one face; duplicate corners are welded (relative + tolerance 1e-6), and the levels are **packed into the mesh's single vertex/index buffers** (see the constraint below). Level 0 is always your exact geometry. 2. **Per frame (CPU).** For each entity, the bounding sphere used by culling is projected to screen pixels (its *perceived size*); that radius picks a level with **asymmetric hysteresis** — going diff --git a/lib/examples/demo.rs b/lib/examples/demo.rs index 8feffc9..861a8e6 100644 --- a/lib/examples/demo.rs +++ b/lib/examples/demo.rs @@ -124,7 +124,7 @@ impl AppHandler for Demo { // 4. One mesh per primitive, each assigned to a textured (or stripe) material. // The cube + ground stay single-level (tiny meshes — LOD would buy nothing); the // rounded primitives get three LOD levels each (Step 19): level 0 is the full mesh, - // levels 1.. are auto-generated by greedy decimation at halving targets (D10), all + // levels 1.. are auto-generated by quadric edge collapse at halving targets (D10), all // packed into the mesh's single vertex/index buffers (D7). Zooming with the wheel // switches levels on the fly (asymmetric hysteresis, D4). app.scene diff --git a/lib/src/README.md b/lib/src/README.md index 231a903..08f9097 100644 --- a/lib/src/README.md +++ b/lib/src/README.md @@ -11,7 +11,7 @@ This is the source tree for `wsg-lib`, a Rust library wrapping [wgpu](https://gi | **pipeline** | PipelineCache — WGSL shader loading and RenderPipeline compilation cache | | **shaders** | Embedded WGSL sources (`standard`, `shadow`, `gpu_driven`) loaded via the `include_str!` fallback in `utils::conf` | | **scene** | Scene — resource depot and slot-based entity graph for declarative rendering setup (Step 17) | -| **math** | Transform, Geometry (per-attribute mesh data + AABB, greedy decimation `decimated`/`generate_lod_levels`), `Frustum` (Gribb–Hartmann, WebGPU `[0,1]` z), `lod` (per-frame level selection: `projected_radius_px` + `lod_level` with asymmetric hysteresis) and `primitives` (procedural mesh generators) | +| **math** | Transform, Geometry (per-attribute mesh data + AABB, quadric edge collapse `decimated`/`generate_lod_levels`), `Frustum` (Gribb–Hartmann, WebGPU `[0,1]` z), `lod` (per-frame level selection: `projected_radius_px` + `lod_level` with asymmetric hysteresis) and `primitives` (procedural mesh generators) | | **utils** | Configuration constants and WsgError type | | **app** | App facade — high-level application orchestration with window lifecycle, event loop, and render automation | | **handler** | AppHandler trait — user-defined game logic interface injected into the render loop | diff --git a/lib/src/math/geometry.rs b/lib/src/math/geometry.rs index a11ba99..95d1742 100644 --- a/lib/src/math/geometry.rs +++ b/lib/src/math/geometry.rs @@ -21,6 +21,7 @@ //! uploading to the GPU. use crate::resources::Vertex; +use glam::{Mat4, Vec3, Vec4}; /// An axis-aligned bounding box in object (local) space: the min/max corners of a geometry's /// positions. Used for conservative sphere culling (Phase 3): the culling radius is the box's @@ -377,26 +378,116 @@ impl Geometry { (cx * cx + cy * cy + cz * cz).sqrt() * 0.5 } - /// Greedy decimation (Step 19, D10 — pure function, no GPU): returns an **indexed** - /// `Geometry` with ≈ `target_triangles` triangles, preserving the silhouette. + /// Welds the given triangles into a single vertex table (positions + optional + /// per-vertex attributes) and u32 face indices — the form [`Collapse`] operates on. + /// Welding uses a small **relative** tolerance (1e-6 of the bounding box, at least + /// 1e-6 world units): positions that agree within it are the same vertex. This + /// absorbs the near-duplicates produced by trig generation (`sin(2π) ≠ 0` — the seam + /// of a torus or UV sphere) so they weld cleanly, while never merging distinct + /// vertices of a well-formed mesh. A welded group's UV/color is the value of the + /// **first vertex encountered** (deterministic; the first triangle's seam UV wins). + fn welded( + &self, + tris: &[[u32; 3]], + ) -> ( + Vec<[f32; 3]>, + Option>, + Option>, + Vec<[u32; 3]>, + ) { + // Relative tolerance from the bounding box (at least 1e-6 world units). + let (mut mn, mut mx) = ([f32::INFINITY; 3], [f32::NEG_INFINITY; 3]); + for p in &self.positions { + for k in 0..3 { + mn[k] = mn[k].min(p[k]); + mx[k] = mx[k].max(p[k]); + } + } + let max_dim = (0..3).map(|k| (mx[k] - mn[k]).abs()).fold(0.0f32, f32::max); + let eps = (max_dim.max(1.0)) * 1e-6; + let inv = 1.0 / eps; + let cell = |p: &[f32; 3]| [0, 1, 2].map(|k| (p[k] * inv).round() as i64); + + let mut positions: Vec<[f32; 3]> = Vec::with_capacity(self.positions.len()); + let mut uvs: Option> = self.uvs.is_some().then(Vec::new); + let mut colors: Option> = self.colors.is_some().then(Vec::new); + // Grid cell → welded id (broad phase; candidates are verified by distance). + let mut grid: std::collections::HashMap<[i64; 3], u32> = std::collections::HashMap::new(); + let mut new_indices: Vec = Vec::with_capacity(tris.len() * 3); + for tri in tris { + for &vi in tri.iter() { + let pos = &self.positions[vi as usize]; + let c = cell(pos); + // Search this cell and its 26 neighbours: rounding can straddle a cell + // boundary, so an in-tolerance twin may sit in an adjacent cell. + let mut ni: Option = None; + 'search: for dx in -1i64..=1 { + for dy in -1i64..=1 { + for dz in -1i64..=1 { + let key = [c[0] + dx, c[1] + dy, c[2] + dz]; + if let Some(&candidate) = grid.get(&key) { + let cp = &positions[candidate as usize]; + let d2 = (0..3) + .map(|k| (pos[k] - cp[k]) * (pos[k] - cp[k])) + .sum::(); + if d2 <= 3.0 * eps * eps { + ni = Some(candidate); + break 'search; + } + } + } + } + } + let ni = match ni { + Some(ni) => ni, + None => { + let ni = positions.len() as u32; + positions.push(*pos); + if let Some(uvs) = &mut uvs { + uvs.push(self.uvs.as_ref().unwrap()[vi as usize]); + } + if let Some(colors) = &mut colors { + colors.push(self.colors.as_ref().unwrap()[vi as usize]); + } + grid.entry(c).or_insert(ni); + ni + } + }; + new_indices.push(ni); + } + } + let faces: Vec<[u32; 3]> = new_indices + .chunks_exact(3) + .map(|c| [c[0], c[1], c[2]]) + .collect(); + (positions, uvs, colors, faces) + } + + /// Quadric edge-collapse decimation (Step 19, D10 — pure function, no GPU): returns an + /// **indexed** `Geometry` with ≈ `target_triangles` triangles, preserving the silhouette + /// **and the mesh's closedness**. /// - /// Algorithm: degenerate (zero-area) triangles are dropped, then the triangles are - /// sorted by **ascending area** (stable, tie-broken by original index — deterministic) - /// and the `T - target` smallest are removed. Any subset of a mesh's faces is a valid - /// (possibly open) mesh — a removed triangle just leaves boundary edges, so no topology - /// repair is needed (full manifold preservation would require edge collapse — out of - /// scope, see DRAFT Step 19 "Out of scope"). The kept triangles are rebuilt in original - /// triangle order: - /// - vertices are **welded by exact position equality** (dedup; non-indexed input is - /// welded first), and the output is always indexed; - /// - smooth normals are **recomputed** over the kept faces (only when the source had - /// normals); - /// - UVs / colors take the value of the **first vertex of each welded group** encountered - /// during the rebuild (documented trade-off: LOD sacrifices UV precision for the - /// silhouette). + /// Algorithm (Garland–Heckbert): the input is first welded with a small relative + /// tolerance — 1e-6 of the bounding box, which absorbs trig-generated seam + /// near-duplicates (non-indexed input is welded first; degenerate triangles are + /// dropped) — then the + /// cheapest edges are collapsed until the target is reached. Each collapse merges two + /// vertices into one (the survivor moves to the optimal point on the edge) and folds the + /// edge's incident faces. A collapse preserves the Euler characteristic, so a **closed** + /// mesh stays closed — no holes — and an open mesh keeps its boundary: this is the + /// topological guarantee that simple triangle removal can never give. Collapses that + /// would create a non-manifold ("book") or a duplicate face are skipped; faces that + /// would degenerate are dropped. Smooth normals are **recomputed** over the surviving + /// faces (only when the source had normals); a welded group's UV/color is the value of + /// the **first vertex encountered** (documented trade-off: LOD sacrifices UV precision + /// at seams for the silhouette). /// - /// Fallback (never corrupts): target ≥ triangle count, target = 0, or malformed input - /// (empty, or counts not multiples of 3) → `self.clone()`. + /// Fallback (never corrupts): target ≥ triangle count, target = 0, malformed input, or a + /// result that would not fit u16 indices (≥ 65536 vertices) → `self.clone()`. Best + /// effort: a collapse removes 2 faces on an interior edge (both degenerate together) + /// and 1 on a boundary edge, so the target is reached exactly only when the topology + /// allows it — otherwise the result is off by a triangle or two (never more: the loop + /// stops as soon as it goes at or under the target). pub fn decimated(&self, target_triangles: u32) -> Geometry { let Some(tris) = self.non_degenerate_triangles() else { return self.clone(); @@ -406,85 +497,106 @@ impl Geometry { return self.clone(); } - // Ascending area (stable; tie-broken by original index) → drop the smallest first. - let mut order: Vec<(f32, u32)> = tris - .iter() - .enumerate() - .map(|(i, tri)| (Self::triangle_area(&self.positions, tri), i as u32)) - .collect(); - order.sort_by(|a, b| { - a.0.partial_cmp(&b.0) - .unwrap_or(std::cmp::Ordering::Equal) - .then(a.1.cmp(&b.1)) - }); - - let needed = t - target_triangles; - let mut removed = vec![false; tris.len()]; - for i in 0..needed as usize { - removed[order[i].1 as usize] = true; + // Welded form (one vertex table + u32 faces) — what edge collapse operates on. + let (wpos, wuvs, wcolors, wfaces) = self.welded(&tris); + if wfaces.is_empty() { + return self.clone(); } - let kept: Vec<[u32; 3]> = tris - .into_iter() - .enumerate() - .filter(|(i, _)| !removed[*i]) - .map(|(_, tri)| tri) - .collect(); - // Rebuild: weld by exact position, remap indices in original triangle order. - let mut new_positions: Vec<[f32; 3]> = Vec::with_capacity(self.positions.len()); - let mut new_uvs: Option> = self.uvs.is_some().then(Vec::new); - let mut new_colors: Option> = self.colors.is_some().then(Vec::new); - // Key = exact float bits (f32 is not Hash/Eq; `to_bits` preserves exact equality). - let mut map: std::collections::HashMap<(u32, u32, u32), u32> = - std::collections::HashMap::new(); - let mut new_indices: Vec = Vec::with_capacity(kept.len() * 3); - - for tri in &kept { - for (_k, &vi) in tri.iter().enumerate() { - let pos = self.positions[vi as usize]; - let key = (pos[0].to_bits(), pos[1].to_bits(), pos[2].to_bits()); - let ni = if let Some(&ni) = map.get(&key) { - ni - } else { - let ni = new_positions.len() as u32; - new_positions.push(pos); - if let Some(uvs) = &mut new_uvs { - uvs.push(self.uvs.as_ref().unwrap()[vi as usize]); - } - if let Some(colors) = &mut new_colors { - colors.push(self.colors.as_ref().unwrap()[vi as usize]); - } - map.insert(key, ni); - ni - }; - new_indices.push(ni as u16); + // Collapse down to `target_triangles` faces (or as close as the topology allows). + let mut c = Collapse::new(wpos, wfaces); + let mut pq = c.rebuild_pq(); + let mut rebuilds = 0u32; + while c.face_count > target_triangles { + let Some(EdgeCost(cost, a, b)) = pq.pop() else { + // Heap exhausted (fresh edges are not in it yet) → full rebuild. + let rebuilt = c.rebuild_pq(); + if rebuilt.is_empty() { + break; // best effort: nothing left that can collapse + } + rebuilds += 1; + if rebuilds > 4 * t { + // Safety valve (pathological mesh: every remaining edge is rejected by + // the book/duplicate tests) → stop at best effort, never spin. + break; + } + pq = rebuilt; + continue; + }; + // Stale entry (endpoint merged, or edge face count changed) → skip. + if !c.active[a as usize] || !c.active[b as usize] { + continue; + } + let live = c.edge_face_count(a, b); + if live == 0 || live > 2 { + continue; + } + // Cost drifted (quadrics/positions changed since the push) → refresh, retry. + let (true_cost, _) = c.cost_and_point(a, b); + if (true_cost - cost).abs() > 1e-9 + 1e-6 * true_cost.abs() { + pq.push(EdgeCost(true_cost, a, b)); + continue; + } + if !c.collapse_edge(a, b, &mut pq) { + continue; // invalid (book/duplicate) → skipped; re-enters at a rebuild } } - // Recompute smooth normals over the kept faces (only when the source had normals). + // Compact survivors: a vertex is kept only if it is active AND referenced by at + // least one surviving face (isolated vertices are pruned). + let n = c.pos.len(); + let mut referenced = vec![false; n]; + for f in c.faces.iter().flatten() { + for &v in f.iter() { + referenced[v as usize] = true; + } + } + let mut new_id = vec![u32::MAX; n]; + let mut new_positions: Vec<[f32; 3]> = Vec::with_capacity(n); + let mut new_uvs = wuvs.is_some().then(Vec::new); + let mut new_colors = wcolors.is_some().then(Vec::new); + for i in 0..n { + if c.active[i] && referenced[i] { + new_id[i] = new_positions.len() as u32; + new_positions.push(c.pos[i]); + if let Some(uvs) = &mut new_uvs { + uvs.push(wuvs.as_ref().unwrap()[i]); + } + if let Some(colors) = &mut new_colors { + colors.push(wcolors.as_ref().unwrap()[i]); + } + } + } + if new_positions.is_empty() || new_positions.len() >= 65_536 { + // Nothing to show, or the result would not fit u16 indices → never corrupt. + return self.clone(); + } + let mut new_indices = Vec::with_capacity(c.face_count as usize * 3); + for f in c.faces.iter().flatten() { + for &v in f.iter() { + new_indices.push(new_id[v as usize] as u16); + } + } + + // Recompute smooth normals over the surviving faces (only when the source had + // normals). let normals = self.normals.as_ref().map(|_| { let mut acc = vec![[0.0f32; 3]; new_positions.len()]; - // Accumulate with the REMAPPED (post-weld) indices: `kept` holds original indices, - // which may not exist in `new_positions` once welding has merged duplicates. - for i in 0..kept.len() { - let (ia, ib, ic) = ( - new_indices[i * 3] as usize, - new_indices[i * 3 + 1] as usize, - new_indices[i * 3 + 2] as usize, - ); - let a = new_positions[ia]; - let b = new_positions[ib]; - let c = new_positions[ic]; - let n = [ - (b[1] - a[1]) * (c[2] - a[2]) - (b[2] - a[2]) * (c[1] - a[1]), - (b[2] - a[2]) * (c[0] - a[0]) - (b[0] - a[0]) * (c[2] - a[2]), - (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]), + for tri in new_indices.chunks_exact(3) { + let (ia, ib, ic) = (tri[0] as usize, tri[1] as usize, tri[2] as usize); + let pa = new_positions[ia]; + let pb = new_positions[ib]; + let pc = new_positions[ic]; + let nrm = [ + (pb[1] - pa[1]) * (pc[2] - pa[2]) - (pb[2] - pa[2]) * (pc[1] - pa[1]), + (pb[2] - pa[2]) * (pc[0] - pa[0]) - (pb[0] - pa[0]) * (pc[2] - pa[2]), + (pb[0] - pa[0]) * (pc[1] - pa[1]) - (pb[1] - pa[1]) * (pc[0] - pa[0]), ]; for vi in [ia, ib, ic] { let v = &mut acc[vi]; - v[0] += n[0]; - v[1] += n[1]; - v[2] += n[2]; + v[0] += nrm[0]; + v[1] += nrm[1]; + v[2] += nrm[2]; } } acc.iter() @@ -532,6 +644,413 @@ impl Geometry { } } +/// Quadric edge-collapse state (private helper for [`Geometry::decimated`]). +/// +/// Each collapse merges the endpoints of the cheapest edge: one vertex dies, the other +/// moves to the **optimal point** on the edge (argmin of the summed quadric, clamped to +/// the segment) and the edge's incident faces fold (the two triangles of an interior edge +/// merge into one new triangle; the single triangle of a boundary edge is dropped). A +/// collapse preserves the Euler characteristic — a **closed** mesh stays closed (no +/// holes), an open mesh keeps its boundary. Faces that would degenerate +/// (area < 0.01% of the original) are dropped; collapses that would create a +/// non-manifold ("book") or a duplicate face are skipped. +/// +/// The priority queue is lazy: entries may go stale (endpoint merged, cost drifted); +/// pops are re-validated against the current state and affected edges are re-pushed +/// with fresh costs after each collapse. Quadrics are maintained incrementally +/// (`Q_t += Q_s + plane(new face)`), so a full rebuild only happens when the heap +/// runs dry. +struct Collapse { + /// Mutable positions: a collapse target moves to its optimal point. + pos: Vec<[f32; 3]>, + /// Vertices not yet merged away. + active: Vec, + /// Living faces by (ever-allocated) id; `None` = collapsed away or degenerated. + faces: Vec>, + /// Per vertex: ids of the faces it belongs to (may hold stale ids — filtered lazily). + vfaces: Vec>, + /// Edge key → face ids (may hold stale ids — filtered lazily). + edge_faces: std::collections::HashMap>, + /// Per-vertex quadric (sum of incident face-plane matrices). + quad: Vec, + /// Number of living faces. + face_count: u32, +} + +/// Priority-queue entry `(cost, endpoint a, endpoint b)`. `BinaryHeap` is a max-heap, +/// so `Ord` is **inverted**: the cheapest edge is the max and pops first; on equal cost, +/// `(a, b)` breaks ties deterministically (f32 is not `Ord` — NaN compares as "equal", +/// then `(a, b)` decides; identical entries are the same edge, so the pop sequence is +/// fully determined regardless of heap layout). +#[derive(Clone, Copy)] +struct EdgeCost(f32, u32, u32); + +impl PartialEq for EdgeCost { + fn eq(&self, other: &Self) -> bool { + self.cmp(other) == std::cmp::Ordering::Equal + } +} +impl Eq for EdgeCost {} +impl PartialOrd for EdgeCost { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl Ord for EdgeCost { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + let cost = other + .0 + .partial_cmp(&self.0) + .unwrap_or(std::cmp::Ordering::Equal); + cost.then(other.1.cmp(&self.1)).then(other.2.cmp(&self.2)) + } +} + +/// Solves a 3×3 linear system by Cramer's rule; `None` when the determinant is ~0 +/// (singular). +fn solve3(m: [[f32; 3]; 3], b: [f32; 3]) -> Option<[f32; 3]> { + let det = |a: [[f32; 3]; 3]| { + a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1]) + - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0]) + + a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]) + }; + let d = det(m); + if d.abs() < 1e-12 { + return None; + } + let repl = |col: usize, v: [f32; 3]| { + let mut a = m; + a[col][0] = v[0]; + a[col][1] = v[1]; + a[col][2] = v[2]; + a + }; + Some([ + det(repl(0, b)) / d, + det(repl(1, b)) / d, + det(repl(2, b)) / d, + ]) +} + +/// Sorts a triple ascending (canonical face key for the duplicate-face test). +fn tri_sorted(mut t: [u32; 3]) -> [u32; 3] { + t.sort_unstable(); + t +} + +impl Collapse { + fn new(pos: Vec<[f32; 3]>, faces: Vec<[u32; 3]>) -> Self { + let n = pos.len(); + let mut c = Self { + pos, + active: vec![true; n], + faces: faces.iter().map(|f| Some(*f)).collect(), + vfaces: vec![Vec::new(); n], + edge_faces: std::collections::HashMap::new(), + quad: vec![Mat4::ZERO; n], + face_count: faces.len() as u32, + }; + for (i, f) in c.faces.iter().enumerate() { + let f = f.unwrap(); + let q = Self::plane_quad(&c.pos, &f); + for &v in f.iter() { + c.vfaces[v as usize].push(i as u32); + c.quad[v as usize] += q; + } + for i2 in 0..3 { + for j2 in (i2 + 1)..3 { + c.edge_faces + .entry(Self::edge_key(f[i2], f[j2])) + .or_default() + .push(i as u32); + } + } + } + c + } + + /// Canonical edge key (order-independent): both endpoints packed into one u64. + fn edge_key(a: u32, b: u32) -> u64 { + let (lo, hi) = if a < b { (a, b) } else { (b, a) }; + (lo as u64) << 32 | hi as u64 + } + + /// Number of **living** faces on edge (a, b) (1 = boundary, 2 = interior). + fn edge_face_count(&self, a: u32, b: u32) -> u32 { + self.edge_faces + .get(&Self::edge_key(a, b)) + .map(|v| { + v.iter() + .filter(|&&f| self.faces[f as usize].is_some()) + .count() as u32 + }) + .unwrap_or(0) + } + + /// 4×4 quadric of the plane through the triangle: `Q = p·pᵀ` with `p = (n, d)` + /// (`n` = unit face normal, `d = -n·a`); `xᵀQx` is the squared distance to the plane + /// for `x = (x, y, z, 1)`. Degenerate plane → zero quadric. + fn plane_quad(pos: &[[f32; 3]], t: &[u32; 3]) -> Mat4 { + let a = pos[t[0] as usize]; + let b = pos[t[1] as usize]; + let c = pos[t[2] as usize]; + let n = Vec3::new( + (b[1] - a[1]) * (c[2] - a[2]) - (b[2] - a[2]) * (c[1] - a[1]), + (b[2] - a[2]) * (c[0] - a[0]) - (b[0] - a[0]) * (c[2] - a[2]), + (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]), + ); + let n = if n.length_squared() < 1e-24 { + Vec3::Z + } else { + n.normalize() + }; + let d = -(n.x * a[0] + n.y * a[1] + n.z * a[2]); + let p = Vec4::new(n.x, n.y, n.z, d); + Mat4::from_cols( + Vec4::new(p.x * p.x, p.x * p.y, p.x * p.z, p.x * p.w), + Vec4::new(p.y * p.x, p.y * p.y, p.y * p.z, p.y * p.w), + Vec4::new(p.z * p.x, p.z * p.y, p.z * p.z, p.z * p.w), + Vec4::new(p.w * p.x, p.w * p.y, p.w * p.z, p.w * p.w), + ) + } + + /// Collapse cost of edge (a, b) and the **optimal point** (where the target moves): + /// quadric error at the optimum + edge length (the length term orders flat regions + /// deterministically, shortest edge first). The unconstrained optimum can lie far off + /// the edge on curved surfaces, so it is **clamped to the segment** — the result then + /// stays inside the input's bounding box. + fn cost_and_point(&self, a: u32, b: u32) -> (f32, [f32; 3]) { + let q = self.quad[a as usize] + self.quad[b as usize]; + let pa = self.pos[a as usize]; + let pb = self.pos[b as usize]; + let mid = [ + (pa[0] + pb[0]) * 0.5, + (pa[1] + pb[1]) * 0.5, + (pa[2] + pb[2]) * 0.5, + ]; + // Minimize xᵀQx over x = (x, y, z, 1): solve the 3×3 normal equations (top-left + // block) · x = −(bottom-left block). Singular (flat/degenerate quadric) → the + // edge midpoint. + let m = [ + [q.x_axis.x, q.x_axis.y, q.x_axis.z], + [q.y_axis.x, q.y_axis.y, q.y_axis.z], + [q.z_axis.x, q.z_axis.y, q.z_axis.z], + ]; + let rhs = [-q.w_axis.x, -q.w_axis.y, -q.w_axis.z]; + let x = solve3(m, rhs).unwrap_or(mid); + let e = [pb[0] - pa[0], pb[1] - pa[1], pb[2] - pa[2]]; + let elen2 = e[0] * e[0] + e[1] * e[1] + e[2] * e[2]; + let s = if elen2 < 1e-24 { + 0.5 + } else { + let s = ((x[0] - pa[0]) * e[0] + (x[1] - pa[1]) * e[1] + (x[2] - pa[2]) * e[2]) / elen2; + s.clamp(0.0, 1.0) + }; + let pt = [pa[0] + s * e[0], pa[1] + s * e[1], pa[2] + s * e[2]]; + let qv = Vec4::new(pt[0], pt[1], pt[2], 1.0); + let err = (q * qv).dot(qv); + (err + elen2.sqrt(), pt) + } + + /// Full priority-queue rebuild: every edge whose endpoints are both active and that + /// has 1–2 living faces, ordered by (cost, a, b) — deterministic. + fn rebuild_pq(&self) -> std::collections::BinaryHeap { + let mut pq = std::collections::BinaryHeap::new(); + for (&key, faces) in &self.edge_faces { + let live = faces + .iter() + .filter(|&&f| self.faces[f as usize].is_some()) + .count(); + if live == 0 || live > 2 { + continue; + } + let a = (key >> 32) as u32; + let b = key as u32; + if !self.active[a as usize] || !self.active[b as usize] { + continue; + } + let (cost, _) = self.cost_and_point(a, b); + pq.push(EdgeCost(cost, a, b)); + } + pq + } + + /// Merges `s` into `t` and rewrites the edge's incident faces, or returns `false` + /// when the collapse would create a non-manifold vertex or a duplicate face (the + /// "book" test). Merge direction: the endpoint with the **smaller** face degree + /// becomes the source (fewer remaps); the **larger**-degree endpoint survives (ties + /// → the larger index survives; deterministic). After a successful collapse the + /// edges whose cost changed (all of `t`'s, incl. the new face's third edge) are + /// re-pushed into `pq` with fresh costs. + fn collapse_edge( + &mut self, + a: u32, + b: u32, + pq: &mut std::collections::BinaryHeap, + ) -> bool { + let live_deg = |v: u32| { + self.vfaces[v as usize] + .iter() + .filter(|&&f| self.faces[f as usize].is_some()) + .count() + }; + let (da, db) = (live_deg(a), live_deg(b)); + let (s, t) = if da != db { + if da < db { (a, b) } else { (b, a) } + } else if a < b { + (a, b) + } else { + (b, a) + }; + + let key = Self::edge_key(s, t); + let pair: Vec = self + .edge_faces + .get(&key) + .map(|v| { + v.iter() + .copied() + .filter(|&f| self.faces[f as usize].is_some()) + .collect() + }) + .unwrap_or_default(); + if pair.is_empty() || pair.len() > 2 { + return false; + } + + // Duplicate-face test (fold): every remapped face of `s` must stay unique among + // the living faces of `t` and the other remapped faces (sorted triples). A + // duplicate is a fold — the surface would double-cover a region — and is rejected. + // The pair faces are skipped: they die in this collapse, so they are not live + // fold partners (pre-existing duplicates among `t`'s OTHER faces are still caught). + let mut seen: std::collections::HashSet<[u32; 3]> = std::collections::HashSet::new(); + for &f in &self.vfaces[t as usize] { + if pair.contains(&f) { + continue; + } + if let Some(tri) = self.faces[f as usize] { + if !seen.insert(tri_sorted(tri)) { + return false; + } + } + } + for &f in &self.vfaces[s as usize] { + let Some(tri) = self.faces[f as usize] else { + continue; + }; + if pair.contains(&f) { + continue; // degenerate (contains s AND t) → dropped, allowed + } + let mut r = tri; + for i in 0..3 { + if r[i] == s { + r[i] = t; + } + } + if r[0] == r[1] || r[1] == r[2] || r[0] == r[2] { + continue; // will degenerate → dropped, allowed + } + if !seen.insert(tri_sorted(r)) { + return false; + } + } + // Areas BEFORE the move (they change when `t` moves): each remapped face's, for + // its own area guard. + let remaps: Vec<(u32, [u32; 3], f32)> = self.vfaces[s as usize] + .iter() + .filter(|&&f| self.faces[f as usize].is_some() && !pair.contains(&f)) + .map(|&f| { + ( + f, + self.faces[f as usize].unwrap(), + Geometry::triangle_area(&self.pos, &self.faces[f as usize].unwrap()), + ) + }) + .collect(); + + // Execute — standard Garland–Heckbert: both pair faces contain s AND t, so both + // degenerate when s merges into t and are simply REMOVED (no new face is created: + // the region folds and the neighbouring faces sweep over it). This keeps the mesh + // a closed 2-manifold — the property that makes the decimated result hole-free. + let (_cost, pt) = self.cost_and_point(s, t); + self.pos[t as usize] = pt; + let qs = self.quad[s as usize]; + self.quad[t as usize] += qs; + self.active[s as usize] = false; + for &f in &pair { + self.faces[f as usize] = None; + } + self.face_count = self.face_count.saturating_sub(pair.len() as u32); + + // Remap `s`'s other faces: s→t, edge bookkeeping updated, area-guarded. + for (f, old, old_area) in remaps { + let mut tri = old; + for i in 0..3 { + if tri[i] == s { + tri[i] = t; + } + } + if tri[0] == tri[1] || tri[1] == tri[2] || tri[0] == tri[2] { + self.faces[f as usize] = None; + self.face_count = self.face_count.saturating_sub(1); + continue; + } + let new_area = Geometry::triangle_area(&self.pos, &tri); + if old_area > 0.0 && new_area < 1e-4 * old_area { + self.faces[f as usize] = None; + self.face_count = self.face_count.saturating_sub(1); + continue; + } + Self::move_face_edges(self, f, old, tri); + self.faces[f as usize] = Some(tri); + // vfaces bookkeeping: `f` no longer contains `s` and now contains `t` (both + // live here — the lists must stay accurate for future remaps; stale entries + // of *dead* faces are filtered by liveness everywhere). + self.vfaces[s as usize].retain(|&x| x != f); + self.vfaces[t as usize].push(f); + } + + // Re-push edges whose cost changed: everything incident to `t` (its quadric and + // position changed). + let mut pushed: std::collections::HashSet = std::collections::HashSet::new(); + let tfaces: Vec = self.vfaces[t as usize] + .iter() + .copied() + .filter(|&f| self.faces[f as usize].is_some()) + .collect(); + for &f in &tfaces { + let tri = self.faces[f as usize].unwrap(); + for i in 0..3 { + for j in (i + 1)..3 { + let k = Self::edge_key(tri[i], tri[j]); + if pushed.insert(k) { + let (cost, _) = self.cost_and_point(tri[i], tri[j]); + pq.push(EdgeCost(cost, tri[i], tri[j])); + } + } + } + } + true + } + + /// Updates the edge map when face `f` changes from `old` to `new` triple: removes `f` + /// from edges it no longer has, adds it to new ones (unchanged edges are skipped). + fn move_face_edges(&mut self, f: u32, old: [u32; 3], new: [u32; 3]) { + for i in 0..3 { + for j in (i + 1)..3 { + let ko = Self::edge_key(old[i], old[j]); + let kn = Self::edge_key(new[i], new[j]); + if ko != kn { + if let Some(v) = self.edge_faces.get_mut(&ko) { + v.retain(|&x| x != f); + } + self.edge_faces.entry(kn).or_default().push(f); + } + } + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -671,7 +1190,7 @@ mod tests { } // ======================================================================== - // decimated / generate_lod_levels (Step 19, D10) + // decimated / generate_lod_levels (Step 19, D10 — quadric edge collapse) // ======================================================================== /// Fan of three triangles sharing vertex 0, with distinct areas 0.5 / 4 / 16. @@ -689,46 +1208,118 @@ mod tests { .with_indices(vec![0, 1, 2, 0, 3, 4, 0, 5, 6]) } - #[test] - fn decimated_removes_smallest_triangles_first() { - // Areas 0.5 / 4 / 16 → target 2 keeps the two largest. - let out = tri_fan().decimated(2); - assert_eq!(out.num_triangles(), 2); - // The smallest triangle's unique vertices (1, 2) are gone. - assert!(!out.positions.contains(&[1.0, 0.0, 0.0])); - assert!(!out.positions.contains(&[0.0, 1.0, 0.0])); - assert!(out.positions.contains(&[2.0, 0.0, 0.0])); - assert!(out.positions.contains(&[0.0, 4.0, 0.0])); - out.validate().expect("decimated output validates"); + /// True when the mesh is **closed** topologically: after welding by position, every + /// edge lies in exactly two faces (no boundary edges → no holes). Welding makes this + /// robust to UV seams (the same position duplicated with different UVs). + fn is_closed(geo: &Geometry) -> bool { + let Some(tris) = geo.non_degenerate_triangles() else { + return false; + }; + let (_pos, _uvs, _colors, faces) = geo.welded(&tris); + let mut edges: std::collections::HashMap<[u32; 2], u32> = std::collections::HashMap::new(); + for tri in &faces { + if tri[0] == tri[1] || tri[1] == tri[2] || tri[0] == tri[2] { + continue; // degenerate after welding (pole/seam duplicate) — no edges to count + } + for (i, j) in [(0, 1), (1, 2), (2, 0)] { + let (a, b) = (tri[i], tri[j]); + let key = if a < b { [a, b] } else { [b, a] }; + *edges.entry(key).or_insert(0) += 1; + } + } + !edges.is_empty() && edges.values().all(|&c| c == 2) + } + + /// The output bounding box stays inside the input's: optimal points are clamped to + /// edge segments (convex combinations of input positions). + fn bbox_inside(input: &Geometry, out: &Geometry) -> bool { + let Some(ib) = input.bbox() else { + return true; + }; + let Some(ob) = out.bbox() else { + return false; + }; + for i in 0..3 { + if ob.min[i] < ib.min[i] - 1e-4 || ob.max[i] > ib.max[i] + 1e-4 { + return false; + } + } + true } #[test] - fn decimated_icosahedron_20_to_10() { + fn decimated_collapse_reduces_to_target() { + let out = tri_fan().decimated(2); + assert!(out.indices().is_some(), "output is indexed"); + assert_eq!(out.num_triangles(), 2); + assert!(out.validate().is_ok()); + assert!(bbox_inside(&tri_fan(), &out)); + } + + #[test] + fn decimated_icosahedron_stays_closed() { use crate::math::primitives; - let ico = primitives::icosphere(1.0, 0); // 12 vertices / 20 faces, equal area - assert_eq!(ico.num_triangles(), 20); + let ico = primitives::icosphere(1.0, 0); // 12 vertices / 20 faces, closed + assert!(is_closed(&ico), "input is closed"); let out = ico.decimated(10); + assert!(out.validate().is_ok()); assert_eq!(out.num_triangles(), 10); - out.validate().expect("output validates"); - // All output positions come from the input; the bbox stays inside the original. - for p in &out.positions { - assert!(ico.positions.contains(p)); - } - let in_bb = ico.bbox().expect("input bbox"); - let out_bb = out.bbox().expect("output bbox"); - for i in 0..3 { - assert!( - (out_bb.min[i] - in_bb.min[i]).abs() < 1e-5 - && out_bb.min[i] >= in_bb.min[i] - 1e-5 - && out_bb.max[i] <= in_bb.max[i] + 1e-5 - ); - } + assert!( + is_closed(&out), + "no holes: every edge stays in exactly two faces" + ); + assert!( + bbox_inside(&ico, &out), + "optimal points stay inside the input bbox" + ); + } + + #[test] + fn decimated_uv_sphere_keeps_its_caps() { + // Regression (user report): the old triangle-removal decimation removed the pole + // triangles first (they are the smallest) → missing caps and holes. + use crate::math::primitives; + let sph = primitives::uv_sphere(1.0, 8, 6); + let t = sph.num_triangles() as u32; + assert!(is_closed(&sph), "input is closed"); + let out = sph.decimated(t / 2); + assert!(out.validate().is_ok()); + assert!(is_closed(&out), "sphere stays closed"); + assert!(bbox_inside(&sph, &out)); + let in_z = sph + .positions + .iter() + .map(|p| p[2].abs()) + .fold(0.0f32, f32::max); + let out_z = out + .positions + .iter() + .map(|p| p[2].abs()) + .fold(0.0f32, f32::max); + assert!( + out_z >= 0.9 * in_z, + "poles preserved: in_z={in_z}, out_z={out_z}" + ); + } + + #[test] + fn decimated_torus_stays_closed() { + use crate::math::primitives; + let torus = primitives::torus(1.0, 0.4, 16, 12); + let t = torus.num_triangles() as u32; + assert!(is_closed(&torus), "input is closed"); + let out = torus.decimated(t / 2); + assert!(out.validate().is_ok()); + assert!(is_closed(&out), "torus stays closed"); + assert!(bbox_inside(&torus, &out)); + assert!( + out.num_triangles() as u32 <= t / 2, + "never above the target" + ); } #[test] fn decimated_welds_non_indexed_input() { - // Four triangles stored NON-indexed (duplicated corners). A and B share the - // (0,0,0)-(4,0,0) edge; C and D are smaller and get removed at target 2. let geo = Geometry::new(vec![ [0.0, 0.0, 0.0], [4.0, 0.0, 0.0], @@ -738,18 +1329,28 @@ mod tests { [0.0, -4.0, 0.0], [0.0, 4.0, 0.0], [1.0, 1.0, 0.0], - [0.0, -4.0, 0.0], // tri C (small, removed) + [0.0, -4.0, 0.0], // tri C (small) [1.0, 1.0, 0.0], [2.0, -1.0, 0.0], - [0.0, -4.0, 0.0], // tri D (smallest, removed) + [0.0, -4.0, 0.0], // tri D (smallest) ]); assert!(geo.indices().is_none()); - // Target 2 keeps the two big triangles; welding merges A and B's duplicated edge corners. let out = geo.decimated(2); assert!(out.indices().is_some(), "output is always indexed"); - assert_eq!(out.num_triangles(), 2); - assert_eq!(out.positions.len(), 4, "12 input vertices weld to 4"); - assert_eq!(out.indices().unwrap(), &[0, 1, 2, 0, 1, 3]); + // Best effort (see the `decimated` contract): an interior (pair) edge collapse + // removes 2 faces, a boundary collapse 1 — greedy on cost may overshoot the + // target by 1 (here the cheap shared edge (0,1) kills tris A and B together). + assert!( + (1..=2).contains(&out.num_triangles()), + "target 2 → 1 or 2 faces, got {}", + out.num_triangles() + ); + assert!(out.validate().is_ok()); + assert!( + out.positions.len() <= 6, + "welding + collapse shrinks the 12 input vertices" + ); + assert!(bbox_inside(&geo, &out)); } #[test] @@ -823,40 +1424,51 @@ mod tests { } #[test] - fn decimated_uv_takes_first_encountered() { - // Big triangle (area 2, good UVs) + small one (area 0.5, seam UVs) → the small - // one is removed; welded UVs come from the kept triangle's vertices. - // A and B are the two big triangles (kept); B reuses v0's position as v4 with a - // DIFFERENT (seam) UV. C and D are small and removed at target 2. Welding v4 into v0 - // must keep the FIRST encountered UV (0,0) and drop the seam (9,9). + fn decimated_uv_target_vertex_keeps_its_own() { + // A collapse target keeps its own UV; the source's UV is dropped. Flat quadric + // (z = 0 plane through the origin) → singular → the optimal point is the midpoint. + let geo = quad(); // 4 vertices, 2 faces + let out = geo.decimated(1); + assert_eq!(out.num_triangles(), 1); + assert_eq!(out.positions.len(), 3); + assert_eq!( + out.positions[0], + [0.5, 0.0, 0.0], + "flat quadric → midpoint of the edge" + ); + assert_eq!( + out.uvs.as_deref(), + Some(&[[0.0, 0.0], [1.0, 1.0], [0.0, 1.0]][..]) + ); + } + + #[test] + fn welded_uv_first_encountered_wins() { + // Seam: the same position with different UVs — the first-encountered UV wins. let geo = Geometry::new(vec![ [0.0, 0.0, 0.0], - [3.0, 0.0, 0.0], - [3.0, 3.0, 0.0], - [0.0, 3.0, 0.0], - [0.0, 0.0, 0.0], // seam copy of v0 - [1.5, 1.5, 0.0], + [2.0, 0.0, 0.0], + [0.0, 2.0, 0.0], + [2.0, 2.0, 0.0], + [0.0, 0.0, 0.0], // duplicate of vertex 0, different UV (seam) ]) .with_uvs(vec![ [0.0, 0.0], [1.0, 0.0], - [1.0, 1.0], [0.0, 1.0], - [9.0, 9.0], // seam UVs on the duplicate corner - [8.0, 8.0], + [1.0, 1.0], + [9.0, 9.0], ]) - .with_indices(vec![0, 1, 2, 4, 2, 3, 3, 4, 5, 5, 1, 2]); - let out = geo.decimated(2); // keeps the two big triangles - assert_eq!(out.num_triangles(), 2); + .with_indices(vec![0, 1, 2, 3, 4, 2]); + let tris = geo.non_degenerate_triangles().unwrap(); + let (positions, uvs, _colors, faces) = geo.welded(&tris); + assert_eq!(positions.len(), 4, "the seam duplicate welds into vertex 0"); assert_eq!( - out.positions.len(), - 4, - "seam corner welds into its first occurrence" - ); - assert_eq!( - out.uvs.as_deref(), - Some(&[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]][..]) + uvs.expect("uvs")[0], + [0.0, 0.0], + "first-encountered UV wins" ); + assert_eq!(faces, [[0, 1, 2], [3, 0, 2]]); } #[test] @@ -867,16 +1479,25 @@ mod tests { assert_eq!(levels.len(), 3); assert_eq!(levels[0].num_triangles(), 80, "L0 exact"); assert_eq!(levels[0].indices(), geo.indices(), "L0 byte-exact"); - assert_eq!(levels[1].num_triangles(), 40); - assert_eq!(levels[2].num_triangles(), 20); + // Collapse is best-effort: the count lands at the target (a collapse removes one + // face; area guards may remove one or two more, never fewer). + for (lvl, target) in levels[1..].iter().zip([40usize, 20]) { + let n = lvl.num_triangles(); + assert!( + (target - 4..=target).contains(&n), + "L target {target}: got {n}" + ); + assert!(is_closed(lvl), "closed stays closed"); + } } #[test] fn generate_lod_levels_one_level() { - let geo = tri_fan(); + use crate::math::primitives; + let geo = primitives::icosphere(1.0, 1); let levels = geo.generate_lod_levels(1); assert_eq!(levels.len(), 1); - assert_eq!(levels[0].indices(), geo.indices()); + assert_eq!(levels[0].num_triangles(), geo.num_triangles(), "L0 exact"); } #[test] diff --git a/lib/src/math/lod.rs b/lib/src/math/lod.rs index 7cbe61c..4d7e9c2 100644 --- a/lib/src/math/lod.rs +++ b/lib/src/math/lod.rs @@ -101,7 +101,7 @@ mod tests { fn camera(dist: f32, fov: f32) -> (Mat4, Mat4) { let view = glam::camera::rh::view::look_at_mat4(Vec3::new(0.0, 0.0, dist), Vec3::ZERO, Vec3::Y); - let proj = glam::Mat4::perspective_rh_gl(fov, 1.0, 0.1, 100.0); + let proj = glam::camera::rh::proj::opengl::perspective(fov, 1.0, 0.1, 100.0); (view, proj) } diff --git a/lib/src/resources/mesh.rs b/lib/src/resources/mesh.rs index 995c2f0..69e06c0 100644 --- a/lib/src/resources/mesh.rs +++ b/lib/src/resources/mesh.rs @@ -38,7 +38,7 @@ pub enum LodMode { /// No LOD: a single level (the plain `create_mesh` path). #[default] Off, - /// Levels 1.. were auto-generated by greedy decimation (`Geometry::decimated`, D10). + /// Levels 1.. were auto-generated by quadric edge collapse (`Geometry::decimated`, D10). Auto, /// Levels 1.. were supplied explicitly (`Scene::add_mesh_lod`). Explicit, diff --git a/lib/src/scene/scene.rs b/lib/src/scene/scene.rs index 53358b7..e09ad97 100644 --- a/lib/src/scene/scene.rs +++ b/lib/src/scene/scene.rs @@ -279,7 +279,7 @@ impl Scene { /// Builds, (optionally) links to a Material, and registers a **multi-level** Mesh in one /// declarative call (Step 19, D6/D7). Level 0 is `geometry` (byte-exact); levels 1.. are - /// auto-generated by greedy decimation (`Geometry::generate_lod_levels`, D10) at halving + /// auto-generated by quadric edge collapse (`Geometry::generate_lod_levels`, D10) at halving /// targets. All levels are packed into the mesh's single vertex/index buffers (D7), and the /// per-level offsets are uploaded per frame as the mesh's LOD table for the GPU cull pass. ///