Files
tono/backend/nodes/shade.py

69 lines
2.2 KiB
Python

"""Shaded presentation — render surface with directional lighting."""
from __future__ import annotations
import numpy as np
from scipy.ndimage import sobel
from backend.node_registry import register_node
from backend.data_types import DataField
@register_node(display_name="Shade")
class Shade:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"field": ("DATA_FIELD",),
"azimuth": ("FLOAT", {"default": 315.0, "min": 0.0, "max": 360.0, "step": 1.0}),
"elevation": ("FLOAT", {"default": 45.0, "min": 5.0, "max": 85.0, "step": 1.0}),
"blend": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.05}),
}
}
OUTPUTS = (
('DATA_FIELD', 'shaded'),
)
FUNCTION = "process"
DESCRIPTION = (
"Render a surface with directional hillshade lighting. "
"Azimuth controls the light direction (0=north, 90=east). "
"Elevation controls the light angle above the horizon. "
"Blend mixes original data (0) with shaded relief (1). "
)
KEYWORDS = ("hillshade", "relief", "lighting", "lambertian", "render", "illumination")
def process(self, field: DataField, azimuth: float, elevation: float,
blend: float) -> tuple:
data = np.asarray(field.data, dtype=np.float64)
gx = sobel(data, axis=1) / (8.0 * field.dx)
gy = sobel(data, axis=0) / (8.0 * field.dy)
az_rad = np.radians(azimuth)
el_rad = np.radians(elevation)
# Lambertian shading
lx = np.cos(el_rad) * np.sin(az_rad)
ly = np.cos(el_rad) * np.cos(az_rad)
lz = np.sin(el_rad)
normal_z = 1.0 / np.sqrt(gx**2 + gy**2 + 1.0)
normal_x = -gx * normal_z
normal_y = -gy * normal_z
shade = np.clip(normal_x * lx + normal_y * ly + normal_z * lz, 0.0, 1.0)
# Normalize original data to [0, 1]
dmin, dmax = data.min(), data.max()
if dmax > dmin:
norm_data = (data - dmin) / (dmax - dmin)
else:
norm_data = np.ones_like(data) * 0.5
result = (1.0 - blend) * norm_data + blend * shade
return (field.replace(data=result, si_unit_z=""),)