Source code for dazpy.materials

"""Domain-level Iray Uber Base material setup built on the DazMaterial primitive.

Provides :class:`IrayMaterial` (declarative material spec), :class:`TextureMap`
(texture-slot assignment with file-existence validation), and
:class:`SurfaceProperty` (generic named Iray surface-channel override) plus
:func:`apply_iray_material` / :func:`apply_texture_map` to apply them, and
:func:`get_surface_property` / :func:`set_surface_property` for ad hoc access
to channels not covered by :class:`IrayMaterial`'s typed fields.

``DzMaterial`` has no dedicated Iray property-holder indirection the way
``DzRenderMgr.getActiveRenderer().getPropertyHolder()`` does for render
settings -- it inherits ``findProperty``/``findPropertyByLabel`` directly
from ``DzElement``, and Iray Uber Base channels (Base Color, Metallic
Weight, Glossy Roughness, ...) are ordinary named properties on the material
itself. Texture-slot assignment goes through the *property* object's
``setMap(path)`` (defined on ``DzNumericProperty``), not through the
material. The channel labels in ``_CHANNEL_LABELS`` are confirmed against a
live DAZ Studio instance -- see
``docs/superpowers/specs/2026-08-15-dazpy-materials-design.md`` for details.
"""

from __future__ import annotations

import json
import os
from dataclasses import dataclass, field
from typing import TYPE_CHECKING

from ._script_builder import ScriptBuilder
from .exceptions import MaterialError

if TYPE_CHECKING:
    from ._material import DazMaterial


# Iray Uber Base shader channel display labels -- confirmed against a live
# DAZ Studio instance (a prop's default Iray Uber Base material, and a
# Genesis 9 figure's eye-moisture/tear materials, both of which use the
# classic Uber Base shader rather than the PBRSkin variant). Note the label
# ("Metallicity", "Base Bump") can differ from the underlying property name
# ("Metallic Weight", "Bump Strength") -- findPropertyByLabel() matches on
# the former.
_CHANNEL_LABELS: dict[str, str] = {
    "base_color": "Base Color",
    "metallic_weight": "Metallicity",
    "roughness": "Glossy Roughness",
    "glossy_reflectivity": "Glossy Reflectivity",
    "cutout_opacity": "Cutout Opacity",
    "bump_strength": "Base Bump",
    "top_coat_weight": "Top Coat Weight",
    "normal": "Normal Map",
    "bump": "Base Bump",
    "diffuse": "Base Color",
    "metallic": "Metallicity",
}


