nodes/track.py

Part of HexGL.

  1"""Procedural anti-grav race track.
  2
  3Built from a closed Catmull-Rom spline through 8 hand-tuned waypoints. Sampled
  4densely at construction time into ``Track.samples`` (centre, tangent, normal,
  5side, bank). The ribbon mesh extrudes a ground strip + low side walls along
  6the curve. Boost pads sit on the ground strip at fixed t-values; checkpoints
  7are virtual (a t-value, not geometry).
  8
  9Public surface:
 10
 11- ``Track(width=14.0, length_segments=400)``: builds samples + meshes.
 12- ``track.sample_at(t) -> (centre, tangent, side, normal, bank_angle)``
 13- ``track.project(world_pos) -> (t_nearest, lateral_offset, height_above_track)``
 14- ``track.checkpoints: list[float]``: 6 evenly spaced t-values.
 15- ``track.boost_pads: list[float]``: 3 t-values where the floor glows red.
 16
 17The samples table is the single source of truth: ship physics, AI follow,
 18and projection all read from it.
 19"""
 20
 21from __future__ import annotations
 22
 23import numpy as np
 24
 25from simvx.core import Material, Mesh, MeshInstance3D, Node3D, Vec3
 26
 27# 8 control points around a closed loop. y rises and falls so the ship
 28# crests two small hills. Loop is roughly 600 m long.
 29_CONTROLS = np.array(
 30    [
 31        (0.0, 0.0, 0.0),
 32        (60.0, 2.0, -30.0),
 33        (110.0, 5.0, -20.0),
 34        (130.0, 8.0, 40.0),
 35        (90.0, 6.0, 100.0),
 36        (10.0, 3.0, 130.0),
 37        (-60.0, 4.0, 90.0),
 38        (-90.0, 1.0, 20.0),
 39    ],
 40    dtype=np.float32,
 41)
 42
 43
 44def _catmull_rom(p0, p1, p2, p3, t):
 45    """Centripetal Catmull-Rom interpolation with tension 0.5 (uniform)."""
 46    t2 = t * t
 47    t3 = t2 * t
 48    return 0.5 * (
 49        (2.0 * p1) + (-p0 + p2) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3
 50    )
 51
 52
 53def _build_centreline(controls: np.ndarray, segments: int) -> np.ndarray:
 54    """Sample N points around the closed Catmull-Rom loop."""
 55    n_ctrl = len(controls)
 56    out = np.empty((segments, 3), dtype=np.float32)
 57    for i in range(segments):
 58        u = (i / segments) * n_ctrl
 59        seg = int(u) % n_ctrl
 60        t = u - int(u)
 61        p0 = controls[(seg - 1) % n_ctrl]
 62        p1 = controls[seg]
 63        p2 = controls[(seg + 1) % n_ctrl]
 64        p3 = controls[(seg + 2) % n_ctrl]
 65        out[i] = _catmull_rom(p0, p1, p2, p3, t)
 66    return out
 67
 68
 69class Track(Node3D):
 70    """Closed procedural race track."""
 71
 72    def __init__(self, width: float = 14.0, length_segments: int = 400, **kwargs) -> None:
 73        super().__init__(**kwargs)
 74        self.width = float(width)
 75        self.length_segments = int(length_segments)
 76
 77        # Build centreline + tangent/side/normal frames + bank angle per sample.
 78        centre = _build_centreline(_CONTROLS, self.length_segments)
 79        # Tangents are forward differences (closed loop).
 80        nxt = np.roll(centre, -1, axis=0)
 81        tangents = nxt - centre
 82        seg_len = np.linalg.norm(tangents, axis=1, keepdims=True)
 83        seg_len = np.maximum(seg_len, 1e-6)
 84        tangents /= seg_len
 85        # World up.
 86        up_world = np.array([0.0, 1.0, 0.0], dtype=np.float32)
 87        # Side = tangent × up, normalised. (right-hand side of travel direction).
 88        side = np.cross(tangents, np.broadcast_to(up_world, tangents.shape))
 89        side /= np.maximum(np.linalg.norm(side, axis=1, keepdims=True), 1e-6)
 90        # Track normal = side × tangent (banked up).
 91        normal = np.cross(side, tangents)
 92        normal /= np.maximum(np.linalg.norm(normal, axis=1, keepdims=True), 1e-6)
 93
 94        # Bank angle: proportional to lateral curvature (change in tangent direction).
 95        nxt_tan = np.roll(tangents, -1, axis=0)
 96        curvature = np.linalg.norm(nxt_tan - tangents, axis=1)
 97        # Sign: positive curvature = turning left (rotate side into tangent forward).
 98        cross_z = np.cross(tangents, nxt_tan)[:, 1]
 99        signed_curv = curvature * np.sign(cross_z + 1e-9)
