OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / blender / ifs_document.py

255 lines · python ref:
"""
Loads an `.ifsjson` document into the host's buffer model.

Mirrors what `RendererGL.UpdateParamsBuffer` builds from an `IFS`, so a document
authored in IFSRenderer renders the same way here.

Three details are easy to get wrong and silent when wrong:

* **Parameter ORDER is positional.** The kernel reads
  `real_params[iter.real_params_index + N]`, where N is the parameter's index in
  the PLUGIN's declaration order. The JSON stores a dict, and dict order is the
  serializer's, not the plugin's. Params are therefore ordered by the plugin's
  declared names, not by whatever order the file happens to use.
* **`xaos` is a plain N x N matrix** of multipliers, row = source, column =
  destination — not a sparse map keyed by iterator Id.
* **`StartWeight` is not `BaseWeight`.** StartWeight drives the RESET distribution
  (`reset_prob` / `reset_alias`, from an alias table over the normalised start
  weights); BaseWeight modulates the xaos rows. Confusing them renders a fractal
  weighted wrongly rather than failing.

Files are UTF-8 with a BOM, so they are read as `utf-8-sig`.
"""

from __future__ import annotations

import json
import math
import os

from ifs_codegen import PluginLibrary
from ifs_host import (
    Camera,
    Iterator,
    Settings,
    generate_alias_table,
    gradient,
)


class DocumentError(Exception):
    """The document could not be mapped onto the current plugin library."""


def _vec3(node: dict) -> tuple[float, float, float]:
    return (float(node["X"]), float(node["Y"]), float(node["Z"]))


def _quat_rotate(q: tuple[float, float, float, float],
                 v: tuple[float, float, float]) -> tuple[float, float, float]:
    """`Vector3.Transform(v, quaternion)` — rotate v by q."""
    x, y, z, w = q
    # t = 2 * cross(q.xyz, v); result = v + w*t + cross(q.xyz, t)
    tx = 2.0 * (y * v[2] - z * v[1])
    ty = 2.0 * (z * v[0] - x * v[2])
    tz = 2.0 * (x * v[1] - y * v[0])
    return (
        v[0] + w * tx + (y * tz - z * ty),
        v[1] + w * ty + (z * tx - x * tz),
        v[2] + w * tz + (x * ty - y * tx),
    )


class Document:
    """A loaded document, ready to pack."""

    def __init__(self, iterators, camera, settings, palette_keys,
                 brightness, gamma, gamma_threshold, vibrancy,
                 width, height, target_level, title):
        self.iterators = iterators
        self.camera = camera
        self.settings = settings
        self.palette_keys = palette_keys
        self.brightness = brightness
        self.gamma = gamma
        self.gamma_threshold = gamma_threshold
        self.vibrancy = vibrancy
        self.width = width
        self.height = height
        self.target_level = target_level
        self.title = title

    def palette(self, resolution: int) -> list[tuple[float, float, float, float]]:
        return gradient(self.palette_keys, resolution)


def load(path: str, library: PluginLibrary) -> Document:
    with open(path, "r", encoding="utf-8-sig") as handle:
        data = json.load(handle)
    return from_dict(data, library)


def from_dict(data: dict, library: PluginLibrary) -> Document:
    plugins = {p.name: p for p in library.plugins}
    tf_ids = library.tf_ids

    raw_iterators = data.get("Iterators") or []
    if not raw_iterators:
        raise DocumentError("the document has no iterators")

    iterators: list[Iterator] = []
    real_cursor = 0
    vec3_cursor = 0

    for entry in raw_iterators:
        name = entry["Transform"]["Item1"]
        plugin = plugins.get(name)
        if plugin is None:
            raise DocumentError(
                f"transform {name!r} is not in the loaded plugin library")

        # Positional, in the PLUGIN's order. A parameter absent from the document
        # falls back to the plugin's default, which is what the engine's
        # UpdatePlugins path does when a plugin gains a parameter.
        doc_real = entry.get("RealParams") or {}
        doc_vec3 = entry.get("Vec3Params") or {}
        real_values = [float(doc_real.get(pname, default))
                       for pname, default in plugin.real_params.items()]
        vec3_values = [_vec3(doc_vec3[pname]) if pname in doc_vec3 else default
                       for pname, default in plugin.vec3_params.items()]

        iterators.append(Iterator(
            color_speed=float(entry.get("ColorSpeed", 0.5)),
            color_index=float(entry.get("ColorIndex", 0.0)),
            opacity=float(entry.get("Opacity", 1.0)),
            tf_id=tf_ids[name],
            real_params_index=real_cursor,
            vec3_params_index=vec3_cursor,
            shading_mode=int(entry.get("ShadingMode", 0)),
            tf_mix=float(entry.get("Mix", 1.0)),
            tf_add=float(entry.get("Add", 0.0)),
            base_weight=float(entry.get("BaseWeight", 1.0)),
            real_params=real_values,
            vec3_params=vec3_values,
        ))
        real_cursor += len(real_values)
        vec3_cursor += len(vec3_values)

    _apply_start_weights(iterators, raw_iterators)
    _apply_xaos(iterators, data.get("xaos"))

    camera = _load_camera(data.get("Camera") or {})
    width, height = _resolution(data.get("ImageResolution"))

    settings = Settings(
        fog_effect=float(data.get("FogEffect", 0.0)),
        itnum=len(iterators),
        warmup=int(data.get("Warmup", 20)),
        entropy=float(data.get("Entropy", 0.01)),
    )

    return Document(
        iterators=iterators,
        camera=camera,
        settings=settings,
        palette_keys=_load_palette(data.get("Palette") or {}),
        brightness=float(data.get("Brightness", 1.0)),
        gamma=float(data.get("Gamma", 4.0)),
        gamma_threshold=float(data.get("GammaThreshold", 0.0)),
        vibrancy=float(data.get("Vibrancy", 1.0)),
        width=width,
        height=height,
        target_level=float(data.get("TargetIterationLevel", 12.0)),
        title=data.get("Title") or "untitled",
    )


