16
$\begingroup$

I'd like to convert certain materials into executable python code without having to re-code them from scratch each time.

While doing some initial experimentation on the principled shader, I realized that there are a lot of different attribute types and sub-attributes to handle eg. ShaderNodeBsdfPrincipled.input collection is holding 23 different structs:

<bpy_struct, NodeSocketColor("Base Color") at 0x7fe51de9b0d8>
<bpy_struct, NodeSocketFloatFactor("Subsurface") at 0x7fe51de9b2a8>
<bpy_struct, NodeSocketVector("Subsurface Radius") at 0x7fe51de9b478>
...

and calling dir(ShaderNodeBsdfPrincipled) yields:

['doc', 'module', 'slots', 'bl_description', 'bl_height_default', 'bl_height_max', 'bl_height_min', 'bl_icon', 'bl_idname', 'bl_label', 'bl_rna', 'bl_static_type', 'bl_width_default', 'bl_width_max', 'bl_width_min', 'color', 'dimensions', 'distribution', 'draw_buttons', 'draw_buttons_ext', 'height', 'hide', 'input_template', 'inputs', 'internal_links', 'is_registered_node_type', 'label', 'location', 'mute', 'name', 'output_template', 'outputs', 'parent', 'poll', 'poll_instance', 'rna_type', 'select', 'show_options', 'show_preview', 'show_texture', 'socket_value_update', 'subsurface_method', 'type', 'update', 'use_custom_color', 'width', 'width_hidden']`

Q: Is there any clever python trickery using "magic" object attributes or anything in order to store and restore the nodes, their attribute values and ideally all node connections as well?


If anyone would like to have a material for testing purposes, the following blend contains some kind of production setup (from Camera projection without generating UV's?).

$\endgroup$
9
  • 3
    $\begingroup$ Very nice...... $\endgroup$
    – batFINGER
    Commented Mar 13, 2021 at 15:28
  • 2
    $\begingroup$ Perhaps using something like DeepDiff pypi.org/project/deepdiff on each node versus a new node to see non-default values? $\endgroup$ Commented Mar 13, 2021 at 15:33
  • $\begingroup$ All approaches are welcome @MarkusvonBroady $\endgroup$
    – brockmann
    Commented Mar 13, 2021 at 15:57
  • $\begingroup$ If a miracle happens and batFINGER's solution isn't amazing, I'll see what I can come up with. $\endgroup$ Commented Mar 13, 2021 at 16:01
  • $\begingroup$ @MarkusvonBroady great idea re comparing against new node. Looked at this a while back, there is a q about it too, re nodes not playing the typical blender bl_rna game re default settings. For most props in blender obj.is_property_set("foo") returns False if ob.foo is default. Brockers, be a good one to have a blend with sample material to test against. $\endgroup$
    – batFINGER
    Commented Mar 13, 2021 at 19:51

2 Answers 2

12
+500
$\begingroup$

Make a dummy material with each node type of material.

Following on from the suggestion in comments by @MarkusvonBroady couldn't resist.... Am waiting to see the excellent material serialization answer from you know who,

Pretty much zip the material node / inputs / outputs etc against a newly added default of same type, and add to string buffer when not same. Have ignored any read only property and select.

Code and test Run.

import bpy
from math import isclose

LUT = dict()
BLACK = [0.0, 0.0, 0.0, 1.0]
WHITE = [1.0, 1.0, 1.0, 1.0]
ul = f'#{"-" * 44}\n'
buffer = []


def cmp(v1, v2):
    return (
        (len(v1) == len(v2))
        and all(isclose(*v) for v in zip(v1, v2))
    )


def vformat(nums, n, indent=8):
    nums = [f"{d}" for d in nums]
    return f",\n{' ' * indent}".join([", ".join(nums[i: i + n]) for i in range(0, len(nums), n)])


def fes(collection, prop, data, size, indent):
    output(
        f'{" " * indent}{collection}.foreach_set(\n'
        f'       "{prop}", [\n'
        f'        {vformat(data, size, 8 + indent)}\n'
        f'        ])'
    )


def groups_in_tree(node_tree):
    for node in node_tree.nodes:
        if hasattr(node, "node_tree"):
            yield node.node_tree
            yield from groups_in_tree(node.node_tree)


def group_io(n):
    output(f'node = nodes.new("{n.bl_rna.identifier}")')
    output(f'node.name = "{n.name}"')
    sockets = ("inputs", "outputs") if n.type == 'GROUP_INPUT' else ("outputs", "inputs")
    for skt in getattr(n, sockets[1]):
        if skt.type != 'CUSTOM' and skt.name:
            output(
                f"skt = group.{sockets[0]}.new('{skt.__class__.__name__}', "
                f'"{skt.name}", '
                f')'
            )
            output(f'skt.name = "{skt.name}"')
            dv = skt.default_value
            val = dv[:] if hasattr(dv, "foreach_get") else dv
            output(f"skt.default_value = {val}")
    output()


def colorramp(a, b):
    n = len(a.elements)
    output(f'ramp = node.color_ramp')
    compare(a, b, fstring="ramp.{k} = {va}")
    locs, deflocs = [0.0] * n, [0.0, 1.0]
    cols, defcols = [0.0] * (n << 2), BLACK + WHITE
    a.elements.foreach_get("position", locs), a.elements.foreach_get("color", cols)
    n = n - 2
    if n:
        output(
            f"for i in range({n}):\n"
            f"    ramp.elements.new(0.0)"
        )

    if not cmp(locs, deflocs):
        fes("ramp.elements", "position", locs, 1, 0)

    if not cmp(cols, defcols):
        fes(f'ramp.elements', "color", cols, 4, 0)


def mapping(a, b):
    output(f'map = node.mapping')
    compare(a, b, fstring="map.{k} = {va}")

    for i, c in enumerate(a.curves):
        n = len(c.points)
        pts, default = [0, 0] * n, [-1.0, -1.0, 1.0, 1.0]
        n -= 2
        if n:
            output(
                f'for i in range({n}):\n'
                f'    map.curves[{i}].points.new(0.0, 1.0)\n'
            )

        c.points.foreach_get("location", pts)
        if not cmp(pts, default):
            fes(f"map.curves[{i}].points", "location", pts, 2, 0)


def output(*args):
    s = " ".join(args) if args else ""
    buffer.append(s)


def compare(a, b, fstring="{k} = {va}", sockets="", i=0, ignore={'select'}):

    props = (
            (k, v)
        for k, v in a.bl_rna.properties.items()
        if (not v.is_readonly or k in ("mapping", "color_ramp"))
        and k not in ignore
    )

    for k, v in props:
        va = getattr(a, k)
        vb = getattr(b, k, None)

        if v.type in ('FLOAT', 'INT'):
            if v.is_array:
                if not isinstance(va, float):
                    va = va[:]
                if vb and not isinstance(vb, float):
                    vb = vb[:]

        if va != vb:
            if v.type == 'ENUM':
                va = f"'{va}'"
            elif v.type == 'STRING':
                va = f'"{va}"'
            elif v.type == 'POINTER':
                if k == "parent":
                    va = f'nodes.get("{va.name}")'
                elif a.type == 'GROUP':
                    return output(f'node.node_tree = groups.get("{a.node_tree.name}")')
                elif issubclass(v.fixed_type.__class__, bpy.types.ID):
                    va = repr(va).replace(f"['{va.name}']", f'.get("{va.name}")')
                elif k == "mapping":
                    return mapping(va, vb)
                elif k.startswith("color_ramp"):
                    return colorramp(va, vb)
            name = f'"{a.name}"' if hasattr(a, "name") else i
            output(fstring.format(**locals()))


def pnode(n, dummy):
    if n.type in ('GROUP_INPUT', 'GROUP_OUTPUT'):
        return group_io(n)
    nodetype = n.bl_rna.identifier
    default = LUT.setdefault(
        nodetype, dummy.nodes.new(nodetype)
    )

    output(f'node = nodes.new("{nodetype}")')
    compare(n, default, fstring="node.{k} = {va}")
    for sockets in ("inputs", "outputs"):
        for i, (a, b) in enumerate(
                zip(
                    getattr(n, sockets),
                    getattr(default, sockets),
                )
        ):

            compare(a, b, fstring='node.{sockets}[{name}].{k} = {va}', i=i, sockets=sockets)
    output()


def material_to_text(m):
    try:
        dummy = bpy.data.node_groups.get("DUMMY")
        if not dummy:
            output("import bpy")
            dummy = bpy.data.node_groups.new("DUMMY", "ShaderNodeTree")
            output("groups = {}  # for node groups")

        if hasattr(m, "use_nodes"):
            # material
            for gn in set(groups_in_tree(m.node_tree)):
                material_to_text(gn)
            nt = m.node_tree
            output(
                f"\n"
                f"{ul}#  Material: {m.name} \n{ul}\n"
                f'mat = bpy.data.materials.new("{m.name}")\n'
                f"mat.use_nodes = True\n"
                f"node_tree = mat.node_tree\n"
                f"nodes = node_tree.nodes\n"
                f"nodes.clear()\n"
                f"links = node_tree.links\n"
            )

        else:
            # group
            nt = m
            output(
                f"\n"
                f"{ul}#  NodeGroup: {m.name} \n{ul}\n"
                f'group = bpy.data.node_groups.new("{m.name}", "{m.bl_rna.identifier}")\n'
                f'groups["{m.name}"] = group\n'
                f"nodes = group.nodes\n"
                f"links = group.links\n"
            )

        for n in sorted(
            nt.nodes,
            key=lambda n: [n.location.x, n.location.y]
        ):
            pnode(n, dummy)
        if nt.links:
            output("#Links\n")
        for l in nt.links:
            output(
                f"links.new(\n"
                f'    nodes["{l.from_node.name}"].outputs["{l.from_socket.name}"],\n'
                f'    nodes["{l.to_node.name}"].inputs["{l.to_socket.name}"]\n    )\n'
            )

    except Exception as e:
        print("There has been an ERROR")
        print(e, e.__traceback__.tb_lineno)
        return False  # failure

    if hasattr(m, "use_nodes"):
        bpy.data.node_groups.remove(dummy)
    return True  # success


if __name__ == "__main__":
    m = bpy.context.object.active_material
    material_to_text(m)

    text = bpy.data.texts.new(m.name)
    text.write("\n".join(buffer))

Test Run on default "Material" with base color set to Red. AFAICT Generates the material in test file linked Ok.

import bpy
groups = {}  # for node groups

#--------------------------------------------
#  Material: Material 
#--------------------------------------------

mat = bpy.data.materials.new("Material")
mat.use_nodes = True
node_tree = mat.node_tree
nodes = node_tree.nodes
nodes.clear()
links = node_tree.links

node = nodes.new("ShaderNodeBsdfPrincipled")
node.location = (10.0, 300.0)
node.inputs["Base Color"].default_value = (1.0, 0.0, 0.0, 1.0)

node = nodes.new("ShaderNodeOutputMaterial")
node.location = (300.0, 300.0)

#Links

links.new(
    nodes["Principled BSDF"].outputs["BSDF"],
    nodes["Material Output"].inputs["Surface"]
    )

Revision

Unlike Madonna and @Gorgeous not so much of a "Material Guy". TBH I'm a sub-feather-weight when it comes to blenders materials and nodes, so this was a nice little excersize for me.

Ultimately the idea, as I see it, is to be able to copy a material via a script in one blend, and re-create it in another.

Default Values.

Have kept the verbosity down a bit by not outputting default values. Could turn this off with a flag. Since non-default values are determined from a newly instanced copy they will be non-default at time of creation. As noted if the defaults change, will need to run script again.

Node Groups

Prior handled a group node by simply pointing the node tree to its bpy.data.node_groups item. Instead this version makes a copy of each node group used in the material. Was a very easy step, since nodes of both is a collection of nodes. To make sure the new group is used in the generated material by means of a dictionary groups to associate new with old name

groups = {}  # for node groups

#--------------------------------------------
#  NodeGroup: NodeGroup.001 
#--------------------------------------------

group = bpy.data.node_groups.new("NodeGroup.001", "ShaderNodeTree")
groups["NodeGroup.001"] = group

could turn this on or off to use existing node groups.

Curves and Ramps.

Wired it up to generate mapping and colorramp nodes. Used foreach_set which enables to add an arbitrary point for each extra (over default) and set from a list.

Color Ramp

node = nodes.new("ShaderNodeValToRGB")
node.location = (-345.2741394042969, 142.6455841064453)
node.parent = nodes.get("Frame")
ramp = node.color_ramp
for i in range(4):
    ramp.elements.new(0.0)
ramp.elements.foreach_set(
       "position", [
        0.0,
        0.25,
        0.45396339893341064,
        0.6530487537384033,
        0.7999999523162842,
        1.0
        ])
ramp.elements.foreach_set(
       "color", [
        0.0, 0.0, 0.0, 1.0,
        0.41859403252601624, 0.000635193195194006, 0.0, 1.0,
        0.0, 0.006493096239864826, 0.21146051585674286, 1.0,
        0.14895662665367126, 0.17292265594005585, 0.2819954454898834, 1.0,
        0.5389295816421509, 0.18324723839759827, 1.0, 1.0,
        1.0, 1.0, 1.0, 1.0
        ])

RGB Curve

node = nodes.new("ShaderNodeRGBCurve")
node.location = (211.58743286132812, 275.4912414550781)
node.parent = nodes.get("Frame")
map = node.mapping
map.tone = 'FILMLIKE'
map.clip_max_x = 0.8999999761581421
for i in range(5):
    map.curves[0].points.new(0.0, 1.0)

map.curves[0].points.foreach_set(
       "location", [
        0.0, 0.0,
        0.15146341919898987, 0.4256756901741028,
        0.24731720983982086, 0.7837838530540466,
        0.4675609767436981, 0.5506754517555237,
        0.5421952605247498, 0.8445945978164673,
        0.6585365533828735, 0.5608108639717102,
        1.0, 1.0
        ])
for i in range(2):
    map.curves[1].points.new(0.0, 1.0)

map.curves[1].points.foreach_set(
       "location", [
        0.0, 0.0,
        0.3512195348739624, 0.6621621251106262,
        0.5926830172538757, 0.3581080138683319,
        1.0, 1.0
        ])
for i in range(3):
    map.curves[2].points.new(0.0, 1.0)

map.curves[2].points.foreach_set(
       "location", [
        0.0, 0.0,
        0.16463413834571838, 0.581081211566925,
        0.5004880428314209, 0.5777024626731873,
        0.7858536243438721, 0.28378361463546753,
        1.0, 1.0
        ])
for i in range(1):
    map.curves[3].points.new(0.0, 1.0)

map.curves[3].points.foreach_set(
       "location", [
        0.0, 0.0,
        0.6782926321029663, 0.4425675868988037,
        1.0, 1.0
        ])

Frames

Added the frames and set as parents to respective nodes, haven't wired up re the location changing, as demonstrated by @Gorgeous.

$\endgroup$
8
  • 4
    $\begingroup$ Very nice ..... $\endgroup$
    – brockmann
    Commented Mar 14, 2021 at 14:44
  • $\begingroup$ Nice !! I was scratching my head and found only hacky workarounds, I knew there was a pythonic way to handle it :) Definitely stealing that for the future ! $\endgroup$
    – Gorgious
    Commented Mar 18, 2021 at 20:41
  • $\begingroup$ What happened to rna_xml that you you referred to in ancient history? Or would that and/or other lower-level ways of stashing data-blocks. not count for this question? $\endgroup$
    – Robin Betts
    Commented Mar 19, 2021 at 19:51
  • 1
    $\begingroup$ @RobinBetts had a better check that moment on re-visiting that 6yo answer (be a lot less ancient myself if i was 6yo at the time...) Fortunately OP has phrased the question well --> to create something akin to blender.stackexchange.com/questions/65129/… whereas the rna_xml , to me, feels more similar to importing / exporting. (subtle, but different enough?, thoughts) $\endgroup$
    – batFINGER
    Commented Mar 20, 2021 at 9:40
  • $\begingroup$ I'd rather shovel hunks of bits around, avoiding even object-description formats, if I knew how.. but I think you're right. @brockmann must have his reasons. $\endgroup$
    – Robin Betts
    Commented Mar 20, 2021 at 11:10
12
+150
$\begingroup$

I wrote this script a few months back for a personal project. You can see that it's a lot longer than the other answer :).

It creates statements to set the value of a node input or output, even if it is the same as the default value. I debated it, and despite leading to text files with a very high number of lines in big node trees, I prefer this option. Since the goal of this script is to create a snapshot of the material, and the defaults of today might not be the defaults of tomorrow, or of someone else's custom build, I prefer still writing explicitly all properties.

It could still be changed by the user if they want to, by implementing the proposed solution of python magician @batFINGER for not overwriting default values.

You'll need to learn how to use a script.

How to use :

Select your object, select the material you want to copy, run the script. The material code will be added in a new text block.

enter image description here

"""
This scripts "serializes" the active material of the currently selected object
And creates a script readable by the Blender API to recreate said Material.
As any Blender script, it is free to use in any way shape or form.
V 1.1 - 20.10.23
Fixed NodeSocketVirtual error
"""

import bpy

from bpy.types import (
    NodeSocketShader,
    NodeSocketVirtual,
    NodeSocketVector,
    NodeSocketVectorDirection,
    NodeSocketVectorXYZ,
    NodeSocketVectorTranslation,
    NodeSocketVectorEuler,
    NodeSocketColor,

    NodeReroute,

    Object,
    Image,
    ImageUser,
    Text,
    ParticleSystem,
    CurveMapping,
    ColorRamp,

    ShaderNodeTree,
)

from mathutils import Vector, Color


ERROR = "~ ERROR ~"

def get_link_statement(link):
    """
    Build the statement to re-create given link
    """
    return f"""\
links.new({link.from_node.path_from_id()}.outputs[{get_socket_index(link.from_socket)}]\
, {link.to_node.path_from_id()}.inputs[{get_socket_index(link.to_socket)}])\
    """


def value_from_socket(socket):
    """
    Returns the evaluated value of a node socket's default value
    """
    # A Shader socket (green dot) doesn't have a default value :
    if isinstance(socket, (NodeSocketShader, NodeSocketVirtual)):
        return ERROR
    elif isinstance(socket, (
            NodeSocketVector,
            NodeSocketVectorXYZ,
            NodeSocketVectorTranslation,
            NodeSocketVectorEuler,
            NodeSocketVectorDirection)):
        return f"{[socket.default_value[i] for i in range(3)]}"
    elif isinstance(socket, NodeSocketColor):
        return f"{[socket.default_value[i] for i in range(4)]}"
    else:
        return socket.default_value.__str__()


class NodeCreator:
    """
    Helper class to programmatically recreate the passed node
    """
    # These props are internal or read-only
    # and aren't useful in the serialization.
    default_props = (
        "dimensions",
        "draw_buttons",
        "draw_buttons_ext",
        "input_template",
        "inputs",
        "internal_links",
        "isAnimationNode",
        "is_registered_node_type",
        "output_template",
        "outputs",
        "poll",
        "poll_instance",
        "rna_type",
        "socket_value_update",
        "type",
        "update",
        "viewLocation",

        "texture_mapping",
        "color_mapping",

        "filepath",

        "cache_point_density",
        "calc_point_density",
        "calc_point_density_minmax",

        "interface",

        "height",
        "show_options",
        "show_preview",
        "show_texture",
        "width_hidden",
    )

    def __init__(self, node):
        """
        Initialize the node inputs and outputs,
        and the different fields' default values
        """
        self.node = node
        self.input_default_values = []
        self.output_default_values = []
        if not isinstance(node, NodeReroute):
            for _input in node.inputs:
                self.input_default_values.append(value_from_socket(_input))
            for output in node.outputs:
                self.output_default_values.append(value_from_socket(output))

        self.type = type(node).__name__
        self.properties = []  # Could use an ordered dict instead.
        for prop_name in dir(node):
            if prop_name.startswith("_") or prop_name.startswith("bl_"):
                continue
            if prop_name in NodeCreator.default_props:
                continue
            self.properties.append((prop_name, getattr(node, prop_name)))

    def statements(self):
        """
        Build the chain of statements to programmatically recreate the node
        """
        statements = []
        statements.append(f"new_node = nodes.new(type='{self.type}')")
        self.properties = sorted(self.properties, key=lambda p: p[0])
        for prop, value in self.properties:
            if isinstance(value, ImageUser):
                statements.append(f"""\
img_text = new_node.{prop}
img_text.frame_current = {value.frame_current}
img_text.frame_duration = {value.frame_duration}
img_text.frame_offset = {value.frame_offset}
img_text.frame_start = {value.frame_start}
img_text.use_auto_refresh = {value.use_auto_refresh}
img_text.use_cyclic = {value.use_cyclic}
img_text.tile = {value.tile}\
                """)
                continue
            if isinstance(value, ParticleSystem):
                # /!\ Make sure this is executed after node.object statement
                statements.append(f"""\
if new_node.object:
    new_node.{prop} = new_node.object.particle_systems.get('{value.name}')
                """)
                continue
            if isinstance(value, CurveMapping):
                statements.append(f"""\
map = new_node.{prop}
map.clip_max_x = {value.clip_max_x}
map.clip_max_y = {value.clip_max_y}
map.clip_min_x = {value.clip_min_x}
map.clip_min_y = {value.clip_min_y}
map.tone = '{value.tone}'
map.use_clip = {value.use_clip}\
                """)
                # Remove the 2 starting default points and only these :
                for i, curve in enumerate(value.curves):
                    statements.append(f"map_c = map.curves[{i}]")
                    for point in curve.points:
                        statements.append(f"""\
map_c.points.new({point.location[0]}, {point.location[1]})""")
                    statements.append("""\
removed_start = removed_end = False
for i in range(len(map_c.points) - 1, -1, -1):
    p = map_c.points[i]
    if not removed_start and p.location[0] == map.clip_min_x and p.location[1] == map.clip_min_y:
        map_c.points.remove(p)
        removed_start = True
    if not removed_end and p.location[0] == 1 and p.location[1] == 1:
        map_c.points.remove(p)
        removed_end = True\
                    """)
                statements.append(f"map.update()")
                continue
            if isinstance(value, ColorRamp):
                statements.append(f"""\
cr = new_node.{prop}
cr.color_mode = '{value.color_mode}'
cr.hue_interpolation = '{value.hue_interpolation}'
cr.interpolation = '{value.interpolation}'\
                """)
                for stop in value.elements:
                    statements.append(f"""new_stop = cr.elements.new({stop.position})
new_stop.color = {[ch for ch in stop.color]}""")
                # Remove the 2 starting default stops and only these :
                statements.append("""\
removed_black = removed_white = False
for i in range(len(cr.elements) - 1, -1, -1):
    stop = cr.elements[i]
    if not removed_black and stop.position == 0 and all([stop.color[i] == (0, 0, 0, 1)[i] for i in range(4)]):
        cr.elements.remove(stop)
        removed_black = True
    if not removed_white and stop.position == 1 and all([stop.color[i] == (1, 1, 1, 1)[i] for i in range(4)]):
        cr.elements.remove(stop)
        removed_white = True\
                """)
                continue
            if isinstance(value, ShaderNodeTree):
                statements.append(f"""\
ng = bpy.data.node_groups.get('{value.name}')
if not ng:
    new_node.label = \"Missing Node Group : '{value.name}'\"
else:
    new_node.{prop} = ng\
                """)
                continue

            if prop in ("hide", "mute", "use_custom_color"):
                if value:
                    statements.append(f"new_node.{prop} = {value}")
            elif prop == "text" and not value:
                continue
            elif prop in ("select", "shrink"):
                if not value:
                    statements.append(f"new_node.{prop} = {value}")
            elif isinstance(value, str):
                if value:
                    statements.append(f"new_node.{prop} = '{value}'")
            elif isinstance(value, Vector):
                if len(value) == 2:
                    statements.append(
                        f"new_node.{prop} = ({value[0]}, {value[1]})")
                else:
                    statements.append(
                        f"new_node.{prop} = ({value[0]}, {value[1]}, {value[2]})")
            elif isinstance(value, Object):
                statements.append(
                    f"new_node.{prop} = bpy.data.objects.get('{value.name}')")
            elif isinstance(value, Image):
                statements.append(
                    f"new_node.{prop} = bpy.data.images.get('{value.name}')")
            elif isinstance(value, Text):
                if value:
                    statements.append(
                        f"new_node.{prop} = bpy.data.texts.get('{value.name}')")
            elif prop == "parent":
                if value:
                    statements.append(f"""\
parent = nodes.get('{value.name}')
if parent:
    new_node.parent = parent
    while True:
        new_node.location += parent.location
        if parent.parent:
            parent = parent.parent
        else:
            break\
                    """)
            elif isinstance(value, Color):
                statements.append(
                    f"new_node.{prop} = ({value[0]}, {value[1]}, {value[2]})")
            else:
                statements.append(f"new_node.{prop} = {value}")
        for i, dv in enumerate(self.input_default_values):
            if dv == ERROR:
                continue
            statements.append(f"new_node.inputs[{i}].default_value = {dv}")

        for i, dv in enumerate(self.output_default_values):
            if dv == ERROR:
                continue
            statements.append(f"new_node.outputs[{i}].default_value = {dv}")

        if not isinstance(self.node, NodeReroute):
            for _input in self.node.inputs:
                if _input.hide:
                    statements.append(
                        f"new_node.inputs[{get_socket_index(_input)}].hide = True")
            for output in self.node.outputs:
                if output.hide:
                    statements.append(
                        f"new_node.outputs[{get_socket_index(output)}].hide = True")
#        DEBUG Print node location as a label :
#        statements.append("new_node.label = str(new_node.location[0]).split('.')[0] + ', ' + str(new_node.location[1]).split('.')[0]")

        return statements


def serialize_material(mat):
    """
    Returns the ordered statements necessary to build the 
    Mateiral generation script
    """
    node_tree = mat.node_tree
    statements = [f"""\
import bpy
new_mat = bpy.data.materials.get('{mat.name}')
if not new_mat:
    new_mat = bpy.data.materials.new('{mat.name}')
    
new_mat.use_nodes = True
node_tree = new_mat.node_tree
nodes = node_tree.nodes
nodes.clear()
    
links = node_tree.links
links.clear()
    """]

    statements.append("# Nodes :\n")
    for node in node_tree.nodes:
        for st in NodeCreator(node).statements():
            statements.append(st)
        statements.append("")

    if node_tree.links:
        statements.append("# Links :\n")
        for link in node_tree.links:
            statements.append(get_link_statement(link))

    return statements


def write_material_to_text_block(obj):
    """
    Create or overwrite a text block with the same name as the material
    Which contains all the necessary statements to duplicate the material
    """
    if not obj or obj.type not in ('MESH', 'CURVE', 'VOLUME', 'SURFACE', 'FONT', 'META', 'GPENCIL'):
        return
    am = obj.active_material
    if not am or not am.use_nodes:
        return
    statements = serialize_material(am)

    text_block = bpy.data.texts.get(am.name)
    if text_block:
        text_block.clear()
    else:
        text_block = bpy.data.texts.new(am.name)

    for st in statements:
        text_block.write(st)
        text_block.write("\n")

    return text_block


def get_socket_index(socket):
    return socket.path_from_id().split(".")[-1].split("[")[-1][:-1]


if __name__ == "__main__":
    text_block = write_material_to_text_block(bpy.context.active_object)

The code is available there for grabbing https://github.com/Gorgious56/Material2Script/blob/main/material_to_script.py

$\endgroup$
4
  • 3
    $\begingroup$ Very nice ..... $\endgroup$
    – brockmann
    Commented Mar 16, 2021 at 16:45
  • $\begingroup$ Oops, the system has "auto-selected" the answer for the bounty and only assigned half of the rep. I'm sorry, nothing intended, hope you don't mind... sh*t. I'm going to start another one next week. $\endgroup$
    – brockmann
    Commented Mar 24, 2021 at 10:27
  • $\begingroup$ @brockmann Haha no problem :) Anyways I think the other answer deserves it more than mine, comparing the two now and my scripts feels really clunky :p $\endgroup$
    – Gorgious
    Commented Mar 24, 2021 at 11:17
  • $\begingroup$ @Exporer Hello, while I understand your proposed edit of my answer, I think it would have made more sense in a comment. As you noticed my answer is limited in regards to the node group creation. I suggest you look into the other answer of this question, which tackles this gracefully. I created this script for my own use back then, and I didn't need to re-create node group so I never implemented it. Feel free to edit it if you find a better solution. Cheers :) $\endgroup$
    – Gorgious
    Commented Aug 23, 2021 at 13:00

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .