Skip to main content

celestialsim/
chunk_mesh.rs

1//! Reference mesh for the per-chunk triangular grid (Phase 2, CEL-62).
2//!
3//! # Canonical vertex order
4//!
5//! Vertices are enumerated in **row-major order** by `(i, j)` where:
6//! - `i` = row index (the `wb` axis), `0 ≤ i ≤ res`
7//! - `j` = column index (the `wc` axis), `0 ≤ j ≤ res − i`
8//!
9//! The linear index `L` for vertex at `(i, j)` is:
10//! ```text
11//! L(i, j) = i*(2*res + 3 − i)/2 + j
12//! ```
13//! (Equivalently: `L(i, j) = i*(res+1) − i*(i−1)/2 + j`.)
14//!
15//! Row `i` starts at `L_row(i) = i*(2*res + 3 − i)/2`, with `res+1−i` vertices.
16//!
17//! Special corner vertices:
18//! - `L = 0`                   → `(i,j) = (0,0)` → corner A: `wa=1, wb=0, wc=0`
19//! - `L = res`                 → `(i,j) = (0,res)` → corner C: `wa=0, wb=0, wc=1`
20//! - `L = verts_per_chunk(res)−1` → `(i,j) = (res,0)` → corner B: `wa=0, wb=1, wc=0`
21//!
22//! `ChunkRealize.slang` (Task 7) must enumerate local vertices in this same order
23//! and decode `L` using the same formula.
24//!
25//! # Vertex storage convention
26//!
27//! Each vertex is stored as `[wa, wb, wc]` — the full barycentric triplet (`wa+wb+wc=1`):
28//! ```text
29//! wa = (res − i − j) as f32 / res as f32
30//! wb = i as f32 / res as f32
31//! wc = j as f32 / res as f32
32//! ```
33//!
34//! # Triangle winding
35//!
36//! Triangles are output in two interleaved passes for each cell `(i, j)` with `i+j < res`:
37//! 1. **Upward triangle**: `L(i,j)`, `L(i,j+1)`, `L(i+1,j)` — always present.
38//! 2. **Downward triangle** (only when `i+j+1 < res`): `L(i+1,j)`, `L(i,j+1)`, `L(i+1,j+1)`.
39//!
40//! Both share the same (uniform) winding, ordered so the OUTWARD surface is front
41//! under `render_mode cull_back` in `terrain_chunk.gdshader`.
42//!
43//! # Perimeter skirts (Phase 3 crack fix)
44//!
45//! Adjacent chunks at different quadtree depths tessellate their shared edge at
46//! different resolutions → T-junctions where heights differ → cracks. To hide them
47//! we append a **skirt**: a ring of extra vertices at the same edge positions as the
48//! interior perimeter, but pushed **radially inward** (toward the planet centre) by a
49//! per-chunk depth, connected to the interior edge by triangles. A crack then shows
50//! skirt terrain instead of a hole.
51//!
52//! ## Skirt vertex numbering (mirrored EXACTLY in `ChunkRealize.slang`)
53//!
54//! Interior verts occupy `L ∈ [0, interior_verts_per_chunk(res))`. Skirt verts are
55//! appended at `L = interior + s`, `s ∈ [0, 3*(res+1))`, with
56//! `edge = s / (res+1)` and `t = s % (res+1)` (the along-edge position `0..=res`):
57//! - `edge 0` (A→B): interior lattice point `(i,j) = (t, 0)`
58//! - `edge 1` (B→C): `(i,j) = (res−t, t)`
59//! - `edge 2` (C→A): `(i,j) = (0, res−t)`
60//!
61//! A skirt vert stores the SAME barycentric as its interior edge vert (so its TEX_UV
62//! samples the same atlas texel and it shades like the edge); the realize shader drops
63//! its *position* inward. Corners are duplicated (each of the 3 edges owns its own
64//! ring), so skirt vertices are always DISTINCT pool entries from the interior edge
65//! verts they mirror.
66//!
67//! ## Skirt triangles
68//!
69//! For each edge and each of the `res` segments `k` (interior verts `E_k`, `E_{k+1}`;
70//! skirt verts `S_k`, `S_{k+1}`) two triangles `T1 = (E_k, E_{k+1}, S_{k+1})`,
71//! `T2 = (E_k, S_{k+1}, S_k)`. The interior triangle adjacent to every perimeter
72//! segment traverses the boundary edge as `E_{k+1} → E_k`; the skirt traverses it the
73//! opposite way (`E_k → E_{k+1}`), so the skirt is consistently oriented with the
74//! interior — i.e. the same front/back class under `cull_back`.
75
76use crate::chunk_descriptors::{interior_verts_per_chunk, verts_per_chunk};
77
78/// Compute the linear vertex index for lattice point `(i, j)` at resolution `res`.
79///
80/// Formula: `L(i, j) = i*(2*res + 3 − i)/2 + j`
81///
82/// Implemented in u64 to avoid u32 overflow for large `res`.
83fn vertex_index(res: u32, i: u32, j: u32) -> i32 {
84    let i = i as u64;
85    let j = j as u64;
86    let res = res as u64;
87    // i*(2*res + 3 - i) is always even:
88    //   i even → even * anything = even
89    //   i odd  → (2*res+3-i) = even+3-odd = odd+odd = even (wait: 2*res is even, 3 is odd,
90    //            so 2*res+3 is odd; odd - odd = even) → odd * even = even ✓
91    let row_start = i * (2 * res + 3 - i) / 2;
92    (row_start + j) as i32
93}
94
95/// Map a skirt `(edge, t)` position to its mirrored interior lattice point `(i, j)`.
96///
97/// The three edges traverse the perimeter `A → B → C → A`, each with positions
98/// `t ∈ [0, res]`:
99/// - `edge 0` (A→B): `(t, 0)`   — `wc = 0`
100/// - `edge 1` (B→C): `(res−t, t)` — `wa = 0`
101/// - `edge 2` (C→A): `(0, res−t)` — `wb = 0`
102///
103/// Must match the `ChunkRealize.slang` skirt decode exactly.
104fn skirt_edge_ij(res: u32, edge: u32, t: u32) -> (u32, u32) {
105    match edge {
106        0 => (t, 0),
107        1 => (res - t, t),
108        _ => (0, res - t),
109    }
110}
111
112/// Generate the triangular grid vertices and triangle indices for a chunk at resolution `res`.
113///
114/// Returns `(vertices, indices)` where:
115/// - `vertices`: exactly `verts_per_chunk(res)` entries — the `interior_verts_per_chunk(res)`
116///   interior grid verts in canonical `(i,j)` row-major order FIRST, then the
117///   `3*(res+1)` perimeter skirt verts (see the module docs). Each is `[wa, wb, wc]`
118///   with `wa+wb+wc=1`; a skirt vert carries the same barycentric as the interior edge
119///   vert it mirrors.
120/// - `indices`: exactly `(res*res + 6*res)*3` values — the `res*res` interior triangles
121///   FIRST, then the `6*res` skirt triangles, all with **uniform winding** (outward =
122///   front) so the surface shader can use backface culling (`render_mode cull_back`).
123///
124/// # Panics
125/// Panics if `res == 0`.
126pub fn chunk_grid(res: u32) -> (Vec<[f32; 3]>, Vec<i32>) {
127    assert!(res > 0, "chunk_grid: res must be > 0");
128
129    let nv = verts_per_chunk(res) as usize;
130    let interior_nv = interior_verts_per_chunk(res) as usize;
131    let nt = (res * res + 6 * res) as usize;
132
133    // --- Vertices: interior grid in row-major (i, j) order, then skirt ring ---
134    let mut verts: Vec<[f32; 3]> = Vec::with_capacity(nv);
135    for i in 0..=res {
136        for j in 0..=(res - i) {
137            let wa = (res - i - j) as f32 / res as f32;
138            let wb = i as f32 / res as f32;
139            let wc = j as f32 / res as f32;
140            verts.push([wa, wb, wc]);
141        }
142    }
143    debug_assert_eq!(verts.len(), interior_nv, "interior vertex count mismatch");
144    // Skirt verts: edge 0/1/2, each t = 0..=res. Same bary as the mirrored interior
145    // edge vert (the realize shader drops the *position* radially inward).
146    for edge in 0..3u32 {
147        for t in 0..=res {
148            let (i, j) = skirt_edge_ij(res, edge, t);
149            let wa = (res - i - j) as f32 / res as f32;
150            let wb = i as f32 / res as f32;
151            let wc = j as f32 / res as f32;
152            verts.push([wa, wb, wc]);
153        }
154    }
155    debug_assert_eq!(verts.len(), nv, "vertex count mismatch");
156
157    // --- Interior triangle indices ---
158    // For each cell (i, j) with i + j < res (uniform winding, outward = front):
159    //   Upward  triangle: L(i,j), L(i,j+1), L(i+1,j)
160    //   Downward triangle (when i+j+1 < res): L(i+1,j), L(i,j+1), L(i+1,j+1)
161    let mut indices: Vec<i32> = Vec::with_capacity(nt * 3);
162    // Uniform winding so backface culling works; the order is chosen so the
163    // OUTWARD-facing surface is front under the conventional `cull_back` (verified
164    // on-screen — the opposite order renders the planet inside-out).
165    for i in 0..res {
166        for j in 0..(res - i) {
167            // Upward triangle (always present for i+j < res)
168            indices.push(vertex_index(res, i, j));
169            indices.push(vertex_index(res, i, j + 1));
170            indices.push(vertex_index(res, i + 1, j));
171
172            // Downward triangle (only when i+j+1 < res), same winding sense.
173            if i + j + 1 < res {
174                indices.push(vertex_index(res, i + 1, j));
175                indices.push(vertex_index(res, i, j + 1));
176                indices.push(vertex_index(res, i + 1, j + 1));
177            }
178        }
179    }
180
181    // --- Skirt triangle indices ---
182    // Per edge, per segment k: interior verts E_k/E_{k+1}, skirt verts S_k/S_{k+1}.
183    // T1 = (E_k, E_{k+1}, S_{k+1}), T2 = (E_k, S_{k+1}, S_k). The interior triangle
184    // adjacent to each perimeter segment traverses the boundary edge E_{k+1}→E_k, so
185    // the skirt traverses E_k→E_{k+1} — opposite, i.e. consistently oriented (same
186    // front/back class) with the interior.
187    let skirt_base = interior_nv as i32;
188    let per_edge = (res + 1) as i32;
189    for edge in 0..3u32 {
190        for k in 0..res {
191            let (ei, ej) = skirt_edge_ij(res, edge, k);
192            let (e1i, e1j) = skirt_edge_ij(res, edge, k + 1);
193            let e_k = vertex_index(res, ei, ej);
194            let e_k1 = vertex_index(res, e1i, e1j);
195            let s_k = skirt_base + edge as i32 * per_edge + k as i32;
196            let s_k1 = skirt_base + edge as i32 * per_edge + (k + 1) as i32;
197
198            // T1: E_k, E_{k+1}, S_{k+1}
199            indices.push(e_k);
200            indices.push(e_k1);
201            indices.push(s_k1);
202            // T2: E_k, S_{k+1}, S_k
203            indices.push(e_k);
204            indices.push(s_k1);
205            indices.push(s_k);
206        }
207    }
208    debug_assert_eq!(indices.len(), nt * 3, "index count mismatch");
209
210    (verts, indices)
211}
212
213/// Build a Godot `ArrayMesh` reference chunk at resolution `res`.
214///
215/// The mesh contains `verts_per_chunk(res)` vertices (barycentric coords stored as
216/// `Vector3(wa, wb, wc)`) and `res*res` triangles, using the canonical `(i,j)` vertex
217/// order from [`chunk_grid`]. This mesh is the template for all chunk MultiMesh instances;
218/// the vertex shader reads actual 3-D positions from the vertex-pool texture via
219/// `INSTANCE_CUSTOM.r` (the slot index).
220///
221/// Mirrors the `reference_triangle` idiom in `crates/celestialsim/src/faces.rs`.
222pub fn reference_chunk_mesh(
223    res: u32,
224    material: &godot::obj::Gd<godot::classes::Material>,
225) -> godot::obj::Gd<godot::classes::ArrayMesh> {
226    use godot::classes::mesh::{ArrayType, PrimitiveType};
227    use godot::classes::ArrayMesh;
228    use godot::prelude::*;
229
230    let (verts_bary, indices_raw) = chunk_grid(res);
231
232    let packed_verts: PackedVector3Array = verts_bary
233        .iter()
234        .map(|&[wa, wb, wc]| Vector3::new(wa, wb, wc))
235        .collect();
236
237    let packed_normals: PackedVector3Array =
238        std::iter::repeat(Vector3::new(0.0, 0.0, 1.0))
239            .take(verts_bary.len())
240            .collect();
241
242    // Phase 4: store each vertex's chunk-local barycentric (wb, wc) in UV so the
243    // fragment shader gets an interpolated per-pixel (u, v) to index the detail
244    // atlas (tx = u*tile_res, ty = v*tile_res). u==wb axis, v==wc axis — matching
245    // ChunkTileBake.slang's texel→bary reconstruction.
246    let packed_uvs: PackedVector2Array = verts_bary
247        .iter()
248        .map(|&[_wa, wb, wc]| Vector2::new(wb, wc))
249        .collect();
250
251    let packed_indices: PackedInt32Array = indices_raw.iter().copied().collect();
252
253    let mut arrays = VarArray::new();
254    arrays.resize(ArrayType::MAX.ord() as usize, &Variant::nil());
255    arrays.set(ArrayType::VERTEX.ord() as usize, &packed_verts.to_variant());
256    arrays.set(ArrayType::NORMAL.ord() as usize, &packed_normals.to_variant());
257    arrays.set(ArrayType::TEX_UV.ord() as usize, &packed_uvs.to_variant());
258    arrays.set(ArrayType::INDEX.ord() as usize, &packed_indices.to_variant());
259
260    let mut mesh = ArrayMesh::new_gd();
261    mesh.add_surface_from_arrays(PrimitiveType::TRIANGLES, &arrays);
262    mesh.surface_set_material(0, material);
263    mesh
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use crate::chunk_descriptors::verts_per_chunk;
270
271    // ── Step 2 (failing tests written before implementation) ─────────────────
272
273    /// Vertex and index counts must match the formulas exactly (interior + skirt).
274    #[test]
275    fn chunk_grid_vertex_and_index_counts() {
276        for res in [1u32, 2, 4, 8, 16] {
277            let (verts, indices) = chunk_grid(res);
278            assert_eq!(
279                verts.len() as u32,
280                verts_per_chunk(res),
281                "res={res}: wrong vertex count (expected {})",
282                verts_per_chunk(res)
283            );
284            // res*res interior triangles + 6*res skirt triangles (2 per segment ×
285            // res segments × 3 edges), each 3 indices.
286            let want = (res * res + 6 * res) * 3;
287            assert_eq!(
288                indices.len() as u32,
289                want,
290                "res={res}: wrong index count (expected {want})"
291            );
292        }
293    }
294
295    /// L=0 must be corner A: [wa=1, wb=0, wc=0].
296    #[test]
297    fn chunk_grid_vertex_0_is_corner_a() {
298        for res in [1u32, 2, 4, 8] {
299            let (verts, _) = chunk_grid(res);
300            assert_eq!(
301                verts[0],
302                [1.0_f32, 0.0, 0.0],
303                "res={res}: vertex 0 must be corner A (wa=1)"
304            );
305        }
306    }
307
308    /// The canonical last INTERIOR vertex (L = interior_verts_per_chunk−1) is
309    /// (i,j)=(res,0) = corner B: [wa=0,wb=1,wc=0]. (The pool's true last vertex is now
310    /// a skirt vert; the interior block must keep its old ordering.)
311    #[test]
312    fn chunk_grid_last_interior_vertex_is_corner_b() {
313        for res in [1u32, 2, 4, 8] {
314            let (verts, _) = chunk_grid(res);
315            let last_interior = verts[interior_verts_per_chunk(res) as usize - 1];
316            assert_eq!(
317                last_interior,
318                [0.0_f32, 1.0, 0.0],
319                "res={res}: last interior vertex must be corner B (wb=1)"
320            );
321        }
322    }
323
324    /// Spot-check specific L↔(i,j) mappings that Task 7's shader must mirror.
325    #[test]
326    fn chunk_grid_canonical_l_mapping() {
327        let res = 4u32;
328        let (verts, _) = chunk_grid(res);
329
330        // L=0: (0,0) → corner A
331        assert_eq!(verts[0], [1.0, 0.0, 0.0], "L=0 must be corner A");
332
333        // L=res: (0,res) → corner C [wa=0, wb=0, wc=1]
334        assert_eq!(verts[res as usize], [0.0, 0.0, 1.0], "L=res must be corner C");
335
336        // L=res+1: (1,0) → [wa=(res-1)/res, wb=1/res, wc=0]
337        let expected_l_1_0 = [(res - 1) as f32 / res as f32, 1.0 / res as f32, 0.0];
338        assert_eq!(
339            verts[(res + 1) as usize],
340            expected_l_1_0,
341            "L=res+1 must be (1,0)"
342        );
343
344        // L = vertex_index(4, 2, 1) = 2*(2*4+3-2)/2 + 1 = 2*9/2 + 1 = 9 + 1 = 10
345        // → (2,1): wa=(4-2-1)/4=0.25, wb=2/4=0.5, wc=1/4=0.25
346        let l_2_1 = vertex_index(res, 2, 1) as usize;
347        assert_eq!(l_2_1, 10, "L(2,1) should be 10 for res=4");
348        assert_eq!(
349            verts[l_2_1],
350            [0.25_f32, 0.5, 0.25],
351            "L(2,1) should be [0.25, 0.5, 0.25]"
352        );
353
354        // L = vertex_index(4, 3, 0) = 3*(2*4+3-3)/2 + 0 = 3*8/2 = 12
355        // → (3,0): wa=(4-3)/4=0.25, wb=3/4=0.75, wc=0
356        let l_3_0 = vertex_index(res, 3, 0) as usize;
357        assert_eq!(l_3_0, 12, "L(3,0) should be 12 for res=4");
358        assert_eq!(
359            verts[l_3_0],
360            [0.25_f32, 0.75, 0.0],
361            "L(3,0) should be [0.25, 0.75, 0.0]"
362        );
363    }
364
365    /// All index values must be in `0..vertex_count`.
366    #[test]
367    fn chunk_grid_indices_in_range() {
368        for res in [1u32, 2, 4, 8, 16] {
369            let (verts, indices) = chunk_grid(res);
370            let nv = verts.len() as i32;
371            for &idx in &indices {
372                assert!(
373                    idx >= 0 && idx < nv,
374                    "res={res}: index {idx} out of range [0, {nv})"
375                );
376            }
377        }
378    }
379
380    /// Every triangle must have 3 distinct vertex indices (no degenerate triangles).
381    #[test]
382    fn chunk_grid_triangles_have_distinct_vertices() {
383        for res in [1u32, 2, 4, 8] {
384            let (_, indices) = chunk_grid(res);
385            for (t, tri) in indices.chunks(3).enumerate() {
386                assert!(
387                    tri[0] != tri[1] && tri[1] != tri[2] && tri[0] != tri[2],
388                    "res={res}: triangle {t} is degenerate: {:?}",
389                    tri
390                );
391            }
392        }
393    }
394
395    /// Barycentric coordinates must satisfy wa+wb+wc=1 and all be in [0,1].
396    #[test]
397    fn chunk_grid_barycentric_coords_valid() {
398        for res in [1u32, 2, 4, 8] {
399            let (verts, _) = chunk_grid(res);
400            for (l, &[wa, wb, wc]) in verts.iter().enumerate() {
401                let sum = wa + wb + wc;
402                assert!(
403                    (sum - 1.0).abs() < 1e-5,
404                    "res={res}, L={l}: wa+wb+wc={sum} ≠ 1"
405                );
406                assert!(wa >= 0.0 && wb >= 0.0 && wc >= 0.0,
407                    "res={res}, L={l}: negative coord [{wa},{wb},{wc}]");
408            }
409        }
410    }
411
412    /// Winding is UNIFORM across every INTERIOR triangle (upward and downward) — the
413    /// invariant that lets the surface shader use `render_mode cull_back` instead
414    /// of `cull_disabled`. A regression to mixed winding would reintroduce the
415    /// ~half-the-mesh-culled bug. (The concrete sign is negative here; the
416    /// outward-vs-inward facing is verified on-screen, not by this test.)
417    ///
418    /// Skirt triangles are degenerate in the (wb, wc) plane (a skirt vert shares its
419    /// edge vert's barycentric), so this 2-D test only covers the interior block;
420    /// [`chunk_grid_consistent_orientation`] checks the skirt's winding combinatorially.
421    #[test]
422    fn chunk_grid_uniform_winding() {
423        let res = 4u32;
424        let (verts, indices) = chunk_grid(res);
425
426        // 2-D signed area in the (wb, wc) plane.  Positive = CCW, negative = CW.
427        let signed_area = |a: usize, b: usize, c: usize| -> f32 {
428            let (wb_a, wc_a) = (verts[a][1], verts[a][2]);
429            let (wb_b, wc_b) = (verts[b][1], verts[b][2]);
430            let (wb_c, wc_c) = (verts[c][1], verts[c][2]);
431            (wb_b - wb_a) * (wc_c - wc_a) - (wb_c - wb_a) * (wc_b - wc_a)
432        };
433
434        // Walk the interior triangles (first res*res of the index buffer), not the
435        // formula, so the test guards the real winding the GPU sees. All must share
436        // the same sign (uniform winding).
437        let interior_tris = (res * res) as usize;
438        for t in indices[..interior_tris * 3].chunks_exact(3) {
439            let (a, b, c) = (t[0] as usize, t[1] as usize, t[2] as usize);
440            let area = signed_area(a, b, c);
441            assert!(area < 0.0, "interior triangle {t:?} must share the uniform winding sign, got {area}");
442        }
443    }
444
445    /// The whole mesh (interior + skirt) is a CONSISTENTLY ORIENTED triangle mesh:
446    /// every undirected edge shared by two triangles is traversed in OPPOSITE
447    /// directions by them. This is exactly the property `cull_back` relies on, and —
448    /// unlike the 2-D area test — it holds for the skirt walls (which are degenerate
449    /// in barycentric space). It also guarantees the skirt is in the SAME orientation
450    /// class as the interior (their shared perimeter edges cancel), so if the interior
451    /// renders front-facing the skirt does too.
452    #[test]
453    fn chunk_grid_consistent_orientation() {
454        use std::collections::HashMap;
455        for res in [1u32, 2, 4, 8, 16] {
456            let (_, indices) = chunk_grid(res);
457            // undirected edge {min,max} -> count of each directed orientation.
458            // +1 for a->b with a<b, -1 for a->b with a>b. A 2-triangle edge must sum to 0.
459            let mut edges: HashMap<(i32, i32), i32> = HashMap::new();
460            let mut uses: HashMap<(i32, i32), u32> = HashMap::new();
461            let mut bump = |a: i32, b: i32, edges: &mut HashMap<(i32, i32), i32>, uses: &mut HashMap<(i32, i32), u32>| {
462                let key = (a.min(b), a.max(b));
463                *uses.entry(key).or_insert(0) += 1;
464                *edges.entry(key).or_insert(0) += if a < b { 1 } else { -1 };
465            };
466            for t in indices.chunks_exact(3) {
467                bump(t[0], t[1], &mut edges, &mut uses);
468                bump(t[1], t[2], &mut edges, &mut uses);
469                bump(t[2], t[0], &mut edges, &mut uses);
470            }
471            for (key, &n) in &uses {
472                assert!(n <= 2, "res={res}: edge {key:?} used {n}× (non-manifold)");
473                if n == 2 {
474                    assert_eq!(
475                        edges[key], 0,
476                        "res={res}: edge {key:?} used by 2 triangles with the SAME orientation \
477                         (inconsistent winding)"
478                    );
479                }
480            }
481        }
482    }
483
484    /// The interior block (`L < interior_verts_per_chunk`) is byte-identical to the
485    /// pre-skirt grid: same verts, same order. The realize `L→(i,j)` decode and the
486    /// GPU readback reference depend on this.
487    #[test]
488    fn chunk_grid_interior_block_unchanged() {
489        for res in [1u32, 2, 4, 8, 16] {
490            let (verts, _) = chunk_grid(res);
491            let interior = interior_verts_per_chunk(res) as usize;
492            // Regenerate the interior independently and compare.
493            let mut expected: Vec<[f32; 3]> = Vec::new();
494            for i in 0..=res {
495                for j in 0..=(res - i) {
496                    expected.push([
497                        (res - i - j) as f32 / res as f32,
498                        i as f32 / res as f32,
499                        j as f32 / res as f32,
500                    ]);
501                }
502            }
503            assert_eq!(&verts[..interior], &expected[..], "res={res}: interior block changed");
504        }
505    }
506
507    /// Each skirt vert carries the barycentric of its mirrored interior edge vert,
508    /// and its (edge, t) decode matches `ChunkRealize.slang`. Edge `e` position `t`
509    /// must lie on edge `e` (the off-edge coordinate is 0).
510    #[test]
511    fn chunk_grid_skirt_verts_have_edge_bary() {
512        for res in [1u32, 2, 4, 8] {
513            let (verts, _) = chunk_grid(res);
514            let base = interior_verts_per_chunk(res) as usize;
515            let per_edge = (res + 1) as usize;
516            for edge in 0..3u32 {
517                for t in 0..=res {
518                    let l = base + edge as usize * per_edge + t as usize;
519                    let [wa, wb, wc] = verts[l];
520                    let (i, j) = super::skirt_edge_ij(res, edge, t);
521                    let exp = [
522                        (res - i - j) as f32 / res as f32,
523                        i as f32 / res as f32,
524                        j as f32 / res as f32,
525                    ];
526                    assert_eq!(verts[l], exp, "res={res} edge={edge} t={t}: skirt bary mismatch");
527                    // Off-edge coordinate is zero: edge0→wc, edge1→wa, edge2→wb.
528                    match edge {
529                        0 => assert_eq!(wc, 0.0, "edge0 must have wc=0"),
530                        1 => assert_eq!(wa, 0.0, "edge1 must have wa=0"),
531                        _ => assert_eq!(wb, 0.0, "edge2 must have wb=0"),
532                    }
533                }
534            }
535        }
536    }
537}