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()