Compare commits

...

7 Commits

Author SHA1 Message Date
Ryan Schultz 6ca69d20d9 dev-notes: record the material usage trap in should_map_representations
The note under "assign_type preserves per-instance" carried a caveat that
skipping the mapping also keeps the occurrence's own profile/material, "fine
when all types share one profile -- revisit if reassigning across different
profiles". That is exactly what bit: map_material_usages lives inside the same
if-block, so the occurrence kept a usage pointing at the old type's profile set
and the material panel edited the old type.

Replace the caveat with what actually happens, why the fix reuses
material.assign_material rather than setting ForProfileSet by hand, and where
the coverage lives. Tick the matching checklist item.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:37:34 -05:00
Ryan Schultz 158a9cb63b Bonsai: cover the material usage re-point on retype
Three tests over core.assign_type with a real IFC file: a per-instance profile
occurrence retyped from one profile type to another must end up with its own
usage pointing at the new type's IfcMaterialProfileSet, must keep its own
non-mapped body at its own extrusion depth while picking up the new type's
profile, and must leave the old type untouched.

The first two fail against the parent commit, with the occurrence's material
resolving to the old type's set and SweptArea never moving off the old profile.
The third passes either way; it is a guard against a future fix that reaches
sideways into the old type.

Placed under test/bim rather than test/core even though the subject is
bonsai.core: Prophecy cannot reach this branch because _is_per_instance_profile
calls ifcopenshell.util directly on the element, so a real entity is required
and Prophecy serialises call arguments to JSON. Driving a real file then imports
ifcopenshell.api.material and so mathutils, which make test-core deliberately
runs without. No bpy is touched.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:36:11 -05:00
Ryan Schultz d41aec733c Bonsai: update the core assign_type tests for should_map_representations
assign_type started passing should_map_representations to api type.assign_type,
but the two core tests still predicted the old call. Prophecy serializes kwargs
into the prediction key, so both failed with "Interface 'bonsai.core.tool.Ifc'
was not called with run".

Both cases use a plain string stand-in for the element, which has no material,
so _is_per_instance_profile returns False and the flag is True.

test/core is green again: 385 passed.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:20:55 -05:00
Ryan Schultz c1515fe2bb Bonsai: re-point an occurrence's material usage when the mapping is skipped
assign_type passes should_map_representations=False for a per-instance profile
occurrence so the new type's shared representation is not mapped over its own
body. That flag gates more than the representation: in api type.assign_type it
also guards map_material_usages, the step that re-points an occurrence's
IfcMaterialProfileSetUsage at the new type's material set.

The occurrence therefore kept a usage whose ForProfileSet still referenced the
OLD type's IfcMaterialProfileSet. get_material(should_skip_usage=True) follows
that pointer, so the material panel showed, and edited, the old type's profile.
Duplicating a profile-based type and then changing the copy's profile silently
rewrote the original's profile instead, taking every other occurrence of the
original with it.

Re-point the usage here when the mapping is suppressed. Reusing
material.assign_material rather than setting ForProfileSet by hand matters: its
update_representation_profile rewrites SweptArea to the new type's profile but
never touches the extrusion depth, so the per-instance length this branch exists
to protect still survives. restore_material_usage_attributes runs afterwards, so
recorded usage attributes land on the new usage.

Traced in Blender against a duplicated IfcFurnitureType: before the fix the
profile edit reported the source type's IfcMaterialProfile, after it the copy's,
while the occurrence's representation entity ids were unchanged across the
retype.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:14:07 -05:00
Ryan Schultz 44339de804 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>
2026-07-16 12:39:47 -05:00
Ryan Schultz dac8563ccf Bonsai: guard change_data against a None mesh datablock
reimport_element_representations' change_data already handles an empty ->
mesh transition (recreate_object_with_data when the datatypes differ), but the
leftover-data cleanup then called has_data_users(old_data) with old_data=None,
crashing with "'NoneType' object has no attribute 'users'". Guard the cleanup
so an object with no prior mesh reloads cleanly.

