"""Light3D, DirectionalLight3D, PointLight3D, SpotLight3D -- 3D light nodes."""
from __future__ import annotations
import math
import numpy as np
from ..descriptors import Property
from ..math.types import Quat, Vec3
from .node3d import Node3D
[docs]
class Light3D(Node3D):
"""Base class for 3D light nodes."""
colour = Property((1.0, 1.0, 1.0), group="Light")
intensity = Property(1.0, group="Light")
shadows = Property(False, group="Light")
light_cull_mask = Property(
0xFFFFFFFF, range=(0, 0xFFFFFFFF), hint="Light cull mask -- which render layers this light affects"
)
[docs]
def set_light_cull_mask_layer(self, index: int, enabled: bool = True) -> None:
"""Enable or disable a specific light cull mask layer (0-31)."""
if not 0 <= index < 32:
raise ValueError(f"Light cull mask layer index must be 0-31, got {index}")
if enabled:
self.light_cull_mask = self.light_cull_mask | (1 << index)
else:
self.light_cull_mask = self.light_cull_mask & ~(1 << index)
[docs]
def is_light_cull_mask_layer_enabled(self, index: int) -> bool:
"""Check if a specific light cull mask layer is enabled (0-31)."""
if not 0 <= index < 32:
raise ValueError(f"Light cull mask layer index must be 0-31, got {index}")
return bool(self.light_cull_mask & (1 << index))
[docs]
class DirectionalLight3D(Light3D):
"""Directional light -- direction derived from node's forward vector."""
gizmo_colour = Property((1.0, 0.95, 0.5, 0.4), hint="Editor gizmo colour")
@property
def direction(self) -> Vec3:
"""Light direction (world-space). Derived from forward vector."""
return self.forward
@direction.setter
def direction(self, v):
"""Set light direction by rotating the node so forward matches *v*."""
d = Vec3(v).normalized()
self.world_rotation = Quat.look_at(d)
[docs]
def get_gizmo_lines(self) -> list[tuple[Vec3, Vec3]]:
"""Return direction arrow and parallel rays showing light direction."""
p = self.world_position
fwd = self.forward
lines: list[tuple[Vec3, Vec3]] = [(p, p + fwd * 2.5)]
# Two parallel side rays
up = Vec3(0, 1, 0) if abs(fwd.y) < 0.9 else Vec3(1, 0, 0)
right = Vec3(*np.cross(fwd, up))
rn = np.linalg.norm(right)
if rn > 1e-6:
right = right * (0.4 / rn)
lines.append((p + right, p + right + fwd * 2.0))
lines.append((p - right, p - right + fwd * 2.0))
return lines
[docs]
class PointLight3D(Light3D):
"""Omnidirectional point light with range-based attenuation."""
range = Property(10.0, group="Light")
gizmo_colour = Property((1.0, 0.95, 0.5, 0.4), hint="Editor gizmo colour")
[docs]
def get_gizmo_lines(self) -> list[tuple[Vec3, Vec3]]:
"""Return 3 circles showing the light range sphere."""
from ..physics_nodes import _circle_lines_3d
p = self.world_position
r = float(self.range)
lines: list[tuple[Vec3, Vec3]] = []
lines.extend(_circle_lines_3d(p, Vec3(1, 0, 0), Vec3(0, 1, 0), r))
lines.extend(_circle_lines_3d(p, Vec3(1, 0, 0), Vec3(0, 0, 1), r))
lines.extend(_circle_lines_3d(p, Vec3(0, 1, 0), Vec3(0, 0, 1), r))
return lines
[docs]
class SpotLight3D(Light3D):
"""Spot light with inner/outer cone angles (degrees)."""
range = Property(10.0, group="Light")
inner_cone = Property(30.0, group="Light")
outer_cone = Property(45.0, group="Light")
gizmo_colour = Property((1.0, 0.95, 0.5, 0.4), hint="Editor gizmo colour")
[docs]
def get_gizmo_lines(self) -> list[tuple[Vec3, Vec3]]:
"""Return cone wireframe showing spot light direction and angle."""
from ..physics_nodes import _circle_lines_3d
p = self.world_position
fwd = self.forward
r = float(self.range)
cone_angle = math.radians(float(self.outer_cone))
base_radius = r * math.tan(cone_angle * 0.5)
base_center = p + fwd * r
# Compute orthonormal basis
up = Vec3(0, 1, 0) if abs(fwd.y) < 0.9 else Vec3(1, 0, 0)
right_raw = np.cross(fwd, up)
rn = np.linalg.norm(right_raw)
if rn < 1e-6:
return [(p, base_center)]
right = Vec3(*(right_raw / rn))
u = Vec3(*np.cross(right, fwd))
lines: list[tuple[Vec3, Vec3]] = []
# Base circle
lines.extend(_circle_lines_3d(base_center, right, u, base_radius, 16))
# 4 lines from apex to base circle
for angle in [0.0, math.pi * 0.5, math.pi, math.pi * 1.5]:
edge = base_center + right * (base_radius * math.cos(angle)) + u * (base_radius * math.sin(angle))
lines.append((p, edge))
return lines