diff --git a/README.md b/README.md index 3381a90..4cf6f0c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Speckle 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_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) diff --git a/main.py b/main.py index ea36041..efeb6e8 100644 --- a/main.py +++ b/main.py @@ -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") diff --git a/utils/geometry.py b/utils/geometry.py index 145eae4..2a8f957 100644 --- a/utils/geometry.py +++ b/utils/geometry.py @@ -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 # --------------------------------------------------------------------------- # diff --git a/utils/mapper.py b/utils/mapper.py index 97d6b37..1f52a47 100644 --- a/utils/mapper.py +++ b/utils/mapper.py @@ -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] diff --git a/utils/properties.py b/utils/properties.py index ef0cede..8652575 100644 --- a/utils/properties.py +++ b/utils/properties.py @@ -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])