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