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>
This commit is contained in:
Ryan Schultz
2026-08-01 08:33:18 -05:00
parent 8ece3790aa
commit d0ebdd53c9
8 changed files with 342 additions and 5 deletions
@@ -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).
@@ -63,6 +63,7 @@ classes = (
operator.OverrideOriginSet,
operator.OverrideOutlinerDelete,
operator.OverridePasteBuffer,
operator.PromoteRepresentationToType,
operator.PurgeUnusedRepresentations,
operator.RefreshLinkedAggregate,
operator.RemoveConnection,
+25 -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
@@ -145,6 +162,10 @@ class RepresentationsData:
"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 ""
@@ -545,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"
+27 -1
View File
@@ -159,7 +159,7 @@ class BIM_PT_representations(Panel):
header.label(text="", icon="BLANK1")
header.label(text="", icon="BLANK1")
for representation in RepresentationsData.data["representations"]:
def draw_representation_row(representation, allow_promote=False):
row = self.layout.row(align=True)
row.label(text=representation["ContextType"])
row.label(text=representation["ContextIdentifier"])
@@ -171,6 +171,10 @@ class BIM_PT_representations(Panel):
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",
@@ -180,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:
+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
+32
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)