10 Commits

Author SHA1 Message Date
NLSA 05a6799109 AGGREGATE "IfcRoad", "IfcBridge", "IfcRailway", "IfcMarineFacility"
build and deploy Speckle functions / publish-automate-function-version (push) Has been cancelled
2026-03-28 17:29:43 +01:00
NLSA fad461c767 update instancing
build and deploy Speckle functions / publish-automate-function-version (push) Has been cancelled
2026-03-25 13:49:27 +01:00
NLSA bf63a73436 Fix contact email and IFC version in README
Updated contact email and corrected IFC version reference in the README.
2026-03-25 13:16:41 +01:00
NLSA cde3a58b08 Update README with project status and contact info
Added project status section indicating active development.
2026-03-20 16:59:27 +01:00
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
NLSA 0fb8ceb73a Update Readme and write file
build and deploy Speckle functions / publish-automate-function-version (push) Has been cancelled
2026-03-19 14:10:27 +01:00
NLSA 09dc8eb8c8 displayvalue updates 2026-03-16 22:58:38 +01:00
NLSA 38e05ccc28 write file
build and deploy Speckle functions / publish-automate-function-version (push) Has been cancelled
2026-03-13 15:48:54 +01:00
10 changed files with 1077 additions and 330 deletions
+101 -30
View File
@@ -1,6 +1,14 @@
# Speckle to IFC 4.3 Exporter
# Speckle IFC 4.3 Exporter (Revit)
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/).
## 🚧 Project Status: WIP
Hey there! This project is still under active development, so expect changes, bugs, and incomplete features.
If you have any questions or suggestions, dont hesitate to reach out at: **nikos@speckle.systems**
A [Speckle Automate](https://docs.speckle.systems/developers/automate/introduction) function that converts Speckle models from Revit into IFC 4X3 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.
> ⚠️ **Note on Model Uploads**
>
> Large models (greater than 200MB) may fail to upload due to current file size limitations. The team is actively working on resolving this issue.
## What It Does
@@ -8,10 +16,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, Arcs, and Polycurves (IfcIndexedPolyCurve with IfcLineIndex/IfcArcIndex)
- 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
@@ -30,10 +41,10 @@ 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 IfcIndexedPolyCurve
│ ├── Create IFC element + placement
│ ├── Write property sets
│ ├── Write property sets & quantities
│ └── Assign IFC type object
@@ -50,21 +61,23 @@ 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/instances.py` | Handles InstanceProxy objects with shared geometry (IfcMappedItem) |
| `utils/properties.py` | Writes IFC property sets from Revit parameters |
| `utils/helpers.py` | Shared utilities (`_get` safe accessor, `MM_SCALES` unit conversion) |
| `utils/geometry.py` | Converts Speckle meshes to IfcPolygonalFaceSet geometry (handles nested BrepX) |
| `utils/curves.py` | Converts Lines, Arcs, and Polycurves to IfcIndexedPolyCurve geometry |
| `utils/instances.py` | Handles InstanceProxy objects with shared geometry (IfcMappedItem), content-based deduplication |
| `utils/properties.py` | Writes IFC property sets and quantities from Revit parameters |
| `utils/type_manager.py` | Creates and caches IfcTypeObjects (IfcWallType, etc.) |
| `utils/materials.py` | Maps Speckle render materials to IfcSurfaceStyle colours |
| `utils/writer.py` | Creates the IFC file scaffold and manages storey creation |
| `utils/config.py` | Project/site/building name configuration |
| `utils/receiver.py` | Connects to Speckle server and receives model data (uses `.env`) |
## Mapping Logic
Classification of Speckle objects to IFC entity types follows a priority chain with three lookup tables. The first match wins.
Classification of Speckle objects to IFC entity types follows a priority chain. The first match wins.
### Priority 1: `builtInCategory` (OST_ enum)
The most reliable source. Read from `obj.properties.builtInCategory`, which contains the Revit `BuiltInCategory` enum value. This is a direct Revit classification and maps unambiguously to IFC.
The most reliable source. Read from `obj.properties.builtInCategory`, which contains the Revit `BuiltInCategory` enum value.
Examples:
| builtInCategory | IFC Class |
@@ -79,25 +92,23 @@ 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.
### Priority 2: `speckle_type` prefix
### Skipped Categories
For typed Speckle objects, the `speckle_type` string is matched. Exact match is tried first, then longest-prefix match.
The following analytical/energy OST categories are automatically skipped (not exported to IFC):
Examples:
| speckle_type | IFC Class |
|---|---|
| `Objects.BuiltElements.Wall` | `IfcWall` |
| `Objects.BuiltElements.Floor` | `IfcSlab` |
| `Objects.BuiltElements.Revit.RevitWall` | `IfcWall` |
| `Objects.BuiltElements.Revit.RevitColumn` | `IfcColumn` |
| `Objects.Geometry.Mesh` | `IfcBuildingElementProxy` |
`OST_MEPLoadAreaSeparationLines`, `OST_EnergyAnalysisZones`, `OST_EnergyAnalysisSurface`, `OST_SolarShading`, `OST_MEPAnalyticalPipeSegments`, `OST_MEPAnalyticalDuctSegments`, `OST_MEPAnalyticalSpaces`, `OST_ElectricalConduitAnalyticalLines`, `OST_MEPLoadBoundaryLines`, `OST_FlowTerminalSeparationLines`
### Priority 3: Category name (display string)
### 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.
@@ -107,11 +118,13 @@ Examples:
| `Walls` | `IfcWall` |
| `Structural Columns` | `IfcColumn` |
| `Plumbing Fixtures` | `IfcSanitaryTerminal` |
| `Structural Rebar` | `IfcReinforcingBar` |
| `Structural Connections` | `IfcMechanicalFastener` |
| `Lighting Fixtures` | `IfcLightFixture` |
### Priority 4: `obj.category` field
### Priority 3: `obj.category` field
Same lookup as Priority 3, but using the object's own `category` attribute.
Same lookup as Priority 2, but using the object's own `category` attribute.
### Fallback
@@ -123,11 +136,12 @@ If none of the above match, the object is classified as `IfcBuildingElementProxy
Objects with `displayValue` containing Mesh objects are converted directly:
1. Extract vertices and faces from each mesh in `displayValue`
1. Extract vertices and faces from each mesh in `displayValue` (recursively handles nested BrepX/Brep objects)
2. Scale vertices to millimetres based on the mesh's unit declaration
3. Deduplicate vertices via snap grid (0.01mm tolerance) to avoid IFC GEM111 errors
4. Build `IfcPolygonalFaceSet` with `IfcCartesianPointList3D` + `IfcIndexedPolygonalFace`
5. Compute bounding box origin for `IfcLocalPlacement`, offset vertices relative to it
4. Round vertex coordinates to 0.001mm precision for smaller IFC file output
5. Build `IfcPolygonalFaceSet` with `IfcCartesianPointList3D` + `IfcIndexedPolygonalFace`
6. Compute bounding box origin incrementally for `IfcLocalPlacement`, offset vertices relative to it
### Instance Objects (Path A / B2)
@@ -136,7 +150,21 @@ Speckle `InstanceProxy` objects reference shared definition geometry via `defini
- **Revit format**: `definitionId` is a 64-char hex hash; geometry is found by walking the object tree
- **IFC format**: `definitionId` starts with `DEFINITION:`; geometry is in `definitionGeometry` collection
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 (e.g. chairs, light fixtures, curtain wall panels).
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. Content-based hashing further deduplicates definitions that share identical geometry.
### Curve Geometry (Path B3)
Objects whose `displayValue` contains `Objects.Geometry.Line`, `Objects.Geometry.Arc`, or `Objects.Geometry.Polycurve` items (and no meshes or instances) are exported as curve geometry using native IFC curve types:
- **Lines** → `IfcLineIndex` segments (start/end points)
- **Arcs** → `IfcArcIndex` segments (start/mid/end points)
- **Polycurves** → Mixed `IfcLineIndex` and `IfcArcIndex` segments from the polycurve's segment list (supports Line, Arc, and Polyline sub-segments)
All curves use `IfcIndexedPolyCurve` with `IfcCartesianPointList3D` for compact, deduplicated point storage. The representation uses `RepresentationType="Curve3D"`.
### 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.
## Property Sets
@@ -145,11 +173,44 @@ The exporter writes property sets matching Revit's native IFC export structure:
| Property Set | Content |
|---|---|
| `Pset_<Entity>Common` | Standard IFC properties: Reference, IsExternal, LoadBearing, ThermalTransmittance |
| `RVT_TypeParameters` | All Revit type parameters (written on the IfcTypeObject) |
| `Pset_SpaceCommon` | Room-specific: Reference, RoomNumber, RoomName, Category (Occupant) |
| `RVT_InstanceParameters` | All Revit instance parameters |
| `RVT_Identity` | Family, Type, ElementId, BuiltInCategory |
| `Qto_<MaterialName>` | Material quantities: area, volume, density |
## Quantities
Quantities follow the IFC standard naming convention: `Qto_<EntityType>BaseQuantities` and `Qto_<MaterialName>BaseQuantities`.
| Quantity Set | Content |
|---|---|
| `Qto_<EntityType>BaseQuantities` | Element-level quantities from Revit computed parameters (area, volume, length, width, height, perimeter) |
| `Qto_SpaceBaseQuantities` | Room quantities: NetFloorArea, NetVolume |
| `Qto_<MaterialName>BaseQuantities` | Per-material quantities: GrossArea, GrossVolume, Density |
### Element Quantity Mapping
| IFC Quantity | Revit Parameter(s) |
|---|---|
| GrossArea | `HOST_AREA_COMPUTED` |
| GrossVolume | `HOST_VOLUME_COMPUTED` |
| Length | `CURVE_ELEM_LENGTH`, `INSTANCE_LENGTH_PARAM` |
| Height | `WALL_USER_HEIGHT_PARAM`, `FAMILY_HEIGHT_PARAM`, `INSTANCE_HEAD_HEIGHT_PARAM` |
| Width | `INSTANCE_WIDTH_PARAM`, `FURNITURE_WIDTH`, `FLOOR_ATTR_THICKNESS_PARAM` |
| Perimeter | `HOST_PERIMETER_COMPUTED` |
### 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_PipeFittingBaseQuantities`, `Qto_SanitaryTerminalBaseQuantities`, `Qto_ReinforcingElementBaseQuantities`, `Qto_MechanicalFastenerBaseQuantities`
## IfcSpace (Rooms)
Revit Rooms (`OST_Rooms`) are exported as `IfcSpace` elements with special handling:
- **Spatial relationship**: Aggregated under `IfcBuildingStorey` via `IfcRelAggregates` (not contained)
- **Naming**: Uses the Speckle object `name` attribute (not Family:Type which is "none:none" for rooms)
- **IfcSpace.Name**: Set to `ROOM_NUMBER`
- **IfcSpace.LongName**: Set to `ROOM_NAME`
- **Geometry**: Converted from `displayValue` meshes like any other element
## Function Inputs
@@ -160,6 +221,16 @@ The exporter writes property sets matching Revit's native IFC export structure:
| `IFC_SITE_NAME` | Name for the IfcSite entity |
| `IFC_BUILDING_NAME` | Name for the IfcBuilding entity |
## Environment Variables
For local testing via `receiver.py`, configure a `.env` file:
| Variable | Description |
|---|---|
| `SPECKLE_SERVER_URL` | Speckle server URL (default: `https://app.speckle.systems`) |
| `SPECKLE_TOKEN` | Personal access token for authentication |
| `SPECKLE_PROJECT_ID` | Project (stream) ID |
## Testing
| Model Name | Revit Size | IFC Size | Conversion Time |
-27
View File
@@ -1,27 +0,0 @@
"""Helper module for a simple speckle object tree flattening."""
from collections.abc import Iterable
from specklepy.objects import Base
def flatten_base(base: Base) -> Iterable[Base]:
"""Flatten a base object into an iterable of bases.
This function recursively traverses the `elements` or `@elements` attribute of the
base object, yielding each nested base object.
Args:
base (Base): The base object to flatten.
Yields:
Base: Each nested base object in the hierarchy.
"""
# Attempt to get the elements attribute, fallback to @elements if necessary
elements = getattr(base, "elements", getattr(base, "@elements", None))
if elements is not None:
for element in elements:
yield from flatten_base(element)
yield base
+63 -29
View File
@@ -1,3 +1,4 @@
import zipfile
from datetime import datetime
import ifcopenshell.api
@@ -6,7 +7,8 @@ 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.instances import is_instance, instance_to_ifc, build_definition_map, print_instance_stats, get_definition_object
from utils.curves import curve_to_ifc
from utils.instances import is_instance, instance_to_ifc, build_definition_map, print_instance_stats, get_definition_object, reset_caches as reset_instance_caches
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
from utils.type_manager import TypeManager
@@ -61,6 +63,7 @@ def automate_function(
# Reset caches from any previous run
reset_props_caches()
reset_mapper_caches()
reset_instance_caches()
# ------------------------------------------------------------------ #
# 1. Receive
@@ -106,6 +109,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
@@ -162,30 +168,52 @@ def automate_function(
total += 1
# B2: Instance objects nested inside displayValue
# Each becomes its own IFC element (same class as parent)
# Use the parent object's name — the InstanceProxy has no meaningful name
# All instances are parts of the SAME element (e.g. window frame + glass + sill)
# Merge all into a single IFC element with combined geometry
nested_instances = get_display_instances(obj)
for inst in nested_instances:
inst_rep, inst_placement = instance_to_ifc(
ifc, body_context, inst, definition_map, scale=scale, material_manager=material_manager
)
if not inst_rep:
no_geometry += 1
continue
inst_element = _create_element(
ifc, ifc_class, name, inst_rep, inst_placement, storey,
storey_manager=storey_manager,
tag=get_element_tag(obj), guid=None,
object_type=getattr(obj, "type", None),
)
write_properties(ifc, inst_element, obj, ifc_class=ifc_class, category_name=category_name)
type_manager.assign(inst_element, obj, ifc_class)
instance_count += 1
total += 1
if nested_instances:
mapped_items = []
inst_placement = None
for inst in nested_instances:
inst_rep, inst_pl = instance_to_ifc(
ifc, body_context, inst, definition_map, scale=scale, material_manager=material_manager
)
if inst_rep:
mapped_items.extend(inst_rep.Items)
if inst_placement is None:
inst_placement = inst_pl
if mapped_items:
combined_rep = ifc.createIfcShapeRepresentation(
ContextOfItems=body_context,
RepresentationIdentifier="Body",
RepresentationType="MappedRepresentation",
Items=mapped_items,
)
element = _create_element(
ifc, ifc_class, name, combined_rep, inst_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)
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 = curve_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")
@@ -205,12 +233,18 @@ def automate_function(
ifc.write(ifc_filename)
print(f"\n💾 IFC file written: {ifc_filename}")
# try:
# automate_context.mark_run_success("Success! You can download the IF file below.")
# automate_context.store_file_result(f"./{ifc_filename}")
# except Exception as e:
# print(f" ⚠️ Could not upload file result (network issue?): {e}")
# automate_context.mark_run_failed(f"Something went wrong when storing file result. Exception detail: {e}")
zip_filename = f"{file_name}_{timestamp}.zip"
with zipfile.ZipFile(zip_filename, "w", zipfile.ZIP_DEFLATED) as zf:
zf.write(ifc_filename)
print(f"Zipped: {zip_filename}")
try:
automate_context.mark_run_success("Success! You can download the IFC file below.")
automate_context.store_file_result(f"./{zip_filename}")
except Exception as e:
print(f" Could not upload file result (network issue?): {e}")
automate_context.mark_run_failed(f"Something went wrong when storing file result. Exception detail: {e}")
print(f"\n{'=' * 60}")
print(f" Export complete!")
@@ -258,7 +292,7 @@ def _create_element(ifc, ifc_class, name, rep, placement, storey,
# IfcSpace is a spatial structure element — must be decomposed (aggregated)
# under its IfcBuildingStorey, not spatially contained.
if storey_manager:
if ifc_class in ("IfcSite", "IfcSpace"):
if ifc_class in ("IfcSite", "IfcSpace", "IfcRoad", "IfcBridge", "IfcRailway", "IfcMarineFacility"):
storey_manager.queue_aggregate(storey, element)
else:
storey_manager.queue_contain(storey, element)
+357
View File
@@ -0,0 +1,357 @@
# =============================================================================
# curves.py
# Converts Speckle 2D curve geometry (Polycurve, Line, Arc, Circle, Polyline)
# into IFC IfcIndexedPolyCurve representations.
#
# Curve types in segments:
# - Objects.Geometry.Line → start/end Points → IfcLineIndex
# - Objects.Geometry.Arc → startPoint/midPoint/endPoint → IfcArcIndex
# - Objects.Geometry.Circle → converted to arc segments
# - Objects.Geometry.Polyline → point sequence → IfcLineIndex chains
#
# The result is an IfcIndexedPolyCurve with IfcCartesianPointList3D.
# =============================================================================
import ifcopenshell
import ifcopenshell.api
from specklepy.objects.base import Base
from utils.helpers import _get, MM_SCALES
from utils.geometry import _get_shared, _make_placement
# Speckle types that are curve geometry
_CURVE_TYPES = {"Line", "Arc", "Circle", "Ellipse", "Polycurve", "Polyline", "Curve"}
def is_curve(obj) -> bool:
"""Return True if this object is a Speckle curve type."""
speckle_type = _get(obj, "speckle_type") or ""
return any(ct in speckle_type for ct in _CURVE_TYPES)
def _resolve_scale(obj, fallback: float) -> float:
"""Resolve unit scale for a curve object."""
units = _get(obj, "units")
if units and isinstance(units, str):
return MM_SCALES.get(units.lower().strip(), fallback)
return fallback
def _point_coords(pt, scale: float) -> tuple:
"""Extract (x, y, z) from a Speckle Point, scaled to mm and rounded."""
x = round(float(_get(pt, "x") or 0) * scale, 3)
y = round(float(_get(pt, "y") or 0) * scale, 3)
z = round(float(_get(pt, "z") or 0) * scale, 3)
return x, y, z
def _extract_polycurve(obj, scale: float) -> tuple:
"""
Extract points and segment indices from a Polycurve.
Returns (points_3d, segments) where:
points_3d: list of [x, y, z] coordinate lists
segments: list of IfcLineIndex/IfcArcIndex-compatible tuples
("line", [i, j]) or ("arc", [i, mid, j]) (1-based)
"""
segments_raw = _get(obj, "segments") or []
if not isinstance(segments_raw, list):
segments_raw = list(segments_raw)
if not segments_raw:
return [], []
obj_scale = _resolve_scale(obj, scale)
points = [] # list of [x, y, z]
point_map = {} # (rounded_x, rounded_y, rounded_z) -> 1-based index
ifc_segments = []
def _add_point(pt, seg_scale: float) -> int:
"""Add a point and return its 1-based index (deduplicating nearby points)."""
x, y, z = _point_coords(pt, seg_scale)
# Snap to 0.01mm grid for deduplication
key = (round(x * 100), round(y * 100), round(z * 100))
if key in point_map:
return point_map[key]
idx = len(points) + 1 # 1-based for IFC
points.append([x, y, z])
point_map[key] = idx
return idx
for seg in segments_raw:
if seg is None:
continue
seg_type = (_get(seg, "speckle_type") or "").split(".")[-1]
seg_scale = _resolve_scale(seg, obj_scale)
if seg_type == "Line":
start_pt = _get(seg, "start")
end_pt = _get(seg, "end")
if start_pt is None or end_pt is None:
continue
i = _add_point(start_pt, seg_scale)
j = _add_point(end_pt, seg_scale)
if i != j:
ifc_segments.append(("line", [i, j]))
elif seg_type == "Arc":
start_pt = _get(seg, "startPoint")
mid_pt = _get(seg, "midPoint")
end_pt = _get(seg, "endPoint")
if start_pt is None or mid_pt is None or end_pt is None:
continue
i = _add_point(start_pt, seg_scale)
m = _add_point(mid_pt, seg_scale)
j = _add_point(end_pt, seg_scale)
if i != j and i != m and m != j:
ifc_segments.append(("arc", [i, m, j]))
elif seg_type == "Polyline":
raw_value = _get(seg, "value") or []
if not raw_value:
continue
values = list(raw_value) if not isinstance(raw_value, list) else raw_value
indices = []
for vi in range(0, len(values) - 2, 3):
x = round(float(values[vi]) * seg_scale, 3)
y = round(float(values[vi + 1]) * seg_scale, 3)
z = round(float(values[vi + 2]) * seg_scale, 3)
key = (round(x * 100), round(y * 100), round(z * 100))
if key in point_map:
idx = point_map[key]
else:
idx = len(points) + 1
points.append([x, y, z])
point_map[key] = idx
indices.append(idx)
if len(indices) >= 2:
ifc_segments.append(("line", indices))
return points, ifc_segments
def _extract_single_line(obj, scale: float) -> tuple:
"""Extract a single Line as points + segment."""
obj_scale = _resolve_scale(obj, scale)
start_pt = _get(obj, "start")
end_pt = _get(obj, "end")
if start_pt is None or end_pt is None:
return [], []
sx, sy, sz = _point_coords(start_pt, obj_scale)
ex, ey, ez = _point_coords(end_pt, obj_scale)
return [[sx, sy, sz], [ex, ey, ez]], [("line", [1, 2])]
def _extract_single_arc(obj, scale: float) -> tuple:
"""Extract a single Arc as points + segment."""
obj_scale = _resolve_scale(obj, scale)
start_pt = _get(obj, "startPoint")
mid_pt = _get(obj, "midPoint")
end_pt = _get(obj, "endPoint")
if start_pt is None or mid_pt is None or end_pt is None:
return [], []
sx, sy, sz = _point_coords(start_pt, obj_scale)
mx, my, mz = _point_coords(mid_pt, obj_scale)
ex, ey, ez = _point_coords(end_pt, obj_scale)
return [[sx, sy, sz], [mx, my, mz], [ex, ey, ez]], [("arc", [1, 2, 3])]
def extract_curve_data(obj, scale: float = 1.0) -> tuple:
"""
Extract curve points and segments from any supported curve type.
Returns (points_3d, segments) or ([], []) if not a curve.
"""
speckle_type = (_get(obj, "speckle_type") or "").split(".")[-1]
if speckle_type == "Polycurve":
return _extract_polycurve(obj, scale)
elif speckle_type == "Line":
return _extract_single_line(obj, scale)
elif speckle_type == "Arc":
return _extract_single_arc(obj, scale)
return [], []
def build_ifc_curve(ifc, points: list, segments: list):
"""
Build an IfcIndexedPolyCurve from points and segment descriptors.
points: list of [x, y, z] coordinates
segments: list of ("line", [indices]) or ("arc", [indices])
Returns IfcIndexedPolyCurve or None.
"""
if not points or not segments:
return None
point_list = ifc.createIfcCartesianPointList3D(points)
ifc_segments = []
for seg_type, indices in segments:
if seg_type == "arc":
ifc_segments.append(ifc.create_entity("IfcArcIndex", indices))
else:
ifc_segments.append(ifc.create_entity("IfcLineIndex", indices))
if not ifc_segments:
return None
return ifc.createIfcIndexedPolyCurve(
Points=point_list,
Segments=ifc_segments,
SelfIntersect=False,
)
def get_display_curves(obj) -> list:
"""
Collect curve objects from an object's displayValue, or the object itself.
Returns a list of curve objects (Polycurve, Line, Arc, etc.).
"""
curves = []
for key in ["displayValue", "@displayValue", "_displayValue"]:
display = _get(obj, key)
if display is None:
continue
items = display if isinstance(display, list) else [display]
for item in items:
if item is not None and is_curve(item):
curves.append(item)
if curves:
break
# Fallback: the object itself is a curve
if not curves and is_curve(obj):
curves.append(obj)
return curves
def curve_to_ifc(
ifc: ifcopenshell.file,
body_context,
obj: Base,
scale: float = 1.0,
material_manager=None,
) -> tuple:
"""
Convert a Speckle object with curve geometry -> (IfcShapeRepresentation, IfcLocalPlacement).
Looks for curves in displayValue first, then checks the object itself.
Creates one IfcIndexedPolyCurve per curve item.
Returns (None, None) if no usable curve geometry.
"""
curves = get_display_curves(obj)
if not curves:
return None, None
obj_app_id = _get(obj, "applicationId")
obj_scale = _resolve_scale(obj, scale)
# Collect curve data and compute origin incrementally
curve_cache = []
xmin = ymin = zmin = float("inf")
xmax = ymax = float("-inf")
has_points = False
for curve_obj in curves:
points, segments = extract_curve_data(curve_obj, obj_scale)
if points and segments:
curve_cache.append((points, segments))
has_points = True
for p in points:
x, y, z = p[0], p[1], p[2]
if x < xmin: xmin = x
if x > xmax: xmax = x
if y < ymin: ymin = y
if y > ymax: ymax = y
if z < zmin: zmin = z
else:
curve_cache.append(None)
if not has_points:
return None, None
ox = (xmin + xmax) / 2.0
oy = (ymin + ymax) / 2.0
oz = zmin
# Build IFC curve entities
geom_items = []
for i, cached in enumerate(curve_cache):
if cached is None:
continue
points, segments = cached
offset_points = [
[p[0] - ox, p[1] - oy, p[2] - oz] for p in points
]
curve_entity = build_ifc_curve(ifc, offset_points, segments)
if curve_entity is None:
continue
# Apply material
if material_manager:
curve_app_id = _get(curves[i], "applicationId") or obj_app_id
if curve_app_id:
material_manager.apply_to_item(curve_entity, str(curve_app_id))
geom_items.append(curve_entity)
if not geom_items:
return None, None
rep = ifc.createIfcShapeRepresentation(
ContextOfItems=body_context,
RepresentationIdentifier="Body",
RepresentationType="Curve3D",
Items=geom_items,
)
placement = _make_placement(ifc, ox, oy, oz)
return rep, placement
def build_curve_rep_map(ifc, body_context, obj, scale: float = 1.0,
material_manager=None, fallback_app_ids: list = None,
definition_id: str = None):
"""
Build an IfcRepresentationMap from a curve definition object.
Used for instance-based curve geometry (shared across instances).
Returns IfcRepresentationMap or None.
"""
points, segments = extract_curve_data(obj, scale)
if not points or not segments:
return None
curve_entity = build_ifc_curve(ifc, points, segments)
if curve_entity is None:
return None
# Apply material (3-tier: object app_id -> fallbacks -> definition)
if material_manager:
app_id = _get(obj, "applicationId")
style = material_manager.get_style_with_fallbacks(
primary_app_id=str(app_id) if app_id else None,
fallback_app_ids=fallback_app_ids,
definition_id=definition_id,
)
if style:
try:
ifcopenshell.api.run(
"style.assign_item_style", ifc,
item=curve_entity, style=style,
)
material_manager._apply_count += 1
except Exception:
pass
shared = _get_shared(ifc)
a2p = ifc.createIfcAxis2Placement3D(shared["origin_0"], None, None)
mapped_rep = ifc.createIfcShapeRepresentation(
ContextOfItems=body_context,
RepresentationIdentifier="Body",
RepresentationType="Curve3D",
Items=[curve_entity],
)
return ifc.createIfcRepresentationMap(a2p, mapped_rep)
+78 -71
View File
@@ -1,6 +1,6 @@
# =============================================================================
# geometry.py
# Converts Speckle DataObject geometry IFC IfcPolygonalFaceSet + IfcLocalPlacement
# Converts Speckle DataObject geometry -> IFC IfcPolygonalFaceSet + IfcLocalPlacement
#
# Key facts:
# - After specklepy receive(), vertices and faces are FLAT Python lists
@@ -8,21 +8,12 @@
# - Units are in mm (for Revit), scale to metres for IFC
# - Vertices are in absolute world coordinates
# - Uses IfcPolygonalFaceSet (indexed vertices) instead of IfcFacetedBrep
# for compact output each vertex stored once, not once per face.
# for compact output -- each vertex stored once, not once per face.
# =============================================================================
import ifcopenshell
from specklepy.objects.base import Base
# Scale factors → MILLIMETRES (IFC file is declared as mm)
_UNIT_SCALES = {
"mm": 1.0, "millimeter": 1.0, "millimeters": 1.0,
"cm": 10.0, "centimeter": 10.0, "centimeters": 10.0,
"m": 1000.0, "meter": 1000.0, "meters": 1000.0,
"ft": 304.8, "foot": 304.8, "feet": 304.8,
"in": 25.4, "inch": 25.4, "inches": 25.4,
}
from utils.helpers import _get, MM_SCALES as _UNIT_SCALES
# --------------------------------------------------------------------------- #
@@ -47,8 +38,8 @@ def build_ifc_facesets(ifc, verts_scaled: list, face_groups: list) -> list:
face_groups: list of index lists [[i,j,k], [i,j,k,l], ...]
Returns: list of IfcPolygonalFaceSet (typically one, empty on failure).
"""
snap_to_idx = {} # snap_key 0-based index in deduped_verts
deduped_verts = [] # [[x, y, z], ...] lists for direct IFC use
snap_to_idx = {} # snap_key -> 0-based index in deduped_verts
deduped_verts = [] # [[x, y, z], ...] -- lists for direct IFC use
inv_tol = _INV_TOL
# Validate faces and remap indices to deduplicated vertex list
@@ -85,6 +76,13 @@ def build_ifc_facesets(ifc, verts_scaled: list, face_groups: list) -> list:
if not valid_faces or not deduped_verts:
return []
# Round vertex coordinates to reduce IFC text file size
# 3 decimal places = 0.001mm precision (more than sufficient)
for v in deduped_verts:
v[0] = round(v[0], 3)
v[1] = round(v[1], 3)
v[2] = round(v[2], 3)
# Build IFC entities
try:
point_list = ifc.createIfcCartesianPointList3D(deduped_verts)
@@ -97,39 +95,15 @@ def build_ifc_facesets(ifc, verts_scaled: list, face_groups: list) -> list:
return []
# --------------------------------------------------------------------------- #
# Safe data access helpers
# --------------------------------------------------------------------------- #
def _get(obj, key, default=None):
"""
Safe access for specklepy Base objects.
Tries attribute access first, then bracket access.
"""
try:
val = getattr(obj, key, None)
if val is not None:
return val
except Exception:
pass
try:
val = obj[key]
if val is not None:
return val
except Exception:
pass
return default
def unwrap_chunks(raw) -> list:
"""
Flatten a Speckle data array into a plain Python list of numbers.
Handles two cases:
1. Already flat list of numbers (after specklepy receive deserializes)
returned as-is (fast path)
-> returned as-is (fast path)
2. List of DataChunk objects (raw from server before deserialization)
each chunk's .data list is concatenated
-> each chunk's .data list is concatenated
"""
if not raw:
return []
@@ -161,7 +135,7 @@ def unwrap_chunks(raw) -> list:
def _resolve_scale(obj, stream_scale: float) -> float:
"""Resolve unit scale: obj.units stream fallback."""
"""Resolve unit scale: obj.units -> stream fallback."""
units = _get(obj, "units")
if units and isinstance(units, str):
return _UNIT_SCALES.get(units.lower().strip(), stream_scale)
@@ -175,7 +149,7 @@ def _resolve_scale(obj, stream_scale: float) -> float:
def _is_mesh(item) -> bool:
"""
Detect if a specklepy object is a Mesh.
Uses speckle_type string more reliable than hasattr on Base objects.
Uses speckle_type string -- more reliable than hasattr on Base objects.
"""
if item is None:
return False
@@ -188,23 +162,39 @@ def _is_mesh(item) -> bool:
return verts is not None and faces is not None
def get_display_meshes(obj: Base) -> list:
def _collect_meshes_from_display(obj) -> list:
"""
Extract all Mesh objects from a DataObject's displayValue.
displayValue is always an array per the Speckle schema docs.
Collect Mesh objects from an object's displayValue.
If an item is not a Mesh (e.g. BrepX, Brep), recursively check
its own displayValue for nested meshes.
"""
meshes = []
for key in ["displayValue", "@displayValue"]:
for key in ["displayValue", "@displayValue", "_displayValue"]:
display = _get(obj, key)
if display is None:
continue
items = display if isinstance(display, list) else [display]
for item in items:
if item is None:
continue
if _is_mesh(item):
meshes.append(item)
else:
# BrepX / Brep / other geometry types may carry a nested
# displayValue with the tessellated mesh representation
meshes.extend(_collect_meshes_from_display(item))
if meshes:
break # found meshes, don't check @displayValue too
break
return meshes
def get_display_meshes(obj: Base) -> list:
"""
Extract all Mesh objects from a DataObject's displayValue.
Handles nested geometry types (BrepX, Brep) that wrap meshes
inside their own displayValue.
"""
meshes = _collect_meshes_from_display(obj)
# Fallback: object itself is a Mesh
if not meshes and _is_mesh(obj):
@@ -225,10 +215,10 @@ def get_display_instances(obj: Base) -> list:
- definitionId: "DEFINITION:{meshAppId}" string
- units: "m"
Raw meshes do NOT appear in displayValue in IFCSpeckle exports.
Raw meshes do NOT appear in displayValue in IFC->Speckle exports.
"""
instances = []
for key in ["displayValue", "@displayValue"]:
for key in ["displayValue", "@displayValue", "_displayValue"]:
display = _get(obj, key)
if display is None:
continue
@@ -253,7 +243,7 @@ def decode_faces(faces_raw: list) -> list:
"""
Decode Speckle's run-length encoded face list into vertex index groups.
Format: [n, i0, i1, ..., n, i0, i1, ...]
n=0 triangle (legacy), n=1 quad (legacy), n≥3 → n-gon
n=0 -> triangle (legacy), n=1 -> quad (legacy), n>=3 -> n-gon
"""
decoded = []
i = 0
@@ -285,7 +275,7 @@ def compute_origin(flat_verts: list) -> tuple:
"""
Compute placement origin from scaled vertex list (mm).
X, Y = bounding box centroid
Z = minimum Z (bottom face of element more natural for IFC)
Z = minimum Z (bottom face of element -- more natural for IFC)
Single-pass to avoid creating 3 sliced copies of a large list.
"""
x0 = flat_verts[0]
@@ -328,9 +318,9 @@ def _get_shared(ifc):
def _make_placement(ifc, x: float, y: float, z: float):
"""Create an IfcLocalPlacement at absolute world coordinates (metres)."""
"""Create an IfcLocalPlacement at absolute world coordinates (mm)."""
shared = _get_shared(ifc)
origin = ifc.createIfcCartesianPoint([x, y, z])
origin = ifc.createIfcCartesianPoint([round(x, 3), round(y, 3), round(z, 3)])
a2p = ifc.createIfcAxis2Placement3D(origin, shared["z_axis"], shared["x_axis"])
return ifc.createIfcLocalPlacement(PlacementRelTo=None, RelativePlacement=a2p)
@@ -347,7 +337,7 @@ def mesh_to_ifc(
material_manager=None,
) -> tuple:
"""
Convert a Speckle DataObject (IfcShapeRepresentation, IfcLocalPlacement).
Convert a Speckle DataObject -> (IfcShapeRepresentation, IfcLocalPlacement).
Creates one IfcPolygonalFaceSet per mesh so each can carry its own material style.
Returns (None, None) if no usable geometry is found.
"""
@@ -355,14 +345,21 @@ def mesh_to_ifc(
if not meshes:
return None, None
# Parent object's applicationId -- used as fallback for material lookup
# when inner meshes (e.g. from BrepX) don't have their own applicationId
obj_app_id = _get(obj, "applicationId")
obj_scale = _resolve_scale(obj, scale)
# ------------------------------------------------------------------ #
# Pass 1: unpack vertices once per mesh, collect all scaled coords
# to compute world origin. Cache (verts, ms) for Pass 2.
# Pass 1: unpack and scale vertices once per mesh, compute origin
# incrementally without accumulating all vertices in memory.
# ------------------------------------------------------------------ #
mesh_cache = [] # [(verts_list, ms, scaled)] or None per mesh
all_scaled = []
mesh_cache = [] # [scaled_verts_list] or None per mesh
xmin = ymin = zmin = float("inf")
xmax = ymax = float("-inf")
has_verts = False
for mesh in meshes:
raw_verts = _get(mesh, "vertices") or []
verts = unwrap_chunks(raw_verts if isinstance(raw_verts, list) else list(raw_verts))
@@ -370,25 +367,34 @@ def mesh_to_ifc(
mesh_cache.append(None)
continue
ms = _resolve_scale(mesh, obj_scale)
# Pre-scale vertices once, reuse in Pass 2
scaled = [float(v) * ms for v in verts]
mesh_cache.append((verts, ms, scaled))
all_scaled.extend(scaled)
mesh_cache.append(scaled)
has_verts = True
if not all_scaled:
# Update bounding box from this mesh's scaled vertices
for i in range(0, len(scaled) - 2, 3):
x, y, z = scaled[i], scaled[i + 1], scaled[i + 2]
if x < xmin: xmin = x
if x > xmax: xmax = x
if y < ymin: ymin = y
if y > ymax: ymax = y
if z < zmin: zmin = z
if not has_verts:
return None, None
ox, oy, oz = compute_origin(all_scaled)
ox = (xmin + xmax) / 2.0
oy = (ymin + ymax) / 2.0
oz = zmin
# ------------------------------------------------------------------ #
# Pass 2: one faceset per mesh reuse cached verts, only unpack faces
# Pass 2: one faceset per mesh -- reuse cached verts, only unpack faces
# ------------------------------------------------------------------ #
geom_items = []
for mesh, cached in zip(meshes, mesh_cache):
if cached is None:
for mesh, scaled in zip(meshes, mesh_cache):
if scaled is None:
continue
verts, ms, scaled = cached
raw_faces = _get(mesh, "faces") or []
faces_raw = unwrap_chunks(raw_faces if isinstance(raw_faces, list) else list(raw_faces))
@@ -398,7 +404,7 @@ def mesh_to_ifc(
try:
face_groups = decode_faces(faces_raw)
except Exception as e:
print(f" ⚠️ Face decode error: {e}")
print(f" Warning: Face decode error: {e}")
continue
# Offset pre-scaled vertices relative to origin (flat list, no tuples)
@@ -415,8 +421,9 @@ def mesh_to_ifc(
continue
# Apply material style to every faceset of this mesh
# Inner meshes (from BrepX) may lack applicationId -- fall back to parent's
if material_manager:
mesh_app_id = _get(mesh, "applicationId")
mesh_app_id = _get(mesh, "applicationId") or obj_app_id
if mesh_app_id:
for fs in mesh_facesets:
material_manager.apply_to_item(fs, str(mesh_app_id))
@@ -437,4 +444,4 @@ def mesh_to_ifc(
)
placement = _make_placement(ifc, ox, oy, oz)
return rep, placement
return rep, placement
+38
View File
@@ -0,0 +1,38 @@
# =============================================================================
# helpers.py
# Shared utilities used across the exporter modules.
# =============================================================================
def _get(obj, key, default=None):
"""
Safe access for specklepy Base objects, dicts, or any hybrid.
Tries attribute access first, then bracket access.
"""
if obj is None:
return default
if isinstance(obj, dict):
return obj.get(key, default)
try:
val = getattr(obj, key, None)
if val is not None:
return val
except Exception:
pass
try:
val = obj[key]
if val is not None:
return val
except Exception:
pass
return default
# Scale factors → MILLIMETRES (IFC file is declared as mm)
MM_SCALES = {
"mm": 1.0, "millimeter": 1.0, "millimeters": 1.0,
"cm": 10.0, "centimeter": 10.0, "centimeters": 10.0,
"m": 1000.0, "meter": 1000.0, "meters": 1000.0,
"ft": 304.8, "foot": 304.8, "feet": 304.8,
"in": 25.4, "inch": 25.4, "inches": 25.4,
}
+278 -108
View File
@@ -2,17 +2,17 @@
# instances.py
# Handles Speckle InstanceProxy objects from both:
#
# FORMAT A Revit connector (our actual use case):
# FORMAT A -- Revit connector (our actual use case):
# _units = "mm"
# transform = 16 floats, row-major, translation in MM
# definitionId = 64-char uppercase hex hash (matches object id[:32] in tree)
# The definition object lives somewhere in the object tree.
#
# FORMAT B speckleifc IFCSpeckle converter:
# FORMAT B -- speckleifc IFC->Speckle converter:
# units = "m"
# transform = 16 floats, row-major, translation in METRES
# definitionId = "DEFINITION:{meshAppId}"
# Definition geometry lives in root Collection("definitionGeometry")
# Definition geometry lives in root -> Collection("definitionGeometry")
#
# We detect the format by the definitionId prefix.
#
@@ -20,9 +20,14 @@
# sharing the same definition reference a single copy of the geometry.
# =============================================================================
import hashlib
import math
import struct
import ifcopenshell.api
from specklepy.objects.base import Base
from utils.geometry import _get, unwrap_chunks, decode_faces, _UNIT_SCALES, build_ifc_facesets, _get_shared
from utils.helpers import _get, MM_SCALES
from utils.geometry import unwrap_chunks, decode_faces, build_ifc_facesets, _get_shared, _is_mesh
from utils.curves import is_curve, build_curve_rep_map
def is_instance(obj) -> bool:
@@ -40,15 +45,18 @@ def build_definition_map(root: Base) -> dict:
Build a unified definition map that handles both formats.
Returns dict with keys:
"by_id" : {obj_id_lower[:32] object} for Revit format
"by_app_id" : {applicationId_lower object} for Revit format
"ifc_proxies" : {"DEFINITION:xxx" proxy} for IFC format
"ifc_meshes" : {meshAppId Mesh} for IFC format
"by_id" : {obj_id_lower[:32] -> object} for Revit format
"by_app_id" : {applicationId_lower -> object} for Revit format
"ifc_proxies" : {"DEFINITION:xxx" -> proxy} for IFC format
"ifc_meshes" : {meshAppId -> Mesh} for IFC format
"definition_sources": set of applicationId (lowercase) that are definition
geometry sources -- these should be skipped during export
"""
by_id = {}
by_app_id = {}
ifc_proxies = {}
ifc_meshes = {}
definition_sources = set()
# --- Walk entire tree for Revit format ---
_collect_all(root, by_id, by_app_id, depth=0)
@@ -61,6 +69,11 @@ def build_definition_map(root: Base) -> dict:
if app_id:
ifc_proxies[app_id] = proxy # original case (for IFC format)
ifc_proxies[app_id.lower()] = proxy # lowercase (for Revit format)
# Collect all objects referenced by this proxy as definition sources
object_ids = _get(proxy, "objects") or []
for oid in (object_ids if isinstance(object_ids, list) else [object_ids]):
if oid:
definition_sources.add(str(oid).lower())
elements = _get(root, "elements") or _get(root, "@elements") or []
for child in (elements if isinstance(elements, list) else []):
@@ -75,12 +88,14 @@ def build_definition_map(root: Base) -> dict:
print(f" Objects indexed by appId: {len(by_app_id)}")
print(f" IFC definition proxies: {len(ifc_proxies)}")
print(f" IFC definition meshes: {len(ifc_meshes)}")
print(f" Definition sources: {len(definition_sources)}")
return {
"by_id": by_id,
"by_app_id": by_app_id,
"ifc_proxies": ifc_proxies,
"ifc_meshes": ifc_meshes,
"by_id": by_id,
"by_app_id": by_app_id,
"ifc_proxies": ifc_proxies,
"ifc_meshes": ifc_meshes,
"definition_sources": definition_sources,
}
@@ -92,7 +107,7 @@ def _collect_all(obj, by_id: dict, by_app_id: dict, depth: int):
if obj_id and isinstance(obj_id, str):
key = obj_id.lower()
by_id[key] = obj
# Also store truncated definitionId (64 chars) matches id (32 chars)
# Also store truncated -- definitionId (64 chars) matches id (32 chars)
if len(key) == 32:
by_id[key] = obj
elif len(key) > 32:
@@ -102,7 +117,8 @@ def _collect_all(obj, by_id: dict, by_app_id: dict, depth: int):
if app_id and isinstance(app_id, str):
by_app_id[app_id.lower()] = obj
for key in ["elements", "@elements", "displayValue", "@displayValue",
for key in ["elements", "@elements", "_elements",
"displayValue", "@displayValue", "_displayValue",
"objects", "@objects", "definition", "@definition"]:
try:
children = obj[key]
@@ -116,11 +132,29 @@ def _collect_all(obj, by_id: dict, by_app_id: dict, depth: int):
continue
def _get_revit_meshes(definition_id: str, definition_map: dict) -> list:
def _get_definition_source_object(definition_id: str, definition_map: dict):
"""Resolve the first source object referenced by a definition proxy."""
ifc_proxies = definition_map.get("ifc_proxies", {})
proxy = ifc_proxies.get(definition_id) or ifc_proxies.get(definition_id.lower())
if proxy is None:
return None
object_ids = _get(proxy, "objects") or []
if not isinstance(object_ids, list):
object_ids = list(object_ids)
if not object_ids:
return None
by_app_id = definition_map.get("by_app_id", {})
return by_app_id.get(str(object_ids[0]).lower())
def _get_revit_meshes(definition_id: str, definition_map: dict) -> tuple:
"""
Revit format:
definitionId (64-char hex) InstanceDefinitionProxy.applicationId
proxy.objects[0] is a UUID applicationId find mesh by applicationId
definitionId (64-char hex) -> InstanceDefinitionProxy.applicationId
proxy.objects[0] is a UUID applicationId -> find mesh by applicationId
Returns (meshes, app_ids) where app_ids are all applicationIds encountered
in the resolution chain (definition objects, geometry objects) for material fallback.
"""
from utils.geometry import get_display_meshes
@@ -128,40 +162,55 @@ def _get_revit_meshes(definition_id: str, definition_map: dict) -> list:
ifc_proxies = definition_map.get("ifc_proxies", {})
proxy = ifc_proxies.get(definition_id) or ifc_proxies.get(definition_id.lower())
if proxy is None:
return []
return [], []
# Step 2: get the mesh applicationIds from proxy.objects
object_ids = _get(proxy, "objects") or []
if not isinstance(object_ids, list):
object_ids = list(object_ids)
# Step 3: look up each mesh by applicationId
# Step 3: look up each mesh by applicationId, collecting all encountered app IDs
by_app_id = definition_map.get("by_app_id", {})
meshes = []
encountered_app_ids = []
for oid in object_ids:
obj = by_app_id.get(str(oid).lower())
if obj is not None:
# Collect this object's applicationId
obj_aid = _get(obj, "applicationId")
if obj_aid:
encountered_app_ids.append(str(obj_aid))
# Also collect applicationIds from displayValue items (BrepX, etc.)
for key in ["displayValue", "@displayValue", "_displayValue"]:
display = _get(obj, key)
if display:
items = display if isinstance(display, list) else [display]
for item in items:
item_aid = _get(item, "applicationId")
if item_aid:
encountered_app_ids.append(str(item_aid))
break
# The found object may itself be a mesh, or contain displayValue meshes
found_meshes = get_display_meshes(obj)
if found_meshes:
meshes.extend(found_meshes)
else:
# It IS the mesh directly
elif _is_mesh(obj):
meshes.append(obj)
return meshes
return meshes, encountered_app_ids
def _get_ifc_meshes(definition_id: str, definition_map: dict) -> list:
def _get_ifc_meshes(definition_id: str, definition_map: dict) -> tuple:
"""
IFC format: definitionId = "DEFINITION:224058_mat0"
Look up proxy objects list meshes from ifc_meshes dict.
Look up proxy -> objects list -> meshes from ifc_meshes dict.
Returns (meshes, []) -- no extra app_ids needed, mesh applicationIds match directly.
"""
ifc_proxies = definition_map.get("ifc_proxies", {})
ifc_meshes = definition_map.get("ifc_meshes", {})
proxy = ifc_proxies.get(definition_id)
if proxy is None:
return []
return [], []
object_ids = _get(proxy, "objects") or []
result = []
@@ -169,20 +218,20 @@ def _get_ifc_meshes(definition_id: str, definition_map: dict) -> list:
mesh = ifc_meshes.get(str(oid))
if mesh is not None:
result.append(mesh)
return result
return result, []
def _resolve_instance_scale(obj, stream_scale: float) -> float:
"""
Resolve scale for the transform translation.
Tries bracket access for '_units' (Revit uses underscore).
IFC format instances have units="m" scale=1.0 (no scaling).
IFC format instances have units="m" -> scale=1.0 (no scaling).
"""
for key in ["units", "_units"]:
try:
units = obj[key]
if units and isinstance(units, str):
s = _UNIT_SCALES.get(units.lower().strip())
s = MM_SCALES.get(units.lower().strip())
if s is not None:
return s
except Exception:
@@ -193,39 +242,62 @@ def _resolve_instance_scale(obj, stream_scale: float) -> float:
# Stats
_stats = {"found": 0, "not_found": 0}
# Cache: mesh id (verts_scaled, face_groups) to avoid re-unpacking
# Cache: mesh id -> (verts_scaled, face_groups) to avoid re-unpacking
# AND re-scaling the same definition mesh across many instances that share it.
_mesh_data_cache: dict = {}
# Cache: definition_id IfcRepresentationMap (or None if no geometry)
# Cache: definition_id -> IfcRepresentationMap (or None if no geometry)
# All instances sharing the same definition reuse one geometry copy.
_rep_map_cache: dict = {}
# Cache: geometry content hash -> IfcRepresentationMap
# Enables sharing across different definitionIds that have identical geometry.
_geometry_hash_cache: dict = {}
# Shared identity placement for all instances (keyed by ifc file id)
_identity_placement_cache: dict[int, object] = {}
_MM_SCALES = {
"mm": 1.0, "millimeter": 1.0, "millimeters": 1.0,
"cm": 10.0, "centimeter": 10.0,
"m": 1000.0, "meter": 1000.0, "meters": 1000.0,
"ft": 304.8, "in": 25.4,
}
# --------------------------------------------------------------------------- #
# Geometry content hashing
# --------------------------------------------------------------------------- #
def _hash_mesh_data(mesh_data_list: list, material_key: str = "") -> str:
"""Compute a content hash from mesh geometry data for deduplication.
mesh_data_list: list of (verts_local, face_groups) tuples
material_key: string identifying the material (included in hash)
Returns: hex digest string
"""
h = hashlib.md5(usedforsecurity=False)
for verts_local, face_groups in mesh_data_list:
# Hash rounded vertices as packed floats (faster than str conversion)
for i in range(0, len(verts_local), 3):
h.update(struct.pack("3f",
round(verts_local[i], 3),
round(verts_local[i+1], 3),
round(verts_local[i+2], 3),
))
# Hash face indices
for face in face_groups:
h.update(struct.pack(f"{len(face)}i", *face))
# Separator between meshes
h.update(b"|")
if material_key:
h.update(material_key.encode())
return h.hexdigest()
# --------------------------------------------------------------------------- #
# IfcRepresentationMap builder geometry created once per definition
# IfcRepresentationMap builder -- geometry created once per definition
# --------------------------------------------------------------------------- #
def _build_rep_map(ifc, body_context, meshes: list, ifc_format: bool,
material_manager=None):
"""
Build an IfcRepresentationMap from definition meshes.
Geometry is in local coordinates (mm, no instance transform applied).
Returns IfcRepresentationMap or None if no valid geometry.
"""
geom_items = []
def _collect_mesh_data(meshes: list, ifc_format: bool) -> list:
"""Unpack, scale, and cache mesh vertex/face data.
Returns list of (mesh_obj, verts_local, face_groups) tuples.
"""
result = []
for mesh in meshes:
mesh_id = _get(mesh, "id") or _get(mesh, "applicationId")
if mesh_id and mesh_id in _mesh_data_cache:
@@ -233,48 +305,103 @@ def _build_rep_map(ifc, body_context, meshes: list, ifc_format: bool,
else:
raw_verts = _get(mesh, "vertices") or []
raw_faces = _get(mesh, "faces") or []
verts = unwrap_chunks(list(raw_verts))
faces_raw = unwrap_chunks(list(raw_faces))
verts = unwrap_chunks(raw_verts if isinstance(raw_verts, list) else list(raw_verts))
faces_raw = unwrap_chunks(raw_faces if isinstance(raw_faces, list) else list(raw_faces))
if not verts or not faces_raw:
continue
mesh_units = _get(mesh, "units") or _get(mesh, "_units") or ("m" if ifc_format else "mm")
ms = _MM_SCALES.get(mesh_units.lower().strip(), 1.0)
ms = MM_SCALES.get(mesh_units.lower().strip(), 1.0)
try:
face_groups = decode_faces(faces_raw)
except Exception as e:
print(f" ⚠️ Instance face decode: {e}")
print(f" Warning: Instance face decode: {e}")
continue
# Scale vertices once and cache the result
verts_local = [float(v) * ms for v in verts]
if mesh_id:
_mesh_data_cache[mesh_id] = (verts_local, face_groups)
mesh_facesets = build_ifc_facesets(ifc, verts_local, face_groups)
result.append((mesh, verts_local, face_groups))
return result
def _resolve_material_key(meshes_data: list, material_manager, fallback_app_ids, definition_id) -> str:
"""Build a material cache key string for geometry hashing."""
if not material_manager:
return ""
parts = []
for mesh, _, _ in meshes_data:
mesh_app_id = _get(mesh, "applicationId")
style = material_manager.get_style_with_fallbacks(
primary_app_id=str(mesh_app_id) if mesh_app_id else None,
fallback_app_ids=fallback_app_ids,
definition_id=definition_id,
)
parts.append(str(id(style)) if style else "")
return "|".join(parts)
def _build_rep_map(ifc, body_context, meshes: list, ifc_format: bool,
material_manager=None, fallback_app_ids: list = None,
definition_id: str = None):
"""
Build an IfcRepresentationMap from definition meshes.
Uses content-based hashing to reuse identical geometry across different
definitionIds. Returns IfcRepresentationMap or None if no valid geometry.
"""
# Step 1: Collect and cache raw mesh data (no IFC entities created yet)
meshes_data = _collect_mesh_data(meshes, ifc_format)
if not meshes_data:
return None
# Step 2: Compute content hash to check for identical geometry
mat_key = _resolve_material_key(meshes_data, material_manager, fallback_app_ids, definition_id)
geom_hash = _hash_mesh_data(
[(verts, faces) for _, verts, faces in meshes_data],
material_key=mat_key,
)
if geom_hash in _geometry_hash_cache:
return _geometry_hash_cache[geom_hash]
# Step 3: No match -- build IFC geometry entities
geom_items = []
for mesh, verts_local, face_groups in meshes_data:
mesh_facesets = build_ifc_facesets(ifc, verts_local, face_groups)
if not mesh_facesets:
continue
# Apply material style to each faceset
if material_manager:
mesh_app_id = _get(mesh, "applicationId")
if mesh_app_id:
style = material_manager.get_style_with_fallbacks(
primary_app_id=str(mesh_app_id) if mesh_app_id else None,
fallback_app_ids=fallback_app_ids,
definition_id=definition_id,
)
if style:
for fs in mesh_facesets:
material_manager.apply_to_item(fs, str(mesh_app_id))
try:
ifcopenshell.api.run(
"style.assign_item_style", ifc,
item=fs, style=style,
)
material_manager._apply_count += 1
except Exception:
pass
geom_items.extend(mesh_facesets)
if not geom_items:
_geometry_hash_cache[geom_hash] = None
return None
# Mapping origin = identity (local coords origin) — reuse shared origin
shared = _get_shared(ifc)
a2p = ifc.createIfcAxis2Placement3D(shared["origin_0"], None, None)
# The mapped representation holds the actual geometry
mapped_rep = ifc.createIfcShapeRepresentation(
ContextOfItems=body_context,
RepresentationIdentifier="Body",
@@ -282,41 +409,57 @@ def _build_rep_map(ifc, body_context, meshes: list, ifc_format: bool,
Items=geom_items,
)
return ifc.createIfcRepresentationMap(a2p, mapped_rep)
rep_map = ifc.createIfcRepresentationMap(a2p, mapped_rep)
_geometry_hash_cache[geom_hash] = rep_map
return rep_map
# --------------------------------------------------------------------------- #
# Transform IfcCartesianTransformationOperator3D
# Transform -> IfcCartesianTransformationOperator3D
# --------------------------------------------------------------------------- #
def _vec_magnitude(x, y, z):
return math.sqrt(x*x + y*y + z*z)
# Cache: rounded direction tuple -> IfcDirection entity (keyed by ifc file id)
_direction_cache: dict[int, dict] = {}
def _get_or_create_direction(ifc, dx, dy, dz):
"""Return a cached IfcDirection or create and cache a new one."""
fid = id(ifc)
if fid not in _direction_cache:
_direction_cache[fid] = {}
cache = _direction_cache[fid]
# Round to 6 decimals -- sufficient for unit vectors
key = (round(dx, 6), round(dy, 6), round(dz, 6))
if key not in cache:
cache[key] = ifc.createIfcDirection([key[0], key[1], key[2]])
return cache[key]
def _make_transform_operator(ifc, t: list, ts: float):
"""
Convert a row-major 4x4 matrix + translation scale into an
IfcCartesianTransformationOperator3DnonUniform.
t: 16 floats, row-major [r00,r01,r02,tx, r10,r11,r12,ty, r20,r21,r22,tz, 0,0,0,1]
ts: scale factor for translation components (e.g. 1000.0 for mmm)
The matrix acts as: p' = M * p + translation, where M rows are:
row0 = (t[0], t[1], t[2])
row1 = (t[4], t[5], t[6])
row2 = (t[8], t[9], t[10])
ts: scale factor for translation components (e.g. 1000.0 for m->mm)
IfcCartesianTransformationOperator axes represent the COLUMNS of M:
Axis1 = column 0 = where local X maps (t[0], t[4], t[8])
Axis2 = column 1 = where local Y maps (t[1], t[5], t[9])
Axis3 = column 2 = where local Z maps (t[2], t[6], t[10])
Axis1 = column 0 = where local X maps -> (t[0], t[4], t[8])
Axis2 = column 1 = where local Y maps -> (t[1], t[5], t[9])
Axis3 = column 2 = where local Z maps -> (t[2], t[6], t[10])
Always uses the non-uniform variant with explicit Axis3 to ensure
correct orientation for all transform types (mirrors, non-orthogonal, etc.).
Returns the IFC entity, or None if the transform is degenerate.
"""
# Extract COLUMNS of the 3x3 rotation/scale sub-matrix
ax1 = (float(t[0]), float(t[4]), float(t[8])) # column 0: X-axis direction
ax2 = (float(t[1]), float(t[5]), float(t[9])) # column 1: Y-axis direction
ax3 = (float(t[2]), float(t[6]), float(t[10])) # column 2: Z-axis direction
ax1 = (float(t[0]), float(t[4]), float(t[8]))
ax2 = (float(t[1]), float(t[5]), float(t[9]))
ax3 = (float(t[2]), float(t[6]), float(t[10]))
s1 = _vec_magnitude(*ax1)
s2 = _vec_magnitude(*ax2)
@@ -325,37 +468,41 @@ def _make_transform_operator(ifc, t: list, ts: float):
if s1 < 1e-10 or s2 < 1e-10 or s3 < 1e-10:
return None # degenerate transform
# Normalized direction vectors
d1 = ifc.createIfcDirection([ax1[0]/s1, ax1[1]/s1, ax1[2]/s1])
d2 = ifc.createIfcDirection([ax2[0]/s2, ax2[1]/s2, ax2[2]/s2])
d3 = ifc.createIfcDirection([ax3[0]/s3, ax3[1]/s3, ax3[2]/s3])
# Normalized direction vectors -- reuse cached IfcDirection entities
d1 = _get_or_create_direction(ifc, ax1[0]/s1, ax1[1]/s1, ax1[2]/s1)
d2 = _get_or_create_direction(ifc, ax2[0]/s2, ax2[1]/s2, ax2[2]/s2)
d3 = _get_or_create_direction(ifc, ax3[0]/s3, ax3[1]/s3, ax3[2]/s3)
# Translation, scaled to mm
tx = float(t[3]) * ts
ty = float(t[7]) * ts
tz = float(t[11]) * ts
# Translation, scaled and rounded to mm
tx = round(float(t[3]) * ts, 3)
ty = round(float(t[7]) * ts, 3)
tz = round(float(t[11]) * ts, 3)
origin = ifc.createIfcCartesianPoint([tx, ty, tz])
# Use non-uniform variant to handle mirrors and non-uniform scale
# Round scales for cleaner output
s1 = round(s1, 6)
s2 = round(s2, 6)
s3 = round(s3, 6)
return ifc.createIfcCartesianTransformationOperator3DnonUniform(
d1, # Axis1
d2, # Axis2
origin, # LocalOrigin
s1, # Scale
d3, # Axis3
d3, # Axis3 (explicit -- never derived)
s2, # Scale2
s3, # Scale3
)
# --------------------------------------------------------------------------- #
# Main conversion IfcMappedItem approach
# Main conversion -- IfcMappedItem approach
# --------------------------------------------------------------------------- #
def instance_to_ifc(ifc, body_context, obj: Base, definition_map: dict,
scale: float = 1.0, material_manager=None):
"""
Convert a Speckle InstanceProxy (IfcShapeRepresentation, IfcLocalPlacement).
Convert a Speckle InstanceProxy -> (IfcShapeRepresentation, IfcLocalPlacement).
Strategy: create geometry once per definition as an IfcRepresentationMap,
then reference it via IfcMappedItem + IfcCartesianTransformationOperator3D
@@ -371,11 +518,11 @@ def instance_to_ifc(ifc, body_context, obj: Base, definition_map: dict,
definition_id = _get(obj, "definitionId") or ""
ifc_format = _is_ifc_format(definition_id)
# Translation scale: IFC format transform is in metres convert to mm
# Translation scale: IFC format transform is in metres -> convert to mm
# Revit format transform is already in mm (same as IFC file units)
ts = 1000.0 if ifc_format else _resolve_instance_scale(obj, scale)
# Identity placement (transform is encoded in the MappedItem) shared across all instances
# Identity placement (transform is encoded in the MappedItem) -- shared across all instances
fid = id(ifc)
if fid not in _identity_placement_cache:
shared = _get_shared(ifc)
@@ -386,19 +533,42 @@ def instance_to_ifc(ifc, body_context, obj: Base, definition_map: dict,
# --- Get or build IfcRepresentationMap (cached per definition_id) ---
if definition_id not in _rep_map_cache:
if ifc_format:
meshes = _get_ifc_meshes(definition_id, definition_map)
meshes, extra_app_ids = _get_ifc_meshes(definition_id, definition_map)
else:
meshes = _get_revit_meshes(definition_id, definition_map)
meshes, extra_app_ids = _get_revit_meshes(definition_id, definition_map)
if not meshes:
# Build fallback app_id list: instance's own + definition chain IDs
instance_app_id = _get(obj, "applicationId")
fallback_ids = []
if instance_app_id:
fallback_ids.append(str(instance_app_id))
fallback_ids.extend(extra_app_ids)
rep_map_result = None
if meshes:
rep_map_result = _build_rep_map(
ifc, body_context, meshes, ifc_format, material_manager,
fallback_app_ids=fallback_ids,
definition_id=definition_id,
)
# If no mesh geometry produced, try curve geometry from the definition object
if rep_map_result is None:
curve_obj = _get_definition_source_object(definition_id, definition_map)
if curve_obj and is_curve(curve_obj):
curve_scale = _resolve_instance_scale(curve_obj, 1.0)
rep_map_result = build_curve_rep_map(
ifc, body_context, curve_obj, scale=curve_scale,
material_manager=material_manager,
fallback_app_ids=fallback_ids,
definition_id=definition_id,
)
_rep_map_cache[definition_id] = rep_map_result
if rep_map_result is not None:
_stats["found"] += 1
else:
_stats["not_found"] += 1
_rep_map_cache[definition_id] = None
return None, placement
_stats["found"] += 1
_rep_map_cache[definition_id] = _build_rep_map(
ifc, body_context, meshes, ifc_format, material_manager
)
else:
# Track stats even for cached definitions
if _rep_map_cache[definition_id] is not None:
@@ -436,34 +606,34 @@ def get_definition_object(obj: Base, definition_map: dict):
definition_id = _get(obj, "definitionId") or ""
if not definition_id:
return None
return _get_definition_source_object(definition_id, definition_map)
ifc_proxies = definition_map.get("ifc_proxies", {})
proxy = ifc_proxies.get(definition_id) or ifc_proxies.get(definition_id.lower())
if proxy is None:
return None
object_ids = _get(proxy, "objects") or []
if not isinstance(object_ids, list):
object_ids = list(object_ids)
if not object_ids:
return None
by_app_id = definition_map.get("by_app_id", {})
source = by_app_id.get(str(object_ids[0]).lower())
return source
def is_definition_source(obj, definition_map: dict) -> bool:
"""Return True if this object is a definition geometry source (should not be exported standalone)."""
app_id = _get(obj, "applicationId")
if not app_id:
return False
return str(app_id).lower() in definition_map.get("definition_sources", set())
def print_instance_stats():
total = _stats["found"] + _stats["not_found"]
print(f" Instance resolution: {_stats['found']}/{total} definitions found")
if _stats["not_found"] > 0:
print(f" ⚠️ {_stats['not_found']} instances had no definition geometry")
print(f" Warning: {_stats['not_found']} instances had no definition geometry")
unique_defs = len(_rep_map_cache)
unique_geom = len([v for v in _geometry_hash_cache.values() if v is not None])
if unique_defs > unique_geom:
print(f" Geometry dedup: {unique_defs} definitions -> {unique_geom} unique geometries")
def reset_caches():
"""Reset module-level caches (call at start of each export run)."""
_mesh_data_cache.clear()
_rep_map_cache.clear()
_geometry_hash_cache.clear()
_identity_placement_cache.clear()
_direction_cache.clear()
_stats["found"] = 0
_stats["not_found"] = 0
+33 -62
View File
@@ -4,9 +4,8 @@
#
# Strategy (priority order):
# 1. builtInCategory (OST_ enum from properties.builtInCategory) — most reliable
# 2. speckle_type prefix match — for typed Speckle objects
# 3. category_name string (traversal context) — display name fallback
# 4. IfcBuildingElementProxy — last resort
# 2. category_name string (traversal context) — display name fallback
# 3. IfcBuildingElementProxy — last resort
#
# builtInCategory values: https://www.revitapidocs.com/2019/ba1c5b30-242f-5fdc-8ea9-ec3b61e6e722.htm
# =============================================================================
@@ -63,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",
@@ -82,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
@@ -119,44 +120,22 @@ BUILTIN_CATEGORY_MAP: dict[str, str] = {
}
# --- speckle_type → IFC class (secondary lookup) ---
SPECKLE_TYPE_MAP: dict[str, str] = {
"Objects.BuiltElements.Wall": "IfcWall",
"Objects.BuiltElements.Floor": "IfcSlab",
"Objects.BuiltElements.Roof": "IfcRoof",
"Objects.BuiltElements.Column": "IfcColumn",
"Objects.BuiltElements.Beam": "IfcBeam",
"Objects.BuiltElements.Brace": "IfcMember",
"Objects.BuiltElements.Duct": "IfcDuctSegment",
"Objects.BuiltElements.Pipe": "IfcPipeSegment",
"Objects.BuiltElements.Wire": "IfcCableCarrierSegment",
"Objects.BuiltElements.Opening": "IfcOpeningElement",
"Objects.BuiltElements.Room": "IfcSpace",
"Objects.BuiltElements.Ceiling": "IfcCovering",
"Objects.BuiltElements.Stair": "IfcStair",
"Objects.BuiltElements.Ramp": "IfcRamp",
"Objects.BuiltElements.Foundation": "IfcFooting",
"Objects.BuiltElements.Grid": "IfcGrid",
"Objects.BuiltElements.Level": "IfcBuildingStorey",
"Objects.BuiltElements.Revit.RevitWall": "IfcWall",
"Objects.BuiltElements.Revit.RevitFloor": "IfcSlab",
"Objects.BuiltElements.Revit.RevitRoof": "IfcRoof",
"Objects.BuiltElements.Revit.RevitColumn": "IfcColumn",
"Objects.BuiltElements.Revit.RevitBeam": "IfcBeam",
"Objects.BuiltElements.Revit.RevitBrace": "IfcMember",
"Objects.BuiltElements.Revit.RevitDuct": "IfcDuctSegment",
"Objects.BuiltElements.Revit.RevitPipe": "IfcPipeSegment",
"Objects.BuiltElements.Revit.RevitRoom": "IfcSpace",
"Objects.BuiltElements.Revit.RevitStair": "IfcStair",
"Objects.BuiltElements.Revit.RevitRailing": "IfcRailing",
"Objects.BuiltElements.Revit.RevitCeiling": "IfcCovering",
"Objects.BuiltElements.Revit.RevitTopography": "IfcGeographicElement",
"Objects.BuiltElements.Revit.RevitElementType": "IfcBuildingElementProxy",
"Objects.Geometry.Mesh": "IfcBuildingElementProxy",
"Objects.Geometry.Brep": "IfcBuildingElementProxy",
# --- 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 (tertiary fallback) ---
# --- Display category name → IFC class (secondary fallback) ---
CATEGORY_MAP: dict[str, str] = {
"Walls": "IfcWall",
"Floors": "IfcSlab",
@@ -184,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",
@@ -235,11 +217,6 @@ def _get_builtin_category(obj) -> str | None:
return result
# Pre-computed: sorted prefixes longest-first for early exit on prefix match
_SPECKLE_PREFIXES: list[tuple[str, str]] = sorted(
SPECKLE_TYPE_MAP.items(), key=lambda x: len(x[0]), reverse=True
)
# Pre-computed lowercase category map for substring matching
_CATEGORY_MAP_LOWER: list[tuple[str, str]] = [
(k.lower(), v) for k, v in CATEGORY_MAP.items()
@@ -249,16 +226,15 @@ _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.
Priority:
1. properties.builtInCategory (OST_ enum) — definitive Revit classification
2. speckle_type prefix match
3. category_name from traversal context (display string)
4. obj.category field
5. IfcBuildingElementProxy fallback
2. category_name from traversal context (display string)
3. obj.category field
4. IfcBuildingElementProxy fallback
"""
cache_key = (id(obj), category_name)
if cache_key in _classify_cache:
@@ -269,22 +245,17 @@ 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]
# 2. speckle_type — exact match first, then longest-prefix match
speckle_type = getattr(obj, "speckle_type", "") or ""
if speckle_type:
if speckle_type in SPECKLE_TYPE_MAP:
return SPECKLE_TYPE_MAP[speckle_type]
for prefix, ifc_class in _SPECKLE_PREFIXES:
if speckle_type.startswith(prefix):
return ifc_class
# 3. category_name from traversal context — exact match first
# 2. category_name from traversal context — exact match first
if category_name:
if category_name in CATEGORY_MAP:
return CATEGORY_MAP[category_name]
@@ -293,7 +264,7 @@ def _classify_impl(obj, category_name: str) -> str:
if key_lower in cat_lower:
return ifc_class
# 4. obj.category field
# 3. obj.category field
obj_category = getattr(obj, "category", None)
if obj_category and isinstance(obj_category, str):
if obj_category in CATEGORY_MAP:
+19
View File
@@ -64,6 +64,7 @@ class MaterialManager:
self._style_map: dict[str, object] = {}
# name → IfcSurfaceStyle (cache to avoid duplicates)
self._style_cache: dict[str, object] = {}
self._apply_count: int = 0
self._build(root)
def _build(self, root: Base):
@@ -135,6 +136,24 @@ class MaterialManager:
self._style_map[key] = style
return style
def get_style_with_fallbacks(self, primary_app_id: str = None,
fallback_app_ids: list = None,
definition_id: str = None):
"""Try primary app_id first, then each fallback, then definition_id. Return style or None."""
if primary_app_id:
style = self.get_style(primary_app_id)
if style:
return style
for aid in (fallback_app_ids or []):
style = self.get_style(aid)
if style:
return style
if definition_id:
style = self.get_style(definition_id)
if style:
return style
return None
def apply_to_item(self, item, mesh_app_id: str):
"""Assign the material style to a single IFC geometry item (e.g. IfcPolygonalFaceSet)."""
style = self.get_style(mesh_app_id)
+110 -3
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",
}
# ---------------------------------------------------------------------------
@@ -644,7 +648,7 @@ def write_material_quantities(ifc, element, obj: Base):
Source: properties."Material Quantities".<MaterialName>.{area, volume, density,
materialName, materialClass, materialCategory}
Each material produces one IfcElementQuantity named "Qto_<MaterialName>" with:
Each material produces one IfcElementQuantity named "Qto_<MaterialName>BaseQuantities" with:
- GrossArea (IfcQuantityArea)
- GrossVolume (IfcQuantityVolume)
- Density (IfcPropertySingleValue — no standard IFC quantity type)
@@ -711,7 +715,7 @@ def write_material_quantities(ifc, element, obj: Base):
continue
# Create IfcElementQuantity and link via IfcRelDefinesByProperties
qto_name = f"Qto_{mat_name}"
qto_name = f"Qto_{mat_name}BaseQuantities"
try:
qto = ifcopenshell.api.run(
"pset.add_qto", ifc,
@@ -723,6 +727,107 @@ def write_material_quantities(ifc, element, obj: Base):
print(f" ⚠️ {qto_name}: {e}")
# ---------------------------------------------------------------------------
# Qto_<EntityType>BaseQuantities — standard element-level quantities
# ---------------------------------------------------------------------------
# IFC entity → Qto name (only entities with standard Qto sets)
_ENTITY_QTO_NAME: dict[str, str] = {
"IfcWall": "Qto_WallBaseQuantities",
"IfcWallStandardCase": "Qto_WallBaseQuantities",
"IfcSlab": "Qto_SlabBaseQuantities",
"IfcColumn": "Qto_ColumnBaseQuantities",
"IfcBeam": "Qto_BeamBaseQuantities",
"IfcDoor": "Qto_DoorBaseQuantities",
"IfcWindow": "Qto_WindowBaseQuantities",
"IfcRoof": "Qto_RoofBaseQuantities",
"IfcCovering": "Qto_CoveringBaseQuantities",
"IfcRailing": "Qto_RailingBaseQuantities",
"IfcStair": "Qto_StairBaseQuantities",
"IfcRamp": "Qto_RampBaseQuantities",
"IfcMember": "Qto_MemberBaseQuantities",
"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])
# First matching Revit param wins for each quantity name.
_ELEMENT_QUANTITY_DEFS: list[tuple[str, str, str, list[str]]] = [
("GrossArea", "IfcQuantityArea", "AreaValue", ["HOST_AREA_COMPUTED"]),
("GrossVolume", "IfcQuantityVolume", "VolumeValue", ["HOST_VOLUME_COMPUTED"]),
("Length", "IfcQuantityLength", "LengthValue", [
"CURVE_ELEM_LENGTH", "INSTANCE_LENGTH_PARAM",
]),
("Height", "IfcQuantityLength", "LengthValue", [
"WALL_USER_HEIGHT_PARAM", "FAMILY_HEIGHT_PARAM",
"INSTANCE_HEAD_HEIGHT_PARAM",
]),
("Width", "IfcQuantityLength", "LengthValue", [
"INSTANCE_WIDTH_PARAM", "FURNITURE_WIDTH",
"FLOOR_ATTR_THICKNESS_PARAM",
]),
("Perimeter", "IfcQuantityLength", "LengthValue", [
"HOST_PERIMETER_COMPUTED",
]),
]
def write_element_quantities(ifc, element, obj: Base, ifc_class: str = ""):
"""
Write Qto_<EntityType>BaseQuantities from Revit computed instance parameters.
Reads HOST_AREA_COMPUTED, HOST_VOLUME_COMPUTED, CURVE_ELEM_LENGTH,
FURNITURE_WIDTH, FAMILY_HEIGHT_PARAM, etc.
IfcSpace is handled separately in _write_space_properties.
"""
if ifc_class == "IfcSpace":
return # Already handled by Qto_SpaceBaseQuantities
qto_name = _ENTITY_QTO_NAME.get(ifc_class)
if not qto_name:
return
props = _get_props_dict(obj)
params = _safe_get(props, "Parameters", {})
inst_params = _safe_get(params, "Instance Parameters", {})
if not inst_params:
return
quantities = []
for qty_name, ifc_entity, value_attr, revit_params in _ELEMENT_QUANTITY_DEFS:
val = None
for internal_name in revit_params:
val = _param_value(inst_params, internal_name)
if val is not None:
break
if val is None:
continue
try:
q = ifc.create_entity(ifc_entity, Name=qty_name, **{value_attr: float(val)})
quantities.append(q)
except Exception:
pass
if not quantities:
return
try:
qto = ifcopenshell.api.run(
"pset.add_qto", ifc,
product=element,
name=qto_name,
)
qto.Quantities = quantities
except Exception as e:
print(f" ⚠️ {qto_name}: {e}")
def write_properties(ifc, element, obj: Base, ifc_class: str = "", category_name: str = ""):
"""
Write all property sets for an IFC element, matching Revit native IFC export structure:
@@ -731,10 +836,12 @@ def write_properties(ifc, element, obj: Base, ifc_class: str = "", category_name
3. RVT_TypeParameters — all remaining Revit type parameters
4. RVT_InstanceParameters — all remaining Revit instance parameters
5. RVT_Identity — family, type, elementId, builtInCategory
6. Qto_<MaterialName> — material quantities (area, volume, density)
6. Qto_<EntityType>BaseQuantities — element-level quantities (area, volume, length)
7. Qto_<MaterialName>BaseQuantities — material quantities (area, volume, density)
"""
write_common_pset(ifc, element, obj, ifc_class, category_name)
write_revit_params(ifc, element, obj)
write_element_quantities(ifc, element, obj, ifc_class)
write_material_quantities(ifc, element, obj)