Source code for simvx.core.nodes_3d.node3d

"""Node3D -- base 3D spatial node."""

from __future__ import annotations

import math

import numpy as np

from ..descriptors import Property
from ..math.types import Quat, Vec3
from ..node import Node


class _ObservedVec3(Vec3):
    """Vec3 subclass that calls ``_notify`` on in-place mutation."""

    def __new__(cls, *args, _notify=None, **kwargs):
        obj = super().__new__(cls, *args, **kwargs)
        obj._notify = _notify
        return obj

    def __array_finalize__(self, obj):
        self._notify = getattr(obj, "_notify", None)

    def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
        args = [np.asarray(i) if isinstance(i, Vec3) else i for i in inputs]
        out = kwargs.pop("out", None)
        if out is not None:
            kwargs["out"] = tuple(np.asarray(o) if isinstance(o, Vec3) else o for o in out)
        result = getattr(ufunc, method)(*args, **kwargs)
        if isinstance(result, np.ndarray) and result.shape == (3,):
            return result.view(Vec3)
        return result

    def __setitem__(self, key, value):
        super().__setitem__(key, value)
        if self._notify:
            self._notify()

    @Vec3.x.setter
    def x(self, val):
        self[0] = val

    @Vec3.y.setter
    def y(self, val):
        self[1] = val

    @Vec3.z.setter
    def z(self, val):
        self[2] = val


