Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 29 additions & 58 deletions LightEditor.py
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,33 @@ def _get_collections_recursive(collection, path=None):
all_collections.add(" > ".join(path))
return sorted(all_collections)

def is_emissive_node_active(node):
"""True only if the node actually emits light.

Every Principled BSDF carries an "Emission Strength" socket, so node type
alone would mark every ordinary material as emissive. Strength 0 or a black
colour means no contribution and must not be listed.
"""
if node.type == 'EMISSION':
strength_socket = node.inputs.get("Strength")
color_socket = node.inputs.get("Color")
elif node.type == 'BSDF_PRINCIPLED':
strength_socket = node.inputs.get("Emission Strength")
color_socket = node.inputs.get("Emission Color")
else:
return False

if not strength_socket or not color_socket:
return False

# A driven or linked socket can be anything at render time; assume active.
if strength_socket.is_linked or color_socket.is_linked:
return True

strength = strength_socket.default_value
color = color_socket.default_value[:3] # RGB
return strength > 0 and any(c > 0 for c in color)

def find_emissive_objects(context, search_objects=None):
"""Find all objects with emissive materials, including all reachable emissive nodes."""
global emissive_material_cache
Expand Down Expand Up @@ -433,7 +460,8 @@ def find_emission_nodes(node, visited, found_nodes):
for link in output_node.inputs['Surface'].links:
find_emission_nodes(link.from_node, set(), found_nodes)
for node in found_nodes:
emissive_objs.append((obj, mat, node))
if is_emissive_node_active(node):
emissive_objs.append((obj, mat, node))

if use_cache:
if not emissive_objs:
Expand Down Expand Up @@ -827,37 +855,6 @@ def execute(self, context):
area.tag_redraw()
return {'FINISHED'}

def execute(self, context):
global isolate_env_header_state, isolate_env_surface_state, isolate_env_volume_state
global env_isolated_ui_state # ← ADD THIS

flag_map = {
"HEADER": "isolate_env_header_state",
"SURFACE": "isolate_env_surface_state",
"VOLUME": "isolate_env_volume_state",
}
mode_map = {
"HEADER": UnifiedIsolateMode.ENVIRONMENT,
"SURFACE": UnifiedIsolateMode.ENVIRONMENT_SURFACE,
"VOLUME": UnifiedIsolateMode.ENVIRONMENT_VOLUME,
}

unified_mode = mode_map.get(self.mode, UnifiedIsolateMode.ENVIRONMENT)
is_currently_active = _unified_isolate_manager.is_active(unified_mode)

if not is_currently_active:
globals()[flag_map[self.mode]] = True
if self.mode == "HEADER":
env_isolated_ui_state = True # ← SET TRUE when activated
_unified_isolate_manager.activate(context, unified_mode)
else:
globals()[flag_map[self.mode]] = False
if self.mode == "HEADER":
env_isolated_ui_state = False # ← SET FALSE when deactivated
_unified_isolate_manager.deactivate(context)

return {'FINISHED'}

class LE_OT_SelectEnvironment(bpy.types.Operator):
"""Select the environment world in the Shader Editor."""
bl_idname = "le.select_environment"
Expand Down Expand Up @@ -1083,33 +1080,7 @@ def execute(self, context):
if area.type in {'VIEW_3D', 'NODE_EDITOR', 'PROPERTIES'}:
area.tag_redraw()
return {'FINISHED'}

def _disable_material_node(self, mat, node):
global _emissive_link_backup
if not node or not mat.use_nodes:
return
nt = mat.node_tree

strength = node.inputs.get("Strength") if node.type == 'EMISSION' else node.inputs.get("Emission Strength")
color = node.inputs.get("Color") if node.type == 'EMISSION' else node.inputs.get("Emission Color")
socket = strength or color
if not socket:
return

key = f"{mat.name}:{node.name}:{socket.name}"

