Bonsai: preserve material usage direction across a type change (#6676)

Changing an element's type flattened its material layer set direction, e.g.
a covering (or wall) with LayerSetDirection AXIS2 became AXIS3. The
underlying ifcopenshell.api recreates the IfcMaterialLayerSetUsage from
scratch and defaults its direction from the occurrence class
(AXIS3_CLASSES includes IfcCovering). Bonsai's core.assign_type already
records the old usage attributes and restores them, but the restore was
gated behind `model.get_usage_type(type)`, which returns None for classes
it does not special-case (e.g. IfcCoveringType), so the restore was skipped
and the AXIS3 default stuck.

Ungate the restore so it runs whenever usage attributes were recorded,
independent of get_usage_type(type). restore_material_usage_attributes is
self-guarding (it only writes when the element still carries a usage of the
recorded type), so this is safe for types with no recognized usage. Also
drops a redundant get_usage_type call.

Verified live in headless Blender on the reporter's model: an IfcCovering
with a manually set AXIS2 usage kept AXIS2 (and its DirectionSense/offset)
across a type change, where before it flattened to AXIS3; reassigning to
the same or another type preserves the direction with no regression. Core
test_type.py: 5 passed (incl. a new regression test).

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Petru Conduraru
2026-07-12 10:16:38 +03:00
parent 0b7e25a3ef
commit 2817009d59
2 changed files with 28 additions and 3 deletions
+7 -1
View File
@@ -36,7 +36,13 @@ def assign_type(
usage_attributes = type_tool.record_material_usage_attributes(element)
ifc.run("type.assign_type", related_objects=[element], relating_type=type)
obj = ifc.get_object(element)
if (usage := model.get_usage_type(type)) and usage_attributes:
# Reassigning the type recreates the material usage from scratch, defaulting
# its LayerSetDirection/DirectionSense/offset to the values derived from the
# occurrence class (e.g. AXIS3 for IfcCovering). Restore the recorded usage
# attributes so a manually-set direction (e.g. AXIS2) is preserved. The
# restore is a no-op when the element no longer carries a matching usage, so
# it is safe regardless of what get_usage_type() reports for the new type.
if usage_attributes:
type_tool.restore_material_usage_attributes(element, usage_attributes)
if (usage := model.get_usage_type(type)) == "PROFILE":
model.regenerate_profile(obj)
+21 -2
View File
@@ -24,7 +24,7 @@ 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()
model.get_usage_type("type").should_be_called(2).will_return(None)
model.get_usage_type("type").should_be_called().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")
type.change_object_data("obj", "type_obj_data", is_global=False).should_be_called()
@@ -35,13 +35,32 @@ 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()
model.get_usage_type("type").should_be_called(2).will_return(None)
model.get_usage_type("type").should_be_called().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)
ifc.get_object("element").should_be_called().will_return("obj")
type.disable_editing("obj").should_be_called()
subject.assign_type(ifc, model, type, element="element", type="type")
def test_restoring_recorded_material_usage_attributes_when_the_type_has_no_recognized_usage(
self, ifc, model, type
):
# Regression test for #6676: changing the type of an element carrying a
# material usage (e.g. an IfcCovering with LayerSetDirection AXIS2)
# recreates the usage with a class-default direction. The recorded usage
# attributes must be restored even when get_usage_type() does not
# recognize the new type's class (returns None), so the direction is
# preserved instead of flattening (AXIS2 -> AXIS3).
type.record_material_usage_attributes("element").should_be_called().will_return({"type": "usage"})
ifc.run("type.assign_type", related_objects=["element"], relating_type="type").should_be_called()
ifc.get_object("element").should_be_called().will_return("obj")
type.restore_material_usage_attributes("element", {"type": "usage"}).should_be_called()
model.get_usage_type("type").should_be_called().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)
type.disable_editing("obj").should_be_called()
subject.assign_type(ifc, model, type, element="element", type="type")
class TestPurgeUnusedTypes:
def test_purge_types_obj_found(self, ifc, type, geometry):