Source code for simvx.core.graphics.mesh

"""
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 numpy as np

log = logging.getLogger(__name__)


[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 file: Mesh.from_obj("model.obj") """ _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.resource_uri: str | None = None # --- Properties --- @property def vertex_count(self) -> int: return len(self.positions) @property def index_count(self) -> int: return len(self.indices) if self.indices is not None else 0 @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 @property def has_normals(self) -> bool: return self.normals is not None @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.""" 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) for i, tri in enumerate(tris): normals[tri] += face_n[i] 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.resource_uri = f"mesh://cube?size={size}" if size != 1.0 else "mesh://cube" 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) parts = [] if radius != 1.0: parts.append(f"radius={radius}") if rings != 16: parts.append(f"rings={rings}") if segments != 16: parts.append(f"segments={segments}") m.resource_uri = "mesh://sphere" + ("?" + "&".join(parts) if parts else "") 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) parts = [] if radius != 0.5: parts.append(f"radius={radius}") if height != 1.0: parts.append(f"height={height}") if segments != 16: parts.append(f"segments={segments}") m.resource_uri = "mesh://cone" + ("?" + "&".join(parts) if parts else "") 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) parts = [] if radius != 0.5: parts.append(f"radius={radius}") if height != 1.0: parts.append(f"height={height}") if segments != 16: parts.append(f"segments={segments}") m.resource_uri = "mesh://cylinder" + ("?" + "&".join(parts) if parts else "") return m
[docs] @classmethod def load(cls, path: str) -> Mesh: """Load a mesh from a file path. Supports OBJ format. Uses ResourceCache when available for deduplication. Args: path: File path to load (currently only ``.obj`` files). Returns: Loaded Mesh instance. Raises: ValueError: If the file format is unsupported. """ if not path.lower().endswith(".obj"): raise ValueError(f"Unsupported mesh format: {path!r} (only .obj is supported)") try: from simvx.core.resource import ResourceCache return ResourceCache.get().resolve_mesh(f"mesh://obj?path={path}") except ImportError: return cls.from_obj(path)
[docs] @classmethod def from_obj(cls, path: str) -> Mesh: """Load from Wavefront OBJ. Handles v/vt/vn/f directives.""" raw_pos, raw_uv, raw_nrm = [], [], [] positions, texcoords, normals, indices = [], [], [], [] vertex_cache: dict[tuple, int] = {} with open(path) 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.resource_uri = f"mesh://obj?path={path}" return m