Source code for simvx.core.nodes_3d.mesh

"""MeshInstance3D -- visible 3D mesh node."""

import logging

import numpy as np

from ..descriptors import Property
from ..math.matrices import mat4_from_trs
from ..properties import NodePath
from .node3d import Node3D

log = logging.getLogger(__name__)


def _scene_root(node) -> object:
    """The top of ``node``'s scene, stopping below a SceneTree's own top node."""
    current = node
    while current.parent is not None and not current.parent._is_tree_root:
        current = current.parent
    return current


class _SkeletonPathProperty(NodePath):
    """The path a file carries for :attr:`MeshInstance3D.skeleton`.

    A node reference has no source form, so a path is what the scene file can
    hold. This keeps that path in step with a skeleton that was assigned as an
    object: a scene is usually built by constructing both nodes, wiring them and
    only then adding them to the tree, which would leave the path empty at the
    moment of assignment and lose the rig on the next save. Reading the path
    re-derives it from the assigned skeleton whenever the two have since come to
    share a scene.
    """

    __slots__ = ()

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        skeleton = getattr(obj, "_skeleton_cache", None)
        if skeleton is not None and _scene_root(skeleton) is _scene_root(obj):
            # Written raw: this is a refresh of a value the node already holds,
            # not an authoring write, so nothing should be validated or notified.
            self.set_raw(obj, skeleton.path)
        return super().__get__(obj)


[docs] class MeshInstance3D(Node3D): """Visible 3D object. Holds a Mesh and Material for the renderer. Set ``skeleton`` to a :class:`~simvx.core.skeleton.Skeleton` to enable GPU vertex skinning: the renderer reads ``skeleton.joint_matrices`` each frame and uploads them as the instance's bone palette. Skinning needs both halves -- the matching joint/weight stream on the mesh and bones on the skeleton, which is what ``import_gltf`` builds for a rigged asset. With either half missing there is nothing to pose the vertices with, so the instance is drawn rigidly and the renderer warns once, naming the node and the missing half. 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.skeleton = 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").', ) skeleton_path = _SkeletonPathProperty( "", hint="Path to the Skeleton posing this mesh (e.g. '/Root/Skeleton'); empty for a rigid mesh", on_change="_on_skeleton_path_changed", ) def __init__(self, mesh=None, material=None, skeleton=None, **kwargs): super().__init__(**kwargs) self.mesh = mesh self.material = material # defaults to white in renderer if None # The skeleton this mesh last resolved or was handed. It is a cache over # ``skeleton_path`` and it is also the only place an out-of-tree # skeleton can be held, which is why the path is not simply read back. self._skeleton_cache = None self._skeleton_warned_path = "" # Guarded, because super().__init__ may have applied a ``skeleton_path=`` # kwarg from a scene file and assigning None here would clear it. if skeleton is not None: self.skeleton = skeleton @property def skeleton(self): """Skeleton posing this mesh, or ``None`` for a rigid mesh. Accepts a :class:`~simvx.core.skeleton.Skeleton` (or ``None`` to turn skinning off). Assigning one does not reparent it and does not require it to be in the tree: the renderer only reads its ``joint_matrices``, so a skeleton driven by an ``AnimationPlayer`` poses the mesh wherever it lives. What the scene file carries is :attr:`skeleton_path`, because a node reference has no source form. **A skeleton that shares a scene with this mesh survives a save; one that lives outside the mesh's tree does not**, and that scene reloads unrigged. The runtime promise above is unchanged: only the trip through a file is narrower. Skinning needs both halves: per-vertex joints and weights on the mesh, and bones on the skeleton. With either one missing the skeleton is ignored -- a bone-less ``Skeleton()`` no more skins the mesh than an unrigged mesh does -- and the instance renders rigidly, exactly as it would with no skeleton, while the renderer logs one warning naming this node and the missing half. The assignment itself stands: the fallback is a draw decision, so reading ``skeleton`` back returns what was set. """ cached = self._skeleton_cache if cached is not None: return cached path = self.skeleton_path if not path: return None return self._resolve_skeleton_path(path)
[docs] @skeleton.setter def skeleton(self, value): from ..skeleton import Skeleton if value is not None and not isinstance(value, Skeleton): raise TypeError(f"skeleton must be a Skeleton node or None, got {type(value).__name__}") # Path first: writing it clears the cache, which is what a path written # from anywhere else has to do. if value is None: self.skeleton_path = "" elif _scene_root(value) is _scene_root(self): self.skeleton_path = value.path self._skeleton_cache = value
def _on_skeleton_path_changed(self) -> None: """A new path names a new skeleton, so the resolved one is stale.""" self._skeleton_cache = None self._skeleton_warned_path = "" def _resolve_skeleton_path(self, path: str): """The Skeleton ``path`` names, or ``None`` with one warning per bad path.""" from ..skeleton import Skeleton node = self.node_at(path, None) if not isinstance(node, Skeleton): if self._skeleton_warned_path != path: self._skeleton_warned_path = path log.warning( "MeshInstance3D %r: skeleton_path %r names %s", self.name, path, "no node" if node is None else f"a {type(node).__name__}, not a Skeleton", ) return None self._skeleton_cache = node return node
[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