Source code for simvx.core.fog_volume

"""FogVolume3D — Localised volumetric fog node for 3D scenes."""


from __future__ import annotations

import logging
import math
from enum import IntEnum

from .descriptors import Property
from .math.types import Vec3
from .nodes_3d.node3d import Node3D

log = logging.getLogger(__name__)


[docs] class FogVolumeShape(IntEnum): """Shape of a FogVolume3D region.""" BOX = 0 SPHERE = 1 CYLINDER = 2
[docs] class FogMaterial: """Custom material controlling fog appearance inside a FogVolume3D. Attributes: albedo: Base colour of the fog (RGBA). density: Override density (applied multiplicatively with the volume's density). emission: Emissive colour contribution (RGB + intensity in alpha). scattering: Anisotropy of light scattering (-1 backward, 0 isotropic, +1 forward). absorption: How much light the fog absorbs per unit distance. """ __slots__ = ("albedo", "density", "emission", "scattering", "absorption") def __init__( self, *, albedo: tuple[float, ...] = (1.0, 1.0, 1.0, 1.0), density: float = 1.0, emission: tuple[float, ...] = (0.0, 0.0, 0.0, 0.0), scattering: float = 0.0, absorption: float = 0.0, ) -> None: self.albedo = albedo self.density = density self.emission = emission self.scattering = scattering self.absorption = absorption
[docs] def __repr__(self) -> str: return f"<FogMaterial density={self.density} scattering={self.scattering}>"
[docs] class FogVolume3D(Node3D): """Localised volumetric fog region in 3D space. Places a shaped fog volume (box, sphere, or cylinder) into the scene. The renderer samples this volume during the lighting pass to produce volumetric fog effects such as god rays, ground mist, and dust clouds. Attributes: shape: Geometric shape of the volume (box, sphere, cylinder). size: Extents of the volume in local space (full width/height/depth for box, diameter on each axis for sphere, diameter + height for cylinder). density: Fog density inside the volume (0 = transparent, 1 = fully opaque). albedo: Fog colour as RGBA tuple. falloff: Rate at which fog density drops off near the volume boundary. height_falloff: Vertical density gradient — higher values concentrate fog at the bottom. priority: Rendering priority when volumes overlap (higher wins). Example:: fog = FogVolume3D(position=(0, 1, 0)) fog.shape = FogVolumeShape.SPHERE fog.size = Vec3(5, 5, 5) fog.density = 0.3 fog.albedo = (0.8, 0.85, 0.9, 1.0) scene.add_child(fog) """ shape = Property(FogVolumeShape.BOX, enum=[s.value for s in FogVolumeShape], hint="Volume shape", group="Fog Volume") size = Property((4.0, 4.0, 4.0), hint="Volume extents (x, y, z)", group="Fog Volume") density = Property(0.5, range=(0.0, 1.0), hint="Fog density", group="Fog Volume") albedo = Property((1.0, 1.0, 1.0, 1.0), hint="Fog colour (RGBA)", group="Fog Volume") falloff = Property(1.0, range=(0.0, 10.0), hint="Edge density falloff exponent", group="Fog Volume") height_falloff = Property(0.0, range=(0.0, 10.0), hint="Vertical density gradient", group="Fog Volume") priority = Property(0, range=(-100, 100), hint="Overlap priority (higher wins)", group="Fog Volume") gizmo_colour = Property((0.4, 0.6, 1.0, 0.5), hint="Editor gizmo colour") def __init__(self, **kwargs): super().__init__(**kwargs) self._material: FogMaterial | None = None @property def material(self) -> FogMaterial | None: """Optional FogMaterial for advanced fog appearance control.""" return self._material @material.setter def material(self, mat: FogMaterial | None) -> None: self._material = mat @property def effective_density(self) -> float: """Density after applying material multiplier.""" d = float(self.density) if self._material is not None: d *= self._material.density return d
[docs] def get_gizmo_lines(self) -> list[tuple[Vec3, Vec3]]: """Return wireframe lines for the editor gizmo based on current shape.""" s = self.size sx, sy, sz = float(s[0]) * 0.5, float(s[1]) * 0.5, float(s[2]) * 0.5 p = self.world_position shape_val = int(self.shape) if shape_val == FogVolumeShape.BOX: return self._box_lines(p, sx, sy, sz) elif shape_val == FogVolumeShape.SPHERE: return self._sphere_lines(p, sx, sy, sz) else: # CYLINDER return self._cylinder_lines(p, sx, sy, sz)
# -- Private gizmo helpers ------------------------------------------------ @staticmethod def _box_lines(p: Vec3, hx: float, hy: float, hz: float) -> list[tuple[Vec3, Vec3]]: """12-edge wireframe box centred at *p* with half-extents.""" corners = [ Vec3(p.x + dx * hx, p.y + dy * hy, p.z + dz * hz) for dx in (-1, 1) for dy in (-1, 1) for dz in (-1, 1) ] # Corner indices: 0=(-,-,-) 1=(-,-,+) 2=(-,+,-) 3=(-,+,+) # 4=(+,-,-) 5=(+,-,+) 6=(+,+,-) 7=(+,+,+) edges = [ (0, 1), (2, 3), (4, 5), (6, 7), # Z-aligned (0, 2), (1, 3), (4, 6), (5, 7), # Y-aligned (0, 4), (1, 5), (2, 6), (3, 7), # X-aligned ] return [(corners[a], corners[b]) for a, b in edges] @staticmethod def _sphere_lines(p: Vec3, hx: float, hy: float, hz: float) -> list[tuple[Vec3, Vec3]]: """Three orthogonal circle outlines approximating a sphere (or ellipsoid).""" from .physics_nodes import _circle_lines_3d lines: list[tuple[Vec3, Vec3]] = [] # XY plane (radius uses hx, hy via separate circles for true ellipsoid support) rx, ry, rz = hx, hy, hz lines.extend(_circle_lines_3d(p, Vec3(1, 0, 0), Vec3(0, 1, 0), (rx + ry) * 0.5)) lines.extend(_circle_lines_3d(p, Vec3(1, 0, 0), Vec3(0, 0, 1), (rx + rz) * 0.5)) lines.extend(_circle_lines_3d(p, Vec3(0, 1, 0), Vec3(0, 0, 1), (ry + rz) * 0.5)) return lines @staticmethod def _cylinder_lines(p: Vec3, hx: float, hy: float, hz: float) -> list[tuple[Vec3, Vec3]]: """Top and bottom circles plus vertical edge lines for a Y-axis cylinder.""" from .physics_nodes import _circle_lines_3d radius = (hx + hz) * 0.5 # Average XZ radii top = Vec3(p.x, p.y + hy, p.z) bottom = Vec3(p.x, p.y - hy, p.z) lines: list[tuple[Vec3, Vec3]] = [] lines.extend(_circle_lines_3d(top, Vec3(1, 0, 0), Vec3(0, 0, 1), radius)) lines.extend(_circle_lines_3d(bottom, Vec3(1, 0, 0), Vec3(0, 0, 1), radius)) # 4 vertical edge lines connecting top and bottom circles for angle_frac in (0.0, 0.25, 0.5, 0.75): angle = angle_frac * math.tau dx = radius * math.cos(angle) dz = radius * math.sin(angle) lines.append(( Vec3(top.x + dx, top.y, top.z + dz), Vec3(bottom.x + dx, bottom.y, bottom.z + dz), )) return lines