def _apply_start_weights(iterators: list[Iterator], raw: list[dict]) -> None:
    """
    `reset_prob` / `reset_alias` from an alias table over normalised StartWeights.

    This is the distribution a point is reset INTO, and it is separate from the
    xaos transition weights (RendererGL.cs:460-486).
    """
    starts = [float(entry.get("StartWeight", 1.0)) for entry in raw]
    total = sum(starts)
    if total <= 0:
        raise DocumentError("no iterator has a non-zero StartWeight, so a reset "
                            "point could never be placed")
    table = generate_alias_table([s / total for s in starts])
    for it, (u, k) in zip(iterators, table):
        it.reset_prob = float(u)
        it.reset_alias = int(k)


def _apply_xaos(iterators: list[Iterator], xaos) -> None:
    """`xaos[source][destination]`, defaulting to fully connected when absent."""
    n = len(iterators)
    if not xaos:
        for it in iterators:
            it.weight_to = {dst: 1.0 for dst in range(n)}
        return

    if len(xaos) != n or any(len(row) != n for row in xaos):
        raise DocumentError(
            f"xaos is not {n}x{n}; the matrix and the iterator list disagree")

    for source, it in enumerate(iterators):
        it.weight_to = {dst: float(xaos[source][dst]) for dst in range(n)
                        if float(xaos[source][dst]) != 0.0}


def _load_camera(node: dict) -> Camera:
    orientation = node.get("Orientation") or {}
    q = (float(orientation.get("X", 0.0)), float(orientation.get("Y", 0.0)),
         float(orientation.get("Z", 0.0)), float(orientation.get("W", 1.0)))
    position = _vec3(node["Position"]) if "Position" in node else (0.0, 0.0, -5.0)

    forward = _quat_rotate(q, (0.0, 0.0, 1.0))
    up = _quat_rotate(q, (0.0, 1.0, 0.0))
    focus_distance = float(node.get("FocusDistance", 10.0))

    camera = Camera(
        position=(position[0], position[1], position[2], 1.0),
        forward=(forward[0], forward[1], forward[2], 1.0),
        focus_point=(position[0] + focus_distance * forward[0],
                     position[1] + focus_distance * forward[1],
                     position[2] + focus_distance * forward[2], 0.0),
        aperture=float(node.get("Aperture", 0.0)),
        focus_distance=focus_distance,
        depth_of_field=float(node.get("DepthOfField", 0.25)),
        projection_type=int(node.get("Projection", 0)),
    )
    camera.field_of_view = float(node.get("FieldOfView", 60.0))
    camera.up = up
    return camera


def _load_palette(node: dict) -> list[tuple[float, float, float, float]]:
    """
    Reads either palette shape.

    The palette-manager merge renamed `Colors` to `KeyColors`; every document
    written before it - including the shipped templates - still uses `Colors`.
    IfsNodesSerializer carries the same shim.
    """
    colors = node.get("KeyColors") or node.get("Colors") or []
    keys = []
    for c in colors:
        if isinstance(c, dict) and "Color" in c:  # KeyColors: {Position, Color}
            c = c["Color"]
        keys.append((float(c.get("X", 1.0)), float(c.get("Y", 1.0)),
                     float(c.get("Z", 1.0)), float(c.get("W", 1.0))))
    return keys or [(1.0, 1.0, 1.0, 1.0)]


def _resolution(value) -> tuple[int, int]:
    """`ImageResolution` is serialised as the string "W, H"."""
    if isinstance(value, str):
        parts = [p.strip() for p in value.split(",")]
        if len(parts) == 2:
            return int(parts[0]), int(parts[1])
    if isinstance(value, dict) and "Width" in value:
        return int(value["Width"]), int(value["Height"])
    return 1920, 1080