if socket.is_linked and socket.links:
link = socket.links[0]
_emissive_link_backup[key] = ('LINK', node.name, socket.name, link.from_node.name, link.from_socket.name)
nt.links.remove(link)
else:
if socket.name == "Color":
_emissive_link_backup[key] = ('VALUE', node.name, socket.name, tuple(socket.default_value[:]))
socket.default_value = (0, 0, 0, 1)
else:
_emissive_link_backup[key] = ('VALUE', node.name, socket.name, socket.default_value)
socket.default_value = 0

class LE_OT_isolate_emissive(bpy.types.Operator):
"""Toggle isolation of emissive nodes—or entire material if node_name == ""."""
bl_idname = "le.isolate_emissive"
Expand Down
154 changes: 117 additions & 37 deletions LightGroup.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
from bpy.props import StringProperty
from bpy.app.handlers import persistent

# Single source of truth for "does this node actually emit light", shared with
# the Light Editor panel so both lists agree. Safe at import time: __init__.py
# imports LightEditor first and LightEditor never imports this module.
from .LightEditor import is_emissive_node_active

# -------------------------------------------------------------------------
# Scene-scoped state
# -------------------------------------------------------------------------
Expand All @@ -43,6 +48,59 @@ def _display_name(obj):
return f"{obj.name} (Environment)"
return obj.name

# Emissive lookup is per-material and runs during panel draw, so the result is
# cached and invalidated from a depsgraph handler (see LG_clear_emissive_cache).
_emissive_material_cache = {}

def _material_is_emissive(mat):
"""True if the material's active output is fed by an Emission/Principled emission node."""
# use_nodes is deprecated and slated for removal in Blender 6.0; default it
# to True so this keeps working once the property is gone.
if not mat or not getattr(mat, "use_nodes", True) or not mat.node_tree:
return False

cached = _emissive_material_cache.get(mat.name)
if cached is not None:
return cached

result = False
nt = mat.node_tree
output_node = next((n for n in nt.nodes if n.type == 'OUTPUT_MATERIAL' and n.is_active_output), None)
surface = output_node.inputs.get('Surface') if output_node else None
if surface and surface.is_linked:
visited = set()
stack = [link.from_node for link in surface.links]
while stack:
node = stack.pop()
if node in visited:
continue
visited.add(node)
# Node type alone isn't enough: every Principled BSDF has an
# emission socket, so an unfiltered check lists the whole scene.
if is_emissive_node_active(node):
result = True
break
for socket in node.inputs:
if socket.is_linked:
stack.extend(link.from_node for link in socket.links)

_emissive_material_cache[mat.name] = result
return result

def _is_emissive_mesh(obj):
"""True for a mesh object carrying at least one emissive material."""
if obj.type != 'MESH':
return False
return any(_material_is_emissive(slot.material) for slot in obj.material_slots)

def _is_lightgroup_object(obj):
"""Objects that Cycles can assign to a light group: lights and emissive meshes."""
return obj.type == 'LIGHT' or _is_emissive_mesh(obj)

def _lightgroup_objects(scene):
"""All light-group-capable objects in the scene."""
return [obj for obj in scene.objects if _is_lightgroup_object(obj)]

def _is_selected(obj):
"""Selection state of an object, safe to call from a draw function.

Expand All @@ -54,6 +112,23 @@ def _is_selected(obj):
except RuntimeError:
return False

def LG_clear_emissive_cache(scene, depsgraph=None):
"""Drop the emissive lookup when a material changes.

