""" materials.py ------------ Shader catalogue and material management for knot_animation. Shader builders --------------- Each `_inner_` 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_(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 try: prin.inputs["Specular IOR Level"].default_value = 1.0 except KeyError: pass # name differs in older Blender versions 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 for key in ("Transmission Weight", "Transmission"): if key in prin.inputs: prin.inputs[key].default_value = 1.0 break 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 if "Specular" in prin.inputs: prin.inputs["Specular"].default_value = 0.1 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 if "Clearcoat" in prin.inputs: prin.inputs["Clearcoat"].default_value = 1.0 prin.inputs["Clearcoat Roughness"].default_value = 0.03 elif "Coat Weight" in prin.inputs: 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 for key in ("Transmission Weight", "Transmission"): if key in prin.inputs: prin.inputs[key].default_value = 1.0 break 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 for key in ("Transmission Weight", "Transmission"): if key in prin.inputs: prin.inputs[key].default_value = 1.0 break 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 if "Clearcoat" in prin.inputs: prin.inputs["Clearcoat"].default_value = 1.0 prin.inputs["Clearcoat 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)