OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / blender / ifs_nodes.py

417 lines · python ref:
"""
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)