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.
bl_rna
game re default settings. For most props in blenderobj.is_property_set("foo")
returns False ifob.foo
is default. Brockers, be a good one to have a blend with sample material to test against. $\endgroup$