[docs] class Node3D(Node): """3D spatial node with position, rotation, and scale. Extends ``Node`` with a 3D transform and cached global-transform propagation through the hierarchy. All 3D objects -- meshes, cameras, lights -- inherit from this. Attributes: position: Local position as ``Vec3``. rotation: Local rotation as ``Quat`` (quaternion). scale: Local scale as ``Vec3`` (default ``(1, 1, 1)``). Example:: cube = Node3D(position=(0, 2, -5), name="Cube") cube.rotate_y(math.radians(45)) print(cube.forward) # unit vector along the local -Z axis """ render_layer = Property(1, range=(0, 0xFFFFFFFF), hint="Render layer bitmask (32 layers)") def __init__(self, position=None, rotation=None, scale=None, **kwargs): if position is not None and not isinstance(position, (Vec3, tuple, list, np.ndarray)): raise TypeError(f"Node3D position must be Vec3, tuple, list, or ndarray, got {type(position).__name__}") if rotation is not None and not isinstance(rotation, Quat): raise TypeError(f"Node3D rotation must be a Quat, got {type(rotation).__name__}") if scale is not None and not isinstance(scale, (Vec3, tuple, list, np.ndarray)): raise TypeError(f"Node3D scale must be Vec3, tuple, list, or ndarray, got {type(scale).__name__}") super().__init__(**kwargs) self._position = _ObservedVec3(position if position is not None else (0, 0, 0), _notify=self._invalidate_transform) self._rotation: Quat = rotation or Quat() self._scale = _ObservedVec3(scale if scale is not None else (1, 1, 1), _notify=self._invalidate_transform) self._transform_dirty: bool = True self._cached_world_position: Vec3 | None = None self._cached_world_rotation: Quat | None = None self._cached_world_scale: Vec3 | None = None # -- Local transform properties with dirty invalidation -- @property def position(self): return self._position @position.setter def position(self, v): self._position = _ObservedVec3(v, _notify=self._invalidate_transform) self._invalidate_transform() @property def rotation(self) -> Quat: return self._rotation @rotation.setter def rotation(self, v): self._rotation = v if isinstance(v, Quat) else Quat(v) self._invalidate_transform() @property def scale(self): return self._scale @scale.setter def scale(self, v): self._scale = _ObservedVec3(v, _notify=self._invalidate_transform) self._invalidate_transform() @property def rotation_degrees(self) -> Vec3: """Euler angles in degrees (convenience for editor display).""" rad = self._rotation.euler_angles() return Vec3(math.degrees(rad.x), math.degrees(rad.y), math.degrees(rad.z)) @rotation_degrees.setter def rotation_degrees(self, deg): if not isinstance(deg, Vec3): deg = Vec3(deg) self.rotation = Quat.from_euler(math.radians(deg.x), math.radians(deg.y), math.radians(deg.z)) # -- Transform cache invalidation -- def _invalidate_transform(self): """Mark this node and all descendants as needing global transform recomputation.""" if not self._transform_dirty: self._transform_dirty = True for child in self.children: if isinstance(child, Node3D) and not child._transform_dirty: child._invalidate_transform() def _recompute_global_transform(self): """Recompute and cache all global transform components.""" if self.parent and isinstance(self.parent, Node3D): rot = self.parent.world_rotation scaled = rot * (self._position * self.parent.world_scale) self._cached_world_position = self.parent.world_position + scaled self._cached_world_rotation = rot * self._rotation self._cached_world_scale = self.parent.world_scale * self._scale else: self._cached_world_position = Vec3(self._position) self._cached_world_rotation = Quat(self._rotation) self._cached_world_scale = Vec3(self._scale) self._transform_dirty = False # -- Global transform properties (cached) -- @property def world_position(self) -> Vec3: if self._transform_dirty: self._recompute_global_transform() return self._cached_world_position @world_position.setter def world_position(self, v: tuple[float, float, float] | np.ndarray): if self.parent and isinstance(self.parent, Node3D): inv_rot = self.parent.world_rotation.inverse() self.position = inv_rot * (v - self.parent.world_position) / self.parent.world_scale else: self.position = Vec3(v) @property def world_rotation(self) -> Quat: if self._transform_dirty: self._recompute_global_transform() return self._cached_world_rotation @world_rotation.setter def world_rotation(self, v: Quat): if self.parent and isinstance(self.parent, Node3D): self.rotation = self.parent.world_rotation.inverse() * v else: self.rotation = Quat(v) @property def world_scale(self) -> Vec3: if self._transform_dirty: self._recompute_global_transform() return self._cached_world_scale @property def forward(self) -> Vec3: return self.world_rotation * Vec3(0, 0, -1) @property def right(self) -> Vec3: return self.world_rotation * Vec3(1, 0, 0) @property def up(self) -> Vec3: return self.world_rotation * Vec3(0, 1, 0)
[docs] def translate(self, offset: tuple[float, float, float] | np.ndarray): """Move by offset in local space.""" self.position = self._position + offset
[docs] def translate_global(self, offset: tuple[float, float, float] | np.ndarray): """Move by offset in world space.""" self.world_position = self.world_position + offset
[docs] def rotate(self, axis: tuple[float, float, float] | np.ndarray, angle: float): """Rotate around an axis by the given angle in radians.""" self.rotation = self._rotation.rotate(axis, angle)
[docs] def rotate_x(self, angle: float): self.rotate((1, 0, 0), angle)
[docs] def rotate_y(self, angle: float): self.rotate((0, 1, 0), angle)
[docs] def rotate_z(self, angle: float): self.rotate((0, 0, 1), angle)
[docs] def look_at(self, target: tuple[float, float, float] | np.ndarray, up=None): """Rotate to face a target position in world space.""" up = up or Vec3(0, 1, 0) direction = (Vec3(target) - self.world_position).normalized() self.world_rotation = Quat.look_at(direction, up)
[docs] def set_render_layer(self, index: int, enabled: bool = True) -> None: """Enable or disable a specific render layer (0-31).""" if not 0 <= index < 32: raise ValueError(f"Render layer index must be 0-31, got {index}") if enabled: self.render_layer = self.render_layer | (1 << index) else: self.render_layer = self.render_layer & ~(1 << index)
[docs] def is_on_render_layer(self, index: int) -> bool: """Check if this node is on a specific render layer (0-31).""" if not 0 <= index < 32: raise ValueError(f"Render layer index must be 0-31, got {index}") return bool(self.render_layer & (1 << index))
[docs] def wrap_bounds(self, bounds: tuple[float, float, float] | np.ndarray, margin: float = 1.0): """Wrap position within a 3D volume centered at origin.""" hx, hy, hz = bounds[0] / 2 + margin, bounds[1] / 2 + margin, bounds[2] / 2 + margin self.position = Vec3( (self._position.x + hx) % (2 * hx) - hx, (self._position.y + hy) % (2 * hy) - hy, (self._position.z + hz) % (2 * hz) - hz, )