Source code for simvx.core.surface_tool

"""Procedural geometry construction tool and convenience mesh generators.

SurfaceTool provides a vertex-by-vertex mesh building API inspired by Godot's
SurfaceTool. ImmediateGeometry3D rebuilds its mesh every frame from a user
callback, suitable for debug visualisations, trails, and dynamic geometry.

Usage::

    from simvx.core import SurfaceTool, PrimitiveType

    st = SurfaceTool()
    st.begin(PrimitiveType.TRIANGLES)
    st.set_normal((0, 1, 0))
    st.set_uv((0, 0))
    st.add_vertex((0, 0, 0))
    st.set_uv((1, 0))
    st.add_vertex((1, 0, 0))
    st.set_uv((0.5, 1))
    st.add_vertex((0.5, 1, 0))
    mesh = st.commit()
"""


from __future__ import annotations

import logging
import math
from collections.abc import Sequence
from enum import IntEnum

import numpy as np

from .graphics.mesh import Mesh
from .math.types import Vec2, Vec3
from .nodes_3d.node3d import Node3D

log = logging.getLogger(__name__)

__all__ = [
    "SurfaceTool",
    "PrimitiveType",
    "ImmediateGeometry3D",
    "create_box",
    "create_sphere",
    "create_cylinder",
    "create_plane",
    "create_capsule",
]


