Compare commits

..

3 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
Bruno Postle 1614791775 Fix SHAPELY fill mode dropping surface fills for all but the last linked file
generate_linework() loops over the main file plus any linked models
(added in 5db955d4), reassigning drawing_elements each iteration. The
SHAPELY fill pass builds elements_with_faces/raycast_objs from
drawing_elements, but did so once *after* that loop finished, so it
only ever saw whichever file was processed last -- silently dropping
surface fills for every other file, including the main model whenever
any link was loaded.

Accumulate elements_with_faces/raycast_objs across every file inside
the loop instead of capturing drawing_elements once after it ends.

Generated with the assistance of an AI coding tool.
2026-07-05 23:45:43 +01:00
26 changed files with 983 additions and 687 deletions
-15
View File
@@ -129,21 +129,6 @@ on CI to catch formatting issues.
within each package under `src/`.
- Run the existing test suite for the package you modified before submitting.
## In-Progress Feature Notes
Living design and working notes for unmerged feature branches live in
[`docs/dev-notes/`](docs/dev-notes/), one Markdown file per feature, named after the
branch. They capture the problem, the design decisions and the *why*, and what still
needs testing — so collaborators (and their AI agents) can pick up the context behind a
branch. Because the note is committed on the branch, it travels with the PR.
- Before working on a feature branch, read its note in `docs/dev-notes/` if one exists.
- Keep the note current as the PR is refined.
- These are not user documentation; at merge they are removed or their durable parts
promoted to code comments / permanent docs.
See [`docs/dev-notes/README.md`](docs/dev-notes/README.md) for details.
## Architecture Quick Reference
### Directory Structure
-28
View File
@@ -1,28 +0,0 @@
<!-- This file was generated with the assistance of an AI coding tool. -->
# Developer notes (in-progress features)
This directory holds **living design/working notes for unmerged feature branches**,
one Markdown file per feature, named after its branch (e.g.
`opening-template-on-type.md`).
## Purpose
A shared scratchpad so collaborators — and the AI agents they work with — can pick up
the context behind an in-progress branch: the problem, the design decisions and the
*why*, dead ends already ruled out, and what still needs testing. Because the note is
committed on the branch, it travels with the PR and shows up in the diff, so it is
discoverable without anyone being told where to look.
## How to use it (humans and agents)
- **Before working on a feature branch**, read its note here if one exists.
- **As the PR is refined**, keep the note current — append decisions, correct things
that changed, update the test checklist.
- **One file per feature**, named after the branch.
## Lifecycle
These are *not* permanent user documentation. When a PR merges, either remove its note
or promote the durable parts (the load-bearing "why") into code comments or the regular
docs, so stale notes do not accumulate on the default branch.
-165
View File
@@ -1,165 +0,0 @@
<!-- This file was generated with the assistance of an AI coding tool. -->
# Opening template on type — preserving custom openings across duplicate_type / append
> **Living dev note** for the `opening-template-on-type` branch/PR. Read before working
> on the feature; append decisions and findings as the PR is refined. This is *not* user
> documentation — at merge it is removed or its durable parts promoted to code comments.
> See [README.md](README.md) for the convention.
## Problem
`bpy.ops.bim.duplicate_type` and `bpy.ops.bim.append_library_element` lose a custom
`IfcOpeningElement` body (e.g. an `IfcPolygonalFaceSet`/tessellation) and replace it
with a generated extrusion. Root cause: the only mechanism that preserved a custom
opening was "copy it from a sibling occurrence of the same type"
(`get_existing_opening_occurrence_if_any`), which returns nothing for a brand-new
type. `generate_opening_from_filling` then always builds an extrusion (profile or
bbox), discarding the custom geometry.
## Key facts established
- IFC-level `root.copy_class` already `copy_deep`s opening representations; the loss
happens on the Bonsai side (the `regenerate_from_type` listener on
`type.assign_type`, and placement-time generation).
- Opening occurrences of one type already **share** a single `IfcRepresentationMap`
via mapped representations — that is why editing one void edits them all
(see `tool.Model.unshare_opening_representation` docstring). Bonsai shares, it does
not copy. The shared map just has no durable home (it is hosted implicitly by
whichever occurrence exists), so it does not survive to a new type.
- IFC4 ADD2 TC1 `IfcShapeRepresentation`: identifier **`Reference`** = "3D
representation that is **not part of the Body representation** ... used, e.g., for
opening geometries ... excluded from an implicit Boolean operation." Schema-valid;
`IfcTypeProduct` has no uniqueness rule on `RepresentationMaps` (only
`ApplicableOccurrence`). So a `Reference` map can sit beside the `Body` map.
- The geometry kernel selects an opening's geometry **by context, not by
`RepresentationIdentifier`** (`mapping::representation_of`, `ifcgeom/mapping/mapping.cpp`).
So a `Reference`-identified opening in the Body context still booleans correctly.
Nothing in Bonsai reads `"Reference"` to *skip* applying an opening.
- Caveat: IFC has no type-level void (`IfcRelVoidsElement` is occurrence-only). The
"opening template on type" is therefore a Bonsai convention using a spec-valid
identifier; other tools see a harmless extra `Reference` rep they ignore. The
regeneration smarts are Bonsai-only by necessity.
## Design
Store the shared opening body on the **type** as a `Reference` representation map.
Because `bim.duplicate_type` (`tool.Root.copy_representation`) and
`append_type_product` both copy a type's `RepresentationMaps`, the template survives
both. Occurrence openings map over the same map, so editing a void rewrites the
shared map = updates the type template in one stroke (no separate write-back needed).
`map_type_representations` must skip `Reference` maps so the window/door occurrence
does not receive the opening shape as its own Body (the kernel would otherwise pick
arbitrarily between the real Body and the opening rep). The skip is both required and
spec-endorsed ("not part of the Body representation").
### Body-context coexistence (Option A)
The template lives in the **Body** subcontext (required: the instance opening that maps
over it must resolve in Body context for the geometry kernel to subtract it). So the
type holds two reps in one context: the `Body` window body and the `Reference` opening
template. Per IFC, `Reference` is a *RepresentationIdentifier value used within the Body
context*, not a separate context - so we keep it there and disambiguate elsewhere:
- The representations panel now shows `RepresentationIdentifier` as its own column
(`geometry/data.py`, `geometry/ui.py`) so the two Body-context reps are
distinguishable (`Model | Body | MODEL_VIEW | Reference | Tessellation`). The panel
column previously read "Body" because it shows `ContextOfItems.ContextIdentifier`,
not the representation's identifier.
- `Geometry.reimport_element_representations` type branch now renders the requested
`base_representation` instead of `get_representation(element, context)`, which matched
only by context and returned the window body when switching to the `Reference` rep.
This is what makes "switch to the Reference row" actually show the void on the type.
### Precedence in `generate_opening_from_filling`
type `Reference` template → (existing sibling occurrence, checked by callers) →
type `Profile` extrusion → bbox extrusion.
### Type switching (assign_type)
On `type.assign_type` the opening is rebuilt to reflect the **assigned** type's void.
Two listeners in `model/handler.py`:
- **pre** `Bonsai.Opening.PreserveOnTypeChange``preserve_opening_on_type_change`:
before the filling moves to the new type, `promote_opening_to_type(old_type)` anchors
the old type's custom void as a template, so it isn't lost when (possibly the last)
occurrence is regenerated. Idempotent; custom voids only.
- **post** `Bonsai.Opening.RegenerateFromType``regenerate_from_type`
`_regenerate_from_type`: rebuilds from the new type's template / sibling / extrusion.
The old PR1 "preserve custom" guard was **removed** here — it kept the previous type's
void on a switch (wrong), and the template now makes preservation unnecessary.
NOTE: upstream `v0.8.0` landed `assign_type` changes + new `test_assign_type_*` tests
(merged under this branch's base). The listeners ride on top of that — re-test the
switch/edit round-trips against the new `assign_type`.
### Write-back on void edit
Editing an occurrence's void writes the new geometry back to the type's `Reference`
template via `update_type_template_from_opening` (creates the template if absent), then
**re-maps every occurrence's opening onto the template** and reloads the affected host walls
(`switch_representation`) so they re-boolean. The re-map (`_remap_opening_to_template`) is the
key part: an earlier version only re-pointed a *pre-existing* shared map, so siblings whose
openings were **independent** (their own `IfcRepresentationMap`, never sharing the template)
didn't follow — the common real-world case. Now they do. Hooked at both commit paths:
`UpdateRepresentation._execute` (the `edited_objs` path) and
`OverrideModeSetObject` after `edit_representation_item` (the in-place item edit). The
older `edit_openings`/`is_edited` path also calls it. `set_type_opening_representation`
has replace semantics (one `Reference` map per type).
### Preserving adjusted extrusions (duplicate_type)
`is_opening_representation_custom` only flags *non-extrusion* geometry (tessellation, brep,
CSG) as worth preserving — a proxy for "not regenerable". That mis-classifies a *manually
adjusted* extrusion, which is still an `IfcExtrudedAreaSolid`, so a hand-tweaked extrusion
opening was reset to the default on `duplicate_type`.
`promote_opening_to_type` now gates on `should_preserve_opening` = custom **or**
`_is_adjusted_extrusion`. The latter generates the default (`generate_opening_from_filling`,
which yields the default since no template exists at promote time) *transiently*, compares the
two bodies' axis-aligned bounding boxes (1 mm tolerance) via the geom engine, then removes the
temporary default. Divergence ⇒ the extrusion was adjusted ⇒ promote it; a plain default
matches ⇒ left regenerable (not frozen — see the "freeze" discussion). Scoped to the duplicate
path so the generate-and-compare stays out of the hot predicate. Limitation: bbox comparison
misses a shape change that preserves the bbox (upgrade to a vertex-set compare if needed).
## Status — implemented (manually verified in Blender)
- core `map_type_representations.py`: skip `Reference` maps.
- `model/opening.py`: `get_/set_type_opening_representation`, `promote_opening_to_type`,
`update_type_template_from_opening` (+ `_remap_opening_to_template`),
`preserve_opening_on_type_change`, `should_preserve_opening` (+ `_is_adjusted_extrusion`,
`_representation_bbox`); `generate_opening_from_filling` consults the template; PR1 guard
removed from `_regenerate_from_type`.
- `model/handler.py`: pre + post assign_type listeners.
- `type/operator.py` `DuplicateType`: promote before copy.
- `project/operator.py` `AppendLibraryElement`: `harvest_opening_template`.
- `geometry/operator.py`: write-back hooks in `UpdateRepresentation` and
`OverrideModeSetObject`; `reimport_element_representations` renders the requested rep.
- `geometry/data.py` + `geometry/ui.py`: `RepresentationIdentifier` column + headers.
Branch `opening-template-on-type` (#8200): initial feature commit + the #7916 build-conflict
ancestry-merge + void-propagation-to-all-occurrences + adjusted-extrusion preservation. The
`docs/dev-notes/` convention itself lives on the stacked branch `dev-notes-system` (#8201).
Still **deferred:** explicit "Apply/Reset to type" operators + a "diverges from type"
indicator; import never auto-writes back. `update_simple_openings` still keeps its
`is_opening_representation_custom` guard (array propagation, same type — left as-is).
## Things to test / verify
- Duplicated/appended type's new occurrence gets the faceset void and it **cuts** the
wall (kernel selects opening geom by context, so a `Reference`-id rep still booleans).
- `harvest_opening_template` cross-file `file.add`: no duplicate
`IfcGeometricRepresentationContext` left behind; units (kernel doesn't rescale rep
coords — same assumption as `append_asset`).
- Switch X→Y→X round-trip restores each type's void; switching to a plain (template-less)
type gives its default extrusion, not the previous faceset.
- Edit a void → type's `Reference` row updates; **all** occurrences follow (including ones
that had independent openings) and their host walls re-boolean; survives duplicate.
- `duplicate_type` on a type whose extrusion opening was **manually adjusted** → Type B keeps
the adjusted extrusion; a type with a plain/default extrusion stays regenerable (not frozen).
- Three write-back hooks are intentional (different commit paths) — candidate for
consolidation in review.
- Re-test against upstream's new `assign_type` (see NOTE under "Type switching").
+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;
@@ -951,6 +951,12 @@ class CreateDrawing(bpy.types.Operator):
tree = ifcopenshell.geom.tree()
tree.enable_face_styles(True)
# Accumulated across every file in the loop below (main model plus any
# linked models) so the SHAPELY fill pass after the loop covers all of
# them, not just whichever file happened to be processed last.
raycast_objs = set()
elements_with_faces = set()
for ifc_path, (ifc, link_matrix) in files.items():
# Don't use draw.main() just whilst we're prototyping and experimenting
# TODO: hash paths are never used
@@ -960,6 +966,15 @@ class CreateDrawing(bpy.types.Operator):
self.serialiser.setFile(ifc)
drawing_elements = tool.Drawing.get_drawing_elements(self.camera_element, ifc_file=ifc)
if self.cprops.fill_mode == "SHAPELY":
for element in drawing_elements.copy():
if element.is_a("IfcAnnotation"):
continue
obj = tool.Ifc.get_object(element)
if obj and obj.type == "MESH" and len(obj.data.polygons):
elements_with_faces.add(element.GlobalId)
raycast_objs.add(obj)
# Get all representation contexts to see what we're dealing with.
# Drawings only draw bodies and annotations (and facetation, due to a Revit bug).
# A drawing prioritises a target view context first, followed by a model view context as a fallback.
@@ -1033,16 +1048,6 @@ class CreateDrawing(bpy.types.Operator):
# shapely variant
group = root.find("{http://www.w3.org/2000/svg}g")
raycast_objs = set()
elements_with_faces = set()
for element in drawing_elements.copy():
if element.is_a("IfcAnnotation"):
continue
obj = tool.Ifc.get_object(element)
if obj and obj.type == "MESH" and len(obj.data.polygons):
elements_with_faces.add(element.GlobalId)
raycast_objs.add(obj)
projections = root.xpath(
".//svg:g[contains(@class, 'projection')]", namespaces={"svg": "http://www.w3.org/2000/svg"}
)
+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")
@@ -69,7 +69,6 @@ classes = (
operator.RemoveRepresentation,
operator.RemoveRepresentationItem,
operator.RemoveRepresentationItemFromShapeAspect,
operator.SelectByRepresentationType,
operator.SelectConnection,
operator.SelectRepresentationItem,
operator.SwitchRepresentation,
@@ -138,11 +138,6 @@ class RepresentationsData:
"ContextType": representation.ContextOfItems.ContextType or "",
"ContextIdentifier": "",
"TargetView": "",
# The representation's own identifier (e.g. 'Body', 'Reference'), which is
# distinct from the subcontext's ContextIdentifier above. Two reps can share
# one context (e.g. a Body body and a Reference opening template), so showing
# this lets them be told apart in the panel.
"RepresentationIdentifier": representation.RepresentationIdentifier or "",
"RepresentationType": representation_type or "",
"is_active": is_active,
}
@@ -418,55 +418,6 @@ class SelectConnection(bpy.types.Operator, tool.Ifc.Operator):
core.select_connection(tool.Geometry, connection=tool.Ifc.get().by_id(self.connection))
class SelectByRepresentationType(bpy.types.Operator):
bl_idname = "bim.select_by_representation_type"
bl_label = "Select By Representation Type"
bl_description = (
"Select objects whose active representation matches this type. "
"Ctrl+Click to also include objects that have this type in any representation (active or not)"
)
bl_options = {"REGISTER", "UNDO"}
representation_type: bpy.props.StringProperty()
select_inactive: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"})
def invoke(self, context, event):
self.select_inactive = event.ctrl
return self.execute(context)
def execute(self, context):
ifc = tool.Ifc.get()
if not ifc:
return {"CANCELLED"}
# Strip the "*" suffix used for mapped/resolved representations.
target_type = self.representation_type.rstrip("*")
matched = 0
for obj in context.visible_objects:
element = tool.Ifc.get_entity(obj)
if not element:
obj.select_set(False)
continue
if self.select_inactive:
# Ctrl: match any representation on the element, active or not.
has_type = any(
(ifcopenshell.util.representation.resolve_representation(rep).RepresentationType or "") == target_type
for rep in ifcopenshell.util.representation.get_representations_iter(element)
)
else:
# Default: match only the currently active (displayed) representation.
active_rep = tool.Geometry.get_active_representation(obj)
if active_rep is None:
obj.select_set(False)
continue
resolved = ifcopenshell.util.representation.resolve_representation(active_rep)
has_type = (resolved.RepresentationType or "") == target_type
obj.select_set(has_type)
if has_type:
matched += 1
mode = "any representation" if self.select_inactive else "active representation"
self.report({"INFO"}, f"Selected {matched} object(s) with RepresentationType '{target_type}' ({mode})")
return {"FINISHED"}
class RemoveConnection(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.remove_connection"
bl_label = "Remove Connection"
@@ -759,16 +710,6 @@ class UpdateRepresentation(bpy.types.Operator, tool.Ifc.Operator):
if mprops.ifc_parameters:
core.get_representation_ifc_parameters(tool.Geometry, obj=obj)
# Persist an edited opening void onto its filling type's 'Reference' template so the
# change survives type duplication/append/switching and propagates to siblings. This
# catches the edited_objs commit path; the in-place item edit is caught in
# bim.override_mode_set_object.
edited_element = tool.Ifc.get_entity(obj)
if edited_element and edited_element.is_a("IfcOpeningElement"):
from bonsai.bim.module.model.opening import FilledOpeningGenerator
FilledOpeningGenerator().update_type_template_from_opening(edited_element)
class UpdateParametricRepresentation(bpy.types.Operator):
bl_idname = "bim.update_parametric_representation"
@@ -2548,15 +2489,6 @@ class OverrideModeSetObject(bpy.types.Operator, tool.Ifc.Operator):
return bpy.ops.bim.edit_boundary_geometry()
elif tool.Geometry.is_representation_item(context.active_object):
self.edit_representation_item(context.active_object)
# If we just edited an opening's void item, persist the new shape onto the
# filling type's 'Reference' template so it survives type duplication/append/
# switching and propagates to siblings.
rep_obj = tool.Geometry.get_geometry_props().representation_obj
edited_element = tool.Ifc.get_entity(rep_obj) if rep_obj else None
if edited_element and edited_element.is_a("IfcOpeningElement"):
from bonsai.bim.module.model.opening import FilledOpeningGenerator
FilledOpeningGenerator().update_type_template_from_opening(edited_element)
tool.Root.reload_item_decorator()
# So you can keep hitting tab to cycle out of edit mode
context.active_object.select_set(False)
+1 -18
View File
@@ -148,29 +148,12 @@ class BIM_PT_representations(Panel):
self.layout.label(text="No Representations Found")
return
header = self.layout.row(align=True)
header.label(text="Context")
header.label(text="Subcontext")
header.label(text="View")
header.label(text="Identifier")
header.label(text="Type")
# Blank icon cells reserve the same width as the switch/remove buttons below so the
# text columns line up with the data rows.
header.label(text="", icon="BLANK1")
header.label(text="", icon="BLANK1")
for representation in RepresentationsData.data["representations"]:
row = self.layout.row(align=True)
row.label(text=representation["ContextType"])
row.label(text=representation["ContextIdentifier"])
row.label(text=representation["TargetView"])
row.label(text=representation["RepresentationIdentifier"])
op = row.operator(
"bim.select_by_representation_type",
text=representation["RepresentationType"],
emboss=False,
)
op.representation_type = representation["RepresentationType"]
row.label(text=representation["RepresentationType"])
op = row.operator(
"bim.switch_representation",
icon="FILE_REFRESH" if representation["is_active"] else "OUTLINER_DATA_MESH",
@@ -41,12 +41,6 @@ def load_post(*args):
profile.DumbProfileRegenerator().regenerate_from_profile,
)
ifcopenshell.api.add_pre_listener(
"type.assign_type",
"Bonsai.Opening.PreserveOnTypeChange",
opening.FilledOpeningGenerator().preserve_opening_on_type_change,
)
ifcopenshell.api.add_post_listener(
"type.assign_type",
"Bonsai.Opening.RegenerateFromType",
@@ -420,25 +420,6 @@ class FilledOpeningGenerator:
tool.Geometry.recut_host(voided_obj, representation)
def preserve_opening_on_type_change(
self, usecase_path: str, ifc_file: ifcopenshell.file, settings: dict[str, Any]
) -> None:
"""Pre-listener for type.assign_type: anchor the old type's void before reassigning.
A custom void that lives only on an occurrence (the type has no 'Reference'
template) would be lost when that occurrence is moved to another type - the
post-assign regeneration replaces it. Promoting it onto its current type first
keeps it durable, so switching back later restores it. Idempotent and only acts on
genuinely custom (non-extrusion) voids.
"""
relating_type = settings.get("relating_type")
for related_object in settings.get("related_objects") or []:
if not getattr(related_object, "FillsVoids", None):
continue
old_type = ifcopenshell.util.element.get_type(related_object)
if old_type and old_type != relating_type:
self.promote_opening_to_type(old_type)
def regenerate_from_type(self, usecase_path: str, ifc_file: ifcopenshell.file, settings: dict[str, Any]) -> None:
relating_type = settings["relating_type"]
@@ -456,13 +437,6 @@ class FilledOpeningGenerator:
opening = filling.FillsVoids[0].RelatingOpeningElement
voided_element = opening.VoidsElements[0].RelatingBuildingElement
# Always regenerate the opening to reflect the *assigned* type's void: its
# 'Reference' template if it has one (generate_opening_from_filling consults it),
# else a sibling occurrence's opening, else a generated extrusion. We deliberately
# do NOT preserve the previous type's custom void on a type change - a custom void
# now survives duplicate_type/append by being anchored on the type as a template
# (promote_opening_to_type / harvest), so keeping the old void here would just show
# the wrong type's opening (e.g. switching to a plain type would keep the faceset).
opening_rep = ifcopenshell.util.representation.get_representation(opening, "Model", "Body", "MODEL_VIEW")
ifcopenshell.api.geometry.unassign_representation(tool.Ifc.get(), product=opening, representation=opening_rep)
ifcopenshell.api.geometry.remove_representation(tool.Ifc.get(), representation=opening_rep)
@@ -519,14 +493,6 @@ class FilledOpeningGenerator:
profile = None
filling_type = ifcopenshell.util.element.get_type(filling)
if filling_type:
# A stored opening template (e.g. a custom IfcPolygonalFaceSet carried
# across bim.duplicate_type / append) takes priority over generating a
# default extrusion. Returning the shared template representation lets the
# caller's map_representation reuse its IfcRepresentationMap, so this
# opening stays in sync with the type template and its sibling occurrences.
opening_template = self.get_type_opening_representation(filling_type)
if opening_template is not None:
return opening_template
profile = ifcopenshell.util.representation.get_representation(
filling_type, "Model", "Profile", "ELEVATION_VIEW"
)
@@ -624,228 +590,6 @@ class FilledOpeningGenerator:
return True
return False
def is_opening_representation_custom(self, opening: ifcopenshell.entity_instance) -> bool:
"""Whether the opening's Body has user-authored geometry rather than a generated extrusion.
Openings produced by ``generate_opening_from_filling`` always consist of a
single ``IfcExtrudedAreaSolid``. Anything else (a tessellation such as an
``IfcPolygonalFaceSet``, a brep, a CSG solid, etc.) was authored by the user
and must not be silently replaced with a default extrusion.
"""
representation = ifcopenshell.util.representation.get_representation(opening, "Model", "Body", "MODEL_VIEW")
if not representation:
return False
representation = ifcopenshell.util.representation.resolve_representation(representation)
return any(not item.is_a("IfcExtrudedAreaSolid") for item in representation.Items)
def should_preserve_opening(self, opening: ifcopenshell.entity_instance) -> bool:
"""Whether an opening's geometry is worth anchoring on the type as a template.
True for user-authored geometry (a tessellation, brep, etc.) or a *manually adjusted*
extrusion - one that no longer matches the default ``generate_opening_from_filling``
would produce for its filling. A plain generated extrusion is regenerable, so it
returns False and is left to regenerate.
"""
if self.is_opening_representation_custom(opening):
return True
return self._is_adjusted_extrusion(opening)
def _is_adjusted_extrusion(self, opening: ifcopenshell.entity_instance) -> bool:
"""Whether the opening's extrusion diverges from the default for its filling.
Generates the default transiently, compares the axis-aligned bounding boxes of the
two bodies (both in the opening's local frame), then removes the temporary default.
A conservative False is returned when the default cannot be computed.
"""
filling = opening.HasFillings[0].RelatedBuildingElement if getattr(opening, "HasFillings", None) else None
filling_obj = tool.Ifc.get_object(filling) if filling else None
current = ifcopenshell.util.representation.get_representation(opening, "Model", "Body", "MODEL_VIEW")
if not filling_obj or current is None:
return False
current = ifcopenshell.util.representation.resolve_representation(current)
default_representation = self.generate_opening_from_filling(filling, filling_obj)
try:
settings = ifcopenshell.geom.settings()
current_bbox = self._representation_bbox(settings, current)
default_bbox = self._representation_bbox(settings, default_representation)
finally:
ifcopenshell.api.geometry.remove_representation(tool.Ifc.get(), representation=default_representation)
if current_bbox is None or default_bbox is None:
return False
(cur_min, cur_max), (def_min, def_max) = current_bbox, default_bbox
tolerance = 1e-3 # 1 mm; differing extents/position => manually adjusted
return bool(np.any(np.abs(cur_min - def_min) > tolerance) or np.any(np.abs(cur_max - def_max) > tolerance))
@staticmethod
def _representation_bbox(settings: Any, representation: ifcopenshell.entity_instance):
try:
geometry = ifcopenshell.geom.create_shape(settings, representation)
except Exception:
return None
verts = ifcopenshell.util.shape.get_vertices(geometry)
if len(verts) == 0:
return None
return verts.min(axis=0), verts.max(axis=0)
def get_type_opening_representation(
self, filling_type: ifcopenshell.entity_instance
) -> Union[ifcopenshell.entity_instance, None]:
"""Return the type's stored opening template (its 'Reference' representation), if any.
The template is the shared opening body anchored on the type as a
'Reference'-identified representation map (see
:meth:`set_type_opening_representation`). Storing it on the type lets a
custom opening survive ``bim.duplicate_type`` and project append, which copy
the type's ``RepresentationMaps`` but not an opening shared only between
occurrences.
"""
for representation_map in filling_type.RepresentationMaps or []:
representation = representation_map.MappedRepresentation
if representation.RepresentationIdentifier == "Reference":
return representation
def set_type_opening_representation(
self, filling_type: ifcopenshell.entity_instance, representation: ifcopenshell.entity_instance
) -> None:
"""Anchor an opening body representation on the type as its 'Reference' template.
``representation`` is tagged 'Reference' (so it is excluded from the
occurrence body geometry, see
``ifcopenshell.api.type.map_type_representations``) and the
``IfcRepresentationMap`` wrapping it is registered in the type's
``RepresentationMaps``, replacing any previous 'Reference' map. The existing map
is reused when present so that occurrences mapping over it stay in sync with the
type template. Idempotent.
"""
ifc_file = tool.Ifc.get()
representation.RepresentationIdentifier = "Reference"
representation_map = next(
(i for i in ifc_file.get_inverse(representation) if i.is_a("IfcRepresentationMap")), None
)
if representation_map is None:
mapping_origin = ifc_file.createIfcAxis2Placement3D(
ifc_file.createIfcCartesianPoint((0.0, 0.0, 0.0)),
ifc_file.createIfcDirection((0.0, 0.0, 1.0)),
ifc_file.createIfcDirection((1.0, 0.0, 0.0)),
)
representation_map = ifc_file.createIfcRepresentationMap(mapping_origin, representation)
# Keep all non-'Reference' maps (Body, Annotation, ...) plus this one, dropping any
# previous 'Reference' template so the type carries exactly one.
new_maps = [
m
for m in (filling_type.RepresentationMaps or [])
if m == representation_map or m.MappedRepresentation.RepresentationIdentifier != "Reference"
]
if representation_map not in new_maps:
new_maps.append(representation_map)
filling_type.RepresentationMaps = new_maps
def update_type_template_from_opening(self, opening: ifcopenshell.entity_instance) -> None:
"""Write an edited opening's geometry back to its filling type's 'Reference' template.
After a user edits an opening's void shape, anchor the new geometry on the type so
the change is durable (survives duplicate_type/append and switching the type away
and back) and propagates to sibling occurrences. Only acts on custom (non-extrusion)
geometry; a re-generated extrusion needs no template.
"""
if not getattr(opening, "HasFillings", None) or not self.is_opening_representation_custom(opening):
return
ifc_file = tool.Ifc.get()
new_representation = ifcopenshell.util.representation.get_representation(opening, "Model", "Body", "MODEL_VIEW")
if not new_representation:
return
new_representation = ifcopenshell.util.representation.resolve_representation(new_representation)
voided_objs_to_reload: set[bpy.types.Object] = set()
for rel in opening.HasFillings:
filling_type = ifcopenshell.util.element.get_type(rel.RelatedBuildingElement)
if not filling_type:
continue
old_template = self.get_type_opening_representation(filling_type)
if old_template is not None and old_template != new_representation:
# The edit gave this opening its own geometry; re-point the shared template
# map - and therefore every sibling occurrence mapping over it - at the
# edited geometry, then drop the now-orphaned old template.
for inverse in ifc_file.get_inverse(old_template):
if inverse.is_a("IfcRepresentationMap"):
inverse.MappedRepresentation = new_representation
ifcopenshell.api.geometry.remove_representation(ifc_file, representation=old_template)
self.set_type_opening_representation(filling_type, new_representation)
# Re-map every other occurrence's opening onto the type template so the edit
# propagates even to siblings that have their own independent opening geometry
# (i.e. openings that never shared the template's IfcRepresentationMap).
for occurrence in ifcopenshell.util.element.get_types(filling_type):
sibling_opening = (
occurrence.FillsVoids[0].RelatingOpeningElement
if getattr(occurrence, "FillsVoids", None)
else None
)
if not sibling_opening or sibling_opening == opening:
continue
if not self._remap_opening_to_template(sibling_opening, new_representation):
continue
if sibling_opening.VoidsElements:
voided_element = sibling_opening.VoidsElements[0].RelatingBuildingElement
for part in ifcopenshell.util.element.get_parts(voided_element) or [voided_element]:
if voided_obj := tool.Ifc.get_object(part):
voided_objs_to_reload.add(voided_obj)
# Reload affected host objects so the viewport re-booleans with the propagated void.
for voided_obj in voided_objs_to_reload:
representation = tool.Geometry.get_active_representation(voided_obj)
if representation:
bonsai.core.geometry.switch_representation(
tool.Ifc, tool.Geometry, obj=voided_obj, representation=representation
)
def _remap_opening_to_template(
self, opening: ifcopenshell.entity_instance, template_representation: ifcopenshell.entity_instance
) -> bool:
"""Point an opening's Body at the shared type template, purging its old standalone body.
:return: True if the opening was changed, False if it already maps over the template.
"""
ifc_file = tool.Ifc.get()
old_body = ifcopenshell.util.representation.get_representation(opening, "Model", "Body", "MODEL_VIEW")
if old_body is not None and (
ifcopenshell.util.representation.resolve_representation(old_body) == template_representation
):
return False
mapped_representation = ifcopenshell.api.geometry.map_representation(
ifc_file, representation=template_representation
)
# The mapped wrapper is the opening's own Body (the 'Reference' identifier belongs to
# the type template it maps over, not to the occurrence's representation).
mapped_representation.RepresentationIdentifier = "Body"
if old_body is not None:
ifcopenshell.api.geometry.unassign_representation(ifc_file, product=opening, representation=old_body)
ifcopenshell.api.geometry.remove_representation(ifc_file, representation=old_body)
ifcopenshell.api.geometry.assign_representation(ifc_file, product=opening, representation=mapped_representation)
return True
def promote_opening_to_type(self, filling_type: ifcopenshell.entity_instance) -> None:
"""Promote a custom opening from an occurrence to a 'Reference' template on the type.
Called before a type is copied (``bim.duplicate_type``) so that a custom
(non-extrusion) opening, currently shared only between occurrences, is
anchored on the type itself and therefore carried to the copy. No-op if the
type already has a template or has no custom opening to promote.
"""
if self.get_type_opening_representation(filling_type):
return
for occurrence in ifcopenshell.util.element.get_types(filling_type):
if not getattr(occurrence, "FillsVoids", None):
continue
opening = occurrence.FillsVoids[0].RelatingOpeningElement
if not self.should_preserve_opening(opening):
continue
representation = ifcopenshell.util.representation.get_representation(
opening, "Model", "Body", "MODEL_VIEW"
)
representation = ifcopenshell.util.representation.resolve_representation(representation)
self.set_type_opening_representation(filling_type, representation)
return
def get_existing_opening_occurrence_if_any(
self, filling: ifcopenshell.entity_instance
) -> Union[ifcopenshell.entity_instance, None]:
@@ -1256,9 +1000,6 @@ class EditOpenings(Operator, tool.Ifc.Operator):
building_objs.update(similar_openings_building_objs)
if opening_edited:
tool.Geometry.run_geometry_update_representation(obj=opening_obj)
# Persist the edited void onto the filling type's 'Reference' template so
# it survives type duplication/append/switching and propagates to siblings.
self.update_type_template_from_opening(opening_element)
else:
bonsai.core.geometry.edit_object_placement(
tool.Ifc, tool.Geometry, tool.Surveyor, obj=opening_obj
@@ -633,7 +633,6 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator):
if not element:
return {"FINISHED"}
if element.is_a("IfcTypeProduct"):
self.harvest_opening_template(element, library_file)
self.import_type_from_ifc(element, context)
elif element.is_a("IfcProduct"):
# NOTE: Non-types are not exposed in UI directly
@@ -659,57 +658,6 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator):
bonsai.bim.handler.refresh_ui_data()
return {"FINISHED"}
def harvest_opening_template(
self, type_element: ifcopenshell.entity_instance, library_file: ifcopenshell.file
) -> None:
"""Seed the appended type's 'Reference' opening template from a library instance.
A type carries no opening of its own (openings are occurrence-level via
IfcRelVoidsElement), so a custom opening would otherwise be lost on append and
regenerated as a default extrusion when occurrences are placed. If the library
file has an instance of this type whose opening is custom (non-extrusion), copy
that opening body onto the appended type as its 'Reference' template. No-op when
the type already carries a template (e.g. a Bonsai-authored library) or the
library has no such instance.
"""
from bonsai.bim.module.model.opening import FilledOpeningGenerator
generator = FilledOpeningGenerator()
if generator.get_type_opening_representation(type_element):
return
library_type = library_file.by_id(self.definition)
if not library_type.is_a("IfcTypeProduct"):
return
for occurrence in ifcopenshell.util.element.get_types(library_type):
if not getattr(occurrence, "FillsVoids", None):
continue
opening = occurrence.FillsVoids[0].RelatingOpeningElement
library_representation = ifcopenshell.util.representation.get_representation(
opening, "Model", "Body", "MODEL_VIEW"
)
if not library_representation:
continue
library_representation = ifcopenshell.util.representation.resolve_representation(library_representation)
if all(item.is_a("IfcExtrudedAreaSolid") for item in library_representation.Items):
continue # A generated extrusion - nothing custom worth preserving.
project_file = tool.Ifc.get()
representation = project_file.add(library_representation)
# file.add brings the library's own representation context across; point the
# copy at the project's Body context and drop the now-orphaned duplicate.
body_context = ifcopenshell.util.representation.get_context(
project_file, "Model", "Body", "MODEL_VIEW"
)
if body_context and representation.ContextOfItems != body_context:
orphan_context = representation.ContextOfItems
representation.ContextOfItems = body_context
if not project_file.get_inverse(orphan_context):
project_file.remove(orphan_context)
generator.set_type_opening_representation(type_element, representation)
return
def import_material_from_ifc(self, element: ifcopenshell.entity_instance, context: bpy.types.Context) -> None:
self.file = tool.Ifc.get()
logger = logging.getLogger("ImportIFC")
@@ -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.")
@@ -375,14 +375,6 @@ class DuplicateType(bpy.types.Operator, tool.Ifc.Operator):
obj = tool.Ifc.get_object(element)
if not obj:
return {"FINISHED"}
# Anchor any custom (non-extrusion) opening on the source type before the
# copy so it is carried to the duplicate as a 'Reference' template, rather
# than regenerated as a default extrusion on the new type's occurrences.
if element.is_a("IfcElementType"):
from bonsai.bim.module.model.opening import FilledOpeningGenerator
FilledOpeningGenerator().promote_opening_to_type(element)
new_obj = obj.copy()
if obj.data:
new_obj.data = obj.data.copy()
+1 -17
View File
@@ -1206,23 +1206,7 @@ class Geometry(bonsai.core.tool.Geometry):
for element in element_types:
if obj := tool.Ifc.get_object(element):
# A type may hold several representations in one context (e.g. a 'Body' body
# plus a 'Reference' opening template), and get_representation() matches only
# by context. When base_representation is one of this type's own
# representations - i.e. we are reimporting it directly, such as switching to
# the Reference rep - render exactly that, otherwise the context lookup could
# return the wrong one. But element_types also contains each occurrence's
# type (see above), for which base_representation is not theirs; fall back to
# the context lookup there (and skip, as before, when it has none).
type_representations = [
ifcopenshell.util.representation.resolve_representation(rm.MappedRepresentation)
for rm in (element.RepresentationMaps or [])
]
if base_representation in type_representations:
representation = base_representation
else:
representation = ifcopenshell.util.representation.get_representation(element, context)
if representation:
if representation := ifcopenshell.util.representation.get_representation(element, context):
geometry = ifcopenshell.geom.create_shape(settings, representation)
mesh_name = tool.Loader.get_mesh_name_from_shape(geometry)
mesh = meshes.get(mesh_name)
-7
View File
@@ -2124,13 +2124,6 @@ class Model(bonsai.core.tool.Model):
if voided_obj is not None:
voided_objs.add(voided_obj)
# Preserve user-authored opening geometry (e.g. an IfcPolygonalFaceSet
# or other tessellation) instead of replacing it with a default extrusion.
from bonsai.bim.module.model.opening import FilledOpeningGenerator
if FilledOpeningGenerator().is_opening_representation_custom(opening):
continue
body = tool.Geometry.get_body_representation(opening)
if body is None:
continue
+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))
@@ -94,13 +94,6 @@ def map_type_representations(
ifcopenshell.api.geometry.remove_representation(file, representation=representation)
for representation_map in relating_type.RepresentationMaps:
representation = representation_map.MappedRepresentation
# 'Reference' representations are, per IfcShapeRepresentation, "not part of
# the Body representation" (used e.g. for opening geometries excluded from an
# implicit Boolean operation). They may be carried on a type purely as a
# template (e.g. a shared opening body) and must not be mapped onto
# occurrences as their own geometry.
if representation.RepresentationIdentifier == "Reference":
continue
mapped_representation = ifcopenshell.api.geometry.map_representation(file, representation=representation)
ifcopenshell.api.geometry.assign_representation(
file,