Wire space volume strategy detection

Dispatch on EXTRUDE_CLIP vs B-rep when building space volumes, and
fix fixture placement and visibility bugs in the spatial tests.

Generated with the assistance of an AI coding tool.
This commit is contained in:
CyrilWaechter
2026-08-03 13:38:38 +02:00
parent 3b16356181
commit de13379162
2 changed files with 114 additions and 6 deletions
+93 -2
View File
@@ -874,7 +874,9 @@ class Spatial(bonsai.core.tool.Spatial):
return ifcopenshell.util.space.get_boundary_lines(tool.Ifc.get(), cache["shapes"], cut_z) return ifcopenshell.util.space.get_boundary_lines(tool.Ifc.get(), cache["shapes"], cut_z)
@classmethod @classmethod
def get_space_polygon_from_context_visible_objects(cls, x: float, y: float, container: Optional[ifcopenshell.entity_instance] = None) -> tuple[ def get_space_polygon_from_context_visible_objects(
cls, x: float, y: float, container: Optional[ifcopenshell.entity_instance] = None
) -> tuple[
Union[shapely.Polygon, Literal["NO POLYGONS FOUND", "NO POLYGON FOR POINT"]], Union[shapely.Polygon, Literal["NO POLYGONS FOUND", "NO POLYGON FOR POINT"]],
list[ifcopenshell.entity_instance], list[ifcopenshell.entity_instance],
]: ]:
@@ -921,6 +923,63 @@ class Spatial(bonsai.core.tool.Spatial):
tool.Ifc.get(), cache["shapes"], space_polygon, base_z, bounding_walls tool.Ifc.get(), cache["shapes"], space_polygon, base_z, bounding_walls
) )
@classmethod
def get_space_volume_strategy(
cls,
space_polygon: shapely.Polygon,
base_z: float,
bounding_walls: list[ifcopenshell.entity_instance],
container: Optional[ifcopenshell.entity_instance] = None,
) -> tuple[str, Optional[list], Optional[list]]:
"""Decide how to build the space volume (clipped extrusion or B-rep).
Rays are cast from the RL cut elevation (``container_z + props.rl3``), the
same level at which the space footprint polygon was found.
"""
ifc_file = tool.Ifc.get()
cache = cls.get_or_build_geom_cache()
start_z = None
if container is None:
container = tool.Root.get_default_container()
if container is not None:
container_obj = tool.Ifc.get_object(container)
props = tool.Model.get_model_props()
start_z = container_obj.matrix_world.translation.z + props.rl3
tree = ifcopenshell.geom.tree(ifc_file)
settings = ifcopenshell.geom.settings()
settings.set("disable-opening-subtractions", True)
settings.set("use-world-coords", True)
tree.add_file(ifc_file, settings)
return ifcopenshell.util.space.detect_space_volume_strategy(
ifc_file, cache["shapes"], tree, space_polygon, base_z, bounding_walls, start_z=start_z
)
@classmethod
def set_brep_representation_from_mesh(
cls,
obj: bpy.types.Object,
element: ifcopenshell.entity_instance,
item: ifcopenshell.entity_instance,
) -> None:
"""Assign a representation item (clipped solid or B-rep) to the element."""
old_body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW")
if old_body:
context = old_body.ContextOfItems
ifcopenshell.api.geometry.unassign_representation(tool.Ifc.get(), product=element, representation=old_body)
ifcopenshell.api.geometry.remove_representation(tool.Ifc.get(), representation=old_body)
else:
context = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW")
builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get())
new_body = builder.get_representation(context, item)
ifcopenshell.api.geometry.assign_representation(tool.Ifc.get(), product=element, representation=new_body)
bonsai.core.geometry.switch_representation(
tool.Ifc,
tool.Geometry,
obj=obj,
representation=new_body,
)
@classmethod @classmethod
def debug_shape(cls, foo: shapely.Polygon) -> None: def debug_shape(cls, foo: shapely.Polygon) -> None:
coords = [(p[0], p[1], 0) for p in foo.exterior.coords] coords = [(p[0], p[1], 0) for p in foo.exterior.coords]
@@ -1257,13 +1316,45 @@ class Spatial(bonsai.core.tool.Spatial):
poly: Polygon, poly: Polygon,
h: float, h: float,
polygon_is_si: bool = True, polygon_is_si: bool = True,
bounding_walls: Optional[list[ifcopenshell.entity_instance]] = None,
container: Optional[ifcopenshell.entity_instance] = None,
) -> None: ) -> None:
"""Create or replace the IFC body representation of a space from a polygon. """Create or replace the IFC body representation of a space from a polygon.
:param h: The height in SI (meters). :param h: The height in SI (meters).
""" """
# Remove collinear points introduced by the mesh bisection so the
# footprint polygon has a minimal vertex count.
poly = poly.simplify(0, preserve_topology=True)
unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
cls.set_extrusion_representation_from_polygon(obj, element, poly, h / unit_scale, polygon_is_si) ifc_file = tool.Ifc.get()
x, y, z = obj.matrix_world.translation
base_z = z / unit_scale
if cls.get_spatial_props().force_space_height:
cls.set_extrusion_representation_from_polygon(obj, element, poly, h / unit_scale, polygon_is_si)
return
if bounding_walls is None:
bounding_walls = []
if container is None:
container = ifcopenshell.util.element.get_container(element)
if container is not None:
for wall in ifc_file.by_type("IfcWall"):
if wall in ifcopenshell.util.element.get_decomposition(container):
bounding_walls.append(wall)
strategy, top_planes, bottom_planes = cls.get_space_volume_strategy(poly, base_z, bounding_walls, container)
if strategy == "EXTRUDE_CLIP" and top_planes:
item = ifcopenshell.util.space.build_extruded_clipped_space(
ifc_file, poly, base_z, top_planes, bottom_planes or []
)
cls.set_brep_representation_from_mesh(obj, element, item)
else:
item = ifcopenshell.util.space.build_brep_space(
ifc_file, element, cls.get_or_build_geom_cache()["shapes"], poly, base_z
)
if item is None:
cls.set_extrusion_representation_from_polygon(obj, element, poly, h / unit_scale, polygon_is_si)
else:
cls.set_brep_representation_from_mesh(obj, element, item)
@classmethod @classmethod
def set_obj_origin_to_cursor_position_and_zero_elevation(cls, obj: bpy.types.Object) -> None: def set_obj_origin_to_cursor_position_and_zero_elevation(cls, obj: bpy.types.Object) -> None:
+21 -4
View File
@@ -26,7 +26,8 @@ import ifcopenshell.api.root
import ifcopenshell.api.spatial import ifcopenshell.api.spatial
import ifcopenshell.util.representation import ifcopenshell.util.representation
import numpy as np import numpy as np
from mathutils import Matrix import shapely
from mathutils import Matrix, Vector
import bonsai.core.tool import bonsai.core.tool
import bonsai.tool as tool import bonsai.tool as tool
@@ -270,7 +271,7 @@ class _BlockHelper:
wall = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall") wall = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall")
placement_2d = ifc.createIfcAxis2Placement2D(ifc.createIfcCartesianPoint([0.0, 0.0])) placement_2d = ifc.createIfcAxis2Placement2D(ifc.createIfcCartesianPoint([0.0, 0.0]))
profile = ifc.createIfcRectangleProfileDef("AREA", None, placement_2d, 10.0, 10.0) profile = ifc.createIfcRectangleProfileDef("AREA", None, placement_2d, 10.0, 10.0)
placement_3d = ifc.createIfcAxis2Placement3D(ifc.createIfcCartesianPoint([-5.0, -5.0, 0.0])) placement_3d = ifc.createIfcAxis2Placement3D(ifc.createIfcCartesianPoint([0.0, 0.0, 0.0]))
extrusion = ifc.createIfcExtrudedAreaSolid( extrusion = ifc.createIfcExtrudedAreaSolid(
profile, placement_3d, ifc.createIfcDirection([0.0, 0.0, 1.0]), height profile, placement_3d, ifc.createIfcDirection([0.0, 0.0, 1.0]), height
) )
@@ -285,7 +286,7 @@ class _BlockHelper:
slab = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcSlab") slab = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcSlab")
placement_2d = ifc.createIfcAxis2Placement2D(ifc.createIfcCartesianPoint([0.0, 0.0])) placement_2d = ifc.createIfcAxis2Placement2D(ifc.createIfcCartesianPoint([0.0, 0.0]))
profile = ifc.createIfcRectangleProfileDef("AREA", None, placement_2d, 12.0, 12.0) profile = ifc.createIfcRectangleProfileDef("AREA", None, placement_2d, 12.0, 12.0)
placement_3d = ifc.createIfcAxis2Placement3D(ifc.createIfcCartesianPoint([-6.0, -6.0, z])) placement_3d = ifc.createIfcAxis2Placement3D(ifc.createIfcCartesianPoint([0.0, 0.0, z]))
extrusion = ifc.createIfcExtrudedAreaSolid(profile, placement_3d, ifc.createIfcDirection([0.0, 0.0, 1.0]), 1.0) extrusion = ifc.createIfcExtrudedAreaSolid(profile, placement_3d, ifc.createIfcDirection([0.0, 0.0, 1.0]), 1.0)
shape_rep = ifc.createIfcShapeRepresentation(ctx, "Body", "SweptSolid", [extrusion]) shape_rep = ifc.createIfcShapeRepresentation(ctx, "Body", "SweptSolid", [extrusion])
slab.Representation = ifc.createIfcProductDefinitionShape(None, None, [shape_rep]) slab.Representation = ifc.createIfcProductDefinitionShape(None, None, [shape_rep])
@@ -384,6 +385,7 @@ class TestGenerateSpace(NewFile):
spatial_props = tool.Spatial.get_spatial_props() spatial_props = tool.Spatial.get_spatial_props()
spatial_props.space_height = 6 spatial_props.space_height = 6
bpy.context.view_layer.objects.active = space bpy.context.view_layer.objects.active = space
space.hide_viewport = False
space.select_set(True) space.select_set(True)
bpy.ops.bim.apply_space_height_to_selection() bpy.ops.bim.apply_space_height_to_selection()
@@ -456,7 +458,7 @@ class TestGenerateSpace(NewFile):
ifc.createIfcIndexedPolygonalFace([3, 7, 5, 1]), ifc.createIfcIndexedPolygonalFace([3, 7, 5, 1]),
ifc.createIfcIndexedPolygonalFace([8, 4, 2, 6]), ifc.createIfcIndexedPolygonalFace([8, 4, 2, 6]),
] ]
face_set = ifc.createIfcPolygonalFaceSet(points, closed=True, faces=faces) face_set = ifc.createIfcPolygonalFaceSet(points, True, faces)
shape_rep = ifc.createIfcShapeRepresentation(ctx, "Body", "Tessellation", [face_set]) shape_rep = ifc.createIfcShapeRepresentation(ctx, "Body", "Tessellation", [face_set])
space_element = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcSpace") space_element = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcSpace")
@@ -486,3 +488,18 @@ class TestGenerateSpace(NewFile):
assert min_z >= 0, f"Expected extrusion to start at local z>=0, got min_z={min_z}" assert min_z >= 0, f"Expected extrusion to start at local z>=0, got min_z={min_z}"
assert max_z > 0, f"Expected extrusion to have positive height, got max_z={max_z}" assert max_z > 0, f"Expected extrusion to have positive height, got max_z={max_z}"
assert np.isclose(obj.location.z, 4.5, atol=0.01), f"Expected location.z=4.5, got {obj.location.z}" assert np.isclose(obj.location.z, 4.5, atol=0.01), f"Expected location.z=4.5, got {obj.location.z}"
class TestSpaceVolumeStrategy(NewFile):
def test_vertical_box_returns_extrude_clip(self):
bpy.ops.bim.create_project()
ifc = tool.Ifc.get()
_BlockHelper.create_wall(ifc, height=10.0)
_BlockHelper.create_slab(ifc, z=4.0)
space_polygon = shapely.box(-5, -5, 5, 5)
strategy, top, bottom = subject.get_space_volume_strategy(space_polygon, 0.0, [ifc.by_type("IfcWall")[0]])
assert strategy == "EXTRUDE_CLIP"
assert len(top) == 1
assert len(bottom) == 0
class TestGenerateSpaceSlopedRoof(NewFile):