ifcpatch: run FixArchiCADToRevitSpaces headlessly

The recipe loaded the model into Blender purely to reach its geometry
engine, so it could only run inside Bonsai and needed a filepath instead
of a file. It is now a plain BasePatcher.

The three fixes Revit needs are unchanged. To lower each space onto its
storey we take the storey elevation from util.placement and the space
placement from util.shape, instead of from Blender object matrices. To
convert to an extruded area solid we triangulate the space with
ifcopenshell.geom, union its downwards facing triangles with shapely to
get the footprint and its voids, and rebuild the body with
util.shape_builder.

Unioning every downwards facing face, rather than only the vertices
sitting at z=0, means stepped and clipped spaces keep their full
footprint, columns poking through a room become profile voids, and
disjoint footprints extrude as one item each. Spaces that cannot be
patched are logged and skipped rather than aborting the run, and a
storey above the top of a space falls back to the space's own height
instead of asking for a negative extrusion depth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-08-04 12:16:46 +10:00
parent 0b8875669e
commit e678012c71
2 changed files with 319 additions and 58 deletions
@@ -17,10 +17,31 @@
# along with IfcPatch. If not, see <http://www.gnu.org/licenses/>.
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
@@ -0,0 +1,187 @@
# IfcOpenShell - IFC toolkit and geometry engine
# Copyright (C) 2026 Dion Moult <dion@thinkmoult.com>
#
# 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 <http://www.gnu.org/licenses/>.
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