"""
A node editor for the iterator graph.
IFSRenderer already models this as a graph — `NodeMap.xaml` says "Map Nodes and
Arrows to Iterators and weights" — so Blender's node editor is a faithful home for
it rather than a metaphor stretched over something else.
## Why a custom NodeTree and not Geometry Nodes
Geometry Nodes was measured at 24-46x slower than the compute shader and has no
scatter-accumulate primitive, so it was never going to EVALUATE the fractal. Once
evaluation is off the table, GN buys nothing and costs a shared Add menu full of
mesh nodes. A custom tree gets its own editor, its own Add menu, and the same
node-group and driver support.
## Why the MATRIX owns xaos and the links are only a picture
An IFS graph is inherently CYCLIC — iterator A follows B follows A, and a fractal
flame depends on it. Blender node trees assume a DAG: a cycle is marked invalid
and the editor warns about it. Links are therefore the wrong primitive to store
xaos in, whatever else they are good for.
A link could not hold the weight anyway. `NodeLink` exposes only `is_valid` and
`is_muted` as writable and rejects custom properties, and a multi-input socket
shares ONE `default_value` across every link into it, so a per-(source,
destination) weight has nowhere to live.
So the weights live in an editable matrix on each node, and the links exist purely
to show the structure at a glance, the way IFSRenderer's own NodeMap does. Blender
will still flag the cycles; that warning is expected and harmless, because nothing
is read from the links.
## Why this is worth having beyond looks
Keyframing a node input produces `nodes["Name"].inputs[0].default_value`, and
renaming the node REWRITES that path. Keyframing a `CollectionProperty` bakes an
INDEX path instead, so deleting an iterator silently repoints every curve at the
wrong one. The node tree is the version that survives editing.
"""
from __future__ import annotations
import bpy
from bpy.props import (
CollectionProperty,
EnumProperty,
FloatProperty,
FloatVectorProperty,
IntProperty,
StringProperty,
)
from bpy.types import Node, NodeSocket, NodeTree, Operator, Panel
import ifs_codegen
import ifs_document
import ifs_render
import ifs_viewport
# Same lazy cache as the add-on's: walking 46 plugin files on every enum draw
# would be absurd.
_library = None
def library():
global _library
if _library is None:
_library = ifs_codegen.load_library(
__import__("os").path.join(ifs_render.REPO, "Transforms"),
__import__("os").path.join(ifs_render.REPO, "WpfDisplay", "Library",
"Includes"))
return _library
MAX_REAL = 8
MAX_VEC3 = 3
# Upper bound for the xaos matrix in the UI. A document with more iterators than
# this still renders; only the matrix stops showing the extra columns.
MAX_ITERATORS = 16
class IFSNodeTree(NodeTree):
"""The iterator graph for one fractal."""
bl_idname = 'IFSNodeTree'
bl_label = "IFS Fractal"
bl_icon = 'NODETREE'
class IFSFlowSocket(NodeSocket):
"""A transition between iterators. Carries no value - see the module docstring."""
bl_idname = 'IFSFlowSocket'
bl_label = "Flow"
def draw(self, context, layout, node, text):
layout.label(text=text)
def draw_color(self, context, node):
return (0.9, 0.55, 0.2, 1.0)
def _transform_items(self, context):
try:
names = sorted(library().tf_ids)
except Exception:
return [('Affine', "Affine", "")]
return [(n, n, "") for n in names]
def _graph_edit(self, context):
"""
Property update callback: tells the live session the graph changed.
This is the ONLY thing that makes node edits reach the fractal. The depsgraph
handler in ifs_viewport was meant to do it, but an IFSNodeTree is a free-floating
datablock with no scene user - no object, modifier or material references it - so
the dependency graph never evaluates it and never reports an edit. Measured: with a
session running, editing a node property produced ZERO depsgraph updates, which is
why the graph appeared connected and drove nothing.
Marking the graph dirty (rather than applying here) matches the panel: a slider drag
fires this on every pixel of movement, and the work belongs in the draw handler.
"""
if not ifs_viewport.is_running():
return
ifs_viewport.session().mark_graph_dirty()
window_manager = bpy.context.window_manager
if window_manager is None:
return
for window in window_manager.windows:
for area in window.screen.areas:
if area.type == 'VIEW_3D':
area.tag_redraw()
class IFSIteratorNode(Node):
"""One iterator: a transform plus its weights and parameters."""
bl_idname = 'IFSIteratorNode'
bl_label = "Iterator"
bl_icon = 'DRIVER_TRANSFORM'
@classmethod
def poll(cls, tree):
return tree.bl_idname == IFSNodeTree.bl_idname
transform: EnumProperty(name="Transform", items=_transform_items,
update=_graph_edit)
opacity: FloatProperty(name="Opacity", default=1.0, min=0.0, max=1.0,
update=_graph_edit)
color_index: FloatProperty(name="Color", default=0.0, min=0.0, max=1.0,
update=_graph_edit)
color_speed: FloatProperty(name="Color speed", default=0.5, min=0.0, max=1.0,
update=_graph_edit)
base_weight: FloatProperty(name="Weight", default=1.0, min=0.0, soft_max=10.0,
update=_graph_edit)
start_weight: FloatProperty(
name="Start weight", default=1.0, min=0.0, soft_max=10.0,
description="Share of RESET points that land on this iterator. Not the "
"same as Weight, which scales its outgoing transitions",
update=_graph_edit)
# Outgoing xaos weights, indexed by DESTINATION iterator. This - not the
# links - is what the fractal reads, because an IFS graph is cyclic and
# Blender invalidates cyclic links.
xaos: FloatVectorProperty(size=MAX_ITERATORS, default=[1.0] * MAX_ITERATORS,
min=0.0, soft_max=4.0, update=_graph_edit)
real_values: FloatVectorProperty(size=MAX_REAL, update=_graph_edit)
vec3_values: FloatVectorProperty(size=MAX_VEC3 * 3, update=_graph_edit)
real_names: StringProperty(default="")
vec3_names: StringProperty(default="")
def init(self, context):
# Multi-input, because any number of iterators may precede this one.
socket_in = self.inputs.new(IFSFlowSocket.bl_idname, "From")
socket_in.link_limit = 4095
self.outputs.new(IFSFlowSocket.bl_idname, "To")
def draw_buttons(self, context, layout):
layout.prop(self, "transform", text="")
layout.prop(self, "opacity", slider=True)
layout.prop(self, "color_index", slider=True)
def draw_buttons_ext(self, context, layout):
layout.prop(self, "transform", text="")
column = layout.column(align=True)
column.prop(self, "opacity", slider=True)
column.prop(self, "color_index", slider=True)
column.prop(self, "color_speed", slider=True)
column.prop(self, "base_weight")
column.prop(self, "start_weight")
real_names = [n for n in self.real_names.split(",") if n]
vec3_names = [n for n in self.vec3_names.split(",") if n]
if real_names or vec3_names:
box = layout.box()
box.label(text=f"{self.transform} parameters")
for index, name in enumerate(real_names[:MAX_REAL]):
box.prop(self, "real_values", index=index, text=name)
for index, name in enumerate(vec3_names[:MAX_VEC3]):
row = box.row(align=True)
row.label(text=name)
for component in range(3):
row.prop(self, "vec3_values", index=index * 3 + component, text="")
def draw_label(self):
return f"{self.transform}"
# ------------------------------------------------------------------ conversion
def build_tree(tree, doc, library):
"""Lays a loaded document out as nodes, linked by its non-zero xaos entries."""
tree.nodes.clear()
plugins = {p.name: p for p in library.plugins}
names = {tf_id: name for name, tf_id in library.tf_ids.items()}
created = []
for index, iterator in enumerate(doc.iterators):
node = tree.nodes.new(IFSIteratorNode.bl_idname)
# The node NAME is what F-Curve paths key on, and renaming rewrites them,
# so it is derived from the iterator's position once and then owned by the
# user.
node.name = f"Iterator_{index}"
node.label = names.get(iterator.tf_id, "?")
node.location = (260 * (index % 4), -190 * (index // 4))
node.transform = names.get(iterator.tf_id, 'Affine')
node.opacity = iterator.opacity
node.color_index = iterator.color_index
node.color_speed = iterator.color_speed
node.base_weight = iterator.base_weight
node.start_weight = max(iterator.reset_prob, 0.0) or 1.0
plugin = plugins.get(node.transform)
if plugin is not None:
node.real_names = ",".join(plugin.real_params)
node.vec3_names = ",".join(plugin.vec3_params)
for i, value in enumerate(iterator.real_params[:MAX_REAL]):
node.real_values[i] = value
for i, vec in enumerate(iterator.vec3_params[:MAX_VEC3]):
for c in range(3):
node.vec3_values[i * 3 + c] = vec[c]
for dest in range(MAX_ITERATORS):
node.xaos[dest] = iterator.weight_to.get(dest, 0.0)
created.append(node)
for source_index, iterator in enumerate(doc.iterators):
for dest_index, weight in iterator.weight_to.items():
if weight == 0.0 or dest_index >= len(created):
continue
tree.links.new(created[source_index].outputs[0],
created[dest_index].inputs[0])
return created
def apply_tree(tree, doc, library):
"""
Reads the node graph back onto a document.
Node ORDER is the tree's creation order, which the user cannot see. The graph
is matched to the document positionally, so this only applies to a document the
graph was built from - it is an editor over that document, not an importer.
"""
nodes = [n for n in tree.nodes if n.bl_idname == IFSIteratorNode.bl_idname]
nodes.sort(key=lambda n: n.name)
if len(nodes) != len(doc.iterators):
raise ValueError(
f"the graph has {len(nodes)} iterators and the document has "
f"{len(doc.iterators)}; rebuild the graph")
index_of = {node.name: i for i, node in enumerate(nodes)}
tf_ids = library.tf_ids
for node, iterator in zip(nodes, doc.iterators):
iterator.opacity = node.opacity
iterator.color_index = node.color_index
iterator.color_speed = node.color_speed
iterator.base_weight = node.base_weight
if node.transform in tf_ids:
iterator.tf_id = tf_ids[node.transform]
count_real = len(iterator.real_params)
iterator.real_params = list(node.real_values[:count_real])
iterator.vec3_params = [tuple(node.vec3_values[i * 3:i * 3 + 3])
for i in range(len(iterator.vec3_params))]
# xaos comes from the MATRIX, never the links: the graph is cyclic, so
# Blender marks those links invalid and they cannot be trusted as data.
for node, iterator in zip(nodes, doc.iterators):
iterator.weight_to = {dest: float(node.xaos[dest])
for dest in range(min(len(nodes), MAX_ITERATORS))
if node.xaos[dest] != 0.0}
return nodes
# ---------------------------------------------------------------------- xaos UI
class IFS_PT_xaos(Panel):
"""The dense weight matrix, which is the half a node graph is bad at."""
bl_label = "Xaos Weights"
bl_space_type = 'NODE_EDITOR'
bl_region_type = 'UI'
bl_category = "IFS"
@classmethod
def poll(cls, context):
tree = getattr(context.space_data, "edit_tree", None)
return tree is not None and tree.bl_idname == IFSNodeTree.bl_idname
def draw(self, context):
layout = self.layout
tree = context.space_data.edit_tree
nodes = sorted((n for n in tree.nodes
if n.bl_idname == IFSIteratorNode.bl_idname),
key=lambda n: n.name)
if not nodes:
layout.label(text="No iterators in this graph")
return
layout.label(text="Row = source, column = destination", icon='INFO')
layout.label(text="Cyclic-link warnings are expected: an IFS graph is "
"cyclic and nothing is read from the links.")
count = min(len(nodes), MAX_ITERATORS)
header = layout.row(align=True)
header.label(text="")
for index in range(count):
header.label(text=str(index))
for source, node in enumerate(nodes[:count]):
row = layout.row(align=True)
row.label(text=f"{source}: {node.label or node.name}")
for dest in range(count):
row.prop(node, "xaos", index=dest, text="")
class IFS_OT_build_graph(Operator):
"""Create a node graph from the loaded document"""
bl_idname = "ifs.build_graph"
bl_label = "Build Node Graph"
def execute(self, context):
settings = context.scene.ifs
path = bpy.path.abspath(settings.document)
if not path:
self.report({'ERROR'}, "Pick an .ifsjson file first")
return {'CANCELLED'}
try:
doc = ifs_document.load(path, library())
except Exception as ex:
self.report({'ERROR'}, f"{type(ex).__name__}: {ex}")
return {'CANCELLED'}
name = f"IFS {doc.title}"
tree = bpy.data.node_groups.get(name)
if tree is None:
tree = bpy.data.node_groups.new(name, IFSNodeTree.bl_idname)
build_tree(tree, doc, library())
# tree_type as well as node_tree: assigning the tree alone leaves the
# editor showing Shader nodes and the graph apparently missing.
shown = False
for area in context.screen.areas:
if area.type == 'NODE_EDITOR':
area.spaces.active.tree_type = IFSNodeTree.bl_idname
area.spaces.active.node_tree = tree
shown = True
if ifs_viewport.is_running():
ifs_viewport.session().bind_tree(tree.name)
settings.status = f"Graph built: {len(doc.iterators)} iterators"
if shown:
self.report({'INFO'}, settings.status)
else:
# The panel lives in the 3D viewport sidebar, and the default Layout
# workspace has NO node editor - only Shading, Compositing and Geometry
# Nodes do. So the graph was built into a datablock with nothing on screen
# displaying it, and the operator reported success: indistinguishable from
# the button doing nothing at all.
settings.status = (
f"Graph '{tree.name}' built ({len(doc.iterators)} iterators) - "
"open a Node Editor to see it")
self.report(
{'WARNING'},
f"Graph '{tree.name}' built, but this workspace has no Node Editor. "
"Switch to the Geometry Nodes or Shading workspace (or change an area "
"to a Node Editor) and set its type to IFS Fractal.")
return {'FINISHED'}
CLASSES = (
IFSNodeTree,
IFSFlowSocket,
IFSIteratorNode,
IFS_PT_xaos,
IFS_OT_build_graph,
)
def _add_menu(self, context):
if getattr(context.space_data, "tree_type", "") == IFSNodeTree.bl_idname:
self.layout.operator("node.add_node", text="Iterator").type = \
IFSIteratorNode.bl_idname
def register():
for cls in CLASSES:
bpy.utils.register_class(cls)
bpy.types.NODE_MT_add.append(_add_menu)
def unregister():
bpy.types.NODE_MT_add.remove(_add_menu)
for cls in reversed(CLASSES):
bpy.utils.unregister_class(cls)