"""
Geometry data with procedural primitives.
High-level: Mesh.cube(), Mesh.sphere(), Mesh.cone()
Low-level: Mesh(positions, indices, normals, texcoords) + interleaved_bytes()
"""
from __future__ import annotations
import logging
import math
import os
from importlib.resources.abc import Traversable
from typing import TYPE_CHECKING, Any, Union
import numpy as np
if TYPE_CHECKING:
from simvx.core.resource import Resource
log = logging.getLogger(__name__)
MeshSource = Union[str, os.PathLike, "Resource", Traversable]
def _drop_defaults(values: dict[str, Any], defaults: dict[str, Any]) -> dict[str, Any]:
"""Return *values* without keys whose value matches the default."""
return {k: v for k, v in values.items() if defaults.get(k) != v}
[docs]
class Mesh:
"""Vertex data with optional GPU buffers. Pure data until renderer uploads.
Create via class methods:
Mesh.cube(), Mesh.sphere(), Mesh.cone(), Mesh.cylinder()
Or from raw data:
Mesh(positions, indices, normals, texcoords)
Or load from a Wavefront OBJ:
Mesh.from_obj("model.obj")
Mesh.from_obj(Resource("game.assets", "ship.obj"))
The optional :attr:`factory_spec` records the call that produced a mesh
(``{"name": "cube", "kwargs": {"size": 1.0}}`` or
``{"name": "obj", "source": <spec>}``) so scene serialisation can
round-trip the mesh by replaying the factory.
"""
_next_uid: int = 0
def __init__(self, positions, indices=None, normals=None, texcoords=None, topology="triangles"):
Mesh._next_uid += 1
self._uid: int = Mesh._next_uid
self.positions = np.asarray(positions, dtype=np.float32).reshape(-1, 3)
self.indices = np.asarray(indices, dtype=np.uint32).ravel() if indices is not None else None
self.normals = np.asarray(normals, dtype=np.float32).reshape(-1, 3) if normals is not None else None
self.texcoords = np.asarray(texcoords, dtype=np.float32).reshape(-1, 2) if texcoords is not None else None
self.topology = topology
self.factory_spec: dict[str, Any] | None = None
# --- Properties ---
[docs]
@property
def vertex_count(self) -> int:
return len(self.positions)
[docs]
@property
def index_count(self) -> int:
return len(self.indices) if self.indices is not None else 0
[docs]
@property
def stride(self) -> int:
"""Bytes per interleaved vertex."""
s = 12 # vec3 position
if self.normals is not None:
s += 12
if self.texcoords is not None:
s += 8
return s
[docs]
@property
def has_normals(self) -> bool:
return self.normals is not None
[docs]
@property
def has_texcoords(self) -> bool:
return self.texcoords is not None
# --- Data export ---
[docs]
def interleaved_bytes(self) -> bytes:
"""Pack vertex data interleaved (pos[,normal][,uv]) for GPU upload."""
arrays = [self.positions]
if self.normals is not None:
arrays.append(self.normals)
if self.texcoords is not None:
arrays.append(self.texcoords)
return np.hstack(arrays).astype(np.float32).tobytes()
[docs]
def index_bytes(self) -> bytes:
return self.indices.tobytes() if self.indices is not None else b""
[docs]
def bounding_box(self) -> tuple[np.ndarray, np.ndarray]:
"""Returns (min_corner, max_corner) as vec3 arrays."""
return self.positions.min(axis=0), self.positions.max(axis=0)
[docs]
def bounding_radius(self) -> float:
"""Radius of bounding sphere centered at origin."""
return float(np.sqrt((self.positions**2).sum(axis=1).max()))
# --- Mutators ---
[docs]
def generate_normals(self) -> Mesh:
"""Compute smooth vertex normals from face geometry. Returns self.
Vectorised via ``np.add.at``: each face normal is scattered into
the three vertex slots that share it, in the same order the
per-triangle loop would have visited them. The result matches
the prior implementation bit-for-bit because addition is
commutative.
"""
if self.indices is None:
return self
normals = np.zeros_like(self.positions)
tris = self.indices.reshape(-1, 3)
v0 = self.positions[tris[:, 0]]
v1 = self.positions[tris[:, 1]]
v2 = self.positions[tris[:, 2]]
face_n = np.cross(v1 - v0, v2 - v0)
np.add.at(normals, tris.ravel(), np.repeat(face_n, 3, axis=0))
lens = np.linalg.norm(normals, axis=1, keepdims=True)
self.normals = np.divide(normals, lens, where=lens > 1e-10, out=normals)
return self
# === Procedural Primitives ===
[docs]
@classmethod
def cube(cls, size: float = 1.0) -> Mesh:
"""Axis-aligned cube centered at origin. 24 vertices, 36 indices."""
s = size / 2
pos = [
[-s, -s, s],
[s, -s, s],
[s, s, s],
[-s, s, s], # Front +Z
[s, -s, -s],
[-s, -s, -s],
[-s, s, -s],
[s, s, -s], # Back -Z
[-s, s, s],
[s, s, s],
[s, s, -s],
[-s, s, -s], # Top +Y
[-s, -s, -s],
[s, -s, -s],
[s, -s, s],
[-s, -s, s], # Bottom -Y
[s, -s, s],
[s, -s, -s],
[s, s, -s],
[s, s, s], # Right +X
[-s, -s, -s],
[-s, -s, s],
[-s, s, s],
[-s, s, -s], # Left -X
]
nrm = (
[[0, 0, 1]] * 4 + [[0, 0, -1]] * 4 + [[0, 1, 0]] * 4 + [[0, -1, 0]] * 4 + [[1, 0, 0]] * 4 + [[-1, 0, 0]] * 4
)
uv = [[0, 0], [1, 0], [1, 1], [0, 1]] * 6
idx = []
for f in range(6):
b = f * 4
idx.extend([b, b + 1, b + 2, b, b + 2, b + 3])
m = cls(pos, idx, nrm, uv)
m.factory_spec = {"name": "cube", "kwargs": _drop_defaults({"size": size}, {"size": 1.0})}
return m
[docs]
@classmethod
def sphere(cls, radius: float = 1.0, rings: int = 16, segments: int = 16) -> Mesh:
"""UV sphere."""
pos, nrm, uv, idx = [], [], [], []
for ring in range(rings + 1):
phi = math.pi * ring / rings
sp, cp = math.sin(phi), math.cos(phi)
for seg in range(segments + 1):
theta = math.tau * seg / segments
x, z = sp * math.cos(theta), sp * math.sin(theta)
pos.append([x * radius, cp * radius, z * radius])
nrm.append([x, cp, z])
uv.append([seg / segments, ring / rings])
row = segments + 1
for ring in range(rings):
for seg in range(segments):
a = ring * row + seg
b = a + row
idx.extend([a, a + 1, b, a + 1, b + 1, b])
m = cls(pos, idx, nrm, uv)
m.factory_spec = {
"name": "sphere",
"kwargs": _drop_defaults(
{"radius": radius, "rings": rings, "segments": segments},
{"radius": 1.0, "rings": 16, "segments": 16},
),
}
return m
[docs]
@classmethod
def cone(cls, radius: float = 0.5, height: float = 1.0, segments: int = 16) -> Mesh:
"""Cone pointing up +Y, base centered at origin."""
pos, nrm, uv, idx = [], [], [], []
half_h = height / 2
slope_len = math.sqrt(radius * radius + height * height)
ny = radius / slope_len
nr = height / slope_len
# Tip (one vertex per segment for correct normals)
for i in range(segments):
theta = math.tau * i / segments
mid_theta = math.tau * (i + 0.5) / segments
nx, nz = math.cos(mid_theta) * nr, math.sin(mid_theta) * nr
pos.append([0, half_h, 0])
nrm.append([nx, ny, nz])
uv.append([0.5, 0])
# Base ring (for sides)
for i in range(segments):
theta = math.tau * i / segments
cx, cz = math.cos(theta), math.sin(theta)
pos.append([cx * radius, -half_h, cz * radius])
nrm.append([cx * nr, ny, cz * nr])
uv.append([i / segments, 1])
# Side triangles
for i in range(segments):
tip = i
bl = segments + i
br = segments + (i + 1) % segments
idx.extend([tip, br, bl])
# Base cap
base_center = len(pos)
pos.append([0, -half_h, 0])
nrm.append([0, -1, 0])
uv.append([0.5, 0.5])
for i in range(segments):
theta = math.tau * i / segments
pos.append([math.cos(theta) * radius, -half_h, math.sin(theta) * radius])
nrm.append([0, -1, 0])
uv.append([0.5 + 0.5 * math.cos(theta), 0.5 + 0.5 * math.sin(theta)])
for i in range(segments):
idx.extend([base_center, base_center + 1 + i, base_center + 1 + (i + 1) % segments])
m = cls(pos, idx, nrm, uv)
m.factory_spec = {
"name": "cone",
"kwargs": _drop_defaults(
{"radius": radius, "height": height, "segments": segments},
{"radius": 0.5, "height": 1.0, "segments": 16},
),
}
return m
[docs]
@classmethod
def cylinder(cls, radius: float = 0.5, height: float = 1.0, segments: int = 16) -> Mesh:
"""Cylinder along +Y axis, centered at origin."""
pos, nrm, uv, idx = [], [], [], []
half_h = height / 2
# Side vertices: two rings
for ring in range(2):
y = half_h if ring == 0 else -half_h
v = float(ring)
for seg in range(segments + 1):
theta = math.tau * seg / segments
cx, cz = math.cos(theta), math.sin(theta)
pos.append([cx * radius, y, cz * radius])
nrm.append([cx, 0, cz])
uv.append([seg / segments, v])
row = segments + 1
for seg in range(segments):
a, b = seg, seg + row
idx.extend([a, a + 1, b, a + 1, b + 1, b])
# Top cap
tc = len(pos)
pos.append([0, half_h, 0])
nrm.append([0, 1, 0])
uv.append([0.5, 0.5])
for i in range(segments):
theta = math.tau * i / segments
pos.append([math.cos(theta) * radius, half_h, math.sin(theta) * radius])
nrm.append([0, 1, 0])
uv.append([0.5 + 0.5 * math.cos(theta), 0.5 + 0.5 * math.sin(theta)])
for i in range(segments):
idx.extend([tc, tc + 1 + (i + 1) % segments, tc + 1 + i])
# Bottom cap
bc = len(pos)
pos.append([0, -half_h, 0])
nrm.append([0, -1, 0])
uv.append([0.5, 0.5])
for i in range(segments):
theta = math.tau * i / segments
pos.append([math.cos(theta) * radius, -half_h, math.sin(theta) * radius])
nrm.append([0, -1, 0])
uv.append([0.5 + 0.5 * math.cos(theta), 0.5 + 0.5 * math.sin(theta)])
for i in range(segments):
idx.extend([bc, bc + 1 + i, bc + 1 + (i + 1) % segments])
m = cls(pos, idx, nrm, uv)
m.factory_spec = {
"name": "cylinder",
"kwargs": _drop_defaults(
{"radius": radius, "height": height, "segments": segments},
{"radius": 0.5, "height": 1.0, "segments": 16},
),
}
return m
[docs]
@classmethod
def extrude_path(
cls,
centerline: list[tuple[float, float, float]] | np.ndarray,
sides: int = 8,
radius: float = 0.5,
profile: np.ndarray | None = None,
closed: bool = False,
) -> Mesh:
"""Sweep a profile (default: regular polygon) along a 3D centerline.
Builds a tube / ribbon mesh: the bread-and-butter primitive for
racing tracks, river meshes, electric arcs, tentacles, and
anything else that follows a polyline. HexGL hand-rolled ~80 LOC
of NumPy ribbon code; this method replaces that with a single
call.
Args:
centerline: ``(N, 3)`` sequence of world-space points. Must
have ``N >= 2``. Successive duplicate points are
tolerated (collapsed segments produce zero-length
quads).
sides: Number of segments around the tube. Default 8.
Ignored if ``profile`` is supplied.
radius: Radius of the default circular profile. Ignored
when ``profile`` is supplied.
profile: Optional ``(sides, 2)`` array of 2D points in the
profile's local frame (X = right, Y = up relative to
the centerline tangent). When ``None`` a regular
``sides``-gon of the given ``radius`` is generated.
closed: When ``True``, the last centerline segment loops
back to the first: useful for closed tracks. The
profile orientation at the join is whatever the local
frame returns; for racetracks pre-join the geometry
yourself if you need smooth continuity.
Returns:
A ``Mesh`` with positions, normals (vertex-averaged from
face normals), texcoords (V along the path, U around the
ring), and triangle indices. ``factory_spec`` is set so the
mesh round-trips through the scene serialiser.
Example::
track = Mesh.extrude_path(
centerline=[(0, 0, 0), (5, 0.5, -1), (8, 0, -6)],
sides=12,
radius=0.8,
)
"""
pts = np.asarray(centerline, dtype=np.float32).reshape(-1, 3)
if len(pts) < 2:
raise ValueError(
f"extrude_path requires at least 2 centerline points, got {len(pts)}",
)
# Generate the cross-section profile in (X, Y): circle is the default.
if profile is None:
angles = np.linspace(0.0, math.tau, sides, endpoint=False, dtype=np.float32)
profile_2d = np.stack([np.cos(angles) * radius, np.sin(angles) * radius], axis=1)
else:
profile_2d = np.asarray(profile, dtype=np.float32).reshape(-1, 2)
if len(profile_2d) < 3:
raise ValueError(
f"profile must have at least 3 points, got {len(profile_2d)}",
)
sides = len(profile_2d)
if closed and not np.allclose(pts[0], pts[-1]):
pts = np.vstack([pts, pts[:1]])
n_path = len(pts)
# Tangents per centerline point: central difference for interior,
# forward / backward at endpoints.
tangents = np.zeros_like(pts)
tangents[0] = pts[1] - pts[0]
tangents[-1] = pts[-1] - pts[-2]
if n_path > 2:
tangents[1:-1] = pts[2:] - pts[:-2]
norms = np.linalg.norm(tangents, axis=1, keepdims=True)
tangents = np.divide(tangents, norms, where=norms > 1e-6, out=tangents)
# Build a stable local frame using parallel transport: pick an
# arbitrary up that isn't parallel to the first tangent.
positions = np.empty((n_path * sides, 3), dtype=np.float32)
normals = np.zeros_like(positions)
texcoords = np.empty((n_path * sides, 2), dtype=np.float32)
up = np.array([0.0, 1.0, 0.0], dtype=np.float32)
if abs(float(np.dot(tangents[0], up))) > 0.95:
up = np.array([1.0, 0.0, 0.0], dtype=np.float32)
right = np.cross(up, tangents[0])
right /= np.linalg.norm(right) + 1e-12
up = np.cross(tangents[0], right)
up /= np.linalg.norm(up) + 1e-12
for i in range(n_path):
tangent = tangents[i]
# Parallel-transport `up` along the tangent by removing its
# component along the new tangent and re-orthogonalising.
if i > 0:
up = up - tangent * float(np.dot(up, tangent))
up_n = float(np.linalg.norm(up))
if up_n < 1e-6:
up = np.array([0.0, 1.0, 0.0], dtype=np.float32) if abs(tangent[1]) < 0.95 \
else np.array([1.0, 0.0, 0.0], dtype=np.float32)
up = up - tangent * float(np.dot(up, tangent))
up_n = float(np.linalg.norm(up))
up /= up_n
right = np.cross(up, tangent)
right /= np.linalg.norm(right) + 1e-12
base = i * sides
v = i / max(n_path - 1, 1)
for j in range(sides):
px, py = float(profile_2d[j, 0]), float(profile_2d[j, 1])
positions[base + j] = pts[i] + right * px + up * py
texcoords[base + j] = (j / sides, v)
# Triangulate the quads connecting ring i to ring i+1.
n_quads = (n_path - 1) * sides
indices = np.empty(n_quads * 6, dtype=np.uint32)
out = 0
for i in range(n_path - 1):
for j in range(sides):
a = i * sides + j
b = i * sides + (j + 1) % sides
c = (i + 1) * sides + j
d = (i + 1) * sides + (j + 1) % sides
indices[out:out + 6] = (a, b, d, a, d, c)
out += 6
m = cls(positions, indices, normals=None, texcoords=texcoords)
m.generate_normals()
m.factory_spec = {
"name": "extrude_path",
"kwargs": _drop_defaults(
{"centerline": pts.tolist(), "sides": sides, "radius": radius,
"profile": None if profile is None else profile_2d.tolist(),
"closed": closed},
{"sides": 8, "radius": 0.5, "profile": None, "closed": False},
),
}
return m
[docs]
@classmethod
def load(cls, source: MeshSource) -> Mesh:
"""Load a mesh from any supported source.
Currently delegates to :meth:`from_obj` (only Wavefront OBJ is
supported). The check is by file extension on the resolved path.
"""
from simvx.core.asset_resolver import resolve_asset_path
path = resolve_asset_path(source)
if not path.suffix.lower() == ".obj":
raise ValueError(f"Unsupported mesh format: {path!r} (only .obj is supported)")
return cls.from_obj(source)
[docs]
@classmethod
def from_obj(cls, source: MeshSource) -> Mesh:
"""Load from Wavefront OBJ. Handles v/vt/vn/f directives.
Accepts ``str`` / ``os.PathLike`` (filesystem path),
:class:`~simvx.core.Resource` (package handle), or
:class:`importlib.resources.abc.Traversable` (raw importlib.resources
result).
"""
from simvx.core.asset_resolver import resolve_asset_path
resolved = resolve_asset_path(source)
raw_pos, raw_uv, raw_nrm = [], [], []
positions, texcoords, normals, indices = [], [], [], []
vertex_cache: dict[tuple, int] = {}
with open(resolved) as f:
for line in f:
parts = line.split()
if not parts:
continue
if parts[0] == "v":
raw_pos.append([float(x) for x in parts[1:4]])
elif parts[0] == "vt":
raw_uv.append([float(x) for x in parts[1:3]])
elif parts[0] == "vn":
raw_nrm.append([float(x) for x in parts[1:4]])
elif parts[0] == "f":
face_verts = []
for vert_str in parts[1:]:
v = vert_str.split("/")
key = tuple(v)
if key not in vertex_cache:
vertex_cache[key] = len(positions)
positions.append(raw_pos[int(v[0]) - 1])
if len(v) > 1 and v[1] and raw_uv:
texcoords.append(raw_uv[int(v[1]) - 1])
if len(v) > 2 and v[2] and raw_nrm:
normals.append(raw_nrm[int(v[2]) - 1])
face_verts.append(vertex_cache[key])
# Triangulate (fan from first vertex)
for i in range(1, len(face_verts) - 1):
indices.extend([face_verts[0], face_verts[i], face_verts[i + 1]])
m = cls(positions, indices, normals or None, texcoords or None)
m.factory_spec = {"name": "obj", "source": source}
return m