mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-08 17:01:40 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ca69d20d9 | |||
| 158a9cb63b | |||
| d41aec733c | |||
| c1515fe2bb | |||
| 44339de804 | |||
| dac8563ccf | |||
| 1c421d01c3 |
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user