"""Decal3D — projects textures onto surfaces within a box volume."""
from __future__ import annotations
import logging
from .descriptors import Property
from .math.types import Vec3
from .nodes_3d.node3d import Node3D
log = logging.getLogger(__name__)
[docs]
class Decal3D(Node3D):
"""Projects a texture onto nearby geometry within a box-shaped volume.
The decal projects along the node's local **-Y** axis. The ``size`` property
defines the half-extents of the projection box in local space (width, height,
depth). Only geometry within this box and matching the ``cull_mask`` receives
the projected texture.
Properties:
size: Half-extents of the projection box (Vec3).
texture: Path to the albedo/colour texture to project.
normal_texture: Path to the normal-map texture to project.
albedo_mix: Blend factor for the albedo texture (0 = invisible, 1 = full).
normal_mix: Blend factor for the normal-map texture (0 = none, 1 = full).
modulate: Colour tint applied to the projected texture (RGBA tuple).
distance_fade_begin: Distance from camera at which the decal starts fading.
distance_fade_length: Fade range — the decal is fully invisible at
``distance_fade_begin + distance_fade_length``.
cull_mask: Bitmask selecting which render layers receive the projection.
upper_fade: Fade at the top (+Y) edge of the projection box (0 = hard, 1 = full fade).
lower_fade: Fade at the bottom (-Y) edge of the projection box (0 = hard, 1 = full fade).
Example::
decal = Decal3D(position=(0, 2, 0), name="BloodSplat")
decal.texture = "res://textures/blood.png"
decal.size = Vec3(1, 0.5, 1)
decal.albedo_mix = 0.8
"""
size = Property(Vec3(1, 1, 1), hint="Projection box half-extents (width, height, depth)", group="Decal")
texture = Property("", hint="Albedo/colour texture path", group="Decal")
normal_texture = Property("", hint="Normal-map texture path", group="Decal")
albedo_mix = Property(1.0, range=(0, 1), hint="Albedo texture blend factor", group="Decal")
normal_mix = Property(0.0, range=(0, 1), hint="Normal-map blend factor", group="Decal")
modulate = Property((1.0, 1.0, 1.0, 1.0), hint="Colour tint (RGBA)", group="Decal")
distance_fade_begin = Property(0.0, range=(0, 1000), hint="Distance at which fade starts", group="Decal")
distance_fade_length = Property(0.0, range=(0, 1000), hint="Fade range length", group="Decal")
cull_mask = Property(0xFFFFFFFF, range=(0, 0xFFFFFFFF), hint="Render layer bitmask (32 layers)", group="Decal")
upper_fade = Property(0.3, range=(0, 1), hint="Fade at upper (+Y) edge of projection box", group="Decal")
lower_fade = Property(0.3, range=(0, 1), hint="Fade at lower (-Y) edge of projection box", group="Decal")
gizmo_colour = Property((0.4, 0.8, 1.0, 0.5), hint="Editor gizmo colour")
@property
def projection_direction(self) -> Vec3:
"""World-space projection direction (local -Y axis)."""
return Vec3(*(self.world_rotation * Vec3(0, -1, 0)))
[docs]
def set_cull_mask_layer(self, index: int, enabled: bool = True) -> None:
"""Enable or disable a specific cull mask layer (0-31)."""
if not 0 <= index < 32:
raise ValueError(f"Cull mask layer index must be 0-31, got {index}")
if enabled:
self.cull_mask = self.cull_mask | (1 << index)
else:
self.cull_mask = self.cull_mask & ~(1 << index)
[docs]
def is_cull_mask_layer_enabled(self, index: int) -> bool:
"""Check if a specific cull mask layer is enabled (0-31)."""
if not 0 <= index < 32:
raise ValueError(f"Cull mask layer index must be 0-31, got {index}")
return bool(self.cull_mask & (1 << index))
[docs]
def get_gizmo_lines(self) -> list[tuple[Vec3, Vec3]]:
"""Return wireframe edges of the projection box in world space.
The box is centred at the node's global position, oriented by the
node's global rotation, with half-extents given by ``size``. An
additional arrow along the projection direction (-Y) is included.
"""
p = self.world_position
rot = self.world_rotation
sz = self.size if isinstance(self.size, Vec3) else Vec3(self.size)
hx, hy, hz = float(sz[0]), float(sz[1]), float(sz[2])
# Local-space axes scaled by half-extents
rx = Vec3(*(rot * Vec3(hx, 0, 0)))
ry = Vec3(*(rot * Vec3(0, hy, 0)))
rz = Vec3(*(rot * Vec3(0, 0, hz)))
# 8 corners of the oriented box
corners = [
p + sx * rx + sy * ry + sz_ * rz
for sx in (-1, 1)
for sy in (-1, 1)
for sz_ in (-1, 1)
]
# 12 edges: connect corners that differ in exactly one axis
lines: list[tuple[Vec3, Vec3]] = []
for i in range(8):
for j in range(i + 1, 8):
# Indices encode (sx, sy, sz) each as 0 or 1 across 3 bits
diff = i ^ j
if diff in (1, 2, 4): # differ in exactly one bit
lines.append((Vec3(*corners[i]), Vec3(*corners[j])))
# Arrow along projection direction (-Y)
arrow_len = hy * 0.6
arrow_tip = p + Vec3(*(rot * Vec3(0, -hy - arrow_len, 0)))
arrow_base = p + Vec3(*(rot * Vec3(0, -hy, 0)))
lines.append((arrow_base, arrow_tip))
return lines