100        bank_angle = np.clip(signed_curv * 18.0, -0.45, 0.45).astype(np.float32)
101        # Smooth banking: 5-tap moving average (closed-loop).
102        kernel = np.ones(5, dtype=np.float32) / 5.0
103        bank_angle = np.convolve(np.r_[bank_angle[-2:], bank_angle, bank_angle[:2]], kernel, mode="valid")
104
105        self.samples_centre = centre.astype(np.float32)
106        self.samples_tangent = tangents.astype(np.float32)
107        self.samples_side = side.astype(np.float32)
108        self.samples_normal = normal.astype(np.float32)
109        self.samples_bank = bank_angle.astype(np.float32)
110        # Cumulative arc-length for parameterisation (not strictly needed but
111        # surfaces real distance: used for HUD km/h scaling).
112        seg_lengths = np.linalg.norm(np.roll(centre, -1, axis=0) - centre, axis=1)
113        self.total_length = float(seg_lengths.sum())
114
115        # 6 checkpoints evenly spaced; checkpoints[0] is the start/finish line.
116        self.checkpoints = [i / 6.0 for i in range(6)]
117        # 3 boost pads at hand-picked t-values.
118        self.boost_pads = [0.15, 0.50, 0.80]
119        self.boost_pad_radius_t = 0.018  # ~half-segment of touch
120
121        self._floor_mesh: MeshInstance3D | None = None
122        self._wall_mesh: MeshInstance3D | None = None
123        self._boost_meshes: list[MeshInstance3D] = []
124
125    # ------------------------------------------------------------------
126    # Sampling helpers
127    # ------------------------------------------------------------------
128
129    def sample_at(self, t: float) -> tuple[Vec3, Vec3, Vec3, Vec3, float]:
130        """Interpolate centre/tangent/side/normal/bank at parameter t in [0,1)."""
131        n = self.length_segments
132        u = (t % 1.0) * n
133        i = int(u) % n
134        j = (i + 1) % n
135        f = u - int(u)
136        c = self.samples_centre[i] * (1 - f) + self.samples_centre[j] * f
137        tg = self.samples_tangent[i] * (1 - f) + self.samples_tangent[j] * f
138        sd = self.samples_side[i] * (1 - f) + self.samples_side[j] * f
139        nm = self.samples_normal[i] * (1 - f) + self.samples_normal[j] * f
140        bk = self.samples_bank[i] * (1 - f) + self.samples_bank[j] * f
141        return Vec3(c), Vec3(tg), Vec3(sd), Vec3(nm), float(bk)
142
143    def project(self, world_pos: Vec3) -> tuple[float, float, float]:
144        """Find the closest centreline sample to ``world_pos``.
145
146        Returns (t_nearest in [0,1), lateral_offset, height_above_track).
147        Lateral offset is signed: positive = right-of-travel (into samples_side).
148        """
149        p = np.asarray(world_pos, dtype=np.float32)
150        # Vectorised distance² to every sample.
151        diff = self.samples_centre - p[None, :]
152        d2 = np.einsum("ij,ij->i", diff, diff)
153        i = int(np.argmin(d2))
154        # Local frame at i.
155        side = self.samples_side[i]
156        normal = self.samples_normal[i]
157        rel = p - self.samples_centre[i]
158        lateral = float(np.dot(rel, side))
159        height = float(np.dot(rel, normal))
160        t = i / self.length_segments
161        return t, lateral, height
162
163    # ------------------------------------------------------------------
164    # Mesh build (called once on_ready)
165    # ------------------------------------------------------------------
166
167    def on_ready(self) -> None:
168        floor_mesh = self._build_floor_mesh()
169        wall_mesh = self._build_wall_mesh()
170
171        # Floor: dark blue with a faint emissive lane stripe (UV.x ≈ 0.5).
172        floor_pixels = self._build_floor_texture()
173        floor_mat = Material(
174            colour=(1.0, 1.0, 1.0, 1.0),
175            roughness=0.55,
176            metallic=0.05,
177            albedo_map=floor_pixels,
178        )
179        self._floor_mesh = MeshInstance3D(name="TrackFloor", mesh=floor_mesh, material=floor_mat)
180        self.add_child(self._floor_mesh)
181
182        # Walls: matt grey, slightly emissive on the inner edge. Single material.
183        wall_mat = Material(colour=(0.18, 0.20, 0.26, 1.0), roughness=0.85, metallic=0.0)
184        self._wall_mesh = MeshInstance3D(name="TrackWalls", mesh=wall_mesh, material=wall_mat)
185        self.add_child(self._wall_mesh)
186
187        # Boost pads: bright red emissive disks on top of the floor.
188        for t in self.boost_pads:
189            pad_mesh = self._build_boost_pad_mesh(t)
190            mat = Material(
191                colour=(1.0, 0.25, 0.18, 1.0),
192                # emissive_colour packs (R, G, B, intensity) per simvx Material.
193                emissive_colour=(1.0, 0.4, 0.2, 2.5),
194                roughness=0.4,
195                metallic=0.0,
196            )
197            pad = MeshInstance3D(name=f"BoostPad_{t:.2f}", mesh=pad_mesh, material=mat)
198            self.add_child(pad)
199            self._boost_meshes.append(pad)
200
201    def _build_floor_texture(self) -> np.ndarray:
202        """64×64 RGBA uint8: dark cyan with a bright centre stripe + edge lights."""
203        tex = np.zeros((64, 64, 4), dtype=np.uint8)
204        tex[..., 0] = 13  # ~0.05
205        tex[..., 1] = 18  # ~0.07
206        tex[..., 2] = 33  # ~0.13
207        tex[..., 3] = 255
208        # Centre lane stripe (U.x near 0.5).
209        stripe = np.abs(np.arange(64) - 32) < 2
210        tex[:, stripe, 0] = 153
211        tex[:, stripe, 1] = 204
212        tex[:, stripe, 2] = 255
213        # Edge lights.
214        tex[:, :3, 0] = 204
215        tex[:, :3, 2] = 102
216        tex[:, -3:, 0] = 204
217        tex[:, -3:, 2] = 102
218        return tex
219
220    def _build_floor_mesh(self) -> Mesh:
221        """Extrude a flat ribbon along the centreline.
222
223        For each sample i: two vertices at centre ± side*half_width, pushed
224        slightly along the normal by sin(bank) so the strip banks into corners.
225        """
226        n = self.length_segments
227        hw = 0.5 * self.width
228        positions = np.empty((n * 2, 3), dtype=np.float32)
229        normals = np.empty((n * 2, 3), dtype=np.float32)
230        uvs = np.empty((n * 2, 2), dtype=np.float32)
231
232        for i in range(n):
233            c = self.samples_centre[i]
234            side = self.samples_side[i]
235            normal = self.samples_normal[i]
236            bank = self.samples_bank[i]
237            # Bank: rotate the side vector around the tangent by ±bank.
238            cos_b = np.cos(bank)
239            sin_b = np.sin(bank)
240            banked_side = side * cos_b + normal * sin_b
241            banked_normal = normal * cos_b - side * sin_b
242            left = c - banked_side * hw
243            right = c + banked_side * hw
244            positions[2 * i] = left
245            positions[2 * i + 1] = right
246            normals[2 * i] = banked_normal
247            normals[2 * i + 1] = banked_normal
248            v = i / n
249            uvs[2 * i] = (0.0, v)
250            uvs[2 * i + 1] = (1.0, v)
251
252        # Triangle strip → indices, closed loop (last segment wraps to first).
253        idx = []
254        for i in range(n):
255            j = (i + 1) % n
256            a = 2 * i
257            b = 2 * i + 1
258            c = 2 * j
259            d = 2 * j + 1
260            # Quad (a, b, d, c).
261            idx.extend([a, b, d, a, d, c])
262        indices = np.array(idx, dtype=np.uint32)
263
264        return Mesh(positions=positions, indices=indices, normals=normals, texcoords=uvs)
265
266    def _build_wall_mesh(self) -> Mesh:
267        """Low side walls flanking the floor: half-metre lip on both sides."""
268        n = self.length_segments
269        hw = 0.5 * self.width
270        wall_height = 0.8
271        # 4 vertices per segment per side: bottom-inner, top-inner, top-outer (= bottom + h*normal).
272        # We model both walls. Total 4*n verts.
273        positions = np.empty((n * 4, 3), dtype=np.float32)
274        normals = np.empty((n * 4, 3), dtype=np.float32)
275        uvs = np.zeros((n * 4, 2), dtype=np.float32)
276        for i in range(n):
277            c = self.samples_centre[i]
278            side = self.samples_side[i]
279            normal = self.samples_normal[i]
280            bank = self.samples_bank[i]
281            cos_b = np.cos(bank)
282            sin_b = np.sin(bank)
283            banked_side = side * cos_b + normal * sin_b
284            banked_normal = normal * cos_b - side * sin_b
285            left_floor = c - banked_side * hw
286            right_floor = c + banked_side * hw
287            left_top = left_floor + banked_normal * wall_height
288            right_top = right_floor + banked_normal * wall_height
289            base = 4 * i
290            positions[base] = left_floor
291            positions[base + 1] = left_top
292            positions[base + 2] = right_floor
293            positions[base + 3] = right_top
294            # Wall normals point inward toward the track centre.
295            normals[base] = banked_side
296            normals[base + 1] = banked_side
297            normals[base + 2] = -banked_side
298            normals[base + 3] = -banked_side
299        # Indices: two strips, one per wall.
300        idx = []
301        for i in range(n):
302            j = (i + 1) % n
303            # Left wall: verts 0(L_floor), 1(L_top) at i and j.
304            l_a, l_b = 4 * i, 4 * i + 1
305            l_c, l_d = 4 * j, 4 * j + 1
306            idx.extend([l_a, l_b, l_d, l_a, l_d, l_c])
307            # Right wall.
308            r_a, r_b = 4 * i + 2, 4 * i + 3
309            r_c, r_d = 4 * j + 2, 4 * j + 3
310            idx.extend([r_a, r_d, r_b, r_a, r_c, r_d])
311        indices = np.array(idx, dtype=np.uint32)
312        return Mesh(positions=positions, indices=indices, normals=normals, texcoords=uvs)
313
314    def _build_boost_pad_mesh(self, t: float) -> Mesh:
315        """A 4 m-long emissive panel inset 0.05 m above the floor at ``t``."""
316        n_segs = 8  # number of centreline samples this pad spans
317        hw = 0.5 * self.width * 0.6  # narrower than full track
318        ofs = 0.04  # raised slightly above floor to avoid Z-fight
319        positions = np.empty((n_segs * 2, 3), dtype=np.float32)
320        normals = np.empty((n_segs * 2, 3), dtype=np.float32)
321        uvs = np.zeros((n_segs * 2, 2), dtype=np.float32)
322        n = self.length_segments
323        i0 = int(t * n) % n
324        for k in range(n_segs):
325            i = (i0 + k) % n
326            c = self.samples_centre[i]
327            side = self.samples_side[i]
328            normal = self.samples_normal[i]
329            bank = self.samples_bank[i]
330            cos_b = np.cos(bank)
331            sin_b = np.sin(bank)
332            banked_side = side * cos_b + normal * sin_b
333            banked_normal = normal * cos_b - side * sin_b
334            left = c - banked_side * hw + banked_normal * ofs
335            right = c + banked_side * hw + banked_normal * ofs
336            positions[2 * k] = left
337            positions[2 * k + 1] = right
338            normals[2 * k] = banked_normal
339            normals[2 * k + 1] = banked_normal
340        idx = []
341        for k in range(n_segs - 1):
342            a = 2 * k
343            b = 2 * k + 1
344            c = 2 * (k + 1)
345            d = 2 * (k + 1) + 1
346            idx.extend([a, b, d, a, d, c])
347        return Mesh(
348            positions=positions,
349            indices=np.array(idx, dtype=np.uint32),
350            normals=normals,
351            texcoords=uvs,
352        )