nodes/planet.py

Part of Procedural Planets.

  1"""Planet: root 3D node owning 6 cube-face MeshInstance3D children.
  2
  3`regenerate()` rebuilds all 6 face meshes in a single vectorised pass using
  4the current ShapeGenerator + ColourSettings. Designed to be called whenever
  5a slider changes, keep N≤128 for live drag and N≤192 for "release" updates.
  6
  7Two-pass elevation model so UV.y mapping uses the global min/max:
  8  Pass 1: evaluate every face's elevation, accumulate min/max into the
  9          shared ShapeGenerator.
 10  Pass 2: build all six meshes: UV.y now resolves against the final
 11          (min, max) so the ramp texture is stable across faces.
 12"""
 13
 14from __future__ import annotations
 15
 16import time
 17
 18import numpy as np
 19
 20from simvx.core import Material, MeshInstance3D, Node3D, Signal
 21
 22from .colour import (
 23    ColourSettings,
 24    build_ramp_texture,
 25    default_colour_settings,
 26)
 27from .shape import (
 28    ShapeGenerator,
 29    ShapeSettings,
 30    default_shape_settings,
 31)
 32from .terrain_face import FACE_DIRECTIONS, build_face_mesh
 33
 34
 35class Planet(Node3D):
 36    """6-face quad-sphere planet."""
 37
 38    regenerated = Signal()
 39
 40    def __init__(
 41        self,
 42        resolution: int = 64,
 43        shape_settings: ShapeSettings | None = None,
 44        colour_settings: ColourSettings | None = None,
 45        **kwargs,
 46    ) -> None:
 47        super().__init__(**kwargs)
 48        self._resolution = max(2, int(resolution))
 49        self.shape_settings: ShapeSettings = shape_settings or default_shape_settings()
 50        self.colour_settings: ColourSettings = colour_settings or default_colour_settings()
 51        self._shape = ShapeGenerator(self.shape_settings)
 52        # Build the ramp texture once: biome colours don't change at runtime
 53        # (only noise sliders do); the planet shader samples by (biome%, elev%).
 54        self._ramp_pixels: np.ndarray = build_ramp_texture(self.colour_settings)
 55        self._material: Material | None = None
 56        self._faces: list[MeshInstance3D] = []
 57        self._last_regen_ms: float = 0.0
 58
 59    @property
 60    def resolution(self) -> int:
 61        return self._resolution
 62
 63    def set_resolution(self, n: int) -> None:
 64        n = max(2, int(n))
 65        if n != self._resolution:
 66            self._resolution = n
 67            self.regenerate()
 68
 69    @property
 70    def last_regen_ms(self) -> float:
 71        return self._last_regen_ms
 72
 73    def on_ready(self) -> None:
 74        # Single shared Material: albedo_map is the biome ramp.
 75        self._material = Material(
 76            colour=(1.0, 1.0, 1.0, 1.0),
 77            roughness=0.85,
 78            metallic=0.0,
 79            albedo_map=self._ramp_pixels,
 80        )
 81        for direction in FACE_DIRECTIONS:
 82            face = MeshInstance3D(
 83                name=f"Face_{direction[0]:+.0f}{direction[1]:+.0f}{direction[2]:+.0f}",
 84                mesh=None,
 85                material=self._material,
 86            )
 87            self._faces.append(face)
 88            self.add_child(face)
 89        self.regenerate()
 90
 91    # ------------------------------------------------------------------
 92    # Regeneration
 93    # ------------------------------------------------------------------
 94
 95    def regenerate(self) -> None:
 96        """Rebuild all six face meshes from the current settings."""
 97        if not self._faces:
 98            return  # add_child happens in on_ready; skip until then.
 99        t0 = time.perf_counter()
100        self._shape.update_settings(self.shape_settings)
101
102        # PASS 1: preflight elevation eval to lock min/max BEFORE we build
103        # meshes (so UV.y in pass 2 maps against the final [min,max] span).
104        self._shape.reset_elevation_bounds()
105        n = self._resolution
106        for direction in FACE_DIRECTIONS:
107            up = np.asarray(direction, dtype=np.float32)
108            axis_a = np.array([up[1], up[2], up[0]], dtype=np.float32)
109            axis_b = np.cross(up, axis_a)
110            coords = np.linspace(0.0, 1.0, n, dtype=np.float32) - 0.5
111            yy, xx = np.meshgrid(coords, coords, indexing="ij")
112            cube = (
113                up[None, None, :]
114                + (2.0 * xx)[..., None] * axis_a[None, None, :]
115                + (2.0 * yy)[..., None] * axis_b[None, None, :]
116            ).reshape(-1, 3)
117            sphere = cube / np.maximum(np.linalg.norm(cube, axis=1, keepdims=True), 1e-8)
118            self._shape.calculate_unscaled_elevation(sphere)
119
120        # PASS 2: build the actual meshes; build_face_mesh re-evaluates
121        # elevation but UV.y now resolves against the locked [min,max].
122        for face, direction in zip(self._faces, FACE_DIRECTIONS, strict=True):
123            mesh = build_face_mesh(direction, n, self._shape, self.colour_settings.biome)
124            mesh.generate_normals()
125            face.mesh = mesh
126
127        self._last_regen_ms = (time.perf_counter() - t0) * 1000.0
128        self.regenerated.emit()
129
130    def update_shape(self, settings: ShapeSettings) -> None:
131        """Replace shape settings and regenerate."""
132        self.shape_settings = settings
133        self.regenerate()