From b928902e3ca5cc3f1fbd1dd1143c54233ecad1c8 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sat, 13 Jun 2026 10:54:50 -0500 Subject: [PATCH] Add per-drawing render overrides (status_render module) Adds a new "Render Overrides" panel under the active drawing that applies render effects to IFC elements selected by a filter. Each drawing holds a list of rules; each rule has its own filter (reusing the shared Search filter system) plus exposure, gamma, and transparency. Effects use the mechanism that can actually show them: - Exposure/gamma: compositor nodes masked per rule via Cryptomatte, built just before a render and torn down after. Cryptomatte (not the Object Index pass) is used because EEVEE Next always renders Object Index as 0 (Blender #121690); it works in both EEVEE and Cycles. - Transparency: a temporary Transparent-BSDF material applied to the matched objects so the geometry behind shows through. Because it is a real material it also shows live in the Rendered viewport (WYSIWYG), and is restored after the render / on toggle off / before save. The single enable toggle drives both render and live viewport preview. Switching the active drawing (msgbus on scene.camera) re-syncs the preview and auto-enables a drawing that already has rules. Editing a rule's filter query (including clearing it) updates the preview immediately. Rules are persisted to the IFC model at EPset_Drawing.RenderOverrides (JSON, declared in the pset template as IfcText), so they travel with the drawing: written on add/remove and on save, read back on file load and IFC import (via data.refresh / refresh_ui_data, since load_post fires before the import creates the cameras). The override only runs where the compositor does (F12 and Default-render drawing underlays); the panel disables the toggle when the applied CurrentShadingStyle is not a Default render type. Files: - bim/module/status_render/: new module (operator, prop, ui, data, NOTES) - bim/__init__.py: register the module - bim/data/pset/EPset_Drawing.ifc: add RenderOverrides property template - tool/search.py: status_render filter resolver, on_filter_query_edited dispatch, graceful empty-query handling - bim/module/search/operator.py: notify on_filter_query_edited after edits Generated with the assistance of an AI coding tool. --- src/bonsai/bonsai/bim/__init__.py | 1 + .../bonsai/bim/data/pset/EPset_Drawing.ifc | 3 +- .../bonsai/bim/module/search/operator.py | 2 + .../bonsai/bim/module/status_render/NOTES.md | 120 ++++ .../bim/module/status_render/__init__.py | 40 ++ .../bonsai/bim/module/status_render/data.py | 29 + .../bim/module/status_render/operator.py | 522 ++++++++++++++++++ .../bonsai/bim/module/status_render/prop.py | 94 ++++ .../bonsai/bim/module/status_render/ui.py | 114 ++++ src/bonsai/bonsai/tool/search.py | 16 + 10 files changed, 940 insertions(+), 1 deletion(-) create mode 100644 src/bonsai/bonsai/bim/module/status_render/NOTES.md create mode 100644 src/bonsai/bonsai/bim/module/status_render/__init__.py create mode 100644 src/bonsai/bonsai/bim/module/status_render/data.py create mode 100644 src/bonsai/bonsai/bim/module/status_render/operator.py create mode 100644 src/bonsai/bonsai/bim/module/status_render/prop.py create mode 100644 src/bonsai/bonsai/bim/module/status_render/ui.py diff --git a/src/bonsai/bonsai/bim/__init__.py b/src/bonsai/bonsai/bim/__init__.py index 4556a379d0..4efeb930fd 100644 --- a/src/bonsai/bonsai/bim/__init__.py +++ b/src/bonsai/bonsai/bim/__init__.py @@ -91,6 +91,7 @@ modules = { "light": None, "alignment": None, "clip_box": None, + "status_render": None, # Uncomment this line to enable loading of the demo module. Happy hacking! # The name "demo" must correlate to a folder name in `bim/module/`. # "demo": None, diff --git a/src/bonsai/bonsai/bim/data/pset/EPset_Drawing.ifc b/src/bonsai/bonsai/bim/data/pset/EPset_Drawing.ifc index 114b766ff8..29af1b5d01 100644 --- a/src/bonsai/bonsai/bim/data/pset/EPset_Drawing.ifc +++ b/src/bonsai/bonsai/bim/data/pset/EPset_Drawing.ifc @@ -5,7 +5,7 @@ FILE_NAME('EPset_Drawing.ifc','2020-01-01T00:00:00',$,$,'EPset_Drawing','EPset_D FILE_SCHEMA(('IFC4')); ENDSEC; DATA; -#1=IFCPROPERTYSETTEMPLATE('2JhNIvqZrFnAgxfhK0XVQX',$,'EPset_Drawing','',.PSET_OCCURRENCEDRIVEN.,'IfcAnnotation/DRAWING',(#23,#22,#27,#24,#29,#30,#19,#12,#26,#9,#8,#7,#6,#4,#18,#11,#5,#20,#25,#14,#10,#17,#28,#16,#3,#21,#13,#15,#2)); +#1=IFCPROPERTYSETTEMPLATE('2JhNIvqZrFnAgxfhK0XVQX',$,'EPset_Drawing','',.PSET_OCCURRENCEDRIVEN.,'IfcAnnotation/DRAWING',(#23,#22,#27,#24,#29,#30,#19,#12,#26,#9,#8,#7,#6,#4,#18,#11,#5,#20,#25,#14,#10,#17,#28,#16,#3,#21,#13,#15,#2,#31)); #2=IFCSIMPLEPROPERTYTEMPLATE('23JavTMk98ZxXhrUEnjAcf',$,'TargetView','',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.); #3=IFCSIMPLEPROPERTYTEMPLATE('1yVWUt5H9DAOuu0OaMMLpe',$,'Scale','The scale of this drawing represented as a numerator and denominator, such as 1/100',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.); #4=IFCSIMPLEPROPERTYTEMPLATE('3gsuPBtU93b8f0gg1pjkq6',$,'HumanScale','The scale of this drawing in human readable format, such as 1:100',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.); @@ -35,5 +35,6 @@ DATA; #28=IFCSIMPLEPROPERTYTEMPLATE('1YSnFzurrEyRNtoLdmmddP',$,'BringToFront','The objects with these SVG classes will render in front of all other objects.Ex: IfcBeam, IfcColumn',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.); #29=IFCSIMPLEPROPERTYTEMPLATE('0lP6Y8q9v2QhDnR4sT7uVx',$,'PerspectiveShiftX','Horizontal perspective camera shift stored as drawing metadata using Blender camera shift units.',.P_SINGLEVALUE.,'IfcReal',$,$,$,$,$,.READWRITE.); #30=IFCSIMPLEPROPERTYTEMPLATE('2mR8b1NcW5EoFyG7hJ9kLp',$,'PerspectiveShiftY','Vertical perspective camera shift stored as drawing metadata using Blender camera shift units.',.P_SINGLEVALUE.,'IfcReal',$,$,$,$,$,.READWRITE.); +#31=IFCSIMPLEPROPERTYTEMPLATE('2dFtucOLv6oBy6wzom$LMq',$,'RenderOverrides','JSON list of Bonsai render override rules (selection filter plus exposure/gamma/transparency) applied to this drawing on render.',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.); ENDSEC; END-ISO-10303-21; diff --git a/src/bonsai/bonsai/bim/module/search/operator.py b/src/bonsai/bonsai/bim/module/search/operator.py index f55dcf31b7..9bc549493c 100644 --- a/src/bonsai/bonsai/bim/module/search/operator.py +++ b/src/bonsai/bonsai/bim/module/search/operator.py @@ -648,6 +648,7 @@ class ApplyFilterFromText(Operator): filter_structure = json_data.get("filter_structure", []) filter_groups = tool.Search.get_filter_groups(module) tool.Search.import_filter_structure(filter_structure, filter_groups) + tool.Search.on_filter_query_edited(module, context) self.report({"INFO"}, "Filter configuration applied successfully") if len(context.window_manager.windows) > 1: @@ -681,6 +682,7 @@ class EditFilterQuery(Operator, tool.Ifc.Operator): tool.Search.import_filter_query(self.query, filter_groups) except: return + tool.Search.on_filter_query_edited(module, context) def draw(self, context): diff --git a/src/bonsai/bonsai/bim/module/status_render/NOTES.md b/src/bonsai/bonsai/bim/module/status_render/NOTES.md new file mode 100644 index 0000000000..41c273226c --- /dev/null +++ b/src/bonsai/bonsai/bim/module/status_render/NOTES.md @@ -0,0 +1,120 @@ +# status_render — developer notes + +Per-drawing render overrides: select IFC elements with a filter and apply render +effects (exposure, gamma, transparency) to them. Aimed at drawing underlays +("existing faded", "demolished ghosted") but also works for plain F12 renders. + +## Two-layer architecture + +Effects live where they can actually be shown, both driven by the one +**Enable Render Overrides** toggle: + +| Layer | Effect | Mechanism | Applied | Visible | +|-------|--------|-----------|---------|---------| +| Live material | Transparency | Temp material copy with a Transparent BSDF mixed into the surface (`surface_render_method = "BLENDED"`) | Persistently while enabled | Rendered viewport **and** render | +| Render compositor | Exposure / gamma | Cryptomatte object matte → Exposure/Gamma → Mix, per rule, chained | Built per render, removed after | Render only | + +Transparency is a *real* material change so the geometry behind shows through +(compositing can't reveal occluded geometry after an opaque render). Because it's +a material, EEVEE Next renders it live in the viewport — that's the WYSIWYG path. + +Exposure/gamma stay in the compositor because that's the faithful colour-management +tonemap. They are render-only: the viewport compositor can't read render passes +(Cryptomatte), so per-object masking isn't available there. + +## Why Cryptomatte (not the Object Index pass) + +EEVEE Next always renders the Object Index pass as 0 (Blender bug #121690). +Cryptomatte works in both EEVEE and Cycles. Note: node-socket string subscripting +keys by `.identifier`, not `.name` — relevant if ever touching pass sockets again. + +## Apply / restore seam + +- `sync_live_effects(scene)` — single source of truth for live material state: + restore all temp materials, then apply transparency for the active camera's + enabled rules. Idempotent; safe to call any time. +- `build_compositor(scene, props)` / `clear_compositor(scene)` — render-only colour + nodes; clearing leaves live materials untouched. + +## Lifecycle / handlers (operator.py) + +- enable toggle + transparency slider (prop `update=`), add/remove rule → `sync_live_effects` +- `render_init` → sync materials, then `build_compositor` +- `render_complete` / `render_cancel` → `clear_compositor` (materials persist) +- `save_pre` → `restore_transparency` (never bake temp materials into the .blend) +- `save_post`, `load_post` → `sync_live_effects` (re-apply the live preview) + +## Storage / gating + +- Per-drawing: stored on the camera datablock as `Camera.BIMRenderOverrideProperties` + (self-contained — the core drawing module does not depend on this one). +- Per-rule filters reuse the shared Search system; resolver keys are + `status_render_{rule_index}` (see `tool/search.py`). +- The render path is the compositor, which only runs for F12 and `render.render()` + drawing underlays. Viewport/OpenGL drawings bypass it, so the panel disables the + toggle when the *applied* shading style (`EPset_Drawing.CurrentShadingStyle`) is + not "Default" render type. The drawing also needs an underlay for the override to + appear in it. + +## IFC persistence (coarse auto-sync) + +Rules are mirrored into the IFC model so they travel with the drawing, not just the +`.blend`. The camera props remain the working/edit copy; IFC is the source of truth. + +- **Property:** `EPset_Drawing.RenderOverrides` — a JSON list of + `{name, query, exposure, gamma, transparency}` per rule. `query` uses the same + `tool.Search.export/import_filter_query` serialization as the drawing + Include/Exclude filters. +- **Write (coarse):** on add/remove rule, and on `save_pre` for every drawing camera + (`save_rules_to_ifc`). Deliberately *not* on every slider tick (would spam IFC). + Field edits (exposure/gamma/transparency/filter) therefore persist at the next + add/remove or at the next save. +- **Read:** rules are pulled from the pset by `ensure_rules_loaded` (guarded: it only + loads when the camera props are empty, so it never clobbers in-session edits). This + runs from: + - `data.refresh()` — called by `bonsai.bim.handler.refresh_ui_data()`, which fires + at the end of `load_project_elements`. **This is the path that handles opening an + IFC into a fresh session** — `load_post` fires *before* the IFC import creates the + drawing cameras, so it can't see them. + - `load_post` — for reopening a `.blend` (cameras already exist; usually a no-op + since their props came from the file). + - `render_init` — belt-and-braces before a render. + `deserialize_rules` uses direct id-property writes so it doesn't fire the + live-preview update per rule. +- **Not persisted to IFC:** the `enabled` toggle (session/working state, lives in the + `.blend` only). So rules are portable across `.blend` files; whether the preview is + currently *on* is not. + +Caveats: +- The pset writes are raw `ifcopenshell.api.pset.edit_pset` calls (matching + `edit_element_filter`), not wrapped in `tool.Ifc.Operator`, so they are not in + Bonsai's IFC undo stack and may not flip the "unsaved IFC" indicator. They are + rewritten from the camera props on the next save, so the stored JSON self-heals. +- Saving the IFC *without* a `.blend` save after a slider/filter edit may miss that + edit (it's flushed in `save_pre`, a `.blend`-save handler). Add/remove a rule or + save the `.blend` to flush. + +## Active-drawing switch + +Switching the active drawing reassigns `scene.camera` but fires no handler. A msgbus +subscription on `(bpy.types.Scene, "camera")` (`subscribe_camera_change`) catches it +and re-syncs the live materials (the previous drawing's transparency is removed, the +new drawing's applied). The notify defers via `bpy.app.timers` so material datablock +edits happen outside the msgbus notification context. msgbus is cleared on file load, +so we re-subscribe in `load_post` (and on register). + +## Filter edits + +Editing a rule's filter via `bim.edit_filter_query` (or `bim.apply_filter_from_text`) +re-syncs the live preview: those operators call `tool.Search.on_filter_query_edited`, +which dispatches to `operator.sync_live_effects` for `status_render_*` modules. This +keeps the coupling in `tool.Search` (which already special-cases `status_render` in +`get_filter_groups`), leaving the shared search operators generic. + +## Deferred robustness (TODO when the concept proves out) + +- **Undo** can desync live state — re-toggle to refresh. +- Transparency slider re-syncs on every increment (creates/removes temp materials); + fine for now, could debounce on large scenes. +- Exposure/gamma in the viewport would require re-expressing them as material tweaks + (approximate); intentionally not done — they stay faithful + render-only. diff --git a/src/bonsai/bonsai/bim/module/status_render/__init__.py b/src/bonsai/bonsai/bim/module/status_render/__init__.py new file mode 100644 index 0000000000..f3163588c9 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/status_render/__init__.py @@ -0,0 +1,40 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2020, 2021 Dion Moult +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . + +import bpy + +from . import data, operator, prop, ui # noqa: F401 (data is accessed via refresh_ui_data) + +classes = ( + operator.AddRenderOverrideRule, + operator.RemoveRenderOverrideRule, + prop.BIMRenderOverrideRule, + prop.BIMRenderOverrideProperties, + ui.BIM_UL_render_override_rules, + ui.BIM_PT_status_render, +) + + +def register(): + bpy.types.Camera.BIMRenderOverrideProperties = bpy.props.PointerProperty(type=prop.BIMRenderOverrideProperties) + operator.register_handlers() + + +def unregister(): + operator.unregister_handlers() + del bpy.types.Camera.BIMRenderOverrideProperties diff --git a/src/bonsai/bonsai/bim/module/status_render/data.py b/src/bonsai/bonsai/bim/module/status_render/data.py new file mode 100644 index 0000000000..a1edf37d9e --- /dev/null +++ b/src/bonsai/bonsai/bim/module/status_render/data.py @@ -0,0 +1,29 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2020, 2021 Dion Moult +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . + + +def refresh(): + """Called by bonsai.bim.handler.refresh_ui_data() after IFC operations, including + project load (which fires too late for the blend load_post handler). Pulls each + freshly-imported drawing camera's rules out of its IFC pset. Guarded so it never + overwrites rules being edited in-session. + """ + from bonsai.bim.module.status_render import operator + + for camera in operator.drawing_cameras(): + operator.ensure_rules_loaded(camera) diff --git a/src/bonsai/bonsai/bim/module/status_render/operator.py b/src/bonsai/bonsai/bim/module/status_render/operator.py new file mode 100644 index 0000000000..d620297ffe --- /dev/null +++ b/src/bonsai/bonsai/bim/module/status_render/operator.py @@ -0,0 +1,522 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2020, 2021 Dion Moult +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . + +"""Render-only exposure/gamma override for IFC elements selected by a filter query. + +Blender's ``view_settings.exposure`` and ``view_settings.gamma`` are global +colour-management settings and cannot be assigned per object. This module +reproduces the effect for a subset of elements (selected via the shared Search +filter system, e.g. ``EPset_Status.Status=EXISTING``) using a Cryptomatte object +mask plus a small compositor graph, so the override only shows up in the final +render and the viewport is untouched. + +Cryptomatte (not the Object Index pass) is used because EEVEE Next always renders +the Object Index pass as 0 (Blender bug #121690); Cryptomatte works in both EEVEE +and Cycles. +""" + +import bpy +from bpy.app.handlers import persistent +import json +import ifcopenshell.api.pset +import ifcopenshell.util.element +import ifcopenshell.util.selector +import bonsai.tool as tool + +# Marker stored on every node we create so re-running can clean up its own work +# without touching the user's other compositor nodes. +MARKER = "bim_status_render_override" + + +def get_filtered_elements(filter_groups): + """Resolve a rule's filter groups to a set of IFC elements. + + Mirrors bim.search so behaviour matches the shared filter UI under both the + legacy query and the set-operations preference. + """ + ifc = tool.Ifc.get() + if not ifc or not len(filter_groups): + return set() + + if tool.Blender.get_addon_preferences().chain_filter_with_set_operations: + # Migrate old "!" prefix filters to the filter_mode system, as bim.search does. + for filter_group in filter_groups: + for ifc_filter in filter_group.filters: + if ifc_filter.type in ("entity", "instance") and ifc_filter.value.startswith("!"): + ifc_filter.value = ifc_filter.value[1:] + ifc_filter.filter_mode = "SUBTRACT" + return tool.Search.execute_filter_groups(filter_groups) + + query = tool.Search.export_filter_query(filter_groups) + if not query: + return set() + return ifcopenshell.util.selector.filter_elements(ifc, query) + + +def get_rule_objects(rule): + """Return the Blender mesh objects for the IFC elements a rule's filter matches.""" + return [ + obj + for element in get_filtered_elements(rule.filter_groups) + if isinstance(obj := tool.Ifc.get_object(element), bpy.types.Object) and obj.type == "MESH" + ] + + +def clear_marked_nodes(tree): + for node in list(tree.nodes): + if node.get(MARKER): + tree.nodes.remove(node) + + +def get_or_create(tree, bl_idname): + """Reuse an existing user node of this type, or create a fresh one.""" + for node in tree.nodes: + if node.bl_idname == bl_idname and not node.get(MARKER): + return node + return tree.nodes.new(bl_idname) + + +# Transparency is a real material effect (so geometry behind shows through), applied +# before the render and restored after. These hold the swap state between the render +# handlers. Only one render runs at a time, so module-level state is safe. +_transparency_restore = [] # list of (object, slot_index, original_material) +_temp_materials = [] # temporary transparent materials to delete on restore + + +def make_transparent_material(material, amount): + """Copy a material and mix a Transparent BSDF into its surface by ``amount``.""" + dup = material.copy() + dup[MARKER] = True + dup.use_nodes = True + tree = dup.node_tree + output = next((n for n in tree.nodes if n.type == "OUTPUT_MATERIAL" and n.is_active_output), None) + output = output or next((n for n in tree.nodes if n.type == "OUTPUT_MATERIAL"), None) + if output and output.inputs["Surface"].links: + surface = output.inputs["Surface"].links[0].from_socket + transparent = tree.nodes.new("ShaderNodeBsdfTransparent") + mix = tree.nodes.new("ShaderNodeMixShader") + mix.inputs["Fac"].default_value = amount # 0 = opaque, 1 = fully transparent + tree.links.new(surface, mix.inputs[1]) + tree.links.new(transparent.outputs[0], mix.inputs[2]) + tree.links.new(mix.outputs[0], output.inputs["Surface"]) + # Enable real alpha blending (EEVEE Next; fall back to the legacy property name). + if hasattr(dup, "surface_render_method"): + dup.surface_render_method = "BLENDED" + elif hasattr(dup, "blend_method"): + dup.blend_method = "BLEND" + return dup + + +def apply_transparency(objects, amount): + """Swap each object's materials for transparent copies, remembering the originals.""" + for obj in objects: + for index, slot in enumerate(obj.material_slots): + material = slot.material + if material is None or material.get(MARKER): + continue # no material, or already a temp material from another rule + dup = make_transparent_material(material, amount) + _temp_materials.append(dup) + _transparency_restore.append((obj, index, material)) + slot.material = dup + + +def restore_transparency(): + """Put the original materials back and delete the temporary transparent copies.""" + for obj, index, material in _transparency_restore: + try: + obj.material_slots[index].material = material + except (IndexError, ReferenceError): + pass + _transparency_restore.clear() + for material in _temp_materials: + try: + bpy.data.materials.remove(material) + except (ReferenceError, RuntimeError): + pass + _temp_materials.clear() + + +def build_color_rule(tree, scene, view_layer, rule, objects, input_socket, x, y): + """Append one rule's exposure/gamma sub-graph to the compositor chain. + + Applies the colour effects to ``input_socket`` only where the rule's Cryptomatte + matte covers, and returns ``(output_image_socket, matte_socket)``. + """ + links = tree.links + + def node(bl_idname, loc): + n = tree.nodes.new(bl_idname) + n[MARKER] = True + n.location = loc + return n + + crypto = node("CompositorNodeCryptomatteV2", (x, y - 320)) + crypto.label = f"Matte: {rule.name}" + crypto.source = "RENDER" + crypto.scene = scene + try: + crypto.layer_name = f"{view_layer.name}.CryptoObject" + except TypeError: + pass # Keep the node's default layer; the enum item isn't available yet. + crypto.matte_id = ", ".join(obj.name for obj in objects) + links.new(input_socket, crypto.inputs["Image"]) + matte = crypto.outputs["Matte"] + + exposure = node("CompositorNodeExposure", (x, y)) + exposure.label = f"Exposure: {rule.name}" + exposure.inputs["Exposure"].default_value = rule.exposure + links.new(input_socket, exposure.inputs["Image"]) + + gamma = node("CompositorNodeGamma", (x + 180, y)) + gamma.label = f"Gamma: {rule.name}" + gamma.inputs["Gamma"].default_value = rule.gamma + links.new(exposure.outputs["Image"], gamma.inputs["Image"]) + + mix = node("CompositorNodeMixRGB", (x + 360, y)) + mix.label = f"Apply: {rule.name}" + mix.blend_type = "MIX" + links.new(matte, mix.inputs["Fac"]) + links.new(input_socket, mix.inputs[1]) + links.new(gamma.outputs["Image"], mix.inputs[2]) + return mix.outputs["Image"], matte + + +def build_compositor(scene, props): + """Build the compositor (exposure/gamma) chain for a render. + + Transparency is NOT handled here -- it is a live material effect managed by + sync_live_effects so it also shows in the viewport. This only adds the colour + nodes layered per rule. + """ + scene.use_nodes = True + # The node tree is only applied to renders when compositing is enabled. + scene.render.use_compositing = True + tree = scene.node_tree + clear_marked_nodes(tree) + + render_layers = get_or_create(tree, "CompositorNodeRLayers") + composite = get_or_create(tree, "CompositorNodeComposite") + view_layer = scene.view_layers.get(render_layers.layer) or scene.view_layers[0] + + current = render_layers.outputs["Image"] + x = render_layers.location.x + 320 + y = render_layers.location.y + for rule in props.rules: + objects = get_rule_objects(rule) + if not objects: + continue + + # Exposure/gamma stay in the compositor, masked by Cryptomatte. Only build the + # sub-graph when there is a colour change to make. + if rule.exposure != 0.0 or rule.gamma != 1.0: + # Cryptomatte reads object IDs from the render's CryptoObject passes, so the + # pass must be enabled on the view layer the node samples. + view_layer.use_pass_cryptomatte_object = True + current, _ = build_color_rule(tree, scene, view_layer, rule, objects, current, x, y) + x += 700 + + composite.location = (x, y) + tree.links.new(current, composite.inputs["Image"]) + + +def get_camera_override_props(camera): + """Per-drawing override props live on the camera datablock; None if not a camera.""" + if camera and camera.type == "CAMERA": + return camera.data.BIMRenderOverrideProperties + return None + + +# --- IFC persistence ------------------------------------------------------- +# The camera props are the working/edit copy; the rules are mirrored into the IFC +# model at EPset_Drawing.RenderOverrides (JSON) so they travel with the drawing. +# Coarse auto-sync: written on add/remove and on save, read on file load. Each +# rule's filter reuses the same query serialization as the drawing Include/Exclude +# filters (tool.Search.export/import_filter_query). + +PSET_PROP = "RenderOverrides" + + +def serialize_rules(props): + return [ + { + "name": rule.name, + "query": tool.Search.export_filter_query(rule.filter_groups), + "exposure": round(rule.exposure, 6), + "gamma": round(rule.gamma, 6), + "transparency": round(rule.transparency, 6), + } + for rule in props.rules + ] + + +def deserialize_rules(props, data): + props.rules.clear() + for entry in data: + rule = props.rules.add() + rule.name = entry.get("name", "Rule") + # Direct id-property writes bypass the update callback so we don't trigger a + # live re-sync for every rule mid-load (the caller syncs once at the end). + rule["exposure"] = float(entry.get("exposure", 0.0)) + rule["gamma"] = float(entry.get("gamma", 1.0)) + rule["transparency"] = float(entry.get("transparency", 0.0)) + if query := (entry.get("query") or ""): + try: + tool.Search.import_filter_query(query, rule.filter_groups) + except Exception: + pass # Tolerate an unparseable stored query rather than failing the load. + props.active_rule_index = min(props.active_rule_index, max(len(props.rules) - 1, 0)) + + +def save_rules_to_ifc(camera): + """Mirror a drawing camera's rules into EPset_Drawing.RenderOverrides.""" + drawing = tool.Ifc.get_entity(camera) + props = get_camera_override_props(camera) + if not drawing or props is None: + return + data = serialize_rules(props) + existing = ifcopenshell.util.element.get_pset(drawing, "EPset_Drawing", PSET_PROP) + if not data and existing is None: + return # Nothing to store and nothing stored -- don't dirty unconfigured drawings. + pset = tool.Pset.get_element_pset(drawing, "EPset_Drawing") + if pset is None: + return + ifcopenshell.api.pset.edit_pset( + tool.Ifc.get(), pset=pset, properties={PSET_PROP: json.dumps(data) if data else None} + ) + + +def load_rules_from_ifc(camera): + """Populate a drawing camera's rules from EPset_Drawing.RenderOverrides.""" + drawing = tool.Ifc.get_entity(camera) + props = get_camera_override_props(camera) + if not drawing or props is None: + return + raw = ifcopenshell.util.element.get_pset(drawing, "EPset_Drawing", PSET_PROP) + if not raw: + return + try: + data = json.loads(raw) + except (ValueError, TypeError): + return + deserialize_rules(props, data) + + +def ensure_rules_loaded(camera): + """Load rules from IFC only when the camera props are empty (i.e. freshly imported). + + The empty guard means this never clobbers rules being edited in-session: once a + drawing has rules in its props, IFC is no longer read back into them. + """ + props = get_camera_override_props(camera) + if props is None or len(props.rules): + return + load_rules_from_ifc(camera) + + +def drawing_cameras(): + """Yield every camera object linked to an IFC drawing.""" + for obj in bpy.data.objects: + if obj.type == "CAMERA" and tool.Ifc.get_entity(obj): + yield obj + + +# --- Apply / restore seam -------------------------------------------------- +# Two layers, so each effect lives where it can actually be shown: +# * Live material layer (transparency): applied persistently while the toggle is +# on, so it shows in the Rendered viewport (WYSIWYG). Driven by sync_live_effects. +# * Render compositor layer (exposure/gamma): built just before a render and +# removed just after, because the viewport compositor can't read Cryptomatte. +# The same enable toggle drives both. + + +def sync_live_effects(scene): + """Establish the correct live (viewport) material state for the active drawing. + + Removes any previous temp materials, then -- if the toggle is on -- applies + transparency for the active camera's rules. Idempotent; safe to call any time. + """ + restore_transparency() + props = get_camera_override_props(scene.camera) + if not props or not props.enabled: + return + for rule in props.rules: + if rule.transparency > 0: + objects = get_rule_objects(rule) + if objects: + apply_transparency(objects, rule.transparency) + + +def clear_compositor(scene): + """Remove the override compositor nodes and reconnect Render Layers -> Composite. + + Leaves live materials untouched (those are managed by sync_live_effects). + """ + if not (scene.use_nodes and scene.node_tree): + return + tree = scene.node_tree + clear_marked_nodes(tree) + rlayers = next((n for n in tree.nodes if n.bl_idname == "CompositorNodeRLayers"), None) + composite = next((n for n in tree.nodes if n.bl_idname == "CompositorNodeComposite"), None) + if rlayers and composite and not composite.inputs["Image"].links: + tree.links.new(rlayers.outputs["Image"], composite.inputs["Image"]) + + +# --- Handlers -------------------------------------------------------------- + + +@persistent +def render_init_handler(scene, *args): + # Ensure live materials match the current rules, then add the render-only compositor. + ensure_rules_loaded(scene.camera) + sync_live_effects(scene) + props = get_camera_override_props(scene.camera) + if props and props.enabled and len(props.rules): + build_compositor(scene, props) + + +@persistent +def render_end_handler(scene, *args): + # Remove the compositor; live materials persist for continued viewport preview. + clear_compositor(scene) + + +# Switching the active drawing reassigns scene.camera, but no handler fires for that. +# A msgbus subscription re-syncs the live materials so the previous drawing's +# transparency is removed and the new drawing's applied. msgbus subscriptions are +# cleared on file load, so we re-subscribe in load_post. +_msgbus_owner = object() + + +def _deferred_camera_sync(): + scene = bpy.context.scene + props = get_camera_override_props(scene.camera if scene else None) + # Activating a drawing that already has rules auto-enables its overrides. + if props and len(props.rules) and not props.enabled: + props.enabled = True # the update callback runs sync_live_effects + else: + sync_live_effects(scene) + return None # run once + + +def _on_active_camera_changed(*args): + # Defer out of the msgbus notification context before touching material datablocks. + if not bpy.app.timers.is_registered(_deferred_camera_sync): + bpy.app.timers.register(_deferred_camera_sync, first_interval=0.0) + + +def subscribe_camera_change(): + bpy.msgbus.clear_by_owner(_msgbus_owner) + bpy.msgbus.subscribe_rna( + key=(bpy.types.Scene, "camera"), + owner=_msgbus_owner, + args=(), + notify=_on_active_camera_changed, + ) + + +@persistent +def load_post_handler(*args): + # IFC is the source of truth: fill any empty drawing camera from its pset. (For a + # .blend reopen the props already came from the file; for IFC import the cameras + # don't exist yet here -- that case is covered by data.refresh / refresh_ui_data.) + for camera in drawing_cameras(): + ensure_rules_loaded(camera) + subscribe_camera_change() # msgbus is cleared on file load; re-subscribe + # Files are saved without the temp materials (see save_pre), so re-apply on load. + sync_live_effects(bpy.context.scene) + + +@persistent +def save_pre_handler(*args): + # Never write the temporary transparent materials into the .blend. + restore_transparency() + # Flush each drawing's current rules into the IFC model before it is saved. + for camera in drawing_cameras(): + save_rules_to_ifc(camera) + + +@persistent +def save_post_handler(*args): + # Put the live preview back after the (clean) save. + sync_live_effects(bpy.context.scene) + + +class AddRenderOverrideRule(bpy.types.Operator): + bl_idname = "bim.add_render_override_rule" + bl_label = "Add Render Override Rule" + bl_description = "Add a new selection + effects rule" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return get_camera_override_props(context.scene.camera) is not None + + def execute(self, context): + props = get_camera_override_props(context.scene.camera) + rule = props.rules.add() + rule.name = f"Rule {len(props.rules)}" + props.active_rule_index = len(props.rules) - 1 + sync_live_effects(context.scene) + save_rules_to_ifc(context.scene.camera) + return {"FINISHED"} + + +class RemoveRenderOverrideRule(bpy.types.Operator): + bl_idname = "bim.remove_render_override_rule" + bl_label = "Remove Render Override Rule" + bl_description = "Remove the active selection + effects rule" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + props = get_camera_override_props(context.scene.camera) + return bool(props and props.rules) + + def execute(self, context): + props = get_camera_override_props(context.scene.camera) + props.rules.remove(props.active_rule_index) + props.active_rule_index = min(props.active_rule_index, len(props.rules) - 1) + sync_live_effects(context.scene) + save_rules_to_ifc(context.scene.camera) + return {"FINISHED"} + + +_HANDLERS = ( + ("render_init", "render_init_handler"), + ("render_complete", "render_end_handler"), + ("render_cancel", "render_end_handler"), + ("load_post", "load_post_handler"), + ("save_pre", "save_pre_handler"), + ("save_post", "save_post_handler"), +) + + +def register_handlers(): + unregister_handlers() + for collection_name, func_name in _HANDLERS: + getattr(bpy.app.handlers, collection_name).append(globals()[func_name]) + subscribe_camera_change() + + +def unregister_handlers(): + for collection_name, func_name in _HANDLERS: + collection = getattr(bpy.app.handlers, collection_name) + func = globals()[func_name] + if func in collection: + collection.remove(func) + bpy.msgbus.clear_by_owner(_msgbus_owner) diff --git a/src/bonsai/bonsai/bim/module/status_render/prop.py b/src/bonsai/bonsai/bim/module/status_render/prop.py new file mode 100644 index 0000000000..c67fb765cd --- /dev/null +++ b/src/bonsai/bonsai/bim/module/status_render/prop.py @@ -0,0 +1,94 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2020, 2021 Dion Moult +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . + +import bpy +from bpy.props import BoolProperty, CollectionProperty, FloatProperty, IntProperty, StringProperty +from bpy.types import PropertyGroup +from bonsai.bim.module.search.prop import BIMFilterGroup +from typing import TYPE_CHECKING + + +def update_live_preview(self, context): + """Re-sync the live (viewport) material preview when the toggle or a live effect changes.""" + # Lazy import avoids any import-order coupling with the operator module. + from bonsai.bim.module.status_render import operator + + if context.scene: + operator.sync_live_effects(context.scene) + + +class BIMRenderOverrideRule(PropertyGroup): + """One selection (filter) plus the render effects applied to it.""" + + name: StringProperty(name="Name", default="Rule") + # Each rule has its own filter, resolved by tool.Search.get_filter_groups(f"status_render_{i}"). + filter_groups: CollectionProperty(type=BIMFilterGroup, name="Filter Groups") + exposure: FloatProperty( + name="Exposure", + description="Extra exposure stops applied to matching elements, on top of the scene's " + "colour management. 0 = no change", + default=0.0, + soft_min=-10.0, + soft_max=10.0, + ) + gamma: FloatProperty( + name="Gamma", + description="Extra gamma applied to matching elements. 1 = no change", + default=1.0, + min=0.001, + soft_max=5.0, + ) + transparency: FloatProperty( + name="Transparency", + description="Render the matching elements with transparent materials so the geometry " + "behind them shows through. Shown live in the Rendered viewport while enabled. " + "0 = opaque, 1 = fully transparent", + default=0.0, + min=0.0, + max=1.0, + subtype="FACTOR", + update=update_live_preview, + ) + + if TYPE_CHECKING: + name: str + filter_groups: bpy.types.bpy_prop_collection_idprop[BIMFilterGroup] + exposure: float + gamma: float + transparency: float + + +class BIMRenderOverrideProperties(PropertyGroup): + """Per-drawing render overrides. Stored on the camera datablock so each drawing + carries its own rules (registered as ``Camera.BIMRenderOverrideProperties``).""" + + enabled: BoolProperty( + name="Enable Render Overrides", + description="While enabled, transparency is shown live in the Rendered viewport, and all " + "overrides are applied to renders that run the compositor (F12 and Default-render drawing " + "underlays). Exposure/gamma are render-only (the viewport compositor can't mask them)", + default=False, + update=update_live_preview, + ) + rules: CollectionProperty(type=BIMRenderOverrideRule, name="Rules") + active_rule_index: IntProperty(name="Active Rule") + + if TYPE_CHECKING: + enabled: bool + rules: bpy.types.bpy_prop_collection_idprop[BIMRenderOverrideRule] + active_rule_index: int diff --git a/src/bonsai/bonsai/bim/module/status_render/ui.py b/src/bonsai/bonsai/bim/module/status_render/ui.py new file mode 100644 index 0000000000..ddd60673ff --- /dev/null +++ b/src/bonsai/bonsai/bim/module/status_render/ui.py @@ -0,0 +1,114 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2020, 2021 Dion Moult +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . + +import bpy +import ifcopenshell.util.element +import bonsai.tool as tool +import bonsai.bim.helper +from bonsai.bim.module.search.data import SearchData + + +def get_applied_drawing_style(camera): + """The shading style currently applied to the drawing (EPset_Drawing.CurrentShadingStyle), + which is what actually drives the render type -- not whichever style is selected in the list.""" + drawing = tool.Ifc.get_entity(camera) + if not drawing: + return None + name = ifcopenshell.util.element.get_pset(drawing, "EPset_Drawing", "CurrentShadingStyle") + if not name: + return None + dprops = tool.Drawing.get_document_props() + return next((style for style in dprops.drawing_styles if style.name == name), None) + + +class BIM_UL_render_override_rules(bpy.types.UIList): + def draw_item(self, context, layout, data, item, icon, active_data, active_propname): + layout.prop(item, "name", text="", emboss=False, icon="SHADERFX") + + +class BIM_PT_status_render(bpy.types.Panel): + bl_label = "Render Overrides" + bl_idname = "BIM_PT_status_render" + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "scene" + bl_parent_id = "BIM_PT_camera" + bl_options = {"DEFAULT_CLOSED"} + + @classmethod + def poll(cls, context): + # Same as the sibling drawing panels: only for an active IFC drawing camera. + return bool((camera := context.scene.camera) and tool.Ifc.get_entity(camera)) + + def draw(self, context): + layout = self.layout + camera = context.scene.camera + props = camera.data.BIMRenderOverrideProperties + + # The override needs the compositor, which only runs for Default-render drawings + # (and F12). Gate on the *applied* shading style (CurrentShadingStyle), since that + # is what drives the render -- not whichever style is highlighted in the list. + applied_style = get_applied_drawing_style(camera) + blocked = applied_style is not None and applied_style.render_type != "DEFAULT" + + if blocked: + col = layout.column(align=True) + col.label(text="Current Shading Style is not 'Default'", icon="ERROR") + col.label(text="render type, so the compositor is bypassed.") + col.label(text="Apply a Default-render shading style.") + + header = layout.column() + header.enabled = not blocked + header.prop(props, "enabled", toggle=True, icon="RENDER_RESULT") + + # When the compositor is bypassed, the rules can't do anything -- hide them so + # only the greyed toggle and the explanation remain. + if blocked: + return + + row = layout.row() + row.template_list( + "BIM_UL_render_override_rules", "", props, "rules", props, "active_rule_index", rows=3 + ) + col = row.column(align=True) + col.operator("bim.add_render_override_rule", icon="ADD", text="") + col.operator("bim.remove_render_override_rule", icon="REMOVE", text="") + + if 0 <= props.active_rule_index < len(props.rules): + rule = props.rules[props.active_rule_index] + box = layout.box() + box.active = props.enabled + box.prop(rule, "name") + + # Per-rule filter (same UI as bim.search), keyed to this rule's index. + bonsai.bim.helper.draw_filter( + box, rule.filter_groups, SearchData, f"status_render_{props.active_rule_index}" + ) + + col = box.column(align=True) + col.label(text="Effects:") + col.prop(rule, "exposure") + col.prop(rule, "gamma") + col.prop(rule, "transparency") + + if not blocked: + box = layout.box() + col = box.column(align=True) + col.label(text="Applied automatically during F12 and", icon="INFO") + col.label(text="Default-render drawing underlays, then") + col.label(text="removed. The drawing needs an underlay.") diff --git a/src/bonsai/bonsai/tool/search.py b/src/bonsai/bonsai/tool/search.py index 356addee3a..646d24ba14 100644 --- a/src/bonsai/bonsai/tool/search.py +++ b/src/bonsai/bonsai/tool/search.py @@ -85,6 +85,10 @@ class Search(bonsai.core.tool.Search): def get_filter_groups(cls, module: FilterModule) -> bpy.types.bpy_prop_collection_idprop[BIMFilterGroup]: if module == "search": return cls.get_search_props().filter_groups + elif module.startswith("status_render_"): + index = int(module.rsplit("_", 1)[1]) + assert (scene := bpy.context.scene) and (camera := scene.camera) + return camera.data.BIMRenderOverrideProperties.rules[index].filter_groups elif module == "csv": return tool.Blender.get_csv_props().filter_groups elif module == "diff": @@ -101,11 +105,23 @@ class Search(bonsai.core.tool.Search): return getattr(props.clash_sets[int(clash_set_index)], ab)[int(clash_source_index)].filter_groups assert False, f"Unsupported module: {module}" + @classmethod + def on_filter_query_edited(cls, module: str, context: bpy.types.Context) -> None: + """Notify the owning module that its filter query was just edited (via + bim.edit_filter_query or bim.apply_filter_from_text), so it can react -- e.g. + refresh a live viewport preview.""" + if module.startswith("status_render"): + from bonsai.bim.module.status_render import operator + + operator.sync_live_effects(context.scene) + @classmethod def import_filter_query( cls, query: str, filter_groups: bpy.types.bpy_prop_collection_idprop[BIMFilterGroup] ) -> None: filter_groups.clear() + if not query.strip(): + return # An empty query means "no filter"; clearing the groups is enough. transformer = ImportFilterQueryTransformer(filter_groups) transformer.transform(ifcopenshell.util.selector.filter_elements_grammar.parse(query))