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}