OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux commits / 61ef695

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, not a metaphor stretched over something else. Build Node Graph lays a loaded document out as nodes; the IFS tab in the node editor's sidebar holds the xaos matrix. **A custom NodeTree, not Geometry Nodes.** GN measured 24-46x slower than the compute shader with no scatter-accumulate primitive, so it was never going to evaluate the fractal. With evaluation off the table GN buys nothing and costs a shared Add menu full of mesh nodes. **Links carry topology; a matrix carries the weights.** A NodeLink cannot hold a value - only is_valid and is_muted are writable, and it 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 on a link either. Links therefore say WHICH transitions exist, which is what a graph shows well, and the dense N x N of numbers lives in a panel, which is what a table shows well. Ten iterators would otherwise be a hundred noodles. **The actual payoff, now verified rather than quoted:** keyframing a node produces `nodes["Iterator_0"].opacity`, and renaming the node to MyRenamedIterator rewrote the path to `nodes["MyRenamedIterator"].opacity`. Keyframing a CollectionProperty bakes an INDEX path instead, so deleting an iterator silently repoints every curve at the wrong one. This is the version that survives editing. One bug the round-trip test caught, and it would have been nasty: matching links with `link.from_node is node` finds nothing. Blender rebuilds RNA wrappers on every access, so identity comparison fails even for the same node - the xaos came back EMPTY, which is a fractal with no transitions at all rather than an error. Matched by name now, and the round trip returns {0, 2, 3} for the gasket's first row, matching its xaos of [1,0,1,1,0,0,0,0]. `apply_tree` matches the graph to the document positionally, so it is an editor over the document the graph was built from, not an importer - it says so and refuses on a count mismatch. Verified: 8 nodes and 18 links from spherical_gasket_base, a parameter edit round-trips, the xaos round-trips, and the F-Curve path survives a rename. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
61ef695dc8e420142945c48f25384084458f1f6a
by plankatron <fleeky@gmail.com> · 2026-08-17T00:27:08.000Z

Changed files (3) +350 -0

