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_rnagame re default settings. For most props in blenderobj.is_property_set("foo")returns False ifob.foois default. Brockers, be a good one to have a blend with sample material to test against. – batFINGER Mar 13 '21 at 19:51