From aa214022211757caa94fd53951c47f2454e5042b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Moritz=20Br=C3=BCckner?= Date: Sat, 24 Jul 2021 13:36:16 +0200 Subject: [PATCH] Live patch: handle deletion of node links --- Sources/armory/logicnode/LogicNode.hx | 162 +++++++++++++++++---- Sources/armory/logicnode/QuaternionNode.hx | 14 +- Sources/armory/logicnode/SetParentNode.hx | 6 +- Sources/armory/logicnode/VectorNode.hx | 6 +- Sources/armory/trait/internal/LivePatch.hx | 77 +++++++--- blender/arm/live_patch.py | 47 ++++++ blender/arm/logicnode/arm_nodes.py | 41 +++++- blender/arm/make_logic.py | 39 +++-- 8 files changed, 312 insertions(+), 80 deletions(-) diff --git a/Sources/armory/logicnode/LogicNode.hx b/Sources/armory/logicnode/LogicNode.hx index 38dac54b..18b3602d 100644 --- a/Sources/armory/logicnode/LogicNode.hx +++ b/Sources/armory/logicnode/LogicNode.hx @@ -4,8 +4,8 @@ package armory.logicnode; class LogicNode { var tree: LogicTree; - var inputs: Array = []; - var outputs: Array> = []; + var inputs: Array = []; + var outputs: Array> = []; #if (arm_debug || arm_patch) public var name = ""; @@ -22,14 +22,115 @@ class LogicNode { this.tree = tree; } - public function addInput(node: LogicNode, from: Int) { - inputs.push(new LogicNodeInput(node, from)); + /** + Resize the inputs array to a given size to minimize dynamic + reallocation and over-allocation later. + **/ + inline function preallocInputs(amount: Int) { + this.inputs.resize(amount); } - public function addOutputs(nodes: Array) { - outputs.push(nodes); + /** + Resize the outputs array to a given size to minimize dynamic + reallocation and over-allocation later. + **/ + inline function preallocOutputs(amount: Int) { + this.outputs.resize(amount); + for (i in 0...outputs.length) { + outputs[i] = []; + } } + /** + Add a link between to nodes to the tree. + **/ + public static function addLink(fromNode: LogicNode, toNode: LogicNode, fromIndex: Int, toIndex: Int): LogicNodeLink { + var link = new LogicNodeLink(fromNode, toNode, fromIndex, toIndex); + + if (toNode.inputs.length <= toIndex) { + toNode.inputs.resize(toIndex + 1); + } + toNode.inputs[toIndex] = link; + + var fromNodeOuts = fromNode.outputs; + var outLen = fromNodeOuts.length; + if (outLen <= fromIndex) { + fromNodeOuts.resize(fromIndex + 1); + + // Initialize with empty arrays + for (i in 0...(fromIndex - (outLen - 1))) { + fromNodeOuts[i] = []; + } + } + fromNodeOuts[fromIndex].push(link); + + return link; + } + + #if arm_patch + /** + Removes a link from the tree. + **/ + static function removeLink(link: LogicNodeLink) { + link.fromNode.outputs[link.fromIndex].remove(link); + + // Reuse the same link and connect a default input node to it. + // That's why this function is only available in arm_patch mode, we need + // access to the link's type and value. + link.fromNode = LogicNode.createSocketDefaultNode(link.toNode.tree, link.toType, link.toValue); + link.fromIndex = 0; + } + + /** + Removes all inputs and their links from this node. + Warning: this function changes the amount of node inputs to 0! + **/ + function clearInputs() { + for (link in inputs) { + link.fromNode.outputs[link.fromIndex].remove(link); + } + inputs.resize(0); + } + + /** + Removes all outputs and their links from this node. + Warning: this function changes the amount of node inputs to 0! + **/ + function clearOutputs() { + for (links in outputs) { + for (link in links) { + var defaultNode = LogicNode.createSocketDefaultNode(tree, link.toType, link.toValue); + link.fromNode = defaultNode; + link.fromIndex = 0; + defaultNode.outputs[0] = [link]; + } + } + outputs.resize(0); + } + + /** + Creates a default node for a socket so that get() and set() can be + used without null checks. + Loosely equivalent to `make_logic.build_default_node()` in Python. + **/ + static inline function createSocketDefaultNode(tree: LogicTree, socketType: String, value: Dynamic): LogicNode { + // Make sure to not add these nodes to the LogicTree.nodes array as they + // won't be garbage collected then if unlinked later. + return switch (socketType) { + case "VECTOR": new armory.logicnode.VectorNode(tree, value[0], value[1], value[2]); + case "RGBA": new armory.logicnode.ColorNode(tree, value[0], value[1], value[2], value[3]); + case "RGB": new armory.logicnode.ColorNode(tree, value[0], value[1], value[2]); + case "VALUE": new armory.logicnode.FloatNode(tree, value); + case "INT": new armory.logicnode.IntegerNode(tree, value); + case "BOOLEAN": new armory.logicnode.BooleanNode(tree, value); + case "STRING": new armory.logicnode.StringNode(tree, value); + case "NONE": new armory.logicnode.NullNode(tree); + case "OBJECT": new armory.logicnode.ObjectNode(tree, value); + default: new armory.logicnode.DynamicNode(tree, value); + } + } + #end + /** Called when this node is activated. @param from impulse index @@ -42,42 +143,45 @@ class LogicNode { **/ function runOutput(i: Int) { if (i >= outputs.length) return; - for (o in outputs[i]) { - // Check which input activated the node - for (j in 0...o.inputs.length) { - if (o.inputs[j].node == this) { - o.run(j); - break; - } - } + for (outLink in outputs[i]) { + outLink.toNode.run(outLink.toIndex); } } - @:allow(armory.logicnode.LogicNodeInput) + @:allow(armory.logicnode.LogicNodeLink) function get(from: Int): Dynamic { return this; } - @:allow(armory.logicnode.LogicNodeInput) + @:allow(armory.logicnode.LogicNodeLink) function set(value: Dynamic) {} } -class LogicNodeInput { +@:allow(armory.logicnode.LogicNode) +@:allow(armory.logicnode.LogicTree) +class LogicNodeLink { - @:allow(armory.logicnode.LogicNode) - var node: LogicNode; - var from: Int; // Socket index + var fromNode: LogicNode; + var toNode: LogicNode; + var fromIndex: Int; + var toIndex: Int; - public function new(node: LogicNode, from: Int) { - this.node = node; - this.from = from; + #if arm_patch + var fromType: String; + var toType: String; + var toValue: Dynamic; + #end + + inline function new(fromNode: LogicNode, toNode: LogicNode, fromIndex: Int, toIndex: Int) { + this.fromNode = fromNode; + this.toNode = toNode; + this.fromIndex = fromIndex; + this.toIndex = toIndex; } - @:allow(armory.logicnode.LogicNode) - function get(): Dynamic { - return node.get(from); + inline function get(): Dynamic { + return fromNode.get(fromIndex); } - @:allow(armory.logicnode.LogicNode) - function set(value: Dynamic) { - node.set(value); + inline function set(value: Dynamic) { + fromNode.set(value); } } diff --git a/Sources/armory/logicnode/QuaternionNode.hx b/Sources/armory/logicnode/QuaternionNode.hx index 95880f4f..bf83f4de 100644 --- a/Sources/armory/logicnode/QuaternionNode.hx +++ b/Sources/armory/logicnode/QuaternionNode.hx @@ -11,10 +11,10 @@ class QuaternionNode extends LogicNode { super(tree); if (x != null) { - addInput(new FloatNode(tree, x), 0); - addInput(new FloatNode(tree, y), 0); - addInput(new FloatNode(tree, z), 0); - addInput(new FloatNode(tree, w), 0); + LogicNode.addLink(new FloatNode(tree, x), this, 0, 0); + LogicNode.addLink(new FloatNode(tree, y), this, 0, 1); + LogicNode.addLink(new FloatNode(tree, z), this, 0, 2); + LogicNode.addLink(new FloatNode(tree, w), this, 0, 3); } } @@ -27,15 +27,15 @@ class QuaternionNode extends LogicNode { switch (from){ case 0: return value; - case 1: - var value1 = new Vec4(); + case 1: + var value1 = new Vec4(); value1.x = value.x; value1.y = value.y; value1.z = value.z; value1.w = 0; // use 0 to avoid this vector being translated. return value1; case 2: - return value.w; + return value.w; default: return null; } diff --git a/Sources/armory/logicnode/SetParentNode.hx b/Sources/armory/logicnode/SetParentNode.hx index 8d37720d..457facad 100644 --- a/Sources/armory/logicnode/SetParentNode.hx +++ b/Sources/armory/logicnode/SetParentNode.hx @@ -14,8 +14,8 @@ class SetParentNode extends LogicNode { var parent: Object; var isUnparent = false; - if (Std.is(inputs[2].node, ObjectNode)) { - var parentNode = cast(inputs[2].node, ObjectNode); + if (Std.is(inputs[2].fromNode, ObjectNode)) { + var parentNode = cast(inputs[2].fromNode, ObjectNode); isUnparent = parentNode.objectName == ""; } if (isUnparent) parent = iron.Scene.active.root; @@ -24,7 +24,7 @@ class SetParentNode extends LogicNode { if (object == null || parent == null || object.parent == parent) return; object.parent.removeChild(object, isUnparent); // keepTransform - + #if arm_physics var rigidBody = object.getTrait(RigidBody); if (rigidBody != null) rigidBody.setActivationState(0); diff --git a/Sources/armory/logicnode/VectorNode.hx b/Sources/armory/logicnode/VectorNode.hx index 2c186907..09cb0204 100644 --- a/Sources/armory/logicnode/VectorNode.hx +++ b/Sources/armory/logicnode/VectorNode.hx @@ -10,9 +10,9 @@ class VectorNode extends LogicNode { super(tree); if (x != null) { - addInput(new FloatNode(tree, x), 0); - addInput(new FloatNode(tree, y), 0); - addInput(new FloatNode(tree, z), 0); + LogicNode.addLink(new FloatNode(tree, x), this, 0, 0); + LogicNode.addLink(new FloatNode(tree, y), this, 0, 1); + LogicNode.addLink(new FloatNode(tree, z), this, 0, 2); } } diff --git a/Sources/armory/trait/internal/LivePatch.hx b/Sources/armory/trait/internal/LivePatch.hx index 550acb5b..fe486bc4 100644 --- a/Sources/armory/trait/internal/LivePatch.hx +++ b/Sources/armory/trait/internal/LivePatch.hx @@ -6,7 +6,7 @@ import armory.logicnode.LogicTree; #if arm_patch @:expose("LivePatch") #end @:access(armory.logicnode.LogicNode) -@:access(armory.logicnode.LogicNodeInput) +@:access(armory.logicnode.LogicNodeLink) class LivePatch extends iron.Trait { #if !arm_patch @@ -40,11 +40,54 @@ class LivePatch extends iron.Trait { var toNode = tree.nodes[toNodeName]; if (fromNode == null || toNode == null) return; - // Don't add a connection twice - if (!fromNode.outputs[fromIndex].contains(toNode)) { - fromNode.outputs[fromIndex].push(toNode); + LogicNode.addLink(fromNode, toNode, fromIndex, toIndex); + } + + public static function patchSetNodeLinks(treeName: String, nodeName: String, inputDatas: Array, outputDatas: Array>) { + var tree = LogicTree.nodeTrees[treeName]; + if (tree == null) return; + + var node = tree.nodes[nodeName]; + if (node == null) return; + + node.clearInputs(); + node.clearOutputs(); + + for (inputData in inputDatas) { + var fromNode: LogicNode; + var fromIndex: Int; + + if (inputData.isLinked) { + fromNode = tree.nodes[inputData.fromNode]; + if (fromNode == null) continue; + fromIndex = inputData.fromIndex; + } + else { + fromNode = LogicNode.createSocketDefaultNode(node.tree, inputData.socketType, inputData.socketValue); + fromIndex = 0; + } + + LogicNode.addLink(fromNode, node, fromIndex, inputData.toIndex); + } + + for (outputData in outputDatas) { + for (linkData in outputData) { + var toNode: LogicNode; + var toIndex: Int; + + if (linkData.isLinked) { + toNode = tree.nodes[linkData.toNode]; + if (toNode == null) continue; + toIndex = linkData.toIndex; + } + else { + toNode = LogicNode.createSocketDefaultNode(node.tree, linkData.socketType, linkData.socketValue); + toIndex = 0; + } + + LogicNode.addLink(node, toNode, linkData.fromIndex, toIndex); + } } - toNode.inputs[toIndex] = new LogicNodeInput(fromNode, fromIndex); } public static function patchUpdateNodeProp(treeName: String, nodeName: String, propName: String, value: Dynamic) { @@ -123,11 +166,12 @@ class LivePatch extends iron.Trait { var i = 0; for (inputData in inputDatas) { - newNode.addInput(createSocketDefaultNode(newNode.tree, inputData[0], inputData[1]), i++); + LogicNode.addLink(LogicNode.createSocketDefaultNode(newNode.tree, inputData[0], inputData[1]), newNode, 0, i++); } + i = 0; for (outputData in outputDatas) { - newNode.addOutputs([createSocketDefaultNode(newNode.tree, outputData[0], outputData[1])]); + LogicNode.addLink(newNode, LogicNode.createSocketDefaultNode(newNode.tree, outputData[0], outputData[1]), i++, 0); } } @@ -151,27 +195,14 @@ class LivePatch extends iron.Trait { var i = 0; for (inputData in inputDatas) { - newNode.addInput(createSocketDefaultNode(newNode.tree, inputData[0], inputData[1]), i++); + LogicNode.addLink(LogicNode.createSocketDefaultNode(newNode.tree, inputData[0], inputData[1]), newNode, 0, i++); } + i = 0; for (outputData in outputDatas) { - newNode.addOutputs([createSocketDefaultNode(newNode.tree, outputData[0], outputData[1])]); + LogicNode.addLink(newNode, LogicNode.createSocketDefaultNode(newNode.tree, outputData[0], outputData[1]), i++, 0); } } - static inline function createSocketDefaultNode(tree: LogicTree, socketType: String, value: Dynamic): LogicNode { - return switch (socketType) { - case "VECTOR": new armory.logicnode.VectorNode(tree, value[0], value[1], value[2]); - case "RGBA": new armory.logicnode.ColorNode(tree, value[0], value[1], value[2], value[3]); - case "RGB": new armory.logicnode.ColorNode(tree, value[0], value[1], value[2]); - case "VALUE": new armory.logicnode.FloatNode(tree, value); - case "INT": new armory.logicnode.IntegerNode(tree, value); - case "BOOLEAN": new armory.logicnode.BooleanNode(tree, value); - case "STRING": new armory.logicnode.StringNode(tree, value); - case "NONE": new armory.logicnode.NullNode(tree); - case "OBJECT": new armory.logicnode.ObjectNode(tree, value); - default: new armory.logicnode.DynamicNode(tree, value); - } - } #end } diff --git a/blender/arm/live_patch.py b/blender/arm/live_patch.py index 99314c54..ba60b13f 100644 --- a/blender/arm/live_patch.py +++ b/blender/arm/live_patch.py @@ -269,6 +269,53 @@ def send_event(event_id: str, opt_data: Any = None): js = f'LivePatch.patchNodeCopy("{tree_name}", "{node_name}", "{newnode_name}", {props_list}, {inp_data}, {out_data});' write_patch(js) + elif event_id == 'ln_update_sockets': + node: ArmLogicTreeNode = opt_data + + tree_name = arm.node_utils.get_export_tree_name(node.get_tree()) + node_name = arm.node_utils.get_export_node_name(node)[1:] + + inp_data = '[' + for idx, inp in enumerate(node.inputs): + inp_data += '{' + # is_linked can be true even if there are no links if the + # user starts dragging a connection away before releasing + # the mouse + if inp.is_linked and len(inp.links) > 0: + inp_data += 'isLinked: true,' + inp_data += f'fromNode: "{arm.node_utils.get_export_node_name(inp.links[0].from_node)[1:]}",' + inp_data += f'fromIndex: {arm.node_utils.get_socket_index(inp.links[0].from_node.outputs, inp.links[0].from_socket)},' + else: + inp_data += 'isLinked: false,' + inp_data += f'socketType: "{inp.arm_socket_type}",' + inp_data += f'socketValue: {arm.node_utils.haxe_format_socket_val(inp.get_default_value())},' + + inp_data += f'toIndex: {idx}' + inp_data += '},' + inp_data += ']' + + out_data = '[' + for idx, out in enumerate(node.outputs): + out_data += '[' + for link in out.links: + out_data += '{' + if out.is_linked: + out_data += 'isLinked: true,' + out_data += f'toNode: "{arm.node_utils.get_export_node_name(link.to_node)[1:]}",' + out_data += f'toIndex: {arm.node_utils.get_socket_index(link.to_node.inputs, link.to_socket)},' + else: + out_data += 'isLinked: false,' + out_data += f'socketType: "{out.arm_socket_type}",' + out_data += f'socketValue: {arm.node_utils.haxe_format_socket_val(out.get_default_value())},' + + out_data += f'fromIndex: {idx}' + out_data += '},' + out_data += '],' + out_data += ']' + + js = f'LivePatch.patchSetNodeLinks("{tree_name}", "{node_name}", {inp_data}, {out_data});' + write_patch(js) + def on_operator(operator_id: str): """As long as bpy.msgbus doesn't listen to changes made by diff --git a/blender/arm/logicnode/arm_nodes.py b/blender/arm/logicnode/arm_nodes.py index d7548c5c..5fd365a6 100644 --- a/blender/arm/logicnode/arm_nodes.py +++ b/blender/arm/logicnode/arm_nodes.py @@ -24,6 +24,10 @@ category_items: ODict[str, List['ArmNodeCategory']] = OrderedDict() array_nodes = dict() +# See ArmLogicTreeNode.update() +# format: [tree pointer => (num inputs, num input links, num outputs, num output links)] +last_node_state: dict[int, tuple[int, int, int, int]] = {} + class ArmLogicTreeNode(bpy.types.Node): arm_category = PKG_AS_CATEGORY @@ -62,6 +66,40 @@ class ArmLogicTreeNode(bpy.types.Node): def get_tree(self): return self.id_data + def update(self): + """Called if the node was updated in some way, for example + if socket connections change. This callback is not called if + socket values were changed. + """ + def num_connected(sockets): + return sum([socket.is_linked for socket in sockets]) + + # If a link between sockets is removed, there is currently no + # _reliable_ way in the Blender API to check which connection + # was removed (*). + # + # So instead we just check _if_ the number of links or sockets + # has changed (the update function is called before and after + # each link removal). Because we listen for those updates in + # general, we automatically also listen to link creation events, + # which is more stable than using the dedicated callback for + # that (`insert_link()`), because adding links can remove other + # links and we would need to react to that as well. + # + # (*) https://devtalk.blender.org/t/how-to-detect-which-link-was-deleted-by-user-in-node-editor + + self_id = self.as_pointer() + + current_state = (len(self.inputs), num_connected(self.inputs), len(self.outputs), num_connected(self.outputs)) + if self_id not in last_node_state: + # Lazily initialize the last_node_state dict to also store + # state for nodes that already exist in the tree + last_node_state[self_id] = current_state + + if last_node_state[self_id] != current_state: + arm.live_patch.send_event('ln_update_sockets', self) + last_node_state[self_id] = current_state + def free(self): """Called before the node is deleted.""" arm.live_patch.send_event('ln_delete', self) @@ -84,7 +122,8 @@ class ArmLogicTreeNode(bpy.types.Node): def insert_link(self, link: bpy.types.NodeLink): """Called on *both* nodes when a link between two nodes is created.""" - arm.live_patch.send_event('ln_insert_link', (self, link)) + # arm.live_patch.send_event('ln_insert_link', (self, link)) + pass def get_replacement_node(self, node_tree: bpy.types.NodeTree): # needs to be overridden by individual node classes with arm_version>1 diff --git a/blender/arm/make_logic.py b/blender/arm/make_logic.py index 3299e085..0e594f17 100755 --- a/blender/arm/make_logic.py +++ b/blender/arm/make_logic.py @@ -68,6 +68,7 @@ def build_node_tree(node_group: 'arm.nodes_logic.ArmLogicTree'): with open(file, 'w', encoding="utf-8") as f: f.write('package ' + pack_path + '.node;\n\n') + f.write('@:access(armory.logicnode.LogicNode)') f.write('@:keep class ' + group_name + ' extends armory.logicnode.LogicTree {\n\n') f.write('\tvar functionNodes:Map;\n\n') f.write('\tvar functionOutputNodes:Map;\n\n') @@ -162,8 +163,12 @@ def build_node(node: bpy.types.Node, f: TextIO) -> Optional[str]: prop = arm.node_utils.haxe_format_prop_value(node, prop_name) f.write('\t\t' + name + '.' + prop_name + ' = ' + prop + ';\n') + # Avoid unnecessary input/output array resizes + f.write(f'\t\t{name}.preallocInputs({len(node.inputs)});\n') + f.write(f'\t\t{name}.preallocOutputs({len(node.outputs)});\n') + # Create inputs - for inp in node.inputs: + for idx, inp in enumerate(node.inputs): # True if the input is connected to a unlinked reroute # somewhere down the reroute line unconnected = False @@ -191,34 +196,40 @@ def build_node(node: bpy.types.Node, f: TextIO) -> Optional[str]: for i in range(0, len(n.outputs)): if n.outputs[i] == socket: inp_from = i + from_type = socket.arm_socket_type break # Not linked -> create node with default values else: inp_name = build_default_node(inp) inp_from = 0 + from_type = inp.arm_socket_type # The input is linked to a reroute, but the reroute is unlinked if unconnected: inp_name = build_default_node(inp) inp_from = 0 + from_type = inp.arm_socket_type # Add input - f.write('\t\t' + name + '.addInput(' + inp_name + ', ' + str(inp_from) + ');\n') + f.write(f'\t\t{"var __link = " if wrd.arm_live_patch else ""}armory.logicnode.LogicNode.addLink({inp_name}, {name}, {inp_from}, {idx});\n') + if wrd.arm_live_patch: + to_type = inp.arm_socket_type + f.write(f'\t\t__link.fromType = "{from_type}";') + f.write(f'\t\t__link.toType = "{to_type}";') + f.write(f'\t\t__link.toValue = {arm.node_utils.haxe_format_socket_val(inp.get_default_value())};') # Create outputs - for out in node.outputs: - if out.is_linked: - out_name = '' - for node in collect_nodes_from_output(out, f): - out_name += '[' if len(out_name) == 0 else ', ' - out_name += node - out_name += ']' - # Not linked - create node with default values - else: - out_name = '[' + build_default_node(out) + ']' - # Add outputs - f.write('\t\t' + name + '.addOutputs(' + out_name + ');\n') + for idx, out in enumerate(node.outputs): + # Linked outputs are already handled after iterating over inputs + # above, so only unconnected outputs are handled here + if not out.is_linked: + f.write(f'\t\t{"var __link = " if wrd.arm_live_patch else ""}armory.logicnode.LogicNode.addLink({name}, {build_default_node(out)}, {idx}, 0);\n') + if wrd.arm_live_patch: + out_type = out.arm_socket_type + f.write(f'\t\t__link.fromType = "{out_type}";') + f.write(f'\t\t__link.toType = "{out_type}";') + f.write(f'\t\t__link.toValue = {arm.node_utils.haxe_format_socket_val(out.get_default_value())};') return name