diff --git a/docs/dev-notes/README.md b/docs/dev-notes/README.md new file mode 100644 index 0000000000..797874673e --- /dev/null +++ b/docs/dev-notes/README.md @@ -0,0 +1,28 @@ + + +# 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. diff --git a/docs/dev-notes/profile-length-per-instance.md b/docs/dev-notes/profile-length-per-instance.md new file mode 100644 index 0000000000..27b27be3fb --- /dev/null +++ b/docs/dev-notes/profile-length-per-instance.md @@ -0,0 +1,136 @@ + + +# Profile length per-instance — typed vs per-instance length for profile types + +> **Living dev note** for the `profile-length-per-instance` 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. +> +> Tracks feature **#8657**; depends on bug fixes **#8655** (`change_data` None) and +> **#8656** (mapped-profile assign cascade). + +## Problem + +Revit-exported profile members (e.g. curtain-wall mullions) come in **typed-length**: +the extrusion — including its **length** — lives on the `IfcMemberType` as an +`IfcRepresentationMap`, and every occurrence shares it via an `IfcMappedItem`. Bonsai's +profile tools assume each occurrence owns an editable extrusion (`IfcMemberStandardCase` +style), so on these mapped occurrences the length UI never appears, editing/joining +crashes, changing the type-material bleeds across all instances, and `extend_profile` +flips the origin. + +Goal: make **Length** a typed-vs-per-instance property of a profile type, toggle-able in +both directions, without corrupting the shared geometry. + +- **Typed length** = geometry (incl. length) on the type's `RepresentationMap`; + occurrences `IfcMappedItem` it. One length for all instances. +- **Per-instance length** = occurrence has its **own** `SweptSolid` body + its **own** + `IfcMaterialProfileSetUsage`. Each instance edits its own length (`IfcMemberStandardCase`). + +## Key facts established + +- **`tool.Model.get_usage_type(el)` returns `"PROFILE"` iff `el` carries an + `IfcMaterialProfileSet(Usage)`** (checked with `should_inherit=False`). Bonsai gates the + per-instance profile UI (Length control) on this. A mapped occurrence that only + *inherits* the type's profile set reports `None` → no Length UI. This is why un-mapping + must also give the occurrence its **own** `IfcMaterialProfileSetUsage`. +- **The mapped representation is shared.** `bonsai.core.geometry.remove_representation`, + when handed a mapped rep, resolves it and loops `get_elements_of_type` → + `switch_from_representation`, i.e. it **cascades to every sibling instance of the type**, + leaving them empty. Any code that removes an occurrence's mapped body triggers this. This + is the single biggest hazard and the root of most crashes we saw. +- **Typed length is spec-valid.** A plain `IfcMember` with a mapped body + a profile-set + usage is valid IFC (just not the `IfcMemberStandardCase` subtype). `IfcMaterialProfileSet` + has **no length**; length is always in the geometry. So offering typed length is a + legitimate mode, not a workaround (verified against local IFC4 ADD2 TC1). +- **Two origins, on opposite ends.** Revit places the object's `ObjectPlacement` origin at + one end of the mullion and the extrusion's own `Position` origin at the other, with the + object's local **+Z pointing away** from the sweep (local Z runs `-depth → 0`). + `get_profile_axis` (object bound-box local-Z range) + `DumbProfileJoiner.recreate_profile` + (which plants the new origin at `body[0]` = min-local-Z) then relocate the origin to the + far end on extend/join — the "flip". Natively-authored profiles run local Z `0 → depth`, + so `body[0]` is already the origin and nothing moves. +- **`create_profile` double-bodies mapped types.** `assign_type` (default + `should_map_representations=True`) maps the type's shared body onto a new occurrence, then + `DumbProfileGenerator.create_profile` adds a per-instance extrusion on top → two Body reps + / typed-by-default. +- The wrapper/core were **not** the cause of the crashes we chased for a while — a + `git reset --hard v0.8.0` reproduced clean, our applied changes reproduced the crash. The + installed environment is now matched (repo source + release wrapper `3e7b739`, via + `dev_environment.py`). + +## Design + +Two explicit operators + a Type-panel toggle, plus defaults/guards so the mapped hazard is +never hit implicitly. + +- **`bim.make_profile_length_per_instance`** (un-map; `MakeProfileLengthPerInstance`): + 1. Copy the mapped extrusion items into a new per-instance `SweptSolid` body, **keeping + the shared `IfcProfileDef`** (`copy_deep(..., exclude=["IfcProfileDef"])`). + 2. **Orphan** the old mapped body (retarget the product shape, do **not** delete it) — + deleting cascades to siblings, and the raw delete dangles Bonsai's Blender-side links. + 3. Give the occurrence its **own** `IfcMaterialProfileSetUsage` (else no Length UI). + 4. **Normalize placement**: if the object's local +Z points away from the sweep (origin at + the max-local-Z end), flip 180° about local X and rebuild the extrusion via + `add_profile_representation` so local Z runs `0 → depth` from the (unchanged) origin. + This is what stops `extend_profile` flipping the origin. + - Idempotent / repair-capable: re-running adds a missing usage to an already-un-mapped + occurrence. Only identity mapping transforms are handled (others are skipped). +- **`bim.make_profile_length_type_driven`** (re-map; `MakeProfileLengthTypeDriven`): + drop the occurrence's own usage, then `ifcopenshell.api.type.map_type_representations` to + map the type's shared geometry back on. If the type has **no** `RepresentationMap` + (Bonsai-authored profile type), first **promote** a copy of the occurrence's body onto the + type as a `RepresentationMap` (so the first toggled occurrence defines the type's length; + siblings snap to it). +- **UI toggle** (`type/ui.py` `draw_product_ui`, `type/data.py`, `type/prop.py`): a single + **Per-instance Length** checkbox. Backed by a `get`/`set` `BoolProperty` + (`length_per_instance`) — `get` reads the current mode from cached `TypeData` flags + (`is_typed_length_profile` / `can_make_length_type_driven`), `set` runs the matching + operator. No stored state to desync. +- **New occurrences default to per-instance** (`DumbProfileGenerator.create_profile`): after + `assign_type`, drop the inherited mapped reps so only the per-instance extrusion remains. + No-op for types without a `RepresentationMap`. +- **`recreate_profile` mapped guard** (`DumbProfileJoiner.recreate_profile`): if the body is + mapped, **skip** the per-instance rebuild (leaving typed geometry alone — a length-driven + type should stay typed) **but `switch_representation` to reload** so the Blender mesh + reflects a just-mapped type's geometry. This kills the "assign a length-driven type ⇒ + Failed to set value + sibling turns empty" cascade *and* keeps the typed display fresh. + Fixes issue **#8656**. +- **`assign_type` preserves per-instance** (`core/type.py`): if the occurrence is already + per-instance (own non-mapped body + own profile usage), pass + `should_map_representations=False` so reassigning a type keeps its own geometry/length + instead of converting it to typed. (Side effect: it also keeps its own profile/material; + fine when all types share one profile — revisit if reassigning across different profiles.) + +## Supporting bug fixes (separate from the feature) + +- **`tool/geometry.py` `change_data`** — guard `has_data_users`/`delete_data` against a + `None` `old_data` (empty→mesh reload path). Real Bonsai bug, exposed by the re-map reload; + filed as **#8655** — worth its own commit. +- **`ifcopenshell/util/placement.py` `get_axis2placement`** — numpy-2.x `x.resize(3)` fix + for 2D `RefDirection`. **Duplicate of open PRs #8307 / #8586** — kept locally only so + profile editing works during testing; **do not commit**, drop when #8307 merges. + +## Dead ends (ruled out) + +- Auto-un-mapping during `type.assign_type`/`regenerate_profile` → sibling cascade. Un-map + is an **explicit** action only. +- Deriving depth from `obj.bound_box` for un-map → unreliable when several instances share a + Blender mesh; use the copied extrusion / native rebuild instead. +- Blaming the compiled wrapper / core (`3e7b739`) for the profile crashes — it was our code. + +## Test checklist / what's left before merge + +- [ ] **Strip debug prints**: `make_length_per_instance` / `make_length_type_driven`, + `DumbProfileJoiner.recreate_profile` + `get_profile_axis`, and `core/type.py` + `assign_type`. +- [ ] Un-map a mullion → own length, Length UI, geometry unchanged, siblings untouched. +- [ ] `extend_profile('T')` on an un-mapped mullion → origin **stays** (no flip). +- [ ] Toggle checkbox both ways → round-trips; typed snaps to type length. +- [ ] New occurrence of a mapped type → defaults to per-instance. +- [ ] Assign a length-driven type to a **typed** occurrence → adopts type length, no cascade. +- [ ] Assign another type to a **per-instance** occurrence → stays per-instance, keeps length. +- [ ] Edge cases untested: non-identity mapping transforms; non-centroid cardinal point vs + the 180° flip; reassigning across types with **different** profiles. +- [ ] Commit layout: feature on `1c421d0`; separate `change_data` fix; exclude `placement.py`. diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index 12bab0bb63..1055792980 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -152,6 +152,7 @@ classes = ( profile.EnableEditingExtrusionAxis, profile.ExtendProfile, profile.MakeProfileLengthPerInstance, + profile.MakeProfileLengthTypeDriven, profile.RecalculateProfile, profile.Rotate90, profile.PatchNonParametricMepSegment, diff --git a/src/bonsai/bonsai/bim/module/model/profile.py b/src/bonsai/bonsai/bim/module/model/profile.py index ae16acf1a1..653ecbc767 100644 --- a/src/bonsai/bonsai/bim/module/model/profile.py +++ b/src/bonsai/bonsai/bim/module/model/profile.py @@ -144,6 +144,17 @@ class DumbProfileGenerator: material = ifcopenshell.util.element.get_material(element) material.CardinalPoint = self.cardinal_point + # assign_type maps the type's shared geometry onto the occurrence when the type has a + # RepresentationMap (e.g. Revit-exported profiles). We build a per-instance extrusion below, + # so drop those inherited mapped representations first -- new occurrences then default to + # per-instance (editable) length. The IfcMaterialProfileSetUsage assign_type created is kept. + if element.Representation: + for representation in list(element.Representation.Representations): + ifcopenshell.api.geometry.unassign_representation( + tool.Ifc.get(), product=element, representation=representation + ) + ifcopenshell.api.geometry.remove_representation(tool.Ifc.get(), representation=representation) + obj.matrix_world = matrix_world bpy.context.view_layer.update() bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj) @@ -445,6 +456,21 @@ class DumbProfileJoiner: self.recreate_profile(element2, profile2, axis2, axis2) def recreate_profile(self, element: ifcopenshell.entity_instance, obj: bpy.types.Object, axis=None, body=None): + _body_rep = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") + _mapped = bool(_body_rep and any(i.is_a("IfcMappedItem") for i in (_body_rep.Items or []))) + # A mapped/shared body means this occurrence is typed-length (its geometry is driven by the + # type's RepresentationMap). Regenerating a per-instance profile here would remove the shared + # mapped body, which cascades to every sibling instance of the type (leaving them empty) and + # surfaces as a generic "Failed to set value". Leave typed geometry alone; converting to + # per-instance is an explicit action (bim.make_profile_length_per_instance). + if _mapped: + # The mapped body may have just changed (e.g. assign_type mapped a new type's geometry), + # but the Blender mesh is stale. Reload it so the display matches the typed geometry + # instead of skipping silently. + bonsai.core.geometry.switch_representation( + tool.Ifc, tool.Geometry, obj=obj, representation=_body_rep, apply_openings=True + ) + return if axis is None or body is None: axis = body = self.get_profile_axis(obj) self.axis = copy.deepcopy(axis) @@ -853,10 +879,11 @@ class DumbProfileJoiner: def get_profile_axis(self, obj: bpy.types.Object) -> list[Vector]: z_values = [v[2] for v in obj.bound_box] - return [ + axis = [ (obj.matrix_world @ Vector((0.0, 0.0, min(z_values)))), (obj.matrix_world @ Vector((0.0, 0.0, max(z_values)))), ] + return axis class RecalculateProfile(bpy.types.Operator, tool.Ifc.Operator): @@ -890,25 +917,20 @@ class MakeProfileLengthPerInstance(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): ifc_file = tool.Ifc.get() unmapped = 0 - print(f"[make_length_per_instance] {len(context.selected_objects)} object(s) selected") for obj in context.selected_objects: element = tool.Ifc.get_entity(obj) if not element: - print(f"[make_length_per_instance] SKIP {getattr(obj, 'name', obj)!r}: not an IFC element") continue if self.unmap_element_body(ifc_file, element, obj): unmapped += 1 - print(f"[make_length_per_instance] done: un-mapped {unmapped} object(s)") self.report({"INFO"}, f"Length made per-instance for {unmapped} object(s)") return {"FINISHED"} def unmap_element_body( self, ifc_file: ifcopenshell.file, element: ifcopenshell.entity_instance, obj: bpy.types.Object ) -> bool: - name = getattr(obj, "name", obj) body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") if not body: - print(f"[make_length_per_instance] SKIP #{element.id()} {name!r}: no Body/MODEL_VIEW representation") return False changed = False @@ -919,9 +941,7 @@ class MakeProfileLengthPerInstance(bpy.types.Operator, tool.Ifc.Operator): # Only identity mapping transforms are handled; refuse rather than move geometry. for mi in mapped_items: if not np.allclose(ifcopenshell.util.placement.get_mappeditem_transformation(mi), np.eye(4)): - print(f"[make_length_per_instance] SKIP #{element.id()} {name!r}: non-identity mapping transform") return False - print(f"[make_length_per_instance] un-mapping body of #{element.id()} {name!r} (body #{body.id()})") # Give this object its own mesh so sibling instances (sharing the mapped mesh) are not disturbed. if obj.data is not None and obj.data.users > 1: @@ -946,8 +966,6 @@ class MakeProfileLengthPerInstance(bpy.types.Operator, tool.Ifc.Operator): tool.Ifc, tool.Geometry, obj=obj, representation=new_body, apply_openings=True ) changed = True - else: - print(f"[make_length_per_instance] #{element.id()} {name!r}: body already per-instance ({body.RepresentationType})") # (2) Give the occurrence its OWN IfcMaterialProfileSetUsage if it only inherits one. Bonsai gates the # per-instance profile-editing UI on get_usage_type(should_inherit=False) == "PROFILE", so an occurrence @@ -962,14 +980,116 @@ class MakeProfileLengthPerInstance(bpy.types.Operator, tool.Ifc.Operator): usage = usage[0] if isinstance(usage, (list, tuple)) else usage if usage is not None and usage.is_a("IfcMaterialProfileSetUsage"): usage.CardinalPoint = 5 # geometric centroid (Bonsai default) - print(f"[make_length_per_instance] #{element.id()} {name!r}: added per-instance IfcMaterialProfileSetUsage") changed = True - if not changed: - print(f"[make_length_per_instance] #{element.id()} {name!r}: nothing to do (already per-instance with its own usage)") + # (3) Normalize a freshly un-mapped body to Bonsai's native profile convention. The copied Revit + # extrusion can leave the object's local +Z pointing AWAY from the extrusion (geometry in local + # -Z, origin at the far end). get_profile_axis/recreate_profile then plant the origin at the + # min-local-Z end, so extend/join relocate ("flip") the origin. Detect that case, flip the + # object 180deg about local X so local +Z runs along the axis (origin unchanged), and rebuild a + # native 0 -> depth extrusion. Only touches the just-un-mapped (per-instance, non-cascading) body. + if mapped_items: + z_vals = [v[2] for v in obj.bound_box] + length = (max(z_vals) - min(z_vals)) if z_vals else 0.0 + if length > 1e-6 and abs(max(z_vals)) <= abs(min(z_vals)): + usage = ifcopenshell.util.element.get_material(element, should_inherit=False) + profile_set = usage.ForProfileSet if usage and usage.is_a("IfcMaterialProfileSetUsage") else None + profile = (profile_set.CompositeProfile or profile_set.MaterialProfiles[0].Profile) if profile_set else None + body_context = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW") + if profile and body_context: + obj.matrix_world = obj.matrix_world @ Matrix.Rotation(pi, 4, "X") + bpy.context.view_layer.update() + bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj) + native_body = ifcopenshell.api.geometry.add_profile_representation( + ifc_file, + context=body_context, + profile=profile, + depth=length, + cardinal_point=(usage.CardinalPoint or 5), + ) + old_body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") + for inverse in ifc_file.get_inverse(old_body): + ifcopenshell.util.element.replace_attribute(inverse, old_body, native_body) + ifcopenshell.api.geometry.remove_representation(ifc_file, old_body) + bonsai.core.geometry.switch_representation( + tool.Ifc, tool.Geometry, obj=obj, representation=native_body, apply_openings=True + ) + return changed +class MakeProfileLengthTypeDriven(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.make_profile_length_type_driven" + bl_label = "Make Profile Length Type Driven" + bl_description = ( + "Re-map selected profile occurrences onto their type's shared representation so the extrusion " + "length is driven by the type again (typed length). The per-instance length is discarded and " + "the occurrence snaps to the type's length. This is the inverse of 'Make Length Per-Instance'" + ) + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return bool(context.selected_objects) + + def _execute(self, context): + ifc_file = tool.Ifc.get() + remapped = 0 + for obj in context.selected_objects: + element = tool.Ifc.get_entity(obj) + if not element: + continue + if self.remap_element_body(ifc_file, element, obj): + remapped += 1 + self.report({"INFO"}, f"Length made type-driven for {remapped} object(s)") + return {"FINISHED"} + + def remap_element_body( + self, ifc_file: ifcopenshell.file, element: ifcopenshell.entity_instance, obj: bpy.types.Object + ) -> bool: + element_type = ifcopenshell.util.element.get_type(element) + if not element_type: + return False + + # Ensure the type carries a body representation to drive the length. If it has none (e.g. a + # Bonsai-authored profile type, where occurrences carry their own geometry), promote a copy of + # this occurrence's current body onto the type. Siblings toggled later snap to this length. + if not getattr(element_type, "RepresentationMaps", None): + body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") + if not body: + return False + template = ifcopenshell.util.element.copy_deep( + ifc_file, body, exclude=["IfcProfileDef", "IfcGeometricRepresentationContext"] + ) + origin = ifc_file.create_entity( + "IfcAxis2Placement3D", + Location=ifc_file.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)), + ) + rep_map = ifc_file.create_entity( + "IfcRepresentationMap", MappingOrigin=origin, MappedRepresentation=template + ) + element_type.RepresentationMaps = list(element_type.RepresentationMaps or []) + [rep_map] + + + # Drop the occurrence's own material association so it inherits the type's IfcMaterialProfileSet again. + if ifcopenshell.util.element.get_material(element, should_inherit=False): + ifcopenshell.api.material.unassign_material(ifc_file, products=[element]) + + # Replace the occurrence's per-instance representation(s) with mapped items pointing at the type's + # RepresentationMaps (this removes the per-instance body and maps the shared/typed geometry). + ifcopenshell.api.type.map_type_representations( + ifc_file, related_object=element, relating_type=element_type + ) + + # Reload the object's geometry from the (now mapped) body representation. + body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") + if body: + bonsai.core.geometry.switch_representation( + tool.Ifc, tool.Geometry, obj=obj, representation=body, apply_openings=True + ) + return True + + class DumbProfileRecalculator: def recalculate(self, profiles): "`profiles` is a list of blender profile objects" diff --git a/src/bonsai/bonsai/bim/module/type/data.py b/src/bonsai/bonsai/bim/module/type/data.py index f94a58bdb8..90eb93c924 100644 --- a/src/bonsai/bonsai/bim/module/type/data.py +++ b/src/bonsai/bonsai/bim/module/type/data.py @@ -46,9 +46,28 @@ class TypeData: "relating_type": cls.relating_type(), "relating_type_attributes": cls.relating_type_attributes(), "is_typed_length_profile": cls.is_typed_length_profile(), + "can_make_length_type_driven": cls.can_make_length_type_driven(), } ) + @classmethod + def can_make_length_type_driven(cls): + """True if the active occurrence is a per-instance profile (its own body + usage) that has a + type, so its length can be re-driven by the type. If the type has no shared representation + yet, the conversion promotes this occurrence's body onto the type.""" + if not (obj := bpy.context.active_object): + return False + element = tool.Ifc.get_entity(obj) + if not element or not element.is_a("IfcProduct"): + return False + own_material = ifcopenshell.util.element.get_material(element, should_inherit=False) + if not (own_material and "Profile" in own_material.is_a()): + return False + body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") + if body and any(i.is_a("IfcMappedItem") for i in (body.Items or [])): + return False # already mapped/typed + return bool(ifcopenshell.util.element.get_type(element)) + @classmethod def is_typed_length_profile(cls): """True if the active occurrence is a profile-based element whose length is 'typed'/shared diff --git a/src/bonsai/bonsai/bim/module/type/prop.py b/src/bonsai/bonsai/bim/module/type/prop.py index d624fd59a0..d715045bba 100644 --- a/src/bonsai/bonsai/bim/module/type/prop.py +++ b/src/bonsai/bonsai/bim/module/type/prop.py @@ -45,6 +45,22 @@ def get_relating_type(self: "BIMTypeProperties", context: bpy.types.Context) -> return TypeData.data["relating_types"] +def get_length_per_instance(self: "BIMTypeProperties") -> bool: + # Reflect the current IFC state (per-instance vs typed length) so the checkbox has no stored + # state to keep in sync. TypeData caches the flag; the panel loads it before drawing. + if not TypeData.is_loaded: + TypeData.load() + return bool(TypeData.data.get("can_make_length_type_driven")) + + +def set_length_per_instance(self: "BIMTypeProperties", value: bool) -> None: + # Ticking the box makes the length per-instance; unticking re-maps it back to the type. + if value: + bpy.ops.bim.make_profile_length_per_instance() + else: + bpy.ops.bim.make_profile_length_type_driven() + + def update_relating_type_class(self: "BIMTypeProperties", context: bpy.types.Context) -> None: TypeData.is_loaded = False @@ -90,6 +106,15 @@ class BIMTypeProperties(PropertyGroup): ) is_editing_type_attributes: BoolProperty(name="Is Editing Type Attributes") type_attributes: CollectionProperty(type=Attribute, name="Type Attributes") + length_per_instance: BoolProperty( + name="Per-instance Length", + description=( + "Ticked: this profile occurrence has its own editable extrusion length. " + "Unticked: the length is typed/shared (driven by the type's representation)" + ), + get=get_length_per_instance, + set=set_length_per_instance, + ) if TYPE_CHECKING: is_editing_type: bool @@ -98,3 +123,4 @@ class BIMTypeProperties(PropertyGroup): relating_type_object: Union[bpy.types.Object, None] is_editing_type_attributes: bool type_attributes: bpy.types.bpy_prop_collection_idprop[Attribute] + length_per_instance: bool diff --git a/src/bonsai/bonsai/bim/module/type/ui.py b/src/bonsai/bonsai/bim/module/type/ui.py index 2b50fb0c39..a08eb4dce6 100644 --- a/src/bonsai/bonsai/bim/module/type/ui.py +++ b/src/bonsai/bonsai/bim/module/type/ui.py @@ -107,13 +107,10 @@ class BIM_PT_type(Panel): row.label(text="No Relating Type") row.operator("bim.enable_editing_type", icon="GREASEPENCIL", text="") - # Offer converting a typed/shared profile length to a per-instance editable length. - if TypeData.data.get("is_typed_length_profile"): - row = layout.row(align=True) - row.label(text="Length is typed (shared)", icon="INFO") - row.operator( - "bim.make_profile_length_per_instance", icon="ARROW_LEFTRIGHT", text="Make Length Per-Instance" - ) + # Length checkbox: ticked = per-instance editable length, unticked = typed/shared length. + # The checkbox state is read from the IFC (get/set property); ticking runs the conversion. + if TypeData.data.get("is_typed_length_profile") or TypeData.data.get("can_make_length_type_driven"): + layout.row(align=True).prop(props, "length_per_instance", text="Per-instance Length") class BIM_PT_type_attributes(Panel): diff --git a/src/bonsai/bonsai/core/type.py b/src/bonsai/bonsai/core/type.py index cf8f3effbe..70f4dc9c48 100644 --- a/src/bonsai/bonsai/core/type.py +++ b/src/bonsai/bonsai/core/type.py @@ -33,8 +33,27 @@ def assign_type( element: ifcopenshell.entity_instance, type: ifcopenshell.entity_instance, ) -> None: + import ifcopenshell.util.element + import ifcopenshell.util.representation + + # A per-instance profile occurrence (its own non-mapped body + own profile usage) should keep its + # own geometry/length when reassigned to another type. Mapping the new type's shared representation + # over it (the default) would convert it to typed/length-driven. Detect this and skip the mapping. + def _is_per_instance_profile(el: ifcopenshell.entity_instance) -> bool: + mat = ifcopenshell.util.element.get_material(el, should_inherit=False) + if not (mat and "Profile" in mat.is_a()): + return False + body = ifcopenshell.util.representation.get_representation(el, "Model", "Body", "MODEL_VIEW") + return bool(body) and not any(i.is_a("IfcMappedItem") for i in (body.Items or [])) + + should_map = not _is_per_instance_profile(element) usage_attributes = type_tool.record_material_usage_attributes(element) - ifc.run("type.assign_type", related_objects=[element], relating_type=type) + ifc.run( + "type.assign_type", + related_objects=[element], + relating_type=type, + should_map_representations=should_map, + ) obj = ifc.get_object(element) if (usage := model.get_usage_type(type)) and usage_attributes: type_tool.restore_material_usage_attributes(element, usage_attributes)