mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-06 16:01:36 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0716e5038e | |||
| b928902e3c | |||
| 1614791775 |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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").
|
||||
@@ -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"}
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user