From ac0ad04c7248481606843964fcaaca00e5d96c82 Mon Sep 17 00:00:00 2001 From: "florentin.luce" Date: Wed, 6 Nov 2024 11:26:28 +0100 Subject: [PATCH] Commit global --- __init__.py | 7 +- bl_utils.py | 0 core/{node.py => _node.py} | 8 +- core/{node_tree.py => _node_tree.py} | 6 +- core/{sockets.py => _sockets.py} | 5 +- core/dumper.py | 788 +++++++++++++++++++++++++++ core/node_utils.py | 68 +++ core/pack_nodes.py | 405 ++++++++++++++ file_utils.py | 12 + operators.py | 199 ++++++- ui.py | 10 +- 11 files changed, 1484 insertions(+), 24 deletions(-) create mode 100644 bl_utils.py rename core/{node.py => _node.py} (96%) rename core/{node_tree.py => _node_tree.py} (91%) rename core/{sockets.py => _sockets.py} (94%) create mode 100644 core/dumper.py create mode 100644 core/node_utils.py create mode 100644 core/pack_nodes.py create mode 100644 file_utils.py diff --git a/__init__.py b/__init__.py index d7f0758..4378d13 100644 --- a/__init__.py +++ b/__init__.py @@ -10,11 +10,8 @@ import sys import importlib from pathlib import Path -# Ensure the name of the module in python import -module_name = Path(__file__).parent.name -sys.modules.update({'node_kit': importlib.import_module(module_name)}) -from node_kit import ui, operators +from . import ui, operators modules = ( ui, @@ -30,7 +27,7 @@ if "bpy" in locals(): def register(): - print('Register Noke kit') + print('Register Node kit') for mod in modules: mod.register() diff --git a/bl_utils.py b/bl_utils.py new file mode 100644 index 0000000..e69de29 diff --git a/core/node.py b/core/_node.py similarity index 96% rename from core/node.py rename to core/_node.py index 35f05a7..7196bf6 100644 --- a/core/node.py +++ b/core/_node.py @@ -130,7 +130,7 @@ class Node: node.outputs = [Output.from_dict(opt_data, node) for opt_data in data['outputs'].values()] return node - def to_dict(self): + def dump(self): """Export currrent Node to its dict representation. Returns: @@ -155,8 +155,8 @@ class Node: self.data[prop_id] = attr_value self.data['id'] = self.id - self.data['inputs'] = {ipt.id: ipt.to_dict() for ipt in self.inputs} - self.data['outputs'] = {opt.id: opt.to_dict() for opt in self.outputs} + self.data['inputs'] = {ipt.id: ipt.dump() for ipt in self.inputs} + self.data['outputs'] = {opt.id: opt.dump() for opt in self.outputs} return self.data @@ -205,7 +205,7 @@ class Link: self.data = {} - def to_dict(self): + def dump(self): self.data['id'] = self.id diff --git a/core/node_tree.py b/core/_node_tree.py similarity index 91% rename from core/node_tree.py rename to core/_node_tree.py index 4e605c0..c042a9a 100644 --- a/core/node_tree.py +++ b/core/_node_tree.py @@ -18,7 +18,7 @@ class NodeTree: for n in self.bl_node_tree.nodes: self.nodes.append(Node.from_blender_node(n, self)) - def to_dict(self, select_only=False): + def dump(self, select_only=False): """Convert all blender nodes and links inside the tree into a dictionnary. Args: @@ -28,12 +28,12 @@ class NodeTree: Returns: dict: Nodes and links as dict. """ - self.data['nodes'] = {n.id: n.to_dict() for n in self.nodes if not select_only or (select_only and n.select)} + self.data['nodes'] = {n.id: n.dump() for n in self.nodes if not select_only or (select_only and n.select)} self.data['links'] = [l.id for l in self.links] return self.data - def ingest_dict(self, data): + def load(self, data): """From a Tree dict representation, create new nodes with their attributes. Then create a connection dict by comparing link id from inputs and outputs of each nodes. Use this dict to link nodes between each others. diff --git a/core/sockets.py b/core/_sockets.py similarity index 94% rename from core/sockets.py rename to core/_sockets.py index 1d6408a..32ab4b8 100644 --- a/core/sockets.py +++ b/core/_sockets.py @@ -12,7 +12,10 @@ class Socket: self.identifier = bl_socket.identifier self.is_linked = bl_socket.is_linked - self._value = bl_socket.default_value + self._value = None + + if hasattr(bl_socket, 'default_value'): + self._value = bl_socket.default_value @property def value(self): diff --git a/core/dumper.py b/core/dumper.py new file mode 100644 index 0000000..31176de --- /dev/null +++ b/core/dumper.py @@ -0,0 +1,788 @@ +import bpy +import mathutils +from pprint import pprint +import json +import itertools +from copy import copy +from os.path import abspath + + + +def get_default(prop): + """Get the default value of a bl property""" + + if getattr(prop, 'is_array', False): + return list(prop.default_array) + elif hasattr(prop, 'default'): + return prop.default + + +def get_dumper(bl_object, fallback=None): + """Find the right dumper type by checking inheritance""" + for dp in dumpers: + if isinstance(bl_object, dp.bl_type): + return dp + + return fallback or Dumper + + +def get_bl_object(data): + """Find the bl object for loading data into it depending on the type and the context""" + if data.get('_new', {}).get('type') == 'GeometryNodeTree': + return bpy.context.object.modifiers.active.node_group + + +def dump(ob): + """Generic Recursive Dump, convert any object into a dict""" + Dumper.pointers.clear() + + if isinstance(ob, (list, tuple)): + data = [get_dumper(o).dump(o) for o in ob] + else: + data = get_dumper(ob).dump(ob) + + Dumper.pointers.clear() + + return data + + +def load(data, bl_object=None): + """Generic Load to create an object from a dict""" + + Dumper.pointers.clear() + #print(Dumper.pointers) + + if bl_object is None: + bl_object = get_bl_object(data) + + dumper = get_dumper(bl_object) + dumper.load(data, bl_object) + + Dumper.pointers.clear() + + +def set_attribute(bl_object, attr, value): + try: + setattr(bl_object, attr, value) + except Exception as e: + print(e) + + +class Dumper: + pointers = {} + includes = [] + excludes = ["rna_type", "bl_rna", 'id_data', 'depsgraph'] + + @classmethod + def properties(cls, bl_object): + if cls.includes and not cls.excludes: + return [bl_object.bl_rna.properties[p] for p in cls.includes] + else: + return [ p for p in bl_object.bl_rna.properties if not + p.identifier.startswith('bl_') and p.identifier not in cls.excludes] + + @classmethod + def new(cls, data): + print(f'New not implemented for data {data}') + + @classmethod + def load(cls, data, bl_object=None): + if bl_object is None: + bl_object = cls.new(data) + + if bl_object is None: + return + + #pprint(data) + if bl_pointer := data.get('bl_pointer'): + cls.pointers[bl_pointer] = bl_object + + props = cls.properties(bl_object) + for key, value in sorted(data.items(), key=lambda x: props.index(x[0]) if x[0] in props else 0): + if key.startswith('_') or key not in bl_object.bl_rna.properties: + continue + + prop = bl_object.bl_rna.properties[key] + attr = getattr(bl_object, key) + + if prop.type == 'COLLECTION': + dumper = PropCollection + if hasattr(attr, 'bl_rna'): + bl_type = attr.bl_rna.type_recast() + dumper = get_dumper(bl_type, fallback=PropCollection) + + dumper.load(value, attr) + continue + + elif prop.type == 'POINTER': + # if key == 'node_tree': + # print('--------------') + # print(bl_object, value) + # print(cls.pointers) + + if isinstance(value, int): # It's a pointer + value = cls.pointers[value] + elif value is None: + set_attribute(bl_object, key, value) + + else: + bl_type = prop.fixed_type.bl_rna.type_recast() + dumper = get_dumper(bl_type) + + # If the pointer exist register the pointer then load data + #print('-----', value) + #pointer = + if attr is None: + attr = dumper.new(value) + + dumper.load(value, attr) + #attr = getattr(bl_object, key) + #if not attr: + cls.pointers[value['bl_pointer']] = attr + + if hasattr(attr, 'update'): + attr.update() + + value = attr + + if not prop.is_readonly: + set_attribute(bl_object, key, value) + + # Some coll needs a manual update like curve mapping + if hasattr(attr, 'update'): + attr.update() + + elif not prop.is_readonly: + #print(key, value) + set_attribute(bl_object, key, value) + continue + + #return bl_object + + @classmethod + def dump(cls, bl_object): + if isinstance(bl_object, (str, int, float, dict, list, type(None))): + return bl_object + + #print('Dumping object', bl_object) + + data = {"bl_pointer": bl_object.as_pointer()} + cls.pointers[bl_object.as_pointer()] = bl_object + + + for prop in cls.properties(bl_object): + if not hasattr(bl_object, prop.identifier): + print(f'{bl_object} has no attribute {prop.identifier}') + continue + + #print(prop.identifier) + + value = getattr(bl_object, prop.identifier) + + # Not storing default value + if prop.identifier not in cls.includes: + if (array := getattr(prop, 'default_array', None)) and value == array: + continue + if isinstance(value, (str, int, float)) and value == prop.default: + continue + + if getattr(prop, "is_array", False): + value = PropArray.dump(value) + + elif prop.type == 'COLLECTION': + value = PropCollection.dump(value) + + elif prop.type == 'POINTER' and value: + #if prop.identifier == 'image': + # print(bl_object, cls.pointers) + if value.as_pointer() in cls.pointers: + value = value.as_pointer() + else: + # print('Register Pointer', value.as_pointer(), value) + cls.pointers[value.as_pointer()] = value + # print(cls.pointers) + # print() + dumper = get_dumper(value) + value = dumper.dump(value) + + elif bl_object.is_property_readonly(prop.identifier): + continue + + else: + dumper = get_dumper(value) + value = dumper.dump(value) + + data[prop.identifier] = value + + return data + + +class PropCollection(Dumper): + bl_type = bpy.types.bpy_prop_collection + + @classmethod + def dump(cls, coll): + if not len(coll): + return [] + + dumper = get_dumper(coll[0]) + values = [dumper.dump(e) for e in coll] + + # Value cannot be None + return [v for v in values if v is not None] + + + @classmethod + def load(cls, values, coll): + if not values: + return + + dumper = None + + if not hasattr(coll, 'new'): # Static collection + for item, value in zip(coll, values): + dumper = dumper or get_dumper(item) + dumper.load(value, item) + + return + + new_func = coll.bl_rna.functions['new'] + for i, value in enumerate(values): + + if value.get('_new'): + params = value['_new'] + else: + params = {k: value.get(k, get_default(v)) for k, v in new_func.parameters.items()[:-1]} + + # Replace arg pointer with bl object + valid_pointers = True + for param in coll.bl_rna.functions['new'].parameters: + if param.identifier not in params or param.type != 'POINTER': + continue + + pointer_id = params[param.identifier] + if bl_object := cls.pointers.get(pointer_id): + params[param.identifier] = bl_object + else: + print(f'No Pointer found for param {param.identifier} of {coll}') + valid_pointers = False + + if not valid_pointers: + continue + + #print(param.identifier, cls.pointers[pointer_id]) + + try: + + item = coll.new(**params) + except RuntimeError as e: + #print(e, coll.data) + #print() + try: + item = coll[i] + except IndexError as e: + #print(e, coll.data) + break + + dumper = get_dumper(item) + dumper.load(value, item)#(item, value) + + +class PropArray(Dumper): + bl_type = bpy.types.bpy_prop_array + + @classmethod + def dump(cls, array): + flat_array = [] + for item in array: + if isinstance(item, (int, float)): + flat_array.append(item) + else: + flat_array.extend(cls.dump(item)) + return flat_array + + +class NodeSocket(Dumper): + bl_type = bpy.types.NodeSocket + excludes = Dumper.excludes + ["node", "links", "display_shape", "rna_type", "link_limit"] + + @classmethod + def dump(cls, socket): + if socket.is_unavailable: + return None + + #cls.pointers[socket.as_pointer()] = socket + + data = super().dump(socket) + + #data["_id"] = socket.as_pointer() + #data.pop('name', '') + + return data + + +class NodeGeometryRepeatOutputItems(PropCollection): + bl_type = bpy.types.NodeGeometryRepeatOutputItems + + @classmethod + def load(cls, values, coll): + coll.clear() + + super().load(values, coll) + + +class NodeLink(Dumper): + bl_type = bpy.types.NodeLink + + @classmethod + def dump(cls, link): + return {"_new": { + "input": link.from_socket.as_pointer(), + "output": link.to_socket.as_pointer() + } + } + + +class NodeTreeInterfaceSocket(Dumper): + bl_type = bpy.types.NodeTreeInterfaceSocket + excludes = Dumper.excludes + ["parent", "interface_items"] + + @classmethod + def dump(cls, socket): + #cls.pointers[socket.as_pointer()] = socket + + data = super().dump(socket) + #data["_id"] = socket.as_pointer() + + data['_new'] = {"name": data.get('name', '')} + + if socket.item_type == 'SOCKET': + data['_new']["in_out"] = socket.in_out + + + # It's a real panel not the interface root + if socket.parent.parent: + data['parent'] = socket.parent.as_pointer() + + return data + + +class NodeSockets(PropCollection): + + @classmethod + def load(cls, values, coll): + + #return + + node_sockets = [s for s in coll if not s.is_unavailable] + for socket, value in zip(node_sockets, values): + cls.pointers[value['bl_pointer']] = socket + Dumper.load(value, socket) + # for k, v in value.items(): + # if k not in socket.bl_rna.properties: + # continue + # setattr(socket, k, v) + + """ + # Match Inputs Pointers + node_sockets = [s for s in coll if not s.is_unavailable] + if len(node_sockets) == len(inputs): # Match by index + super().load({"inputs": inputs}, node) + for socket, value in zip(node_sockets, coll): + cls.pointers[value['_id']] = socket + else: # Match by name + print(f'Match Inputs by Name for node {node}') + for socket in node_sockets: + index = next((i for i, v in enumerate(inputs) if v['name'] == socket.name), None) + if index is None: + continue + + value = inputs[index] + print(socket, value) + cls.pointers[value['_id']] = socket + + Dumper.load(value, socket) + del inputs[index] + """ + + +class NodeInputs(NodeSockets): + bl_type = bpy.types.NodeInputs + + +class NodeOutputs(NodeSockets): + bl_type = bpy.types.NodeOutputs + + +class Node(Dumper): + bl_type = bpy.types.Node + excludes = Dumper.excludes + ["dimensions", "height", "internal_links", "paired_output"] + + @classmethod + def dump(cls, node=None): + #cls.pointers[node.as_pointer()] = node + + data = super().dump(node) + #data["_id"] = node.as_pointer() + data["_new"] = {"type": node.bl_rna.identifier} # 'node_tree': node.id_data.as_pointer() + + if paired_output := getattr(node, "paired_output", None): + data["_pair_with_output"] = paired_output.as_pointer() + + #if node.parent: + # data['location'] -= Vector()node.parent.location + + return data + + @classmethod + def load(cls, data, node): + if node is None: + return + #cls.pointers[data['bl_pointer']] = node + + inputs = copy(data.pop('inputs', [])) + outputs = copy(data.pop('outputs', [])) + + super().load(data, node) + + data['inputs'] = inputs + data['outputs'] = outputs + + # Loading input and outputs after the properties + super().load({"inputs": inputs, "outputs": outputs}, node) + + if node.parent: + node.location += node.parent.location + + #if node.type != 'FRAME': + # node.location.y -= 500 + + +class CompositorNodeGlare(Node): + bl_type = bpy.types.CompositorNodeGlare + + includes = ["quality"] + + +class NodeTreeInterface(Dumper): + bl_type = bpy.types.NodeTreeInterface + + @classmethod + def load(cls, data, interface): + + print('Load Interface') + + for value in data.get('items_tree', []): + item_type = value.get('item_type', 'SOCKET') + if item_type == 'SOCKET': + item = interface.new_socket(**value['_new']) + elif item_type == 'PANEL': + #print(value['_new']) + item = interface.new_panel(**value['_new']) + + NodeTreeInterfaceSocket.load(value, item) + + interface.active_index = data.get('active_index', 0) + + +class Nodes(PropCollection): + bl_type = bpy.types.Nodes + + @classmethod + def load(cls, values, coll): + super().load(values, coll) + + # Pair zone input and output + for node_data in values: + if paired_output_id := node_data.get('_pair_with_output', None): + node = cls.pointers[node_data['bl_pointer']] + node.pair_with_output(cls.pointers[paired_output_id]) + + #print(node, node_data['outputs']) + + Dumper.load({"inputs": node_data['inputs'], "outputs": node_data['outputs']}, node) + + +class NodeTree(Dumper): + bl_type = bpy.types.NodeTree + excludes = [] + includes = ["name", "interface", "nodes", "links"] + + @classmethod + def new(cls, data): + if link := data.get('_link'): + with bpy.data.libraries.load(link['filepath'], link=True) as (data_from, data_to): + setattr(data_to, link['data_type'], [link['name']]) + return getattr(data_to, link['data_type'])[0] + + return bpy.data.node_groups.new(**data["_new"]) + + @classmethod + def dump(cls, node_tree): + if node_tree.library: + data = {'bl_pointer': node_tree.as_pointer()} + filepath = abspath(bpy.path.abspath(node_tree.library.filepath, library=node_tree.library.library)) + data["_link"] = {"filepath": filepath, "data_type": 'node_groups', 'name': node_tree.name} + else: + data = super().dump(node_tree) + data["_new"] = {"type": node_tree.bl_rna.identifier, 'name': node_tree.name} + + return data + + +class Points(PropCollection): + + @classmethod + def load(cls, values, coll): + new_func = coll.bl_rna.functions['new'] + params = {k: get_default(v)+1.1 for k, v in new_func.parameters.items()[:-1]} + + # Match the same number of elements in collection + if len(values) > len(coll): + for _ in range(len(values) - len(coll)): + coll.new(**params) + + for i, value in enumerate(values): + Dumper.load(value, coll[i]) + #for k, v in value.items(): + #setattr(coll[i], k, v) + + +class CurveMapPoints(Points): + bl_type = bpy.types.CurveMapPoints + + +class ColorRampElements(Points): + bl_type = bpy.types.ColorRampElements + + +class CompositorNodeOutputFileLayerSlots(PropCollection): + bl_type = bpy.types.CompositorNodeOutputFileLayerSlots + + @classmethod + def load(cls, values, coll): + coll.clear() + + super().load(values, coll) + + +class CompositorNodeOutputFileFileSlots(PropCollection): + @classmethod + def load(cls, values, coll): + coll.clear() + + super().load(values, coll) + + +class AOVs(PropCollection): + bl_type = bpy.types.AOVs + + @classmethod + def load(cls, values, coll): + for value in values: + aov = coll.get(value['name']) + + if not aov: + aov = coll.add() + + Dumper.load(value, aov) + + +class Image(Dumper): + bl_type = bpy.types.Image + + excludes = [] + includes = ['name', 'filepath'] + + @classmethod + def new(cls, data): + # image = next(( img for img in bpy.data.images if not img.library + # and img.filepath == data['filepath']), None) + + # if image is None: + # image = bpy.data.images.load(data['filepath']) + + return bpy.data.images.load(data['filepath'], check_existing=True) + + +class Material(Dumper): + bl_type = bpy.types.Material + + excludes = Dumper.excludes + ['preview', "original"] + + @classmethod + def new(cls, data): + material = bpy.data.materials.get(data.get('name', '')) + + if material is None: + material = bpy.data.materials.new(data['name']) + + return material + + +class Object(Dumper): + bl_type = bpy.types.Object + excludes = [] + includes = ['name'] + + @classmethod + def new(cls, data): + if name := data.get('name'): + return bpy.data.objects.get(name) + + +class Scene(Dumper): + bl_type = bpy.types.Scene + excludes = [] + includes = ['name'] + + + @classmethod + def new(cls, data): + if scene := bpy.data.scenes.get(data.get('name', '')): + return scene + + return bpy.data.scenes.new(name=data.get('name', '')) + + """ + @classmethod + def dump(cls, scene): + view_layer = scene.view_layers[node.layer] + view_layer_data = ViewLayer.dump(view_layer) + + return { + 'bl_pointer': scene.as_pointer(), + 'name': scene.name, + 'render' : {'bl_pointer': scene.render.as_pointer(), "engine": scene.render.engine}, + 'view_layers': [view_layer_data] + } + """ + +class Collection(Dumper): + bl_type = bpy.types.Collection + includes = ['name'] + excludes = [] + + @classmethod + def new(cls, data): + if name := data.get('name'): + return bpy.data.collections.get(name) + + # @classmethod + # def dump(cls, data): + # data = super().dump(data) + + # data['render'] = {"engine": scene.render.engine} + # return data + + +class CompositorNodeRLayers(Node): + bl_type = bpy.types.CompositorNodeRLayers + + excludes = Dumper.excludes + ['scene'] + + @classmethod + def load(cls, data, node): + + #print('load CompositorNodeRLayers') + + scene_data = data.pop('scene') + #print(scene_data) + layer = data.pop('layer') + scene = Scene.new(scene_data) + Scene.load(scene_data, scene) + + node.scene = scene + node.layer = layer + + super().load(data, node) + + # Resetter the view_layer because it might have been created + # with the scene attr in the dictionnary and nor available yet + + #print(bpy.) + + + + @classmethod + def dump(cls, node): + # Add scene and viewlayer passes + data = super().dump(node) + + #if + + view_layer = node.scene.view_layers[node.layer] + view_layer_data = ViewLayer.dump(view_layer) + + ''' + view_layer_data = { + "name": view_layer.name} + properties = {p.name: p for p in view_layer.bl_rna.properties} + for prop in view_layer.bl_rna: + if prop.identifier.startswith('use_pass'): + view_layer_data[prop.identifier] + ''' + + #cls.pointers[bl_object.as_pointer()] = bl_object + + data['scene'] = { + 'bl_pointer': node.scene.as_pointer(), + 'name': node.scene.name, + 'render' : {'bl_pointer': node.scene.render.as_pointer(), "engine": node.scene.render.engine}, + 'view_layers': [view_layer_data] + } + + return data + + +class ViewLayer(Dumper): + bl_type = bpy.types.ViewLayer + excludes = Dumper.excludes + ['freestyle_settings', 'eevee', 'cycles', 'active_layer_collection', + 'active_aov', 'active_lightgroup_index', 'layer_collection', 'lightgroups', 'material_override', + 'objects', 'use'] + #includes = ['name'] + + +class ViewLayers(PropCollection): + bl_type = bpy.types.ViewLayers + + @classmethod + def load(cls, values, coll): + #print('LOAD VIEWLAYERS', values) + for value in values: + view_layer = coll.get(value['name']) + + if view_layer is None: + view_layer = coll.new(value['name']) + + Dumper.load(value, view_layer) + + +dumpers = [ + CompositorNodeRLayers, + CompositorNodeGlare, + Node, + NodeSocket, + NodeTree, + NodeLink, + NodeTreeInterface, + NodeTreeInterfaceSocket, + NodeGeometryRepeatOutputItems, + Image, + Material, + Object, + Scene, + Collection, + ViewLayer, + CurveMapPoints, + ColorRampElements, + NodeInputs, + NodeOutputs, + Nodes, + ViewLayers, + PropCollection, + AOVs, + PropArray, + CompositorNodeOutputFileLayerSlots, + CompositorNodeOutputFileFileSlots, +] \ No newline at end of file diff --git a/core/node_utils.py b/core/node_utils.py new file mode 100644 index 0000000..693c6cf --- /dev/null +++ b/core/node_utils.py @@ -0,0 +1,68 @@ + +import bpy +import re + + +def clean_name(name): + if re.match(r'(.*)\.\d{3}$', name): + return name[:-4] + return name + + +def is_node_groups_duplicate(node_groups): + node_group_types = sorted([n.type for n in node_groups[0].nodes]) + return all( sorted([n.type for n in ng.nodes]) == + node_group_types for ng in node_groups[1:]) + + +def remap_node_group_duplicates(nodes=None, force=False): + if nodes is None: + nodes = list(bpy.data.node_groups) + + nodes = [n for n in nodes if not n.library] + + failed = [] + merged = [] + + # Group by name + groups = {} + for node in nodes: + groups.setdefault(clean_name(node.name), []).append(node) + + for node in bpy.data.node_groups: + name = clean_name(node.name) + if name in groups and node not in groups[name]: + groups[name].append(node) + + print("\nMerge Duplicate NodeGroup...") + + for node_groups in groups.values(): + if len(node_groups) == 1: + continue + + if not force: + node_groups.sort(key=lambda x : x.name, reverse=True) + + print(node_groups) + + for node_group in node_groups[1:]: + is_duplicate = is_node_groups_duplicate((node_group, node_groups[0])) + + if not is_duplicate and not force: + failed.append((node_group.name, node_groups[0].name)) + print(f'Cannot merge Nodegroup {node_group.name} with {node_groups[0].name} they are different') + continue + + merged.append((node_group.name, node_groups[0].name)) + print(f'Merge Nodegroup {node_group.name} into {node_groups[0].name}') + + node_group.user_remap(node_groups[0]) + bpy.data.node_groups.remove(node_group) + node_groups.remove(node_group) + + # Rename groups if it has no duplicate left + for node_groups in groups.values(): + if len(node_groups) == 1 and not node_groups[0].library: + node_groups[0].name = clean_name(node_groups[0].name) + + return merged, failed \ No newline at end of file diff --git a/core/pack_nodes.py b/core/pack_nodes.py new file mode 100644 index 0000000..620336e --- /dev/null +++ b/core/pack_nodes.py @@ -0,0 +1,405 @@ +import bpy + +def set_params(src, tgt, mod_to_node=True, org_modifier=None): + # mod to node: est-ce qu'on copie les valeurs d'un modifier a une node, ou l'inverse + if mod_to_node: # syntax for node and modifier are slightly different + tree = src.node_group.interface.items_tree + else: + tree = src.node_tree.interface.items_tree + + for param in tree: + if param.socket_type == 'NodeSocketGeometry': + continue + if param.in_out == 'OUTPUT': + continue + + # seulement en extract mode, src est une node donc on check si des parametres sont dans le modifier + node_link_value = get_node_link_value(src, param.name, org_modifier) + identifier = tree.get(param.name).identifier + + if mod_to_node: + tgt.inputs[identifier].default_value = src[identifier] + + else: + if node_link_value: + tgt[identifier] = node_link_value + else: + tgt[identifier] = src.inputs[identifier].default_value + +def set_group_inputs(target, objects, group): + mod = target.modifiers[0] + node_dct = {} # used for cleanup + for key, inp in get_node_inputs(objects).items(): + # add the socket to the node group / modifier pannel + sock = group.interface.new_socket(inp["label"],in_out="INPUT",socket_type=inp["socket"]) + mod[sock.identifier] = inp["data"] + + # inspect all nodes and add a group input node when that socket is used + for node in parse_nodes(objects): + for param in node.node_tree.interface.items_tree: + nkey = get_input_socket_key(node, param) + if not nkey: continue + if nkey == key: + input_node = add_input_node(group, node, param.identifier, sock) + + # on efface les parametres par defaut qui sont connectes + # ( pour ne pas garder de trace des anciens params / collections /object) + node.inputs[param.identifier].default_value = None + + # add to dict for cleanup + if not node in node_dct.keys(): + node_dct[node] = [input_node] + else: + node_dct[node].append(input_node) + + # on refait la meme chose pour les object info nodes car leur syntaxe est un peu differente + for node in parse_nodes(objects, type = "OBJECT_INFO"): + nkey = get_input_socket_key(node, param) + if not nkey: continue + if nkey == key: + input_node = add_input_node(group, node, 'Object', sock) + node.inputs['Object'].default_value = None + + # add to dict for cleanup + if not node in node_dct.keys(): + node_dct[node] = [input_node] + else: + node_dct[node].append(input_node) + + # cleanup tree + for input_nodes in node_dct.values(): + for offset, input_node in enumerate(input_nodes): + input_node.location[1] += 50 * offset + hide_sockets(input_node) + +def get_node_link_value(node, param_name, org_mod): + if not org_mod: + return + # est-ce que le param est connecté a une autre node ? + if not node.inputs[param_name].links: + return + socket_id = node.inputs[param_name].links[0].from_socket.identifier + + return org_mod[socket_id] + +def get_geo_socket(node, input=True): + if node.type != "GROUP": + return('Geometry') + for param in node.node_tree.interface.items_tree: + if param.socket_type != 'NodeSocketGeometry': + continue + if input and param.in_out == 'INPUT' : return param.identifier + if not input and param.in_out == 'OUTPUT' : return param.identifier + return None +def get_input_socket_key(node, param): + if node.type == "GROUP": + if param.in_out != 'INPUT': + return False + if not param.socket_type in ['NodeSocketObject','NodeSocketCollection']: + return False + tgt = node.inputs[param.identifier].default_value + + if not tgt: + return False + return f"{param.socket_type[10:][:3]} {tgt.name}" + + if node.type == "OBJECT_INFO": + tgt = node.inputs['Object'].default_value + if not tgt: + return False + return f"Object {tgt.name}" + +def get_node_inputs(combined_nodes): + # inputs["Col COL.name"] = {name = COL.name, data = COL, socket = "COLLECTION"} + # inputs["Obj OBJ.name"] = {name = OBJ.name, data = OBJ, socket = "OBJECT"} + inputs = {} + for node in parse_nodes(combined_nodes): + for param in node.node_tree.interface.items_tree: + key = get_input_socket_key(node, param) + if not key: + continue + tgt = node.inputs[param.identifier].default_value + inputs[key] = {'name': tgt.name, 'data': tgt, 'label': param.name , 'socket': param.socket_type} + + for node in parse_nodes(combined_nodes, type = "OBJECT_INFO"): + key = get_input_socket_key(node, None) + if not key: + continue + tgt = node.inputs['Object'].default_value + inputs[key] = {'name': tgt.name, 'data': tgt, 'label': 'Source OB' , 'socket': "NodeSocketObject"} + + return inputs + +def get_node_bounds(objects, mode=0, x=0, y=0): + min_x = min_y = 10000000 + max_x = max_y = 0 + + for ob in objects: + for node in ob: + co = node.location + min_x = min(co[0],min_x) + max_x = max(co[0],max_x) + + min_y = min(co[1],min_y) + max_y = max(co[1],max_y) + if mode == 0: + return([max_x+x, (min_y+max_y)/2 ]) + +def get_collection(name): + scn = bpy.context.scene + col = None + link = False + + # look for existing + for c in bpy.data.collections: + if c.name == name: col = c + + # create if needed + if not col: col = bpy.data.collections.new(name) + + # link to scene if needed + for c in scn.collection.children_recursive: + if c.name == col.name: link = True + if not link: + scn.collection.children.link(col) + return col + +def get_mod_frames(grp): + frames = [] + for node in grp.nodes: + if node.type == "FRAME": frames.append(node) + return(frames) + +def get_frame_childrens(frame): + childrens = [] + locs = {} + + for node in frame.id_data.nodes: + if node.parent == frame: + locs[node.location[0]] = node + + # sort nodes by their x location, je sais c'est mal ecris... + entries = sorted(locs.keys()) + childrens = [locs[x] for x in entries] + return childrens + +def parse_nodes(combined_nodes, type = "GROUP"): + nodes = [] + for frame in combined_nodes: + for node in frame: + if node.type == type: nodes.append(node) + return nodes + +def copy_source_ob(ob, col): + # est-ce que l'objet a des data ? si oui on cree une copie , + # si non on renvois None + new_ob = None + if ob.type == "MESH" and len(ob.data.vertices) > 0: + new_ob = ob.copy() + new_ob.data = ob.data.copy() + + if ob.type == "CURVE" and len(ob.data.splines) > 0: + new_ob = ob.copy() + new_ob.data = ob.data.copy() + + if new_ob: + for mod in new_ob.modifiers: + new_ob.modifiers.remove(mod) + + if new_ob and col: + col.objects.link(new_ob) + return new_ob + +def hide_sockets(node,collapse = True): + for socket in node.outputs: + if not socket.links: + socket.hide = True + for socket in node.inputs: + if not socket.links: + socket.hide = True + if collapse: + node.hide = True + +def add_input_node(group, node, param_id, socket): + group_input_node = group.nodes.new('NodeGroupInput') + group_input_node.location = node.location + group_input_node.location[1] += 70 + group_input_node.label = socket.name + group.links.new(group_input_node.outputs[socket.identifier], + node.inputs[param_id]) + return(group_input_node) + +def add_material_node(ob, group, nodes): + if not ob.material_slots: + return nodes + if not ob.material_slots[0].material: + return nodes + last_node = nodes[-1:][0] + node = group.nodes.new('GeometryNodeSetMaterial') + node.inputs['Material'].default_value = ob.material_slots[0].material + node.location = last_node.location + node.location[0] += 300 + nodes.append(node) + return nodes + +def join_nodes(group, nodes): + prev = None + for i , node in enumerate(nodes): + if not prev: + prev = node + continue + geo_in = get_geo_socket(node) + geo_out = get_geo_socket(prev, input = False) + + if not geo_in or not geo_out: + continue + group.links.new(prev.outputs[geo_out], node.inputs[geo_in]) + prev = node + +def frame_nodes(group, nodes, ob): + nd = group.nodes.new('NodeFrame') + # frame = nodes.new(type='NodeFrame') + for n in nodes: + n.parent = nd + + nd.label = ob.name + +def combine_ob(ob, group, y=0, col=None): + nodes = [] + + # object info node + nd = group.nodes.new('GeometryNodeObjectInfo') + nd.location[0] -= 300 + nd.location[1] = y * 800 + nd.transform_space = "RELATIVE" + nd.inputs['Object'].default_value = copy_source_ob(ob, col) # si l'objet contient des data on crée une copie + nodes.append(nd) + + # ob modifiers + for x,md in enumerate(ob.modifiers): + if md.type != "NODES" : + print(abordage) + if md.node_group == group: + continue + + nd = group.nodes.new('GeometryNodeGroup') + nd.label = md.name + nd.width = 230 + nd.location[0] = x * 300 + nd.location[1] = y * 800 + + nd.node_tree = md.node_group + set_params(md, nd) + nodes.append(nd) + nodes = add_material_node(ob, group, nodes) + join_nodes(group, nodes) + frame_nodes(group, nodes, ob) + return nodes + +def gen_target_ob(group, col=None): + ob = gen_empty_ob(group.name, col=col) + mod = ob.modifiers.new(group.name, "NODES") + mod.node_group = group + + ob.show_name = True + bpy.context.view_layer.objects.active = ob + return(ob) + +def gen_empty_ob(name, col=None): + scn = bpy.context.scene + ob = bpy.data.objects.new(name, object_data=bpy.data.meshes.new(name)) + + ob.data.materials.append(None) + ob.material_slots[0].link = 'OBJECT' + + if not col: + scn.collection.objects.link(ob) + else: + col.objects.link(ob) + return(ob) + +def assign_modifiers(ob, frame, org_modifier): + for node in get_frame_childrens(frame): + if node.type != "GROUP": + continue + mod = ob.modifiers.new(node.label, "NODES") + mod.node_group = node.node_tree + mod.show_expanded = False + set_params(node, mod, mod_to_node=False, org_modifier=org_modifier) + mod.node_group.interface_update(bpy.context) + +def join_branches(objects, group): + # join all trees and add an output node + join = group.nodes.new('GeometryNodeJoinGeometry') + out = group.nodes.new('NodeGroupOutput') + out_sock = group.interface.new_socket("Geometry",in_out="OUTPUT",socket_type="NodeSocketGeometry") + + loc = get_node_bounds(objects, x=500) + join.location = loc + out.location = loc + out.location[0] += 700 + + for ob in objects: + node = ob[-1:][0] + group.links.new(node.outputs[get_geo_socket(node, input=False)], + join.inputs[get_geo_socket(join)]) + + group.links.new(join.outputs[get_geo_socket(join, input=False)], + out.inputs[out_sock.identifier]) + +def gen_extracted_ob(name, frame, col, mod): + ob = None + for node in get_frame_childrens(frame): + if node.type != "OBJECT_INFO": + continue + target = get_node_link_value(node, 'Object', mod) + + if target: + ob = target.copy() + ob.data = ob.data.copy() + col.objects.link(ob) + if not ob: ob = gen_empty_ob(name , col = col) + + # assign material + for node in get_frame_childrens(frame): + if node.type != "SET_MATERIAL": + continue + ob.material_slots[0].material = node.inputs['Material'].default_value + return ob + +def combine_objects(objs): + name = f"NODEGROUP_combined" + col = get_collection(name) + group = bpy.data.node_groups.new(name=name, type='GeometryNodeTree') + + objects = [] + for y , ob in enumerate(objs): + objects.append(combine_ob(ob, group, y=y, col=col)) + + target = gen_target_ob(group, col = col) + set_group_inputs(target, objects, group) + join_branches(objects, group) + +def extract_objects(object): + mod = object.modifiers[0] + grp = mod.node_group + col = get_collection(grp.name) + + for frame in get_mod_frames(grp): + name = f"{object.name} {frame.label}" + ob = gen_extracted_ob(name, frame, col, mod) + assign_modifiers(ob, frame, mod) + +#combine_objects(bpy.context.selected_objects) +#extract_objects(bpy.context.active_object) +""" +TODO: extract copier les transform de l'objet original ... +OK ! combine: si un objet a un materiel on cree un node set material en fin de liste +OK ! extract: si on trouve un noeud set material on l'assigne +OK ! extract: si un socket est connecté on recup la valeur de la connection plutot que du socket +OK ! combine: effacer tout les parametres par defaut qui sont connectes ( pour ne pas garder de trace des anciens params / collections /object) +OK ! combine: mettre tout les objets crees/copiés dans une collection +OK ! combine: si un objet source a des mesh/curve data, on en fait une copie, remove les modifiers, et assign dans le object node source +OK ! combine: si un noeud object info n'est pas vide on expose son contenu dans l'interface +OK ! extract: si un noeud object info n'est pas vide on duplique son contenu au lieu de creer un mesh vide +""" diff --git a/file_utils.py b/file_utils.py new file mode 100644 index 0000000..f2ce558 --- /dev/null +++ b/file_utils.py @@ -0,0 +1,12 @@ +import plateform +from pathlib import Path +from os.path import expandvars + + +def get_cache_dir() + if plateform.system() == 'Linux': + return Path(expandvars('$HOME/.cache/blender')) + elif plateform.system() == 'Darwin': + return Path('/Library/Caches/Blender') + elif plateform.system() == 'Windows': + return Path(expandvars('%USERPROFILE%\AppData\Local\Blender Foundation\Blender')) \ No newline at end of file diff --git a/operators.py b/operators.py index c75f72f..fbb7984 100644 --- a/operators.py +++ b/operators.py @@ -7,41 +7,224 @@ This module contains all addons operators """ import json +from pprint import pprint +from pathlib import Path + import bpy +from bpy.props import BoolProperty, EnumProperty +from bpy.types import Operator -from node_kit.core.node_tree import NodeTree +#from node_kit.core.node_tree import NodeTree +from . core.dumper import dump, load +from .core.node_utils import remap_node_group_duplicates +from .core.pack_nodes import combine_objects, extract_objects -class NODEKIT_OT_copy(bpy.types.Operator): +class NODEKIT_OT_copy(Operator): bl_idname = 'node_kit.copy_node_tree' bl_label = 'Copy nodes' bl_options = {'REGISTER', 'UNDO'} - select_only: bpy.props.BoolProperty(default=False) + select_only: BoolProperty(default=True) def execute(self, context): - tree = NodeTree(context.space_data.node_tree) - context.window_manager.clipboard = json.dumps(tree.to_dict(select_only=self.select_only)) + ntree = context.space_data.edit_tree + if self.select_only: + ntree_data = { + "nodes" : dump([n for n in ntree.nodes if n.select]) ,#[dump(n) for n in ntree.nodes if n.select], + "links" : dump([l for l in ntree.links if l.from_node.select and l.to_node.select]) + } + else: + ntree_data = dump(ntree) + + pprint(ntree_data) + + context.window_manager.clipboard = json.dumps(ntree_data) return {'FINISHED'} -class NODEKIT_OT_paste(bpy.types.Operator): +class NODEKIT_OT_paste(Operator): bl_idname = 'node_kit.paste_node_tree' bl_label = 'Paste nodes' def execute(self, context): - tree = NodeTree(context.space_data.node_tree) - tree.ingest_dict(json.loads(context.window_manager.clipboard)) + ntree_data = json.loads(context.window_manager.clipboard) + load(ntree_data, context.space_data.edit_tree) return {'FINISHED'} +class NODEKIT_OT_remap_node_group_duplicates(Operator): + bl_idname = 'node_kit.remap_node_group_duplicates' + bl_label = 'Clean nodes' + bl_options = {"REGISTER", "UNDO"} + + selection : EnumProperty(items=[(s, s.title(), '') for s in ('ALL', 'SELECTED', 'CURRENT')], default="CURRENT", name='All Nodes') + force : BoolProperty(default=False, description='Remap nodes even if there are different', name='Force') + + def invoke(self, context, event): + return context.window_manager.invoke_props_dialog(self) + + def execute(self, context): + nodes = None + if self.selection == 'SELECTED': + nodes = [ n.node_tree for n in context.space_data.edit_tree.nodes + if n.type == "GROUP" and n.select] + elif self.selection == 'ACTIVE': + active_node = context.space_data.edit_tree + nodes = [active_node] + + merged, failed = remap_node_group_duplicates(nodes=nodes, force=self.force) + + if failed and not merged: + self.report({"ERROR"}, 'No duplicates remapped, Node Group are differents') + return {"CANCELLED"} + + self.report({"INFO"}, f'{len(merged)} Node Groups Remapped, {len(failed)} Node Groups failed') + + return {'FINISHED'} + + def draw(self, context): + + + layout = self.layout + layout.prop(self, "selection", expand=True) + layout.prop(self, "force") + if self.force and self.selection == 'CURRENT': + ntree = context.space_data.edit_tree + layout.label(text=f'Remap node {ntree.name} to others') + elif self.force and self.selection == 'SELECTED': + layout.label(text='Selected nodes will override others') + elif self.selection == 'SELECTED': + layout.label(text='Remap last .*** nodes') + layout.label(text='Ex: Node.001 will override Node') + elif self.selection in ('CURRENT', 'ALL'): + layout.label(text='Remap last .*** nodes') + layout.label(text='Ex: Node.001 will override Node') + + +class NODEKIT_OT_update_nodes(Operator): + bl_idname = 'node_kit.update_nodes' + bl_label = 'Update node' + bl_options = {"REGISTER", "UNDO"} + + selection : EnumProperty(items=[(s, s.title(), '') for s in ('ALL', 'SELECTED', 'ACTIVE')], default="ACTIVE", name='All Nodes') + + def invoke(self, context, event): + return context.window_manager.invoke_props_dialog(self) + + def execute(self, context): + asset_libraries = context.preferences.filepaths.asset_libraries + + ntree = context.space_data.edit_tree + ntree_name = ntree.name + new_ntree = None + + if self.selection == 'SELECTED': + nodes = [ n.node_tree for n in context.space_data.edit_tree.nodes + if n.type == "GROUP" and n.select] + elif self.selection == 'ACTIVE': + active_node = context.space_data.edit_tree + nodes = [active_node] + else: + nodes = list(bpy.data.node_groups) + + + node_names = set(n.name for n in nodes) + #new_node_groups = [] + + #print("node_names", node_names) + + for asset_library in asset_libraries: + library_path = Path(asset_library.path) + blend_files = [fp for fp in library_path.glob("**/*.blend") if fp.is_file()] + + node_groups = list(bpy.data.node_groups)# Storing original node_geoup to compare with imported + + link = (asset_library.import_method == 'LINK') + for blend_file in blend_files: + print(blend_file) + with bpy.data.libraries.load(str(blend_file), assets_only=True, link=link) as (data_from, data_to): + + print(data_from.node_groups) + import_node_groups = [n for n in data_from.node_groups if n in node_names] + print("import_node_groups", import_node_groups) + data_to.node_groups = import_node_groups + + #print(data_from.node_groups) + #print("data_to.node_groups", data_to.node_groups) + node_names -= set(import_node_groups) # Store already updated nodes + + #new_ntree = data_to.node_groups[0] + new_node_groups = [n for n in bpy.data.node_groups if n not in node_groups] + + #break + + #if new_ntree: + # break + new_node_groups = list(set(new_node_groups)) + #print(new_node_groups) + + # if new_node_groups: + for new_node_group in new_node_groups: + new_node_group_name = new_node_group.library_weak_reference.id_name[2:] + local_node_group = next((n for n in bpy.data.node_groups if n.name == new_node_group_name and n != new_node_group), None) + + if not local_node_group: + print(f'No local node_group {new_node_group_name}') + continue + + print(f'Merge node {local_node_group.name} into {new_node_group.name}') + + local_node_group.user_remap(new_node_group) + new_node_group.interface_update(context) + bpy.data.node_groups.remove(local_node_group) + + new_node_group.name = new_node_group_name + new_node_group.asset_clear() + + #self.report({"INFO"}, f"Node updated from {blend_file}") + return {'FINISHED'} + + # else: + # self.report({"ERROR"}, f'No Node Group named "{ntree_name}" in the library') + # return {'CANCELLED'} + + def draw(self, context): + layout = self.layout + layout.prop(self, "selection", expand=True) + + +class NODEKIT_OT_pack_nodes(Operator): + bl_idname = 'node_kit.pack_nodes' + bl_label = 'Update node' + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + combine_objects(context.selected_objects) + return {'FINISHED'} + + +class NODEKIT_OT_unpack_nodes(Operator): + bl_idname = 'node_kit.unpack_nodes' + bl_label = 'Update node' + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + extract_objects(context.active_object) + return {'FINISHED'} + + classes = ( NODEKIT_OT_copy, NODEKIT_OT_paste, + NODEKIT_OT_remap_node_group_duplicates, + NODEKIT_OT_update_nodes, + NODEKIT_OT_pack_nodes, + NODEKIT_OT_unpack_nodes ) diff --git a/ui.py b/ui.py index 8f610c3..90461e2 100644 --- a/ui.py +++ b/ui.py @@ -15,10 +15,14 @@ class NODEKIT_MT_node_kit(bpy.types.Menu): def draw(self, context): layout = self.layout - layout.operator('node_kit.copy_node_tree', text='Copy node tree', icon='COPYDOWN') - layout.operator('node_kit.paste_node_tree', text='Paste node tree', icon='PASTEDOWN') + layout.operator('node_kit.copy_node_tree', text='Copy Nodes', icon='COPYDOWN') + layout.operator('node_kit.paste_node_tree', text='Paste Nodes', icon='PASTEDOWN') layout.separator() - + layout.operator('node_kit.remap_node_group_duplicates', text='Remap Node Groups Duplicates', icon='NODE_INSERT_OFF') + layout.operator('node_kit.update_nodes', text='Update Nodes', icon='IMPORT') + layout.separator() + layout.operator('node_kit.pack_nodes', text='Pack Nodes', icon='PACKAGE') + layout.operator('node_kit.unpack_nodes', text='UnPack Nodes', icon='UGLYPACKAGE') classes = ( NODEKIT_MT_node_kit,