Compare commits

...

7 Commits

Author SHA1 Message Date
Ryan Schultz d0ebdd53c9 Bonsai: normalize occurrence-local representations onto the type
Adds tooling to surface and normalize representations that live on an
occurrence but not its type, aligning with the convention that typed
occurrences share the type's representations (per maintainer feedback on #8788).

- Promote to Type (bim.promote_representation_to_type): slot-based, "type wins".
  Copies the promoted rep onto the type as a RepresentationMap, then for every
  occurrence of the type removes ANY existing rep in the same slot -- context
  (context/subcontext/target view) + RepresentationIdentifier + resolved
  RepresentationType -- and assigns the type's mapped rep in its place;
  occurrences with none inherit it. Covers both local reps and reps inherited
  from a floating IfcRepresentationMap not anchored to the type (Revit exports),
  so no duplicate is left; removes the type's existing slot map first so
  promoting is idempotent. Geometry is not compared, so independently-meshed /
  mirrored / rotated instances are consolidated too. Adds tool.Geometry
  copy_representation_deep and add_type_representation_map.
- Representations panel: group rows under Type (mapped/inherited) vs Occurrence
  (local) headers so occurrence-local reps are surfaced. Adds is_mapped /
  element_is_type / element_has_type to RepresentationsData; drops the old "*"
  suffix.

Copy and add-representation behaviour is unchanged from base. Design note at
docs/dev-notes/occurrence-representations.md.

Issue: #8788

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-01 08:33:18 -05:00
Ryan Schultz 8ece3790aa Update opening-template dev-note
Reflect void-propagation-to-all-occurrences and adjusted-extrusion
preservation added to #8200 since the note was seeded.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 18:56:26 -05:00
Ryan Schultz 6585f0ee2b Add dev-notes convention for feature branches
Introduce docs/dev-notes/ for living design notes on unmerged feature
branches (one Markdown file per branch), so collaborators and the AI agents
they work with can pick up a branch's context from the diff. Documented in
AGENTS.md and a directory README; seeded with the opening-template-on-type
note.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 18:56:26 -05:00
Ryan Schultz 4ec042595e Preserve adjusted extrusion openings on duplicate
promote_opening_to_type now preserves an extrusion opening that was manually
adjusted away from the default - detected by comparing its bounding box to a
freshly generated default - not only non-extrusion geometry. The generate-and-
compare check is scoped to the duplicate path via should_preserve_opening.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:22:33 -05:00
Ryan Schultz 48f6e2908b Propagate edited void to all type occurrences
update_type_template_from_opening now re-maps every occurrence's opening onto
the type's Reference template (not only ones already sharing its map) and
reloads the affected host walls, so editing one void updates all instances
even when their openings were independent.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:22:33 -05:00
Ryan Schultz 1cd7e52c49 Helps with #7853: Select objects by RepresentationType from panel
Clicking the RepresentationType label in the Representations
panel selects all visible objects whose active representation
matches that type. Ctrl+Click broadens the selection to any
object that has the type in any of its representations,
whether currently active or not.

Generated with the assistance of an AI coding tool.
2026-07-05 15:22:33 -05:00
Ryan Schultz 2e6f17ed0f Preserve custom opening geometry via a type-level Reference template
Custom IfcOpeningElement voids (e.g. an IfcPolygonalFaceSet / tessellation)
were lost - reset to a default extrusion - on bim.duplicate_type, project
append, and type switching, because the void lived only on occurrences and
nothing carried it to a new type.

Anchor the shared opening body on the filling type as a 'Reference'
representation map (per IfcShapeRepresentation, 'Reference' is geometry "not
part of the Body representation", used for opening geometries excluded from an
implicit Boolean operation). bim.duplicate_type and append copy a type's
RepresentationMaps, so the template survives; generate_opening_from_filling
consults it before falling back to a generated extrusion.

- map_type_representations: skip 'Reference' maps so occurrences don't receive
  the opening shape as their own Body geometry.
