1 Commits

Author SHA1 Message Date
dependabot[bot] e0fec86bb2 Bump specklepy from 3.1.0 to 3.2.4
Bumps [specklepy](https://github.com/specklesystems/specklepy) from 3.1.0 to 3.2.4.
- [Release notes](https://github.com/specklesystems/specklepy/releases)
- [Commits](https://github.com/specklesystems/specklepy/compare/3.1.0...3.2.4)

---
updated-dependencies:
- dependency-name: specklepy
  dependency-version: 3.2.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-13 07:58:13 +00:00
11 changed files with 361 additions and 1184 deletions
+56 -101
View File
@@ -1,14 +1,6 @@
# Speckle IFC 4.3 Exporter (Revit)
# Speckle to IFC 4.3 Exporter
## 🚧 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.
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/).
## What It Does
@@ -16,13 +8,10 @@ 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
@@ -41,10 +30,10 @@ Speckle Model
4. Traverse object tree
│ For each leaf element:
│ ├── Classify → IFC entity class (skip analytical categories)
│ ├── Convert geometry → IfcPolygonalFaceSet or IfcIndexedPolyCurve
│ ├── Classify → IFC entity class
│ ├── Convert geometry → IfcPolygonalFaceSet
│ ├── Create IFC element + placement
│ ├── Write property sets & quantities
│ ├── Write property sets
│ └── Assign IFC type object
@@ -61,23 +50,21 @@ 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/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/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/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/receiver.py` | Connects to Speckle server and receives model data (uses `.env`) |
| `utils/config.py` | Project/site/building name configuration |
## Mapping Logic
Classification of Speckle objects to IFC entity types follows a priority chain. The first match wins.
Classification of Speckle objects to IFC entity types follows a priority chain with three lookup tables. 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.
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.
Examples:
| builtInCategory | IFC Class |
@@ -92,23 +79,25 @@ 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
### Priority 2: `speckle_type` prefix
The following analytical/energy OST categories are automatically skipped (not exported to IFC):
For typed Speckle objects, the `speckle_type` string is matched. Exact match is tried first, then longest-prefix match.
`OST_MEPLoadAreaSeparationLines`, `OST_EnergyAnalysisZones`, `OST_EnergyAnalysisSurface`, `OST_SolarShading`, `OST_MEPAnalyticalPipeSegments`, `OST_MEPAnalyticalDuctSegments`, `OST_MEPAnalyticalSpaces`, `OST_ElectricalConduitAnalyticalLines`, `OST_MEPLoadBoundaryLines`, `OST_FlowTerminalSeparationLines`
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` |
### Priority 2: Category name (display string)
### Priority 3: 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.
@@ -118,13 +107,11 @@ Examples:
| `Walls` | `IfcWall` |
| `Structural Columns` | `IfcColumn` |
| `Plumbing Fixtures` | `IfcSanitaryTerminal` |
| `Structural Rebar` | `IfcReinforcingBar` |
| `Structural Connections` | `IfcMechanicalFastener` |
| `Lighting Fixtures` | `IfcLightFixture` |
### Priority 3: `obj.category` field
### Priority 4: `obj.category` field
Same lookup as Priority 2, but using the object's own `category` attribute.
Same lookup as Priority 3, but using the object's own `category` attribute.
### Fallback
@@ -136,12 +123,11 @@ 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` (recursively handles nested BrepX/Brep objects)
1. Extract vertices and faces from each mesh in `displayValue`
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. 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
4. Build `IfcPolygonalFaceSet` with `IfcCartesianPointList3D` + `IfcIndexedPolygonalFace`
5. Compute bounding box origin for `IfcLocalPlacement`, offset vertices relative to it
### Instance Objects (Path A / B2)
@@ -150,21 +136,7 @@ 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. 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.
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).
## Property Sets
@@ -173,44 +145,46 @@ 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 |
| `Pset_SpaceCommon` | Room-specific: Reference, RoomNumber, RoomName, Category (Occupant) |
| `RVT_TypeParameters` | All Revit type parameters (written on the IfcTypeObject) |
| `RVT_InstanceParameters` | All Revit instance parameters |
| `RVT_Identity` | Family, Type, ElementId, BuiltInCategory |
| `Qto_<MaterialName>` | Material quantities: area, volume, density |
## Quantities
## Getting Started
Quantities follow the IFC standard naming convention: `Qto_<EntityType>BaseQuantities` and `Qto_<MaterialName>BaseQuantities`.
### Prerequisites
| 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 |
- Python 3.11+
- A Speckle account and project with a Revit model
### Element Quantity Mapping
### Setup
| 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` |
```bash
python -m venv .venv
# Windows
.venv\Scripts\activate
# macOS/Linux
source .venv/bin/activate
### Supported Entity Qto Sets
pip install --upgrade pip
pip install .[dev]
```
`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`
### Running Locally
## IfcSpace (Rooms)
Configure your Speckle Automate credentials, then:
Revit Rooms (`OST_Rooms`) are exported as `IfcSpace` elements with special handling:
```bash
python main.py
```
- **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
### Deploying to Speckle Automate
1. [Create](https://automate.speckle.dev/) a new Speckle Automation
2. Select your Speckle Project and Model
3. Select this function
4. Configure the inputs (file name, project/site/building names)
5. Click Create Automation
## Function Inputs
@@ -221,25 +195,6 @@ Revit Rooms (`OST_Rooms`) are exported as `IfcSpace` elements with special handl
| `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 |
|----------------------------------|------------|----------|-----------------|
| Huge confidential model | 450 MB | 391 MB | 2h 30m |
| Snowdon Towers (Architecture) | 93.2 MB | 118 MB | 8m 37s |
| Speckle Tower | 51 MB | 45 MB | 3m |
| Rac Basic Sample Model | 18.8 MB | 12 MB | 12s |
## Resources
- [Speckle Developer Docs](https://speckle.guide/dev/python.html)
+27
View File
@@ -0,0 +1,27 @@
"""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
+32 -73
View File
@@ -1,4 +1,3 @@
import zipfile
from datetime import datetime
import ifcopenshell.api
@@ -7,8 +6,7 @@ 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.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.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
from utils.type_manager import TypeManager
@@ -16,7 +14,7 @@ from utils.type_manager import TypeManager
SPATIAL_STRUCTURE_TYPES = {
"IfcBuilding", "IfcBuildingStorey",
"IfcExternalSpatialElement", "IfcSpatialZone",
"IfcSpace", "IfcExternalSpatialElement", "IfcSpatialZone",
"IfcGrid", "IfcAnnotation",
}
@@ -63,7 +61,6 @@ def automate_function(
# Reset caches from any previous run
reset_props_caches()
reset_mapper_caches()
reset_instance_caches()
# ------------------------------------------------------------------ #
# 1. Receive
@@ -109,19 +106,11 @@ 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
# IfcSpace uses the Speckle object name (e.g. "Rooms - Live/Work Unit 507")
# instead of Family:Type (which is "none:none" for Revit rooms)
if ifc_class == "IfcSpace":
name = getattr(obj, "name", None) or build_element_name(obj)
else:
name = build_element_name(obj)
name = build_element_name(obj)
storey = storey_manager.get_or_create(level_name)
# ------------------------------------------------------------------ #
@@ -168,52 +157,30 @@ def automate_function(
total += 1
# B2: Instance objects nested inside displayValue
# All instances are parts of the SAME element (e.g. window frame + glass + sill)
# Merge all into a single IFC element with combined geometry
# Each becomes its own IFC element (same class as parent)
# Use the parent object's name — the InstanceProxy has no meaningful name
nested_instances = get_display_instances(obj)
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
# B3: Curve geometry (Lines, Arcs in displayValue)
if not rep and not nested_instances:
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:
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
# Track if neither path produced geometry
if not rep and not nested_instances:
no_geometry += 1
if total % 100 == 0:
print(f" ... processed {total} elements")
@@ -233,18 +200,12 @@ def automate_function(
ifc.write(ifc_filename)
print(f"\n💾 IFC file written: {ifc_filename}")
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}")
# 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}")
print(f"\n{'=' * 60}")
print(f" Export complete!")
@@ -289,10 +250,8 @@ def _create_element(ifc, ifc_class, name, rep, placement, storey,
element.ObjectPlacement = _make_placement(ifc, 0.0, 0.0, 0.0)
# Queue spatial assignment (batched flush at end for performance)
# 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", "IfcRoad", "IfcBridge", "IfcRailway", "IfcMarineFacility"):
if ifc_class == "IfcSite":
storey_manager.queue_aggregate(storey, element)
else:
storey_manager.queue_contain(storey, element)
+1 -1
View File
@@ -9,7 +9,7 @@ readme = "README.md"
license = "Apache-2.0"
keywords = ["speckle", "automate", "bim", "aec", "ifc", "export", "revit"]
dependencies = ["specklepy==3.1.0",
dependencies = ["specklepy==3.2.4",
"ifcopenshell==0.8.4.post1",
"python-dotenv>=1.0.0",]
-357
View File
@@ -1,357 +0,0 @@
# =============================================================================
# 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)
+71 -78
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,12 +8,21 @@
# - 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
from utils.helpers import _get, MM_SCALES as _UNIT_SCALES
# 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,
}
# --------------------------------------------------------------------------- #
@@ -38,8 +47,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
@@ -76,13 +85,6 @@ 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)
@@ -95,15 +97,39 @@ 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 []
@@ -135,7 +161,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)
@@ -149,7 +175,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
@@ -162,39 +188,23 @@ def _is_mesh(item) -> bool:
return verts is not None and faces is not None
def _collect_meshes_from_display(obj) -> list:
def get_display_meshes(obj: Base) -> list:
"""
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.
Extract all Mesh objects from a DataObject's displayValue.
displayValue is always an array per the Speckle schema docs.
"""
meshes = []
for key in ["displayValue", "@displayValue", "_displayValue"]:
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 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
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)
break # found meshes, don't check @displayValue too
# Fallback: object itself is a Mesh
if not meshes and _is_mesh(obj):
@@ -215,10 +225,10 @@ def get_display_instances(obj: Base) -> list:
- definitionId: "DEFINITION:{meshAppId}" string
- units: "m"
Raw meshes do NOT appear in displayValue in IFC->Speckle exports.
Raw meshes do NOT appear in displayValue in IFCSpeckle exports.
"""
instances = []
for key in ["displayValue", "@displayValue", "_displayValue"]:
for key in ["displayValue", "@displayValue"]:
display = _get(obj, key)
if display is None:
continue
@@ -243,7 +253,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
@@ -275,7 +285,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]
@@ -318,9 +328,9 @@ def _get_shared(ifc):
def _make_placement(ifc, x: float, y: float, z: float):
"""Create an IfcLocalPlacement at absolute world coordinates (mm)."""
"""Create an IfcLocalPlacement at absolute world coordinates (metres)."""
shared = _get_shared(ifc)
origin = ifc.createIfcCartesianPoint([round(x, 3), round(y, 3), round(z, 3)])
origin = ifc.createIfcCartesianPoint([x, y, z])
a2p = ifc.createIfcAxis2Placement3D(origin, shared["z_axis"], shared["x_axis"])
return ifc.createIfcLocalPlacement(PlacementRelTo=None, RelativePlacement=a2p)
@@ -337,7 +347,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.
"""
@@ -345,21 +355,14 @@ 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 and scale vertices once per mesh, compute origin
# incrementally without accumulating all vertices in memory.
# Pass 1: unpack vertices once per mesh, collect all scaled coords
# to compute world origin. Cache (verts, ms) for Pass 2.
# ------------------------------------------------------------------ #
mesh_cache = [] # [scaled_verts_list] or None per mesh
xmin = ymin = zmin = float("inf")
xmax = ymax = float("-inf")
has_verts = False
mesh_cache = [] # [(verts_list, ms, scaled)] or None per mesh
all_scaled = []
for mesh in meshes:
raw_verts = _get(mesh, "vertices") or []
verts = unwrap_chunks(raw_verts if isinstance(raw_verts, list) else list(raw_verts))
@@ -367,34 +370,25 @@ 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(scaled)
has_verts = True
mesh_cache.append((verts, ms, scaled))
all_scaled.extend(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:
if not all_scaled:
return None, None
ox = (xmin + xmax) / 2.0
oy = (ymin + ymax) / 2.0
oz = zmin
ox, oy, oz = compute_origin(all_scaled)
# ------------------------------------------------------------------ #
# 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, scaled in zip(meshes, mesh_cache):
if scaled is None:
for mesh, cached in zip(meshes, mesh_cache):
if cached 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))
@@ -404,7 +398,7 @@ def mesh_to_ifc(
try:
face_groups = decode_faces(faces_raw)
except Exception as e:
print(f" Warning: Face decode error: {e}")
print(f" ⚠️ Face decode error: {e}")
continue
# Offset pre-scaled vertices relative to origin (flat list, no tuples)
@@ -421,9 +415,8 @@ 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") or obj_app_id
mesh_app_id = _get(mesh, "applicationId")
if mesh_app_id:
for fs in mesh_facesets:
material_manager.apply_to_item(fs, str(mesh_app_id))
@@ -444,4 +437,4 @@ def mesh_to_ifc(
)
placement = _make_placement(ifc, ox, oy, oz)
return rep, placement
return rep, placement
-38
View File
@@ -1,38 +0,0 @@
# =============================================================================
# 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,
}
+109 -279
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 IFC->Speckle converter:
# FORMAT B speckleifc IFCSpeckle 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,14 +20,9 @@
# 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.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
from utils.geometry import _get, unwrap_chunks, decode_faces, _UNIT_SCALES, build_ifc_facesets, _get_shared
def is_instance(obj) -> bool:
@@ -45,18 +40,15 @@ 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
"definition_sources": set of applicationId (lowercase) that are definition
geometry sources -- these should be skipped during export
"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 = {}
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)
@@ -69,11 +61,6 @@ 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 []):
@@ -88,14 +75,12 @@ 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,
"definition_sources": definition_sources,
"by_id": by_id,
"by_app_id": by_app_id,
"ifc_proxies": ifc_proxies,
"ifc_meshes": ifc_meshes,
}
@@ -107,7 +92,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:
@@ -117,8 +102,7 @@ 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", "_elements",
"displayValue", "@displayValue", "_displayValue",
for key in ["elements", "@elements", "displayValue", "@displayValue",
"objects", "@objects", "definition", "@definition"]:
try:
children = obj[key]
@@ -132,29 +116,11 @@ def _collect_all(obj, by_id: dict, by_app_id: dict, depth: int):
continue
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:
def _get_revit_meshes(definition_id: str, definition_map: dict) -> list:
"""
Revit format:
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.
definitionId (64-char hex) InstanceDefinitionProxy.applicationId
proxy.objects[0] is a UUID applicationId find mesh by applicationId
"""
from utils.geometry import get_display_meshes
@@ -162,55 +128,40 @@ def _get_revit_meshes(definition_id: str, definition_map: dict) -> tuple:
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, collecting all encountered app IDs
# Step 3: look up each mesh by applicationId
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)
elif _is_mesh(obj):
else:
# It IS the mesh directly
meshes.append(obj)
return meshes, encountered_app_ids
return meshes
def _get_ifc_meshes(definition_id: str, definition_map: dict) -> tuple:
def _get_ifc_meshes(definition_id: str, definition_map: dict) -> list:
"""
IFC format: definitionId = "DEFINITION:224058_mat0"
Look up proxy -> objects list -> meshes from ifc_meshes dict.
Returns (meshes, []) -- no extra app_ids needed, mesh applicationIds match directly.
Look up proxy objects list meshes from ifc_meshes dict.
"""
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 = []
@@ -218,20 +169,20 @@ def _get_ifc_meshes(definition_id: str, definition_map: dict) -> tuple:
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 = MM_SCALES.get(units.lower().strip())
s = _UNIT_SCALES.get(units.lower().strip())
if s is not None:
return s
except Exception:
@@ -242,62 +193,39 @@ 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
# IfcRepresentationMap builder — geometry created once per definition
# --------------------------------------------------------------------------- #
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
def _build_rep_map(ifc, body_context, meshes: list, ifc_format: bool,
material_manager=None):
"""
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
# --------------------------------------------------------------------------- #
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.
Build an IfcRepresentationMap from definition meshes.
Geometry is in local coordinates (mm, no instance transform applied).
Returns IfcRepresentationMap or None if no valid geometry.
"""
result = []
geom_items = []
for mesh in meshes:
mesh_id = _get(mesh, "id") or _get(mesh, "applicationId")
if mesh_id and mesh_id in _mesh_data_cache:
@@ -305,103 +233,48 @@ def _collect_mesh_data(meshes: list, ifc_format: bool) -> list:
else:
raw_verts = _get(mesh, "vertices") or []
raw_faces = _get(mesh, "faces") or []
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))
verts = unwrap_chunks(list(raw_verts))
faces_raw = unwrap_chunks(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" Warning: Instance face decode: {e}")
print(f" ⚠️ 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)
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")
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:
if mesh_app_id:
for fs in mesh_facesets:
try:
ifcopenshell.api.run(
"style.assign_item_style", ifc,
item=fs, style=style,
)
material_manager._apply_count += 1
except Exception:
pass
material_manager.apply_to_item(fs, str(mesh_app_id))
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",
@@ -409,57 +282,41 @@ def _build_rep_map(ifc, body_context, meshes: list, ifc_format: bool,
Items=geom_items,
)
rep_map = ifc.createIfcRepresentationMap(a2p, mapped_rep)
_geometry_hash_cache[geom_hash] = rep_map
return rep_map
return ifc.createIfcRepresentationMap(a2p, mapped_rep)
# --------------------------------------------------------------------------- #
# 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 m->mm)
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])
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])
Always uses the non-uniform variant with explicit Axis3 to ensure
correct orientation for all transform types (mirrors, non-orthogonal, etc.).
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])
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]))
ax2 = (float(t[1]), float(t[5]), float(t[9]))
ax3 = (float(t[2]), float(t[6]), float(t[10]))
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
s1 = _vec_magnitude(*ax1)
s2 = _vec_magnitude(*ax2)
@@ -468,41 +325,37 @@ 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 -- 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)
# 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])
# 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)
# Translation, scaled to mm
tx = float(t[3]) * ts
ty = float(t[7]) * ts
tz = float(t[11]) * ts
origin = ifc.createIfcCartesianPoint([tx, ty, tz])
# Round scales for cleaner output
s1 = round(s1, 6)
s2 = round(s2, 6)
s3 = round(s3, 6)
# Use non-uniform variant to handle mirrors and non-uniform scale
return ifc.createIfcCartesianTransformationOperator3DnonUniform(
d1, # Axis1
d2, # Axis2
origin, # LocalOrigin
s1, # Scale
d3, # Axis3 (explicit -- never derived)
d3, # Axis3
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
@@ -518,11 +371,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)
@@ -533,42 +386,19 @@ 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, extra_app_ids = _get_ifc_meshes(definition_id, definition_map)
meshes = _get_ifc_meshes(definition_id, definition_map)
else:
meshes, extra_app_ids = _get_revit_meshes(definition_id, definition_map)
meshes = _get_revit_meshes(definition_id, definition_map)
# 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:
if not meshes:
_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:
@@ -606,34 +436,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
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())
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 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" 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")
print(f" ⚠️ {_stats['not_found']} instances had no definition geometry")
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
+62 -33
View File
@@ -4,8 +4,9 @@
#
# Strategy (priority order):
# 1. builtInCategory (OST_ enum from properties.builtInCategory) — most reliable
# 2. category_name string (traversal context) — display name fallback
# 3. IfcBuildingElementProxy — last resort
# 2. speckle_type prefix match — for typed Speckle objects
# 3. category_name string (traversal context) — display name fallback
# 4. IfcBuildingElementProxy — last resort
#
# builtInCategory values: https://www.revitapidocs.com/2019/ba1c5b30-242f-5fdc-8ea9-ec3b61e6e722.htm
# =============================================================================
@@ -62,7 +63,6 @@ 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,7 +82,6 @@ BUILTIN_CATEGORY_MAP: dict[str, str] = {
"OST_PipeAccessory": "IfcPipeSegment",
"OST_FlexPipeCurves": "IfcPipeSegment",
"OST_PlumbingFixtures": "IfcSanitaryTerminal",
"OST_PlumbingEquipment": "IfcSanitaryTerminal",
"OST_Sprinklers": "IfcFireSuppressionTerminal",
# MEP - Electrical
@@ -120,22 +119,44 @@ 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",
# --- 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",
}
# --- Display category name → IFC class (secondary fallback) ---
# --- Display category name → IFC class (tertiary fallback) ---
CATEGORY_MAP: dict[str, str] = {
"Walls": "IfcWall",
"Floors": "IfcSlab",
@@ -163,13 +184,10 @@ 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",
@@ -217,6 +235,11 @@ 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()
@@ -226,15 +249,16 @@ _CATEGORY_MAP_LOWER: list[tuple[str, str]] = [
_classify_cache: dict[tuple, str] = {}
def classify(obj, category_name: str = "") -> str | None:
def classify(obj, category_name: str = "") -> str:
"""
Determine the IFC class for a Speckle object.
Priority:
1. properties.builtInCategory (OST_ enum) — definitive Revit classification
2. category_name from traversal context (display string)
3. obj.category field
4. IfcBuildingElementProxy fallback
2. speckle_type prefix match
3. category_name from traversal context (display string)
4. obj.category field
5. IfcBuildingElementProxy fallback
"""
cache_key = (id(obj), category_name)
if cache_key in _classify_cache:
@@ -245,17 +269,22 @@ def classify(obj, category_name: str = "") -> str | None:
return result
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
def _classify_impl(obj, category_name: str) -> str:
# 1. builtInCategory — most reliable, direct Revit enum
bic = _get_builtin_category(obj)
if bic and bic in BUILTIN_CATEGORY_MAP:
return BUILTIN_CATEGORY_MAP[bic]
# 2. category_name from traversal context — exact match first
# 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
if category_name:
if category_name in CATEGORY_MAP:
return CATEGORY_MAP[category_name]
@@ -264,7 +293,7 @@ def _classify_impl(obj, category_name: str) -> str | None:
if key_lower in cat_lower:
return ifc_class
# 3. obj.category field
# 4. 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,7 +64,6 @@ 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):
@@ -136,24 +135,6 @@ 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)
+3 -205
View File
@@ -53,10 +53,6 @@ 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",
}
# ---------------------------------------------------------------------------
@@ -428,104 +424,9 @@ def write_common_pset(ifc, element, obj: Base, ifc_class: str, category_name: st
except Exception:
pass
# IfcSpace-specific: set Name, LongName, Category, and BaseQuantities
if ifc_class == "IfcSpace":
_write_space_properties(ifc, element, obj, ifc_props)
_write_pset(ifc, element, pset_name, ifc_props)
def _write_space_properties(ifc, element, obj: Base, ifc_props: list):
"""
Set IfcSpace attributes and BaseQuantities from Revit Room parameters.
Uses internalDefinitionName to find values:
ROOM_NUMBER IfcSpace.Name + Pset_SpaceCommon.Reference
ROOM_NAME IfcSpace.LongName
Occupant Pset_SpaceCommon.Category
ROOM_AREA Qto_SpaceBaseQuantities.NetFloorArea
ROOM_VOLUME Qto_SpaceBaseQuantities.NetVolume
"""
props = _get_props_dict(obj)
params = _safe_get(props, "Parameters", {})
inst_params = _safe_get(params, "Instance Parameters", {})
# --- Room Number → IfcSpace.Name + Pset_SpaceCommon.Reference ---
room_number = _param_value(inst_params, "ROOM_NUMBER")
if room_number:
room_number = str(room_number).strip()
element.Name = room_number
# Replace any existing Reference in ifc_props
ifc_props[:] = [p for p in ifc_props if p.Name != "Reference"]
p = _make_prop(ifc, "Reference", "IfcIdentifier", room_number)
if p:
ifc_props.append(p)
# Also add as explicit RoomNumber in the pset
p = _make_prop(ifc, "RoomNumber", "IfcLabel", room_number)
if p:
ifc_props.append(p)
# --- Room Name → IfcSpace.LongName + Pset_SpaceCommon.RoomName ---
room_name = _param_value(inst_params, "ROOM_NAME")
if not room_name:
# Fallback to the Speckle object's own name
room_name = getattr(obj, "name", None)
if room_name:
room_name = str(room_name).strip()
try:
element.LongName = room_name
except AttributeError:
pass
p = _make_prop(ifc, "RoomName", "IfcLabel", room_name)
if p:
ifc_props.append(p)
# --- Occupant → Pset_SpaceCommon.Category ---
occupant = _param_value(inst_params, "Occupant")
if occupant:
p = _make_prop(ifc, "Category", "IfcLabel", str(occupant).strip())
if p:
ifc_props.append(p)
# --- Area & Volume → Qto_SpaceBaseQuantities ---
quantities = []
area_val = _param_value(inst_params, "ROOM_AREA")
if area_val is not None:
try:
q = ifc.create_entity(
"IfcQuantityArea",
Name="NetFloorArea",
AreaValue=float(area_val),
)
quantities.append(q)
except Exception:
pass
volume_val = _param_value(inst_params, "ROOM_VOLUME")
if volume_val is not None:
try:
q = ifc.create_entity(
"IfcQuantityVolume",
Name="NetVolume",
VolumeValue=float(volume_val),
)
quantities.append(q)
except Exception:
pass
if quantities:
try:
qto = ifcopenshell.api.run(
"pset.add_qto", ifc,
product=element,
name="Qto_SpaceBaseQuantities",
)
qto.Quantities = quantities
except Exception as e:
print(f" ⚠️ Qto_SpaceBaseQuantities: {e}")
# ---------------------------------------------------------------------------
# Pset_EnvironmentalImpactIndicators (always written, Reference = TypeName)
# ---------------------------------------------------------------------------
@@ -648,7 +549,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>BaseQuantities" with:
Each material produces one IfcElementQuantity named "Qto_<MaterialName>" with:
- GrossArea (IfcQuantityArea)
- GrossVolume (IfcQuantityVolume)
- Density (IfcPropertySingleValue no standard IFC quantity type)
@@ -715,7 +616,7 @@ def write_material_quantities(ifc, element, obj: Base):
continue
# Create IfcElementQuantity and link via IfcRelDefinesByProperties
qto_name = f"Qto_{mat_name}BaseQuantities"
qto_name = f"Qto_{mat_name}"
try:
qto = ifcopenshell.api.run(
"pset.add_qto", ifc,
@@ -727,107 +628,6 @@ 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:
@@ -836,12 +636,10 @@ 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_<EntityType>BaseQuantities element-level quantities (area, volume, length)
7. Qto_<MaterialName>BaseQuantities material quantities (area, volume, density)
6. Qto_<MaterialName> 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)