harness.py¶
Part of Procedural Planets.
1"""Procedural Planets: CPU-only harness.
2
3Smoke test for the biome ramp, the quad-sphere mesh builder and the noise
4rebuild budget, all without booting the GPU. Run with:
5
6 uv run python examples/ports/procedural_planets/harness.py
7"""
8
9from __future__ import annotations
10
11import sys
12import time
13from pathlib import Path
14
15_PORT_DIR = Path(__file__).parent
16if str(_PORT_DIR) not in sys.path:
17 sys.path.insert(0, str(_PORT_DIR))
18
19import numpy as np # noqa: E402
20from nodes.colour import biome_percent_array, build_ramp_texture, default_colour_settings # noqa: E402
21from nodes.shape import ( # noqa: E402
22 ShapeGenerator,
23 default_shape_settings,
24)
25from nodes.terrain_face import FACE_DIRECTIONS, build_face_mesh # noqa: E402
26
27
28def _sphere_grid(direction, n: int) -> np.ndarray:
29 up = np.asarray(direction, dtype=np.float32)
30 axis_a = np.array([up[1], up[2], up[0]], dtype=np.float32)
31 axis_b = np.cross(up, axis_a)
32 coords = np.linspace(0.0, 1.0, n, dtype=np.float32) - 0.5
33 yy, xx = np.meshgrid(coords, coords, indexing="ij")
34 cube = (
35 up[None, None, :]
36 + (2.0 * xx)[..., None] * axis_a[None, None, :]
37 + (2.0 * yy)[..., None] * axis_b[None, None, :]
38 ).reshape(-1, 3)
39 return cube / np.maximum(np.linalg.norm(cube, axis=1, keepdims=True), 1e-8)
40
41
42def main() -> None:
43 settings = default_shape_settings()
44 colour = default_colour_settings()
45 shape = ShapeGenerator(settings)
46
47 # 1. Ramp texture builds at expected shape.
48 ramp = build_ramp_texture(colour)
49 assert ramp.shape == (4, 100, 4), ramp.shape
50 assert ramp.dtype == np.uint8
51 print(f"OK: ramp texture {ramp.shape} dtype={ramp.dtype}")
52
53 # 2. Biome percent stays in [0, 1] for an arbitrary face.
54 sphere = _sphere_grid((0, 1, 0), 32)
55 pct = biome_percent_array(sphere, colour.biome)
56 assert pct.min() >= 0.0 and pct.max() <= 1.0, (pct.min(), pct.max())
57 print(f"OK: biome % range [{pct.min():.3f}, {pct.max():.3f}]")
58
59 # 3. Quad-sphere mesh build matches expected vertex count + has UVs.
60 for direction in FACE_DIRECTIONS:
61 # Pre-flight pass to lock min/max BEFORE building the mesh, so UV.y maps cleanly.
62 shape.calculate_unscaled_elevation(_sphere_grid(direction, 32))
63 sample_mesh = build_face_mesh((0, 1, 0), 32, shape, colour.biome)
64 assert sample_mesh.vertex_count == 32 * 32, sample_mesh.vertex_count
65 assert sample_mesh.indices.size == 2 * 31 * 31 * 3, sample_mesh.indices.size
66 assert sample_mesh.texcoords is not None
67 assert sample_mesh.normals is not None
68 print(f"OK: face mesh vertices={sample_mesh.vertex_count} tris={sample_mesh.indices.size // 3}")
69
70 # 4. Timing report: a full six-face rebuild at 128², single-threaded. This is
71 # the budget the slider coalescing in main.py is sized against, so print it
72 # rather than assert a machine-specific threshold.
73 n = 128
74 shape = ShapeGenerator(settings)
75 # Pass 1: preflight every face's elevation.
76 for direction in FACE_DIRECTIONS:
77 shape.calculate_unscaled_elevation(_sphere_grid(direction, n))
78 t0 = time.perf_counter()
79 for direction in FACE_DIRECTIONS:
80 m = build_face_mesh(direction, n, shape, colour.biome)
81 m.generate_normals()
82 elapsed = (time.perf_counter() - t0) * 1000.0
83 print(f"OK: 6-face rebuild at N={n}² took {elapsed:.1f} ms")
84
85 print("harness OK")
86
87
88if __name__ == "__main__":
89 main()