Fix #8138: door/window container assignment no-op

Spatial.get_root_element walks aggregate / nest / filled-void /
voided-element chains and core.assign_container assigns the container
to whatever the walk returns. For an IfcDoor the filled-void hop
redirects to the IfcOpeningElement, then voided-element to the host
wall, so a user who selects a door and runs bim.assign_container ends
up targeting the wall — and silently no-ops on the door if the wall is
already in the target storey.

Per IFC4 / IFC4.3 (IfcDoor, IfcWindow): the spatial containment of a
filling is defined independently of the filling relationship. Major
exporters (Revit, ArchiCAD, Tekla, Allplan) emit independent
ContainedInStructure on doors / windows accordingly. Drop the
filled-void / voided-element hops from the walk; aggregate and nest
remain — those are true sub-part relationships where the parent
legitimately owns the container.

New TestGetRootElement in test/tool pins the new contract (filling
resolves to itself) plus the retained aggregate / nest / loose-element
paths so a future PR that re-adds either hop is caught. Two new
TestAssignContainer cases in test/core pin filling-to-self through the
core layer and per-element can_contain filtering.

Generated with the assistance of an AI coding tool.
This commit is contained in:
Gorgious56
2026-06-08 18:53:50 +02:00
parent 3346a59284
commit 5b79cefee2
3 changed files with 67 additions and 4 deletions
+1 -4
View File
@@ -80,10 +80,7 @@ class Spatial(bonsai.core.tool.Spatial):
def get_root_element(cls, element: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance: def get_root_element(cls, element: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance:
while True: while True:
if parent := ( if parent := (
ifcopenshell.util.element.get_aggregate(element) ifcopenshell.util.element.get_aggregate(element) or ifcopenshell.util.element.get_nest(element)
or ifcopenshell.util.element.get_nest(element)
or ifcopenshell.util.element.get_filled_void(element)
or ifcopenshell.util.element.get_voided_element(element)
): ):
element = parent element = parent
else: else:
+29
View File
@@ -52,6 +52,35 @@ class TestAssignContainer:
collector.assign("obj2").should_be_called() collector.assign("obj2").should_be_called()
subject.assign_container(ifc, collector, spatial, container="container", objs=["obj"]) subject.assign_container(ifc, collector, spatial, container="container", objs=["obj"])
def test_root_resolves_to_self_for_a_filling(self, ifc, collector, spatial):
ifc.get_entity("door_obj").should_be_called().will_return("door")
spatial.get_root_element("door").should_be_called().will_return("door")
spatial.disable_editing("door_obj").should_be_called()
spatial.get_decomposition("door").should_be_called().will_return(["door"])
spatial.can_contain("container", "door").should_be_called().will_return(True)
ifc.run("spatial.assign_container", products=["door"], relating_structure="container").should_be_called()
ifc.get_object("door").should_be_called().will_return("door_obj")
collector.assign("door_obj").should_be_called()
subject.assign_container(ifc, collector, spatial, container="container", objs=["door_obj"])
def test_can_contain_is_evaluated_per_root_element(self, ifc, collector, spatial):
ifc.get_entity("door_obj").should_be_called().will_return("door")
spatial.get_root_element("door").should_be_called().will_return("door")
spatial.disable_editing("door_obj").should_be_called()
spatial.get_decomposition("door").should_be_called().will_return(["door"])
ifc.get_entity("opening_obj").should_be_called().will_return("opening")
spatial.get_root_element("opening").should_be_called().will_return("opening")
spatial.disable_editing("opening_obj").should_be_called()
spatial.get_decomposition("opening").should_be_called().will_return(["opening"])
spatial.can_contain("container", "door").should_be_called().will_return(True)
spatial.can_contain("container", "opening").should_be_called().will_return(False)
ifc.run("spatial.assign_container", products=["door"], relating_structure="container").should_be_called()
ifc.get_object("door").should_be_called().will_return("door_obj")
ifc.get_object("opening").should_be_called().will_return("opening_obj")
collector.assign("door_obj").should_be_called()
collector.assign("opening_obj").should_be_called()
subject.assign_container(ifc, collector, spatial, container="container", objs=["door_obj", "opening_obj"])
class TestEnableEditingContainer: class TestEnableEditingContainer:
def test_run(self, spatial): def test_run(self, spatial):
+37
View File
@@ -19,6 +19,9 @@
import bpy import bpy
import ifcopenshell import ifcopenshell
import ifcopenshell.api import ifcopenshell.api
import ifcopenshell.api.aggregate
import ifcopenshell.api.feature
import ifcopenshell.api.nest
import ifcopenshell.api.root import ifcopenshell.api.root
import ifcopenshell.api.spatial import ifcopenshell.api.spatial
import numpy as np import numpy as np
@@ -148,6 +151,40 @@ class TestGetContainer(NewFile):
assert subject.get_container(wall) == site assert subject.get_container(wall) == site
class TestGetRootElement(NewFile):
def test_a_door_filling_a_wall_is_its_own_root_element(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
wall = ifc.createIfcWall()
opening = ifc.createIfcOpeningElement()
door = ifc.createIfcDoor()
ifcopenshell.api.feature.add_feature(ifc, feature=opening, element=wall)
ifcopenshell.api.feature.add_filling(ifc, opening=opening, element=door)
assert subject.get_root_element(door) == door
def test_an_aggregated_element_walks_to_its_aggregate_root(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
assembly = ifc.createIfcElementAssembly()
beam = ifc.createIfcBeam()
ifcopenshell.api.aggregate.assign_object(ifc, products=[beam], relating_object=assembly)
assert subject.get_root_element(beam) == assembly
def test_a_nested_element_walks_to_its_nest_root(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
parent_task = ifc.createIfcTask()
child_task = ifc.createIfcTask()
ifcopenshell.api.nest.assign_object(ifc, related_objects=[child_task], relating_object=parent_task)
assert subject.get_root_element(child_task) == parent_task
def test_a_loose_element_is_its_own_root(self):
ifc = ifcopenshell.file()
tool.Ifc.set(ifc)
wall = ifc.createIfcWall()
assert subject.get_root_element(wall) == wall
class TestGetDecomposedElements(NewFile): class TestGetDecomposedElements(NewFile):
def test_run(self): def test_run(self):
ifc = ifcopenshell.file() ifc = ifcopenshell.file()