3 Commits

Author SHA1 Message Date
NLSA 682a21130f Merge branch 'main' of https://github.com/specklesystems/IFC-Exporter
build and deploy Speckle functions / publish-automate-function-version (push) Has been cancelled
2026-03-20 14:48:57 +01:00
NLSA 50e62020ef support more categories and 2D lines and update readme 2026-03-20 14:48:53 +01:00
NLSA 1681d756e8 Update project title in README.md 2026-03-19 14:40:08 +01:00
5 changed files with 258 additions and 12 deletions
+29 -6
View File
@@ -1,6 +1,6 @@
# Speckle to IFC 4.3 Exporter
# Speckle-Revit to IFC 4.3 Exporter
A [Speckle Automate](https://automate.speckle.dev/) function that converts Speckle BIM models (primarily from Revit) into IFC 4.3 files using [ifcopenshell](https://ifcopenshell.org/).
A [Speckle Automate](https://automate.speckle.dev/) function that converts Speckle Revit models into IFC 4.3 files using [ifcopenshell](https://ifcopenshell.org/). This exporter is specifically designed for models sent to Speckle from Autodesk Revit and relies on Revit-specific object structures, categories, and parameters.
## What It Does
@@ -8,11 +8,13 @@ The exporter receives a Speckle model version, walks its object tree, and produc
- Correct IFC entity classification (IfcWall, IfcSlab, IfcColumn, etc.)
- Tessellated geometry (IfcPolygonalFaceSet)
- Curve geometry for Lines and Arcs (IfcGeometricCurveSet with IfcPolyline)
- Material colours from Speckle render materials
- Revit property sets (Common psets, instance/type parameters, material quantities)
- IFC type objects (IfcWallType, IfcSlabType, etc.) shared across instances
- Spatial structure (IfcProject > IfcSite > IfcBuilding > IfcBuildingStorey)
- IfcSpace elements aggregated under storeys with Room properties
- Automatic skipping of analytical/energy categories (e.g. Energy Analysis, MEP Analytical, Solar Shading)
## Pipeline Overview
@@ -31,8 +33,8 @@ Speckle Model
4. Traverse object tree
│ For each leaf element:
│ ├── Classify → IFC entity class
│ ├── Convert geometry → IfcPolygonalFaceSet
│ ├── Classify → IFC entity class (skip analytical categories)
│ ├── Convert geometry → IfcPolygonalFaceSet or IfcGeometricCurveSet
│ ├── Create IFC element + placement
│ ├── Write property sets & quantities
│ └── Assign IFC type object
@@ -51,7 +53,7 @@ Speckle Model
| `main.py` | Entry point, orchestrates the full pipeline |
| `utils/traversal.py` | Walks the Speckle Collection tree (Project > Level > Category > Element) |
| `utils/mapper.py` | Classifies Speckle objects into IFC entity types |
| `utils/geometry.py` | Converts Speckle meshes to IfcPolygonalFaceSet geometry |
| `utils/geometry.py` | Converts Speckle meshes to IfcPolygonalFaceSet and Lines/Arcs to IfcGeometricCurveSet geometry |
| `utils/instances.py` | Handles InstanceProxy objects with shared geometry (IfcMappedItem) |
| `utils/properties.py` | Writes IFC property sets and quantities from Revit parameters |
| `utils/type_manager.py` | Creates and caches IfcTypeObjects (IfcWallType, etc.) |
@@ -80,12 +82,22 @@ Examples:
| `OST_CurtainWallPanels` | `IfcCurtainWall` |
| `OST_DuctCurves` | `IfcDuctSegment` |
| `OST_PipeCurves` | `IfcPipeSegment` |
| `OST_PipeFitting` | `IfcPipeFitting` |
| `OST_PlumbingEquipment` | `IfcSanitaryTerminal` |
| `OST_Rebar` | `IfcReinforcingBar` |
| `OST_StructConnections` | `IfcMechanicalFastener` |
| `OST_LightingFixtures` | `IfcLightFixture` |
| `OST_Furniture` | `IfcFurnishingElement` |
| `OST_Rooms` | `IfcSpace` |
The full table covers ~70 Revit categories across Architectural, Structural, MEP (HVAC, Plumbing, Electrical), and Site/Civil disciplines.
### Skipped Categories
The following analytical/energy OST categories are automatically skipped (not exported to IFC):
`OST_MEPLoadAreaSeparationLines`, `OST_EnergyAnalysisZones`, `OST_EnergyAnalysisSurface`, `OST_SolarShading`, `OST_MEPAnalyticalPipeSegments`, `OST_MEPAnalyticalDuctSegments`, `OST_MEPAnalyticalSpaces`, `OST_ElectricalConduitAnalyticalLines`, `OST_MEPLoadBoundaryLines`, `OST_FlowTerminalSeparationLines`
### Priority 2: Category name (display string)
The category name from the traversal context (the name of the parent Collection in the Speckle tree). Exact match first, then case-insensitive substring match.
@@ -96,6 +108,8 @@ Examples:
| `Walls` | `IfcWall` |
| `Structural Columns` | `IfcColumn` |
| `Plumbing Fixtures` | `IfcSanitaryTerminal` |
| `Structural Rebar` | `IfcReinforcingBar` |
| `Structural Connections` | `IfcMechanicalFastener` |
| `Lighting Fixtures` | `IfcLightFixture` |
### Priority 3: `obj.category` field
@@ -127,6 +141,15 @@ Speckle `InstanceProxy` objects reference shared definition geometry via `defini
Performance optimisation: geometry is built once as an `IfcRepresentationMap`, then each instance references it via `IfcMappedItem` + `IfcCartesianTransformationOperator3DnonUniform`. This avoids duplicating vertex data across hundreds of identical elements.
### Curve Geometry (Path B3)
Objects whose `displayValue` contains `Objects.Geometry.Line` or `Objects.Geometry.Arc` items (and no meshes or instances) are exported as curve geometry:
- **Lines** → `IfcPolyline` with start and end points
- **Arcs** → `IfcPolyline` approximated with 8 segments, sampled parametrically from the arc's plane origin, radius, and domain angles. Falls back to start/mid/end points if plane data is unavailable.
All curves are wrapped in an `IfcGeometricCurveSet` inside an `IfcShapeRepresentation` with `RepresentationType="GeometricCurveSet"`.
### Composite Objects (Path B2 — merged instances)
Objects like Windows and Doors may have multiple `InstanceProxy` items in their `displayValue` (e.g. frame, glass, sill). These are **not** separate IFC elements — all instance geometries are merged into a single `IfcShapeRepresentation` with combined `IfcMappedItem` entries, producing one IFC element per Speckle object.
@@ -165,7 +188,7 @@ Quantities follow the IFC standard naming convention: `Qto_<EntityType>BaseQuant
### Supported Entity Qto Sets
`Qto_WallBaseQuantities`, `Qto_SlabBaseQuantities`, `Qto_ColumnBaseQuantities`, `Qto_BeamBaseQuantities`, `Qto_DoorBaseQuantities`, `Qto_WindowBaseQuantities`, `Qto_RoofBaseQuantities`, `Qto_CoveringBaseQuantities`, `Qto_RailingBaseQuantities`, `Qto_StairBaseQuantities`, `Qto_RampBaseQuantities`, `Qto_MemberBaseQuantities`, `Qto_FootingBaseQuantities`, `Qto_CurtainWallBaseQuantities`, `Qto_BuildingElementProxyBaseQuantities`
`Qto_WallBaseQuantities`, `Qto_SlabBaseQuantities`, `Qto_ColumnBaseQuantities`, `Qto_BeamBaseQuantities`, `Qto_DoorBaseQuantities`, `Qto_WindowBaseQuantities`, `Qto_RoofBaseQuantities`, `Qto_CoveringBaseQuantities`, `Qto_RailingBaseQuantities`, `Qto_StairBaseQuantities`, `Qto_RampBaseQuantities`, `Qto_MemberBaseQuantities`, `Qto_FootingBaseQuantities`, `Qto_CurtainWallBaseQuantities`, `Qto_BuildingElementProxyBaseQuantities`, `Qto_PipeFittingBaseQuantities`, `Qto_SanitaryTerminalBaseQuantities`, `Qto_ReinforcingElementBaseQuantities`, `Qto_MechanicalFastenerBaseQuantities`
## IfcSpace (Rooms)
+17 -3
View File
@@ -5,7 +5,7 @@ import ifcopenshell.api
from utils.materials import MaterialManager
from utils.traversal import traverse, print_tree
from utils.mapper import classify, reset_caches as reset_mapper_caches
from utils.geometry import mesh_to_ifc, get_display_instances, _make_placement
from utils.geometry import mesh_to_ifc, get_display_instances, curves_to_ifc, _make_placement
from utils.instances import is_instance, instance_to_ifc, build_definition_map, print_instance_stats, get_definition_object
from utils.properties import write_properties, write_common_properties, build_element_name, get_element_tag, get_ifc_guid, reset_caches as reset_props_caches
from utils.writer import create_ifc_scaffold, StoreyManager
@@ -106,6 +106,9 @@ def automate_function(
ifc_class = classify(obj, category_name)
if ifc_class is None:
continue
if ifc_class in SPATIAL_STRUCTURE_TYPES:
skipped_spatial += 1
continue
@@ -194,9 +197,20 @@ def automate_function(
instance_count += 1
total += 1
# Track if neither path produced geometry
# B3: Curve geometry (Lines, Arcs in displayValue)
if not rep and not nested_instances:
no_geometry += 1
curve_rep, curve_placement = curves_to_ifc(ifc, body_context, obj, scale=scale, material_manager=material_manager)
if curve_rep:
element = _create_element(ifc, ifc_class, name, curve_rep, curve_placement, storey,
storey_manager=storey_manager,
tag=get_element_tag(obj), guid=get_ifc_guid(obj),
object_type=getattr(obj, "type", None),
)
write_properties(ifc, element, obj, ifc_class=ifc_class, category_name=category_name)
type_manager.assign(element, obj, ifc_class)
total += 1
else:
no_geometry += 1
if total % 100 == 0:
print(f" ... processed {total} elements")
+177
View File
@@ -11,6 +11,8 @@
# for compact output — each vertex stored once, not once per face.
# =============================================================================
import math
import ifcopenshell
from specklepy.objects.base import Base
@@ -245,6 +247,181 @@ def get_display_instances(obj: Base) -> list:
return instances
# --------------------------------------------------------------------------- #
# Curve detection & extraction (Lines, Arcs)
# --------------------------------------------------------------------------- #
def _is_line(item) -> bool:
"""Detect Objects.Geometry.Line (but not Polyline)."""
if item is None:
return False
st = _get(item, "speckle_type") or ""
return "Line" in st and "Polyline" not in st
def _is_arc(item) -> bool:
"""Detect Objects.Geometry.Arc."""
if item is None:
return False
st = _get(item, "speckle_type") or ""
return "Arc" in st
def get_display_curves(obj: Base) -> list:
"""Extract Line and Arc objects from a DataObject's displayValue."""
curves = []
for key in ["displayValue", "@displayValue"]:
display = _get(obj, key)
if display is None:
continue
items = display if isinstance(display, list) else [display]
for item in items:
if _is_line(item) or _is_arc(item):
curves.append(item)
if curves:
break
return curves
def _point_coords(pt, fallback_scale: float) -> tuple:
"""Extract (x, y, z) from a Speckle Point, scaled to mm."""
scale = _resolve_scale(pt, fallback_scale)
x = float(_get(pt, "x") or 0.0) * scale
y = float(_get(pt, "y") or 0.0) * scale
z = float(_get(pt, "z") or 0.0) * scale
return x, y, z
def _arc_to_points(arc, scale: float, num_segments: int = 8) -> list:
"""
Approximate a Speckle Arc as a list of (x, y, z) points in mm.
Uses plane origin (center), radius, and domain angles for parametric sampling.
Falls back to start/mid/end points if plane data is missing.
"""
plane = _get(arc, "plane")
radius = _get(arc, "radius")
domain = _get(arc, "domain")
if not plane or not radius or not domain:
points = []
for key in ["startPoint", "midPoint", "endPoint"]:
pt = _get(arc, key)
if pt:
points.append(_point_coords(pt, scale))
return points if len(points) >= 2 else []
origin = _get(plane, "origin")
xdir = _get(plane, "xdir")
ydir = _get(plane, "ydir")
if not origin or not xdir or not ydir:
points = []
for key in ["startPoint", "midPoint", "endPoint"]:
pt = _get(arc, key)
if pt:
points.append(_point_coords(pt, scale))
return points if len(points) >= 2 else []
cx, cy, cz = _point_coords(origin, scale)
# Direction vectors are unitless — do not scale
dxx = float(_get(xdir, "x") or 0.0)
dxy = float(_get(xdir, "y") or 0.0)
dxz = float(_get(xdir, "z") or 0.0)
dyx = float(_get(ydir, "x") or 0.0)
dyy = float(_get(ydir, "y") or 0.0)
dyz = float(_get(ydir, "z") or 0.0)
r = float(radius) * scale
t_start = float(_get(domain, "start") or 0.0)
t_end = float(_get(domain, "end") or 0.0)
points = []
for i in range(num_segments + 1):
t = t_start + (t_end - t_start) * i / num_segments
cos_t = math.cos(t)
sin_t = math.sin(t)
x = cx + r * (cos_t * dxx + sin_t * dyx)
y = cy + r * (cos_t * dxy + sin_t * dyy)
z = cz + r * (cos_t * dxz + sin_t * dyz)
points.append((x, y, z))
return points
def curves_to_ifc(
ifc: ifcopenshell.file,
body_context,
obj: Base,
scale: float = 0.001,
material_manager=None,
) -> tuple:
"""
Convert Speckle Line/Arc objects in displayValue to IFC curve geometry.
Lines → IfcPolyline (2 points), Arcs → IfcPolyline (sampled points).
Wrapped in IfcGeometricCurveSet.
Returns (IfcShapeRepresentation, IfcLocalPlacement) or (None, None).
"""
curves = get_display_curves(obj)
if not curves:
return None, None
obj_scale = _resolve_scale(obj, scale)
polylines = []
all_points = []
for curve in curves:
cs = _resolve_scale(curve, obj_scale)
if _is_line(curve):
start = _get(curve, "start")
end = _get(curve, "end")
if not start or not end:
continue
p1 = _point_coords(start, cs)
p2 = _point_coords(end, cs)
all_points.extend([p1, p2])
polylines.append([p1, p2])
elif _is_arc(curve):
pts = _arc_to_points(curve, cs)
if len(pts) >= 2:
all_points.extend(pts)
polylines.append(pts)
if not polylines or not all_points:
return None, None
# Compute origin from all curve points
xs = [p[0] for p in all_points]
ys = [p[1] for p in all_points]
zs = [p[2] for p in all_points]
ox = (min(xs) + max(xs)) / 2.0
oy = (min(ys) + max(ys)) / 2.0
oz = min(zs)
# Build IfcPolylines offset from origin
ifc_polylines = []
for pts in polylines:
ifc_points = [
ifc.createIfcCartesianPoint([p[0] - ox, p[1] - oy, p[2] - oz])
for p in pts
]
ifc_polylines.append(ifc.createIfcPolyline(ifc_points))
if not ifc_polylines:
return None, None
curve_set = ifc.createIfcGeometricCurveSet(ifc_polylines)
rep = ifc.createIfcShapeRepresentation(
ContextOfItems=body_context,
RepresentationIdentifier="Body",
RepresentationType="GeometricCurveSet",
Items=[curve_set],
)
placement = _make_placement(ifc, ox, oy, oz)
return rep, placement
# --------------------------------------------------------------------------- #
# Face decoding
# --------------------------------------------------------------------------- #
+27 -3
View File
@@ -62,6 +62,7 @@ BUILTIN_CATEGORY_MAP: dict[str, str] = {
"OST_StructuralStiffener": "IfcMember",
"OST_StructuralTruss": "IfcMember",
"OST_StructuralConnectionModel": "IfcMechanicalFastener",
"OST_StructConnections": "IfcMechanicalFastener",
"OST_Rebar": "IfcReinforcingBar",
"OST_FabricAreas": "IfcReinforcingMesh",
"OST_FabricReinforcement": "IfcReinforcingMesh",
@@ -81,6 +82,7 @@ BUILTIN_CATEGORY_MAP: dict[str, str] = {
"OST_PipeAccessory": "IfcPipeSegment",
"OST_FlexPipeCurves": "IfcPipeSegment",
"OST_PlumbingFixtures": "IfcSanitaryTerminal",
"OST_PlumbingEquipment": "IfcSanitaryTerminal",
"OST_Sprinklers": "IfcFireSuppressionTerminal",
# MEP - Electrical
@@ -118,6 +120,21 @@ BUILTIN_CATEGORY_MAP: dict[str, str] = {
}
# --- OST categories to skip entirely (analytical / energy / separation lines) ---
SKIP_CATEGORIES: set[str] = {
"OST_MEPLoadAreaSeparationLines",
"OST_EnergyAnalysisZones",
"OST_EnergyAnalysisSurface",
"OST_SolarShading",
"OST_MEPAnalyticalPipeSegments",
"OST_MEPAnalyticalDuctSegments",
"OST_MEPAnalyticalSpaces",
"OST_ElectricalConduitAnalyticalLines",
"OST_MEPLoadBoundaryLines",
"OST_FlowTerminalSeparationLines",
}
# --- Display category name → IFC class (secondary fallback) ---
CATEGORY_MAP: dict[str, str] = {
"Walls": "IfcWall",
@@ -146,10 +163,13 @@ CATEGORY_MAP: dict[str, str] = {
"Furniture Systems": "IfcFurnishingElement",
"Casework": "IfcFurnishingElement",
"Plumbing Fixtures": "IfcSanitaryTerminal",
"Plumbing Equipment": "IfcSanitaryTerminal",
"Electrical Fixtures": "IfcElectricAppliance",
"Lighting Fixtures": "IfcLightFixture",
"Mechanical Equipment": "IfcUnitaryEquipment",
"Electrical Equipment": "IfcElectricDistributionBoard",
"Structural Rebar": "IfcReinforcingBar",
"Structural Connections": "IfcMechanicalFastener",
"Structural Foundations": "IfcFooting",
"Foundation Slabs": "IfcSlab",
"Topography": "IfcGeographicElement",
@@ -206,7 +226,7 @@ _CATEGORY_MAP_LOWER: list[tuple[str, str]] = [
_classify_cache: dict[tuple, str] = {}
def classify(obj, category_name: str = "") -> str:
def classify(obj, category_name: str = "") -> str | None:
"""
Determine the IFC class for a Speckle object.
@@ -225,9 +245,13 @@ def classify(obj, category_name: str = "") -> str:
return result
def _classify_impl(obj, category_name: str) -> str:
# 1. builtInCategory — most reliable, direct Revit enum
def _classify_impl(obj, category_name: str) -> str | None:
# 0. Skip analytical / energy / separation-line categories
bic = _get_builtin_category(obj)
if bic and bic in SKIP_CATEGORIES:
return None
# 1. builtInCategory — most reliable, direct Revit enum
if bic and bic in BUILTIN_CATEGORY_MAP:
return BUILTIN_CATEGORY_MAP[bic]
+8
View File
@@ -53,6 +53,10 @@ COMMON_PSET: dict[str, str] = {
"IfcOpeningElement": "Pset_OpeningElementCommon",
"IfcPlate": "Pset_PlateCommon",
"IfcGeographicElement": "Pset_SiteCommon",
"IfcPipeFitting": "Pset_PipeFittingTypeCommon",
"IfcSanitaryTerminal": "Pset_SanitaryTerminalTypeCommon",
"IfcReinforcingBar": "Pset_ReinforcingBarBendingsBECCommon",
"IfcMechanicalFastener": "Pset_MechanicalFastenerTypeCommon",
}
# ---------------------------------------------------------------------------
@@ -745,6 +749,10 @@ _ENTITY_QTO_NAME: dict[str, str] = {
"IfcFooting": "Qto_FootingBaseQuantities",
"IfcCurtainWall": "Qto_CurtainWallBaseQuantities",
"IfcBuildingElementProxy": "Qto_BuildingElementProxyBaseQuantities",
"IfcPipeFitting": "Qto_PipeFittingBaseQuantities",
"IfcSanitaryTerminal": "Qto_SanitaryTerminalBaseQuantities",
"IfcReinforcingBar": "Qto_ReinforcingElementBaseQuantities",
"IfcMechanicalFastener": "Qto_MechanicalFastenerBaseQuantities",
}
# IFC quantity name → (IFC entity type, value attribute, [Revit param fallbacks])