- opening.py: get_/set_type_opening_representation, promote_opening_to_type,
  update_type_template_from_opening; pre/post type.assign_type listeners
  (anchor the old type's void before a switch; regenerate to the assigned
  type's void afterwards, replacing the previous "preserve custom" guard).
- DuplicateType promotes the void before copy; AppendLibraryElement harvests
  the template cross-file from a library instance.
- Write-back on void edit, hooked at both commit paths (UpdateRepresentation
  and OverrideModeSetObject).
- reimport_element_representations renders the requested representation, so
  switching a type to its Reference row shows the void rather than the body.
- Representations panel shows RepresentationIdentifier plus column headers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:21:49 -05:00
17 changed files with 998 additions and 7 deletions
+15
View File
@@ -129,6 +129,21 @@ on CI to catch formatting issues.
within each package under `src/`.
- Run the existing test suite for the package you modified before submitting.
## In-Progress Feature Notes
Living design and working notes for unmerged feature branches live in
[`docs/dev-notes/`](docs/dev-notes/), one Markdown file per feature, named after the
branch. They capture the problem, the design decisions and the *why*, and what still
needs testing — so collaborators (and their AI agents) can pick up the context behind a
branch. Because the note is committed on the branch, it travels with the PR.
- Before working on a feature branch, read its note in `docs/dev-notes/` if one exists.
- Keep the note current as the PR is refined.
- These are not user documentation; at merge they are removed or their durable parts
promoted to code comments / permanent docs.
See [`docs/dev-notes/README.md`](docs/dev-notes/README.md) for details.
## Architecture Quick Reference
### Directory Structure
+28
View File
@@ -0,0 +1,28 @@
<!-- 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.
@@ -0,0 +1,135 @@
<!-- This file was generated with the assistance of an AI coding tool. -->
# Occurrence representations — normalize occurrence-local reps onto the type
> **Living dev note** for the `occurrence-representations` 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.
Tracking issue: [#8788](https://github.com/IfcOpenShell/IfcOpenShell/issues/8788). Supersedes
the closed PR #8789 (see "History / pivot"). Stacked on `dev-notes-system` (#8201) →
`opening-template-on-type` (#8200) → `select-by-representation-type` (#7916), because it shares
the Representations panel and `RepresentationsData`.
## Position (maintainer-aligned)
Per Dion Moult (project lead), **if a type has representations, its occurrences should share
them** — an occurrence carrying a representation the type lacks is an *anomaly to normalize up
to the type*, not something to preserve. This follows the MVD concept-template intent (mapped
representations mirror the type relationship) and the `IfcTypeProduct` text that typed
occurrences "have to reference the representation maps", even though EXPRESS has no WHERE rule
enforcing it. See the buildingSMART thread Moult started:
<https://forums.buildingsmart.org/t/must-mappedrepresentations-come-from-the-corresponding-ifc-type/3361>.
This branch therefore provides the **normalization path**, and deliberately does *not* try to
make occurrence-local reps a first-class, persisted thing.
## Scope
**In:**
1. **Promote to Type** (`bim.promote_representation_to_type`) — lift an occurrence-local rep
onto its type as a `RepresentationMap`, so occurrences inherit it. The migration tool for
imported/legacy models (Revit et al. emit occurrence-only / partial-from-type reps).
2. **Type / Occurrence panel split**`BIM_PT_representations` groups rows under **Type**
(mapped/inherited) vs **Occurrence** (local) headers, so an anomalous occurrence-local rep
is *surfaced* instead of silent.
**Deliberately out (dropped from the earlier draft):**
- **Copy-time preservation** — `copy_class` is left as-is (occurrence-only reps are not
re-added on duplicate). Preserving them perpetuates the anomaly; normalize first, then copy.
- **"Add to Occurrence" toggle** — removed. `add_representation` keeps stock behaviour
(`geometry.assign_representation` already redirects a new rep onto the type when the type has
maps). No force-local override.
## Design
### Promote to Type (slot-based, "type wins")
`bim.promote_representation_to_type` (`EXPORT` icon on Occurrence rows, only when
`element_has_type`) → `core.geometry.promote_representation_to_type`. Copies the promoted rep
onto the type as a new `RepresentationMap` (`tool.Geometry.add_type_representation_map`), then for
**every** occurrence of the type: removes **any** existing rep in the same **slot** and assigns
the type's mapped rep in its place. Occurrences with no rep in the slot simply inherit it.
"Any existing rep" is the load-bearing part: it covers a **local** (non-mapped) rep *and* an
already-**mapped** rep the occurrence inherited from another map — e.g. a floating
`IfcRepresentationMap` not anchored to the type, which Revit emits (each occurrence maps to its
own or a shared floating map, the type's `RepresentationMaps` is empty). Local reps are removed
via `core.remove_representation` (Blender-aware); mapped reps via a per-occurrence
`geometry.unassign_representation` + `geometry.remove_representation` (its `remove_deep2` keeps a
shared map alive until its last user is gone, so floating maps get garbage-collected). If the
type already holds a rep in the slot it is removed too, so promoting is idempotent (replaces
rather than accumulating maps). The slot key resolves through mapped items
(`resolve_mapped_representation`), because an inherited rep's own `RepresentationType` is
`"MappedRepresentation"`, not the underlying type.
The slot key is context (context/subcontext/target view) + `RepresentationIdentifier` +
resolved `RepresentationType`. **Geometry is not compared** — the type's representation replaces the
occurrence's for that slot even when the occurrence's geometry genuinely differs (e.g. an
independently meshed / mirrored / rotated Revit instance), so such occurrences visibly adopt the
type's geometry. The mapped rep uses `map_representation`'s identity transform, so a divergent
instance takes the type geometry at *its own placement* (baked per-instance mesh orientation is
lost — the accepted tradeoff of "type wins").
Rationale for dropping the earlier geometry comparison: for Revit-style imports each occurrence
carries an independently tessellated body (same vertex count but reordered + reoriented; no
single affine maps one to another, confirmed via a least-squares fit — `max_err ≈ 1.3 m`), so an
"only consolidate byte-identical" rule left most real-world duplicates unconsolidated. Slot-based
replace is the deliberate, user-chosen behaviour.
### Panel split
`RepresentationsData` (geometry/data.py) exposes `is_mapped`
(`resolve_representation(rep) != rep`), `element_is_type`, and `element_has_type`.
`draw_representation_row` is shared and carries the stack's `RepresentationIdentifier` column +
`select_by_representation_type` button; the Occurrence-group rows additionally show the promote
button. A type element shows a flat list.
## The divergent-occurrence case (decision made)
Two occurrences of one type that carry **different** geometry in the same slot cannot both live
on the type (one mapped rep per slot). The chosen resolution is **"type wins"**: promote
replaces every occurrence's local rep in that slot with the type's, discarding divergent
per-instance geometry. This favours a single authoritative type geometry over preserving
independently-authored instance bodies. (Intrinsic per-instance geometry — voids/joins;
`IfcRelVoidsElement` is occurrence-only — lives in a *different* mechanism and is unaffected.)
The broader "can occurrences ever legitimately diverge" question is still worth raising with
Moult on #8788, but Promote no longer tries to adjudicate it.
## Status — implemented (verified in live Blender)
- `tool/geometry.py`: `copy_representation_deep`, `add_type_representation_map`.
- `core/geometry.py`: `promote_representation_to_type` (slot-based).
- `core/tool.py`: interface decls for the two new `Geometry` methods.
- `bim/module/geometry/operator.py`: `PromoteRepresentationToType`.
- `bim/module/geometry/{data,ui}.py`: `is_mapped` / `element_is_type` / `element_has_type`;
Type/Occurrence grouping merged with the stack's panel columns; old `*` suffix removed.
- `bim/module/geometry/__init__.py`: register `PromoteRepresentationToType`.
- `core/root.py`, `tool/root.py`, `core/geometry.py::add_representation`: reverted to base
(copy-preservation + add-to-occurrence removed).
## History / pivot
Originally four pieces incl. a `copy_class` fix that re-added occurrence-only reps on duplicate,
and an "Add to Occurrence" toggle. PR #8789 was closed by Moult as "based on the wrong premise
— there shouldn't be representations on occurrence and not on type if the type has
representations." Re-scoped to the normalization-only subset above; copy-preservation and the
toggle removed.
## Things to test / verify
- Promote (verified on a Revit sink type, 5 occurrences): every occurrence ends up referencing
the type's mapped rep — occurrences with a local body in the slot have it replaced (including
independently-meshed/mirrored ones, which visibly adopt the type geometry), and occurrences
with none inherit it. Exercises `remove_representation`'s Blender mesh/data-link side effects.
- Promoting a second slot (e.g. Body/PLAN_VIEW/Curve3D) adds a second `RepresentationMap` and all
occurrences inherit both.
- Re-open the saved IFC and confirm the mapped instances render sensibly (the divergent ones will
have changed orientation — that's the accepted "type wins" tradeoff, not a bug).
- Panel: Type vs Occurrence grouping correct for occurrence, typed occurrence with no local
reps (only Type header), typeless element (only Occurrence), and a type element (flat list);
columns still align with the stack's header row.
- Confirm copy/add behave as stock v0.8.0 (no regression from the removed pieces).
+165
View File
@@ -0,0 +1,165 @@
<!-- 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").
@@ -63,12 +63,14 @@ classes = (
operator.OverrideOriginSet,
operator.OverrideOutlinerDelete,
operator.OverridePasteBuffer,
operator.PromoteRepresentationToType,
operator.PurgeUnusedRepresentations,
operator.RefreshLinkedAggregate,
operator.RemoveConnection,
operator.RemoveRepresentation,
operator.RemoveRepresentationItem,
operator.RemoveRepresentationItemFromShapeAspect,
operator.SelectByRepresentationType,
operator.SelectConnection,
operator.SelectRepresentationItem,
operator.SwitchRepresentation,
+30 -4
View File
@@ -102,12 +102,32 @@ class RepresentationsData:
def load(cls):
cls.data = {"representations": cls.representations()}
cls.data["contexts"] = cls.contexts()
cls.data["element_is_type"] = cls.element_is_type()
cls.data["element_has_type"] = cls.element_has_type()
# Only after cls.representations().
cls.data["shape_aspects"] = cls.shape_aspects()
cls.is_loaded = True
@classmethod
def element_is_type(cls) -> bool:
obj = tool.Geometry.get_active_or_representation_obj()
assert obj
element = tool.Ifc.get_entity(obj)
return bool(element and element.is_a("IfcTypeProduct"))
@classmethod
def element_has_type(cls) -> bool:
"""True when the active element is an occurrence with a type — i.e. a
local representation could be promoted onto that type."""
obj = tool.Geometry.get_active_or_representation_obj()
assert obj
element = tool.Ifc.get_entity(obj)
if not element or element.is_a("IfcTypeProduct"):
return False
return bool(ifcopenshell.util.element.get_type(element))
@classmethod
def representations(cls) -> list[dict[str, Any]]:
results: list[dict[str, Any]] = []
@@ -122,11 +142,8 @@ class RepresentationsData:
active_representation_id = active_representation.id()
for representation in tool.Geometry.get_representations_iter(element):
representation_type = representation.RepresentationType
resolved_representation = ifcopenshell.util.representation.resolve_representation(representation)
if resolved_representation != representation:
representation_type = resolved_representation.RepresentationType + "*"
representation_type = resolved_representation.RepresentationType
is_active = (
representation.id() == active_representation_id
@@ -138,8 +155,17 @@ 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,
# True when this representation is an IfcMappedItem resolving to
# the type's MappedRepresentation, i.e. inherited from the type
# rather than local to the occurrence.
"is_mapped": resolved_representation != representation,
}
if representation.ContextOfItems.is_a("IfcGeometricRepresentationSubContext"):
data["ContextIdentifier"] = representation.ContextOfItems.ContextIdentifier or ""
@@ -418,6 +418,55 @@ 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"
@@ -496,6 +545,36 @@ class RemoveRepresentation(bpy.types.Operator, tool.Ifc.Operator):
self.report({"INFO"}, f"{self.bl_label} was finished in {operator_time:.2f} seconds.")
class PromoteRepresentationToType(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.promote_representation_to_type"
bl_label = "Promote Representation to Type"
bl_description = (
"Move this occurrence-local representation onto its type so occurrences can inherit it.\n"
"Occurrences with an identical local representation inherit from the type;\n"
"occurrences with a divergent local representation keep their own override"
)
bl_options = {"REGISTER", "UNDO"}
representation_id: bpy.props.IntProperty()
if TYPE_CHECKING:
representation_id: int
def _execute(self, context):
assert context.active_object
counts = core.promote_representation_to_type(
tool.Ifc,
tool.Geometry,
obj=context.active_object,
representation=tool.Ifc.get().by_id(self.representation_id),
)
self.report(
{"INFO"},
"Representation promoted to type. "
f"{counts['occurrences']} occurrence(s) now inherit it "
f"({counts['replaced']} local representation(s) replaced).",
)
class PurgeUnusedRepresentations(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.purge_unused_representations"
bl_label = "Purge Unused Representations"
@@ -710,6 +789,16 @@ 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"
@@ -2489,6 +2578,15 @@ 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)
+45 -2
View File
@@ -148,12 +148,33 @@ class BIM_PT_representations(Panel):
self.layout.label(text="No Representations Found")
return
for representation in RepresentationsData.data["representations"]:
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")
def draw_representation_row(representation, allow_promote=False):
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["RepresentationType"])
row.label(text=representation["RepresentationIdentifier"])
op = row.operator(
"bim.select_by_representation_type",
text=representation["RepresentationType"],
emboss=False,
)
op.representation_type = representation["RepresentationType"]
if allow_promote:
row.operator(
"bim.promote_representation_to_type", icon="EXPORT", text=""
).representation_id = representation["id"]
op = row.operator(
"bim.switch_representation",
icon="FILE_REFRESH" if representation["is_active"] else "OUTLINER_DATA_MESH",
@@ -163,6 +184,28 @@ class BIM_PT_representations(Panel):
op.disable_opening_subtractions = False
row.operator("bim.remove_representation", icon="X", text="").representation_id = representation["id"]
representations = RepresentationsData.data["representations"]
# For a type element every representation is its own; for an occurrence
# split them into those inherited from the type (mapped) vs those local
# to the occurrence, so it's clear which are driven by the type.
if RepresentationsData.data["element_is_type"]:
for representation in representations:
draw_representation_row(representation)
else:
type_representations = [r for r in representations if r["is_mapped"]]
occurrence_representations = [r for r in representations if not r["is_mapped"]]
# A local representation can be promoted onto the type only when the
# occurrence actually has a type to promote it onto.
allow_promote = RepresentationsData.data["element_has_type"]
if type_representations:
self.layout.label(text="Type", icon="LINKED")
for representation in type_representations:
draw_representation_row(representation)
if occurrence_representations:
self.layout.label(text="Occurrence", icon="OBJECT_DATA")
for representation in occurrence_representations:
draw_representation_row(representation, allow_promote=allow_promote)
# Presentation layers.
self.layout.separator()
if not LayersData.is_loaded:
@@ -41,6 +41,12 @@ 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,6 +420,25 @@ 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"]
@@ -437,6 +456,13 @@ 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)
@@ -493,6 +519,14 @@ 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"
)
@@ -590,6 +624,228 @@ 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]:
@@ -1000,6 +1256,9 @@ 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,6 +633,7 @@ 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
@@ -658,6 +659,57 @@ 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")
@@ -375,6 +375,14 @@ 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()
+90
View File
@@ -176,6 +176,96 @@ def remove_representation(
geometry.delete_data(data)
def promote_representation_to_type(
ifc: type[tool.Ifc],
geometry: type[tool.Geometry],
obj: bpy.types.Object,
representation: ifcopenshell.entity_instance,
) -> dict[str, int]:
"""Move an occurrence-local representation onto its type (slot-based, type wins).
The representation is copied onto the type as a mapped representation. Then,
for every occurrence of the type, **any** existing representation in the same
*slot* same context (context/subcontext/target view), same
``RepresentationIdentifier`` and same ``RepresentationType`` is removed and
replaced by the type's mapped representation. This covers both a local
(non-mapped) rep and an already-mapped rep the occurrence inherited from
another map (e.g. a floating ``IfcRepresentationMap`` not anchored to the
type, as Revit exports), so no duplicate is left. If the type already holds a
rep in the slot it is removed too, so promoting is idempotent / replaces
rather than accumulating maps.
Geometry is NOT compared: the type's representation replaces the
occurrence's for that slot, even when the occurrence's geometry differs
(e.g. an independently meshed / mirrored / rotated instance) such
occurrences visibly adopt the type's geometry.
:return: counts dict with ``replaced`` (occurrence reps removed) and
``occurrences`` (occurrences that now reference the type's mapped rep).
"""
element = ifc.get_entity(obj)
assert element
element_type = geometry.get_element_type(element)
assert element_type, "Cannot promote a representation without a type."
context = representation.ContextOfItems
identifier = representation.RepresentationIdentifier
# Compare the *resolved* type: an inherited rep is a "MappedRepresentation"
# whose real type lives on the map's target, so matching the raw
# RepresentationType would miss it and leave a duplicate.
rep_type = geometry.resolve_mapped_representation(representation).RepresentationType
def _in_slot(r: ifcopenshell.entity_instance) -> bool:
return (
r.ContextOfItems == context
and r.RepresentationIdentifier == identifier
and geometry.resolve_mapped_representation(r).RepresentationType == rep_type
)
# Copy the promoted geometry for the type up front, before any removal below
# can touch the source occurrence's representation.
type_representation = geometry.copy_representation_deep(representation)
# Snapshot every occurrence's reps in the slot (local AND mapped) so we can
# replace them -- an occurrence may already inherit a mapped rep in the slot,
# which would otherwise be left behind as a duplicate.
occurrence_reps: dict[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]] = {}
for occurrence in geometry.get_elements_of_type(element_type):
occurrence_reps[occurrence] = [r for r in geometry.get_representations_iter(occurrence) if _in_slot(r)]
counts = {"replaced": 0, "occurrences": 0}
# 1. Drop every existing rep in the slot from each occurrence.
for occurrence, reps_in_slot in occurrence_reps.items():
occurrence_obj = ifc.get_object(occurrence)
for rep in reps_in_slot:
if occurrence_obj is not None and not geometry.is_mapped_representation(rep):
# Blender-aware removal for a local mesh the object may display.
remove_representation(ifc, geometry, obj=occurrence_obj, representation=rep)
else:
# Mapped wrapper (or object not loaded): remove from this
# occurrence only. remove_representation's remove_deep2 keeps a
# shared map alive until its last user is gone.
ifc.run("geometry.unassign_representation", product=occurrence, representation=rep)
ifc.run("geometry.remove_representation", representation=rep)
counts["replaced"] += 1
# 2. Drop any rep the type already holds in this slot, so we replace rather
# than accumulate maps. (Must run before adding the new map below.)
for rm in list(element_type.RepresentationMaps or []):
mapped = rm.MappedRepresentation
if mapped and _in_slot(mapped):
ifc.run("geometry.unassign_representation", product=element_type, representation=mapped)
ifc.run("geometry.remove_representation", representation=mapped)
# 3. Add the promoted geometry to the type and map it onto every occurrence.
geometry.add_type_representation_map(element_type, type_representation)
for occurrence in occurrence_reps:
mapped_representation = ifc.run("geometry.map_representation", representation=type_representation)
ifc.run("geometry.assign_representation", product=occurrence, representation=mapped_representation)
counts["occurrences"] += 1
return counts
def purge_unused_representations(ifc: type[tool.Ifc], geometry: type[tool.Geometry]) -> int:
"""Purge representations without inverses.
+2
View File
@@ -445,6 +445,8 @@ class Geometry:
def clear_modifiers(cls, obj): pass
def clear_scale(cls, obj): pass
def copy_data_links(cls, data, copied_entities) -> None: pass
def copy_representation_deep(cls, representation): pass
def add_type_representation_map(cls, element_type, representation): pass
def delete_data(cls, data): pass
def delete_ifc_object(cls, obj): pass
def delete_opening_object_placement(cls, opening): pass
+49 -1
View File
@@ -888,6 +888,38 @@ class Geometry(bonsai.core.tool.Geometry):
) -> Union[ifcopenshell.entity_instance, None]:
return ifcopenshell.util.representation.get_representation(element, context)
@classmethod
def copy_representation_deep(cls, representation: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance:
"""Deep copy a representation, sharing geometric contexts and preserving
named profiles (mirrors ``tool.Root.copy_representation``'s exclusions)."""
def exclude_callback(attribute: ifcopenshell.entity_instance) -> bool:
return attribute.is_a("IfcProfileDef") and attribute.ProfileName
return ifcopenshell.util.element.copy_deep(
tool.Ifc.get(),
representation,
exclude=["IfcGeometricRepresentationContext"],
exclude_callback=exclude_callback,
)
@classmethod
def add_type_representation_map(
cls, element_type: ifcopenshell.entity_instance, representation: ifcopenshell.entity_instance
) -> ifcopenshell.entity_instance:
"""Register ``representation`` on ``element_type`` as a new
``IfcRepresentationMap`` (mapping origin at the type's local origin).
Subsequent ``geometry.map_representation`` calls reuse this map."""
ifc_file = tool.Ifc.get()
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)),
)
rep_map = ifc_file.createIfcRepresentationMap(origin, representation)
element_type.RepresentationMaps = list(element_type.RepresentationMaps or []) + [rep_map]
return rep_map
@classmethod
def get_cartesian_point_offset(cls, obj: bpy.types.Object) -> npt.NDArray[np.float64] | None:
props = tool.Blender.get_object_bim_props(obj)
@@ -1206,7 +1238,23 @@ class Geometry(bonsai.core.tool.Geometry):
for element in element_types:
if obj := tool.Ifc.get_object(element):
if representation := ifcopenshell.util.representation.get_representation(element, context):
# 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:
geometry = ifcopenshell.geom.create_shape(settings, representation)
mesh_name = tool.Loader.get_mesh_name_from_shape(geometry)
mesh = meshes.get(mesh_name)
+7
View File
@@ -2124,6 +2124,13 @@ 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
@@ -94,6 +94,13 @@ 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,