[docs] class PrimitiveType(IntEnum): """Primitive topology for SurfaceTool.""" TRIANGLES = 0 LINES = 1 POINTS = 2 TRIANGLE_STRIP = 3
[docs] class SurfaceTool: """Vertex-by-vertex mesh construction tool. Accumulates vertex attributes then emits a ``Mesh`` on ``commit()``. Set per-vertex attributes (normal, uv, colour) *before* calling ``add_vertex()`` -- the current values are latched when the vertex is added. """ __slots__ = ( "_primitive", "_positions", "_normals", "_uvs", "_colours", "_indices", "_cur_normal", "_cur_uv", "_cur_colour", "_tangents", ) def __init__(self) -> None: self._primitive: PrimitiveType | None = None self._positions: list[tuple[float, float, float]] = [] self._normals: list[tuple[float, float, float]] = [] self._uvs: list[tuple[float, float]] = [] self._colours: list[tuple[float, float, float, float]] = [] self._indices: list[int] = [] self._cur_normal: tuple[float, float, float] = (0.0, 0.0, 1.0) self._cur_uv: tuple[float, float] = (0.0, 0.0) self._cur_colour: tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0) # -- Configuration -------------------------------------------------------
[docs] def begin(self, primitive: PrimitiveType = PrimitiveType.TRIANGLES) -> None: """Start a new surface. Clears any previously accumulated data.""" self._primitive = PrimitiveType(primitive) self._positions.clear() self._normals.clear() self._uvs.clear() self._colours.clear() self._indices.clear() self._cur_normal = (0.0, 0.0, 1.0) self._cur_uv = (0.0, 0.0) self._cur_colour = (1.0, 1.0, 1.0, 1.0)
# -- Per-vertex attribute setters ----------------------------------------
[docs] def set_normal(self, normal: Sequence[float] | Vec3) -> None: """Set the normal for subsequent vertices.""" n = normal self._cur_normal = (float(n[0]), float(n[1]), float(n[2]))
[docs] def set_uv(self, uv: Sequence[float] | Vec2) -> None: """Set the UV coordinate for subsequent vertices.""" self._cur_uv = (float(uv[0]), float(uv[1]))
[docs] def set_colour(self, colour: Sequence[float]) -> None: """Set the vertex colour (RGBA) for subsequent vertices.""" r, g, b = float(colour[0]), float(colour[1]), float(colour[2]) a = float(colour[3]) if len(colour) > 3 else 1.0 self._cur_colour = (r, g, b, a)
# -- Vertex / index accumulation -----------------------------------------
[docs] def add_vertex(self, position: Sequence[float] | Vec3) -> None: """Add a vertex with the currently set attributes.""" if self._primitive is None: raise RuntimeError("Call begin() before add_vertex()") p = position self._positions.append((float(p[0]), float(p[1]), float(p[2]))) self._normals.append(self._cur_normal) self._uvs.append(self._cur_uv) self._colours.append(self._cur_colour)
[docs] def add_index(self, index: int) -> None: """Add an index referencing a previously added vertex.""" self._indices.append(int(index))
[docs] def add_triangle_fan( self, vertices: Sequence[Sequence[float]], uvs: Sequence[Sequence[float]] | None = None, normals: Sequence[Sequence[float]] | None = None, ) -> None: """Add a triangle fan as indexed triangles. The first vertex is the hub; triangles are formed with consecutive pairs of the remaining vertices. """ if self._primitive is None: raise RuntimeError("Call begin() before add_triangle_fan()") base = len(self._positions) for i, v in enumerate(vertices): if normals is not None: self.set_normal(normals[i]) if uvs is not None: self.set_uv(uvs[i]) self.add_vertex(v) for i in range(1, len(vertices) - 1): self._indices.extend([base, base + i, base + i + 1])
# -- Post-processing -----------------------------------------------------
[docs] def generate_normals(self) -> None: """Auto-compute smooth normals from triangle geometry. Only works when primitive type is TRIANGLES. Uses the index list if present, otherwise assumes every three consecutive vertices form a triangle. """ if self._primitive not in (PrimitiveType.TRIANGLES, None): return n = len(self._positions) if n == 0: return positions = np.array(self._positions, dtype=np.float32) normals = np.zeros((n, 3), dtype=np.float32) if self._indices: idx = np.array(self._indices, dtype=np.int32).reshape(-1, 3) else: idx = np.arange(n, dtype=np.int32).reshape(-1, 3) v0 = positions[idx[:, 0]] v1 = positions[idx[:, 1]] v2 = positions[idx[:, 2]] face_normals = np.cross(v1 - v0, v2 - v0) for i, tri in enumerate(idx): normals[tri] += face_normals[i] lengths = np.linalg.norm(normals, axis=1, keepdims=True) np.divide(normals, lengths, where=lengths > 1e-10, out=normals) self._normals = [tuple(normals[i]) for i in range(n)]
[docs] def generate_tangents(self) -> None: """Auto-compute tangents using Lengyel's method (MikkTSpace-lite). Stores tangent data on the resulting mesh as a ``tangents`` attribute (Nx4 float32 array with w = handedness). This is a best-effort implementation suitable for normal mapping. """ n = len(self._positions) if n == 0: return positions = np.array(self._positions, dtype=np.float32) uvs = np.array(self._uvs, dtype=np.float32) normals_arr = np.array(self._normals, dtype=np.float32) tan1 = np.zeros((n, 3), dtype=np.float32) tan2 = np.zeros((n, 3), dtype=np.float32) if self._indices: idx = np.array(self._indices, dtype=np.int32).reshape(-1, 3) else: idx = np.arange(n, dtype=np.int32).reshape(-1, 3) i0, i1, i2 = idx[:, 0], idx[:, 1], idx[:, 2] dp1 = positions[i1] - positions[i0] dp2 = positions[i2] - positions[i0] duv1 = uvs[i1] - uvs[i0] duv2 = uvs[i2] - uvs[i0] denom = duv1[:, 0] * duv2[:, 1] - duv1[:, 1] * duv2[:, 0] r = np.where(np.abs(denom) > 1e-10, 1.0 / denom, 0.0)[:, None] sdir = (dp1 * duv2[:, 1:2] - dp2 * duv1[:, 1:2]) * r tdir = (dp2 * duv1[:, 0:1] - dp1 * duv2[:, 0:1]) * r for i in range(len(idx)): tan1[i0[i]] += sdir[i] tan1[i1[i]] += sdir[i] tan1[i2[i]] += sdir[i] tan2[i0[i]] += tdir[i] tan2[i1[i]] += tdir[i] tan2[i2[i]] += tdir[i] # Gram-Schmidt orthogonalise dot_tn = np.sum(normals_arr * tan1, axis=1, keepdims=True) tangent3 = tan1 - normals_arr * dot_tn lengths = np.linalg.norm(tangent3, axis=1, keepdims=True) np.divide(tangent3, lengths, where=lengths > 1e-10, out=tangent3) # Handedness w = np.sign(np.sum(np.cross(normals_arr, tan1) * tan2, axis=1)) w[w == 0] = 1.0 self._tangents = np.column_stack([tangent3, w]).astype(np.float32)
# -- Output --------------------------------------------------------------
[docs] def commit(self) -> Mesh: """Build and return a ``Mesh`` from the accumulated vertex data.""" if self._primitive is None: raise RuntimeError("Call begin() before commit()") if not self._positions: raise RuntimeError("No vertices added") topology_map = { PrimitiveType.TRIANGLES: "triangles", PrimitiveType.LINES: "lines", PrimitiveType.POINTS: "points", PrimitiveType.TRIANGLE_STRIP: "triangle_strip", } positions = np.array(self._positions, dtype=np.float32) normals = np.array(self._normals, dtype=np.float32) texcoords = np.array(self._uvs, dtype=np.float32) if self._indices: indices = np.array(self._indices, dtype=np.uint32) else: # No explicit indices — generate sequential (non-indexed draw as indexed) indices = np.arange(len(self._positions), dtype=np.uint32) mesh = Mesh(positions, indices, normals, texcoords, topology=topology_map[self._primitive]) # Attach optional tangent data if hasattr(self, "_tangents"): mesh.tangents = self._tangents # Attach vertex colours if any non-white colour was set colours = np.array(self._colours, dtype=np.float32) if not np.allclose(colours, 1.0): mesh.colours = colours return mesh
[docs] def clear(self) -> None: """Reset the tool for reuse (same as constructing a new instance).""" self._primitive = None self._positions.clear() self._normals.clear() self._uvs.clear() self._colours.clear() self._indices.clear() self._cur_normal = (0.0, 0.0, 1.0) self._cur_uv = (0.0, 0.0) self._cur_colour = (1.0, 1.0, 1.0, 1.0) if hasattr(self, "_tangents"): del self._tangents
# -- Introspection ------------------------------------------------------- @property def vertex_count(self) -> int: """Number of vertices accumulated so far.""" return len(self._positions) @property def index_count(self) -> int: """Number of indices accumulated so far.""" return len(self._indices)
# ============================================================================ # ImmediateGeometry3D — rebuilt every frame # ============================================================================
[docs] class ImmediateGeometry3D(Node3D): """A 3D node whose mesh is rebuilt every frame via a callback. Override ``_draw_geometry`` or connect to the ``draw`` signal to provide geometry each frame. The node creates a fresh ``SurfaceTool``, passes it to your callback, and commits the result as the renderable mesh. Usage:: class MyTrail(ImmediateGeometry3D): def _draw_geometry(self, st: SurfaceTool) -> None: st.begin(PrimitiveType.TRIANGLES) # ... add vertices ... Or with signals:: def draw_fn(st: SurfaceTool) -> None: st.begin(PrimitiveType.TRIANGLES) # ... ig = ImmediateGeometry3D() ig.draw.connect(draw_fn) """ def __init__(self, **kwargs): from .descriptors import Signal super().__init__(**kwargs) self.draw = Signal() self.mesh = None self.material = None self._tool = SurfaceTool()
[docs] def process(self, dt: float) -> None: tool = self._tool tool.clear() self._draw_geometry(tool) self.draw.emit(tool) if tool.vertex_count > 0: self.mesh = tool.commit() else: self.mesh = None
def _draw_geometry(self, tool: SurfaceTool) -> None: """Override to provide geometry. Called every frame.""" pass
# ============================================================================ # Convenience primitive generators # ============================================================================
[docs] def create_box(size: float | Sequence[float] = 1.0) -> Mesh: """Create a box mesh centred at the origin. Args: size: Uniform size (float) or per-axis extents (x, y, z). Returns: A ``Mesh`` with 24 vertices and 36 indices. """ if isinstance(size, int | float): sx = sy = sz = float(size) / 2 else: sx, sy, sz = float(size[0]) / 2, float(size[1]) / 2, float(size[2]) / 2 # Faces: +Z, -Z, +Y, -Y, +X, -X face_data = [ (( 0, 0, 1), [(-sx, -sy, sz), ( sx, -sy, sz), ( sx, sy, sz), (-sx, sy, sz)]), (( 0, 0, -1), [( sx, -sy, -sz), (-sx, -sy, -sz), (-sx, sy, -sz), ( sx, sy, -sz)]), (( 0, 1, 0), [(-sx, sy, sz), ( sx, sy, sz), ( sx, sy, -sz), (-sx, sy, -sz)]), (( 0, -1, 0), [(-sx, -sy, -sz), ( sx, -sy, -sz), ( sx, -sy, sz), (-sx, -sy, sz)]), (( 1, 0, 0), [( sx, -sy, sz), ( sx, -sy, -sz), ( sx, sy, -sz), ( sx, sy, sz)]), ((-1, 0, 0), [(-sx, -sy, -sz), (-sx, -sy, sz), (-sx, sy, sz), (-sx, sy, -sz)]), ] face_uvs = [(0, 0), (1, 0), (1, 1), (0, 1)] st = SurfaceTool() st.begin(PrimitiveType.TRIANGLES) for normal, corners in face_data: base = st.vertex_count st.set_normal(normal) for i, pos in enumerate(corners): st.set_uv(face_uvs[i]) st.add_vertex(pos) st.add_index(base) st.add_index(base + 1) st.add_index(base + 2) st.add_index(base) st.add_index(base + 2) st.add_index(base + 3) return st.commit()
[docs] def create_sphere(radius: float = 1.0, rings: int = 16, sectors: int = 16) -> Mesh: """Create a UV sphere mesh centred at the origin. Args: radius: Sphere radius. rings: Number of horizontal rings (latitude divisions). sectors: Number of vertical sectors (longitude divisions). Returns: A ``Mesh`` with smooth normals and UVs. """ st = SurfaceTool() st.begin(PrimitiveType.TRIANGLES) for ring in range(rings + 1): phi = math.pi * ring / rings sp, cp = math.sin(phi), math.cos(phi) for seg in range(sectors + 1): theta = math.tau * seg / sectors x = sp * math.cos(theta) z = sp * math.sin(theta) y = cp st.set_normal((x, y, z)) st.set_uv((seg / sectors, ring / rings)) st.add_vertex((x * radius, y * radius, z * radius)) row = sectors + 1 for ring in range(rings): for seg in range(sectors): a = ring * row + seg b = a + row st.add_index(a) st.add_index(a + 1) st.add_index(b) st.add_index(a + 1) st.add_index(b + 1) st.add_index(b) return st.commit()
[docs] def create_cylinder(radius: float = 0.5, height: float = 1.0, segments: int = 16) -> Mesh: """Create a cylinder mesh along the Y axis, centred at the origin. Args: radius: Cylinder radius. height: Total height. segments: Number of radial segments. Returns: A ``Mesh`` with side, top cap, and bottom cap geometry. """ st = SurfaceTool() st.begin(PrimitiveType.TRIANGLES) 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) st.set_normal((cx, 0, cz)) st.set_uv((seg / segments, v)) st.add_vertex((cx * radius, y, cz * radius)) row = segments + 1 for seg in range(segments): a, b = seg, seg + row st.add_index(a) st.add_index(a + 1) st.add_index(b) st.add_index(a + 1) st.add_index(b + 1) st.add_index(b) # Top cap tc = st.vertex_count st.set_normal((0, 1, 0)) st.set_uv((0.5, 0.5)) st.add_vertex((0, half_h, 0)) for i in range(segments): theta = math.tau * i / segments st.set_uv((0.5 + 0.5 * math.cos(theta), 0.5 + 0.5 * math.sin(theta))) st.add_vertex((math.cos(theta) * radius, half_h, math.sin(theta) * radius)) for i in range(segments): st.add_index(tc) st.add_index(tc + 1 + (i + 1) % segments) st.add_index(tc + 1 + i) # Bottom cap bc = st.vertex_count st.set_normal((0, -1, 0)) st.set_uv((0.5, 0.5)) st.add_vertex((0, -half_h, 0)) for i in range(segments): theta = math.tau * i / segments st.set_uv((0.5 + 0.5 * math.cos(theta), 0.5 + 0.5 * math.sin(theta))) st.add_vertex((math.cos(theta) * radius, -half_h, math.sin(theta) * radius)) for i in range(segments): st.add_index(bc) st.add_index(bc + 1 + i) st.add_index(bc + 1 + (i + 1) % segments) return st.commit()
[docs] def create_plane(size: float | Sequence[float] = 1.0, subdivisions: int = 1) -> Mesh: """Create a flat plane on the XZ plane, centred at the origin. Args: size: Uniform size (float) or (width, depth) tuple. subdivisions: Number of subdivisions per axis (1 = single quad). Returns: A ``Mesh`` with upward-facing normals. """ if isinstance(size, int | float): w = d = float(size) else: w, d = float(size[0]), float(size[1]) st = SurfaceTool() st.begin(PrimitiveType.TRIANGLES) st.set_normal((0, 1, 0)) hw, hd = w / 2, d / 2 rows = cols = subdivisions for row in range(rows + 1): for col in range(cols + 1): u = col / cols v = row / rows x = -hw + u * w z = -hd + v * d st.set_uv((u, v)) st.add_vertex((x, 0, z)) stride = cols + 1 for row in range(rows): for col in range(cols): a = row * stride + col b = a + stride st.add_index(a) st.add_index(a + 1) st.add_index(b) st.add_index(a + 1) st.add_index(b + 1) st.add_index(b) return st.commit()
[docs] def create_capsule(radius: float = 0.5, height: float = 1.0, rings: int = 8, sectors: int = 16) -> Mesh: """Create a capsule mesh (cylinder with hemispherical caps) along the Y axis. Args: radius: Capsule radius. height: Total height including the hemispherical caps. rings: Number of rings per hemisphere. sectors: Number of radial sectors. Returns: A ``Mesh`` with smooth normals. """ st = SurfaceTool() st.begin(PrimitiveType.TRIANGLES) # Cylinder body height (excluding caps) cyl_h = max(0.0, height - 2 * radius) half_cyl = cyl_h / 2 # Top hemisphere for ring in range(rings + 1): phi = (math.pi / 2) * ring / rings # 0 .. pi/2 sp, cp = math.sin(phi), math.cos(phi) y_offset = half_cyl + cp * radius for seg in range(sectors + 1): theta = math.tau * seg / sectors x = sp * math.cos(theta) z = sp * math.sin(theta) st.set_normal((x, cp, z)) st.set_uv((seg / sectors, ring / (2 * rings + 1))) st.add_vertex((x * radius, y_offset, z * radius)) # Cylinder body (two rings) cyl_base = st.vertex_count for ring_i in range(2): y = half_cyl if ring_i == 0 else -half_cyl v_coord = (rings + ring_i) / (2 * rings + 1) for seg in range(sectors + 1): theta = math.tau * seg / sectors cx, cz = math.cos(theta), math.sin(theta) st.set_normal((cx, 0, cz)) st.set_uv((seg / sectors, v_coord)) st.add_vertex((cx * radius, y, cz * radius)) # Bottom hemisphere bot_base = st.vertex_count for ring in range(rings + 1): phi = math.pi / 2 + (math.pi / 2) * ring / rings # pi/2 .. pi sp, cp = math.sin(phi), math.cos(phi) y_offset = -half_cyl + cp * radius for seg in range(sectors + 1): theta = math.tau * seg / sectors x = sp * math.cos(theta) z = sp * math.sin(theta) st.set_normal((x, cp, z)) st.set_uv((seg / sectors, (rings + 1 + ring) / (2 * rings + 1))) st.add_vertex((x * radius, y_offset, z * radius)) row = sectors + 1 # Top hemisphere indices for ring in range(rings): for seg in range(sectors): a = ring * row + seg b = a + row st.add_index(a) st.add_index(a + 1) st.add_index(b) st.add_index(a + 1) st.add_index(b + 1) st.add_index(b) # Connect top hemisphere to cylinder body top ring top_last_ring = rings * row for seg in range(sectors): a = top_last_ring + seg b = cyl_base + seg st.add_index(a) st.add_index(a + 1) st.add_index(b) st.add_index(a + 1) st.add_index(b + 1) st.add_index(b) # Cylinder body indices for seg in range(sectors): a = cyl_base + seg b = a + row st.add_index(a) st.add_index(a + 1) st.add_index(b) st.add_index(a + 1) st.add_index(b + 1) st.add_index(b) # Connect cylinder body bottom ring to bottom hemisphere cyl_bot_ring = cyl_base + row for seg in range(sectors): a = cyl_bot_ring + seg b = bot_base + seg st.add_index(a) st.add_index(a + 1) st.add_index(b) st.add_index(a + 1) st.add_index(b + 1) st.add_index(b) # Bottom hemisphere indices for ring in range(rings): for seg in range(sectors): a = bot_base + ring * row + seg b = a + row st.add_index(a) st.add_index(a + 1) st.add_index(b) st.add_index(a + 1) st.add_index(b + 1) st.add_index(b) return st.commit()