1use crate::camera_projection::CameraProjection;
4use crate::tile_manager::TileTextureRegion;
5use rustial_math::{ElevationGrid, TileId};
6use std::sync::Arc;
7
8pub fn skirt_height(zoom: u8, exaggeration: f64) -> f64 {
23 6.0 * 1.5_f64.powi(22 - zoom as i32) * exaggeration.max(1.0)
24}
25
26fn tile_vertex_geo(tile: &TileId, u: f64, v: f64) -> rustial_math::GeoCoord {
27 let nw = rustial_math::tile_to_geo(tile);
28 let se = rustial_math::tile_xy_to_geo(tile.zoom, tile.x as f64 + 1.0, tile.y as f64 + 1.0);
29 let lat = nw.lat + (se.lat - nw.lat) * v;
30 let lon = nw.lon + (se.lon - nw.lon) * u;
31 rustial_math::GeoCoord::from_lat_lon(lat, lon)
32}
33
34fn project_tile_vertex(
35 projection: CameraProjection,
36 tile: &TileId,
37 u: f64,
38 v: f64,
39 altitude: f64,
40) -> [f64; 3] {
41 let mut geo = tile_vertex_geo(tile, u, v);
42 geo.alt = altitude;
43 projection.project_position(&geo)
44}
45
46#[derive(Debug, Clone)]
48pub struct TerrainElevationTexture {
49 pub width: u32,
51 pub height: u32,
53 pub min_elev: f32,
55 pub max_elev: f32,
57 pub data: Arc<Vec<f32>>,
63}
64
65pub fn elevation_region_in_texture_space(
74 region: TileTextureRegion,
75 width: u32,
76 height: u32,
77) -> TileTextureRegion {
78 if width < 4 || height < 4 {
79 return region;
80 }
81
82 let map_axis = |min: f32, max: f32, extent: u32| {
83 let denom = (extent - 1) as f32;
84 let interior_span = (extent - 3) as f32;
85 (
86 (1.0 + min * interior_span) / denom,
87 (1.0 + max * interior_span) / denom,
88 )
89 };
90
91 let (u_min, u_max) = map_axis(region.u_min, region.u_max, width);
92 let (v_min, v_max) = map_axis(region.v_min, region.v_max, height);
93 TileTextureRegion {
94 u_min,
95 v_min,
96 u_max,
97 v_max,
98 }
99}
100
101#[derive(Debug, Clone)]
103pub struct TerrainMeshData {
104 pub tile: TileId,
106 pub elevation_source_tile: TileId,
111 pub elevation_region: TileTextureRegion,
117 pub positions: Vec<[f64; 3]>,
123 pub uvs: Vec<[f32; 2]>,
125 pub normals: Vec<[f32; 3]>,
130 pub indices: Vec<u32>,
132 pub generation: u64,
139 pub grid_resolution: u16,
141 pub vertical_exaggeration: f32,
143 pub elevation_texture: Option<TerrainElevationTexture>,
145}
146
147pub fn build_terrain_descriptor(
151 tile: &TileId,
152 elevation: &ElevationGrid,
153 resolution: u16,
154 exaggeration: f64,
155 generation: u64,
156) -> TerrainMeshData {
157 build_terrain_descriptor_with_source(
158 tile,
159 *tile,
160 TileTextureRegion::FULL,
161 elevation,
162 resolution,
163 exaggeration,
164 generation,
165 )
166}
167
168pub fn build_terrain_descriptor_with_source(
170 tile: &TileId,
171 elevation_source_tile: TileId,
172 elevation_region: TileTextureRegion,
173 elevation: &ElevationGrid,
174 resolution: u16,
175 exaggeration: f64,
176 generation: u64,
177) -> TerrainMeshData {
178 let (min_elev, max_elev) = subregion_min_max(elevation, &elevation_region);
183
184 TerrainMeshData {
185 tile: *tile,
186 elevation_source_tile,
187 elevation_region,
188 positions: Vec::new(),
189 uvs: Vec::new(),
190 normals: Vec::new(),
191 indices: Vec::new(),
192 generation,
193 grid_resolution: resolution,
194 vertical_exaggeration: exaggeration as f32,
195 elevation_texture: Some(TerrainElevationTexture {
196 width: elevation.width,
197 height: elevation.height,
198 min_elev,
199 max_elev,
200 data: Arc::new(elevation.data.clone()),
201 }),
202 }
203}
204
205fn subregion_min_max(elevation: &ElevationGrid, region: &TileTextureRegion) -> (f32, f32) {
211 let w = elevation.width as usize;
212 let h = elevation.height as usize;
213 if w <= 2 || h <= 2 || elevation.data.is_empty() {
214 return (elevation.min_elev, elevation.max_elev);
215 }
216
217 let (interior_w, interior_h, offset) = if w >= 4 && h >= 4 {
220 (w - 2, h - 2, 1usize)
223 } else {
224 (w, h, 0)
225 };
226
227 let x0 = (region.u_min as f64 * interior_w as f64).floor() as usize + offset;
230 let x1 = ((region.u_max as f64 * interior_w as f64).ceil() as usize + offset).min(w);
231 let y0 = (region.v_min as f64 * interior_h as f64).floor() as usize + offset;
232 let y1 = ((region.v_max as f64 * interior_h as f64).ceil() as usize + offset).min(h);
233
234 let mut lo = f32::MAX;
235 let mut hi = f32::MIN;
236 for y in y0..y1 {
237 let row_start = y * w;
238 for x in x0..x1 {
239 let v = elevation.data[row_start + x];
240 if v < lo { lo = v; }
241 if v > hi { hi = v; }
242 }
243 }
244
245 if lo > hi {
246 (elevation.min_elev, elevation.max_elev)
247 } else {
248 (lo, hi)
249 }
250}
251
252pub fn materialize_terrain_mesh(
256 mesh: &TerrainMeshData,
257 projection: CameraProjection,
258 skirt_depth: f64,
259) -> TerrainMeshData {
260 if !mesh.positions.is_empty() {
261 return mesh.clone();
262 }
263
264 let Some(elevation_texture) = mesh.elevation_texture.as_ref() else {
265 return mesh.clone();
266 };
267 let Some(elevation) = ElevationGrid::from_data(
268 mesh.tile,
269 elevation_texture.width,
270 elevation_texture.height,
271 elevation_texture.data.to_vec(),
272 ) else {
273 return mesh.clone();
274 };
275
276 build_terrain_mesh_with_source(
277 &mesh.tile,
278 mesh.elevation_source_tile,
279 mesh.elevation_region,
280 &elevation,
281 projection,
282 mesh.grid_resolution,
283 mesh.vertical_exaggeration as f64,
284 skirt_depth,
285 mesh.generation,
286 )
287}
288
289pub fn build_terrain_mesh(
296 tile: &TileId,
297 elevation: &ElevationGrid,
298 projection: CameraProjection,
299 resolution: u16,
300 exaggeration: f64,
301 skirt_depth: f64,
302 generation: u64,
303) -> TerrainMeshData {
304 build_terrain_mesh_with_source(
305 tile,
306 *tile,
307 TileTextureRegion::FULL,
308 elevation,
309 projection,
310 resolution,
311 exaggeration,
312 skirt_depth,
313 generation,
314 )
315}
316
317pub fn build_terrain_mesh_with_source(
319 tile: &TileId,
320 elevation_source_tile: TileId,
321 elevation_region: TileTextureRegion,
322 elevation: &ElevationGrid,
323 projection: CameraProjection,
324 resolution: u16,
325 exaggeration: f64,
326 skirt_depth: f64,
327 generation: u64,
328) -> TerrainMeshData {
329 let res = resolution as usize;
330 let vertex_count = res * res;
331 let mut positions = Vec::with_capacity(vertex_count);
332 let mut uvs = Vec::with_capacity(vertex_count);
333 let sample_region = if *tile != elevation_source_tile {
334 elevation_region_in_texture_space(
335 elevation_region,
336 elevation.width,
337 elevation.height,
338 )
339 } else {
340 elevation_region
341 };
342
343 for row in 0..res {
345 for col in 0..res {
346 let u = col as f64 / (res - 1).max(1) as f64;
347 let v = row as f64 / (res - 1).max(1) as f64;
348 let sample_u = sample_region.u_min as f64
349 + (sample_region.u_max - sample_region.u_min) as f64 * u;
350 let sample_v = sample_region.v_min as f64
351 + (sample_region.v_max - sample_region.v_min) as f64 * v;
352
353 let raw_elev = elevation
354 .sample(sample_u, sample_v)
355 .unwrap_or(0.0)
356 .clamp(-500.0, 10_000.0);
357 let elev = raw_elev as f64 * exaggeration;
358
359 positions.push(project_tile_vertex(projection, tile, u, v, elev));
360 uvs.push([u as f32, v as f32]);
361 }
362 }
363
364 let quad_count = (res - 1) * (res - 1);
366 let mut indices = Vec::with_capacity(quad_count * 6);
367 for row in 0..(res - 1) {
368 for col in 0..(res - 1) {
369 let tl = (row * res + col) as u32;
370 let tr = tl + 1;
371 let bl = ((row + 1) * res + col) as u32;
372 let br = bl + 1;
373 indices.push(tl);
374 indices.push(bl);
375 indices.push(tr);
376 indices.push(tr);
377 indices.push(bl);
378 indices.push(br);
379 }
380 }
381
382 let mut normals = vec![[0.0f32; 3]; vertex_count];
384 for row in 0..res {
385 for col in 0..res {
386 let idx = row * res + col;
387 let left = positions[if col > 0 { idx - 1 } else { idx }];
388 let right = positions[if col < res - 1 { idx + 1 } else { idx }];
389 let down = positions[if row < res - 1 { idx + res } else { idx }];
390 let up = positions[if row > 0 { idx - res } else { idx }];
391
392 let tangent_x = [
393 (right[0] - left[0]) as f32,
394 (right[1] - left[1]) as f32,
395 (right[2] - left[2]) as f32,
396 ];
397 let tangent_y = [
398 (up[0] - down[0]) as f32,
399 (up[1] - down[1]) as f32,
400 (up[2] - down[2]) as f32,
401 ];
402
403 let nx = tangent_y[1] * tangent_x[2] - tangent_y[2] * tangent_x[1];
404 let ny = tangent_y[2] * tangent_x[0] - tangent_y[0] * tangent_x[2];
405 let nz = tangent_y[0] * tangent_x[1] - tangent_y[1] * tangent_x[0];
406 let len = (nx * nx + ny * ny + nz * nz).sqrt();
407 normals[idx] = if len > 1e-6 {
408 let mut normal = [nx / len, ny / len, nz / len];
409 if normal[2] < 0.0 {
410 normal = [-normal[0], -normal[1], -normal[2]];
411 }
412 normal
413 } else {
414 [0.0, 0.0, 1.0]
415 };
416 }
417 }
418
419 if skirt_depth > 0.0 {
420 add_skirt(
421 &mut positions,
422 &mut uvs,
423 &mut normals,
424 &mut indices,
425 res,
426 skirt_depth,
427 );
428 }
429
430 TerrainMeshData {
431 tile: *tile,
432 elevation_source_tile,
433 elevation_region,
434 positions,
435 uvs,
436 normals,
437 indices,
438 generation,
439 grid_resolution: resolution,
440 vertical_exaggeration: exaggeration as f32,
441 elevation_texture: Some(TerrainElevationTexture {
442 width: elevation.width,
443 height: elevation.height,
444 min_elev: elevation.min_elev,
445 max_elev: elevation.max_elev,
446 data: Arc::new(elevation.data.clone()),
447 }),
448 }
449}
450
451fn add_skirt(
452 positions: &mut Vec<[f64; 3]>,
453 uvs: &mut Vec<[f32; 2]>,
454 normals: &mut Vec<[f32; 3]>,
455 indices: &mut Vec<u32>,
456 res: usize,
457 skirt_depth: f64,
458) {
459 let min_z = positions[..res * res]
461 .iter()
462 .map(|p| p[2])
463 .fold(f64::INFINITY, f64::min);
464 let skirt_z = min_z - skirt_depth;
465
466 let edges: [(&[f32; 3], Vec<usize>); 4] = [
472 (&[0.0, 1.0, 0.0], (0..res).collect()),
474 (&[0.0, -1.0, 0.0], ((res - 1) * res..res * res).collect()),
476 (&[-1.0, 0.0, 0.0], (0..res).map(|r| r * res).collect()),
478 (&[1.0, 0.0, 0.0], (0..res).map(|r| r * res + res - 1).collect()),
480 ];
481
482 for (normal, edge) in &edges {
483 for i in 0..edge.len() - 1 {
484 let a = edge[i] as u32;
485 let b = edge[i + 1] as u32;
486
487 let base_a = positions.len() as u32;
488 let base_b = base_a + 1;
489
490 let pa = positions[edge[i]];
492 positions.push([pa[0], pa[1], skirt_z]);
493 uvs.push(uvs[edge[i]]);
494 normals.push(**normal);
495
496 let pb = positions[edge[i + 1]];
498 positions.push([pb[0], pb[1], skirt_z]);
499 uvs.push(uvs[edge[i + 1]]);
500 normals.push(**normal);
501
502 indices.push(a);
504 indices.push(base_a);
505 indices.push(b);
506 indices.push(b);
507 indices.push(base_a);
508 indices.push(base_b);
509 }
510 }
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516 use crate::camera_projection::CameraProjection;
517
518 #[test]
519 fn flat_mesh_z_zero() {
520 let tile = TileId::new(10, 100, 100);
521 let elev = ElevationGrid::flat(tile, 4, 4);
522 let mesh = build_terrain_mesh(&tile, &elev, CameraProjection::WebMercator, 4, 1.0, 0.0, 0);
523
524 assert_eq!(mesh.positions.len(), 16);
526 assert_eq!(mesh.indices.len(), 54);
527
528 for pos in &mesh.positions {
530 assert!((pos[2] - 0.0).abs() < 1e-6);
531 }
532 }
533
534 #[test]
535 fn sloped_mesh_z_nonzero() {
536 let tile = TileId::new(10, 100, 100);
537 let data = vec![0.0, 100.0, 200.0, 300.0];
538 let elev = ElevationGrid::from_data(tile, 2, 2, data).unwrap();
539 let mesh = build_terrain_mesh(&tile, &elev, CameraProjection::WebMercator, 2, 1.0, 0.0, 0);
540
541 assert!((mesh.positions[0][2] - 0.0).abs() < 1e-6);
543 assert!((mesh.positions[1][2] - 100.0).abs() < 1e-6);
544 }
545
546 #[test]
547 fn exaggeration() {
548 let tile = TileId::new(10, 100, 100);
549 let data = vec![100.0; 4];
550 let elev = ElevationGrid::from_data(tile, 2, 2, data).unwrap();
551 let mesh = build_terrain_mesh(&tile, &elev, CameraProjection::WebMercator, 2, 3.0, 0.0, 0);
552
553 for pos in &mesh.positions {
554 assert!((pos[2] - 300.0).abs() < 1e-6);
555 }
556 }
557
558 #[test]
559 fn skirt_adds_vertices() {
560 let tile = TileId::new(10, 100, 100);
561 let elev = ElevationGrid::flat(tile, 4, 4);
562 let mesh_no_skirt = build_terrain_mesh(&tile, &elev, CameraProjection::WebMercator, 4, 1.0, 0.0, 0);
563 let mesh_with_skirt = build_terrain_mesh(&tile, &elev, CameraProjection::WebMercator, 4, 1.0, 100.0, 0);
564
565 assert!(mesh_with_skirt.positions.len() > mesh_no_skirt.positions.len());
566 assert!(mesh_with_skirt.indices.len() > mesh_no_skirt.indices.len());
567 }
568
569 #[test]
570 fn normals_flat_point_up() {
571 let tile = TileId::new(10, 100, 100);
572 let elev = ElevationGrid::flat(tile, 4, 4);
573 let mesh = build_terrain_mesh(&tile, &elev, CameraProjection::WebMercator, 4, 1.0, 0.0, 0);
574
575 for n in &mesh.normals {
577 assert!(n[2] > 0.99);
578 }
579 }
580
581 #[test]
582 fn adjacent_tiles_share_edge_positions() {
583 let left = TileId::new(10, 100, 100);
586 let right = TileId::new(10, 101, 100);
587
588 let elev_l = ElevationGrid::flat(left, 4, 4);
589 let elev_r = ElevationGrid::flat(right, 4, 4);
590
591 let mesh_l = build_terrain_mesh(&left, &elev_l, CameraProjection::WebMercator, 4, 1.0, 0.0, 0);
592 let mesh_r = build_terrain_mesh(&right, &elev_r, CameraProjection::WebMercator, 4, 1.0, 0.0, 0);
593
594 let res = 4;
597 for row in 0..res {
598 let l_idx = row * res + (res - 1); let r_idx = row * res; let l_pos = mesh_l.positions[l_idx];
602 let r_pos = mesh_r.positions[r_idx];
603
604 assert!(
605 (l_pos[0] - r_pos[0]).abs() < 1e-6
606 && (l_pos[1] - r_pos[1]).abs() < 1e-6
607 && (l_pos[2] - r_pos[2]).abs() < 1e-6,
608 "row {row}: left right-edge {l_pos:?} != right left-edge {r_pos:?}",
609 );
610 }
611 }
612
613 #[test]
614 fn adjacent_tiles_share_vertical_edge() {
615 let upper = TileId::new(10, 100, 100);
618 let lower = TileId::new(10, 100, 101);
619
620 let elev_u = ElevationGrid::flat(upper, 4, 4);
621 let elev_d = ElevationGrid::flat(lower, 4, 4);
622
623 let mesh_u = build_terrain_mesh(&upper, &elev_u, CameraProjection::WebMercator, 4, 1.0, 0.0, 0);
624 let mesh_d = build_terrain_mesh(&lower, &elev_d, CameraProjection::WebMercator, 4, 1.0, 0.0, 0);
625
626 let res = 4;
627 for col in 0..res {
628 let u_idx = (res - 1) * res + col; let d_idx = col; let u_pos = mesh_u.positions[u_idx];
632 let d_pos = mesh_d.positions[d_idx];
633
634 assert!(
635 (u_pos[0] - d_pos[0]).abs() < 1e-6
636 && (u_pos[1] - d_pos[1]).abs() < 1e-6
637 && (u_pos[2] - d_pos[2]).abs() < 1e-6,
638 "col {col}: upper bottom-edge {u_pos:?} != lower top-edge {d_pos:?}",
639 );
640 }
641 }
642
643 #[test]
644 fn adjacent_tiles_share_edge_with_elevation() {
645 let left = TileId::new(10, 100, 100);
649 let right = TileId::new(10, 101, 100);
650
651 let res: u16 = 4;
652 let w = res as u32;
653 let h = res as u32;
654
655 let data_l: Vec<f32> = (0..h)
657 .flat_map(|_row| (0..w).map(|col| col as f32 / (w - 1) as f32 * 100.0))
658 .collect();
659 let elev_l = ElevationGrid::from_data(left, w, h, data_l).unwrap();
660
661 let data_r: Vec<f32> = (0..h)
663 .flat_map(|_row| {
664 (0..w).map(|col| 100.0 + col as f32 / (w - 1) as f32 * 100.0)
665 })
666 .collect();
667 let elev_r = ElevationGrid::from_data(right, w, h, data_r).unwrap();
668
669 let mesh_l = build_terrain_mesh(&left, &elev_l, CameraProjection::WebMercator, res, 1.0, 0.0, 0);
670 let mesh_r = build_terrain_mesh(&right, &elev_r, CameraProjection::WebMercator, res, 1.0, 0.0, 0);
671
672 let r = res as usize;
673 for row in 0..r {
674 let l_idx = row * r + (r - 1);
675 let r_idx = row * r;
676
677 let l_z = mesh_l.positions[l_idx][2];
678 let r_z = mesh_r.positions[r_idx][2];
679
680 assert!(
681 (l_z - r_z).abs() < 1e-3,
682 "row {row}: left right-edge Z={l_z:.4} != right left-edge Z={r_z:.4}",
683 );
684 }
685 }
686
687 #[test]
688 fn skirt_drops_to_absolute_base() {
689 let tile = TileId::new(10, 100, 100);
693 let data = vec![0.0, 100.0, 200.0, 300.0]; let elev = ElevationGrid::from_data(tile, 2, 2, data).unwrap();
695 let skirt_depth = 50.0;
696 let mesh = build_terrain_mesh(&tile, &elev, CameraProjection::WebMercator, 2, 1.0, skirt_depth, 0);
697
698 let surface_count = 4; let skirt_vertices = &mesh.positions[surface_count..];
702 assert!(!skirt_vertices.is_empty(), "should have skirt vertices");
703
704 let expected_z = -50.0;
705 for (i, sv) in skirt_vertices.iter().enumerate() {
706 assert!(
707 (sv[2] - expected_z).abs() < 1e-6,
708 "skirt vertex {i}: Z={:.4}, expected {expected_z:.4}",
709 sv[2],
710 );
711 }
712 }
713
714 #[test]
715 fn adjacent_tile_skirts_overlap() {
716 let right = TileId::new(10, 101, 100);
717
718 let res: u16 = 4;
719 let w = res as u32;
720 let h = res as u32;
721
722 let data_r = vec![1000.0f32; (w * h) as usize];
724 let elev_r = ElevationGrid::from_data(right, w, h, data_r).unwrap();
725
726 let mesh_r_deep = build_terrain_mesh(&right, &elev_r, CameraProjection::WebMercator, res, 1.0, 1200.0, 0);
730
731 let surface_count = (res as usize) * (res as usize);
732 let skirt_z_r: Vec<f64> = mesh_r_deep.positions[surface_count..]
733 .iter()
734 .map(|p| p[2])
735 .collect();
736 assert!(
737 skirt_z_r.iter().all(|&z| z < 0.0),
738 "right tile skirt should extend below left tile surface (Z=0)"
739 );
740 }
741
742 #[test]
743 fn equirectangular_projection_changes_xy_positions() {
744 let tile = TileId::new(3, 4, 2);
745 let elev = ElevationGrid::flat(tile, 4, 4);
746 let merc = build_terrain_mesh(&tile, &elev, CameraProjection::WebMercator, 4, 1.0, 0.0, 0);
747 let eq = build_terrain_mesh(&tile, &elev, CameraProjection::Equirectangular, 4, 1.0, 0.0, 0);
748
749 assert_eq!(merc.positions.len(), eq.positions.len());
750 let different_xy = merc
751 .positions
752 .iter()
753 .zip(eq.positions.iter())
754 .any(|(a, b)| (a[0] - b[0]).abs() > 1.0 || (a[1] - b[1]).abs() > 1.0);
755 assert!(different_xy);
756 }
757
758 #[test]
759 fn elevation_region_maps_to_bordered_texture_space() {
760 let full = elevation_region_in_texture_space(TileTextureRegion::FULL, 6, 6);
761 assert!((full.u_min - 0.2).abs() < 1e-6);
762 assert!((full.v_min - 0.2).abs() < 1e-6);
763 assert!((full.u_max - 0.8).abs() < 1e-6);
764 assert!((full.v_max - 0.8).abs() < 1e-6);
765
766 let quarter = elevation_region_in_texture_space(
767 TileTextureRegion {
768 u_min: 0.0,
769 v_min: 0.0,
770 u_max: 0.5,
771 v_max: 0.5,
772 },
773 6,
774 6,
775 );
776 assert!((quarter.u_min - 0.2).abs() < 1e-6);
777 assert!((quarter.v_min - 0.2).abs() < 1e-6);
778 assert!((quarter.u_max - 0.5).abs() < 1e-6);
779 assert!((quarter.v_max - 0.5).abs() < 1e-6);
780 }
781
782 #[test]
783 fn overzoom_child_mesh_samples_only_child_region() {
784 let child = TileId::new(1, 0, 0);
785 let source = TileId::new(0, 0, 0);
786 let data = vec![
787 0.0, 0.0, 0.0, 100.0, 100.0, 100.0,
788 0.0, 0.0, 0.0, 100.0, 100.0, 100.0,
789 0.0, 0.0, 0.0, 100.0, 100.0, 100.0,
790 200.0, 200.0, 200.0, 300.0, 300.0, 300.0,
791 200.0, 200.0, 200.0, 300.0, 300.0, 300.0,
792 200.0, 200.0, 200.0, 300.0, 300.0, 300.0,
793 ];
794 let elev = ElevationGrid::from_data(source, 6, 6, data).unwrap();
795 let mesh = build_terrain_mesh_with_source(
796 &child,
797 source,
798 TileTextureRegion::from_tiles(&child, &source),
799 &elev,
800 CameraProjection::WebMercator,
801 2,
802 1.0,
803 0.0,
804 0,
805 );
806
807 let z_values: Vec<f64> = mesh.positions.iter().map(|p| p[2]).collect();
808 let expected = [0.0, 50.0, 100.0, 150.0];
809 for (actual, expected) in z_values.iter().zip(expected.iter()) {
810 assert!(
811 (actual - expected).abs() < 1e-3,
812 "child mesh should sample the top-left parent subregion, got {z_values:?}"
813 );
814 }
815 }
816}