diff --git a/engine/final.py b/engine/final.py index d31dc571..81e57701 100644 --- a/engine/final.py +++ b/engine/final.py @@ -36,7 +36,9 @@ def render(engine, depsgraph): _check_halt_conditions(engine, scene) - layer = depsgraph.view_layer_eval + # Use original view_layer instead of evaluated (evaluated doesn't work correctly with indirect_only_get) + view_layer_eval = depsgraph.view_layer_eval + layer = scene.original.view_layers.get(view_layer_eval.name) print('[Engine/Final] Rendering layer "%s"' % layer.name) diff --git a/engine/viewport.py b/engine/viewport.py index c8d264c5..850c2cf1 100644 --- a/engine/viewport.py +++ b/engine/viewport.py @@ -105,8 +105,14 @@ def view_update(engine, context, depsgraph, changes=None): print("=" * 50) print("[Engine/Viewport] New session") engine.exporter = export.Exporter() + + # Get original view_layer for indirect_only and holdout support + view_layer_eval = depsgraph.view_layer_eval + scene_orig = depsgraph.scene_eval.original + view_layer = scene_orig.view_layers.get(view_layer_eval.name) + engine.session = engine.exporter.create_session( - depsgraph, context, engine=engine + depsgraph, context, engine=engine, view_layer=view_layer ) # Start in separate thread to avoid blocking the UI engine.starting_session = True diff --git a/export/__init__.py b/export/__init__.py index da03e6f0..a6aac765 100644 --- a/export/__init__.py +++ b/export/__init__.py @@ -503,8 +503,12 @@ def _update_scene(self, depsgraph, context, changes, luxcore_scene): props.Set(self.camera_cache.props) if changes & Change.OBJECT: + # Get original view_layer from evaluated (evaluated doesn't work correctly with indirect_only_get) + view_layer_eval = depsgraph.view_layer_eval + scene_orig = depsgraph.scene_eval.original + view_layer = scene_orig.view_layers.get(view_layer_eval.name) self.object_cache2.update( - self, depsgraph, luxcore_scene, props, context + self, depsgraph, luxcore_scene, props, context, view_layer ) if changes & Change.MATERIAL: diff --git a/export/caches/object_cache.py b/export/caches/object_cache.py index a1ea746d..1b2eda2a 100644 --- a/export/caches/object_cache.py +++ b/export/caches/object_cache.py @@ -210,7 +210,7 @@ def get_material(obj, material_index, depsgraph): def export_material( - obj, material_index, exporter, depsgraph, is_viewport_render + obj, material_index, exporter, depsgraph, is_viewport_render, force_holdout=False ): mat = get_material(obj, material_index, depsgraph) @@ -220,7 +220,7 @@ def export_material( mat = mat.original lux_mat_name, mat_props = material.convert( - exporter, depsgraph, mat, is_viewport_render, obj.name + exporter, depsgraph, mat, is_viewport_render, obj.name, force_holdout ) node_tree = mat.luxcore.node_tree return lux_mat_name, mat_props, node_tree @@ -320,6 +320,24 @@ def first_run( is_viewport_render = bool(context) instances = {} + # Hybrid batching: Conditional + Smart + # Check once if scene uses indirect_only/holdout to avoid per-instance overhead + scene_uses_indirect_or_holdout = False + if view_layer: + def check_layer_coll(lc): + if lc.indirect_only or lc.holdout: + return True + for child in lc.children: + if check_layer_coll(child): + return True + return False + scene_uses_indirect_or_holdout = check_layer_coll(view_layer.layer_collection) + + if scene_uses_indirect_or_holdout: + print(f"[Export] Hybrid batching: Scene uses indirect_only/holdout - full smart batching") + else: + print(f"[Export] Hybrid batching: Scene clean - fast batching") + if engine: obj_count_estimate = max(1, get_obj_count_estimate(depsgraph)) else: @@ -340,6 +358,7 @@ def first_run( ) ) and obj.type in MESH_OBJECTS + # Smart batching: always allow batching, group by (mesh, visibility) later ): # This code is optimized for large amounts of duplis. Drawback is that objects generated from this # code can't be transformed later in a viewport render session (due to BlendLuxCore implementation @@ -351,10 +370,31 @@ def first_run( engine, obj.name, " (dupli)", index, obj_count_estimate ) + # Hybrid batching: Conditional + Smart + if scene_uses_indirect_or_holdout: + # Full smart batching: Calculate visibility and holdout per instance + instance_visible = utils.visible_to_camera(dg_obj_instance, is_viewport_render, view_layer) + + # Holdout overrides indirect_only + check_obj = dg_obj_instance.parent if dg_obj_instance.is_instance else obj + is_holdout = utils.is_holdout_object(check_obj.original, view_layer) + + if is_holdout: + instance_visible = True # Holdout needs to be visible to camera to cut hole + + # Key: (mesh_pointer, camerainvisible, is_holdout) + # Must distinguish holdout vs normal visible - they need different materials! + camerainvisible = not instance_visible + batch_key = (obj.original.as_pointer(), camerainvisible, is_holdout) + else: + # Fast batching: Simple key without per-instance overhead + # All instances assumed visible, no holdout/indirect_only checks + batch_key = (obj.original.as_pointer(), False, False) # (mesh, camerainvisible=False, is_holdout=False) + try: # The code in this try block is performance-critical, as it is # executed most often when exporting millions of instances. - duplis = instances[obj.original.as_pointer()] + duplis = instances[batch_key] # If duplis is None, then a non-exportable object like a curve with zero faces is being duplicated if duplis: obj_id = dg_obj_instance.object.original.luxcore.id @@ -393,12 +433,13 @@ def first_run( if exported_obj: # Note, the transformation matrix and object ID of this first instance is not added # to the duplication list, since it already exists in the scene - instances[obj.original.as_pointer()] = Duplis( + # Smart batching: Store by (mesh, visibility) key + instances[batch_key] = Duplis( exported_obj ) else: # Could not export the object, happens e.g. with curve objects with zero faces - instances[obj.original.as_pointer()] = None + instances[batch_key] = None else: # This code is for singular objects and for duplis that should be movable later in a viewport render if not utils.is_instance_visible( @@ -542,8 +583,10 @@ def _convert_obj( self.exported_hair[obj_key] = lux_shape if lux_shape: + # Check if object is in holdout layer collection + force_holdout = utils.is_holdout_object(obj.original, view_layer) lux_mat, mat_props, node_tree = export_material( - obj, 0, exporter, depsgraph, is_viewport_render + obj, 0, exporter, depsgraph, is_viewport_render, force_holdout ) scene_props.Set(mat_props) set_hair_props( @@ -645,8 +688,10 @@ def _convert_obj( self.exported_hair[psys_key] = lux_shape if lux_shape: + # Check if object is in holdout layer collection + force_holdout = utils.is_holdout_object(obj.original, view_layer) lux_mat, mat_props, node_tree = export_material( - obj, mat_index, exporter, depsgraph, is_viewport_render + obj, mat_index, exporter, depsgraph, is_viewport_render, force_holdout ) scene_props.Set(mat_props) set_hair_props( @@ -720,13 +765,18 @@ def _convert_mesh_obj( loaded_from_cache = False if exported_mesh: + # Check if object is in holdout layer collection (like Cycles) + # For instances, check the parent object (similar to visible_to_camera logic) + check_obj = dg_obj_instance.parent if dg_obj_instance.is_instance else obj + force_holdout = utils.is_holdout_object(check_obj.original, view_layer) + mat_names = [] for idx, (shape_name, mat_index) in enumerate( exported_mesh.mesh_definitions ): shape = shape_name lux_mat_name, mat_props, node_tree = export_material( - obj, mat_index, exporter, depsgraph, is_viewport_render + obj, mat_index, exporter, depsgraph, is_viewport_render, force_holdout ) scene_props.Set(mat_props) mat_names.append(lux_mat_name) @@ -744,14 +794,21 @@ def _convert_mesh_obj( obj_transform = transform.copy() if use_instancing else None obj_id = utils.make_object_id(dg_obj_instance) + visible = utils.visible_to_camera( + dg_obj_instance, is_viewport_render, view_layer + ) + + # Holdout overrides indirect_only - holdout needs object to be visible to camera + # to "cut a hole" in the film. In reflections/GI it will still be visible normally. + if force_holdout: + visible = True + return ExportedObject( obj_key, exported_mesh.mesh_definitions, mat_names, obj_transform, - utils.visible_to_camera( - dg_obj_instance, is_viewport_render, view_layer - ), + visible, obj_id, ) @@ -761,7 +818,9 @@ def diff(self, depsgraph): ) return depsgraph.id_type_updated("OBJECT") and not only_scene - def update(self, exporter, depsgraph, luxcore_scene, scene_props, context): + def update(self, exporter, depsgraph, luxcore_scene, scene_props, context, view_layer=None): + if view_layer is None: + view_layer = depsgraph.view_layer_eval is_viewport_render = bool(context) redefine_objs_with_these_mesh_keys = [] # Always instance in viewport so we can move objects around @@ -898,12 +957,17 @@ def update(self, exporter, depsgraph, luxcore_scene, scene_props, context): exported_obj.obj_id = obj_id updated = True - if exported_obj.visible_to_camera != utils.visible_to_camera( - dg_obj_instance, is_viewport_render - ): - exported_obj.visible_to_camera = utils.visible_to_camera( - dg_obj_instance, is_viewport_render - ) + visible = utils.visible_to_camera( + dg_obj_instance, is_viewport_render, view_layer + ) + + # Holdout overrides indirect_only + check_obj = dg_obj_instance.parent if dg_obj_instance.is_instance else dg_obj_instance.object + if utils.is_holdout_object(check_obj.original, view_layer): + visible = True + + if exported_obj.visible_to_camera != visible: + exported_obj.visible_to_camera = visible updated = True if updated: @@ -918,6 +982,7 @@ def update(self, exporter, depsgraph, luxcore_scene, scene_props, context): luxcore_scene, scene_props, is_viewport_render, + view_layer, ) # self._debug_info() diff --git a/export/cycles_node_reader.py b/export/cycles_node_reader.py index a425cf0e..070226b8 100644 --- a/export/cycles_node_reader.py +++ b/export/cycles_node_reader.py @@ -15,30 +15,48 @@ } -def convert(material, props, luxcore_name, obj_name=""): +def convert(material, props, luxcore_name, obj_name="", force_holdout=False): # print("Converting Cycles node tree of material", material.name_full) + # Note: luxcore_name already has "_holdout" suffix if force_holdout=True (added in material.py) + output = material.node_tree.get_output_node("CYCLES") if output is None: - return black(luxcore_name) + return black(luxcore_name, force_holdout) link = utils_node.get_link(output.inputs["Surface"]) if link is None: - return black(luxcore_name) + return black(luxcore_name, force_holdout) result = _node(link.from_node, link.from_socket, props, material, luxcore_name, obj_name) if result == ERROR_VALUE: - return black(luxcore_name) + return black(luxcore_name, force_holdout) assert result == luxcore_name + + # Override: If force_holdout, set holdout.enable flag + if force_holdout: + prefix = "scene.materials." + luxcore_name + "." + import pyluxcore + props.Set(pyluxcore.Property(prefix + "holdout.enable", True)) + return luxcore_name, props -def black(luxcore_name="__BLACK__"): +def black(luxcore_name="__BLACK__", force_holdout=False): props = pyluxcore.Properties() - props.SetFromString(""" - scene.materials.{mat_name}.type = matte - scene.materials.{mat_name}.kd = 0 - """.format(mat_name=luxcore_name)) + + if force_holdout: + props.SetFromString(""" + scene.materials.{mat_name}.type = matte + scene.materials.{mat_name}.kd = 0 + scene.materials.{mat_name}.holdout.enable = true + """.format(mat_name=luxcore_name)) + else: + props.SetFromString(""" + scene.materials.{mat_name}.type = matte + scene.materials.{mat_name}.kd = 0 + """.format(mat_name=luxcore_name)) + return luxcore_name, props diff --git a/export/material.py b/export/material.py index 01dbf09a..76569591 100644 --- a/export/material.py +++ b/export/material.py @@ -11,13 +11,17 @@ is_blender_5 = bpy.app.version[0] >= 5 # only test of Blender 5 for now -def convert(exporter, depsgraph, material, is_viewport_render, obj_name=""): +def convert(exporter, depsgraph, material, is_viewport_render, obj_name="", force_holdout=False): try: if material is None: return fallback() props = pyluxcore.Properties() luxcore_name = utils.get_luxcore_name(material, is_viewport_render) + + # If force_holdout, append suffix to create variant material + if force_holdout: + luxcore_name += "_holdout" node_tree = material.luxcore.node_tree # Try to use Cycles nodes on assets without LuxCore nodes, so the user doesn't have to @@ -32,17 +36,17 @@ def convert(exporter, depsgraph, material, is_viewport_render, obj_name=""): matusenodes = material.use_nodes if matusenodes and (material.luxcore.use_cycles_nodes or is_asset_without_lux_mat): - return cycles_node_reader.convert(material, props, luxcore_name, obj_name) + return cycles_node_reader.convert(material, props, luxcore_name, obj_name, force_holdout) if node_tree is None: LuxCoreErrorLog.add_warning(f'Material "{material.name}": Missing node tree', obj_name=obj_name) - return fallback(luxcore_name) + return fallback(luxcore_name, force_holdout) active_output = get_active_output(node_tree) if active_output is None: LuxCoreErrorLog.add_warning(f'Node tree "{node_tree.name}": Missing active output node', obj_name=obj_name) - return fallback(luxcore_name) + return fallback(luxcore_name, force_holdout) if _has_volumes_and_transparency(node_tree, active_output): msg = f'Material "{material.name}": Combining volumes and materials with opacity < 1 can lead to artifacts!' @@ -51,6 +55,17 @@ def convert(exporter, depsgraph, material, is_viewport_render, obj_name=""): # Now export the material node tree, starting at the output node active_output.export(exporter, depsgraph, props, luxcore_name) + # Override: If force_holdout, set holdout.enable flag + # Similar to how Cycles respects LayerCollection.holdout + if force_holdout: + import sys + sys.stderr.write(f"[MAT HOLDOUT] Setting holdout for {luxcore_name}\n") + sys.stderr.flush() + prefix = "scene.materials." + luxcore_name + "." + props.Set(pyluxcore.Property(prefix + "holdout.enable", True)) + sys.stderr.write(f"[MAT HOLDOUT] Props after Set: {props.GetSize()}\n") + sys.stderr.flush() + return luxcore_name, props except Exception as error: msg = f'Material "{material.name}": {error}' @@ -60,13 +75,24 @@ def convert(exporter, depsgraph, material, is_viewport_render, obj_name=""): return fallback() -def fallback(luxcore_name=GLOBAL_FALLBACK_MAT): +def fallback(luxcore_name=GLOBAL_FALLBACK_MAT, force_holdout=False): props = pyluxcore.Properties() - props.SetFromString(""" - scene.materials.{mat_name}.type = matte - scene.materials.{mat_name}.kd = 0.5 - """.format(mat_name=luxcore_name)) - return luxcore_name, props + + if force_holdout: + # If holdout is forced, create holdout material instead of matte + luxcore_name_with_suffix = luxcore_name if luxcore_name == GLOBAL_FALLBACK_MAT else luxcore_name + "_holdout" + props.SetFromString(""" + scene.materials.{mat_name}.type = matte + scene.materials.{mat_name}.kd = 0.5 + scene.materials.{mat_name}.holdout.enable = true + """.format(mat_name=luxcore_name_with_suffix)) + return luxcore_name_with_suffix, props + else: + props.SetFromString(""" + scene.materials.{mat_name}.type = matte + scene.materials.{mat_name}.kd = 0.5 + """.format(mat_name=luxcore_name)) + return luxcore_name, props def _has_volumes_and_transparency(node_tree, active_output): diff --git a/utils/__init__.py b/utils/__init__.py index d1c00909..4a850f12 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -1,5 +1,6 @@ """Various utilities, NOT REQUIRING OTHER SUBMODULES.""" + # DO NOT IMPORT ANY OF OTHER MODULES IN THIS MODULE AND ITS SUBMODULES # # This module should be importable without further dependence to other @@ -18,8 +19,6 @@ import itertools from os.path import basename, dirname import tomllib -from .. import __package__ as base_package -from .. import __file__ as base_package_path _needs_reload = "bpy" in locals() import bpy @@ -38,29 +37,23 @@ MESH_OBJECTS = {"MESH", "CURVES", "SURFACE", "META", "FONT"} EXPORTABLE_OBJECTS = MESH_OBJECTS | {"LIGHT"} -NON_DEFORMING_MODIFIERS = { - "COLLISION", - "PARTICLE_INSTANCE", - "PARTICLE_SYSTEM", - "SMOKE", -} +NON_DEFORMING_MODIFIERS = {"COLLISION", "PARTICLE_INSTANCE", "PARTICLE_SYSTEM", "SMOKE"} def sanitize_luxcore_name(string): - """ This is just a regex that removes non-allowed characters. - - Do NOT use this function to create a luxcore name for an - object/material/etc.! Use the function get_luxcore_name() instead. + """ + Do NOT use this function to create a luxcore name for an object/material/etc.! + Use the function get_luxcore_name() instead. + This is just a regex that removes non-allowed characters. """ return re.sub("[^_0-9a-zA-Z]+", "__", string) def make_key(datablock): - # We use the memory address as key, e.g. to track materials or objects even - # when they are renamed during viewport render. - # Note that the memory address changes on undo/redo, but in this case the - # viewport render is stopped and re-started anyway, so it should not be a - # problem. + # We use the memory address as key, e.g. to track materials or objects even when they are + # renamed during viewport render. + # Note that the memory address changes on undo/redo, but in this case the viewport render + # is stopped and re-started anyway, so it should not be a problem. assert isinstance(datablock, bpy.types.ID) return str(datablock.original.as_pointer()) @@ -132,7 +125,7 @@ def make_object_id(dg_obj_instance): if dg_obj_instance.is_instance: # random_id seems to be a 4-Byte integer in range -0xffffffff to 0xffffffff. - return dg_obj_instance.random_id & 0xFFFFFFFE + return dg_obj_instance.random_id & 0xfffffffe key = dg_obj_instance.object.original.name @@ -142,18 +135,16 @@ def make_object_id(dg_obj_instance): as_int = int.from_bytes(digest, byteorder="little") # Truncate to 4 bytes because LuxCore uses unsigned int for the object ID. # Make sure it's not exactly 0xffffffff because that's LuxCore's Null index for object IDs. - return min(as_int & 0xFFFFFFFF, 0xFFFFFFFF - 1) + return min(as_int & 0xffffffff, 0xffffffff - 1) def list_to_matrix(lst): - return mathutils.Matrix( - [ - lst[0:4], - lst[4:8], - lst[8:12], - lst[12:16], - ] - ) + return mathutils.Matrix([ + lst[0:4], + lst[4:8], + lst[8:12], + lst[12:16], + ]) def calc_filmsize_raw(scene, context=None): @@ -172,32 +163,22 @@ def calc_filmsize_raw(scene, context=None): def calc_filmsize(scene, context=None): render = scene.render - border_min_x, border_max_x, border_min_y, border_max_y = ( - calc_blender_border(scene, context) - ) + border_min_x, border_max_x, border_min_y, border_max_y = calc_blender_border(scene, context) width_raw, height_raw = calc_filmsize_raw(scene, context) - + if context: - # Viewport render + # Viewport render width = width_raw height = height_raw - if context.region_data.view_perspective in ("ORTHO", "PERSP"): - width = int(width_raw * border_max_x) - int( - width_raw * border_min_x - ) - height = int(height_raw * border_max_y) - int( - height_raw * border_min_y - ) + if context.region_data.view_perspective in ("ORTHO", "PERSP"): + width = int(width_raw * border_max_x) - int(width_raw * border_min_x) + height = int(height_raw * border_max_y) - int(height_raw * border_min_y) else: # Camera viewport - zoom = 0.25 * ( - (math.sqrt(2) + context.region_data.view_camera_zoom / 50) ** 2 - ) - aspectratio, aspect_x, aspect_y = calc_aspect( - render.resolution_x * render.pixel_aspect_x, - render.resolution_y * render.pixel_aspect_y, - scene.camera.data.sensor_fit, - ) + zoom = 0.25 * ((math.sqrt(2) + context.region_data.view_camera_zoom / 50) ** 2) + aspectratio, aspect_x, aspect_y = calc_aspect(render.resolution_x * render.pixel_aspect_x, + render.resolution_y * render.pixel_aspect_y, + scene.camera.data.sensor_fit) if render.use_border: base = zoom @@ -208,12 +189,8 @@ def calc_filmsize(scene, context=None): elif scene.camera.data.sensor_fit == "VERTICAL": base *= height - width = int(base * aspect_x * border_max_x) - int( - base * aspect_x * border_min_x - ) - height = int(base * aspect_y * border_max_y) - int( - base * aspect_y * border_min_y - ) + width = int(base * aspect_x * border_max_x) - int(base * aspect_x * border_min_x) + height = int(base * aspect_y * border_max_y) - int(base * aspect_y * border_min_y) pixel_size = int(scene.luxcore.viewport.pixel_size) width //= pixel_size @@ -221,9 +198,7 @@ def calc_filmsize(scene, context=None): else: # Final render width = int(width_raw * border_max_x) - int(width_raw * border_min_x) - height = int(height_raw * border_max_y) - int( - height_raw * border_min_y - ) + height = int(height_raw * border_max_y) - int(height_raw * border_min_y) # Make sure width and height are never zero # (can e.g. happen if you have a small border in camera viewport and zoom out a lot) @@ -255,12 +230,7 @@ def calc_blender_border(scene, context=None): use_border = render.use_border if use_border: - blender_border = [ - border_min_x, - border_max_x, - border_min_y, - border_max_y, - ] + blender_border = [border_min_x, border_max_x, border_min_y, border_max_y] # Round all values to avoid running into problems later # when a value is for example 0.699999988079071 blender_border = [round(value, 6) for value in blender_border] @@ -276,85 +246,77 @@ def calc_screenwindow(zoom, shift_x, shift_y, scene, context=None): render = scene.render width_raw, height_raw = calc_filmsize_raw(scene, context) - border_min_x, border_max_x, border_min_y, border_max_y = ( - calc_blender_border(scene, context) - ) + border_min_x, border_max_x, border_min_y, border_max_y = calc_blender_border(scene, context) # Following: Black Magic scale = 1 offset_x = 0 offset_y = 0 - + if context: # Viewport rendering if context.region_data.view_perspective == "CAMERA": # Camera view offset_x, offset_y = context.region_data.view_camera_offset - - if scene.camera and scene.camera.data.type == "ORTHO": + + if scene.camera and scene.camera.data.type == "ORTHO": scale = 0.5 * scene.camera.data.ortho_scale - + if render.use_border: offset_x = 0 offset_y = 0 zoom = 1 - aspectratio, xaspect, yaspect = calc_aspect( - render.resolution_x * render.pixel_aspect_x, - render.resolution_y * render.pixel_aspect_y, - scene.camera.data.sensor_fit, - ) - + aspectratio, xaspect, yaspect = calc_aspect(render.resolution_x * render.pixel_aspect_x, + render.resolution_y * render.pixel_aspect_y, + scene.camera.data.sensor_fit) + if scene.camera and scene.camera.data.type == "ORTHO": # zoom = scale * world_scale zoom = scale - + else: # No border - aspectratio, xaspect, yaspect = calc_aspect( - width_raw, height_raw, scene.camera.data.sensor_fit - ) - + aspectratio, xaspect, yaspect = calc_aspect(width_raw, height_raw, scene.camera.data.sensor_fit) + else: # Normal viewport aspectratio, xaspect, yaspect = calc_aspect(width_raw, height_raw) else: # Final rendering - aspectratio, xaspect, yaspect = calc_aspect( - render.resolution_x * render.pixel_aspect_x, - render.resolution_y * render.pixel_aspect_y, - scene.camera.data.sensor_fit, - ) - - if scene.camera and scene.camera.data.type == "ORTHO": - scale = 0.5 * scene.camera.data.ortho_scale + aspectratio, xaspect, yaspect = calc_aspect(render.resolution_x * render.pixel_aspect_x, + render.resolution_y * render.pixel_aspect_y, + scene.camera.data.sensor_fit) + + if scene.camera and scene.camera.data.type == "ORTHO": + scale = 0.5 * scene.camera.data.ortho_scale dx = scale * 2 * (shift_x + 2 * xaspect * offset_x) dy = scale * 2 * (shift_y + 2 * yaspect * offset_y) screenwindow = [ - -xaspect * zoom + dx, - +xaspect * zoom + dx, - -yaspect * zoom + dy, - +yaspect * zoom + dy, + -xaspect*zoom + dx, + xaspect*zoom + dx, + -yaspect*zoom + dy, + yaspect*zoom + dy ] - + screenwindow = [ screenwindow[0] * (1 - border_min_x) + screenwindow[1] * border_min_x, screenwindow[0] * (1 - border_max_x) + screenwindow[1] * border_max_x, screenwindow[2] * (1 - border_min_y) + screenwindow[3] * border_min_y, - screenwindow[2] * (1 - border_max_y) + screenwindow[3] * border_max_y, + screenwindow[2] * (1 - border_max_y) + screenwindow[3] * border_max_y ] - + return screenwindow def calc_aspect(width, height, fit="AUTO"): horizontal_fit = False if fit == "AUTO": - horizontal_fit = width > height + horizontal_fit = (width > height) elif fit == "HORIZONTAL": horizontal_fit = True - + if horizontal_fit: aspect = height / width xaspect = 1 @@ -363,7 +325,7 @@ def calc_aspect(width, height, fit="AUTO"): aspect = width / height xaspect = aspect yaspect = 1 - + return aspect, xaspect, yaspect @@ -384,14 +346,12 @@ def find_active_vertex_color_layer(vertex_colors): def is_instance_visible(dg_obj_instance, obj, context): if not (dg_obj_instance.show_self or dg_obj_instance.show_particles): return False - + if context: - viewport_vis_obj = ( - dg_obj_instance.parent if dg_obj_instance.parent else obj - ) + viewport_vis_obj = dg_obj_instance.parent if dg_obj_instance.parent else obj if not viewport_vis_obj.visible_in_viewport_get(context.space_data): return False - + return is_obj_visible(obj) @@ -399,9 +359,7 @@ def is_obj_visible(obj): if obj.luxcore.exclude_from_render: return False - if obj.type not in EXPORTABLE_OBJECTS and ( - obj.data == None or obj.data.rna_type.name != "Hair Curves" - ): + if obj.type not in EXPORTABLE_OBJECTS and (obj.data == None or obj.data.rna_type.name != 'Hair Curves'): return False # Do not export the object if it's made completely invisible through Cycles settings @@ -410,44 +368,54 @@ def is_obj_visible(obj): def is_obj_visible_in_cycles(obj): - return any( - ( - obj.visible_camera, - obj.visible_diffuse, - obj.visible_glossy, - obj.visible_transmission, - obj.visible_volume_scatter, - obj.visible_shadow, - ) - ) + return any((obj.visible_camera, obj.visible_diffuse, obj.visible_glossy, obj.visible_transmission, obj.visible_volume_scatter, obj.visible_shadow)) + def visible_to_camera(dg_obj_instance, is_viewport_render, view_layer=None): - obj = ( - dg_obj_instance.parent - if dg_obj_instance.is_instance - else dg_obj_instance.object - ) + obj = dg_obj_instance.parent if dg_obj_instance.is_instance else dg_obj_instance.object if not obj.luxcore.visible_to_camera: return False - if is_viewport_render: - obj = obj.original + # Always use original object for indirect_only_get() because evaluated objects + # don't have collection membership data (users_collection is empty) + obj = obj.original return not obj.indirect_only_get(view_layer=view_layer) +def is_holdout_object(obj, view_layer=None): + """ + Check if object is in any LayerCollection with holdout=True. + Similar to indirect_only but for holdout. + Works like Cycles - respects LayerCollection.holdout settings. + """ + if not view_layer: + return False + + # Use original object - evaluated objects don't have collection membership + obj = obj.original if hasattr(obj, 'original') else obj + + # Get all collections this object belongs to + obj_collections = set(obj.users_collection) + + # Recursively check layer collections + def check_layer_collection(layer_coll): + if layer_coll.collection in obj_collections and layer_coll.holdout: + return True + for child in layer_coll.children: + if check_layer_collection(child): + return True + return False + + return check_layer_collection(view_layer.layer_collection) + + def get_theme(context): current_theme_name = context.preferences.themes.items()[0][0] return context.preferences.themes[current_theme_name] -def get_abspath( - path, - library=None, - must_exist=False, - must_be_existing_file=False, - must_be_existing_dir=False, -): - """library: The library this path is from.""" +def get_abspath(path, library=None, must_exist=False, must_be_existing_file=False, must_be_existing_dir=False): + """ library: The library this path is from. """ assert not (must_be_existing_file and must_be_existing_dir) abspath = bpy.path.abspath(path, library=library) @@ -472,11 +440,7 @@ def absorption_at_depth_scaled(abs_col, depth, scale=1): scaled = [0, 0, 0] for i in range(len(abs_col)): v = float(abs_col[i]) - scaled[i] = ( - (-math.log(max([v, 1e-30])) / depth) - * scale - * (v == 1.0 and -1 or 1) - ) + scaled[i] = (-math.log(max([v, 1e-30])) / depth) * scale * (v == 1.0 and -1 or 1) return scaled @@ -489,7 +453,7 @@ def all_elems_equal(_list): def use_obj_motion_blur(obj, scene): - """Check if this particular object will be exported with motion blur""" + """ Check if this particular object will be exported with motion blur """ cam = scene.camera if cam is None: @@ -502,9 +466,7 @@ def use_obj_motion_blur(obj, scene): def has_deforming_modifiers(obj): - return any( - [mod.type not in NON_DEFORMING_MODIFIERS for mod in obj.modifiers] - ) + return any([mod.type not in NON_DEFORMING_MODIFIERS for mod in obj.modifiers]) def can_share_mesh(obj): @@ -546,26 +508,17 @@ def using_filesaver(is_viewport_render, scene): def using_bidir_in_viewport(scene): - return ( - scene.luxcore.config.engine == "BIDIR" - and scene.luxcore.viewport.use_bidir - ) + return scene.luxcore.config.engine == "BIDIR" and scene.luxcore.viewport.use_bidir def using_hybridbackforward(scene): config = scene.luxcore.config - return ( - config.engine == "PATH" - and not config.use_tiles - and config.path.hybridbackforward_enable - ) + return (config.engine == "PATH" and not config.use_tiles + and config.path.hybridbackforward_enable) def using_hybridbackforward_in_viewport(scene): - return ( - using_hybridbackforward(scene) - and scene.luxcore.viewport.add_light_tracing - ) + return using_hybridbackforward(scene) and scene.luxcore.viewport.add_light_tracing def using_photongi_debug_mode(is_viewport_render, scene): @@ -608,12 +561,7 @@ def use_two_tiled_passes(scene): config = scene.luxcore.config denoiser = scene.luxcore.denoiser using_tilepath = config.engine == "PATH" and config.use_tiles - return ( - denoiser.enabled - and denoiser.type == "BCD" - and using_tilepath - and not config.tile.multipass_enable - ) + return denoiser.enabled and denoiser.type == "BCD" and using_tilepath and not config.tile.multipass_enable def image_sequence_resolve_all(image): @@ -627,7 +575,6 @@ def image_sequence_resolve_all(image): filename_noext, ext = os.path.splitext(filename) from string import digits - if isinstance(filepath, bytes): digits = digits.encode() filename_nodigits = filename_noext.rstrip(digits) @@ -638,20 +585,17 @@ def image_sequence_resolve_all(image): indexed_filepaths = [] for f in os.scandir(basedir): - index_str = f.name[len(filename_nodigits) : -len(ext) if ext else -1] - - if ( - f.is_file() - and f.name.startswith(filename_nodigits) - and f.name.endswith(ext) - and index_str.isdigit() - ): + index_str = f.name[len(filename_nodigits):-len(ext) if ext else -1] + + if (f.is_file() + and f.name.startswith(filename_nodigits) + and f.name.endswith(ext) + and index_str.isdigit()): elem = (int(index_str), f.path) indexed_filepaths.append(elem) return sorted(indexed_filepaths, key=lambda elem: elem[0]) - def openVDB_sequence_resolve_all(file): filepath = get_abspath(file) basedir, filename = os.path.split(filepath) @@ -661,11 +605,11 @@ def openVDB_sequence_resolve_all(file): # in case of the Blender cache files the structure is name_frame_index.ext # Test if the filename structure matches the Blender nomenclature - matchstr = r"(.*)_([0-9]{6})_([0-9]{2})" + matchstr = r'(.*)_([0-9]{6})_([0-9]{2})' matchObj = re.match(matchstr, filename_noext) if not matchObj: - matchstr = r"(\D*)([0-9]+)" + matchstr = r'(\D*)([0-9]+)' # Test if the filename structure matches a general sequence structure matchObj = re.match(matchstr, filename_noext) @@ -696,18 +640,14 @@ def get_blendfile_name(): return os.path.splitext(basename)[0] # remove ".blend" -def get_persistent_cache_file_path( - file_path, save_or_overwrite, is_viewport_render, scene -): +def get_persistent_cache_file_path(file_path, save_or_overwrite, is_viewport_render, scene): file_path_abs = get_abspath(file_path, library=scene.library) if not os.path.isfile(file_path_abs) and not save_or_overwrite: # Do not save the cache file return "" else: - if using_filesaver(is_viewport_render, scene) and file_path.startswith( - "//" - ): + if using_filesaver(is_viewport_render, scene) and file_path.startswith("//"): # It is a relative path and we are using filesaver - don't make it # an absolute path, just strip the leading "//" return file_path[2:] @@ -728,29 +668,28 @@ def count_index(func): A decorator that increments an index each time the decorated function is called. It also passes the index as a keyword argument to the function. """ - def wrapper(*args, **kwargs): kwargs["index"] = wrapper.index wrapper.index += 1 return func(*args, **kwargs) - wrapper.index = 0 return wrapper -def get_module_id(): - """Get module name (bl_idname) for current addon.""" - return base_package - +ADDON_NAME = "BlendLuxCore" -def get_module_path(): - """Get absolute path to module.""" - return pathlib.Path(base_package_path).parent +def get_module_name(): + """Get module name (bl_idname) for current addon.""" + components = __package__.split('.') + prefix = list(itertools.takewhile(lambda x: x != ADDON_NAME, components)) + prefix.append(ADDON_NAME) + return '.'.join(prefix) def get_addon_preferences(context): """Get addon_preferences handle.""" - return context.preferences.addons[base_package].preferences + addon_name = get_module_name() + return context.preferences.addons[addon_name].preferences def get_version_string(): @@ -759,22 +698,21 @@ def get_version_string(): Load version information from blender_manifest.toml, which replaces the old "bl_info" dictionary. """ - manifest_path = get_module_path() / "blender_manifest.toml" + root_path = pathlib.Path(__file__).parent.parent.resolve() + manifest_path = root_path / "blender_manifest.toml" with open(manifest_path, "rb") as f: manifest_data = tomllib.load(f) - return manifest_data["version"] - + version_string = manifest_data["version"] + return version_string def get_user_dir(name): """Get a user writeable directory, create it if not existing.""" - print(f"[BLC] Module id: {get_module_id()}") + print(f"[BLC] Module name: {get_module_name()}") return pathlib.Path( - bpy.utils.extension_path_user(get_module_id(), path=name, create=True) + bpy.utils.extension_path_user(get_module_name(), path=name, create=True) ) - -VERBOSE_REGISTER = False # Set to true to see per-class info (debug) - +VERBOSE_REGISTER = False # Set to true to see per-class info def register_module(module_name, classes, submodules=[]): """Register a module in Blender.