Closes #8655

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 12:39:47 -05:00
Ryan Schultz 1c421d01c3 Bonsai: add "Make Length Per-Instance" for typed/mapped profile types
Adds bim.make_profile_length_per_instance, which converts profile
occurrences whose length is "typed" (shared via the type's mapped
RepresentationMap) into per-instance editable extrusions.

Per selected occurrence it:
- swaps the mapped body for its own SweptSolid copy (geometry/length
  preserved exactly, shared IfcProfileDef kept), leaving the old mapped
  IfcShapeRepresentation as a harmless orphan rather than deleting it (a
  delete would either cascade to sibling instances via the shared
  RepresentationMap or leave Bonsai's Blender-side links dangling);
- gives it its own IfcMaterialProfileSetUsage, since occurrences that only
  inherit the type's IfcMaterialProfileSet report get_usage_type()==None and
  never get the per-instance Length UI.

Sibling instances and the shared MappingSource/MappedRepresentation are left
untouched. The operator is idempotent: re-running repairs partial conversions
(e.g. adds a missing usage to an already-un-mapped occurrence).

A notice + "Make Length Per-Instance" button in the Type panel surfaces the
conversion when the active occurrence is still typed-length.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 22:17:31 -05:00
11 changed files with 760 additions and 5 deletions
+28
View File
@@ -0,0 +1,28 @@
<!-- This file was generated with the assistance of an AI coding tool. -->
# Developer notes (in-progress features)
This directory holds **living design/working notes for unmerged feature branches**,
one Markdown file per feature, named after its branch (e.g.
`opening-template-on-type.md`).
## Purpose
A shared scratchpad so collaborators — and the AI agents they work with — can pick up
the context behind an in-progress branch: the problem, the design decisions and the
*why*, dead ends already ruled out, and what still needs testing. Because the note is
committed on the branch, it travels with the PR and shows up in the diff, so it is
discoverable without anyone being told where to look.
## How to use it (humans and agents)
- **Before working on a feature branch**, read its note here if one exists.
- **As the PR is refined**, keep the note current — append decisions, correct things
that changed, update the test checklist.
- **One file per feature**, named after the branch.
## Lifecycle
These are *not* permanent user documentation. When a PR merges, either remove its note
or promote the durable parts (the load-bearing "why") into code comments or the regular
docs, so stale notes do not accumulate on the default branch.
@@ -0,0 +1,154 @@
<!-- 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.
- **…but that flag gates the material usage too** (`core/type.py`): in `api type.assign_type`
the `map_material_usages` call sits *inside* the same `if should_map_representations:`
block. Suppressing the mapping therefore also suppressed the step that re-points the
occurrence's `IfcMaterialProfileSetUsage` at the new type's set, leaving a usage whose
`ForProfileSet` still referenced the **old** type. `get_material(should_skip_usage=True)`
follows that pointer, so the material panel showed — and edited — the old type's profile:
duplicate a profile type, change the copy's profile, and the *original's* profile was
rewritten instead, taking every other occurrence of the original with it. This is the
"revisit if reassigning across different profiles" caveat that used to sit here, and it
bit in the wild.
Fixed by re-pointing the usage in `core/type.py` immediately after the `ifc.run`. Reuse
`material.assign_material` rather than setting `ForProfileSet` by hand: its
`update_representation_profile` rewrites `SweptArea` to the new type's profile but never
touches the extrusion depth, so the per-instance length this whole branch exists to
protect still survives. `restore_material_usage_attributes` runs after it and lands on the
new usage. Covered by `test/bim/module/type/test_assign_type_material_usage.py`.
## 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.
- [x] Reassigning across types with **different** profiles — was broken (the occurrence's
usage kept the old type's profile set, so editing it mutated the old type). Fixed in
`core/type.py`; covered by `test_assign_type_material_usage.py`.
- [ ] Edge cases untested: non-identity mapping transforms; non-centroid cardinal point vs
the 180° flip.
- [ ] Commit layout: feature on `1c421d0`; separate `change_data` fix; exclude `placement.py`.
@@ -151,6 +151,8 @@ classes = (
profile.EditExtrusionAxis,
profile.EnableEditingExtrusionAxis,
profile.ExtendProfile,
profile.MakeProfileLengthPerInstance,
profile.MakeProfileLengthTypeDriven,
profile.RecalculateProfile,
profile.Rotate90,
profile.PatchNonParametricMepSegment,
+220 -1
View File
@@ -21,8 +21,10 @@ from math import atan2, degrees, pi, radians
from typing import TYPE_CHECKING, Any, Literal, Optional, Union
import bpy
import numpy as np
import ifcopenshell
import ifcopenshell.api.geometry
import ifcopenshell.api.material
import ifcopenshell.api.pset
import ifcopenshell.api.type
import ifcopenshell.util.element
@@ -142,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)
@@ -443,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)
@@ -851,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):
@@ -871,6 +900,196 @@ class RecalculateProfile(bpy.types.Operator, tool.Ifc.Operator):
return {"FINISHED"}
class MakeProfileLengthPerInstance(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.make_profile_length_per_instance"
bl_label = "Make Profile Length Per Instance"
bl_description = (
"Un-map typed/shared profile geometry so each selected occurrence gets its own body "
"(the extrusion length lives on the instance instead of being typed/shared via a "
"RepresentationMap). Geometry is preserved exactly and sibling instances are left untouched"
)
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return bool(context.selected_objects)
def _execute(self, context):
ifc_file = tool.Ifc.get()
unmapped = 0
for obj in context.selected_objects:
element = tool.Ifc.get_entity(obj)
if not element:
continue
if self.unmap_element_body(ifc_file, element, obj):
unmapped += 1
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:
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
if not body:
return False
changed = False
# (1) Un-map the body if it is a shared/mapped representation.
mapped_items = [i for i in (body.Items or []) if i.is_a("IfcMappedItem")]
if mapped_items:
# 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)):
return False
# 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:
obj.data = obj.data.copy()
# Per-instance copy of the mapped geometry items, keeping the shared IfcProfileDef.
new_items = []
for mi in mapped_items:
for src_item in (mi.MappingSource.MappedRepresentation.Items or []):
new_items.append(ifcopenshell.util.element.copy_deep(ifc_file, src_item, exclude=["IfcProfileDef"]))
# New per-instance SweptSolid body replacing the mapped wrapper; retarget the product shape to it.
# The old mapped IfcShapeRepresentation is left as a harmless orphan (purgeable later) rather than
# deleted -- deleting it would either cascade to siblings (via the shared RepresentationMap) or
# leave Bonsai's Blender-side links dangling.
new_body = ifcopenshell.util.element.copy(ifc_file, body)
new_body.Items = new_items
new_body.RepresentationType = "SweptSolid"
for inverse in ifc_file.get_inverse(body):
ifcopenshell.util.element.replace_attribute(inverse, body, new_body)
bonsai.core.geometry.switch_representation(
tool.Ifc, tool.Geometry, obj=obj, representation=new_body, apply_openings=True
)
changed = True
# (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
# that merely inherits the type's IfcMaterialProfileSet never shows the Length control. This runs even
# when the body was already un-mapped, so re-running repairs earlier partial conversions.
if not ifcopenshell.util.element.get_material(element, should_inherit=False):
profile_set = ifcopenshell.util.element.get_material(element, should_inherit=True)
if profile_set and profile_set.is_a("IfcMaterialProfileSet"):
usage = ifcopenshell.api.material.assign_material(
ifc_file, products=[element], type="IfcMaterialProfileSetUsage", material=profile_set
)
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)
changed = True
# (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"
+39
View File
@@ -18,6 +18,7 @@
import bpy
import ifcopenshell.util.element
import ifcopenshell.util.representation
import ifcopenshell.util.type
from ifcopenshell.util.doc import get_entity_doc
@@ -44,9 +45,47 @@ class TypeData:
"total_instances": cls.total_instances(),
"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
(its body is a mapped/shared representation and/or it only inherits the type's material
profile set), so it could be converted to per-instance editable length."""
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
material = ifcopenshell.util.element.get_material(element, should_inherit=True)
if not (material and "Profile" in material.is_a()): # IfcMaterialProfileSet(Usage)
return False
has_own_usage = bool(ifcopenshell.util.element.get_material(element, should_inherit=False))
body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
body_is_mapped = bool(body and any(i.is_a("IfcMappedItem") for i in (body.Items or [])))
return (not has_own_usage) or body_is_mapped
@classmethod
def relating_type_classes(cls):
results = []
+26
View File
@@ -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
+5
View File
@@ -107,6 +107,11 @@ class BIM_PT_type(Panel):
row.label(text="No Relating Type")
row.operator("bim.enable_editing_type", icon="GREASEPENCIL", text="")
# 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):
bl_label = "Type Attributes"
+34 -1
View File
@@ -33,8 +33,41 @@ 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,
)
if not should_map:
# should_map_representations=False also suppresses api type.assign_type's
# map_material_usages, which is what re-points an occurrence's usage at the new type's
# material set. Skipping it leaves the occurrence's IfcMaterialProfileSetUsage pointing
# at the OLD type's set, so the material panel then edits the old type's profile and
# every other occurrence of that type moves with it. Re-point it here instead: this
# rewrites SweptArea to the new type's profile but leaves the extrusion depth alone,
# so the per-instance length this branch exists to protect still survives.
type_material = ifcopenshell.util.element.get_material(type)
if type_material and (material_class := type_material.is_a()) in (
"IfcMaterialLayerSet",
"IfcMaterialProfileSet",
):
ifc.run("material.assign_material", products=[element], type=f"{material_class}Usage")
obj = ifc.get_object(element)
if (usage := model.get_usage_type(type)) and usage_attributes:
type_tool.restore_material_usage_attributes(element, usage_attributes)
+4 -1
View File
@@ -1094,7 +1094,10 @@ class Geometry(bonsai.core.tool.Geometry):
else:
obj = cls.recreate_object_with_data(obj, data, is_global=False)
cls.record_object_materials(obj)
if not cls.has_data_users(old_data):
# old_data is None when the object had no mesh (e.g. an EMPTY being given geometry);
# recreate_object_with_data already handled that transition above, so there is nothing to
# clean up. Guard has_data_users/delete_data, which both assume a non-None datablock.
if old_data is not None and not cls.has_data_users(old_data):
cls.delete_data(old_data)
cls.clear_modifiers(obj)
cls.clear_cache(element)
@@ -0,0 +1,236 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2026
#
# This file is part of Bonsai.
#
# Bonsai is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Bonsai is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
#
# This file was generated with the assistance of an AI coding tool.
"""Regression test: retyping a per-instance profile occurrence must move its
material usage to the new type.
``core.assign_type`` passes ``should_map_representations=False`` for an
occurrence that owns its body, so the new type's shared representation is not
mapped over it. That flag also gates ``map_material_usages`` inside
``api type.assign_type``, which is the step that re-points the occurrence's
``IfcMaterialProfileSetUsage`` at the new type's material set.
Left unhandled, the occurrence kept a usage whose ``ForProfileSet`` still
referenced the OLD type's set. ``get_material(should_skip_usage=True)`` follows
that pointer, so the material panel showed — and edited — the old type's
profile: duplicating a profile-based type and changing the copy's profile
silently rewrote the original's, and every other occurrence of the original
moved with it.
The fix must re-point the usage without undoing what the flag exists for, so
the occurrence's own extrusion (and its length) has to survive intact.
These live here rather than in ``test/core``, even though the subject is
``bonsai.core.type``. The Prophecy harness ``test/core`` uses cannot reach this
branch: ``_is_per_instance_profile`` calls ``ifcopenshell.util`` directly on the
element, so a real entity is needed, and Prophecy serialises call arguments to
JSON. Driving a real IFC file in turn imports ``ifcopenshell.api.material`` and
so ``mathutils``, which ``make test-core`` (``pytest -p no:pytest-blender
test/core``) deliberately runs without. Nothing here touches ``bpy`` itself.
"""
from unittest import mock
import pytest
pytestmark = pytest.mark.type
DEPTH = 3.0
class _IfcStub:
"""Stand-in for ``tool.Ifc``. ``core.assign_type`` only reaches for ``run``
and ``get_object``; ``run`` dispatches to the real API so the assertions are
made against genuine IFC state rather than recorded calls."""
def __init__(self, ifc_file):
self._file = ifc_file
def run(self, usecase, **kwargs):
import ifcopenshell.api
return ifcopenshell.api.run(usecase, self._file, **kwargs)
def get_object(self, element):
return None
def _add_profile_type(ifc_file, body_context, name, profile_name):
"""An element type carrying its own IfcMaterialProfileSet."""
import ifcopenshell.api.material
import ifcopenshell.api.root
element_type = ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcColumnType", name=name)
material = ifcopenshell.api.material.add_material(ifc_file, name="STEEL")
profile_set = ifcopenshell.api.material.add_material_set(
ifc_file, name=f"{name} set", set_type="IfcMaterialProfileSet"
)
profile = ifc_file.create_entity(
"IfcRectangleProfileDef", ProfileType="AREA", ProfileName=profile_name, XDim=0.1, YDim=0.2
)
ifcopenshell.api.material.add_profile(ifc_file, profile_set=profile_set, material=material, profile=profile)
ifcopenshell.api.material.assign_material(ifc_file, products=[element_type], material=profile_set)
return element_type, profile_set, profile
@pytest.fixture
def model():
"""Two profile types, and one occurrence of the first that owns its body:
its own non-mapped extrusion plus its own usage pointing at type A's set.
That is what makes ``_is_per_instance_profile`` true."""
import ifcopenshell
import ifcopenshell.api.material
import ifcopenshell.api.root
import ifcopenshell.api.type
ifc_file = ifcopenshell.file(schema="IFC4")
ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcProject")
context = ifc_file.create_entity(
"IfcGeometricRepresentationContext",
ContextType="Model",
CoordinateSpaceDimension=3,
WorldCoordinateSystem=ifc_file.create_entity(
"IfcAxis2Placement3D", Location=ifc_file.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0))
),
)
body_context = ifc_file.create_entity(
"IfcGeometricRepresentationSubContext",
ContextIdentifier="Body",
ContextType="Model",
ParentContext=context,
TargetView="MODEL_VIEW",
)
type_a, profile_set_a, profile_a = _add_profile_type(ifc_file, body_context, "Type A", "RECT_A")
type_b, profile_set_b, profile_b = _add_profile_type(ifc_file, body_context, "Type B", "RECT_B")
occurrence = ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcColumn", name="Column")
ifcopenshell.api.type.assign_type(
ifc_file, related_objects=[occurrence], relating_type=type_a, should_map_representations=False
)
ifcopenshell.api.material.assign_material(
ifc_file, products=[occurrence], type="IfcMaterialProfileSetUsage", material=profile_set_a
)
solid = ifc_file.create_entity(
"IfcExtrudedAreaSolid",
SweptArea=profile_a,
Position=ifc_file.create_entity(
"IfcAxis2Placement3D", Location=ifc_file.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0))
),
ExtrudedDirection=ifc_file.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0)),
Depth=DEPTH,
)
representation = ifc_file.create_entity(
"IfcShapeRepresentation",
ContextOfItems=body_context,
RepresentationIdentifier="Body",
RepresentationType="SweptSolid",
Items=[solid],
)
occurrence.Representation = ifc_file.create_entity(
"IfcProductDefinitionShape", Representations=[representation]
)
return {
"file": ifc_file,
"occurrence": occurrence,
"type_a": type_a,
"type_b": type_b,
"profile_set_a": profile_set_a,
"profile_set_b": profile_set_b,
"profile_b": profile_b,
"solid": solid,
}
def _retype(model, new_type):
"""Drive ``core.assign_type`` with the real IFC file behind ``ifc.run``.
``model``/``type_tool`` are mocked: they only drive Blender-side refresh,
which has no bearing on the IFC state under test."""
import bonsai.core.type as subject
model_tool = mock.Mock()
model_tool.get_usage_type.return_value = None
type_tool = mock.Mock()
type_tool.record_material_usage_attributes.return_value = None
type_tool.get_object_data.return_value = None
subject.assign_type(
_IfcStub(model["file"]),
model_tool,
type_tool,
element=model["occurrence"],
type=new_type,
)
def test_usage_follows_the_new_type(model):
"""The occurrence's own usage must point at the new type's profile set.
Before the fix it still referenced type A's set, so the material panel
edited type A's profile while the user believed they were editing type B's."""
import ifcopenshell.util.element
_retype(model, model["type_b"])
usage = ifcopenshell.util.element.get_material(model["occurrence"], should_inherit=False)
assert usage is not None, "occurrence must keep a material usage of its own"
assert usage.is_a("IfcMaterialProfileSetUsage")
assert usage.ForProfileSet == model["profile_set_b"], (
"usage must follow the new type; still pointing at the old type's set means editing "
"the occurrence's profile would mutate the old type"
)
resolved = ifcopenshell.util.element.get_material(model["occurrence"], should_skip_usage=True)
assert resolved == model["profile_set_b"], "the set the material panel resolves to must be the new type's"
def test_per_instance_geometry_survives_the_retype(model):
"""Re-pointing the usage must not undo what should_map_representations=False
exists for: the occurrence keeps its own non-mapped body at its own length,
picking up only the new type's profile."""
import ifcopenshell.util.element
import ifcopenshell.util.representation
_retype(model, model["type_b"])
body = ifcopenshell.util.representation.get_representation(
model["occurrence"], "Model", "Body", "MODEL_VIEW"
)
assert body is not None
assert not any(
item.is_a("IfcMappedItem") for item in body.Items
), "occurrence must keep its own body, not be mapped onto the type's shared representation"
assert model["solid"].Depth == DEPTH, "per-instance extrusion length must be preserved"
assert model["solid"].SweptArea == model["profile_b"], "body must pick up the new type's profile"
def test_old_type_is_left_alone(model):
"""The type the occurrence came from must not be touched by the retype."""
import ifcopenshell.util.element
profile_set_a = model["profile_set_a"]
profile_a = profile_set_a.MaterialProfiles[0].Profile
_retype(model, model["type_b"])
assert ifcopenshell.util.element.get_material(model["type_a"], should_skip_usage=True) == profile_set_a
assert profile_set_a.MaterialProfiles[0].Profile == profile_a, "old type's profile must be untouched"
+12 -2
View File
@@ -23,7 +23,12 @@ from test.core.bootstrap import geometry, ifc, model, type
class TestAssignType:
def test_assigning_and_switching_to_an_existing_type_data(self, ifc, model, type):
type.record_material_usage_attributes("element").should_be_called().will_return(None)
ifc.run("type.assign_type", related_objects=["element"], relating_type="type").should_be_called()
ifc.run(
"type.assign_type",
related_objects=["element"],
relating_type="type",
should_map_representations=True,
).should_be_called()
model.get_usage_type("type").should_be_called(2).will_return(None)
ifc.get_object("type").should_be_called().will_return("type_obj")
type.get_object_data("type_obj").should_be_called().will_return("type_obj_data")
@@ -34,7 +39,12 @@ class TestAssignType:
def test_assigning_and_not_changing_data_if_the_type_has_no_data(self, ifc, model, type):
type.record_material_usage_attributes("element").should_be_called().will_return(None)
ifc.run("type.assign_type", related_objects=["element"], relating_type="type").should_be_called()
ifc.run(
"type.assign_type",
related_objects=["element"],
relating_type="type",
should_map_representations=True,
).should_be_called()
model.get_usage_type("type").should_be_called(2).will_return(None)
ifc.get_object("type").should_be_called().will_return("type_obj")
type.get_object_data("type_obj").should_be_called().will_return(None)