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:
while True:
if parent := (
ifcopenshell.util.element.get_aggregate(element)
or ifcopenshell.util.element.get_nest(element)
or ifcopenshell.util.element.get_filled_void(element)
or ifcopenshell.util.element.get_voided_element(element)
ifcopenshell.util.element.get_aggregate(element) or ifcopenshell.util.element.get_nest(element)
):
element = parent
else:
+29
View File
@@ -52,6 +52,35 @@ class TestAssignContainer:
collector.assign("obj2").should_be_called()
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:
def test_run(self, spatial):
+37
View File
@@ -19,6 +19,9 @@
import bpy
import ifcopenshell
import ifcopenshell.api
import ifcopenshell.api.aggregate
import ifcopenshell.api.feature
import ifcopenshell.api.nest
import ifcopenshell.api.root
import ifcopenshell.api.spatial
import numpy as np
@@ -148,6 +151,40 @@ class TestGetContainer(NewFile):
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):
def test_run(self):
ifc = ifcopenshell.file()