Files
blender-portable-repo/scripts/addons/SimpleBake/ui/node_bake_panel.py
T
2026-07-16 12:41:53 -06:00

411 lines
15 KiB
Python

import bpy
from bpy.types import Panel, Operator, PropertyGroup
from bpy.props import StringProperty, IntProperty, BoolProperty, EnumProperty, PointerProperty
from bpy.utils import register_class, unregister_class
# Module-level caches prevent enum item lists being garbage-collected mid-draw,
# which would crash Blender when dynamic EnumProperty items are used.
_socket_items_cache = [('0', 'Select a node', '', 0)]
_uv_items_cache = [('__active__', '(Active UV)', '', 0)]
def _get_socket_items(self, context):
global _socket_items_cache
node = getattr(context, 'active_node', None) if context else None
if node is None:
_socket_items_cache = [('0', 'Select a node', '', 0)]
return _socket_items_cache
items = []
for i, socket in enumerate(node.outputs):
if (socket.bl_idname not in ('NodeSocketShader', 'NodeSocketVirtual')
and not socket.hide
and socket.enabled):
items.append((str(i), socket.name, socket.name, len(items)))
if not items:
_socket_items_cache = [('0', 'No bakeable outputs', '', 0)]
return _socket_items_cache
_socket_items_cache = items
return _socket_items_cache
def _get_uv_items(self, context):
global _uv_items_cache
obj = context.active_object if context else None
items = [('__active__', '(Active UV)', 'Use whichever UV map is currently active on the object', 0)]
if obj and obj.type == 'MESH':
for i, layer in enumerate(obj.data.uv_layers):
items.append((layer.name, layer.name, layer.name, i + 1))
_uv_items_cache = items
return _uv_items_cache
class SimpleBakeNodeBakeProps(PropertyGroup):
socket: EnumProperty(
name="Socket",
description="Output socket to bake",
items=_get_socket_items,
)
image_name: StringProperty(
name="Image Name",
default="NodeBake",
description="Name for the baked image in the blend file",
)
width: IntProperty(name="Width", default=1024, min=1, max=16384)
height: IntProperty(name="Height", default=1024, min=1, max=16384)
float_buffer: BoolProperty(
name="32-bit Float",
default=False,
description="Use a 32-bit float image buffer (higher precision, larger memory footprint)",
)
uv_map: EnumProperty(
name="UV Map",
description="UV map to bake to. Defaults to whichever map is currently active on the object",
items=_get_uv_items,
)
replace_with_image: BoolProperty(
name="Replace with Image Texture",
default=False,
description=(
"After baking, insert an Image Texture node and reconnect it "
"to wherever this socket was feeding"
),
)
def _find_active_mat_output(nodes):
"""Return the active Material Output node, or any Material Output, or None."""
active = next((n for n in nodes if n.bl_idname == 'ShaderNodeOutputMaterial' and n.is_active_output), None)
if active:
return active
return next((n for n in nodes if n.bl_idname == 'ShaderNodeOutputMaterial'), None)
class SIMPLEBAKE_OT_NodeBake(Operator):
bl_idname = "simplebake.node_bake"
bl_label = "Bake Node Output"
bl_description = (
"Bake the selected output socket of the active node to an image texture. "
"The render engine is temporarily switched to Cycles for baking"
)
@classmethod
def poll(cls, context):
sd = getattr(context, 'space_data', None)
if sd is None or sd.type != 'NODE_EDITOR':
return False
if sd.tree_type != 'ShaderNodeTree':
return False
if context.active_object is None:
return False
node = context.active_node
if node is None:
return False
return any(
s.bl_idname not in ('NodeSocketShader', 'NodeSocketVirtual')
for s in node.outputs
)
# ------------------------------------------------------------------
# Material helpers
# ------------------------------------------------------------------
def _silence_material(self, mat):
"""
Temporarily wire a zero-emission shader into mat's Material Output so
it contributes nothing to an EMIT bake. Returns a restore tuple.
"""
nt = mat.node_tree
mat_out = _find_active_mat_output(nt.nodes)
if mat_out is None:
return None
orig = mat_out.inputs['Surface'].links[0].from_socket if mat_out.inputs['Surface'].is_linked else None
emit = nt.nodes.new('ShaderNodeEmission')
emit.inputs['Color'].default_value = (0.0, 0.0, 0.0, 1.0)
emit.inputs['Strength'].default_value = 0.0
nt.links.new(emit.outputs['Emission'], mat_out.inputs['Surface'])
return (nt, mat_out, emit, orig)
def _restore_material(self, restore_data):
if restore_data is None:
return
nt, mat_out, emit_node, orig_socket = restore_data
nt.nodes.remove(emit_node)
if orig_socket is not None:
nt.links.new(orig_socket, mat_out.inputs['Surface'])
# ------------------------------------------------------------------
# Operator execute
# ------------------------------------------------------------------
def execute(self, context):
props = context.scene.SimpleBakeNodeBake
space = context.space_data
node = context.active_node
node_location = (node.location[0], node.location[1])
# Must be at the root of the node tree, not inside a group
if space.edit_tree is not space.node_tree:
self.report({'ERROR'}, "Exit the node group before baking")
return {'CANCELLED'}
mat = space.id
if not isinstance(mat, bpy.types.Material):
self.report({'ERROR'}, "Could not determine the active material")
return {'CANCELLED'}
obj = context.active_object
mat_names = {s.material.name for s in obj.material_slots if s.material}
if mat.name not in mat_names:
self.report({'ERROR'}, f"'{mat.name}' is not assigned to the active object")
return {'CANCELLED'}
if not obj.data.uv_layers:
self.report({'ERROR'}, "The active object has no UV map — add one before baking")
return {'CANCELLED'}
# Activate the chosen UV map, remember original to restore later
chosen_uv = props.uv_map
orig_uv_index = obj.data.uv_layers.active_index
if chosen_uv != '__active__':
idx = next((i for i, l in enumerate(obj.data.uv_layers) if l.name == chosen_uv), None)
if idx is None:
self.report({'ERROR'}, f"UV map '{chosen_uv}' not found on object")
return {'CANCELLED'}
obj.data.uv_layers.active_index = idx
# Resolve the target socket
socket_idx = int(props.socket)
if socket_idx >= len(node.outputs):
self.report({'ERROR'}, "Socket index out of range — refresh by reselecting the node")
return {'CANCELLED'}
target_socket = node.outputs[socket_idx]
if target_socket.bl_idname in ('NodeSocketShader', 'NodeSocketVirtual'):
self.report({'ERROR'}, "Cannot bake a Shader or Virtual socket")
return {'CANCELLED'}
# Remember downstream links for the optional reconnect.
# Store names/identifiers rather than live Python wrapper references —
# those can become stale after node tree modifications during the bake.
downstream = [(lnk.to_node.name, lnk.to_socket.identifier) for lnk in target_socket.links]
# Create the output image
image_name = props.image_name.strip() or f"{node.name}_{target_socket.name}"
existing = bpy.data.images.get(image_name)
if existing:
bpy.data.images.remove(existing)
image = bpy.data.images.new(
image_name,
width=props.width,
height=props.height,
alpha=False,
float_buffer=props.float_buffer,
)
# --- Set up the target material ---
nt = mat.node_tree
nodes = nt.nodes
links = nt.links
mat_out = _find_active_mat_output(nodes)
created_mat_out = False
if mat_out is None:
mat_out = nodes.new('ShaderNodeOutputMaterial')
mat_out.location = (300.0, 300.0)
created_mat_out = True
orig_surface = mat_out.inputs['Surface'].links[0].from_socket if mat_out.inputs['Surface'].is_linked else None
# Temporary nodes
emit_node = nodes.new('ShaderNodeEmission')
emit_node.location = (mat_out.location[0] - 220, mat_out.location[1] - 80)
img_node = nodes.new('ShaderNodeTexImage')
img_node.name = "SB_NodeBake_Image"
img_node.image = image
img_node.location = (mat_out.location[0] - 420, mat_out.location[1] - 160)
# Make img_node the active image node (Cycles writes bake output here)
for n in nodes:
n.select = False
img_node.select = True
nodes.active = img_node
links.new(target_socket, emit_node.inputs['Color'])
links.new(emit_node.outputs['Emission'], mat_out.inputs['Surface'])
# --- Silence all other materials so they don't pollute the EMIT bake ---
silence_restores = []
for slot in obj.material_slots:
if slot.material and slot.material.name != mat.name:
rd = self._silence_material(slot.material)
if rd:
silence_restores.append(rd)
# --- Save scene state ---
orig_engine = context.scene.render.engine
orig_active_obj = context.view_layer.objects.active
orig_selected = list(context.selected_objects)
orig_mode = context.mode
# Configure for bake
context.scene.render.engine = 'CYCLES'
for o in list(context.selected_objects):
o.select_set(False)
obj.select_set(True)
context.view_layer.objects.active = obj
if orig_mode != 'OBJECT':
bpy.ops.object.mode_set(mode='OBJECT')
# --- Bake ---
success = False
try:
bpy.ops.object.bake(type='EMIT', use_clear=True)
success = True
except Exception as exc:
self.report({'ERROR'}, f"Bake failed: {exc}")
# --- Restore UV map ---
obj.data.uv_layers.active_index = orig_uv_index
# --- Restore scene state ---
context.scene.render.engine = orig_engine
context.view_layer.objects.active = orig_active_obj
for o in orig_selected:
try:
o.select_set(True)
except Exception:
pass
# --- Restore silenced materials ---
for rd in silence_restores:
self._restore_material(rd)
# --- Restore target material ---
nodes.remove(emit_node)
if created_mat_out:
nodes.remove(mat_out)
elif orig_surface is not None:
links.new(orig_surface, mat_out.inputs['Surface'])
# --- Handle image node ---
# Always remove the bake-helper image node — it was only needed for Cycles
# to know where to write the result. If replacing, a fresh node is created
# below at the correct position, avoiding any stale Python wrapper issues.
bake_helper = nodes.get("SB_NodeBake_Image") or img_node
nodes.remove(bake_helper)
if not success:
bpy.data.images.remove(image)
return {'CANCELLED'}
if props.replace_with_image:
final_node = nodes.new('ShaderNodeTexImage')
final_node.image = image
final_node.parent = node.parent
image_node_height = max(
float(getattr(final_node, "height", 0.0) or 0.0),
float(final_node.dimensions[1] or 0.0),
)
if image_node_height <= 0.0:
image_node_height = 260.0
final_node.location = (node_location[0], node_location[1] + image_node_height + 80.0)
if chosen_uv != '__active__':
uv_node = nodes.new('ShaderNodeUVMap')
uv_node.uv_map = chosen_uv
uv_node.parent = final_node.parent
uv_node.location = (final_node.location[0] - 220, final_node.location[1] - 100)
links.new(uv_node.outputs['UV'], final_node.inputs['Vector'])
for node_name, sock_id in downstream:
to_node = nodes.get(node_name)
if to_node is None:
continue
to_socket = next((s for s in to_node.inputs if s.identifier == sock_id), None)
if to_socket is not None:
links.new(final_node.outputs['Color'], to_socket)
self.report({'INFO'}, f"Baked '{target_socket.name}''{image_name}'")
return {'FINISHED'}
class SIMPLEBAKE_PT_NodeBake(Panel):
bl_label = "Node Bake"
bl_idname = "SIMPLEBAKE_PT_NodeBake"
bl_space_type = 'NODE_EDITOR'
bl_region_type = 'UI'
bl_category = "SimpleBake"
@classmethod
def poll(cls, context):
sd = context.space_data
return (
sd.type == 'NODE_EDITOR'
and sd.tree_type == 'ShaderNodeTree'
and context.active_object is not None
)
def draw(self, context):
layout = self.layout
props = context.scene.SimpleBakeNodeBake
node = context.active_node
if node is None:
layout.label(text="Select a node to bake", icon='INFO')
return
valid_outputs = [
s for s in node.outputs
if s.bl_idname not in ('NodeSocketShader', 'NodeSocketVirtual')
]
if not valid_outputs:
layout.label(text="No bakeable outputs on this node", icon='ERROR')
return
layout.label(text=node.name, icon='NODE')
layout.prop(props, "socket")
layout.prop(props, "uv_map")
layout.prop(props, "image_name")
row = layout.row(align=True)
row.prop(props, "width", text="W")
row.prop(props, "height", text="H")
layout.prop(props, "float_buffer")
layout.prop(props, "replace_with_image")
layout.separator()
row = layout.row()
row.scale_y = 1.4
row.operator("simplebake.node_bake", text="Bake Node Output", icon='RENDER_STILL')
classes = [
SimpleBakeNodeBakeProps,
SIMPLEBAKE_OT_NodeBake,
SIMPLEBAKE_PT_NodeBake,
]
def register():
for cls in classes:
register_class(cls)
bpy.types.Scene.SimpleBakeNodeBake = PointerProperty(type=SimpleBakeNodeBakeProps)
def unregister():
del bpy.types.Scene.SimpleBakeNodeBake
for cls in reversed(classes):
unregister_class(cls)