"""
Per-array hillshade material parameters.
Each :class:`WolfArray` carries a :class:`HillshadeRenderParams` instance
that stores the material properties used by the per-pixel Blinn-Phong
hillshade shader. Sun position and global enable flags remain on the
viewer (:class:`WolfMapViewer`).
The viewer can operate in **synchronised** mode (all arrays share one
set of material parameters) or **per-array** mode (each array has its
own parameters, and the panel reflects the active array).
Author: HECE - University of Liege
Date: 2026
Copyright (c) 2026 University of Liege. All rights reserved.
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
[docs]
logger = logging.getLogger(__name__)
# Sidecar file suffix appended to WolfArray.filename
[docs]
SIDECAR_SUFFIX = '.hillshade.json'
[docs]
class HillshadeRenderParams:
"""Material parameters for per-pixel Blinn-Phong hillshade.
These are the parameters that **differ per WolfArray** when running
in per-array mode. Sun position, enable flag and multi-directional
flag remain on the viewer.
All values are stored in *user-facing* convention:
* **glossiness** ∈ [0, 1]: 0 = matte (broad highlight), 1 = mirror
(sharp highlight). The shader receives ``roughness = 1 − glossiness``.
* **highlight** ∈ [0, 1]: 0 = palette-tinted specular (subtle),
1 = pure white specular (prominent). The shader receives
``metallic = 1 − highlight``.
"""
__slots__ = ('z_exaggeration', 'specular', 'glossiness', 'highlight')
def __init__(self, *,
z_exaggeration: float = 1.0,
specular: float = 0.0,
glossiness: float = 0.5,
highlight: float = 1.0):
[docs]
self.z_exaggeration = z_exaggeration
[docs]
self.specular = specular
[docs]
self.glossiness = glossiness
[docs]
self.highlight = highlight
# ---- Cloning ----
[docs]
def copy(self) -> "HillshadeRenderParams":
return HillshadeRenderParams(
z_exaggeration=self.z_exaggeration,
specular=self.specular,
glossiness=self.glossiness,
highlight=self.highlight,
)
[docs]
def copy_from(self, other: "HillshadeRenderParams") -> None:
"""Copy all values from *other* into *self*."""
self.z_exaggeration = other.z_exaggeration
self.specular = other.specular
self.glossiness = other.glossiness
self.highlight = other.highlight
# ---- Serialisation ----
[docs]
def to_dict(self) -> dict:
return {
'z_exaggeration': self.z_exaggeration,
'specular': self.specular,
'glossiness': self.glossiness,
'highlight': self.highlight,
}
@classmethod
[docs]
def from_dict(cls, d: dict) -> "HillshadeRenderParams":
return cls(
z_exaggeration=float(d.get('z_exaggeration', 1.0)),
specular=float(d.get('specular', 0.0)),
glossiness=float(d.get('glossiness', 0.5)),
highlight=float(d.get('highlight', 1.0)),
)
# ---- JSON sidecar persistence ----
[docs]
def save(self, array_filename: str | Path) -> None:
"""Write a ``*.hillshade.json`` sidecar next to *array_filename*."""
path = Path(str(array_filename) + SIDECAR_SUFFIX)
try:
with open(path, 'w', encoding='utf-8') as f:
json.dump(self.to_dict(), f, indent=2)
except OSError:
logger.warning('Could not write hillshade params to %s', path)
@classmethod
[docs]
def load(cls, array_filename: str | Path) -> "HillshadeRenderParams | None":
"""Load from sidecar if it exists, else return *None*."""
path = Path(str(array_filename) + SIDECAR_SUFFIX)
if not path.is_file():
return None
try:
with open(path, 'r', encoding='utf-8') as f:
return cls.from_dict(json.load(f))
except (OSError, json.JSONDecodeError, KeyError):
logger.warning('Could not read hillshade params from %s', path)
return None
# ---- Equality (for tests & sync logic) ----
def __eq__(self, other):
if not isinstance(other, HillshadeRenderParams):
return NotImplemented
return (self.z_exaggeration == other.z_exaggeration
and self.specular == other.specular
and self.glossiness == other.glossiness
and self.highlight == other.highlight)
def __repr__(self):
return (f'HillshadeRenderParams(z_exaggeration={self.z_exaggeration}, '
f'specular={self.specular}, glossiness={self.glossiness}, '
f'highlight={self.highlight})')