diff --git a/src/ifcpatch/ifcpatch/recipes/FixArchiCADToRevitSpaces.py b/src/ifcpatch/ifcpatch/recipes/FixArchiCADToRevitSpaces.py index fac4573a23..612026f8b1 100644 --- a/src/ifcpatch/ifcpatch/recipes/FixArchiCADToRevitSpaces.py +++ b/src/ifcpatch/ifcpatch/recipes/FixArchiCADToRevitSpaces.py @@ -17,10 +17,31 @@ # along with IfcPatch. If not, see . import logging +from typing import TypeVar, Union + +import numpy as np +import numpy.typing as npt +import shapely +import shapely.ops +from shapely.geometry.polygon import orient + +import ifcopenshell +import ifcopenshell.api.geometry +import ifcopenshell.geom +import ifcopenshell.util.element +import ifcopenshell.util.placement +import ifcopenshell.util.representation +import ifcopenshell.util.shape +import ifcopenshell.util.unit +from ifcopenshell.util.shape_builder import ShapeBuilder + +import ifcpatch + +T = TypeVar("T", float, npt.NDArray[np.float64]) -class Patcher: - def __init__(self, file: None, logger: logging.Logger, filepath: str): +class Patcher(ifcpatch.BasePatcher): + def __init__(self, file: ifcopenshell.file, logger: Union[logging.Logger, None] = None): """Allow ArchiCAD IFC spaces to open as Revit rooms The underlying problem is that Revit does not bring in IFC spaces as @@ -44,75 +65,128 @@ class Patcher: successfully. This patch is designed to only work on ArchiCAD IFC exports where the - only contents of the IFC is IFC space and `nothing else`. It also - requires you to run it using Blender, as the geometric modification - uses the Blender geometry engine. - - `filepath` argument is required for this recipe, `file` argument is - ignored. - - :param filepath: The filepath of the IFC model. This is required to - load into Bonsai. - :filter_glob filepath: *.ifc;*.ifczip;*.ifcxml + only contents of the IFC is IFC space and `nothing else`. Example: .. code:: python - ifcpatch.execute({"input": "input.ifc", "recipe": "FixArchiCADToRevitSpaces", "arguments": []}) + ifcpatch.execute({"file": model, "recipe": "FixArchiCADToRevitSpaces", "arguments": []}) """ - self.file = file - self.logger = logger - self.filepath = filepath + super().__init__(file, logger) + self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file) + self.builder = ShapeBuilder(file) + # Triangulated coplanar faces are riddled with collinear vertices. A + # micron is small enough to only cull those, but large enough to + # survive floating point noise from the mesher. + self.tolerance = 1e-6 def patch(self) -> None: - import bonsai.tool as tool - import bpy # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] - import ifcopenshell.util.element - from mathutils import Matrix, Vector # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] + settings = ifcopenshell.geom.settings() - if len(bpy.data.objects) > 0: - bpy.data.batch_remove(bpy.data.objects) - bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True) - - bpy.ops.bim.load_project(filepath=self.filepath) - - def recalculate_origin(wall: bpy.types.Object) -> None: - new_origin = wall.matrix_world @ Vector(wall.bound_box[0]) - if (wall.matrix_world.translation - new_origin).length < 0.001: - return - assert isinstance(wall.data, bpy.types.Mesh) - wall.data.transform( - Matrix.Translation( - wall.matrix_world.inverted().to_quaternion() @ (wall.matrix_world.translation - new_origin) - ) - ) - wall.matrix_world.translation = new_origin - - for obj in bpy.context.visible_objects: - bpy.context.view_layer.update() - if "IfcSpace" not in obj.name: + for space in self.file.by_type("IfcSpace"): + body = ifcopenshell.util.representation.get_representation(space, "Model", "Body") + if body is None: + self.logger.warning(f"Space {space.GlobalId} has no body representation and was not patched.") continue - element = tool.Ifc.get_entity(obj) - storey = ifcopenshell.util.element.get_aggregate(element) - storey_obj = tool.Ifc.get_object(storey) - target_z = storey_obj.location.z + storey_elevation = self.get_storey_elevation(space) + if storey_elevation is None: + self.logger.warning(f"Space {space.GlobalId} is not on a storey and was not patched.") + continue - local_target_z = (obj.matrix_world.inverted() @ Vector((0, 0, target_z))).z - local_target_zup = (obj.matrix_world.inverted() @ Vector((0, 0, target_z + 3))).z - for v in obj.data.vertices: - if round(v.co.z, 3) == 0: - v.co.z = local_target_z - bpy.context.view_layer.update() - recalculate_origin(obj) - obj.select_set(True) + try: + shape = ifcopenshell.geom.create_shape(settings, space) + except RuntimeError: + self.logger.warning(f"Space {space.GlobalId} geometry could not be processed and was not patched.") + continue - bpy.ops.bim.update_representation( - ifc_representation_class="IfcExtrudedAreaSolid/IfcArbitraryProfileDefWithVoids" - ) - for context in tool.Ifc.get().by_type("IfcGeometricRepresentationContext", include_subtypes=False): + # Geometry is in SI units, local to the space's own placement. + matrix = ifcopenshell.util.shape.get_shape_matrix(shape) + vertices = ifcopenshell.util.shape.get_vertices(shape.geometry) + faces = ifcopenshell.util.shape.get_faces(shape.geometry) + + # Revit only converts spaces into rooms if the lower bound sits at + # the elevation of its storey, so instead of extruding from the + # bottom of the space we extrude from the storey. + base_z = (np.linalg.inv(matrix) @ np.array((0.0, 0.0, storey_elevation, 1.0)))[2] + top_z, bottom_z = vertices[:, 2].max(), vertices[:, 2].min() + depth = top_z - base_z + if depth <= self.tolerance: + # The storey can sit at or above the top of the space, typically + # when the space is assigned to the wrong storey. Keeping the + # space's own height is better than losing its geometry. + depth = top_z - bottom_z + if depth <= self.tolerance: + self.logger.warning(f"Space {space.GlobalId} is flat and was not patched.") + continue + + extrusions = [ + self.create_extrusion(polygon, base_z, depth) for polygon in self.get_footprints(vertices, faces) + ] + if not extrusions: + self.logger.warning(f"Space {space.GlobalId} has no footprint and was not patched.") + continue + + representation = self.builder.get_representation(body.ContextOfItems, extrusions, "SweptSolid") + representations = list(space.Representation.Representations) + representations[representations.index(body)] = representation + space.Representation.Representations = representations + ifcopenshell.api.geometry.remove_representation(self.file, representation=body) + + for context in self.file.by_type("IfcGeometricRepresentationContext", include_subtypes=False): if context.Precision: context.Precision = 10 - self.file = tool.Ifc.get() + def get_storey_elevation(self, space: ifcopenshell.entity_instance) -> Union[float, None]: + """Get the absolute Z of the storey the space belongs to, in SI units""" + storey = ifcopenshell.util.element.get_aggregate(space) or ifcopenshell.util.element.get_container(space) + if storey is None or not storey.ObjectPlacement: + return None + return ifcopenshell.util.placement.get_local_placement(storey.ObjectPlacement)[2][3] * self.unit_scale + + def get_footprints(self, vertices: npt.NDArray[np.float64], faces: npt.NDArray[np.int32]) -> list[shapely.Polygon]: + """Flatten every downwards facing triangle into a set of 2D footprints + + Unioning the downwards facing triangles (as opposed to only taking the + faces sitting at the bottom of the space) means that spaces which are + stepped or clipped from below still yield their full footprint, and + that any columns poking through the space become voids in that + footprint. + """ + v1 = vertices[faces[:, 1]] - vertices[faces[:, 0]] + v2 = vertices[faces[:, 2]] - vertices[faces[:, 0]] + normals = np.cross(v1, v2) + lengths = np.linalg.norm(normals, axis=1) + # Degenerate triangles have no meaningful normal to test against. + is_downwards = np.zeros(len(faces), dtype=bool) + is_valid = lengths > self.tolerance + is_downwards[is_valid] = (normals[is_valid, 2] / lengths[is_valid]) < -0.01 + + polygons = [] + for face in faces[is_downwards]: + polygon = shapely.Polygon(vertices[face][:, :2]) + if polygon.is_valid and polygon.area: + polygons.append(polygon) + + footprint = shapely.ops.unary_union(polygons) + footprints = footprint.geoms if footprint.geom_type == "MultiPolygon" else [footprint] + return [orient(f.simplify(self.tolerance)) for f in footprints if f.geom_type == "Polygon" and not f.is_empty] + + def create_extrusion(self, footprint: shapely.Polygon, base_z: float, depth: float) -> ifcopenshell.entity_instance: + outer_curve = self.create_curve(footprint.exterior) + inner_curves = [self.create_curve(interior) for interior in footprint.interiors] + profile = self.builder.profile(outer_curve, inner_curves=inner_curves) + return self.builder.extrude( + profile, + magnitude=self.convert_si_to_unit(depth), + position=(0.0, 0.0, self.convert_si_to_unit(base_z)), + ) + + def create_curve(self, ring: shapely.LinearRing) -> ifcopenshell.entity_instance: + # Shapely repeats the first point to close the ring, IFC does not. + points = np.array(ring.coords[:-1]) + return self.builder.polyline(self.convert_si_to_unit(points), closed=True) + + def convert_si_to_unit(self, value: T) -> T: + return value / self.unit_scale diff --git a/src/ifcpatch/test/test_FixArchiCADToRevitSpaces.py b/src/ifcpatch/test/test_FixArchiCADToRevitSpaces.py new file mode 100644 index 0000000000..7041efa814 --- /dev/null +++ b/src/ifcpatch/test/test_FixArchiCADToRevitSpaces.py @@ -0,0 +1,187 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2026 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import numpy as np +import pytest + +import ifcopenshell.api.aggregate +import ifcopenshell.api.context +import ifcopenshell.api.geometry +import ifcopenshell.api.root +import ifcopenshell.api.unit +import ifcopenshell.geom +import ifcopenshell.util.placement +import ifcopenshell.util.representation +import ifcopenshell.util.shape +from ifcopenshell.util.shape_builder import ShapeBuilder + +import ifcpatch +import test.bootstrap + +# All models are authored in millimetres so that the unit conversion is +# exercised, whereas everything IfcOpenShell hands back is in SI units. +STOREY_ELEVATION = 3000.0 +# An L shaped room, as would be exported by ArchiCAD as a faceted brep. +L_SHAPE = [(0.0, 0.0), (6000.0, 0.0), (6000.0, 2000.0), (2000.0, 2000.0), (2000.0, 5000.0), (0.0, 5000.0)] +L_SHAPE_AREA = 18.0 +# A rectangular room with a column poking through it, i.e. a profile with a void. +RECTANGLE = [(0.0, 0.0), (5000.0, 0.0), (5000.0, 4000.0), (0.0, 4000.0)] +COLUMN = [(1000.0, 1000.0), (1000.0, 2000.0), (2000.0, 2000.0), (2000.0, 1000.0)] +RECTANGLE_AREA = 19.0 + + +class TestFixArchiCADToRevitSpaces(test.bootstrap.IFC4): + def create_project(self) -> None: + ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") + unit = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT", prefix="MILLI") + ifcopenshell.api.unit.assign_unit(self.file, units=[unit]) + model = ifcopenshell.api.context.add_context(self.file, context_type="Model") + self.body = ifcopenshell.api.context.add_context( + self.file, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model + ) + self.builder = ShapeBuilder(self.file) + self.storey = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcBuildingStorey") + self.place(self.storey, STOREY_ELEVATION) + + def place(self, element: ifcopenshell.entity_instance, elevation: float, angle: float = 0.0) -> None: + matrix = np.eye(4) + matrix[:2, :2] = [[np.cos(angle), -np.sin(angle)], [np.sin(angle), np.cos(angle)]] + matrix[2][3] = elevation + ifcopenshell.api.geometry.edit_object_placement(self.file, product=element, matrix=matrix, is_si=False) + + def create_space(self, elevation: float, angle: float = 0.0) -> ifcopenshell.entity_instance: + space = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSpace") + ifcopenshell.api.aggregate.assign_object(self.file, products=[space], relating_object=self.storey) + self.place(space, elevation, angle) + return space + + def assign_brep(self, space: ifcopenshell.entity_instance, height: float = 2500.0) -> None: + """Extrude the L shape as a faceted brep, the way ArchiCAD exports spaces""" + points = [(x, y, 0.0) for x, y in L_SHAPE] + [(x, y, height) for x, y in L_SHAPE] + total = len(L_SHAPE) + faces = [list(reversed(range(total))), list(range(total, total * 2))] + faces += [[i, (i + 1) % total, (i + 1) % total + total, i + total] for i in range(total)] + brep = self.builder.faceted_brep(points, faces) + representation = self.builder.get_representation(self.body, [brep], "Brep") + ifcopenshell.api.geometry.assign_representation(self.file, product=space, representation=representation) + + def assign_extrusion_with_a_void(self, space: ifcopenshell.entity_instance, height: float = 2500.0) -> None: + outer_curve = self.builder.polyline(RECTANGLE, closed=True) + inner_curve = self.builder.polyline(COLUMN, closed=True) + profile = self.builder.profile(outer_curve, inner_curves=[inner_curve]) + extrusion = self.builder.extrude(profile, magnitude=height) + representation = self.builder.get_representation(self.body, [extrusion], "SweptSolid") + ifcopenshell.api.geometry.assign_representation(self.file, product=space, representation=representation) + + def run(self) -> None: + ifcpatch.execute({"file": self.file, "recipe": "FixArchiCADToRevitSpaces", "arguments": []}) + + def get_geometry(self, space: ifcopenshell.entity_instance) -> tuple[float, float]: + """Get the SI volume and the SI absolute elevation of the bottom of a space""" + shape = ifcopenshell.geom.create_shape(ifcopenshell.geom.settings(), space) + volume = ifcopenshell.util.shape.get_volume(shape.geometry) + elevation = ifcopenshell.util.shape.get_shape_bottom_elevation(shape, shape.geometry) + return volume, elevation + + def test_run(self): + self.create_project() + space = self.create_space(3500.0) + self.assign_brep(space) + + assert self.get_geometry(space) == pytest.approx((L_SHAPE_AREA * 2.5, 3.5)) + self.run() + # The space now starts at the storey and still ends where it used to. + assert self.get_geometry(space) == pytest.approx((L_SHAPE_AREA * 3.0, 3.0)) + + body = ifcopenshell.util.representation.get_representation(space, "Model", "Body") + assert body.RepresentationType == "SweptSolid" + assert len(body.Items) == 1 + assert body.Items[0].is_a("IfcExtrudedAreaSolid") + # The brep it was converted from is purged, not left dangling. + assert not self.file.by_type("IfcFacetedBrep") + + def test_extruding_from_the_storey_elevation(self): + self.create_project() + space = self.create_space(3500.0) + self.assign_brep(space) + self.run() + + extrusion = ifcopenshell.util.representation.get_representation(space, "Model", "Body").Items[0] + # The space sits 500mm above its storey, so the profile drops by that much. + assert extrusion.Position.Location.Coordinates == (0.0, 0.0, -500.0) + assert extrusion.Depth == 3000.0 + + def test_extruding_from_the_storey_elevation_of_a_rotated_space(self): + self.create_project() + space = self.create_space(3500.0, angle=np.radians(30.0)) + self.assign_brep(space) + matrix = ifcopenshell.util.placement.get_local_placement(space.ObjectPlacement) + self.run() + + assert self.get_geometry(space) == pytest.approx((L_SHAPE_AREA * 3.0, 3.0)) + # The rotation belongs to the placement and must survive untouched. + assert np.allclose(ifcopenshell.util.placement.get_local_placement(space.ObjectPlacement), matrix) + + def test_falling_back_to_the_space_height_if_the_storey_is_above_the_space(self): + self.create_project() + # The space tops out at 1500mm, well below its storey at 3000mm, which + # would otherwise ask for a negative extrusion depth. + space = self.create_space(1000.0) + self.assign_brep(space, height=500.0) + self.run() + + assert self.get_geometry(space) == pytest.approx((L_SHAPE_AREA * 0.5, 3.0)) + + def test_converting_a_column_in_a_space_into_a_profile_void(self): + self.create_project() + space = self.create_space(3500.0) + self.assign_extrusion_with_a_void(space) + self.run() + + assert self.get_geometry(space) == pytest.approx((RECTANGLE_AREA * 3.0, 3.0)) + profile = ifcopenshell.util.representation.get_representation(space, "Model", "Body").Items[0].SweptArea + assert profile.is_a("IfcArbitraryProfileDefWithVoids") + assert len(profile.InnerCurves) == 1 + + def test_obscene_precision_makes_revit_convert_more_rooms(self): + self.create_project() + self.run() + + contexts = self.file.by_type("IfcGeometricRepresentationContext", include_subtypes=False) + assert [c.Precision for c in contexts] == [10.0] + + def test_ignoring_spaces_without_a_body_representation(self): + self.create_project() + space = self.create_space(3500.0) + self.run() + + assert space.Representation is None + + def test_ignoring_spaces_that_are_not_on_a_storey(self): + self.create_project() + space = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSpace") + self.place(space, 3500.0) + self.assign_brep(space) + self.run() + + assert self.get_geometry(space) == pytest.approx((L_SHAPE_AREA * 2.5, 3.5)) + assert self.file.by_type("IfcFacetedBrep") + + +class TestFixArchiCADToRevitSpacesIFC2X3(test.bootstrap.IFC2X3, TestFixArchiCADToRevitSpaces): + pass