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 )