Source code for simvx.core.mesh_builder

"""Procedural geometry construction tool.

MeshBuilder provides a vertex-by-vertex mesh building API: set the attributes
that apply, add a vertex, and commit a ``Mesh`` at the end. ImmediateGeometry3D
rebuilds its mesh every frame from a user callback, suitable for debug
visualisations, trails, and dynamic geometry. Primitive shapes come from the
``Mesh`` classmethods (``Mesh.box()``, ``Mesh.plane()``, ``Mesh.sphere()`` and the rest).

Usage::

    from simvx.core import MeshBuilder, PrimitiveType

    st = MeshBuilder()
    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()
"""

import logging
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__ = [
    "MeshBuilder",
    "PrimitiveType",
    "ImmediateGeometry3D",
]


[docs] class PrimitiveType(IntEnum): """Primitive topology for MeshBuilder.""" TRIANGLES = 0 LINES = 1 POINTS = 2 TRIANGLE_STRIP = 3
[docs] class MeshBuilder: """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 -------------------------------------------------------
[docs] @property def vertex_count(self) -> int: """Number of vertices accumulated so far.""" return len(self._positions)
[docs] @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 ``MeshBuilder``, passes it to your callback, and commits the result as the renderable mesh. Usage:: class MyTrail(ImmediateGeometry3D): def _draw_geometry(self, st: MeshBuilder) -> None: st.begin(PrimitiveType.TRIANGLES) # ... add vertices ... Or with signals:: def draw_fn(st: MeshBuilder) -> None: st.begin(PrimitiveType.TRIANGLES) # ... ig = ImmediateGeometry3D() ig.draw.connect(draw_fn) """ def __init__(self, **kwargs): from .signals import Signal super().__init__(**kwargs) self.draw = Signal() self.mesh = None self.material = None self._tool = MeshBuilder()
[docs] def on_update(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: MeshBuilder) -> None: """Override to provide geometry. Called every frame.""" pass