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:
+413
-119
@@ -378,19 +378,55 @@ impl Geometry {
|
||||
(cx * cx + cy * cy + cz * cz).sqrt() * 0.5
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// Attribute-aware weld (used by [`Self::decimated`]): positions within a small
|
||||
/// relative tolerance are clustered, but a duplicate is merged into a cluster only
|
||||
/// when it also shares the same UV tile region (within half a tile — a full-tile
|
||||
/// jump is a texture seam) and nearly the same normal (dot > 0.9, ≈25° — a larger
|
||||
/// angle is a hard edge). Seam and hard-edge duplicates therefore stay separate, so
|
||||
/// an edge collapse can never cross a texture chart or a hard edge: the decimated
|
||||
/// level keeps the source's per-chart texture and shading layout.
|
||||
fn welded(
|
||||
&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, 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; 4]>>,
|
||||
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 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 colors: Option<Vec<[f32; 4]>> = 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();
|
||||
// Grid cell → welded ids (broad phase; candidates are verified by distance, and
|
||||
// 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);
|
||||
for tri in tris {
|
||||
for &vi in tri.iter() {
|
||||
@@ -425,14 +467,22 @@ impl Geometry {
|
||||
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::<f32>();
|
||||
if d2 <= 3.0 * eps * eps {
|
||||
ni = Some(candidate);
|
||||
break 'search;
|
||||
if let Some(cands) = grid.get(&key) {
|
||||
for &candidate in cands {
|
||||
let cp = &positions[candidate as usize];
|
||||
let d2 = (0..3)
|
||||
.map(|k| (pos[k] - cp[k]) * (pos[k] - cp[k]))
|
||||
.sum::<f32>();
|
||||
if d2 <= 3.0 * eps * eps
|
||||
&& (!attrs_aware
|
||||
|| self.attrs_compatible(
|
||||
vi as usize,
|
||||
rep[candidate as usize],
|
||||
))
|
||||
{
|
||||
ni = Some(candidate);
|
||||
break 'search;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -443,13 +493,17 @@ impl Geometry {
|
||||
None => {
|
||||
let ni = positions.len() as u32;
|
||||
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 {
|
||||
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);
|
||||
grid.entry(c).or_default().push(ni);
|
||||
ni
|
||||
}
|
||||
};
|
||||
@@ -460,7 +514,27 @@ impl Geometry {
|
||||
.chunks_exact(3)
|
||||
.map(|c| [c[0], c[1], c[2]])
|
||||
.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
|
||||
@@ -477,13 +551,12 @@ impl Geometry {
|
||||
/// 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 survivor **moved** by a collapse gets
|
||||
/// its UV/color **re-interpolated** between the two collapsed endpoints at the same λ
|
||||
/// as its new position, so the texture stays attached to the surface and coarsens
|
||||
/// smoothly across levels; a UV seam (endpoints more than half a tile apart) is never
|
||||
/// blended across — the survivor keeps its own value there — and a welded group's
|
||||
/// UV/color is the value of the **first vertex encountered** (deterministic).
|
||||
/// would degenerate are dropped. A survivor **moved** by a collapse gets its
|
||||
/// UV/color/normal **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
|
||||
/// coarsen smoothly across levels; a UV seam (endpoints more than half a tile apart)
|
||||
/// is never blended across — the survivor keeps its own value there — and a welded
|
||||
/// group's attributes are the value of the **first vertex encountered** (deterministic).
|
||||
///
|
||||
/// Fallback (never corrupts): target ≥ triangle count, target = 0, malformed input, or a
|
||||
/// 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
|
||||
/// 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).
|
||||
///
|
||||
/// 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 {
|
||||
let Some(tris) = self.non_degenerate_triangles() else {
|
||||
return self.clone();
|
||||
@@ -501,22 +588,31 @@ impl Geometry {
|
||||
}
|
||||
|
||||
// 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() {
|
||||
return self.clone();
|
||||
}
|
||||
|
||||
// Collapse down to `target_triangles` faces (or as close as the topology allows).
|
||||
let mut c = Collapse::new(wpos, wuvs, wcolors, wfaces);
|
||||
let mut pq = c.rebuild_pq();
|
||||
// The orientation is preserved as-is (source winding + source normals, inherited
|
||||
// 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;
|
||||
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();
|
||||
// Heap exhausted (fresh edges are not in it yet) → full rebuild; a dry
|
||||
// strict queue de-escalates to the fallback mode (see above).
|
||||
let rebuilt = c.rebuild_pq(!strict);
|
||||
if rebuilt.is_empty() {
|
||||
break; // best effort: nothing left that can collapse
|
||||
}
|
||||
strict = false;
|
||||
rebuilds += 1;
|
||||
if rebuilds > 4 * t {
|
||||
// Safety valve (pathological mesh: every remaining edge is rejected by
|
||||
@@ -540,8 +636,8 @@ impl Geometry {
|
||||
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
|
||||
if !c.collapse_edge(a, b, strict, &mut pq) {
|
||||
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_positions: Vec<[f32; 3]> = Vec::with_capacity(n);
|
||||
// The collapses moved survivors to optimal points and re-interpolated their
|
||||
// UVs/colors — these tables hold the post-collapse values.
|
||||
let (cuvs, ccolors) = (c.uvs.take(), c.colors.take());
|
||||
// normals/UVs/colors — these tables hold the post-collapse values.
|
||||
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_colors = ccolors.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(normals) = &mut new_normals {
|
||||
normals.push(cnormals.as_ref().unwrap()[i]);
|
||||
}
|
||||
if let Some(uvs) = &mut new_uvs {
|
||||
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 {
|
||||
positions: new_positions,
|
||||
normals,
|
||||
normals: new_normals,
|
||||
uvs: new_uvs,
|
||||
colors: new_colors,
|
||||
indices: Some(new_indices),
|
||||
@@ -669,6 +736,11 @@ impl Geometry {
|
||||
struct Collapse {
|
||||
/// Mutable positions: a collapse target moves to its optimal point.
|
||||
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
|
||||
/// 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
|
||||
@@ -751,9 +823,35 @@ fn tri_sorted(mut t: [u32; 3]) -> [u32; 3] {
|
||||
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 {
|
||||
fn new(
|
||||
pos: Vec<[f32; 3]>,
|
||||
normals: Option<Vec<[f32; 3]>>,
|
||||
uvs: Option<Vec<[f32; 2]>>,
|
||||
colors: Option<Vec<[f32; 4]>>,
|
||||
faces: Vec<[u32; 3]>,
|
||||
@@ -761,6 +859,7 @@ impl Collapse {
|
||||
let n = pos.len();
|
||||
let mut c = Self {
|
||||
pos,
|
||||
normals,
|
||||
uvs,
|
||||
colors,
|
||||
active: vec![true; n],
|
||||
@@ -875,14 +974,20 @@ impl Collapse {
|
||||
|
||||
/// 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<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();
|
||||
for (&key, faces) in &self.edge_faces {
|
||||
let live = faces
|
||||
let live: Vec<u32> = faces
|
||||
.iter()
|
||||
.filter(|&&f| self.faces[f as usize].is_some())
|
||||
.count();
|
||||
if live == 0 || live > 2 {
|
||||
.copied()
|
||||
.filter(|&f| self.faces[f as usize].is_some())
|
||||
.collect();
|
||||
if live.is_empty() || live.len() > 2 {
|
||||
continue;
|
||||
}
|
||||
let a = (key >> 32) as u32;
|
||||
@@ -890,12 +995,34 @@ impl Collapse {
|
||||
if !self.active[a as usize] || !self.active[b as usize] {
|
||||
continue;
|
||||
}
|
||||
if strict && !live.iter().all(|&f| self.face_fully_interior(f)) {
|
||||
continue;
|
||||
}
|
||||
let (cost, _, _) = self.cost_and_point(a, b);
|
||||
pq.push(EdgeCost(cost, a, b));
|
||||
}
|
||||
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`
|
||||
/// 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
|
||||
@@ -907,6 +1034,7 @@ impl Collapse {
|
||||
&mut self,
|
||||
a: u32,
|
||||
b: u32,
|
||||
strict: bool,
|
||||
pq: &mut std::collections::BinaryHeap<EdgeCost>,
|
||||
) -> bool {
|
||||
let live_deg = |v: u32| {
|
||||
@@ -939,6 +1067,12 @@ impl Collapse {
|
||||
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
|
||||
// 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.
|
||||
@@ -997,10 +1131,12 @@ impl Collapse {
|
||||
self.pos[t as usize] = pt;
|
||||
// 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
|
||||
// UVs/colors with the same λ and the texture stays attached to the surface (it
|
||||
// coarsens smoothly across levels instead of jumping). A UV **seam** (endpoints
|
||||
// more than half a tile apart) is never blended across — that would smear the
|
||||
// texture across the seam — so the survivor keeps its own value there.
|
||||
// UVs/colors/normals with the same λ and the texture/shading stay attached to the
|
||||
// surface (they coarsen smoothly across levels instead of jumping). A UV **seam**
|
||||
// (endpoints more than half a tile apart) is never blended across — that would
|
||||
// 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 {
|
||||
let us = uvs[s 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]);
|
||||
}
|
||||
}
|
||||
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];
|
||||
self.quad[t as usize] += qs;
|
||||
self.active[s as usize] = false;
|
||||
@@ -1249,28 +1401,6 @@ mod tests {
|
||||
.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
|
||||
/// edge segments (convex combinations of input positions).
|
||||
fn bbox_inside(input: &Geometry, out: &Geometry) -> bool {
|
||||
@@ -1325,7 +1455,21 @@ mod tests {
|
||||
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");
|
||||
// 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));
|
||||
let in_z = sph
|
||||
.positions
|
||||
@@ -1351,7 +1495,20 @@ mod tests {
|
||||
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");
|
||||
// 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!(
|
||||
out.num_triangles() as u32 <= t / 2,
|
||||
@@ -1453,14 +1610,75 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimated_recomputes_unit_normals() {
|
||||
// Garbage (unnormalized) input normals; output normals must be unit length.
|
||||
let geo = tri_fan().with_normals(vec![[9.0, 9.0, 9.0]; 7]);
|
||||
fn decimated_inherits_source_normals() {
|
||||
// Normals are INHERITED from the source, never recomputed from the faces (the
|
||||
// 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 normals = out.normals.expect("normals recomputed");
|
||||
let normals = out.normals.expect("normals inherited");
|
||||
for n in &normals {
|
||||
let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
|
||||
assert!((len - 1.0).abs() < 1e-5, "normal not unit: {n:?}");
|
||||
assert_eq!(*n, [0.0, 0.0, 1.0], "normal must equal the source's: {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]
|
||||
fn welded_uv_first_encountered_wins() {
|
||||
// Seam: the same position with different UVs — the first-encountered UV wins.
|
||||
let geo = Geometry::new(vec![
|
||||
fn decimated_cylinder_keeps_charts() {
|
||||
use crate::math::primitives;
|
||||
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],
|
||||
[2.0, 0.0, 0.0],
|
||||
[0.0, 2.0, 0.0],
|
||||
@@ -1561,15 +1822,48 @@ mod tests {
|
||||
[9.0, 9.0],
|
||||
])
|
||||
.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");
|
||||
let tris = seam.non_degenerate_triangles().unwrap();
|
||||
let (positions, _normals, uvs, _colors, faces) = seam.welded(&tris);
|
||||
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!(
|
||||
uvs.expect("uvs")[0],
|
||||
[0.0, 0.0],
|
||||
"first-encountered UV wins"
|
||||
positions.len(),
|
||||
3,
|
||||
"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]
|
||||
|
||||
Reference in New Issue
Block a user