IfcPatch recipe for Revit 2025 TINs import workaround

To understand what's going on, please read the docstring. Then please
take as much alcohol required to erase that memory.
This commit is contained in:
Dion Moult
2025-02-26 18:13:20 +11:00
parent 34acd6de35
commit cfb7d026d1
@@ -0,0 +1,540 @@
# IfcPatch - IFC patching utiliy
# Copyright (C) 2023 Dion Moult <dion@thinkmoult.com>
#
# This file is part of IfcPatch.
#
# IfcPatch 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.
#
# IfcPatch 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 IfcPatch. If not, see <http://www.gnu.org/licenses/>.
import ifcopenshell
import logging
from typing import Optional
class Patcher:
def __init__(
self, file: None, logger: logging.Logger, filepath: str, is_solid: bool = True, should_create_edges: bool = True
):
"""Fix missing or spot-coordinate bugged TINs loading in Revit
TINs exported from 12D or Civil 3D may contain dense or highly obtuse
triangles. Although these will load in Revit, you will not be able to
use Revit's Spot Coordinate or Spot Elevation tool.
See bug: https://github.com/Autodesk/revit-ifc/issues/511
If `is_solid` is enabled, we assume the surface is represented as a
solid (e.g. I have come across surfaces which are extruded by 1mm from
Civil 3D). The solution will delete any faces with a Z normal less
than 0.5. In case the mesh has any side faces or thickness, this should
leave only the top surface which is relevant for spot coordinates and
elevations.
Vertices closer than 10mm will also be merged to prevent dense
portions of the TIN at a minor sacrifice of surveying accuracy. It will
also triangulate all meshes to prevent non-coplanar surfaces, and
delete any obtuse triangles where one of their XY angles is less than
0.3 degrees. Therefore the result will contain some minor "holes" in
the TIN, but these holes will only be in dense triangles that Revit
can't handle anyway and won't affect most coordination tasks.
After that, it will:
1. Reassign everything to an IfcGeographicElement
2. Detect boundary edges and create an edge-only IfcVirtualElement.
Good for clean viz in Revit.
3. Create a copy of the object which has no sharp faces. This will
allow Revit's spot coordinate tool to work on any arbitrary face
surface. Note that Revit cannot snap to edges or vertices on this
object.
4. Create a copy of the obejct which has one artifically injected sharp
face. This trick allows Revit's spot coordinate tool to snap to
edges and points. However, Revit cannot sample an arbitrary
surface. By combining this object with the previous object, you get
the best of both worlds.
If you're thinking that this overlapping, Z-fighting, duplication of
objects with arbitrary almost-degenerate triangles being added is a
horrific abomination in the world of software workarounds, you are
absolutely correct.
This is a variation of FixRevitTINs which has been tested on Revit <=
2023. I've tested this one on Revit 2025 (the behaviour has changed).
Note that you may may want to run other tools like
OffsetObjectPlacements or ResetAbsoluteCoordinates to fix large
coordinates as these can also cause issues in Revit (such as inaccuracy
or inability to use the Spot Coordinate / Elevation tool).
This patch is designed to work on any TIN-like export, typically coming
from civil software. 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.
:param is_solid: If true, assume a thickness and delete anything that
isn't the top face.
:param should_create_edges: If true, a new IfcVirtualElement is created
representing the perimeter of the objects. This allows you to to
hide regular surface edges in Revit and only use the perimeter edge
for visualisation.
:filter_glob filepath: *.ifc;*.ifczip;*.ifcxml
Example:
.. code:: python
ifcpatch.execute({"input": "input.ifc", "recipe": "FixRevit2025TINs", "arguments": []})
"""
self.file = file
self.filepath = filepath
self.logger = logger
self.is_solid = is_solid
self.should_create_edges = should_create_edges
def patch(self) -> None:
import bpy
import bmesh
import bonsai.tool as tool
import ifcopenshell.util.shape_builder
bpy.context.scene.BIMProjectProperties.should_use_native_meshes = True
bpy.ops.bim.load_project(filepath=self.filepath)
old_history_size = tool.Ifc.get().history_size
old_undo_steps = bpy.context.preferences.edit.undo_steps
tool.Ifc.get().history_size = 0
bpy.context.preferences.edit.undo_steps = 0
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
for obj in bpy.data.objects:
if not obj.BIMObjectProperties.ifc_definition_id or not obj.data:
continue
if not obj.data.polygons:
continue
element = tool.Ifc.get_entity(obj)
element.PredefinedType = "USERDEFINED"
element.ObjectType = "TIN"
element = ifcopenshell.util.schema.reassign_class(tool.Ifc.get(), element, "IfcGeographicElement")
bm = bmesh.new()
bm.from_mesh(obj.data)
faces_to_delete = []
if self.is_solid:
for face in bm.faces:
global_normal = obj.matrix_world.to_3x3() @ face.normal
if global_normal.z < 0.5:
faces_to_delete.append(face)
bmesh.ops.delete(bm, geom=faces_to_delete, context="FACES_ONLY")
bmesh.ops.triangulate(bm, faces=bm.faces[:], quad_method="BEAUTY", ngon_method="BEAUTY")
bm.to_mesh(obj.data)
bm.free()
obj.data.update()
if self.should_create_edges:
self.create_edges(obj)
self.create_face_sampleable_object(obj)
self.create_edge_sampleable_object(obj)
tool.Ifc.get().history_size = old_history_size
bpy.context.preferences.edit.undo_steps = old_undo_steps
self.file = tool.Ifc.get()
def create_edges(self, obj):
import bpy
import bmesh
import bonsai.tool as tool
import ifcopenshell.util.element
import ifcopenshell.util.representation
import ifcopenshell.api.root
import ifcopenshell.api.type
import ifcopenshell.api.spatial
import ifcopenshell.api.geometry
element = tool.Ifc.get_entity(obj)
data = obj.data
bm = bmesh.new()
bm.from_mesh(data)
bm.faces.ensure_lookup_table()
if self.is_solid:
faces_to_delete = []
for face in bm.faces:
if face.normal.z < 0.5:
faces_to_delete.append(face)
bmesh.ops.delete(bm, geom=faces_to_delete, context="FACES_ONLY")
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.01)
edges_to_delete = []
bm.faces.ensure_lookup_table()
for edge in bm.edges:
if len(edge.link_faces) != 1:
edges_to_delete.append(edge)
bmesh.ops.delete(bm, geom=edges_to_delete, context="EDGES_FACES")
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.0001)
bm.verts.ensure_lookup_table()
for vert in bm.verts:
vert.co.z += 0.003
mesh = bpy.data.meshes.new("Mesh")
bm.to_mesh(mesh)
obj = bpy.data.objects.new("Perimeter", mesh)
bpy.context.scene.collection.objects.link(obj)
with bpy.context.temp_override(**tool.Blender.get_viewport_context()):
tool.Blender.select_and_activate_single_object(bpy.context, obj)
bpy.ops.object.convert(target="CURVE")
context = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW")
if self.file.schema == "IFC2X3":
curves = self.create_curves_from_curve_ifc2x3(is_2d=False, curve_object_data=obj.data)
else:
curves = self.create_curves_from_curve(is_2d=False, curve_object_data=obj.data)
representation = tool.Ifc.get().createIfcShapeRepresentation(
context, context.ContextIdentifier, "Curve3D", curves
)
element2 = ifcopenshell.api.root.copy_class(tool.Ifc.get(), product=element)
element2.Name += "-boundary"
element2.ObjectType = "TINBOUNDARY"
ifcopenshell.api.geometry.assign_representation(tool.Ifc.get(), element2, representation)
ifcopenshell.util.schema.reassign_class(tool.Ifc.get(), element2, "IfcVirtualElement")
bm.free()
def create_face_sampleable_object(self, obj):
# No sharp faces
import bpy
import bmesh
import bonsai.tool as tool
import ifcopenshell.util.element
import ifcopenshell.util.representation
import ifcopenshell.api.root
import ifcopenshell.api.type
import ifcopenshell.api.spatial
import ifcopenshell.api.geometry
from math import degrees
print("working on ", obj.name)
element = tool.Ifc.get_entity(obj)
data = obj.data
bm = bmesh.new()
bm.from_mesh(data)
bm.faces.ensure_lookup_table()
angle_threshold = 0.3
for polygon in bm.faces:
try:
v1, v2, v3 = [v.co for v in polygon.verts]
d1 = degrees((v2 - v1).angle(v3 - v1))
d2 = degrees((v3 - v2).angle(v1 - v2))
d3 = degrees((v1 - v3).angle(v2 - v3))
if d1 < angle_threshold or d2 < angle_threshold or d3 < angle_threshold:
print("removing", d1, d2, d3, polygon)
bm.faces.remove(polygon)
except:
print("removing", polygon)
bm.faces.remove(polygon)
context = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW")
builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get())
verts = [v.co / self.unit_scale for v in bm.verts]
faces = [[v.index for v in p.verts] for p in bm.faces]
item = builder.mesh(verts, faces)
representation = builder.get_representation(context, [item])
element2 = ifcopenshell.api.root.copy_class(tool.Ifc.get(), product=element)
element2.Name += "-face-sample"
print("new", element2, element)
ifcopenshell.api.geometry.assign_representation(tool.Ifc.get(), element2, representation)
bm.free()
def create_edge_sampleable_object(self, obj):
# This is crazy but we need a sharp face per island
import bpy
import bmesh
import bonsai.tool as tool
import ifcopenshell.util.element
import ifcopenshell.util.representation
import ifcopenshell.api.root
import ifcopenshell.api.type
import ifcopenshell.api.spatial
import ifcopenshell.api.geometry
from math import degrees, radians, sin
from mathutils import Matrix
# Get the active object (assumed to have a mesh)
mesh = obj.data
# Create a BMesh representation
bm = bmesh.new()
bm.from_mesh(mesh)
# First purge existing sharp edges (don't ask me why, really don't)
bm.faces.ensure_lookup_table()
angle_threshold = 0.3
for polygon in bm.faces:
try:
# v1, v2, v3 = [v.co.to_2d() for v in polygon.verts]
v1, v2, v3 = [v.co for v in polygon.verts]
d1 = degrees((v2 - v1).angle(v3 - v1))
d2 = degrees((v3 - v2).angle(v1 - v2))
d3 = degrees((v1 - v3).angle(v2 - v3))
if d1 < angle_threshold or d2 < angle_threshold or d3 < angle_threshold:
print("removing", d1, d2, d3, polygon)
bm.faces.remove(polygon)
except:
print("removing", polygon)
bm.faces.remove(polygon)
bm.faces.ensure_lookup_table()
# Now we add our own.
# A set to mark faces that have been visited
visited_faces = set()
def get_island(start_face):
"""Return the connected set of faces (a 'mesh island') starting from start_face."""
island = set()
stack = [start_face]
while stack:
f = stack.pop()
if f in island:
continue
island.add(f)
for edge in f.edges:
# For every face sharing this edge, add to the stack
for f2 in edge.link_faces:
if f2 not in island:
stack.append(f2)
return island
# Loop over all faces and process each island once
islands_count = 0
for face in bm.faces:
if face in visited_faces:
continue
# Get the connected component (island) containing this face
island = get_island(face)
visited_faces |= island # mark all island faces as visited
islands_count += 1
boundary_edge = None
for face in island:
for edge in face.edges:
# Count how many faces in the island use this edge.
count = sum(1 for f in edge.link_faces if f in island)
if count == 1:
boundary_edge = edge
break
if boundary_edge:
break
# If no boundary edge is found, skip this island.
if boundary_edge is None:
continue
# Use the two vertices of the boundary edge as A and B.
A, B = boundary_edge.verts[0], boundary_edge.verts[1]
# THIRD ATTEMPT
# Use the normal from the boundary face (i.e. the single linked face of the boundary edge)
base_face = boundary_edge.link_faces[0]
plane_normal = (
base_face.normal.copy()
) # This normal defines the plane in which we'll construct the triangle
# Compute the edge AB vector and its length.
AB_vec = B.co - A.co
d = AB_vec.length
if d == 0:
continue # degenerate edge
# --- Desired angles for the new triangle (in degrees) ---
# This is the crazy degenerate triangle
angle_A_deg = 0.004 # angle at vertex A
angle_B_deg = 1.146 # angle at vertex B
angle_C_deg = 178.85 # angle at vertex C (note: 180 - (0.004 + 1.146) = 178.85)
# Convert angles to radians.
angle_A = radians(angle_A_deg)
angle_B = radians(angle_B_deg)
angle_C = radians(angle_C_deg)
# --- Use the law of sines to compute the new triangle's side lengths ---
# In triangle ABC, with AB opposite angle C:
# AB / sin(angle_C) = AC / sin(angle_B) = BC / sin(angle_A)
# We'll compute AC (from A) as it is needed to place the new vertex.
AC_length = d * sin(angle_B) / sin(angle_C)
# --- Determine the direction for AC in the triangle's plane ---
# Starting at A, the direction of AB is our baseline.
u = AB_vec.normalized()
# To get the direction for AC, rotate u by angle_A about the plane_normal.
rot_mat = Matrix.Rotation(angle_A, 3, plane_normal)
dA = u.copy()
dA.rotate(rot_mat)
# Compute the position for the new vertex C.
C_co = A.co + AC_length * dA
# --- Create the new vertex and triangle face in the BMesh ---
new_vert = bm.verts.new(C_co)
bm.verts.index_update() # update indices if needed
# Create the new triangle face from vertices A, B, and new_vert.
# (The order of vertices may be adjusted if you need a specific winding.)
try:
new_face = bm.faces.new((A, B, new_vert))
visited_faces.add(new_face)
print("added new face")
except ValueError:
# Face already exists or some error occurred
print("Could not create face on island", islands_count)
context = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW")
builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get())
verts = [v.co / self.unit_scale for v in bm.verts]
faces = [[v.index for v in p.verts] for p in bm.faces]
item = builder.mesh(verts, faces)
representation = builder.get_representation(context, [item])
element = tool.Ifc.get_entity(obj)
element2 = ifcopenshell.api.root.copy_class(tool.Ifc.get(), product=element)
element2.Name += "-edge-sample"
print("new", element2, element)
ifcopenshell.api.geometry.assign_representation(tool.Ifc.get(), element2, representation)
bm.free()
print("Added a triangle to", islands_count, "mesh island(s).")
return
def create_curves_from_curve_ifc2x3(
self, is_2d: bool = False, curve_object_data=None
) -> list[ifcopenshell.entity_instance]:
import bonsai.tool as tool
dim = (lambda v: v.xy) if is_2d else (lambda v: v.xyz)
results = []
for spline in curve_object_data.splines:
points = spline.bezier_points[:] + spline.points[:]
if spline.use_cyclic_u:
points.append(points[0])
ifc_points = [self.create_cartesian_point(*dim(point.co)) for point in points]
results.append(tool.Ifc.get().createIfcPolyline(ifc_points))
return results
def create_curves_from_curve(
self, is_2d: bool = False, curve_object_data=None
) -> list[ifcopenshell.entity_instance]:
import bonsai.tool as tool
import numpy as np
dim = (lambda v: v.xy) if is_2d else (lambda v: v.xyz)
to_units = lambda v: np.array([self.convert_si_to_unit(i) for i in v])
builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get())
results = []
for spline in curve_object_data.splines:
points = spline.bezier_points[:] + spline.points[:]
points = [to_units(dim(p.co)) for p in points]
closed_polyline = spline.use_cyclic_u and len(points) > 1
results.append(builder.polyline(points, closed=closed_polyline))
return results
def create_cartesian_point(
self, x: float, y: float, z: Optional[float] = None, is_model_coords: bool = True
) -> ifcopenshell.entity_instance:
"""Create IfcCartesianPoint.
x, y, z coords are provided in SI units.
"""
x = self.convert_si_to_unit(x)
y = self.convert_si_to_unit(y)
z = self.convert_si_to_unit(z)
return self.file.createIfcCartesianPoint((x, y, z))
def create_curves_from_mesh(self, geom_data) -> list[ifcopenshell.entity_instance]:
curves = []
points = self.create_cartesian_point_list_from_vertices(geom_data.vertices)
edge_loops = []
previous_edge = None
edge_loop = []
for i, edge in enumerate(geom_data.edges):
if previous_edge is None:
edge_loop = [self.file.createIfcLineIndex((edge.vertices[0] + 1, edge.vertices[1] + 1))]
elif edge.vertices[0] == previous_edge.vertices[1]:
edge_loop.append(self.file.createIfcLineIndex((edge.vertices[0] + 1, edge.vertices[1] + 1)))
else:
edge_loops.append(edge_loop)
edge_loop = [self.file.createIfcLineIndex((edge.vertices[0] + 1, edge.vertices[1] + 1))]
previous_edge = edge
edge_loops.append(edge_loop)
for edge_loop in edge_loops:
curves.append(self.file.createIfcIndexedPolyCurve(points, edge_loop))
return curves
def create_curves_from_mesh_ifc2x3(self, geom_data) -> list[ifcopenshell.entity_instance]:
curves = []
points = [self.create_cartesian_point(v.co.x, v.co.y, v.co.z) for v in geom_data.vertices]
edge_loops = []
previous_edge = None
edge_loop = []
for i, edge in enumerate(geom_data.edges):
if previous_edge is None:
edge_loop = [edge.vertices]
elif edge.vertices[0] == previous_edge.vertices[1]:
edge_loop.append(edge.vertices)
else:
edge_loops.append(edge_loop)
edge_loop = [edge.vertices]
previous_edge = edge
edge_loops.append(edge_loop)
for edge_loop in edge_loops:
loop_points = [points[p[0]] for p in edge_loop]
loop_points.append(points[edge_loop[-1][1]])
curves.append(self.file.createIfcPolyline(loop_points))
return curves
def create_cartesian_point_list_from_vertices(self, vertices) -> ifcopenshell.entity_instance:
import numpy as np
from ifcopenshell.util.shape_builder import ifc_safe_vector_type
# Catch values as floats to benefit from fast buffer copy.
coords = np.empty(len(vertices) * 3, dtype="f")
vertices.foreach_get("co", coords)
coords = coords.reshape(-1, 3)
coords_class = "IfcCartesianPointList3D"
return self.file.create_entity(coords_class, ifc_safe_vector_type(self.convert_si_to_unit(coords)))
def convert_si_to_unit(self, co):
return co / self.unit_scale