Files
Pr3tz/pr3tz/materials.py
T
2026-06-04 06:04:45 -04:00

711 lines
32 KiB
Python

"""
materials.py
------------
Shader catalogue and material management for knot_animation.
Shader builders
---------------
Each `_inner_<SHADER_ID>` function receives an already-cleared node tree
(nodes, links, y_offset) and returns the final output shader socket.
They do NOT add an Output Material node — that is the caller's responsibility.
Adding a new shader
-------------------
1. Define `_inner_MYSHADER(nodes, links, y=0, color=..., roughness=...,
metallic=..., emission_strength=..., **kwargs)`.
2. Add its name to `_TRANSPARENT_SHADERS` below ONLY if it needs alpha blending.
No other changes are required — INNER_FACTORIES and SHADER_IDS are built
automatically from the naming convention.
"""
from __future__ import annotations
import bpy
from .constants import KNOT_OBJ_NAME, KNOT_MAT_NAME, SHADER_BLEND_MAT_NAME
# ---------------------------------------------------------------------------
# Inner shader builders
# Convention: _inner_<SHADER_ID>(nodes, links, y, color, roughness, metallic,
# emission_strength, **kwargs) -> output_socket
# ---------------------------------------------------------------------------
def _inner_GLOSS_BLUE(nodes, links, y=0, color=(0.2, 0.6, 1.0, 1.0),
roughness=0.1, metallic=0.0, emission_strength=1.0, **kwargs):
mix = nodes.new("ShaderNodeMixShader"); mix.location = (300, y)
glos = nodes.new("ShaderNodeBsdfGlossy"); glos.location = (0, y + 80)
emit = nodes.new("ShaderNodeEmission"); emit.location = (0, y - 80)
glos.inputs["Color"].default_value = color
glos.inputs["Roughness"].default_value = roughness
emit.inputs["Color"].default_value = (0.4, 0.8, 1.0, 1.0)
emit.inputs["Strength"].default_value = emission_strength * 0.5
mix.inputs["Fac"].default_value = 0.3
links.new(glos.outputs["BSDF"], mix.inputs[1])
links.new(emit.outputs["Emission"], mix.inputs[2])
return mix.outputs["Shader"]
def _inner_NEON_GLOW(nodes, links, y=0, color=(0.0, 1.0, 0.9, 1.0),
roughness=0.1, metallic=0.0, emission_strength=1.0, **kwargs):
mix = nodes.new("ShaderNodeMixShader"); mix.location = (300, y)
em1 = nodes.new("ShaderNodeEmission"); em1.location = (0, y + 80)
em2 = nodes.new("ShaderNodeEmission"); em2.location = (0, y - 80)
em1.inputs["Color"].default_value = color
em1.inputs["Strength"].default_value = emission_strength * 3.0
em2.inputs["Color"].default_value = (1.0, 0.05, 0.8, 1.0)
em2.inputs["Strength"].default_value = emission_strength * 3.0
mix.inputs["Fac"].default_value = 0.5
links.new(em1.outputs["Emission"], mix.inputs[1])
links.new(em2.outputs["Emission"], mix.inputs[2])
return mix.outputs["Shader"]
def _inner_METALLIC(nodes, links, y=0, color=(1.0, 0.78, 0.28, 1.0),
roughness=0.05, metallic=1.0, emission_strength=1.0, **kwargs):
prin = nodes.new("ShaderNodeBsdfPrincipled"); prin.location = (0, y)
prin.inputs["Base Color"].default_value = color
prin.inputs["Metallic"].default_value = metallic
prin.inputs["Roughness"].default_value = roughness
prin.inputs["Specular IOR Level"].default_value = 1.0
return prin.outputs["BSDF"]
def _inner_GLASS(nodes, links, y=0, color=(0.85, 0.95, 1.0, 1.0),
roughness=0.0, metallic=0.0, emission_strength=1.0, **kwargs):
prin = nodes.new("ShaderNodeBsdfPrincipled"); prin.location = (0, y)
prin.inputs["Base Color"].default_value = color
prin.inputs["Metallic"].default_value = metallic
prin.inputs["Roughness"].default_value = roughness
prin.inputs["IOR"].default_value = 1.45
prin.inputs["Transmission Weight"].default_value = 1.0
return prin.outputs["BSDF"]
def _inner_HOLOGRAM(nodes, links, y=0, color=(0.2, 1.0, 0.5, 1.0),
roughness=0.1, metallic=0.0, emission_strength=1.0, **kwargs):
mix = nodes.new("ShaderNodeMixShader"); mix.location = (400, y)
emit = nodes.new("ShaderNodeEmission"); emit.location = (0, y + 100)
trans = nodes.new("ShaderNodeBsdfTransparent"); trans.location = (0, y - 100)
fres = nodes.new("ShaderNodeFresnel"); fres.location = (0, y + 220)
emit.inputs["Color"].default_value = color
emit.inputs["Strength"].default_value = emission_strength * 2.0
fres.inputs["IOR"].default_value = 1.3
links.new(fres.outputs["Fac"], mix.inputs["Fac"])
links.new(emit.outputs["Emission"], mix.inputs[1])
links.new(trans.outputs["BSDF"], mix.inputs[2])
return mix.outputs["Shader"]
def _inner_LAVA(nodes, links, y=0, color=(1.0, 0.1, 0.0, 1.0),
roughness=1.0, metallic=0.0, emission_strength=1.0, **kwargs):
add = nodes.new("ShaderNodeAddShader"); add.location = (500, y)
diff = nodes.new("ShaderNodeBsdfDiffuse"); diff.location = (0, y + 100)
emit = nodes.new("ShaderNodeEmission"); emit.location = (0, y - 100)
ramp = nodes.new("ShaderNodeValToRGB"); ramp.location = (-250, y - 100)
noise = nodes.new("ShaderNodeTexNoise"); noise.location = (-500, y - 100)
coord = nodes.new("ShaderNodeTexCoord"); coord.location = (-700, y - 100)
diff.inputs["Color"].default_value = (color[0] * 0.08, color[1] * 0.08, color[2] * 0.08, 1.0)
diff.inputs["Roughness"].default_value = roughness
emit.inputs["Strength"].default_value = emission_strength * 4.0
noise.inputs["Scale"].default_value = 4.0
noise.inputs["Detail"].default_value = 8.0
cr = ramp.color_ramp
cr.elements[0].position = 0.0; cr.elements[0].color = (0.0, 0.0, 0.0, 1.0)
cr.elements[1].position = 1.0; cr.elements[1].color = color
cr.elements.new(0.55).color = (color[0], color[1] * 0.1, color[2] * 0.1, 1.0)
cr.elements.new(0.85).color = (color[0], color[1] * 0.9, color[2] * 0.9, 1.0)
links.new(coord.outputs["Generated"], noise.inputs["Vector"])
links.new(noise.outputs["Fac"], ramp.inputs["Fac"])
links.new(ramp.outputs["Color"], emit.inputs["Color"])
links.new(diff.outputs["BSDF"], add.inputs[0])
links.new(emit.outputs["Emission"], add.inputs[1])
return add.outputs["Shader"]
def _inner_IRIDESCENT(nodes, links, y=0, color=(1.0, 1.0, 1.0, 1.0),
roughness=0.05, metallic=0.0, emission_strength=1.0, **kwargs):
add = nodes.new("ShaderNodeAddShader"); add.location = (500, y)
glos = nodes.new("ShaderNodeBsdfGlossy"); glos.location = (0, y + 100)
emit = nodes.new("ShaderNodeEmission"); emit.location = (0, y - 100)
ramp = nodes.new("ShaderNodeValToRGB"); ramp.location = (-250, y - 100)
lw = nodes.new("ShaderNodeLayerWeight"); lw.location = (-450, y - 100)
glos.inputs["Roughness"].default_value = roughness
emit.inputs["Strength"].default_value = emission_strength * 1.2
lw.inputs["Blend"].default_value = 0.5
cr = ramp.color_ramp
cr.interpolation = 'LINEAR'
rainbow = [
(0.0, (1.0, 0.0, 0.0, 1.0)),
(0.17, (1.0, 0.5, 0.0, 1.0)),
(0.33, (1.0, 1.0, 0.0, 1.0)),
(0.5, (0.0, 1.0, 0.0, 1.0)),
(0.67, (0.0, 0.5, 1.0, 1.0)),
(0.83, (0.5, 0.0, 1.0, 1.0)),
(1.0, (1.0, 0.0, 0.5, 1.0)),
]
for i, (pos, col) in enumerate(rainbow):
if i < 2:
cr.elements[i].position = pos
cr.elements[i].color = col
else:
cr.elements.new(pos).color = col
links.new(lw.outputs["Facing"], ramp.inputs["Fac"])
links.new(ramp.outputs["Color"], emit.inputs["Color"])
links.new(ramp.outputs["Color"], glos.inputs["Color"])
links.new(glos.outputs["BSDF"], add.inputs[0])
links.new(emit.outputs["Emission"], add.inputs[1])
return add.outputs["Shader"]
def _inner_MATTE_CLAY(nodes, links, y=0, color=(0.6, 0.4, 0.3, 1.0),
roughness=0.95, metallic=0.0, emission_strength=1.0, **kwargs):
prin = nodes.new("ShaderNodeBsdfPrincipled"); prin.location = (0, y)
prin.inputs["Base Color"].default_value = color
prin.inputs["Roughness"].default_value = roughness
return prin.outputs["BSDF"]
def _inner_GHOST(nodes, links, y=0, color=(0.7, 0.8, 1.0, 1.0),
roughness=0.1, metallic=0.0, emission_strength=1.0, **kwargs):
mix = nodes.new("ShaderNodeMixShader"); mix.location = (400, y)
trans = nodes.new("ShaderNodeBsdfTransparent"); trans.location = (0, y)
emit = nodes.new("ShaderNodeEmission"); emit.location = (0, y + 150)
fres = nodes.new("ShaderNodeFresnel"); fres.location = (0, y + 300)
emit.inputs["Color"].default_value = color
emit.inputs["Strength"].default_value = emission_strength * 2.0
fres.inputs["IOR"].default_value = 1.05
links.new(fres.outputs["Fac"], mix.inputs["Fac"])
links.new(trans.outputs["BSDF"], mix.inputs[1])
links.new(emit.outputs["Emission"], mix.inputs[2])
return mix.outputs["Shader"]
def _inner_CAR_PAINT(nodes, links, y=0, color=(0.8, 0.05, 0.1, 1.0),
roughness=0.3, metallic=0.8, emission_strength=1.0, **kwargs):
prin = nodes.new("ShaderNodeBsdfPrincipled"); prin.location = (0, y)
prin.inputs["Base Color"].default_value = color
prin.inputs["Metallic"].default_value = metallic
prin.inputs["Roughness"].default_value = roughness
prin.inputs["Coat Weight"].default_value = 1.0
prin.inputs["Coat Roughness"].default_value = 0.03
return prin.outputs["BSDF"]
def _inner_CHROME(nodes, links, y=0, color=(0.95, 0.95, 0.95, 1.0),
roughness=0.01, metallic=1.0, emission_strength=1.0, **kwargs):
prin = nodes.new("ShaderNodeBsdfPrincipled"); prin.location = (0, y)
prin.inputs["Base Color"].default_value = color
prin.inputs["Metallic"].default_value = metallic
prin.inputs["Roughness"].default_value = roughness
return prin.outputs["BSDF"]
def _inner_RUBY_GLASS(nodes, links, y=0, color=(1.0, 0.05, 0.05, 1.0),
roughness=0.02, metallic=0.0, emission_strength=1.0, **kwargs):
prin = nodes.new("ShaderNodeBsdfPrincipled"); prin.location = (0, y)
prin.inputs["Base Color"].default_value = color
prin.inputs["Metallic"].default_value = metallic
prin.inputs["Roughness"].default_value = roughness
prin.inputs["IOR"].default_value = 1.6
prin.inputs["Transmission Weight"].default_value = 1.0
return prin.outputs["BSDF"]
def _inner_FROSTED_ICE(nodes, links, y=0, color=(0.9, 0.95, 1.0, 1.0),
roughness=0.4, metallic=0.0, emission_strength=1.0, **kwargs):
prin = nodes.new("ShaderNodeBsdfPrincipled"); prin.location = (0, y)
prin.inputs["Base Color"].default_value = color
prin.inputs["Metallic"].default_value = metallic
prin.inputs["Roughness"].default_value = roughness
prin.inputs["IOR"].default_value = 1.31
prin.inputs["Transmission Weight"].default_value = 1.0
return prin.outputs["BSDF"]
def _inner_LICHEN_ROUGH(nodes, links, y=0, color=(0.3, 0.6, 0.1, 1.0),
roughness=0.9, metallic=0.0, emission_strength=1.0, **kwargs):
mix = nodes.new("ShaderNodeMixShader"); mix.location = (300, y)
diff1 = nodes.new("ShaderNodeBsdfDiffuse"); diff1.location = (0, y + 100)
diff2 = nodes.new("ShaderNodeBsdfDiffuse"); diff2.location = (0, y - 100)
noise = nodes.new("ShaderNodeTexNoise"); noise.location = (-400, y)
ramp = nodes.new("ShaderNodeValToRGB"); ramp.location = (-200, y)
coord = nodes.new("ShaderNodeTexCoord"); coord.location = (-600, y)
diff1.inputs["Color"].default_value = (0.15, 0.15, 0.15, 1.0)
diff1.inputs["Roughness"].default_value = 1.0
diff2.inputs["Color"].default_value = color
diff2.inputs["Roughness"].default_value = roughness
noise.inputs["Scale"].default_value = 8.0
noise.inputs["Detail"].default_value = 6.0
cr = ramp.color_ramp
cr.elements[0].position = 0.45; cr.elements[0].color = (0.0, 0.0, 0.0, 1.0)
cr.elements[1].position = 0.55; cr.elements[1].color = (1.0, 1.0, 1.0, 1.0)
links.new(coord.outputs["Generated"], noise.inputs["Vector"])
links.new(noise.outputs["Fac"], ramp.inputs["Fac"])
links.new(ramp.outputs["Color"], mix.inputs["Fac"])
links.new(diff1.outputs["BSDF"], mix.inputs[1])
links.new(diff2.outputs["BSDF"], mix.inputs[2])
return mix.outputs["Shader"]
def _inner_COPPER_PATINA(nodes, links, y=0, color=(0.2, 0.6, 0.45, 1.0),
roughness=0.15, metallic=1.0, emission_strength=1.0, **kwargs):
mix = nodes.new("ShaderNodeMixShader"); mix.location = (300, y)
prin1 = nodes.new("ShaderNodeBsdfPrincipled"); prin1.location = (0, y + 120)
prin2 = nodes.new("ShaderNodeBsdfPrincipled"); prin2.location = (0, y - 120)
noise = nodes.new("ShaderNodeTexNoise"); noise.location = (-400, y)
ramp = nodes.new("ShaderNodeValToRGB"); ramp.location = (-200, y)
coord = nodes.new("ShaderNodeTexCoord"); coord.location = (-600, y)
prin1.inputs["Base Color"].default_value = (0.95, 0.45, 0.3, 1.0)
prin1.inputs["Metallic"].default_value = metallic
prin1.inputs["Roughness"].default_value = roughness
prin2.inputs["Base Color"].default_value = color
prin2.inputs["Metallic"].default_value = 0.0
prin2.inputs["Roughness"].default_value = 0.85
noise.inputs["Scale"].default_value = 6.0
noise.inputs["Detail"].default_value = 4.0
cr = ramp.color_ramp
cr.elements[0].position = 0.4; cr.elements[0].color = (0.0, 0.0, 0.0, 1.0)
cr.elements[1].position = 0.6; cr.elements[1].color = (1.0, 1.0, 1.0, 1.0)
links.new(coord.outputs["Generated"], noise.inputs["Vector"])
links.new(noise.outputs["Fac"], ramp.inputs["Fac"])
links.new(ramp.outputs["Color"], mix.inputs["Fac"])
links.new(prin1.outputs["BSDF"], mix.inputs[1])
links.new(prin2.outputs["BSDF"], mix.inputs[2])
return mix.outputs["Shader"]
def _inner_ZEBRA_STRIPES(nodes, links, y=0, color=(0.98, 0.98, 0.98, 1.0),
roughness=0.1, metallic=0.0, emission_strength=1.0, **kwargs):
mix = nodes.new("ShaderNodeMixShader"); mix.location = (300, y)
prin1 = nodes.new("ShaderNodeBsdfPrincipled"); prin1.location = (0, y + 100)
prin2 = nodes.new("ShaderNodeBsdfPrincipled"); prin2.location = (0, y - 100)
wave = nodes.new("ShaderNodeTexWave"); wave.location = (-400, y)
ramp = nodes.new("ShaderNodeValToRGB"); ramp.location = (-200, y)
coord = nodes.new("ShaderNodeTexCoord"); coord.location = (-600, y)
prin1.inputs["Base Color"].default_value = (0.02, 0.02, 0.02, 1.0)
prin1.inputs["Metallic"].default_value = metallic
prin1.inputs["Roughness"].default_value = roughness
prin2.inputs["Base Color"].default_value = color
prin2.inputs["Metallic"].default_value = metallic
prin2.inputs["Roughness"].default_value = roughness
wave.inputs["Scale"].default_value = 5.0
cr = ramp.color_ramp
cr.interpolation = 'CONSTANT'
cr.elements[0].position = 0.0; cr.elements[0].color = (0.0, 0.0, 0.0, 1.0)
cr.elements[1].position = 0.5; cr.elements[1].color = (1.0, 1.0, 1.0, 1.0)
links.new(coord.outputs["Generated"], wave.inputs["Vector"])
links.new(wave.outputs["Color"], ramp.inputs["Fac"])
links.new(ramp.outputs["Color"], mix.inputs["Fac"])
links.new(prin1.outputs["BSDF"], mix.inputs[1])
links.new(prin2.outputs["BSDF"], mix.inputs[2])
return mix.outputs["Shader"]
def _inner_WOOD_VENEER(nodes, links, y=0, color=(0.4, 0.2, 0.08, 1.0),
roughness=0.2, metallic=0.0, emission_strength=1.0, **kwargs):
prin = nodes.new("ShaderNodeBsdfPrincipled"); prin.location = (300, y)
noise = nodes.new("ShaderNodeTexNoise"); noise.location = (-100, y)
ramp = nodes.new("ShaderNodeValToRGB"); ramp.location = (100, y)
mapping = nodes.new("ShaderNodeMapping"); mapping.location = (-300, y)
coord = nodes.new("ShaderNodeTexCoord"); coord.location = (-500, y)
mapping.inputs["Scale"].default_value = (1.0, 10.0, 1.0)
noise.inputs["Scale"].default_value = 4.0
noise.inputs["Detail"].default_value = 4.0
cr = ramp.color_ramp
cr.interpolation = 'LINEAR'
cr.elements[0].position = 0.0; cr.elements[0].color = (color[0] * 0.4, color[1] * 0.4, color[2] * 0.4, 1.0)
cr.elements[1].position = 1.0; cr.elements[1].color = color
cr.elements.new(0.5).color = (color[0] * 0.7, color[1] * 0.7, color[2] * 0.7, 1.0)
prin.inputs["Metallic"].default_value = metallic
prin.inputs["Roughness"].default_value = roughness
links.new(coord.outputs["Generated"], mapping.inputs["Vector"])
links.new(mapping.outputs["Vector"], noise.inputs["Vector"])
links.new(noise.outputs["Fac"], ramp.inputs["Fac"])
links.new(ramp.outputs["Color"], prin.inputs["Base Color"])
return prin.outputs["BSDF"]
def _inner_CARBON_FIBER(nodes, links, y=0, color=(0.1, 0.1, 0.1, 1.0),
roughness=0.15, metallic=0.0, emission_strength=1.0, **kwargs):
mix = nodes.new("ShaderNodeMixShader"); mix.location = (300, y)
prin1 = nodes.new("ShaderNodeBsdfPrincipled"); prin1.location = (0, y + 100)
prin2 = nodes.new("ShaderNodeBsdfPrincipled"); prin2.location = (0, y - 100)
check = nodes.new("ShaderNodeTexChecker"); check.location = (-200, y)
coord = nodes.new("ShaderNodeTexCoord"); coord.location = (-400, y)
prin1.inputs["Base Color"].default_value = (0.05, 0.05, 0.05, 1.0)
prin1.inputs["Metallic"].default_value = 0.5
prin1.inputs["Roughness"].default_value = roughness
prin2.inputs["Base Color"].default_value = color
prin2.inputs["Metallic"].default_value = 0.5
prin2.inputs["Roughness"].default_value = roughness
check.inputs["Scale"].default_value = 25.0
links.new(coord.outputs["Generated"], check.inputs["Vector"])
links.new(check.outputs["Color"], mix.inputs["Fac"])
links.new(prin1.outputs["BSDF"], mix.inputs[1])
links.new(prin2.outputs["BSDF"], mix.inputs[2])
return mix.outputs["Shader"]
def _inner_PEARLESCENT(nodes, links, y=0, color=(1.0, 0.4, 0.7, 1.0),
roughness=0.05, metallic=0.1, emission_strength=1.0, **kwargs):
prin = nodes.new("ShaderNodeBsdfPrincipled"); prin.location = (300, y)
ramp = nodes.new("ShaderNodeValToRGB"); ramp.location = (100, y)
lw = nodes.new("ShaderNodeLayerWeight"); lw.location = (-100, y)
lw.inputs["Blend"].default_value = 0.35
cr = ramp.color_ramp
cr.interpolation = 'LINEAR'
cr.elements[0].position = 0.0; cr.elements[0].color = (0.1, 0.8, 1.0, 1.0)
cr.elements[1].position = 1.0; cr.elements[1].color = color
prin.inputs["Metallic"].default_value = metallic
prin.inputs["Roughness"].default_value = roughness
prin.inputs["Coat Weight"].default_value = 1.0
prin.inputs["Coat Roughness"].default_value = 0.02
links.new(lw.outputs["Facing"], ramp.inputs["Fac"])
links.new(ramp.outputs["Color"], prin.inputs["Base Color"])
return prin.outputs["BSDF"]
def _inner_PLASMA_GLOW(nodes, links, y=0, color=(1.0, 0.2, 0.8, 1.0),
roughness=0.1, metallic=0.0, emission_strength=1.0, **kwargs):
emit = nodes.new("ShaderNodeEmission"); emit.location = (300, y)
ramp = nodes.new("ShaderNodeValToRGB"); ramp.location = (100, y)
voro = nodes.new("ShaderNodeTexVoronoi"); voro.location = (-100, y)
coord = nodes.new("ShaderNodeTexCoord"); coord.location = (-300, y)
voro.inputs["Scale"].default_value = 6.0
cr = ramp.color_ramp
cr.interpolation = 'LINEAR'
cr.elements[0].position = 0.0; cr.elements[0].color = (0.1, 0.0, 0.3, 1.0)
cr.elements[1].position = 1.0; cr.elements[1].color = (1.0, 0.9, 0.5, 1.0)
cr.elements.new(0.5).color = color
emit.inputs["Strength"].default_value = emission_strength * 8.0
links.new(coord.outputs["Generated"], voro.inputs["Vector"])
links.new(voro.outputs["Distance"], ramp.inputs["Fac"])
links.new(ramp.outputs["Color"], emit.inputs["Color"])
return emit.outputs["Emission"]
# ---------------------------------------------------------------------------
# Auto-discovery — P5 fix
# INNER_FACTORIES and SHADER_IDS are built from the _inner_* naming convention.
# Python 3.7+ dicts preserve insertion order, so SHADER_IDS matches the
# function definition order above.
# ---------------------------------------------------------------------------
INNER_FACTORIES: dict = {
name[len("_inner_"):]: fn
for name, fn in globals().items()
if name.startswith("_inner_") and callable(fn)
}
# Stable ordered list used by EnumProperty items and the unregister purge.
SHADER_IDS: list = list(INNER_FACTORIES)
# Shaders that require alpha blending on the material.
_TRANSPARENT_SHADERS: frozenset = frozenset({
"GLASS", "HOLOGRAM", "GHOST", "RUBY_GLASS", "FROSTED_ICE",
})
# ---------------------------------------------------------------------------
# Material name helpers
# ---------------------------------------------------------------------------
def _shader_mat_name(shader_id: str) -> str:
return f"KnotShader_{shader_id}"
# ---------------------------------------------------------------------------
# Shader material cache
# ---------------------------------------------------------------------------
def _get_shader_material(shader_id: str):
"""Look up the named shader material. Returns None if not yet built.
This is the HANDLER-SAFE path — it never allocates.
"""
return bpy.data.materials.get(_shader_mat_name(shader_id))
def _ensure_shader_material(shader_id: str):
"""Get-or-create the named shader material with default properties.
MAIN THREAD ONLY.
"""
mat_name = _shader_mat_name(shader_id)
mat = bpy.data.materials.get(mat_name)
if mat is None:
mat = bpy.data.materials.new(name=mat_name)
if hasattr(mat, "use_nodes"):
try:
mat.use_nodes = True
except AttributeError:
pass
nodes = mat.node_tree.nodes
links = mat.node_tree.links
nodes.clear()
out = nodes.new("ShaderNodeOutputMaterial")
out.location = (600, 0)
sock = INNER_FACTORIES[shader_id](nodes, links, y=0)
links.new(sock, out.inputs["Surface"])
if shader_id in _TRANSPARENT_SHADERS:
mat.blend_method = 'BLEND'
return mat
def _ensure_item_preset_material(item):
"""Ensure the per-KnotItem preset material exists and is up to date.
MAIN THREAD ONLY. Call before playback or from property update callbacks.
"""
import random
if not item.uid:
item.uid = f"knot_{random.randint(100000, 999999)}"
mat_name = f"KnotItem_Preset_{item.uid}"
mat = bpy.data.materials.get(mat_name)
if mat is None:
mat = bpy.data.materials.new(name=mat_name)
if hasattr(mat, "use_nodes"):
try:
mat.use_nodes = True
except AttributeError:
pass
nodes = mat.node_tree.nodes
links = mat.node_tree.links
nodes.clear()
out = nodes.new("ShaderNodeOutputMaterial")
out.location = (600, 0)
color = (item.preset_color[0], item.preset_color[1], item.preset_color[2], 1.0)
roughness = item.preset_roughness
metallic = item.preset_metallic
emission_strength = item.preset_emission_strength
sock = INNER_FACTORIES[item.shader_id](
nodes, links, y=0,
color=color,
roughness=roughness,
metallic=metallic,
emission_strength=emission_strength,
)
links.new(sock, out.inputs["Surface"])
if item.shader_id in _TRANSPARENT_SHADERS:
mat.blend_method = 'BLEND'
else:
mat.blend_method = 'OPAQUE'
return mat
def get_effective_material(item):
"""Return the bpy.types.Material currently active for this KnotItem."""
if item.material_mode == 'PROJECT':
return item.project_material
return _ensure_item_preset_material(item)
def _update_viewport_knot_material(scene) -> None:
"""Instantly update the viewport AnimKnot object's material slot."""
obj = bpy.data.objects.get(KNOT_OBJ_NAME)
if not obj:
return
idx = scene.knot_list_index
if 0 <= idx < len(scene.knot_list):
item = scene.knot_list[idx]
mat = get_effective_material(item)
if mat:
if len(obj.data.materials) == 0:
obj.data.materials.append(mat)
else:
obj.data.materials[0] = mat
# ---------------------------------------------------------------------------
# Node-copy helper for cross-material blend materials
# ---------------------------------------------------------------------------
def _copy_nodes_to_blend(src_mat, dst_mat, offset_y: int, prefix: str):
"""Robustly duplicate a source material's node setup into a blend material.
Returns the final output shader socket so the caller can wire it into a
MixShader. Falls back to a plain Principled BSDF if anything goes wrong.
"""
if not src_mat or getattr(src_mat, "node_tree", None) is None:
nodes = dst_mat.node_tree.nodes
fallback = nodes.new("ShaderNodeBsdfPrincipled")
fallback.name = f"{prefix}_fallback"
fallback.location = (-300, offset_y)
if src_mat:
fallback.inputs["Base Color"].default_value = src_mat.diffuse_color
return fallback.outputs["BSDF"]
nodes = dst_mat.node_tree.nodes
links = dst_mat.node_tree.links
node_map = {}
for src_node in src_mat.node_tree.nodes:
if src_node.type == 'OUTPUT_MATERIAL':
continue
new_node = nodes.new(src_node.bl_idname)
new_node.name = f"{prefix}_{src_node.name}"
new_node.label = src_node.label
new_node.location = (src_node.location.x - 300, src_node.location.y + offset_y)
# Copy only non-readonly primitive values
for prop in src_node.bl_rna.properties:
if prop.identifier in {'rna_type', 'type', 'inputs', 'outputs',
'internal_links', 'bl_idname'}:
continue
if not prop.is_readonly and prop.type in {'BOOLEAN', 'INT', 'FLOAT', 'STRING', 'ENUM'}:
try:
setattr(new_node, prop.identifier, getattr(src_node, prop.identifier))
except Exception:
pass
# Special-case: ColorRamp needs manual element duplication
if src_node.type == 'VALTO_RGB':
try:
new_ramp = new_node.color_ramp
src_ramp = src_node.color_ramp
new_ramp.interpolation = src_ramp.interpolation
while len(new_ramp.elements) < len(src_ramp.elements):
new_ramp.elements.new(0.5)
while len(new_ramp.elements) > len(src_ramp.elements):
new_ramp.elements.remove(new_ramp.elements[-1])
for idx, el in enumerate(src_ramp.elements):
new_ramp.elements[idx].position = el.position
new_ramp.elements[idx].color = el.color
except Exception:
pass
for i, inp in enumerate(src_node.inputs):
try:
new_node.inputs[i].default_value = inp.default_value
except Exception:
pass
node_map[src_node] = new_node
# Reconnect internal links
for link in src_mat.node_tree.links:
if link.to_node.type == 'OUTPUT_MATERIAL':
continue
try:
from_node = node_map.get(link.from_node)
to_node = node_map.get(link.to_node)
if not from_node or not to_node:
continue
try:
out_sock = from_node.outputs[link.from_socket.identifier]
except (KeyError, IndexError):
f_idx = list(link.from_node.outputs).index(link.from_socket)
out_sock = from_node.outputs[f_idx] if f_idx < len(from_node.outputs) else None
try:
in_sock = to_node.inputs[link.to_socket.identifier]
except (KeyError, IndexError):
t_idx = list(link.to_node.inputs).index(link.to_socket)
in_sock = to_node.inputs[t_idx] if t_idx < len(to_node.inputs) else None
if out_sock and in_sock:
links.new(out_sock, in_sock)
except Exception:
pass
# Find the source surface output socket via the Material Output node
src_out = next(
(n for n in src_mat.node_tree.nodes if n.type == 'OUTPUT_MATERIAL' and n.is_active_output),
None,
) or next(
(n for n in src_mat.node_tree.nodes if n.type == 'OUTPUT_MATERIAL'),
None,
)
if src_out and len(src_out.inputs["Surface"].links) > 0:
src_link = src_out.inputs["Surface"].links[0]
mapped_node = node_map.get(src_link.from_node)
if mapped_node and len(mapped_node.outputs) > 0:
try:
return mapped_node.outputs[src_link.from_socket.identifier]
except (KeyError, IndexError):
pass
try:
f_idx = list(src_link.from_node.outputs).index(src_link.from_socket)
if f_idx < len(mapped_node.outputs):
return mapped_node.outputs[f_idx]
except (ValueError, IndexError):
pass
return mapped_node.outputs[0]
# Final fallback
fallback = nodes.new("ShaderNodeBsdfPrincipled")
fallback.name = f"{prefix}_fallback"
fallback.location = (-300, offset_y)
return fallback.outputs["BSDF"]
# ---------------------------------------------------------------------------
# Playlist blend material system
# ---------------------------------------------------------------------------
def prebuild_playlist_blend_materials(scene) -> None:
"""Pre-build cross-fade blend materials for every adjacent playlist pair.
Called from the main thread (operators, property callbacks) so the frame
handler never needs to allocate node trees itself.
"""
if not hasattr(scene, "knot_list") or len(scene.knot_list) == 0:
return
for i, item in enumerate(scene.knot_list):
if item.transition_frames <= 0:
continue
next_item = scene.knot_list[(i + 1) % len(scene.knot_list)]
mat_a = get_effective_material(item)
mat_b = get_effective_material(next_item)
blend_name = f"KnotBlend_Item_{i}"
blend_mat = bpy.data.materials.get(blend_name)
if not blend_mat:
blend_mat = bpy.data.materials.new(name=blend_name)
if hasattr(blend_mat, "use_nodes"):
try:
blend_mat.use_nodes = True
except AttributeError:
pass
nodes = blend_mat.node_tree.nodes
links = blend_mat.node_tree.links
nodes.clear()
out = nodes.new("ShaderNodeOutputMaterial")
out.location = (600, 0)
mix = nodes.new("ShaderNodeMixShader")
mix.name = "_KnotBlendMix"
mix.location = (300, 0)
sock_a = _copy_nodes_to_blend(mat_a, blend_mat, 250, "A")
sock_b = _copy_nodes_to_blend(mat_b, blend_mat, -250, "B")
links.new(sock_a, mix.inputs[1])
links.new(sock_b, mix.inputs[2])
links.new(mix.outputs["Shader"], out.inputs["Surface"])
if (mat_a and mat_a.blend_method == 'BLEND') or (mat_b and mat_b.blend_method == 'BLEND'):
blend_mat.blend_method = 'BLEND'
else:
blend_mat.blend_method = 'OPAQUE'
def prewarm_materials_and_blends(scene) -> None:
"""Ensure all playlist preset materials are generated and transitions pre-built."""
if not hasattr(scene, "knot_list"):
return
for item in scene.knot_list:
if item.material_mode == 'PRESET':
_ensure_item_preset_material(item)
prebuild_playlist_blend_materials(scene)