Source code for simvx.core.nodes_3d.mesh
"""MeshInstance3D -- visible 3D mesh node."""
import numpy as np
from ..descriptors import Property
from ..math.matrices import mat4_from_trs
from .node3d import Node3D
[docs]
class MeshInstance3D(Node3D):
"""Visible 3D object. Holds a Mesh and Material for the renderer.
Set ``skin`` to a :class:`~simvx.core.skeleton.Skeleton` node to enable
skeletal animation. The renderer reads ``skin.joint_matrices`` each frame
to upload bone transforms for vertex skinning.
The ``pivot`` Property controls where the mesh's local origin sits
relative to its bounding box. ``"center"`` (default) treats the
mesh-local origin as the geometric centre: convenient for free-
floating objects. ``"bottom"`` lifts the mesh by half its height
along local +Y, so foot-aligned entities (characters, props,
obstacles) place naturally on a ground plane at ``local_position.y
= 0``. The shift is applied at draw time via the model matrix; the
mesh data itself is untouched.
Usage:
from simvx.core.graphics.mesh import Mesh
from simvx.core.graphics.material import Material
mi = MeshInstance3D(mesh=Mesh.cube(), material=Material(colour=(1, 0, 0)))
# Foot-aligned cube: position.y = 0 puts the bottom on the floor:
mi = MeshInstance3D(mesh=Mesh.cube(), pivot="bottom")
# Skeletal mesh:
mi.skin = skeleton_node
"""
lod_bias = Property(0.0, range=(-10.0, 10.0), hint="LOD distance bias (positive = prefer coarser)")
pivot = Property(
"center",
enum=["center", "bottom"],
hint="Pivot point relative to the mesh bounding box (\"center\" or \"bottom\").",
)
def __init__(self, mesh=None, material=None, skin=None, **kwargs):
super().__init__(**kwargs)
self.mesh = mesh
self.material = material # defaults to white in renderer if None
self._skin = None
if skin is not None:
self.skin = skin
@property
def skin(self):
"""Skeleton node providing joint matrices for vertex skinning.
Accepts a :class:`~simvx.core.skeleton.Skeleton` instance (or ``None``
to disable skinning). Assigning a skeleton does *not* reparent it;
the skeleton should already be part of the scene tree.
"""
return self._skin
[docs]
@skin.setter
def skin(self, value):
from ..skeleton import Skeleton
if value is not None and not isinstance(value, Skeleton):
raise TypeError(f"skin must be a Skeleton node or None, got {type(value).__name__}")
self._skin = value
[docs]
@property
def model_matrix(self) -> np.ndarray:
"""Model transform matrix from global position/rotation/scale.
When ``pivot == "bottom"``, the mesh is pre-translated along its
local +Y by half its bounding-box height so the bottom of the
mesh sits at ``local_position.y``. The shift is applied in the
node's local frame so it rotates / scales with the node.
"""
offset = self._pivot_offset()
if offset is None:
return mat4_from_trs(self.world_position, self.world_rotation, self.world_scale)
# Apply pivot offset in local space: post-multiply by a translation
# that shifts the mesh before the node's TRS is applied.
trs = mat4_from_trs(self.world_position, self.world_rotation, self.world_scale)
offset_mat = np.eye(4, dtype=np.float32)
offset_mat[0, 3] = offset[0]
offset_mat[1, 3] = offset[1]
offset_mat[2, 3] = offset[2]
return (trs @ offset_mat).astype(np.float32)
def _pivot_offset(self) -> np.ndarray | None:
"""Return the local-space shift to apply for the current pivot mode."""
if self.pivot == "center" or self.mesh is None:
return None
if self.pivot == "bottom":
try:
lo, _ = self.mesh.bounding_box()
except (AttributeError, ValueError):
return None
# Shift the mesh up so its lowest Y vertex lands at the node's
# local origin. ``-lo[1]`` works for any mesh: centred or not.
return np.array([0.0, -float(lo[1]), 0.0], dtype=np.float32)
return None