diff --git a/docs/dev-notes/occurrence-representations.md b/docs/dev-notes/occurrence-representations.md new file mode 100644 index 0000000000..5f84b29b25 --- /dev/null +++ b/docs/dev-notes/occurrence-representations.md @@ -0,0 +1,135 @@ + + +# 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: +. + +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). diff --git a/src/bonsai/bonsai/bim/module/geometry/__init__.py b/src/bonsai/bonsai/bim/module/geometry/__init__.py index 4b8a8abb29..e29bc24015 100644 --- a/src/bonsai/bonsai/bim/module/geometry/__init__.py +++ b/src/bonsai/bonsai/bim/module/geometry/__init__.py @@ -63,6 +63,7 @@ classes = ( operator.OverrideOriginSet, operator.OverrideOutlinerDelete, operator.OverridePasteBuffer, + operator.PromoteRepresentationToType, operator.PurgeUnusedRepresentations, operator.RefreshLinkedAggregate, operator.RemoveConnection, diff --git a/src/bonsai/bonsai/bim/module/geometry/data.py b/src/bonsai/bonsai/bim/module/geometry/data.py index 0f4a349a08..ba56c24555 100644 --- a/src/bonsai/bonsai/bim/module/geometry/data.py +++ b/src/bonsai/bonsai/bim/module/geometry/data.py @@ -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 "" diff --git a/src/bonsai/bonsai/bim/module/geometry/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py index 9913422ed9..6626a8e369 100644 --- a/src/bonsai/bonsai/bim/module/geometry/operator.py +++ b/src/bonsai/bonsai/bim/module/geometry/operator.py @@ -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" diff --git a/src/bonsai/bonsai/bim/module/geometry/ui.py b/src/bonsai/bonsai/bim/module/geometry/ui.py index f6d633b859..b2542804fc 100644 --- a/src/bonsai/bonsai/bim/module/geometry/ui.py +++ b/src/bonsai/bonsai/bim/module/geometry/ui.py @@ -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: diff --git a/src/bonsai/bonsai/core/geometry.py b/src/bonsai/bonsai/core/geometry.py index 83ba921d30..3e8e9d1280 100644 --- a/src/bonsai/bonsai/core/geometry.py +++ b/src/bonsai/bonsai/core/geometry.py @@ -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. diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index 3aa8d7408f..ae078376a3 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -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 diff --git a/src/bonsai/bonsai/tool/geometry.py b/src/bonsai/bonsai/tool/geometry.py index 0f6d7163a5..eb47b682e9 100644 --- a/src/bonsai/bonsai/tool/geometry.py +++ b/src/bonsai/bonsai/tool/geometry.py @@ -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)