Compare commits

...

4 Commits

Author SHA1 Message Date
Ryan Schultz 4d031f1469 Gate render overrides on the style the render actually uses
The Render Overrides panel decided whether the compositor would run by reading
EPset_Drawing.CurrentShadingStyle. That is not what drives the render:
CreateDrawing.generate_underlay branches on
BIMCameraProperties.get_active_drawing_style(), i.e. the row highlighted in the
Drawing Styles list for that camera.

The two drift apart. CurrentShadingStyle is only rewritten by
bim.activate_drawing_style, while the index is written directly by the UI list and
is reset whenever the camera datablock is rebuilt. A drawing could therefore report
"Blender Default" (a DEFAULT render type) in the panel while actually rendering
through "Technical" (VIEWPORT), silently bypassing the compositor -- and because the
stale name did not resolve in the styles list, the old gate treated it as "fine" and
showed no warning at all.

Gate on get_active_drawing_style() instead, so the panel cannot disagree with the
render. It now also names the offending style and its render type, blocks when no
style resolves (the underlay cannot render at all in that state), and calls out the
CurrentShadingStyle mismatch explicitly rather than leaving two panels quietly
contradicting each other.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 21:00:32 -05:00
Ryan Schultz 3fddc3523c Fix render overrides on Blender 5.x (compositor API rewrite)
The status_render compositor path worked in Blender 4.5 but did nothing in 5.2:
Blender 5.0 reworked the compositor and removed every API it used.

- scene.node_tree is gone -- the compositor tree is now a standalone
  CompositorNodeTree ID at scene.compositing_node_group, and scene.use_nodes is a
  deprecated no-op that no longer creates one. Reading scene.node_tree raised
  AttributeError inside the render_init handler, so build_compositor died before
  adding any nodes.
- CompositorNodeComposite was removed; the final image now goes to a
  NodeGroupOutput backed by an interface socket.
- CompositorNodeGamma -> ShaderNodeGamma (its image socket is named "Color").
- CompositorNodeMixRGB -> ShaderNodeMix with data_type "RGBA", whose sockets have
  to be picked by identifier since several share a display name.

Render Layers, Exposure and Cryptomatte are unchanged, and the transparency layer
(temporary materials) was never affected.

All of this is funnelled through get_compositor_tree / get_output_socket /
add_gamma_node / add_mix_node / find_socket so the graph-building code stays
version agnostic. Two 5.x-only wrinkles:

- A compositing group is a standalone ID, so a Render Layers node created in one
  is not implicitly bound to the scene being rendered; set .scene when it is None.
- When no group exists we create one and tag it, and clear_compositor deletes it
  again, so a scene that never composited does not end up permanently compositing
  through a passthrough graph.

Also replaces the deprecated material.use_nodes write with tool.Style.set_use_nodes.

