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