[docs] @dataclass(frozen=True) class TextureMap: """One texture-slot assignment for an Iray material channel. Args: channel: A key into the known Iray channel labels (e.g. ``"base_color"``, ``"normal"``, ``"bump"``, ``"metallic"``, ``"roughness"``, ``"cutout_opacity"``) or an arbitrary DazScript property display label not in that table. file_path: Absolute path to an image file on disk. Must exist -- validated by :func:`apply_texture_map` before any DazScript call is made, since an invalid path passed to ``setMap()`` can hang or crash DAZ Studio via a blocking file-not-found dialog. """ channel: str file_path: str
[docs] @dataclass(frozen=True) class SurfaceProperty: """Generic named Iray surface-channel override. For channels not covered by :class:`IrayMaterial`'s typed fields. Args: label: The DazScript property display label (``getLabel()``). value: The value to set. Must be JSON-serialisable. """ label: str value: object
[docs] @dataclass(frozen=True) class IrayMaterial: """Declarative Iray Uber Base material spec. Targets the classic Iray Uber Base shader specifically. Figures using the PBRSkin shader variant (e.g. default Genesis 9 skin materials, which have no ``Cutout Opacity``/``Glossy Roughness`` channels at all) need different channel labels -- use :class:`SurfaceProperty` / :func:`set_surface_property` with PBRSkin's own labels instead. Args: base_color: ``(r, g, b)`` in the 0-255 range. metallic_weight: 0.0-1.0. roughness: 0.0-1.0 (Glossy Roughness). glossy_reflectivity: 0.0-1.0. cutout_opacity: 0.0 (fully cut out) - 1.0 (opaque). bump_strength: Bump map strength multiplier. top_coat_weight: 0.0-1.0. textures: Texture-slot assignments, applied after the typed fields above. properties: Ad hoc named-channel overrides, applied last so they can supersede any typed field or texture set in the same call. """ base_color: tuple[int, int, int] | None = None metallic_weight: float | None = None roughness: float | None = None glossy_reflectivity: float | None = None cutout_opacity: float | None = None bump_strength: float | None = None top_coat_weight: float | None = None textures: tuple[TextureMap, ...] = field(default_factory=tuple) properties: tuple[SurfaceProperty, ...] = field(default_factory=tuple)
def _channel_label(channel: str) -> str: return _CHANNEL_LABELS.get(channel, channel) def _set_channel_value(material: "DazMaterial", label: str, value: object) -> None: serialized = ScriptBuilder.serialize_arg(value) script = ScriptBuilder.iife(f""" var m = {material._locator}; if (!m) return {{"error": "material_not_found"}}; var p = m.findPropertyByLabel({json.dumps(label)}); if (!p) return {{"error": "property_not_found"}}; p.setValue({serialized}); return {{"success": true}}; """) result = material._client.execute(script).value if not isinstance(result, dict) or result.get("success") is not True: error = result.get("error") if isinstance(result, dict) else "unknown_error" raise MaterialError(f"Failed to set Iray channel {label!r}: {error}") def _get_channel_value(material: "DazMaterial", label: str) -> object: script = ScriptBuilder.iife(f""" var m = {material._locator}; if (!m) return {{"error": "material_not_found"}}; var p = m.findPropertyByLabel({json.dumps(label)}); if (!p) return {{"error": "property_not_found"}}; return {{"success": true, "value": p.getValue()}}; """) result = material._client.execute(script).value if not isinstance(result, dict) or result.get("success") is not True: error = result.get("error") if isinstance(result, dict) else "unknown_error" raise MaterialError(f"Failed to read Iray channel {label!r}: {error}") return result.get("value") def _set_channel_map(material: "DazMaterial", label: str, path: str) -> None: if not os.path.isabs(path): raise ValueError(f"Texture map path must be absolute: {path}") if not os.path.isfile(path): raise FileNotFoundError(f"Texture map not found: {path}") script = ScriptBuilder.iife(f""" var m = {material._locator}; if (!m) return {{"error": "material_not_found"}}; var p = m.findPropertyByLabel({json.dumps(label)}); if (!p) return {{"error": "property_not_found"}}; p.setMap({json.dumps(path)}); return {{"success": true}}; """) result = material._client.execute(script).value if not isinstance(result, dict) or result.get("success") is not True: error = result.get("error") if isinstance(result, dict) else "unknown_error" raise MaterialError(f"Failed to set texture map on channel {label!r}: {error}")
[docs] def apply_texture_map(material: "DazMaterial", texture: TextureMap) -> None: """Assign *texture* to *material*'s corresponding Iray channel. Args: material: The target material. texture: The texture-slot assignment. Raises: ValueError: If ``texture.file_path`` is not an absolute path. FileNotFoundError: If ``texture.file_path`` does not exist on disk. Checked before any DazScript call is made. MaterialError: If the material or the resolved channel property cannot be found on the live material. """ _set_channel_map(material, _channel_label(texture.channel), texture.file_path)
[docs] def get_surface_property(material: "DazMaterial", label: str) -> object: """Return the current value of the named Iray surface property. Args: material: The target material. label: The DazScript property display label. Raises: MaterialError: If the material or property cannot be found. """ return _get_channel_value(material, label)
[docs] def set_surface_property(material: "DazMaterial", prop: SurfaceProperty) -> None: """Set a single named Iray surface property. Args: material: The target material. prop: The label/value pair to set. Raises: MaterialError: If the material or property cannot be found. """ _set_channel_value(material, prop.label, prop.value)
_TYPED_FIELDS = ( "base_color", "metallic_weight", "roughness", "glossy_reflectivity", "cutout_opacity", "bump_strength", "top_coat_weight", )
[docs] def apply_iray_material(material: "DazMaterial", spec: IrayMaterial) -> None: """Apply *spec* to *material* in typed-fields -> textures -> properties order. Args: material: The target material. spec: The material configuration. ``None``-valued typed fields are skipped. ``spec.properties`` is applied last, so it can supersede any typed field or texture set earlier in the same call. Raises: ValueError: If any ``spec.textures[*].file_path`` is not absolute. FileNotFoundError: If any ``spec.textures[*].file_path`` does not exist on disk. All texture paths are validated before any DazScript call is made, for the same reason as :func:`apply_texture_map`. MaterialError: If the material or a resolved channel property cannot be found on the live material. """ for texture in spec.textures: path = texture.file_path if not os.path.isabs(path): raise ValueError(f"Texture map path must be absolute: {path}") if not os.path.isfile(path): raise FileNotFoundError(f"Texture map not found: {path}") for field_name in _TYPED_FIELDS: value = getattr(spec, field_name) if value is not None: _set_channel_value(material, _channel_label(field_name), value) for texture in spec.textures: _set_channel_map(material, _channel_label(texture.channel), texture.file_path) for prop in spec.properties: _set_channel_value(material, prop.label, prop.value)