Verified end-to-end headless on 4.5.7 and 5.2.0 (Cryptomatte-masked exposure and
gamma applied to the matched object and not the background, teardown, and the
transparency swap/restore) with identical rendered pixel values.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 09:48:23 -05:00
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 1115 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,148 @@
# 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.
## Blender version compatibility (4.x vs 5.x)
Blender 5.0 reworked the compositor and broke every API the first version of this
module used. All of it is funnelled through helpers in `operator.py` (`get_compositor_tree`,
`get_output_socket`, `add_gamma_node`, `add_mix_node`, `find_socket`) so the
graph-building code reads the same on both:
| | ≤ 4.5 | ≥ 5.0 |
|---|---|---|
| Node tree | `scene.node_tree` (embedded), enabled via `scene.use_nodes` | `scene.compositing_node_group` — a standalone `CompositorNodeTree` ID in `bpy.data.node_groups`. `scene.node_tree` is **gone**; `scene.use_nodes` is a deprecated no-op that no longer creates a tree |
| Final output | `CompositorNodeComposite` | node **removed** — use `NodeGroupOutput` plus `tree.interface.new_socket("Image", in_out="OUTPUT", socket_type="NodeSocketColor")` |
| Gamma | `CompositorNodeGamma` (image socket "Image") | `ShaderNodeGamma` (image socket "Color") — addressed positionally |
| Mix | `CompositorNodeMixRGB` | `ShaderNodeMix` with `data_type = "RGBA"`; sockets picked by identifier (`Factor_Float`, `A_Color`, `B_Color`, `Result_Color`) since several share a display name |
| Render Layers / Exposure / Cryptomatte | unchanged | unchanged |
Two 5.x-only wrinkles, both handled in `build_compositor`/`clear_compositor`:
- A compositing group is a standalone ID, so a Render Layers node created inside one
is not implicitly bound to the scene being rendered — `render_layers.scene` is set
when it comes back `None`.
- If no group existed, we create one and tag it with `MARKER`; `clear_compositor`
deletes it and resets `scene.compositing_node_group` to `None`, so a scene that
never composited doesn't end up permanently compositing through a passthrough graph.
Verified end-to-end (Cryptomatte-masked exposure and gamma both applied to the matched
object and not the background, everything torn down afterwards) on 4.5.7 and 5.2.0 —
identical rendered pixel values.
## 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,617 @@
# 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)
# --- Blender 5 compositor compatibility ------------------------------------
# Blender 5.0 reworked the compositor and broke every API this module used:
# * the scene's embedded node tree became a standalone CompositorNodeTree ID
# (``scene.compositing_node_group``); ``scene.node_tree`` is gone and
# ``scene.use_nodes`` is a deprecated no-op,
# * the Composite output node was replaced by the node group's output,
# * Gamma and MixRGB moved to the unified shader node types.
# Render Layers, Exposure and Cryptomatte are unchanged. Everything below funnels
# through these helpers so the graph-building code stays version agnostic.
def find_socket(sockets, identifier):
"""Look a socket up by ``identifier`` -- socket collections key by identifier, and
the unified 5.x nodes carry several same-named sockets (one per data type)."""
return next(socket for socket in sockets if socket.identifier == identifier)
def get_compositor_tree(scene, create=False):
"""Return the scene's compositor node tree, optionally creating it. None if absent."""
if tool.Blender.BLENDER_5:
tree = scene.compositing_node_group
if tree is None and create:
tree = bpy.data.node_groups.new("Compositor Nodes", "CompositorNodeTree")
tree[MARKER] = True # ours -- torn down again in clear_compositor
scene.compositing_node_group = tree
return tree
if create:
scene.use_nodes = True
return scene.node_tree if scene.use_nodes else None
def get_output_socket(tree, create=False):
"""Return the socket that receives the tree's final image, or None.
Pre-5.0 that is the Composite node's Image input; from 5.0 it is the first input
of the group output node, backed by an interface socket.
"""
if not tool.Blender.BLENDER_5:
composite = next((n for n in tree.nodes if n.bl_idname == "CompositorNodeComposite"), None)
if composite is None:
if not create:
return None
composite = tree.nodes.new("CompositorNodeComposite")
return composite.inputs["Image"]
output = next((n for n in tree.nodes if n.bl_idname == "NodeGroupOutput"), None)
if output is None:
if not create:
return None
output = tree.nodes.new("NodeGroupOutput")
if not any(i.item_type == "SOCKET" and i.in_out == "OUTPUT" for i in tree.interface.items_tree):
if not create:
return None
tree.interface.new_socket("Image", in_out="OUTPUT", socket_type="NodeSocketColor")
return output.inputs[0] # the trailing "__extend__" virtual socket is always last
def add_gamma_node(tree, gamma):
"""Create a Gamma node and return ``(node, image_in, image_out)``.
ShaderNodeGamma names its image sockets "Color", so they are addressed positionally.
"""
node = tree.nodes.new("ShaderNodeGamma" if tool.Blender.BLENDER_5 else "CompositorNodeGamma")
node.inputs["Gamma"].default_value = gamma
return node, node.inputs[0], node.outputs[0]
def add_mix_node(tree):
"""Create a colour Mix node and return ``(node, fac_in, a_in, b_in, result_out)``."""
if tool.Blender.BLENDER_5:
node = tree.nodes.new("ShaderNodeMix")
node.data_type = "RGBA"
node.blend_type = "MIX"
return (
node,
find_socket(node.inputs, "Factor_Float"),
find_socket(node.inputs, "A_Color"),
find_socket(node.inputs, "B_Color"),
find_socket(node.outputs, "Result_Color"),
)
node = tree.nodes.new("CompositorNodeMixRGB")
node.blend_type = "MIX"
return node, node.inputs[0], node.inputs[1], node.inputs[2], node.outputs[0]
# 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
tool.Style.set_use_nodes(dup, True) # deprecated no-op on Blender 5+, where it is always on
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, gamma_in, gamma_out = add_gamma_node(tree, rule.gamma)
gamma[MARKER] = True
gamma.location = (x + 180, y)
gamma.label = f"Gamma: {rule.name}"
links.new(exposure.outputs["Image"], gamma_in)
mix, fac_in, base_in, effect_in, mix_out = add_mix_node(tree)
mix[MARKER] = True
mix.location = (x + 360, y)
mix.label = f"Apply: {rule.name}"
links.new(matte, fac_in)
links.new(input_socket, base_in)
links.new(gamma_out, effect_in)
return mix_out, 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.
"""
tree = get_compositor_tree(scene, create=True)
# The node tree is only applied to renders when compositing is enabled.
scene.render.use_compositing = True
clear_marked_nodes(tree)
render_layers = get_or_create(tree, "CompositorNodeRLayers")
if render_layers.scene is None:
# A 5.x compositing group is a standalone ID, so a fresh Render Layers node in
# one isn't implicitly bound to the scene we are rendering.
render_layers.scene = scene
output_socket = get_output_socket(tree, create=True)
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
output_socket.node.location = (x, y)
tree.links.new(current, output_socket)
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).
"""
tree = get_compositor_tree(scene)
if tree is None:
return
clear_marked_nodes(tree)
if tree.get(MARKER):
# A 5.x group we created just for the override -- remove it wholesale rather than
# leaving the scene compositing through a passthrough graph it never had.
scene.compositing_node_group = None
bpy.data.node_groups.remove(tree)
return
rlayers = next((n for n in tree.nodes if n.bl_idname == "CompositorNodeRLayers"), None)
output_socket = get_output_socket(tree)
if rlayers and output_socket and not output_socket.links:
tree.links.new(rlayers.outputs["Image"], output_socket)
# --- 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,140 @@
# 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_render_drawing_style(camera):
"""The drawing style the underlay render will actually use.
``CreateDrawing.generate_underlay`` branches on
``BIMCameraProperties.get_active_drawing_style()`` -- the row highlighted in the
Drawing Styles list for this camera -- so that, and not the pset's
``CurrentShadingStyle``, decides whether the compositor runs.
The two drift apart in practice: ``CurrentShadingStyle`` is only rewritten by
``bim.activate_drawing_style``, and ``bim.reload_drawing_styles`` leaves the index
pointing at whatever it pointed at before (often index 0) when it can't find that
style name in the drawing's shading_styles.json. Gating on the pset then reads
"Blender Default" while the render is really using style 0.
"""
return tool.Drawing.get_camera_props(camera).get_active_drawing_style()
def get_current_shading_style_name(camera):
"""EPset_Drawing.CurrentShadingStyle -- what the Drawing Styles panel displays."""
drawing = tool.Ifc.get_entity(camera)
if not drawing:
return None
return ifcopenshell.util.element.get_pset(drawing, "EPset_Drawing", "CurrentShadingStyle")
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 exactly what the underlay render branches on -- the camera's
# highlighted drawing style -- so the panel can never disagree with the render.
render_style = get_render_drawing_style(camera)
blocked = render_style is None or render_style.render_type != "DEFAULT"
if blocked:
col = layout.column(align=True)
if render_style is None:
col.label(text="No drawing style resolved for this camera,", icon="ERROR")
col.label(text="so the underlay cannot render at all.")
col.label(text="Press Reload Drawing Styles in Drawing Styles.")
else:
col.label(text=f"Style '{render_style.name}' has render type", icon="ERROR")
col.label(text=f"'{render_style.render_type.title()}', so the compositor")
col.label(text="is bypassed. Use a Default-render style.")
# CurrentShadingStyle is what the Drawing Styles panel shows, and it goes
# stale whenever reload can't find its name in shading_styles.json -- the
# render keeps using the highlighted row. Call the mismatch out here,
# otherwise the two panels appear to contradict each other.
current = get_current_shading_style_name(camera)
if current and current != render_style.name:
col.separator()
col.label(text=f"(EPset_Drawing says '{current}', but that", icon="INFO")
col.label(text="is not the style being rendered.)")
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))