"""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
# ============================================================================