"""Node3D -- base 3D spatial node."""
import math
import numpy as np
from .._spatial_property import _SpatialVecProperty
from ..descriptors import Notification, 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
"""
position = _SpatialVecProperty(3, hint="Local position")
rotation = Property(
default_factory=Quat,
on_change="_invalidate_transform",
persist=True,
hint="Local rotation (quaternion)",
)
scale = _SpatialVecProperty(3, default=(1.0, 1.0, 1.0), hint="Local scale")
render_layer = Property(1, range=(0, 0xFFFFFFFF), hint="Render layer bitmask (32 layers)")
def __init__(self, **kwargs):
# State that ``_invalidate_transform`` and the spatial Property setters
# touch must exist before ``super().__init__()`` walks Property kwargs.
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
super().__init__(**kwargs)
@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))
[docs]
@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 dirty; fire TRANSFORM_CHANGED.
The notification fires on every mutation and propagates only to
Node3D descendants -- plain Node children don't own a transform.
The dirty-flag short-circuit avoids repeated child propagation
when a parent is mutated multiple times before any world_* read.
"""
self._notification(Notification.TRANSFORM_CHANGED)
if not self._transform_dirty:
self._transform_dirty = True
for child in self.children:
if isinstance(child, Node3D):
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
[docs]
@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
[docs]
@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)
[docs]
@property
def world_scale(self) -> Vec3:
if self._transform_dirty:
self._recompute_global_transform()
return self._cached_world_scale
[docs]
@property
def forward(self) -> Vec3:
return self.world_rotation * Vec3(0, 0, -1)
[docs]
@property
def right(self) -> Vec3:
return self.world_rotation * Vec3(1, 0, 0)
[docs]
@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 face_along(
self,
forward: tuple[float, float, float] | np.ndarray,
up: tuple[float, float, float] | np.ndarray | None = None,
) -> None:
"""Rotate so the local -Z axis aligns with the given world-space direction.
Convenience for setting orientation from a velocity / heading vector
without first computing a target point. Wraps :meth:`Quat.look_at`,
which already negates ``forward`` to match the engine's -Z convention.
Args:
forward: World-space direction the front of the node should face.
up: World-space up hint (defaults to ``(0, 1, 0)``).
Raises:
ValueError: If ``forward`` is zero-length (no well-defined facing).
"""
fwd = Vec3(forward)
if fwd.length() < 1e-6:
raise ValueError("face_along requires a non-zero forward vector")
up_v = Vec3(0, 1, 0) if up is None else Vec3(up)
self.world_rotation = Quat.look_at(fwd.normalized(), up_v)
[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
pos = self.position
self.position = Vec3(
(pos.x + hx) % (2 * hx) - hx,
(pos.y + hy) % (2 * hy) - hy,
(pos.z + hz) % (2 * hz) - hz,
)