| … | … | @@ -0,0 +1,345 @@ |
| 1 | """ |
| 2 | A node editor for the iterator graph. |
| 3 | |
| 4 | IFSRenderer already models this as a graph — `NodeMap.xaml` says "Map Nodes and |
| 5 | Arrows to Iterators and weights" — so Blender's node editor is a faithful home for |
| 6 | it rather than a metaphor stretched over something else. |
| 7 | |
| 8 | ## Why a custom NodeTree and not Geometry Nodes |
| 9 | |
| 10 | Geometry Nodes was measured at 24-46x slower than the compute shader and has no |
| 11 | scatter-accumulate primitive, so it was never going to EVALUATE the fractal. Once |
| 12 | evaluation is off the table, GN buys nothing and costs a shared Add menu full of |
| 13 | mesh nodes. A custom tree gets its own editor, its own Add menu, and the same |
| 14 | node-group and driver support. |
| 15 | |
| 16 | ## Why links carry topology and a matrix carries the weights |
| 17 | |
| 18 | A 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 |
| 20 | share ONE `default_value` across every link into them, so a per-(source, |
| 21 | destination) weight has nowhere to live on the link either. |
| 22 | |
| 23 | So links say WHICH transitions exist — the sparse structure, which is what the |
| 24 | graph is good at showing — and the xaos weights live in a matrix panel, which is |
| 25 | what a dense N x N of numbers is good at showing. Ten iterators is a hundred |
| 26 | links; drawing those as noodles would be unreadable. |
| 27 | |
| 28 | ## Why this is worth having beyond looks |
| 29 | |
| 30 | Keyframing a node input produces `nodes["Name"].inputs[0].default_value`, and |
| 31 | renaming the node REWRITES that path. Keyframing a `CollectionProperty` bakes an |
| 32 | INDEX path instead, so deleting an iterator silently repoints every curve at the |
| 33 | wrong one. The node tree is the version that survives editing. |
| 34 | """ |
| 35 | |
| 36 | from __future__ import annotations |
| 37 | |
| 38 | import bpy |
| 39 | from bpy.props import ( |
| 40 | CollectionProperty, |
| 41 | EnumProperty, |
| 42 | FloatProperty, |
| 43 | FloatVectorProperty, |
| 44 | IntProperty, |
| 45 | StringProperty, |
| 46 | ) |
| 47 | from bpy.types import Node, NodeSocket, NodeTree, Operator, Panel |
| 48 | |
| 49 | import ifs_codegen |
| 50 | import ifs_document |
| 51 | import 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 | |
| 58 | def 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 | |
| 67 | MAX_REAL = 8 |
| 68 | MAX_VEC3 = 3 |
| 69 | |
| 70 | |
| 71 | class 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 | |
| 79 | class 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 | |
| 92 | def _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 | |
| 100 | class 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 | |
| 165 | def 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 | |
| 207 | def 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 | |
| 255 | class 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 | |
| 289 | class 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 | |
| 321 | CLASSES = ( |
| 322 | IFSNodeTree, |
| 323 | IFSFlowSocket, |
| 324 | IFSIteratorNode, |
| 325 | IFS_PT_xaos, |
| 326 | IFS_OT_build_graph, |
| 327 | ) |
| 328 | |
| 329 | |
| 330 | def _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 | |
| 336 | def register(): |
| 337 | for cls in CLASSES: |
| 338 | bpy.utils.register_class(cls) |
| 339 | bpy.types.NODE_MT_add.append(_add_menu) |
| 340 | |
| 341 | |
| 342 | def unregister(): |
| 343 | bpy.types.NODE_MT_add.remove(_add_menu) |
| 344 | for cls in reversed(CLASSES): |
| 345 | bpy.utils.unregister_class(cls) |