Without this the panel would keep listing a mesh after its emission node
was removed, and miss one that just gained it. Only Material updates matter
— the cache is keyed by material, and which materials an object uses is
re-read on every lookup — so object transforms leave it intact rather than
forcing a full node-tree rescan on every viewport move.
"""
if depsgraph is None:
_emissive_material_cache.clear()
return
for update in depsgraph.updates:
if isinstance(update.id, bpy.types.Material):
_emissive_material_cache.clear()
return

@persistent
def LG_clear_state_on_load(dummy):
"""Drop solo state on file load.
Expand All @@ -62,6 +137,7 @@ def LG_clear_state_on_load(dummy):
so carrying them into a freshly loaded file would leave the UI claiming a
group is soloed while the backup refers to objects from the old scene.
"""
_emissive_material_cache.clear()
_exclusive_visibility_backup.clear()
if hasattr(bpy.types.Scene, "group_exclusive_dict"):
bpy.types.Scene.group_exclusive_dict.clear()
Expand Down Expand Up @@ -116,8 +192,9 @@ def execute(self, context):
and view_layer.active_lightgroup_index < len(view_layer.lightgroups)):
active_group = view_layer.lightgroups[view_layer.active_lightgroup_index]

# Selected LIGHT objects (selection driven by Object.is_selected -> select_set)
selected_lights = [obj for obj in context.selected_objects if obj.type == 'LIGHT']
# Selected lights and emissive meshes (selection driven by
# Object.is_selected -> select_set)
selected_lights = [obj for obj in context.selected_objects if _is_lightgroup_object(obj)]
for light in selected_lights:
light.lightgroup = active_group.name

Expand All @@ -137,8 +214,8 @@ class LG_UnassignLightGroup(Operator):
bl_label = "Unassign"

def execute(self, context):
# Selected LIGHT objects
selected_lights = [obj for obj in context.selected_objects if obj.type == 'LIGHT']
# Selected lights and emissive meshes
selected_lights = [obj for obj in context.selected_objects if _is_lightgroup_object(obj)]
for light in selected_lights:
light.lightgroup = ""

Expand All @@ -157,9 +234,8 @@ class LG_ResetLightSelection(Operator):

def execute(self, context):
bpy.ops.object.select_all(action='DESELECT')
for obj in context.scene.objects:
if obj.type == 'LIGHT':
obj.is_selected = False
for obj in _lightgroup_objects(context.scene):
obj.is_selected = False

world = _get_world_if_lightgroup_capable(context)
if world and hasattr(world, "le_is_selected"):
Expand Down Expand Up @@ -213,29 +289,28 @@ def execute(self, context):
# (already hidden) state as if it were the user's own.
if not was_soloing:
_exclusive_visibility_backup.clear()
for obj in context.scene.objects:
if obj.type == 'LIGHT':
_exclusive_visibility_backup[obj.name] = (
obj.hide_viewport, obj.hide_render
)
for obj in _lightgroup_objects(context.scene):
_exclusive_visibility_backup[obj.name] = (
obj.hide_viewport, obj.hide_render
)

exclusive_dict[self.group_key] = True
exclusive_group_name = self.group_key.replace("group_", "")
for obj in context.scene.objects:
if obj.type == 'LIGHT':
hidden = getattr(obj, "lightgroup", "") != exclusive_group_name
obj.hide_viewport = hidden
obj.hide_render = hidden
for obj in _lightgroup_objects(context.scene):
hidden = getattr(obj, "lightgroup", "") != exclusive_group_name
obj.hide_viewport = hidden
obj.hide_render = hidden
# World has no viewport toggle; leave it untouched.
else:
# Restore what the user had before soloing rather than forcing
# everything visible (which wiped their own hidden lights).
for obj in context.scene.objects:
if obj.type != 'LIGHT':
continue
vp, rp = _exclusive_visibility_backup.get(obj.name, (False, False))
obj.hide_viewport = vp
obj.hide_render = rp
# Iterate the backup, not the scene: a mesh whose emission was
# edited away while soloed still needs its visibility restored.
for name, (vp, rp) in _exclusive_visibility_backup.items():
obj = context.scene.objects.get(name)
if obj:
obj.hide_viewport = vp
obj.hide_render = rp
_exclusive_visibility_backup.clear()

for area in context.screen.areas:
Expand Down Expand Up @@ -280,9 +355,9 @@ def execute(self, context):
if view_layer.active_lightgroup_index >= 0 and view_layer.active_lightgroup_index < len(view_layer.lightgroups):
active_group_name = view_layer.lightgroups[view_layer.active_lightgroup_index].name

# Unassign lights from the group before removing
for obj in context.scene.objects:
if obj.type == 'LIGHT' and getattr(obj, "lightgroup", "") == active_group_name:
# Unassign lights and emissive meshes from the group before removing
for obj in _lightgroup_objects(context.scene):
if getattr(obj, "lightgroup", "") == active_group_name:
obj.lightgroup = ""

# Note: We don't touch World.lightgroup here; Blender will handle invalid refs.
Expand All @@ -308,8 +383,8 @@ def execute(self, context):
# Drawing
# -------------------------------------------------------------------------
def draw_main_row(box, obj):
"""Draw a row for either a LIGHT object or the Environment (World).
- LIGHT: toggles viewport selection
"""Draw a row for a LIGHT object, an emissive mesh, or the Environment (World).
- LIGHT / emissive mesh: toggles viewport selection
- WORLD: toggles World.le_is_selected (for Assign/Unassign)

Both use the same select-cursor icon as the Light Editor panel — a
Expand All @@ -332,7 +407,8 @@ def draw_main_row(box, obj):
icon='RESTRICT_SELECT_ON' if selected else 'RESTRICT_SELECT_OFF',
depress=selected)
op.light_name = obj.name
row.label(text=obj.name, icon='LIGHT')
# Same icon the Light Editor panel uses for emissive meshes.
row.label(text=obj.name, icon='LIGHT' if obj.type == 'LIGHT' else 'SHADING_RENDERED')

# -------------------------------------------------------------------------
# Main Panel
Expand Down Expand Up @@ -386,28 +462,27 @@ def draw(self, context):
groups = {}
capable_world = _get_world_if_lightgroup_capable(context)

# Resolved once per draw: emissive detection walks node trees, so
# re-scanning the scene for every light group would cost real time.
candidates = _lightgroup_objects(scene)

if hasattr(view_layer, "lightgroups"):
for lg in view_layer.lightgroups:
# Membership is about light group assignment, not visibility.
# Filtering on hide_render here made lights disappear from the
# list whenever anything hid them (solo/exclusive, the Light
# Editor's enable toggle, or a manual outliner click).
lights_in_group = [
obj for obj in scene.objects
if obj.type == 'LIGHT'
and getattr(obj, "lightgroup", "") == lg.name
obj for obj in candidates
if getattr(obj, "lightgroup", "") == lg.name
]
# Include the World if it's assigned to this group
if capable_world and getattr(capable_world, "lightgroup", "") == lg.name:
lights_in_group.append(capable_world)
groups[lg.name] = lights_in_group

# Not Assigned
not_assigned = [
obj for obj in scene.objects
if obj.type == 'LIGHT'
and not getattr(obj, "lightgroup", "")
]
not_assigned = [obj for obj in candidates if not getattr(obj, "lightgroup", "")]
if capable_world and not getattr(capable_world, "lightgroup", ""):
not_assigned.append(capable_world)
if not_assigned:
Expand Down Expand Up @@ -504,12 +579,17 @@ def register():

if LG_clear_state_on_load not in bpy.app.handlers.load_post:
bpy.app.handlers.load_post.append(LG_clear_state_on_load)
if LG_clear_emissive_cache not in bpy.app.handlers.depsgraph_update_post:
bpy.app.handlers.depsgraph_update_post.append(LG_clear_emissive_cache)


def unregister():
if LG_clear_state_on_load in bpy.app.handlers.load_post:
bpy.app.handlers.load_post.remove(LG_clear_state_on_load)
if LG_clear_emissive_cache in bpy.app.handlers.depsgraph_update_post:
bpy.app.handlers.depsgraph_update_post.remove(LG_clear_emissive_cache)

_emissive_material_cache.clear()
_exclusive_visibility_backup.clear()

# Remove props. Each removal is guarded so that one failure can't abort the
Expand Down
2 changes: 1 addition & 1 deletion __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "Light Editor",
"author": "Robert Rioux aka Blender Bob, Rombout Versluijs",
"location": "3Dview > Light Editor",
"version": (2, 4, 5),
"version": (2, 4, 6),
"blender": (4, 2, 0),
"description": "A Light Editor and Light Linking addon",
"category": "Object",
Expand Down
Loading