LOD: fix inverted normals on decimated meshes + rim protection
Root cause of the user-reported artifacts (stripes disappearing on the far LOD): normals were RECOMPUTED from the surviving faces after the collapse. uv_sphere is wound inward, so the recomputed normals pointed inward — the far LOD was back-face-lit (measured deviation 2.0 vs 0.0 on L0). Fix — normals are INHERITED, never recomputed: - welded() now also welds normals (first-encountered per cluster) - Collapse owns the normal table; collapse_edge λ-blends + renormalizes at the same λ as the position (no seam guard: the attribute-aware weld kept hard-edge vertices separate, so no edge crosses a shading break) - compaction reads the post-collapse table instead of recomputing - the outward reorientation added earlier is removed: the source winding + normals are preserved as-is, so every LOD level is shading-compatible with L0 whatever the source orientation Rim protection (attribute-aware weld leaves UV-seam slits / pole fans as boundary rims): a face touching such a rim is never removed while interior collapses remain — strict PQ mode (edges whose incident faces are fully interior, re-validated at pop) with a best-effort fallback when the interior alone cannot reach the target. Seam-free meshes (icosahedron) stay topologically closed; sewn meshes stay geometrically complete (no hole at the slit) — tests now assert seam-column survival. Docs: gpu-driven.md §LOD, ARCHI_CPU_GPU LOD note, ROADMAP 4.3 updated with the attribute-aware weld + rim protection + inherited normals. Gate: fmt ✓, check 0 warnings ✓, 104 tests ✓, demo runs ✓. Measured (uv_sphere 32×20): normal max deviation 0.0000 on L0–L2 (was 2.0000), UV max error 0.0084 vs analytical (was 0.5000).
This commit is contained in:
+1
-1
@@ -162,7 +162,7 @@ generated: { by: human:jerome, at: 2026-07-31T00:00:00Z }
|
|||||||
|
|
||||||
### 4.3 Optimisations
|
### 4.3 Optimisations
|
||||||
- [x] Batching par Material (réduction des state changes GPU) — 2026-09-22 (Étape 18 : draws groupés par `Arc<Material>` dans la passe principale, 1 `set_pipeline` par matériau distinct — le démo passe de 7 à 3 ; pass d'ombre inchangé)
|
- [x] Batching par Material (réduction des state changes GPU) — 2026-09-22 (Étape 18 : draws groupés par `Arc<Material>` 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 **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)
|
- [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, weld tolérance 1e-6 **conscient des attributs** (UV ≤ ½ tuile + normales dot > 0.9 — jamais à travers une seam/côte dure), mesh sans couture reste fermé, lèvres de couture protégées sinon (surface géométriquement complète), UVs/couleurs/normales interpolés au repli (normales héritées, jamais recalculées), 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)
|
- [ ] HDR + Tone Mapping (optionnel)
|
||||||
|
|
||||||
### 4.4 Gestion du Resize (cycle de vie Surface + Depth)
|
### 4.4 Gestion du Resize (cycle de vie Surface + Depth)
|
||||||
|
|||||||
@@ -42,9 +42,12 @@ Ce document sert de spécification technique et de trame d'implémentation pour
|
|||||||
> (rayon de la sphère bounding projeté en pixels + hystérésis asymétrique — `math/lod.rs`, pur et unit-testé) ;
|
> (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
|
> 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 **quadric edge collapse** (Garland–Heckbert) au setup (`Geometry::decimated` : les
|
> 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
|
> arêtes au coût quadrique minimal sont repliées en premier ; soudure **consciente des attributs**
|
||||||
> trous, pas de « books » ; soudure tolérance 1e-6, UVs/couleurs interpolés au repli — jamais
|
> — un doublon ne fusionne que si UV proches (≤ ½ tuile) ET normales proches (dot > 0.9) — ; un mesh
|
||||||
> à travers une seam UV —, rebase u16) et **empilés dans les buffers vertex/index du mesh** (offsets en
|
> sans couture reste fermé, sur un mesh couturé les lèvres de couture sont protégées (pas de trous,
|
||||||
|
> pas de « books ») ; UVs/couleurs/normales interpolés au repli, normales **héritées** de la source
|
||||||
|
> (jamais recalculées — l'éclairage reste identique au niveau 0 quelle que soit l'orientation source),
|
||||||
|
> jamais à travers une seam UV ; 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 :
|
> 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
|
> 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`
|
> bit-à-bit identique au pré-LOD (niveau 0 partout = comptes complets). Détail : `docs/user/gpu-driven.md`
|
||||||
|
|||||||
+13
-7
@@ -96,11 +96,17 @@ CPU):
|
|||||||
automatically from level 0 by **quadric edge collapse** (Garland–Heckbert,
|
automatically from level 0 by **quadric edge collapse** (Garland–Heckbert,
|
||||||
`Geometry::generate_lod_levels`): edges are ranked by quadric error and collapsed
|
`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
|
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
|
neighbours — no new face, so a **seam-free mesh stays closed** (no holes, no non-manifold
|
||||||
"books"), a boundary collapse removes one face; duplicate corners are welded (relative
|
"books") and a boundary collapse removes one face; duplicate corners are welded **aware of
|
||||||
tolerance 1e-6); a survivor **moved** by a collapse gets its UV/color **interpolated**
|
their attributes** (relative position tolerance 1e-6, merged only when the UVs are within half
|
||||||
between the collapsed endpoints (same λ as its new position — the texture stays attached
|
a tile and the normals within ~25° — a seam or a hard edge stays a separate corner, so a
|
||||||
to the surface and coarsens smoothly across levels, never across a UV seam), and the
|
collapse can never cross a texture chart or a shading break; on a mesh with a UV seam the
|
||||||
|
seam's lips are then *protected* — their faces never collapse while interior ones remain — so
|
||||||
|
the surface stays geometrically complete); a survivor **moved** by a collapse gets its
|
||||||
|
UV/color/normal **interpolated** between the collapsed endpoints (same λ as its new position —
|
||||||
|
texture and shading stay attached to the surface and coarsen smoothly across levels, never
|
||||||
|
across a UV seam; normals are **inherited from the source, never recomputed**, so the lighting
|
||||||
|
is identical to level 0 whatever the source's winding), and the
|
||||||
levels are **packed into the mesh's single
|
levels are **packed into the mesh's single
|
||||||
vertex/index buffers** (see the constraint below). Level 0 is always your exact geometry.
|
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
|
2. **Per frame (CPU).** For each entity, the bounding sphere used by culling is projected to screen
|
||||||
@@ -219,8 +225,8 @@ and verified fixed, see the D14 note in `docs/tech/ARCHI_CPU_GPU.md`.)
|
|||||||
- **Mesh bounding boxes are recomputed when meshes are added**; a scene whose mesh set changes
|
- **Mesh bounding boxes are recomputed when meshes are added**; a scene whose mesh set changes
|
||||||
at runtime simply re-uploads the small bbox table (a few bytes per mesh).
|
at runtime simply re-uploads the small bbox table (a few bytes per mesh).
|
||||||
- **LOD levels are packed into the mesh's own buffers**: u16 indices cap the *total* across all
|
- **LOD levels are packed into the mesh's own buffers**: u16 indices cap the *total* across all
|
||||||
levels at 65 535 vertices, and there are at most 4 levels. The decimation (smallest-triangle
|
levels at 65 535 vertices, and there are at most 4 levels. The decimation (quadric edge collapse
|
||||||
removal + welding) is a setup-time cost only (a few ms for thousands of triangles); the
|
+ attribute-aware welding) is a setup-time cost only (a few ms for thousands of triangles); the
|
||||||
per-frame cost is one sphere projection per entity on the CPU.
|
per-frame cost is one sphere projection per entity on the CPU.
|
||||||
- **LOD detail loss is visible by design** — the hysteresis dead band makes the pop rare and
|
- **LOD detail loss is visible by design** — the hysteresis dead band makes the pop rare and
|
||||||
one-directional (immediate when gaining detail, delayed when losing it), but a coarse level is
|
one-directional (immediate when gaining detail, delayed when losing it), but a coarse level is
|
||||||
|
|||||||
+413
-119
@@ -378,19 +378,55 @@ impl Geometry {
|
|||||||
(cx * cx + cy * cy + cz * cz).sqrt() * 0.5
|
(cx * cx + cy * cy + cz * cz).sqrt() * 0.5
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Welds the given triangles into a single vertex table (positions + optional
|
/// Attribute-aware weld (used by [`Self::decimated`]): positions within a small
|
||||||
/// per-vertex attributes) and u32 face indices — the form [`Collapse`] operates on.
|
/// relative tolerance are clustered, but a duplicate is merged into a cluster only
|
||||||
/// Welding uses a small **relative** tolerance (1e-6 of the bounding box, at least
|
/// when it also shares the same UV tile region (within half a tile — a full-tile
|
||||||
/// 1e-6 world units): positions that agree within it are the same vertex. This
|
/// jump is a texture seam) and nearly the same normal (dot > 0.9, ≈25° — a larger
|
||||||
/// absorbs the near-duplicates produced by trig generation (`sin(2π) ≠ 0` — the seam
|
/// angle is a hard edge). Seam and hard-edge duplicates therefore stay separate, so
|
||||||
/// of a torus or UV sphere) so they weld cleanly, while never merging distinct
|
/// an edge collapse can never cross a texture chart or a hard edge: the decimated
|
||||||
/// vertices of a well-formed mesh. A welded group's UV/color is the value of the
|
/// level keeps the source's per-chart texture and shading layout.
|
||||||
/// **first vertex encountered** (deterministic; the first triangle's seam UV wins).
|
|
||||||
fn welded(
|
fn welded(
|
||||||
&self,
|
&self,
|
||||||
tris: &[[u32; 3]],
|
tris: &[[u32; 3]],
|
||||||
) -> (
|
) -> (
|
||||||
Vec<[f32; 3]>,
|
Vec<[f32; 3]>,
|
||||||
|
Option<Vec<[f32; 3]>>,
|
||||||
|
Option<Vec<[f32; 2]>>,
|
||||||
|
Option<Vec<[f32; 4]>>,
|
||||||
|
Vec<[u32; 3]>,
|
||||||
|
) {
|
||||||
|
self.welded_inner(tris, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Position-only weld (attribute-blind) — used by [`is_closed`]: it welds the trig
|
||||||
|
/// seams even when the duplicated vertices carry different charts, so topological
|
||||||
|
/// closedness is measured on the geometric surface, not on the attribute layout.
|
||||||
|
#[cfg(test)]
|
||||||
|
fn welded_by_position(
|
||||||
|
&self,
|
||||||
|
tris: &[[u32; 3]],
|
||||||
|
) -> (
|
||||||
|
Vec<[f32; 3]>,
|
||||||
|
Option<Vec<[f32; 3]>>,
|
||||||
|
Option<Vec<[f32; 2]>>,
|
||||||
|
Option<Vec<[f32; 4]>>,
|
||||||
|
Vec<[u32; 3]>,
|
||||||
|
) {
|
||||||
|
self.welded_inner(tris, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared weld (see the two wrappers): a grid of **relative-tolerance** position
|
||||||
|
/// clusters (1e-6 of the bounding box, at least 1e-6 world units — absorbs the
|
||||||
|
/// near-duplicates produced by trig generation, `sin(2π) ≠ 0`), plus the attribute
|
||||||
|
/// test in attribute-aware mode. A welded group's attributes (normal/UV/color) are
|
||||||
|
/// the value of its **first vertex encountered** (deterministic).
|
||||||
|
fn welded_inner(
|
||||||
|
&self,
|
||||||
|
tris: &[[u32; 3]],
|
||||||
|
attrs_aware: bool,
|
||||||
|
) -> (
|
||||||
|
Vec<[f32; 3]>,
|
||||||
|
Option<Vec<[f32; 3]>>,
|
||||||
Option<Vec<[f32; 2]>>,
|
Option<Vec<[f32; 2]>>,
|
||||||
Option<Vec<[f32; 4]>>,
|
Option<Vec<[f32; 4]>>,
|
||||||
Vec<[u32; 3]>,
|
Vec<[u32; 3]>,
|
||||||
@@ -409,10 +445,16 @@ impl Geometry {
|
|||||||
let cell = |p: &[f32; 3]| [0, 1, 2].map(|k| (p[k] * inv).round() as i64);
|
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 positions: Vec<[f32; 3]> = Vec::with_capacity(self.positions.len());
|
||||||
|
let mut normals: Option<Vec<[f32; 3]>> = self.normals.is_some().then(Vec::new);
|
||||||
let mut uvs: Option<Vec<[f32; 2]>> = self.uvs.is_some().then(Vec::new);
|
let mut uvs: Option<Vec<[f32; 2]>> = self.uvs.is_some().then(Vec::new);
|
||||||
let mut colors: Option<Vec<[f32; 4]>> = self.colors.is_some().then(Vec::new);
|
let mut colors: Option<Vec<[f32; 4]>> = self.colors.is_some().then(Vec::new);
|
||||||
// Grid cell → welded id (broad phase; candidates are verified by distance).
|
// Grid cell → welded ids (broad phase; candidates are verified by distance, and
|
||||||
let mut grid: std::collections::HashMap<[i64; 3], u32> = std::collections::HashMap::new();
|
// by attribute compatibility in attribute-aware mode). A cell holds several ids
|
||||||
|
// when attribute-duplicated vertices (seam/hard edge) live at one position.
|
||||||
|
let mut grid: std::collections::HashMap<[i64; 3], Vec<u32>> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
// Welded id → source vertex id of the group's first encountered member.
|
||||||
|
let mut rep: Vec<usize> = Vec::with_capacity(self.positions.len());
|
||||||
let mut new_indices: Vec<u32> = Vec::with_capacity(tris.len() * 3);
|
let mut new_indices: Vec<u32> = Vec::with_capacity(tris.len() * 3);
|
||||||
for tri in tris {
|
for tri in tris {
|
||||||
for &vi in tri.iter() {
|
for &vi in tri.iter() {
|
||||||
@@ -425,14 +467,22 @@ impl Geometry {
|
|||||||
for dy in -1i64..=1 {
|
for dy in -1i64..=1 {
|
||||||
for dz in -1i64..=1 {
|
for dz in -1i64..=1 {
|
||||||
let key = [c[0] + dx, c[1] + dy, c[2] + dz];
|
let key = [c[0] + dx, c[1] + dy, c[2] + dz];
|
||||||
if let Some(&candidate) = grid.get(&key) {
|
if let Some(cands) = grid.get(&key) {
|
||||||
let cp = &positions[candidate as usize];
|
for &candidate in cands {
|
||||||
let d2 = (0..3)
|
let cp = &positions[candidate as usize];
|
||||||
.map(|k| (pos[k] - cp[k]) * (pos[k] - cp[k]))
|
let d2 = (0..3)
|
||||||
.sum::<f32>();
|
.map(|k| (pos[k] - cp[k]) * (pos[k] - cp[k]))
|
||||||
if d2 <= 3.0 * eps * eps {
|
.sum::<f32>();
|
||||||
ni = Some(candidate);
|
if d2 <= 3.0 * eps * eps
|
||||||
break 'search;
|
&& (!attrs_aware
|
||||||
|
|| self.attrs_compatible(
|
||||||
|
vi as usize,
|
||||||
|
rep[candidate as usize],
|
||||||
|
))
|
||||||
|
{
|
||||||
|
ni = Some(candidate);
|
||||||
|
break 'search;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -443,13 +493,17 @@ impl Geometry {
|
|||||||
None => {
|
None => {
|
||||||
let ni = positions.len() as u32;
|
let ni = positions.len() as u32;
|
||||||
positions.push(*pos);
|
positions.push(*pos);
|
||||||
|
rep.push(vi as usize);
|
||||||
|
if let Some(normals) = &mut normals {
|
||||||
|
normals.push(self.normals.as_ref().unwrap()[vi as usize]);
|
||||||
|
}
|
||||||
if let Some(uvs) = &mut uvs {
|
if let Some(uvs) = &mut uvs {
|
||||||
uvs.push(self.uvs.as_ref().unwrap()[vi as usize]);
|
uvs.push(self.uvs.as_ref().unwrap()[vi as usize]);
|
||||||
}
|
}
|
||||||
if let Some(colors) = &mut colors {
|
if let Some(colors) = &mut colors {
|
||||||
colors.push(self.colors.as_ref().unwrap()[vi as usize]);
|
colors.push(self.colors.as_ref().unwrap()[vi as usize]);
|
||||||
}
|
}
|
||||||
grid.entry(c).or_insert(ni);
|
grid.entry(c).or_default().push(ni);
|
||||||
ni
|
ni
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -460,7 +514,27 @@ impl Geometry {
|
|||||||
.chunks_exact(3)
|
.chunks_exact(3)
|
||||||
.map(|c| [c[0], c[1], c[2]])
|
.map(|c| [c[0], c[1], c[2]])
|
||||||
.collect();
|
.collect();
|
||||||
(positions, uvs, colors, faces)
|
(positions, normals, uvs, colors, faces)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether two source vertices (known to be within the weld tolerance) belong to the
|
||||||
|
/// same texture chart / shading region: same UV tile region (within half a tile) and
|
||||||
|
/// nearly the same normal (dot > 0.9, ≈25°). Missing attribute tables are skipped
|
||||||
|
/// (position-only weld). Assumes normalized normals.
|
||||||
|
fn attrs_compatible(&self, i: usize, j: usize) -> bool {
|
||||||
|
if let Some(uvs) = &self.uvs {
|
||||||
|
let (a, b) = (uvs[i], uvs[j]);
|
||||||
|
if (a[0] - b[0]).abs() > 0.5 || (a[1] - b[1]).abs() > 0.5 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(normals) = &self.normals {
|
||||||
|
let (a, b) = (normals[i], normals[j]);
|
||||||
|
if a[0] * b[0] + a[1] * b[1] + a[2] * b[2] <= 0.9 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Quadric edge-collapse decimation (Step 19, D10 — pure function, no GPU): returns an
|
/// Quadric edge-collapse decimation (Step 19, D10 — pure function, no GPU): returns an
|
||||||
@@ -477,13 +551,12 @@ impl Geometry {
|
|||||||
/// mesh stays closed — no holes — and an open mesh keeps its boundary: this is the
|
/// 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
|
/// 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 create a non-manifold ("book") or a duplicate face are skipped; faces that
|
||||||
/// would degenerate are dropped. Smooth normals are **recomputed** over the surviving
|
/// would degenerate are dropped. A survivor **moved** by a collapse gets its
|
||||||
/// faces (only when the source had normals). A survivor **moved** by a collapse gets
|
/// UV/color/normal **re-interpolated** between the two collapsed endpoints at the same
|
||||||
/// its UV/color **re-interpolated** between the two collapsed endpoints at the same λ
|
/// λ as its new position, so the texture and shading stay attached to the surface and
|
||||||
/// as its new position, so the texture stays attached to the surface and coarsens
|
/// coarsen smoothly across levels; a UV seam (endpoints more than half a tile apart)
|
||||||
/// smoothly across levels; a UV seam (endpoints more than half a tile apart) is never
|
/// is never blended across — the survivor keeps its own value there — and a welded
|
||||||
/// blended across — the survivor keeps its own value there — and a welded group's
|
/// group's attributes are the value of the **first vertex encountered** (deterministic).
|
||||||
/// UV/color is the value of the **first vertex encountered** (deterministic).
|
|
||||||
///
|
///
|
||||||
/// Fallback (never corrupts): target ≥ triangle count, target = 0, malformed input, or a
|
/// Fallback (never corrupts): target ≥ triangle count, target = 0, malformed input, or a
|
||||||
/// result that would not fit u16 indices (≥ 65536 vertices) → `self.clone()`. Best
|
/// result that would not fit u16 indices (≥ 65536 vertices) → `self.clone()`. Best
|
||||||
@@ -491,6 +564,20 @@ impl Geometry {
|
|||||||
/// and 1 on a boundary edge, so the target is reached exactly only when the topology
|
/// 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
|
/// 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).
|
/// stops as soon as it goes at or under the target).
|
||||||
|
///
|
||||||
|
/// The **orientation is preserved** from the source: the output faces keep the
|
||||||
|
/// source winding, and the normals are inherited (λ-blended) from the source vertex
|
||||||
|
/// normals — never recomputed from the (possibly inward-wound) faces. Every LOD
|
||||||
|
/// level is therefore shading-compatible with L0, whatever the source's winding
|
||||||
|
/// (`uv_sphere` is wound inward but its normals point outward — both are kept as-is;
|
||||||
|
/// recomputing normals from the faces would have inverted them at the LOD switch).
|
||||||
|
///
|
||||||
|
/// **Rim protection**: the attribute-aware weld leaves the UV-seam slit and pole
|
||||||
|
/// fans as *boundary loops* (topologically open rims on a geometrically closed
|
||||||
|
/// surface). A face touching such a rim is never removed while interior collapses
|
||||||
|
/// remain — killing it would open a real hole where the cut's other side used to be.
|
||||||
|
/// Only when the interior alone cannot reach the target (best effort) do boundary
|
||||||
|
/// collapses run: the rim then loses slivers, the standard open-mesh behavior.
|
||||||
pub fn decimated(&self, target_triangles: u32) -> Geometry {
|
pub fn decimated(&self, target_triangles: u32) -> Geometry {
|
||||||
let Some(tris) = self.non_degenerate_triangles() else {
|
let Some(tris) = self.non_degenerate_triangles() else {
|
||||||
return self.clone();
|
return self.clone();
|
||||||
@@ -501,22 +588,31 @@ impl Geometry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Welded form (one vertex table + u32 faces) — what edge collapse operates on.
|
// Welded form (one vertex table + u32 faces) — what edge collapse operates on.
|
||||||
let (wpos, wuvs, wcolors, wfaces) = self.welded(&tris);
|
let (wpos, wnormals, wuvs, wcolors, wfaces) = self.welded(&tris);
|
||||||
if wfaces.is_empty() {
|
if wfaces.is_empty() {
|
||||||
return self.clone();
|
return self.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collapse down to `target_triangles` faces (or as close as the topology allows).
|
// Collapse down to `target_triangles` faces (or as close as the topology allows).
|
||||||
let mut c = Collapse::new(wpos, wuvs, wcolors, wfaces);
|
// The orientation is preserved as-is (source winding + source normals, inherited
|
||||||
let mut pq = c.rebuild_pq();
|
// through the collapse) — see the doc: the LOD levels are shading-compatible
|
||||||
|
// with L0 whatever the source's winding.
|
||||||
|
let mut c = Collapse::new(wpos, wnormals, wuvs, wcolors, wfaces);
|
||||||
|
// Strict mode (default): only edges whose faces are fully interior collapse —
|
||||||
|
// see the **Rim protection** doc. A dry strict queue drops to the best-effort
|
||||||
|
// fallback (boundary collapses allowed); a dry fallback queue ends the loop.
|
||||||
|
let mut strict = true;
|
||||||
|
let mut pq = c.rebuild_pq(strict);
|
||||||
let mut rebuilds = 0u32;
|
let mut rebuilds = 0u32;
|
||||||
while c.face_count > target_triangles {
|
while c.face_count > target_triangles {
|
||||||
let Some(EdgeCost(cost, a, b)) = pq.pop() else {
|
let Some(EdgeCost(cost, a, b)) = pq.pop() else {
|
||||||
// Heap exhausted (fresh edges are not in it yet) → full rebuild.
|
// Heap exhausted (fresh edges are not in it yet) → full rebuild; a dry
|
||||||
let rebuilt = c.rebuild_pq();
|
// strict queue de-escalates to the fallback mode (see above).
|
||||||
|
let rebuilt = c.rebuild_pq(!strict);
|
||||||
if rebuilt.is_empty() {
|
if rebuilt.is_empty() {
|
||||||
break; // best effort: nothing left that can collapse
|
break; // best effort: nothing left that can collapse
|
||||||
}
|
}
|
||||||
|
strict = false;
|
||||||
rebuilds += 1;
|
rebuilds += 1;
|
||||||
if rebuilds > 4 * t {
|
if rebuilds > 4 * t {
|
||||||
// Safety valve (pathological mesh: every remaining edge is rejected by
|
// Safety valve (pathological mesh: every remaining edge is rejected by
|
||||||
@@ -540,8 +636,8 @@ impl Geometry {
|
|||||||
pq.push(EdgeCost(true_cost, a, b));
|
pq.push(EdgeCost(true_cost, a, b));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if !c.collapse_edge(a, b, &mut pq) {
|
if !c.collapse_edge(a, b, strict, &mut pq) {
|
||||||
continue; // invalid (book/duplicate) → skipped; re-enters at a rebuild
|
continue; // invalid (book/duplicate/strict-rim) → skipped; re-enters at a rebuild
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -557,14 +653,18 @@ impl Geometry {
|
|||||||
let mut new_id = vec![u32::MAX; n];
|
let mut new_id = vec![u32::MAX; n];
|
||||||
let mut new_positions: Vec<[f32; 3]> = Vec::with_capacity(n);
|
let mut new_positions: Vec<[f32; 3]> = Vec::with_capacity(n);
|
||||||
// The collapses moved survivors to optimal points and re-interpolated their
|
// The collapses moved survivors to optimal points and re-interpolated their
|
||||||
// UVs/colors — these tables hold the post-collapse values.
|
// normals/UVs/colors — these tables hold the post-collapse values.
|
||||||
let (cuvs, ccolors) = (c.uvs.take(), c.colors.take());
|
let (cnormals, cuvs, ccolors) = (c.normals.take(), c.uvs.take(), c.colors.take());
|
||||||
|
let mut new_normals = cnormals.is_some().then(Vec::new);
|
||||||
let mut new_uvs = cuvs.is_some().then(Vec::new);
|
let mut new_uvs = cuvs.is_some().then(Vec::new);
|
||||||
let mut new_colors = ccolors.is_some().then(Vec::new);
|
let mut new_colors = ccolors.is_some().then(Vec::new);
|
||||||
for i in 0..n {
|
for i in 0..n {
|
||||||
if c.active[i] && referenced[i] {
|
if c.active[i] && referenced[i] {
|
||||||
new_id[i] = new_positions.len() as u32;
|
new_id[i] = new_positions.len() as u32;
|
||||||
new_positions.push(c.pos[i]);
|
new_positions.push(c.pos[i]);
|
||||||
|
if let Some(normals) = &mut new_normals {
|
||||||
|
normals.push(cnormals.as_ref().unwrap()[i]);
|
||||||
|
}
|
||||||
if let Some(uvs) = &mut new_uvs {
|
if let Some(uvs) = &mut new_uvs {
|
||||||
uvs.push(cuvs.as_ref().unwrap()[i]);
|
uvs.push(cuvs.as_ref().unwrap()[i]);
|
||||||
}
|
}
|
||||||
@@ -584,42 +684,9 @@ impl Geometry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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()];
|
|
||||||
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] += nrm[0];
|
|
||||||
v[1] += nrm[1];
|
|
||||||
v[2] += nrm[2];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
acc.iter()
|
|
||||||
.map(|v| {
|
|
||||||
let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
|
|
||||||
if len < 1e-12 {
|
|
||||||
[0.0, 0.0, 1.0]
|
|
||||||
} else {
|
|
||||||
[v[0] / len, v[1] / len, v[2] / len]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
});
|
|
||||||
|
|
||||||
Geometry {
|
Geometry {
|
||||||
positions: new_positions,
|
positions: new_positions,
|
||||||
normals,
|
normals: new_normals,
|
||||||
uvs: new_uvs,
|
uvs: new_uvs,
|
||||||
colors: new_colors,
|
colors: new_colors,
|
||||||
indices: Some(new_indices),
|
indices: Some(new_indices),
|
||||||
@@ -669,6 +736,11 @@ impl Geometry {
|
|||||||
struct Collapse {
|
struct Collapse {
|
||||||
/// Mutable positions: a collapse target moves to its optimal point.
|
/// Mutable positions: a collapse target moves to its optimal point.
|
||||||
pos: Vec<[f32; 3]>,
|
pos: Vec<[f32; 3]>,
|
||||||
|
/// Normals, λ-blended on a collapse like the UVs but with no seam guard (normals are
|
||||||
|
/// continuous on a smooth chart, and the attribute-aware weld kept hard-edge
|
||||||
|
/// duplicates separate). Inherited from the source, never recomputed (see
|
||||||
|
/// `Geometry::decimated`). `None` when the source had no normals.
|
||||||
|
normals: Option<Vec<[f32; 3]>>,
|
||||||
/// UVs, moved in with the positions: a collapse target's UV is re-interpolated
|
/// UVs, moved in with the positions: a collapse target's UV is re-interpolated
|
||||||
/// between the two collapsed endpoints (at the same λ as its new position) so the
|
/// between the two collapsed endpoints (at the same λ as its new position) so the
|
||||||
/// texture stays attached to the surface across levels. `None` when the source had
|
/// texture stays attached to the surface across levels. `None` when the source had
|
||||||
@@ -751,9 +823,35 @@ fn tri_sorted(mut t: [u32; 3]) -> [u32; 3] {
|
|||||||
t
|
t
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True when the mesh is **closed** topologically: after a position-only weld, every
|
||||||
|
/// edge lies in exactly two faces (no boundary edges → no holes). The position-only weld
|
||||||
|
/// makes this robust to UV seams (the same position duplicated with different charts) and
|
||||||
|
/// to attribute-duplicated pole vertices. Used by the decimation tests to check that
|
||||||
|
/// seam-free inputs (icosahedron) stay topologically closed after the collapse.
|
||||||
|
#[cfg(test)]
|
||||||
|
fn is_closed(geo: &Geometry) -> bool {
|
||||||
|
let Some(tris) = geo.non_degenerate_triangles() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let (_pos, _normals, _uvs, _colors, faces) = geo.welded_by_position(&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)
|
||||||
|
}
|
||||||
|
|
||||||
impl Collapse {
|
impl Collapse {
|
||||||
fn new(
|
fn new(
|
||||||
pos: Vec<[f32; 3]>,
|
pos: Vec<[f32; 3]>,
|
||||||
|
normals: Option<Vec<[f32; 3]>>,
|
||||||
uvs: Option<Vec<[f32; 2]>>,
|
uvs: Option<Vec<[f32; 2]>>,
|
||||||
colors: Option<Vec<[f32; 4]>>,
|
colors: Option<Vec<[f32; 4]>>,
|
||||||
faces: Vec<[u32; 3]>,
|
faces: Vec<[u32; 3]>,
|
||||||
@@ -761,6 +859,7 @@ impl Collapse {
|
|||||||
let n = pos.len();
|
let n = pos.len();
|
||||||
let mut c = Self {
|
let mut c = Self {
|
||||||
pos,
|
pos,
|
||||||
|
normals,
|
||||||
uvs,
|
uvs,
|
||||||
colors,
|
colors,
|
||||||
active: vec![true; n],
|
active: vec![true; n],
|
||||||
@@ -875,14 +974,20 @@ impl Collapse {
|
|||||||
|
|
||||||
/// Full priority-queue rebuild: every edge whose endpoints are both active and that
|
/// Full priority-queue rebuild: every edge whose endpoints are both active and that
|
||||||
/// has 1–2 living faces, ordered by (cost, a, b) — deterministic.
|
/// has 1–2 living faces, ordered by (cost, a, b) — deterministic.
|
||||||
fn rebuild_pq(&self) -> std::collections::BinaryHeap<EdgeCost> {
|
///
|
||||||
|
/// `strict`: only edges whose incident faces are **fully interior** (every edge in
|
||||||
|
/// exactly two living faces) are queued — see `decimated`'s **Rim protection**.
|
||||||
|
/// `!strict` (best-effort fallback) queues every 1–2 face edge, the standard
|
||||||
|
/// behavior where a boundary collapse may remove a face.
|
||||||
|
fn rebuild_pq(&self, strict: bool) -> std::collections::BinaryHeap<EdgeCost> {
|
||||||
let mut pq = std::collections::BinaryHeap::new();
|
let mut pq = std::collections::BinaryHeap::new();
|
||||||
for (&key, faces) in &self.edge_faces {
|
for (&key, faces) in &self.edge_faces {
|
||||||
let live = faces
|
let live: Vec<u32> = faces
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|&&f| self.faces[f as usize].is_some())
|
.copied()
|
||||||
.count();
|
.filter(|&f| self.faces[f as usize].is_some())
|
||||||
if live == 0 || live > 2 {
|
.collect();
|
||||||
|
if live.is_empty() || live.len() > 2 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let a = (key >> 32) as u32;
|
let a = (key >> 32) as u32;
|
||||||
@@ -890,12 +995,34 @@ impl Collapse {
|
|||||||
if !self.active[a as usize] || !self.active[b as usize] {
|
if !self.active[a as usize] || !self.active[b as usize] {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if strict && !live.iter().all(|&f| self.face_fully_interior(f)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let (cost, _, _) = self.cost_and_point(a, b);
|
let (cost, _, _) = self.cost_and_point(a, b);
|
||||||
pq.push(EdgeCost(cost, a, b));
|
pq.push(EdgeCost(cost, a, b));
|
||||||
}
|
}
|
||||||
pq
|
pq
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True when every edge of face `f` lies in exactly two living faces (the face
|
||||||
|
/// touches no boundary). Only such a face may die in a strict collapse: each of its
|
||||||
|
/// edges has a live neighbour on the far side that the remap extends over the freed
|
||||||
|
/// area. A face with a boundary edge has an uncovered side — removing it opens a
|
||||||
|
/// hole (at a UV-seam slit, the far side is the cut's other rim, not a face).
|
||||||
|
fn face_fully_interior(&self, f: u32) -> bool {
|
||||||
|
let Some(tri) = self.faces[f as usize] else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
for i in 0..3 {
|
||||||
|
for j in (i + 1)..3 {
|
||||||
|
if self.edge_face_count(tri[i], tri[j]) != 2 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// Merges `s` into `t` and rewrites the edge's incident faces, or returns `false`
|
/// 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
|
/// 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
|
/// "book" test). Merge direction: the endpoint with the **smaller** face degree
|
||||||
@@ -907,6 +1034,7 @@ impl Collapse {
|
|||||||
&mut self,
|
&mut self,
|
||||||
a: u32,
|
a: u32,
|
||||||
b: u32,
|
b: u32,
|
||||||
|
strict: bool,
|
||||||
pq: &mut std::collections::BinaryHeap<EdgeCost>,
|
pq: &mut std::collections::BinaryHeap<EdgeCost>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
let live_deg = |v: u32| {
|
let live_deg = |v: u32| {
|
||||||
@@ -939,6 +1067,12 @@ impl Collapse {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Strict path: the pair faces must STILL be fully interior — a neighbour may
|
||||||
|
// have died since the edge was queued, opening a boundary edge on one of them.
|
||||||
|
if strict && !pair.iter().all(|&f| self.face_fully_interior(f)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Duplicate-face test (fold): every remapped face of `s` must stay unique among
|
// 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
|
// 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.
|
// duplicate is a fold — the surface would double-cover a region — and is rejected.
|
||||||
@@ -997,10 +1131,12 @@ impl Collapse {
|
|||||||
self.pos[t as usize] = pt;
|
self.pos[t as usize] = pt;
|
||||||
// Keep the survivor's texture attributes consistent with its NEW position: the
|
// Keep the survivor's texture attributes consistent with its NEW position: the
|
||||||
// optimal point is clamped to the segment, so it is (1−λ)·p_s + λ·p_t — blend the
|
// optimal point is clamped to the segment, so it is (1−λ)·p_s + λ·p_t — blend the
|
||||||
// UVs/colors with the same λ and the texture stays attached to the surface (it
|
// UVs/colors/normals with the same λ and the texture/shading stay attached to the
|
||||||
// coarsens smoothly across levels instead of jumping). A UV **seam** (endpoints
|
// surface (they coarsen smoothly across levels instead of jumping). A UV **seam**
|
||||||
// more than half a tile apart) is never blended across — that would smear the
|
// (endpoints more than half a tile apart) is never blended across — that would
|
||||||
// texture across the seam — so the survivor keeps its own value there.
|
// smear the texture across the seam — so the survivor keeps its own value there.
|
||||||
|
// Normals are always blended: the weld kept hard-edge vertices separate, so no
|
||||||
|
// edge crosses a shading discontinuity.
|
||||||
if let Some(uvs) = &mut self.uvs {
|
if let Some(uvs) = &mut self.uvs {
|
||||||
let us = uvs[s as usize];
|
let us = uvs[s as usize];
|
||||||
let ut = uvs[t as usize];
|
let ut = uvs[t as usize];
|
||||||
@@ -1015,6 +1151,22 @@ impl Collapse {
|
|||||||
colors[t as usize][k] = cs[k] + lam * (ct[k] - cs[k]);
|
colors[t as usize][k] = cs[k] + lam * (ct[k] - cs[k]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(normals) = &mut self.normals {
|
||||||
|
let ns = normals[s as usize];
|
||||||
|
let nt = normals[t as usize];
|
||||||
|
let mut m = [0.0f32; 3];
|
||||||
|
for k in 0..3 {
|
||||||
|
m[k] = ns[k] + lam * (nt[k] - ns[k]);
|
||||||
|
}
|
||||||
|
// Re-normalize: a blend of two unit normals has a slightly sub-unit length.
|
||||||
|
let len = (m[0] * m[0] + m[1] * m[1] + m[2] * m[2]).sqrt();
|
||||||
|
if len > 1e-12 {
|
||||||
|
for k in 0..3 {
|
||||||
|
m[k] /= len;
|
||||||
|
}
|
||||||
|
normals[t as usize] = m;
|
||||||
|
}
|
||||||
|
}
|
||||||
let qs = self.quad[s as usize];
|
let qs = self.quad[s as usize];
|
||||||
self.quad[t as usize] += qs;
|
self.quad[t as usize] += qs;
|
||||||
self.active[s as usize] = false;
|
self.active[s as usize] = false;
|
||||||
@@ -1249,28 +1401,6 @@ mod tests {
|
|||||||
.with_indices(vec![0, 1, 2, 0, 3, 4, 0, 5, 6])
|
.with_indices(vec![0, 1, 2, 0, 3, 4, 0, 5, 6])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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
|
/// The output bounding box stays inside the input's: optimal points are clamped to
|
||||||
/// edge segments (convex combinations of input positions).
|
/// edge segments (convex combinations of input positions).
|
||||||
fn bbox_inside(input: &Geometry, out: &Geometry) -> bool {
|
fn bbox_inside(input: &Geometry, out: &Geometry) -> bool {
|
||||||
@@ -1325,7 +1455,21 @@ mod tests {
|
|||||||
assert!(is_closed(&sph), "input is closed");
|
assert!(is_closed(&sph), "input is closed");
|
||||||
let out = sph.decimated(t / 2);
|
let out = sph.decimated(t / 2);
|
||||||
assert!(out.validate().is_ok());
|
assert!(out.validate().is_ok());
|
||||||
assert!(is_closed(&out), "sphere stays closed");
|
// The attribute-aware weld slits the seam/poles (topological rims); rim
|
||||||
|
// protection keeps the rim faces alive, so the surface stays geometrically
|
||||||
|
// complete — no hole. Check: the seam column (u = 1) vertices all survive.
|
||||||
|
let seam = |g: &Geometry| {
|
||||||
|
g.positions
|
||||||
|
.iter()
|
||||||
|
.zip(g.uvs.as_ref().unwrap().iter())
|
||||||
|
.filter(|(_, uv)| uv[0] > 0.99)
|
||||||
|
.count()
|
||||||
|
};
|
||||||
|
let (si, so) = (seam(&sph), seam(&out));
|
||||||
|
assert!(
|
||||||
|
so >= si - 2,
|
||||||
|
"seam column survives (no hole at the slit): in={si}, out={so}"
|
||||||
|
);
|
||||||
assert!(bbox_inside(&sph, &out));
|
assert!(bbox_inside(&sph, &out));
|
||||||
let in_z = sph
|
let in_z = sph
|
||||||
.positions
|
.positions
|
||||||
@@ -1351,7 +1495,20 @@ mod tests {
|
|||||||
assert!(is_closed(&torus), "input is closed");
|
assert!(is_closed(&torus), "input is closed");
|
||||||
let out = torus.decimated(t / 2);
|
let out = torus.decimated(t / 2);
|
||||||
assert!(out.validate().is_ok());
|
assert!(out.validate().is_ok());
|
||||||
assert!(is_closed(&out), "torus stays closed");
|
// As for the sphere: the welded seam is a rim; rim protection keeps the seam
|
||||||
|
// column alive → the surface stays geometrically complete (no hole at the slit).
|
||||||
|
let seam = |g: &Geometry| {
|
||||||
|
g.positions
|
||||||
|
.iter()
|
||||||
|
.zip(g.uvs.as_ref().unwrap().iter())
|
||||||
|
.filter(|(_, uv)| uv[0] > 0.99)
|
||||||
|
.count()
|
||||||
|
};
|
||||||
|
let (si, so) = (seam(&torus), seam(&out));
|
||||||
|
assert!(
|
||||||
|
so >= si - 2,
|
||||||
|
"seam column survives (no hole at the slit): in={si}, out={so}"
|
||||||
|
);
|
||||||
assert!(bbox_inside(&torus, &out));
|
assert!(bbox_inside(&torus, &out));
|
||||||
assert!(
|
assert!(
|
||||||
out.num_triangles() as u32 <= t / 2,
|
out.num_triangles() as u32 <= t / 2,
|
||||||
@@ -1453,14 +1610,75 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn decimated_recomputes_unit_normals() {
|
fn decimated_inherits_source_normals() {
|
||||||
// Garbage (unnormalized) input normals; output normals must be unit length.
|
// Normals are INHERITED from the source, never recomputed from the faces (the
|
||||||
let geo = tri_fan().with_normals(vec![[9.0, 9.0, 9.0]; 7]);
|
// source's winding may be inward — recomputation would flip the lighting at the
|
||||||
|
// LOD switch). A collapse blends the pair's normals at the same λ and
|
||||||
|
// re-normalizes — with identical input normals the output is exactly the source
|
||||||
|
// value, so every output normal must equal [0,0,1].
|
||||||
|
let geo = tri_fan().with_normals(vec![[0.0, 0.0, 1.0]; 7]);
|
||||||
let out = geo.decimated(2);
|
let out = geo.decimated(2);
|
||||||
let normals = out.normals.expect("normals recomputed");
|
let normals = out.normals.expect("normals inherited");
|
||||||
for n in &normals {
|
for n in &normals {
|
||||||
let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
|
assert_eq!(*n, [0.0, 0.0, 1.0], "normal must equal the source's: {n:?}");
|
||||||
assert!((len - 1.0).abs() < 1e-5, "normal not unit: {n:?}");
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Regression (user-reported artifact): the decimated levels of a UV sphere must
|
||||||
|
/// keep their UVs close to the analytical parameterization AND their normals pointing
|
||||||
|
/// outward (inherited from the source — before the fix, normals were recomputed from
|
||||||
|
/// the inward-wound faces and pointed INWARD, deviation ≈ 2.0, far LOD looked flat).
|
||||||
|
#[test]
|
||||||
|
fn decimated_sphere_uv_and_normal_error() {
|
||||||
|
use crate::math::primitives;
|
||||||
|
let r = 0.55;
|
||||||
|
let geo = primitives::uv_sphere(r, 32, 20);
|
||||||
|
let levels = geo.generate_lod_levels(3);
|
||||||
|
let analytical = |p: [f32; 3]| -> [f32; 2] {
|
||||||
|
let mut theta = p[2].atan2(p[0]);
|
||||||
|
if theta < 0.0 {
|
||||||
|
theta += std::f32::consts::TAU;
|
||||||
|
}
|
||||||
|
let phi = (p[1] / r).clamp(-1.0, 1.0).acos();
|
||||||
|
[theta / std::f32::consts::TAU, phi / std::f32::consts::PI]
|
||||||
|
};
|
||||||
|
let udist = |a: f32, b: f32| {
|
||||||
|
let d = (a - b).abs();
|
||||||
|
d.min(1.0 - d)
|
||||||
|
};
|
||||||
|
// L0 is the untouched source (its seam keeps u = 0 by design) — check L1/L2 only.
|
||||||
|
for (i, lvl) in levels.iter().enumerate().filter(|(i, _)| *i > 0) {
|
||||||
|
let uvs = lvl.uvs.as_ref().expect("uvs");
|
||||||
|
let (mut max_e, mut sum_e) = (0.0f32, 0.0f32);
|
||||||
|
for (p, uv) in lvl.positions.iter().zip(uvs.iter()) {
|
||||||
|
let a = analytical(*p);
|
||||||
|
let e = if (p[1] / r).abs() > 0.999 {
|
||||||
|
// Pole: a single point, the parameterization degenerates there — u is
|
||||||
|
// arbitrary (even `atan2` flips on the ±0.0 of a pole vertex), check v only.
|
||||||
|
(uv[1] - a[1]).abs()
|
||||||
|
} else {
|
||||||
|
udist(uv[0], a[0]).max((uv[1] - a[1]).abs())
|
||||||
|
};
|
||||||
|
max_e = max_e.max(e);
|
||||||
|
sum_e += e;
|
||||||
|
}
|
||||||
|
let mean_e = sum_e / uvs.len() as f32;
|
||||||
|
assert!(
|
||||||
|
max_e < 0.25,
|
||||||
|
"L{i}: max UV error {max_e:.4} vs the analytical parameterization"
|
||||||
|
);
|
||||||
|
assert!(mean_e < 0.05, "L{i}: mean UV error {mean_e:.4}");
|
||||||
|
let norms = lvl.normals.as_ref().expect("normals");
|
||||||
|
let mut max_n = 0.0f32;
|
||||||
|
for (p, nrm) in lvl.positions.iter().zip(norms.iter()) {
|
||||||
|
let rl = (p[0] * p[0] + p[1] * p[1] + p[2] * p[2]).sqrt();
|
||||||
|
let dot = ((p[0] * nrm[0] + p[1] * nrm[1] + p[2] * nrm[2]) / rl).clamp(-1.0, 1.0);
|
||||||
|
max_n = max_n.max((1.0 - dot).max(0.0));
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
max_n < 0.1,
|
||||||
|
"L{i}: normals must point outward (max deviation {max_n:.4})"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1543,10 +1761,53 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A cylinder has two UV charts at the same ring positions (side band: v on the 0/1
|
||||||
|
/// lines; caps: a disc around (0.5, 0.5)). The attribute-aware weld keeps the
|
||||||
|
/// duplicates separate, so decimating must never blend the charts together.
|
||||||
#[test]
|
#[test]
|
||||||
fn welded_uv_first_encountered_wins() {
|
fn decimated_cylinder_keeps_charts() {
|
||||||
// Seam: the same position with different UVs — the first-encountered UV wins.
|
use crate::math::primitives;
|
||||||
let geo = Geometry::new(vec![
|
let cyl = primitives::cylinder(0.4, 0.9, 8);
|
||||||
|
let out = cyl.decimated(16);
|
||||||
|
assert!(out.validate().is_ok());
|
||||||
|
let uvs = out.uvs.as_ref().expect("uvs");
|
||||||
|
let mut cap_chart = false;
|
||||||
|
let mut side_chart = false;
|
||||||
|
for (p, uv) in out.positions.iter().zip(uvs.iter()) {
|
||||||
|
if p[1].abs() < 0.3 {
|
||||||
|
continue; // mid-height side vertices — not part of either ring chart
|
||||||
|
}
|
||||||
|
// A collapse blends UVs only along an edge, and no edge ever spans two
|
||||||
|
// charts (the weld kept them in separate vertex groups) — so a ring vertex's
|
||||||
|
// UV must stay inside one chart's region: the cap DISC (interior included —
|
||||||
|
// a blend of two ring points lies inside the disc) or the side v = 0/1 lines.
|
||||||
|
let d2 = (uv[0] - 0.5) * (uv[0] - 0.5) + (uv[1] - 0.5) * (uv[1] - 0.5);
|
||||||
|
let on_vline = uv[1] < 0.05 || uv[1] > 0.95;
|
||||||
|
if d2 <= 0.31 {
|
||||||
|
cap_chart = true; // inside the cap disc (ring or blended interior)
|
||||||
|
} else if on_vline {
|
||||||
|
side_chart = true; // on the v = 0/1 band lines
|
||||||
|
} else {
|
||||||
|
panic!(
|
||||||
|
"chimera vertex at {:?}: uv {:?} belongs to neither chart",
|
||||||
|
p, uv
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
side_chart,
|
||||||
|
"the side chart ring survived (v on the 0/1 lines)"
|
||||||
|
);
|
||||||
|
assert!(cap_chart, "the cap chart ring survived (disc UVs)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The weld is attribute-aware: it merges duplicates only when the UV and normal
|
||||||
|
/// charts agree — a seam (Δuv > ½ tile) or a hard edge (dot ≤ 0.9) stays separate,
|
||||||
|
/// so a collapse can never cross a chart boundary.
|
||||||
|
#[test]
|
||||||
|
fn welded_attribute_aware() {
|
||||||
|
// Seam: same position, different UVs (Δu = 9 > 0.5) → NOT welded.
|
||||||
|
let seam = Geometry::new(vec![
|
||||||
[0.0, 0.0, 0.0],
|
[0.0, 0.0, 0.0],
|
||||||
[2.0, 0.0, 0.0],
|
[2.0, 0.0, 0.0],
|
||||||
[0.0, 2.0, 0.0],
|
[0.0, 2.0, 0.0],
|
||||||
@@ -1561,15 +1822,48 @@ mod tests {
|
|||||||
[9.0, 9.0],
|
[9.0, 9.0],
|
||||||
])
|
])
|
||||||
.with_indices(vec![0, 1, 2, 3, 4, 2]);
|
.with_indices(vec![0, 1, 2, 3, 4, 2]);
|
||||||
let tris = geo.non_degenerate_triangles().unwrap();
|
let tris = seam.non_degenerate_triangles().unwrap();
|
||||||
let (positions, uvs, _colors, faces) = geo.welded(&tris);
|
let (positions, _normals, uvs, _colors, faces) = seam.welded(&tris);
|
||||||
assert_eq!(positions.len(), 4, "the seam duplicate welds into vertex 0");
|
assert_eq!(positions.len(), 5, "the seam duplicate stays separate");
|
||||||
|
assert_eq!(uvs.expect("uvs")[4], [9.0, 9.0], "its chart is preserved");
|
||||||
|
assert_eq!(faces, [[0, 1, 2], [3, 4, 2]]);
|
||||||
|
|
||||||
|
// Same position, same UV, same normal → welded (first-encountered id wins).
|
||||||
|
let same = Geometry::new(vec![
|
||||||
|
[0.0, 0.0, 0.0],
|
||||||
|
[1.0, 0.0, 0.0],
|
||||||
|
[0.0, 1.0, 0.0],
|
||||||
|
[0.0, 0.0, 0.0], // duplicate of vertex 0, identical attributes
|
||||||
|
])
|
||||||
|
.with_uvs(vec![[0.0, 0.0], [0.2, 0.0], [0.0, 0.2], [0.0, 0.0]])
|
||||||
|
.with_normals(vec![[0.0, 0.0, 1.0]; 4])
|
||||||
|
.with_indices(vec![0, 1, 2, 3, 1, 2]);
|
||||||
|
let tris = same.non_degenerate_triangles().unwrap();
|
||||||
|
let (positions, _normals, _uvs, _colors, _faces) = same.welded(&tris);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
uvs.expect("uvs")[0],
|
positions.len(),
|
||||||
[0.0, 0.0],
|
3,
|
||||||
"first-encountered UV wins"
|
"the identical duplicate welds into vertex 0"
|
||||||
);
|
);
|
||||||
assert_eq!(faces, [[0, 1, 2], [3, 0, 2]]);
|
|
||||||
|
// Same position + UV, different normal (hard edge, dot = 0 ≤ 0.9) → NOT welded.
|
||||||
|
let hard = Geometry::new(vec![
|
||||||
|
[0.0, 0.0, 0.0],
|
||||||
|
[1.0, 0.0, 0.0],
|
||||||
|
[0.0, 1.0, 0.0],
|
||||||
|
[0.0, 0.0, 0.0], // duplicate of vertex 0, perpendicular normal
|
||||||
|
])
|
||||||
|
.with_uvs(vec![[0.0, 0.0], [0.2, 0.0], [0.0, 0.2], [0.0, 0.0]])
|
||||||
|
.with_normals(vec![
|
||||||
|
[0.0, 0.0, 1.0],
|
||||||
|
[0.0, 0.0, 1.0],
|
||||||
|
[0.0, 0.0, 1.0],
|
||||||
|
[1.0, 0.0, 0.0],
|
||||||
|
])
|
||||||
|
.with_indices(vec![0, 1, 2, 3, 1, 2]);
|
||||||
|
let tris = hard.non_degenerate_triangles().unwrap();
|
||||||
|
let (positions, _normals, _uvs, _colors, _faces) = hard.welded(&tris);
|
||||||
|
assert_eq!(positions.len(), 4, "the hard-edge duplicate stays separate");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user