"""Decal3D: projects textures onto surfaces within a box volume."""
import logging
from .descriptors import Property
from .math.types import Vec3
from .nodes_3d.node3d import Node3D
from .properties import Bitmask, Colour, get_mask_bit, set_mask_bit
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
is the FULL extent of the projection box in local space (width, height,
depth): a decal of ``size = Vec3(2, 1, 2)`` covers two units across and one
unit deep, centred on the node. Only geometry within this box and matching
the ``cull_mask`` receives the projected texture.
Properties:
size: Full extents of the projection box (Vec3), centred on the node.
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(2, 1, 2)
decal.albedo_mix = 0.8
"""
size = Property(Vec3(2, 2, 2), hint="Projection box full 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 = Colour((1.0, 1.0, 1.0, 1.0), 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 = Bitmask(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 = Colour((0.4, 0.8, 1.0, 0.5))
[docs]
@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)."""
self.cull_mask = set_mask_bit(self.cull_mask, index, enabled, label="Cull mask layer")
[docs]
def is_cull_mask_layer_enabled(self, index: int) -> bool:
"""Check if a specific cull mask layer is enabled (0-31)."""
return get_mask_bit(self.cull_mask, index, label="Cull mask layer")
[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, and spans the full 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]) * 0.5, float(sz[1]) * 0.5, float(sz[2]) * 0.5
# 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