From d7f4d88f1073278463d0cc0a9e9dab62ed5d6757 Mon Sep 17 00:00:00 2001 From: riouxr Date: Mon, 10 Aug 2026 13:12:46 -0400 Subject: [PATCH 1/3] Show emissive meshes in the Light Group panel The Light Group panel filtered every list on obj.type == 'LIGHT', so emissive meshes could never appear there even though Cycles assigns them to light groups like any other emitter. Lights and emissive meshes are now resolved through one helper used by the panel, Assign/Unassign, Deselect All, solo and group removal. Also restores is_emissive_node_active, which "cleanup code" (8d679a8) dropped along with its call site. Without it node type alone decided the answer, and since every Principled BSDF carries an Emission Strength socket that marked every ordinary material as emissive. It is now shared by both panels so their lists agree. Solo restore iterates the visibility backup rather than the scene, so a mesh whose emission is edited away while a group is soloed still gets its visibility back instead of being left hidden. Emissive lookup is cached per material and invalidated from a depsgraph handler on Material updates only, keeping node-tree walks out of the redraw path without going stale when a shader is edited. Additionally: - Removes a module-level execute() and _disable_material_node() from LightEditor.py. Both had been dedented out of their classes and were unreachable; nothing referenced either name. - Fixes blender_manifest.toml, which still described a mesh display-type add-on and tagged itself "Mesh". That text is what Blender shows in the extensions list. Fixes #13 Co-Authored-By: Claude Opus 5 --- LightEditor.py | 87 ++++++++---------------- LightGroup.py | 154 ++++++++++++++++++++++++++++++++---------- blender_manifest.toml | 4 +- 3 files changed, 148 insertions(+), 97 deletions(-) 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/blender_manifest.toml b/blender_manifest.toml index 9d69db4..bda36bf 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -2,11 +2,11 @@ schema_version = "1.0.0" id = "LightEditor" version = "2.4.5" name = "Light Editor" -tagline = "Adds buttons to quickly switch between mesh display types" +tagline = "Edit, isolate and group lights, emissive meshes and the 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 From 6c00dff93bfd40f9db5bf70870504cfdb365a5db Mon Sep 17 00:00:00 2001 From: riouxr Date: Mon, 10 Aug 2026 14:04:25 -0400 Subject: [PATCH 2/3] Bump version to 2.4.6 Co-Authored-By: Claude Opus 5 --- __init__.py | 2 +- blender_manifest.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 bda36bf..6299079 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -1,6 +1,6 @@ schema_version = "1.0.0" id = "LightEditor" -version = "2.4.5" +version = "2.4.6" name = "Light Editor" tagline = "Edit, isolate and group lights, emissive meshes and the environment" maintainer = "Blender Bob" From 16150dd06f8bbfbd25893ee6367e23f07aec6b82 Mon Sep 17 00:00:00 2001 From: riouxr Date: Mon, 10 Aug 2026 14:05:36 -0400 Subject: [PATCH 3/3] Shorten tagline to Blender's 64-character manifest limit `blender --command extension validate` rejects the zip otherwise: key "tagline" invalid: a value no longer than 64 characters expected. Co-Authored-By: Claude Opus 5 --- blender_manifest.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blender_manifest.toml b/blender_manifest.toml index 6299079..9c957c6 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -2,7 +2,7 @@ schema_version = "1.0.0" id = "LightEditor" version = "2.4.6" name = "Light Editor" -tagline = "Edit, isolate and group lights, emissive meshes and the environment" +tagline = "Edit, isolate and group lights, emissive meshes and environment" maintainer = "Blender Bob" type = "add-on" website = "https://github.com/riouxr/Light-Editor"