Compare commits

...

2 Commits

Author SHA1 Message Date
Ryan Schultz 0716e5038e Group Active Drawing controls into a collapsible "Drawing Settings" section
Wrap the underlay/linework/annotation toggles, Draw Linked Projects (and its
nested "Linked Projects to Draw" panel), and the drawing config (target view,
linework/fill/cut modes, material layers, width/height/depth, scale, DPI) in a
single collapsible layout.panel, collapsed by default, to declutter the
BIM_PT_camera panel.

Generated with the assistance of an AI coding tool.
2026-07-05 19:21:57 -05:00
Ryan Schultz b928902e3c 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.
2026-07-05 19:20:25 -05:00
11 changed files with 966 additions and 21 deletions
+1
View File
@@ -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,
@@ -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;
+26 -20
View File
@@ -63,7 +63,13 @@ class BIM_PT_camera(Panel):
self.layout.use_property_split = True
dprops = tool.Drawing.get_document_props()
col = self.layout.column(align=True)
header, body = self.layout.panel("drawing_settings", default_closed=True)
header.label(text="Drawing Settings", icon="PREFERENCES")
if not body:
return
body.use_property_split = True
col = body.column(align=True)
row = col.row(align=True)
row.prop(props, "has_underlay", icon="OUTLINER_OB_IMAGE")
row.prop(dprops, "should_use_underlay_cache", text="", icon="FILE_REFRESH")
@@ -78,44 +84,44 @@ class BIM_PT_camera(Panel):
row = col.row(align=True)
row.prop(dprops, "should_draw_linked_projects")
if dprops.should_draw_linked_projects:
header, panel = self.layout.panel("links_to_draw")
header.label(text="Linked Projects to Draw", icon="OUTPUT")
links_header, links_panel = body.panel("links_to_draw")
links_header.label(text="Linked Projects to Draw", icon="OUTPUT")
pprops = tool.Project.get_project_props()
links = list(pprops.get_loaded_links())
if panel:
if links_panel:
if links:
for link in links:
row = panel.row(align=True)
row = links_panel.row(align=True)
split = row.split(factor=0.9)
split.label(text=link.filepath, icon="FILE")
split.prop(link, "include_in_drawings", text="")
else:
panel.label(text="No IFC projects linked and loaded.")
links_panel.label(text="No IFC projects linked and loaded.")
row = self.layout.row(align=True)
row = body.row(align=True)
row.prop(props, "target_view")
if props.target_view == "MODEL_VIEW":
row = self.layout.row()
row = body.row()
row.prop(props, "camera_type")
if props.camera_type == "PERSP":
row = self.layout.row(align=True)
row = body.row(align=True)
row.prop(camera_data, "shift_x", text="Camera Shift X/Y:")
row.prop(camera_data, "shift_y", text="")
row = self.layout.row()
row = body.row()
row.prop(props, "linework_mode")
row = self.layout.row()
row = body.row()
row.prop(props, "generate_material_layers")
if props.linework_mode == "OPENCASCADE":
row = self.layout.row()
row = body.row()
row.prop(props, "fill_mode")
row = self.layout.row()
row = body.row()
row.prop(props, "cut_mode")
row = self.layout.row()
row = body.row()
row.prop(props, "width")
row = self.layout.row()
row = body.row()
row.prop(props, "height")
render = context.scene.render
@@ -126,7 +132,7 @@ class BIM_PT_camera(Panel):
and str(render.engine) == tool.Blender.get_eevee_name()
and ((megapixels := (render.resolution_x * render.resolution_y / 10**6)) > MEGAPIXELS_WARNING_THRESHOLD)
):
box = self.layout.box()
box = body.box()
box.label(
text=f"Resulting image size is {render.resolution_x} x {render.resolution_y} ({round(megapixels, 2)} MP).",
icon="ERROR",
@@ -136,20 +142,20 @@ class BIM_PT_camera(Panel):
)
box.label(text="Underlay render might crash if VRAM requirement is not met.")
row = self.layout.row()
row = body.row()
row.prop(camera_data, "clip_end", text="Depth")
row = self.layout.row(align=True)
row = body.row(align=True)
row.prop(props, "diagram_scale", text="Scale")
row.prop(props, "is_nts", text="", icon="MOD_EDGESPLIT")
if props.diagram_scale == "CUSTOM":
row = self.layout.row(align=True)
row = body.row(align=True)
row.prop(props, "custom_scale_numerator", text="Custom Scale")
row.prop(props, "custom_scale_denominator", text="")
if props.has_underlay:
row = self.layout.row()
row = body.row()
row.prop(props, "dpi")
@@ -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):
@@ -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.
@@ -0,0 +1,40 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>
#
# 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 <http://www.gnu.org/licenses/>.
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
@@ -0,0 +1,29 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>
#
# 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 <http://www.gnu.org/licenses/>.
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)
@@ -0,0 +1,522 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>
#
# 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 <http://www.gnu.org/licenses/>.
"""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)
@@ -0,0 +1,94 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>
#
# 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 <http://www.gnu.org/licenses/>.
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
@@ -0,0 +1,114 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>
#
# 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 <http://www.gnu.org/licenses/>.
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.")
+16
View File
@@ -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))