diff --git a/src/bonsai/bonsai/tool/spatial.py b/src/bonsai/bonsai/tool/spatial.py index 16baba257d..fc55f77d84 100644 --- a/src/bonsai/bonsai/tool/spatial.py +++ b/src/bonsai/bonsai/tool/spatial.py @@ -874,7 +874,9 @@ class Spatial(bonsai.core.tool.Spatial): return ifcopenshell.util.space.get_boundary_lines(tool.Ifc.get(), cache["shapes"], cut_z) @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"]], 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 ) + @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 def debug_shape(cls, foo: shapely.Polygon) -> None: coords = [(p[0], p[1], 0) for p in foo.exterior.coords] @@ -1257,13 +1316,45 @@ class Spatial(bonsai.core.tool.Spatial): poly: Polygon, h: float, polygon_is_si: bool = True, + bounding_walls: Optional[list[ifcopenshell.entity_instance]] = None, + container: Optional[ifcopenshell.entity_instance] = None, ) -> None: """Create or replace the IFC body representation of a space from a polygon. :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()) - 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 def set_obj_origin_to_cursor_position_and_zero_elevation(cls, obj: bpy.types.Object) -> None: diff --git a/src/bonsai/test/tool/test_spatial.py b/src/bonsai/test/tool/test_spatial.py index 0509cc3424..e00a29bcad 100644 --- a/src/bonsai/test/tool/test_spatial.py +++ b/src/bonsai/test/tool/test_spatial.py @@ -26,7 +26,8 @@ import ifcopenshell.api.root import ifcopenshell.api.spatial import ifcopenshell.util.representation import numpy as np -from mathutils import Matrix +import shapely +from mathutils import Matrix, Vector import bonsai.core.tool import bonsai.tool as tool @@ -270,7 +271,7 @@ class _BlockHelper: wall = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall") placement_2d = ifc.createIfcAxis2Placement2D(ifc.createIfcCartesianPoint([0.0, 0.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( 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") placement_2d = ifc.createIfcAxis2Placement2D(ifc.createIfcCartesianPoint([0.0, 0.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) shape_rep = ifc.createIfcShapeRepresentation(ctx, "Body", "SweptSolid", [extrusion]) slab.Representation = ifc.createIfcProductDefinitionShape(None, None, [shape_rep]) @@ -384,6 +385,7 @@ class TestGenerateSpace(NewFile): spatial_props = tool.Spatial.get_spatial_props() spatial_props.space_height = 6 bpy.context.view_layer.objects.active = space + space.hide_viewport = False space.select_set(True) bpy.ops.bim.apply_space_height_to_selection() @@ -426,3 +428,78 @@ class TestGenerateSpace(NewFile): bpy.ops.bim.generate_space() new_height = space.dimensions.z assert new_height != original_height or new_height > 0 + + def test_regenerate_space_from_centered_cube_representation(self): + bpy.ops.bim.create_project() + ifc = tool.Ifc.get() + _BlockHelper.create_wall(ifc, height=10.0) + scene = bpy.context.scene + scene.cursor.location = (0, 0, 0) + + # Create a space with a unit cube PolygonalFaceSet centered at local origin. + ctx = ifcopenshell.util.representation.get_context(ifc, "Model", "Body", "MODEL_VIEW") + points = ifc.createIfcCartesianPointList3D( + [ + (-0.5, -0.5, -0.5), + (-0.5, -0.5, 0.5), + (-0.5, 0.5, -0.5), + (-0.5, 0.5, 0.5), + (0.5, -0.5, -0.5), + (0.5, -0.5, 0.5), + (0.5, 0.5, -0.5), + (0.5, 0.5, 0.5), + ] + ) + faces = [ + ifc.createIfcIndexedPolygonalFace([1, 2, 4, 3]), + ifc.createIfcIndexedPolygonalFace([3, 4, 8, 7]), + ifc.createIfcIndexedPolygonalFace([7, 8, 6, 5]), + ifc.createIfcIndexedPolygonalFace([5, 6, 2, 1]), + ifc.createIfcIndexedPolygonalFace([3, 7, 5, 1]), + ifc.createIfcIndexedPolygonalFace([8, 4, 2, 6]), + ] + face_set = ifc.createIfcPolygonalFaceSet(points, True, faces) + shape_rep = ifc.createIfcShapeRepresentation(ctx, "Body", "Tessellation", [face_set]) + + space_element = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcSpace") + space_element.Representation = ifc.createIfcProductDefinitionShape(None, None, [shape_rep]) + + bpy.ops.mesh.primitive_cube_add(size=1, location=(0, 0, 5)) + obj = bpy.data.objects["Cube"] + scene.collection.objects.link(obj) + tool.Ifc.link(space_element, obj) + bpy.context.view_layer.update() + obj.name = "MySpace" + + # Check the cube's world bottom Z before regeneration. + bottom_z = (obj.matrix_world @ Vector(obj.bound_box[0])).z + assert np.isclose(bottom_z, 4.5), f"Expected bottom_z=4.5, got {bottom_z}" + + # Regenerate the space. + bpy.context.view_layer.objects.active = obj + obj.select_set(True) + bpy.ops.bim.generate_space() + + mesh = obj.data + assert isinstance(mesh, bpy.types.Mesh) + verts = [v.co.z for v in mesh.vertices] + min_z = min(verts) + max_z = max(verts) + 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 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):