"""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,
)