mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-12 18:43:26 +00:00
Bonsai: toggle a profile type's length between typed and per-instance
Lets an occurrence's extrusion length be either driven by the type (typed/shared, e.g. Revit-mapped mullions) or owned by the occurrence (per-instance, editable), and lets the user switch between the two. - bim.make_profile_length_per_instance: un-maps a shared mapped body into a per-instance SweptSolid (shared IfcProfileDef kept; the old mapped body is orphaned, not deleted, to avoid cascading to sibling instances), gives the occurrence its own IfcMaterialProfileSetUsage (so the Length UI appears), and normalizes the placement so the extrusion runs local Z 0->depth -- Revit puts the object and extrusion origins on opposite ends, which made extend_profile flip the origin. - bim.make_profile_length_type_driven: the inverse -- re-maps the occurrence onto the type's shared representation, promoting the occurrence's body onto the type first if the type has no RepresentationMap. - A "Per-instance Length" checkbox in the Type panel (a get/set property that reads the current mode straight from the IFC, so there is no stored state to desync). - New occurrences of a mapped/typed profile type default to per-instance. - recreate_profile now skips mapped bodies (reloading the mesh instead of regenerating), so assigning a length-driven type keeps the occurrence typed rather than un-mapping it mid-assign and emptying sibling instances via the shared-representation removal cascade. - assign_type preserves a per-instance occurrence's own geometry/length (should_map_representations=False) instead of converting it to typed. Design/working notes under docs/dev-notes/profile-length-per-instance.md. Closes #8657 Closes #8656 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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,136 @@
|
||||
<!-- This file was generated with the assistance of an AI coding tool. -->
|
||||
|
||||
# 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`.
|
||||
@@ -152,6 +152,7 @@ classes = (
|
||||
profile.EnableEditingExtrusionAxis,
|
||||
profile.ExtendProfile,
|
||||
profile.MakeProfileLengthPerInstance,
|
||||
profile.MakeProfileLengthTypeDriven,
|
||||
profile.RecalculateProfile,
|
||||
profile.Rotate90,
|
||||
profile.PatchNonParametricMepSegment,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user