diff --git a/LightEditor.py b/LightEditor.py index 8bf1ee0..a522581 100644 --- a/LightEditor.py +++ b/LightEditor.py @@ -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 @@ -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: @@ -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" @@ -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" diff --git a/LightGroup.py b/LightGroup.py index f463902..72f3979 100644 --- a/LightGroup.py +++ b/LightGroup.py @@ -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 # ------------------------------------------------------------------------- @@ -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. @@ -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. @@ -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() @@ -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 @@ -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 = "" @@ -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"): @@ -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: @@ -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. @@ -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 @@ -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 @@ -386,6 +462,10 @@ 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. @@ -393,9 +473,8 @@ def draw(self, context): # 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: @@ -403,11 +482,7 @@ def draw(self, context): 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: @@ -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 diff --git a/__init__.py b/__init__.py index ccc3474..9e1ead2 100644 --- a/__init__.py +++ b/__init__.py @@ -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", diff --git a/blender_manifest.toml b/blender_manifest.toml index 9d69db4..9c957c6 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -1,12 +1,12 @@ schema_version = "1.0.0" id = "LightEditor" -version = "2.4.5" +version = "2.4.6" name = "Light Editor" -tagline = "Adds buttons to quickly switch between mesh display types" +tagline = "Edit, isolate and group lights, emissive meshes and environment" maintainer = "Blender Bob" type = "add-on" website = "https://github.com/riouxr/Light-Editor" -tags = ["Mesh"] +tags = ["Lighting", "Render"] blender_version_min = "4.2.0" license = ["SPDX:GPL-3.0-or-later"] copyright = ["2025 Robert Rioux"] \ No newline at end of file