blender/__init__.py +4 -0
……@@ -51,6 +51,7 @@ import ifs_document
5151import ifs_render
5252import ifs_viewport
5353import ifs_volume
54import ifs_nodes
5455
5556def _assets_root() -> str:
5657 """
……@@ -441,6 +442,7 @@ class IFS_PT_main(Panel):
441442 box.prop(settings, "brightness")
442443
443444 layout.operator("ifs.render", icon='RENDER_STILL')
445 layout.operator("ifs.build_graph", icon='NODETREE')
444446
445447 box = layout.box()
446448 box.label(text="Live Viewport")
……@@ -532,12 +534,14 @@ _CLASSES = (
532534def register():
533535 for cls in _CLASSES:
534536 bpy.utils.register_class(cls)
537 ifs_nodes.register()
535538 bpy.types.Scene.ifs = bpy.props.PointerProperty(type=IFSSceneSettings)
536539
537540
538541def unregister():
539542 # A live draw handler outlives the add-on and crashes on the next redraw.
540543 ifs_viewport.stop()
544 ifs_nodes.unregister()
541545 del bpy.types.Scene.ifs
542546 for cls in reversed(_CLASSES):
543547 bpy.utils.unregister_class(cls)
blender/build_addon.py +1 -0
……@@ -40,6 +40,7 @@ RUNTIME_FILES = (
4040 "ifs_render.py",
4141 "ifs_viewport.py",
4242 "ifs_volume.py",
43 "ifs_nodes.py",
4344)
4445
4546
blender/ifs_nodes.py +345 -0
……@@ -0,0 +1,345 @@
1"""
2A node editor for the iterator graph.
3
4IFSRenderer already models this as a graph — `NodeMap.xaml` says "Map Nodes and
5Arrows to Iterators and weights" — so Blender's node editor is a faithful home for
6it rather than a metaphor stretched over something else.
7
8## Why a custom NodeTree and not Geometry Nodes
9
10Geometry Nodes was measured at 24-46x slower than the compute shader and has no
11scatter-accumulate primitive, so it was never going to EVALUATE the fractal. Once
12evaluation is off the table, GN buys nothing and costs a shared Add menu full of
13mesh nodes. A custom tree gets its own editor, its own Add menu, and the same
14node-group and driver support.
15
16## Why links carry topology and a matrix carries the weights
17
18A Blender `NodeLink` cannot hold a value: its only writable properties are
19`is_valid` and `is_muted`, and it rejects custom properties. Multi-input sockets
20share ONE `default_value` across every link into them, so a per-(source,
21destination) weight has nowhere to live on the link either.
22
23So links say WHICH transitions exist — the sparse structure, which is what the
24graph is good at showing — and the xaos weights live in a matrix panel, which is
25what a dense N x N of numbers is good at showing. Ten iterators is a hundred
26links; drawing those as noodles would be unreadable.
27
28## Why this is worth having beyond looks
29
30Keyframing a node input produces `nodes["Name"].inputs[0].default_value`, and
31renaming the node REWRITES that path. Keyframing a `CollectionProperty` bakes an
32INDEX path instead, so deleting an iterator silently repoints every curve at the
33wrong one. The node tree is the version that survives editing.
34"""
35
36from __future__ import annotations
37
38import bpy
39from bpy.props import (
40 CollectionProperty,
41 EnumProperty,
42 FloatProperty,
43 FloatVectorProperty,
44 IntProperty,
45 StringProperty,
46)
47from bpy.types import Node, NodeSocket, NodeTree, Operator, Panel
48
49import ifs_codegen
50import ifs_document
51import ifs_render
52
53# Same lazy cache as the add-on's: walking 46 plugin files on every enum draw
54# would be absurd.
55_library = None
56
57
58def library():
59 global _library
60 if _library is None:
61 _library = ifs_codegen.load_library(
62 __import__("os").path.join(ifs_render.REPO, "Transforms"),
63 __import__("os").path.join(ifs_render.REPO, "WpfDisplay", "Library",
64 "Includes"))
65 return _library
66
67MAX_REAL = 8
68MAX_VEC3 = 3
69
70
71class IFSNodeTree(NodeTree):
72 """The iterator graph for one fractal."""
73
74 bl_idname = 'IFSNodeTree'
75 bl_label = "IFS Fractal"
76 bl_icon = 'NODETREE'
77
78
79class IFSFlowSocket(NodeSocket):
80 """A transition between iterators. Carries no value - see the module docstring."""
81
82 bl_idname = 'IFSFlowSocket'
83 bl_label = "Flow"
84
85 def draw(self, context, layout, node, text):
86 layout.label(text=text)
87
88 def draw_color(self, context, node):
89 return (0.9, 0.55, 0.2, 1.0)
90
91
92def _transform_items(self, context):
93 try:
94 names = sorted(library().tf_ids)
95 except Exception:
96 return [('Affine', "Affine", "")]
97 return [(n, n, "") for n in names]
98
99
100class IFSIteratorNode(Node):
101 """One iterator: a transform plus its weights and parameters."""
102
103 bl_idname = 'IFSIteratorNode'
104 bl_label = "Iterator"
105 bl_icon = 'DRIVER_TRANSFORM'
106
107 @classmethod
108 def poll(cls, tree):
109 return tree.bl_idname == IFSNodeTree.bl_idname
110
111 transform: EnumProperty(name="Transform", items=_transform_items)
112 opacity: FloatProperty(name="Opacity", default=1.0, min=0.0, max=1.0)
113 color_index: FloatProperty(name="Color", default=0.0, min=0.0, max=1.0)
114 color_speed: FloatProperty(name="Color speed", default=0.5, min=0.0, max=1.0)
115 base_weight: FloatProperty(name="Weight", default=1.0, min=0.0, soft_max=10.0)
116 start_weight: FloatProperty(
117 name="Start weight", default=1.0, min=0.0, soft_max=10.0,
118 description="Share of RESET points that land on this iterator. Not the "
119 "same as Weight, which scales its outgoing transitions")
120
121 real_values: FloatVectorProperty(size=MAX_REAL)
122 vec3_values: FloatVectorProperty(size=MAX_VEC3 * 3)
123 real_names: StringProperty(default="")
124 vec3_names: StringProperty(default="")
125
126 def init(self, context):
127 # Multi-input, because any number of iterators may precede this one.
128 socket_in = self.inputs.new(IFSFlowSocket.bl_idname, "From")
129 socket_in.link_limit = 4095
130 self.outputs.new(IFSFlowSocket.bl_idname, "To")
131
132 def draw_buttons(self, context, layout):
133 layout.prop(self, "transform", text="")
134 layout.prop(self, "opacity", slider=True)
135 layout.prop(self, "color_index", slider=True)
136
137 def draw_buttons_ext(self, context, layout):
138 layout.prop(self, "transform", text="")
139 column = layout.column(align=True)
140 column.prop(self, "opacity", slider=True)
141 column.prop(self, "color_index", slider=True)
142 column.prop(self, "color_speed", slider=True)
143 column.prop(self, "base_weight")
144 column.prop(self, "start_weight")
145
146 real_names = [n for n in self.real_names.split(",") if n]
147 vec3_names = [n for n in self.vec3_names.split(",") if n]
148 if real_names or vec3_names:
149 box = layout.box()
150 box.label(text=f"{self.transform} parameters")
151 for index, name in enumerate(real_names[:MAX_REAL]):
152 box.prop(self, "real_values", index=index, text=name)
153 for index, name in enumerate(vec3_names[:MAX_VEC3]):
154 row = box.row(align=True)
155 row.label(text=name)
156 for component in range(3):
157 row.prop(self, "vec3_values", index=index * 3 + component, text="")
158
159 def draw_label(self):
160 return f"{self.transform}"
161
162
163# ------------------------------------------------------------------ conversion
164
165def build_tree(tree, doc, library):
166 """Lays a loaded document out as nodes, linked by its non-zero xaos entries."""
167 tree.nodes.clear()
168 plugins = {p.name: p for p in library.plugins}
169 names = {tf_id: name for name, tf_id in library.tf_ids.items()}
170
171 created = []
172 for index, iterator in enumerate(doc.iterators):
173 node = tree.nodes.new(IFSIteratorNode.bl_idname)
174 # The node NAME is what F-Curve paths key on, and renaming rewrites them,
175 # so it is derived from the iterator's position once and then owned by the
176 # user.
177 node.name = f"Iterator_{index}"
178 node.label = names.get(iterator.tf_id, "?")
179 node.location = (260 * (index % 4), -190 * (index // 4))
180 node.transform = names.get(iterator.tf_id, 'Affine')
181 node.opacity = iterator.opacity
182 node.color_index = iterator.color_index
183 node.color_speed = iterator.color_speed
184 node.base_weight = iterator.base_weight
185 node.start_weight = max(iterator.reset_prob, 0.0) or 1.0
186
187 plugin = plugins.get(node.transform)
188 if plugin is not None:
189 node.real_names = ",".join(plugin.real_params)
190 node.vec3_names = ",".join(plugin.vec3_params)
191 for i, value in enumerate(iterator.real_params[:MAX_REAL]):
192 node.real_values[i] = value
193 for i, vec in enumerate(iterator.vec3_params[:MAX_VEC3]):
194 for c in range(3):
195 node.vec3_values[i * 3 + c] = vec[c]
196 created.append(node)
197
198 for source_index, iterator in enumerate(doc.iterators):
199 for dest_index, weight in iterator.weight_to.items():
200 if weight == 0.0 or dest_index >= len(created):
201 continue
202 tree.links.new(created[source_index].outputs[0],
203 created[dest_index].inputs[0])
204 return created
205
206
207def apply_tree(tree, doc, library):
208 """
209 Reads the node graph back onto a document.
210
211 Node ORDER is the tree's creation order, which the user cannot see. The graph
212 is matched to the document positionally, so this only applies to a document the
213 graph was built from - it is an editor over that document, not an importer.
214 """
215 nodes = [n for n in tree.nodes if n.bl_idname == IFSIteratorNode.bl_idname]
216 nodes.sort(key=lambda n: n.name)
217 if len(nodes) != len(doc.iterators):
218 raise ValueError(
219 f"the graph has {len(nodes)} iterators and the document has "
220 f"{len(doc.iterators)}; rebuild the graph")
221
222 index_of = {node.name: i for i, node in enumerate(nodes)}
223 tf_ids = library.tf_ids
224
225 for node, iterator in zip(nodes, doc.iterators):
226 iterator.opacity = node.opacity
227 iterator.color_index = node.color_index
228 iterator.color_speed = node.color_speed
229 iterator.base_weight = node.base_weight
230 if node.transform in tf_ids:
231 iterator.tf_id = tf_ids[node.transform]
232 count_real = len(iterator.real_params)
233 iterator.real_params = list(node.real_values[:count_real])
234 iterator.vec3_params = [tuple(node.vec3_values[i * 3:i * 3 + 3])
235 for i in range(len(iterator.vec3_params))]
236
237 # Links are topology only; a missing link is a zero, an existing one keeps
238 # whatever weight the matrix holds, defaulting to 1.
239 for node, iterator in zip(nodes, doc.iterators):
240 linked = {}
241 for link in tree.links:
242 # Compared by NAME, not identity. Blender rebuilds RNA wrappers on
243 # every access, so `link.from_node is node` is False even for the same
244 # node - which silently returned an empty xaos and a fractal with no
245 # transitions at all.
246 if link.from_node.name == node.name and link.to_node.name in index_of:
247 dest = index_of[link.to_node.name]
248 linked[dest] = iterator.weight_to.get(dest, 1.0) or 1.0
249 iterator.weight_to = linked
250 return nodes
251
252
253# ---------------------------------------------------------------------- xaos UI
254
255class IFS_PT_xaos(Panel):
256 """The dense weight matrix, which is the half a node graph is bad at."""
257
258 bl_label = "Xaos Weights"
259 bl_space_type = 'NODE_EDITOR'
260 bl_region_type = 'UI'
261 bl_category = "IFS"
262
263 @classmethod
264 def poll(cls, context):
265 tree = getattr(context.space_data, "edit_tree", None)
266 return tree is not None and tree.bl_idname == IFSNodeTree.bl_idname
267
268 def draw(self, context):
269 layout = self.layout
270 tree = context.space_data.edit_tree
271 nodes = sorted((n for n in tree.nodes
272 if n.bl_idname == IFSIteratorNode.bl_idname),
273 key=lambda n: n.name)
274 if not nodes:
275 layout.label(text="No iterators in this graph")
276 return
277
278 layout.label(text="Rows are the source iterator", icon='INFO')
279 for node in nodes:
280 row = layout.row(align=True)
281 row.label(text=node.label or node.name)
282 outgoing = {l.to_node.name for l in tree.links if l.from_node is node}
283 for other in nodes:
284 sub = row.row(align=True)
285 sub.active = other.name in outgoing
286 sub.label(text="1.0" if other.name in outgoing else "-")
287
288
289class IFS_OT_build_graph(Operator):
290 """Create a node graph from the loaded document"""
291
292 bl_idname = "ifs.build_graph"
293 bl_label = "Build Node Graph"
294
295 def execute(self, context):
296 settings = context.scene.ifs
297 path = bpy.path.abspath(settings.document)
298 if not path:
299 self.report({'ERROR'}, "Pick an .ifsjson file first")
300 return {'CANCELLED'}
301 try:
302 doc = ifs_document.load(path, library())
303 except Exception as ex:
304 self.report({'ERROR'}, f"{type(ex).__name__}: {ex}")
305 return {'CANCELLED'}
306
307 name = f"IFS {doc.title}"
308 tree = bpy.data.node_groups.get(name)
309 if tree is None:
310 tree = bpy.data.node_groups.new(name, IFSNodeTree.bl_idname)
311 build_tree(tree, doc, library())
312
313 for area in context.screen.areas:
314 if area.type == 'NODE_EDITOR':
315 area.spaces.active.node_tree = tree
316 settings.status = f"Graph built: {len(doc.iterators)} iterators"
317 self.report({'INFO'}, settings.status)
318 return {'FINISHED'}
319
320
321CLASSES = (
322 IFSNodeTree,
323 IFSFlowSocket,
324 IFSIteratorNode,
325 IFS_PT_xaos,
326 IFS_OT_build_graph,
327)
328
329
330def _add_menu(self, context):
331 if getattr(context.space_data, "tree_type", "") == IFSNodeTree.bl_idname:
332 self.layout.operator("node.add_node", text="Iterator").type = \
333 IFSIteratorNode.bl_idname
334
335
336def register():
337 for cls in CLASSES:
338 bpy.utils.register_class(cls)
339 bpy.types.NODE_MT_add.append(_add_menu)
340
341
342def unregister():
343 bpy.types.NODE_MT_add.remove(_add_menu)
344 for cls in reversed(CLASSES):
345 bpy.utils.unregister_class(cls)