98 lines
2.9 KiB
Python
98 lines
2.9 KiB
Python
"""
|
|
GLB 3D model builder for roller coaster rails.
|
|
|
|
Builds a tube mesh along each rail path using pure numpy + trimesh (no extra
|
|
deps). build123d is installed for future complex solid geometry work.
|
|
"""
|
|
|
|
import logging
|
|
|
|
import numpy as np
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _tube_mesh(path: np.ndarray, radius: float, segments: int = 8):
|
|
"""
|
|
Create a trimesh.Trimesh tube swept along a 3-D polyline path.
|
|
|
|
Parameters
|
|
----------
|
|
path : (n, 3) float path centreline in metres
|
|
radius : float tube radius in metres
|
|
segments : int number of cross-section vertices
|
|
"""
|
|
import trimesh # type: ignore
|
|
|
|
n = len(path)
|
|
angles = np.linspace(0, 2 * np.pi, segments, endpoint=False)
|
|
circle_xy = np.column_stack([np.cos(angles), np.sin(angles)]) # (seg, 2)
|
|
|
|
rings = []
|
|
for i in range(n):
|
|
# Tangent vector
|
|
if i == 0:
|
|
t = path[1] - path[0]
|
|
elif i == n - 1:
|
|
t = path[-1] - path[-2]
|
|
else:
|
|
t = path[i + 1] - path[i - 1]
|
|
t = t / (np.linalg.norm(t) + 1e-12)
|
|
|
|
# Build an orthonormal frame (right, up2) perpendicular to t
|
|
world_up = np.array([0.0, 0.0, 1.0])
|
|
if abs(np.dot(t, world_up)) > 0.9:
|
|
world_up = np.array([1.0, 0.0, 0.0])
|
|
right = np.cross(t, world_up)
|
|
right /= np.linalg.norm(right) + 1e-12
|
|
up2 = np.cross(right, t)
|
|
|
|
# Place the circle ring at path[i]
|
|
ring = path[i] + (circle_xy[:, 0:1] * right + circle_xy[:, 1:2] * up2) * radius
|
|
rings.append(ring)
|
|
|
|
verts = np.array(rings).reshape(-1, 3) # (n * segments, 3)
|
|
|
|
# Stitch adjacent rings into quads (two triangles each)
|
|
faces = []
|
|
for i in range(n - 1):
|
|
for j in range(segments):
|
|
j1 = (j + 1) % segments
|
|
a = i * segments + j
|
|
b = i * segments + j1
|
|
c = (i + 1) * segments + j
|
|
d = (i + 1) * segments + j1
|
|
faces.append([a, b, d])
|
|
faces.append([a, d, c])
|
|
|
|
return trimesh.Trimesh(vertices=verts, faces=np.array(faces), process=False)
|
|
|
|
|
|
def build_glb(rail_1_pts: np.ndarray, rail_2_pts: np.ndarray, radius_m: float = 0.05) -> bytes:
|
|
"""
|
|
Build a GLB model containing two solid rail tubes.
|
|
|
|
Parameters
|
|
----------
|
|
rail_1_pts, rail_2_pts : np.ndarray shape (n, 3) metres, local ENU frame
|
|
radius_m : float tube radius in metres
|
|
|
|
Returns
|
|
-------
|
|
bytes GLB binary data
|
|
"""
|
|
import trimesh # type: ignore
|
|
|
|
logger.info("Building rail tubes (radius=%.3f m)…", radius_m)
|
|
mesh_1 = _tube_mesh(rail_1_pts, radius_m)
|
|
mesh_2 = _tube_mesh(rail_2_pts, radius_m)
|
|
|
|
# Steel-grey colour
|
|
for mesh in (mesh_1, mesh_2):
|
|
mesh.visual.face_colors = [180, 185, 192, 255]
|
|
|
|
scene = trimesh.Scene([mesh_1, mesh_2])
|
|
glb_bytes = scene.export(file_type="glb")
|
|
logger.info("GLB size: %d bytes", len(glb_bytes))
|
|
return glb_bytes
|