"""
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