mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-05 23:41:44 +00:00
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:
@@ -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
|
||||
Reference in New Issue
Block a user