17 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
NLSA f9193ee3a0 ifcSpace Update
build and deploy Speckle functions / publish-automate-function-version (push) Has been cancelled
2026-03-13 14:43:27 +01:00
NLSA 112f031608 Update README to remove setup instructions and add testing info
Removed the Getting Started section and added a Testing section with model conversion details.
2026-03-13 11:30:37 +01:00
NLSA 673c024ac5 Beta 2026-03-13 08:56:29 +01:00
NLSA f8c9d4237d update main store_file
build and deploy Speckle functions / publish-automate-function-version (push) Has been cancelled
2026-03-11 23:17:51 +01:00
NLSA 11acb02fd1 Update main.yml time
build and deploy Speckle functions / publish-automate-function-version (push) Has been cancelled
2026-03-11 23:02:39 +01:00
NLSA f7aa6c29da performance update
build and deploy Speckle functions / publish-automate-function-version (push) Has been cancelled
2026-03-11 22:53:34 +01:00
NLSA 63082a881c update memory and performance 2026-03-10 15:22:59 +01:00
18 changed files with 568903 additions and 783 deletions
+1
View File
@@ -30,3 +30,4 @@ jobs:
speckle_function_id: ${{ secrets.SPECKLE_FUNCTION_ID }}
speckle_function_input_schema_file_path: ${{ env.FUNCTION_SCHEMA_FILE_NAME }}
speckle_function_command: "python -u main.py run"
speckle_function_recommended_memory_mi: 8000
+195 -121
View File
@@ -1,174 +1,248 @@
# Speckle Automate function template - Python
# Speckle → IFC 4.3 Exporter (Revit)
This template repository is for a Speckle Automate function written in Python
using the [specklepy](https://pypi.org/project/specklepy/) SDK to interact with Speckle data.
## 🚧 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**
This template contains the full scaffolding required to publish a function to the Automate environment.
It also has some sane defaults for development environment setups.
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.
## Getting started
> ⚠️ **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.
1. Use this template repository to create a new repository in your own / organization's profile.
1. Register the function
## What It Does
### Add new dependencies
The exporter receives a Speckle model version, walks its object tree, and produces a standards-compliant IFC 4.3 file. Each Speckle object becomes an IFC element with:
To add new Python package dependencies to the project, edit the `pyproject.toml` file:
- 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)
**For packages your function needs to run** (like pandas, requests, etc.):
```toml
dependencies = [
"specklepy==3.0.0",
"pandas==2.1.0", # Add production dependencies here
]
## Pipeline Overview
```
Speckle Model
1. Receive version (specklepy)
2. Build definition map (for instance geometry reuse)
3. Create IFC scaffold (Project → Site → Building)
4. Traverse object tree
│ For each leaf element:
│ ├── Classify → IFC entity class (skip analytical categories)
│ ├── Convert geometry → IfcPolygonalFaceSet or IfcIndexedPolyCurve
│ ├── Create IFC element + placement
│ ├── Write property sets & quantities
│ └── Assign IFC type object
5. Flush spatial containment & type relationships
6. Write .ifc file
```
**For development tools** (like testing or formatting tools):
```toml
[project.optional-dependencies]
dev = [
"black==23.12.1",
"pytest-mock==3.11.1", # Add development dependencies here
# ... other dev tools
]
```
## Module Structure
**How to decide which section?**
- If your `main.py` (or other function logic) imports it → `dependencies`
- If it's just a tool to help you code → `[project.optional-dependencies].dev`
| File | Purpose |
|------|---------|
| `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/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`) |
Example:
```python
# In your main.py
import pandas as pd # ← This goes in dependencies
import specklepy # ← This goes in dependencies
## Mapping Logic
# You won't import these in main.py:
# pytest, black, mypy ← These go in [project.optional-dependencies].dev
```
Classification of Speckle objects to IFC entity types follows a priority chain. The first match wins.
### Change launch variables
### Priority 1: `builtInCategory` (OST_ enum)
Describe how the launch.json should be edited.
The most reliable source. Read from `obj.properties.builtInCategory`, which contains the Revit `BuiltInCategory` enum value.
### GitHub Codespaces
Examples:
| builtInCategory | IFC Class |
|---|---|
| `OST_Walls` | `IfcWall` |
| `OST_Floors` | `IfcSlab` |
| `OST_StructuralColumns` | `IfcColumn` |
| `OST_StructuralFraming` | `IfcBeam` |
| `OST_Doors` | `IfcDoor` |
| `OST_Windows` | `IfcWindow` |
| `OST_Roofs` | `IfcRoof` |
| `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` |
Create a new repo from this template, and use the create new code.
The full table covers ~70 Revit categories across Architectural, Structural, MEP (HVAC, Plumbing, Electrical), and Site/Civil disciplines.
### Using this Speckle Function
### Skipped Categories
1. [Create](https://automate.speckle.dev/) a new Speckle Automation.
1. Select your Speckle Project and Speckle Model.
1. Select the deployed Speckle Function.
1. Enter a phrase to use in the comment.
1. Click `Create Automation`.
The following analytical/energy OST categories are automatically skipped (not exported to IFC):
## Getting Started with Creating Your Own Speckle Function
`OST_MEPLoadAreaSeparationLines`, `OST_EnergyAnalysisZones`, `OST_EnergyAnalysisSurface`, `OST_SolarShading`, `OST_MEPAnalyticalPipeSegments`, `OST_MEPAnalyticalDuctSegments`, `OST_MEPAnalyticalSpaces`, `OST_ElectricalConduitAnalyticalLines`, `OST_MEPLoadBoundaryLines`, `OST_FlowTerminalSeparationLines`
1. [Register](https://automate.speckle.dev/) your Function with [Speckle Automate](https://automate.speckle.dev/) and select the Python template.
1. A new repository will be created in your GitHub account.
1. Make changes to your Function in `main.py`. See below for the Developer Requirements and instructions on how to test.
1. To create a new version of your Function, create a new [GitHub release](https://docs.github.com/en/repositories/releasing-projects-on-github/managing-releases-in-a-repository) in your repository.
### Priority 2: Category name (display string)
## Developer Requirements
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.
1. Install the following:
- [Python 3.11+](https://www.python.org/downloads/)
1. Run the following to set up your development environment:
```bash
python -m venv .venv
# On Windows
.venv\Scripts\activate
# On macOS/Linux
source .venv/bin/activate
Examples:
| Category Name | IFC Class |
|---|---|
| `Walls` | `IfcWall` |
| `Structural Columns` | `IfcColumn` |
| `Plumbing Fixtures` | `IfcSanitaryTerminal` |
| `Structural Rebar` | `IfcReinforcingBar` |
| `Structural Connections` | `IfcMechanicalFastener` |
| `Lighting Fixtures` | `IfcLightFixture` |
pip install --upgrade pip
pip install .[dev]
```
### Priority 3: `obj.category` field
**What this installs:**
- All the packages your function needs to run (`dependencies`)
- Plus development tools like testing and code formatting (`[project.optional-dependencies].dev`)
Same lookup as Priority 2, but using the object's own `category` attribute.
**Why separate sections?**
- `dependencies`: Only what gets deployed with your function (lightweight)
- `dev` dependencies: Extra tools to help you write better code locally
### Fallback
## Building and Testing
If none of the above match, the object is classified as `IfcBuildingElementProxy`.
The code can be tested locally by running `pytest`.
## Geometry Handling
### Alternative dependency managers
### Direct Meshes (Path B1)
This template uses the modern **PEP 621** standard in `pyproject.toml`, which works with all modern Python dependency managers:
Objects with `displayValue` containing Mesh objects are converted directly:
#### Using Poetry
```bash
poetry install # Automatically reads pyproject.toml
```
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. 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
#### Using uv
```bash
uv sync # Automatically reads pyproject.toml
```
### Instance Objects (Path A / B2)
#### Using pip-tools
```bash
pip-compile pyproject.toml # Generate requirements.txt from pyproject.toml
pip install -r requirements.txt
```
Speckle `InstanceProxy` objects reference shared definition geometry via `definitionId`. The exporter supports two formats:
#### Using pdm
```bash
pdm install # Automatically reads pyproject.toml
```
- **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
**Advantage**: All tools read the same `pyproject.toml` file, so there's no need to keep multiple files in sync!
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.
### Building and running the Docker Container Image
### Curve Geometry (Path B3)
Running and testing your code on your machine is a great way to develop your Function; the following instructions are a bit more in-depth and only required if you are having issues with your Function in GitHub Actions or on Speckle Automate.
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:
#### Building the Docker Container Image
- **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)
The GitHub Action packages your code into the format required by Speckle Automate. This is done by building a Docker Image, which Speckle Automate runs. You can attempt to build the Docker Image locally to test the building process.
All curves use `IfcIndexedPolyCurve` with `IfcCartesianPointList3D` for compact, deduplicated point storage. The representation uses `RepresentationType="Curve3D"`.
To build the Docker Container Image, you must have [Docker](https://docs.docker.com/get-docker/) installed.
### Composite Objects (Path B2 — merged instances)
Once you have Docker running on your local machine:
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.
1. Open a terminal
1. Navigate to the directory in which you cloned this repository
1. Run the following command:
## Property Sets
```bash
docker build -f ./Dockerfile -t speckle_automate_python_example .
```
The exporter writes property sets matching Revit's native IFC export structure:
#### Running the Docker Container Image
| Property Set | Content |
|---|---|
| `Pset_<Entity>Common` | Standard IFC properties: Reference, IsExternal, LoadBearing, ThermalTransmittance |
| `Pset_SpaceCommon` | Room-specific: Reference, RoomNumber, RoomName, Category (Occupant) |
| `RVT_InstanceParameters` | All Revit instance parameters |
| `RVT_Identity` | Family, Type, ElementId, BuiltInCategory |
Once the GitHub Action has built the image, it is sent to Speckle Automate. When Speckle Automate runs your Function as part of an Automation, it will run the Docker Container Image. You can test that your Docker Container Image runs correctly locally.
## Quantities
1. To then run the Docker Container Image, run the following command:
Quantities follow the IFC standard naming convention: `Qto_<EntityType>BaseQuantities` and `Qto_<MaterialName>BaseQuantities`.
```bash
docker run --rm speckle_automate_python_example \
python -u main.py run \
'{"projectId": "1234", "modelId": "1234", "branchName": "myBranch", "versionId": "1234", "speckleServerUrl": "https://speckle.xyz", "automationId": "1234", "automationRevisionId": "1234", "automationRunId": "1234", "functionId": "1234", "functionName": "my function", "functionLogo": "base64EncodedPng"}' \
'{}' \
yourSpeckleServerAuthenticationToken
```
| 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 |
Let's explain this in more detail:
### Element Quantity Mapping
`docker run—-rm speckle_automate_python_example` tells Docker to run the Docker Container Image we built earlier. `speckle_automate_python_example` is the name of the Docker Container Image. The `--rm` flag tells Docker to remove the container after it has finished running, freeing up space on your machine.
| 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` |
The line `python -u main.py run` is the command run inside the Docker Container Image. The rest of the command is the arguments passed to the command. The arguments are:
### Supported Entity Qto Sets
- `'{"projectId": "1234", "modelId": "1234", "branchName": "myBranch", "versionId": "1234", "speckleServerUrl": "https://speckle.xyz", "automationId": "1234", "automationRevisionId": "1234", "automationRunId": "1234", "functionId": "1234", "functionName": "my function", "functionLogo": "base64EncodedPng"}'` - the metadata that describes the automation and the function.
- `{}` - the input parameters for the function the Automation creator can set. Here, they are blank, but you can add your parameters to test your function.
- `yourSpeckleServerAuthenticationToken`—the authentication token for the Speckle Server that the Automation can connect to. This is required to interact with the Speckle Server, for example, to get data from the Model.
`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
| Input | Description |
|---|---|
| `file_name` | Output IFC filename (timestamp is appended automatically) |
| `IFC_PROJECT_NAME` | Name for the IfcProject entity |
| `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
- [Learn](https://speckle.guide/dev/python.html) more about SpecklePy and interacting with Speckle from Python.
- [Speckle Developer Docs](https://speckle.guide/dev/python.html)
- [ifcopenshell Documentation](https://ifcopenshell.org/)
- [IFC 4.3 Schema](https://standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/)
- [Revit BuiltInCategory Reference](https://www.revitapidocs.com/2019/ba1c5b30-242f-5fdc-8ea9-ec3b61e6e722.htm)
-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
+123 -47
View File
@@ -1,22 +1,22 @@
import zipfile
from datetime import datetime
import ifcopenshell.api
import utils.config as config
from utils.materials import MaterialManager
from utils.traversal import traverse, print_tree
from utils.mapper import classify
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
from utils.properties import write_properties, write_common_properties, build_element_name, get_element_tag, get_ifc_guid
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
SPATIAL_STRUCTURE_TYPES = {
"IfcSite", "IfcBuilding", "IfcBuildingStorey",
"IfcSpace", "IfcExternalSpatialElement", "IfcSpatialZone",
"IfcBuilding", "IfcBuildingStorey",
"IfcExternalSpatialElement", "IfcSpatialZone",
"IfcGrid", "IfcAnnotation",
}
@@ -60,6 +60,11 @@ def automate_function(
print(" Speckle -> IFC4.3 Exporter")
print("=" * 60)
# Reset caches from any previous run
reset_props_caches()
reset_mapper_caches()
reset_instance_caches()
# ------------------------------------------------------------------ #
# 1. Receive
# ------------------------------------------------------------------ #
@@ -71,20 +76,22 @@ def automate_function(
# ------------------------------------------------------------------ #
# 2. Build definition map (for instance resolution)
# ------------------------------------------------------------------ #
print("\n🔍 Building definition map...")
# ----------------------------------------------
definition_map = build_definition_map(base)
# ------------------------------------------------------------------ #
# 3. Set up IFC
# ------------------------------------------------------------------ #
ifc, building, body_context = create_ifc_scaffold()
ifc, _site, building, body_context = create_ifc_scaffold(
project_name=function_inputs.IFC_PROJECT_NAME,
site_name=function_inputs.IFC_SITE_NAME,
building_name=function_inputs.IFC_BUILDING_NAME,
)
storey_manager = StoreyManager(ifc, building)
# ------------------------------------------------------------------ #
# 3b. Build material map from renderMaterialProxies
# ------------------------------------------------------------------ #
print("\n🎨 Building material map...")
material_manager = MaterialManager(ifc, base)
type_manager = TypeManager(ifc)
@@ -102,23 +109,40 @@ 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
name = build_element_name(obj)
# 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)
storey = storey_manager.get_or_create(level_name)
# ------------------------------------------------------------------ #
# Path A: Instance object (has transform + definitionId, no displayValue)
# ------------------------------------------------------------------ #
if is_instance(obj):
# Instances may lack category info — inherit from definition object
if ifc_class == "IfcBuildingElementProxy":
def_obj = get_definition_object(obj, definition_map)
if def_obj:
ifc_class = classify(def_obj, category_name)
rep, placement = instance_to_ifc(ifc, body_context, obj, definition_map, scale=scale, material_manager=material_manager)
if not rep:
no_geometry += 1
continue
element = _create_element(ifc, ifc_class, name, rep, placement, storey,
tag=get_element_tag(obj), guid=get_ifc_guid(obj))
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
@@ -135,33 +159,61 @@ def automate_function(
rep, placement = mesh_to_ifc(ifc, body_context, obj, scale=scale, material_manager=material_manager)
if rep:
element = _create_element(ifc, ifc_class, name, rep, placement, storey,
tag=get_element_tag(obj), guid=get_ifc_guid(obj))
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
# 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
)
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")
@@ -169,7 +221,9 @@ def automate_function(
# ------------------------------------------------------------------ #
# 5. Write output
# ------------------------------------------------------------------ #
print("\n🔗 Flushing type relationships...")
print("\n🔗 Flushing spatial containment...")
storey_manager.flush()
print("🔗 Flushing type relationships...")
type_manager.flush()
file_name = function_inputs.file_name
@@ -178,7 +232,19 @@ def automate_function(
ifc_filename = f"{file_name}_{timestamp}.ifc"
ifc.write(ifc_filename)
automate_context.store_file_result(f"./{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}")
print(f"\n{'=' * 60}")
print(f" Export complete!")
@@ -191,15 +257,22 @@ def automate_function(
print_instance_stats()
print(f"{'=' * 60}\n")
def _create_element(ifc, ifc_class, name, rep, placement, storey, tag=None, guid=None):
"""Helper: create an IFC element, assign geometry + placement + container."""
element = ifcopenshell.api.run(
"root.create_entity", ifc,
ifc_class=ifc_class,
name=str(name),
)
def _create_element(ifc, ifc_class, name, rep, placement, storey,
storey_manager=None,
tag=None, guid=None, object_type=None):
"""Helper: create an IFC element, assign geometry + placement, queue containment."""
element = ifcopenshell.api.run("root.create_entity", ifc,
ifc_class=ifc_class, name=str(name))
if tag:
element.Tag = str(tag)
try:
element.Tag = str(tag)
except AttributeError:
pass
if object_type:
try:
element.ObjectType = str(object_type)
except AttributeError:
pass
if guid:
try:
element.GlobalId = guid
@@ -215,11 +288,14 @@ def _create_element(ifc, ifc_class, name, rep, placement, storey, tag=None, guid
else:
element.ObjectPlacement = _make_placement(ifc, 0.0, 0.0, 0.0)
ifcopenshell.api.run(
"spatial.assign_container", ifc,
relating_structure=storey,
products=[element],
)
# 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"):
storey_manager.queue_aggregate(storey, element)
else:
storey_manager.queue_contain(storey, element)
return element
# make sure to call the function with the executor
+3 -2
View File
@@ -7,10 +7,11 @@ maintainers = [{ name = "Speckle Systems", email = "hello@speckle.systems" }]
description = "A Speckle Automate function template using specklepy"
readme = "README.md"
license = "Apache-2.0"
keywords = ["speckle", "automate", "bim", "aec"]
keywords = ["speckle", "automate", "bim", "aec", "ifc", "export", "revit"]
dependencies = ["specklepy==3.1.0",
"ifcopenshell==0.8.4.post1",]
"ifcopenshell==0.8.4.post1",
"python-dotenv>=1.0.0",]
[project.optional-dependencies]
dev = [
File diff suppressed because one or more lines are too long
-34
View File
@@ -1,34 +0,0 @@
# =============================================================================
# config.py
# All user-facing settings. Edit this file before running main.py.
# =============================================================================
# --- Speckle Connection ---
SPECKLE_HOST = "app.speckle.systems" # or your self-hosted server URL
SPECKLE_TOKEN = "****" # from app.speckle.systems/profile
# --- Speckle Project ---
PROJECT_ID = "d7d987146d" # the stream/project ID from the URL
VERSION_ID = "d59178f01e" # the specific version/commit to export
# --- IFC Output ---
OUTPUT_PATH = "output3.ifc" # where to write the IFC file
IFC_SCHEMA = "IFC4X3" # IFC4X3 = IFC4.3
# --- Project Metadata (written into the IFC file) ---
IFC_PROJECT_NAME = "Speckle Export"
IFC_SITE_NAME = "Site"
IFC_BUILDING_NAME = "Building"
# --- Units ---
# Speckle unit → metres scale factor
# The exporter reads units from the root object automatically,
# but this is the fallback if units are not set on the stream.
DEFAULT_UNITS = "mm"
UNIT_SCALE = {
"mm": 0.001,
"cm": 0.01,
"m": 1.0,
"ft": 0.3048,
"in": 0.0254,
}
+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)
+197 -215
View File
@@ -1,186 +1,98 @@
# =============================================================================
# geometry.py
# Converts Speckle DataObject geometry IFC IfcFacetedBrep + IfcLocalPlacement
# Converts Speckle DataObject geometry -> IFC IfcPolygonalFaceSet + IfcLocalPlacement
#
# Key facts:
# - After specklepy receive(), vertices and faces are FLAT Python lists
# - displayValue is an array of Mesh objects
# - 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.
# =============================================================================
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
# --------------------------------------------------------------------------- #
# Geometry validation helpers (GEM111 + BRP002 fixes)
# Geometry validation helpers (GEM111 fix)
# --------------------------------------------------------------------------- #
# Minimum distance in mm below which two vertices are considered identical (GEM111).
_VERTEX_MERGE_TOL = 0.01 # 0.01 mm
_INV_TOL = 1.0 / _VERTEX_MERGE_TOL # pre-computed: multiply instead of divide
def snap_coord(v: float) -> int:
"""Snap a coordinate to integer grid at _VERTEX_MERGE_TOL resolution."""
return round(v / _VERTEX_MERGE_TOL)
def _find_connected_components(snapped_faces: list) -> list:
def build_ifc_facesets(ifc, verts_scaled: list, face_groups: list) -> list:
"""
Union-Find: group face indices into connected components.
Two faces are connected if they share an edge (pair of snapped vertex keys).
Returns list of components, each a list of face indices.
Build a list of IfcPolygonalFaceSet from scaled (x,y,z) vertices and face index groups.
BRP002 requires all faces in an IfcClosedShell to form ONE component.
If multiple components exist, each must become a separate IfcClosedShell.
"""
n = len(snapped_faces)
if n == 0:
return []
parent = list(range(n))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(a, b):
parent[find(a)] = find(b)
# Map each edge to the first face that used it, then union subsequent faces
edge_to_face = {}
for fi, keys in enumerate(snapped_faces):
for i in range(len(keys)):
edge = frozenset([keys[i], keys[(i + 1) % len(keys)]])
if edge in edge_to_face:
union(fi, edge_to_face[edge])
else:
edge_to_face[edge] = fi
from collections import defaultdict
groups: dict = defaultdict(list)
for fi in range(n):
groups[find(fi)].append(fi)
return list(groups.values())
def build_ifc_breps(ifc, verts_scaled: list, face_groups: list) -> list:
"""
Build a list of IfcFacetedBrep from scaled (x,y,z) vertices and face index groups.
Uses IfcCartesianPointList3D + IfcIndexedPolygonalFace for compact output.
Vertices are deduplicated via snap grid so each unique position is stored once.
GEM111 fix: skip faces with near-duplicate vertices (snapped to same grid cell).
BRP002 fix: split faces into connected components; each component → its own
IfcClosedShell → IfcFacetedBrep so every shell is arc-wise connected.
verts_scaled: flat list of already-scaled floats [x0,y0,z0, x1,y1,z1, ...]
face_groups: list of index lists [[i,j,k], [i,j,k,l], ...]
Returns: list of IfcFacetedBrep (one per connected component, never empty).
Returns: list of IfcPolygonalFaceSet (typically one, empty on failure).
"""
# Pass 1: validate faces and build snapped key lists for connectivity analysis
valid_faces = [] # list of (pts_raw, snapped_keys)
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
valid_faces = [] # list of (idx0+1, idx1+1, ...) tuples (1-based for IFC)
for indices in face_groups:
try:
pts_raw = []
snapped = []
remapped = []
seen_snaps = set()
degenerate = False
seen = set()
for i in indices:
x = float(verts_scaled[i * 3])
y = float(verts_scaled[i * 3 + 1])
z = float(verts_scaled[i * 3 + 2])
key = (snap_coord(x), snap_coord(y), snap_coord(z))
if key in seen:
i3 = i * 3
x = verts_scaled[i3]
y = verts_scaled[i3 + 1]
z = verts_scaled[i3 + 2]
key = (round(x * inv_tol), round(y * inv_tol), round(z * inv_tol))
if key in seen_snaps:
degenerate = True
break
seen.add(key)
pts_raw.append((x, y, z))
snapped.append(key)
seen_snaps.add(key)
idx = snap_to_idx.get(key)
if idx is None:
idx = len(deduped_verts)
snap_to_idx[key] = idx
deduped_verts.append([x, y, z])
remapped.append(idx + 1) # 1-based for IFC
if degenerate or len(pts_raw) < 3:
if degenerate or len(remapped) < 3:
continue
valid_faces.append((pts_raw, snapped))
valid_faces.append(remapped)
except Exception:
continue
if not valid_faces:
if not valid_faces or not deduped_verts:
return []
# Pass 2: split into connected components (BRP002)
snapped_only = [f[1] for f in valid_faces]
components = _find_connected_components(snapped_only)
# 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)
# Pass 3: build one IfcFacetedBrep per component
breps = []
for component_indices in components:
ifc_faces = []
for fi in component_indices:
pts_raw, _ = valid_faces[fi]
try:
pts = [ifc.createIfcCartesianPoint([x, y, z]) for x, y, z in pts_raw]
poly = ifc.createIfcPolyLoop(pts)
bound = ifc.createIfcFaceOuterBound(poly, True)
ifc_faces.append(ifc.createIfcFace([bound]))
except Exception:
continue
if not ifc_faces:
continue
shell = ifc.createIfcClosedShell(ifc_faces)
breps.append(ifc.createIfcFacetedBrep(shell))
return breps
# Keep old name as alias so instances.py import works unchanged
def build_ifc_faces(ifc, verts_scaled: list, face_groups: list) -> list:
"""Legacy wrapper — returns flat list of IfcFace (no connectivity splitting)."""
# Used only as a fallback; callers should prefer build_ifc_breps directly.
breps = build_ifc_breps(ifc, verts_scaled, face_groups)
# Return the faces from all shells combined (for callers that need face lists)
faces = []
for brep in breps:
faces.extend(brep.Outer.CfsFaces)
return faces
# --------------------------------------------------------------------------- #
# Safe data access helpers
# --------------------------------------------------------------------------- #
def _get(obj, key, default=None):
"""
Safe access for specklepy Base objects.
Tries attribute access first, then bracket access.
"""
# Build IFC entities
try:
val = getattr(obj, key, None)
if val is not None:
return val
point_list = ifc.createIfcCartesianPointList3D(deduped_verts)
ifc_faces = [
ifc.createIfcIndexedPolygonalFace(fi) for fi in valid_faces
]
faceset = ifc.createIfcPolygonalFaceSet(point_list, None, ifc_faces, None)
return [faceset]
except Exception:
pass
try:
val = obj[key]
if val is not None:
return val
except Exception:
pass
return default
return []
def unwrap_chunks(raw) -> list:
@@ -189,31 +101,32 @@ def unwrap_chunks(raw) -> list:
Handles two cases:
1. Already flat list of numbers (after specklepy receive deserializes)
→ [3, 0, 1, 2, 3, ...] returned as-is
-> returned as-is (fast path)
2. List of DataChunk objects (raw from server before deserialization)
each chunk's .data list is concatenated
Both cases are handled so this function is always safe to call.
-> each chunk's .data list is concatenated
"""
if not raw:
return []
# Fast path: if first item is a number, assume all items are numbers
first = raw[0]
if isinstance(first, (int, float)):
return raw
# Slow path: DataChunk objects or mixed content
result = []
for item in raw:
if item is None:
continue
# Plain number — already flat
if isinstance(item, (int, float)):
result.append(item)
continue
# DataChunk — unwrap .data
speckle_type = getattr(item, "speckle_type", "") or ""
if "DataChunk" in speckle_type:
chunk_data = _get(item, "data") or _get(item, "@data")
if chunk_data:
result.extend(list(chunk_data))
else:
# Unknown — try iterating (handles nested lists)
try:
result.extend(list(item))
except Exception:
@@ -222,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)
@@ -236,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
@@ -249,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):
@@ -286,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
@@ -314,21 +243,26 @@ 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
while i < len(faces_raw):
n = int(faces_raw[i])
total = len(faces_raw)
# Check if values are already ints (common after unwrap_chunks)
already_int = total > 0 and isinstance(faces_raw[0], int)
while i < total:
n = faces_raw[i] if already_int else int(faces_raw[i])
if n == 0:
n = 3
elif n == 1:
n = 4
end = i + 1 + n
if end > len(faces_raw):
if end > total:
break
indices = [int(faces_raw[i + 1 + j]) for j in range(n)]
decoded.append(indices)
if already_int:
decoded.append(faces_raw[i + 1:end])
else:
decoded.append([int(v) for v in faces_raw[i + 1:end]])
i = end
return decoded
@@ -339,25 +273,55 @@ def decode_faces(faces_raw: list) -> list:
def compute_origin(flat_verts: list) -> tuple:
"""
Compute placement origin from scaled vertex list (metres).
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.
"""
xs = flat_verts[0::3]
ys = flat_verts[1::3]
zs = flat_verts[2::3]
cx = (min(xs) + max(xs)) / 2.0
cy = (min(ys) + max(ys)) / 2.0
cz = min(zs)
return cx, cy, cz
x0 = flat_verts[0]
y0 = flat_verts[1]
z0 = flat_verts[2]
xmin = xmax = x0
ymin = ymax = y0
zmin = z0
for i in range(3, len(flat_verts) - 2, 3):
x = flat_verts[i]
y = flat_verts[i + 1]
z = flat_verts[i + 2]
if x < xmin:
xmin = x
elif x > xmax:
xmax = x
if y < ymin:
ymin = y
elif y > ymax:
ymax = y
if z < zmin:
zmin = z
return (xmin + xmax) / 2.0, (ymin + ymax) / 2.0, zmin
# Cache for shared IFC direction/point entities (keyed by ifc file id)
_shared_entities: dict[int, dict] = {}
def _get_shared(ifc):
"""Return (or create) shared IfcDirection and IfcCartesianPoint entities for this file."""
fid = id(ifc)
if fid not in _shared_entities:
_shared_entities[fid] = {
"z_axis": ifc.createIfcDirection([0.0, 0.0, 1.0]),
"x_axis": ifc.createIfcDirection([1.0, 0.0, 0.0]),
"origin_0": ifc.createIfcCartesianPoint([0.0, 0.0, 0.0]),
}
return _shared_entities[fid]
def _make_placement(ifc, x: float, y: float, z: float):
"""Create an IfcLocalPlacement at absolute world coordinates (metres)."""
origin = ifc.createIfcCartesianPoint([x, y, z])
z_axis = ifc.createIfcDirection([0.0, 0.0, 1.0])
x_axis = ifc.createIfcDirection([1.0, 0.0, 0.0])
a2p = ifc.createIfcAxis2Placement3D(origin, z_axis, x_axis)
"""Create an IfcLocalPlacement at absolute world coordinates (mm)."""
shared = _get_shared(ifc)
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)
@@ -373,82 +337,100 @@ def mesh_to_ifc(
material_manager=None,
) -> tuple:
"""
Convert a Speckle DataObject (IfcShapeRepresentation, IfcLocalPlacement).
Creates one IfcFacetedBrep per mesh so each can carry its own material style.
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.
"""
meshes = get_display_meshes(obj)
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: collect all scaled vertices to compute world origin
# Pass 1: unpack and scale vertices once per mesh, compute origin
# incrementally without accumulating all vertices in memory.
# ------------------------------------------------------------------ #
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(list(raw_verts))
verts = unwrap_chunks(raw_verts if isinstance(raw_verts, list) else list(raw_verts))
if not verts:
mesh_cache.append(None)
continue
ms = _resolve_scale(mesh, obj_scale)
for i in range(0, len(verts) - 2, 3):
all_scaled.extend([
float(verts[i]) * ms,
float(verts[i+1]) * ms,
float(verts[i+2]) * ms,
])
scaled = [float(v) * ms for v in verts]
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 brep per mesh (so each can have its own material style)
# Pass 2: one faceset per mesh -- reuse cached verts, only unpack faces
# ------------------------------------------------------------------ #
brep_items = []
geom_items = []
for mesh in meshes:
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))
if not verts or not faces_raw:
for mesh, scaled in zip(meshes, mesh_cache):
if scaled is None:
continue
raw_faces = _get(mesh, "faces") or []
faces_raw = unwrap_chunks(raw_faces if isinstance(raw_faces, list) else list(raw_faces))
ms = _resolve_scale(mesh, obj_scale)
if not faces_raw:
continue
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
# Build pre-scaled vertex list (relative to origin) for this mesh
verts_scaled = []
for vi in range(0, len(verts) - 2, 3):
verts_scaled.append(float(verts[vi]) * ms - ox)
verts_scaled.append(float(verts[vi+1]) * ms - oy)
verts_scaled.append(float(verts[vi+2]) * ms - oz)
# Offset pre-scaled vertices relative to origin (flat list, no tuples)
n = len(scaled)
verts_scaled = [0.0] * n
for vi in range(0, n, 3):
verts_scaled[vi] = scaled[vi] - ox
verts_scaled[vi + 1] = scaled[vi + 1] - oy
verts_scaled[vi + 2] = scaled[vi + 2] - oz
mesh_breps = build_ifc_breps(ifc, verts_scaled, face_groups)
mesh_facesets = build_ifc_facesets(ifc, verts_scaled, face_groups)
if not mesh_breps:
if not mesh_facesets:
continue
# Apply material style to every component brep of this mesh
# 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 brep in mesh_breps:
material_manager.apply_to_item(brep, str(mesh_app_id))
for fs in mesh_facesets:
material_manager.apply_to_item(fs, str(mesh_app_id))
brep_items.extend(mesh_breps)
geom_items.extend(mesh_facesets)
if not brep_items:
if not geom_items:
return None, None
# ------------------------------------------------------------------ #
@@ -457,9 +439,9 @@ def mesh_to_ifc(
rep = ifc.createIfcShapeRepresentation(
ContextOfItems=body_context,
RepresentationIdentifier="Body",
RepresentationType="Brep",
Items=brep_items,
RepresentationType="Tessellation",
Items=geom_items,
)
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,
}
+434 -168
View File
@@ -2,23 +2,32 @@
# 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.
#
# Performance: uses IfcRepresentationMap + IfcMappedItem so that all instances
# 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_breps
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:
@@ -36,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)
@@ -57,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 []):
@@ -71,39 +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)}")
# Diagnostic: dump first 3 instanceDefinitionProxies to understand structure
print("\n [PROXY DIAG] First 3 instanceDefinitionProxies from root:")
proxies_raw2 = _get(root, "instanceDefinitionProxies")
if proxies_raw2:
sample = proxies_raw2 if isinstance(proxies_raw2, list) else [proxies_raw2]
for i, proxy in enumerate(sample[:3]):
app_id = _get(proxy, "applicationId") or "?"
name = _get(proxy, "name") or "?"
objects = _get(proxy, "objects") or []
obj_ids = list(objects)[:3] if objects else []
print(f" [{i}] appId={app_id}")
print(f" name={name}")
print(f" objects={obj_ids} (len={len(list(objects)) if objects else 0})")
# Check if first object is found in our maps
if obj_ids:
oid = str(obj_ids[0])
in_by_id = oid.lower()[:32] in by_id
in_by_app_id = oid.lower() in by_app_id
print(f" objects[0]='{oid}' → in by_id: {in_by_id}, in by_app_id: {in_by_app_id}")
else:
print(" [PROXY DIAG] No instanceDefinitionProxies found on root!")
# Check where they might be
for key in ["@instanceDefinitionProxies", "instancedefinitionproxies"]:
val = _get(root, key)
if val:
print(f" Found under key '{key}': {type(val)}")
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,
}
@@ -115,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:
@@ -125,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]
@@ -139,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
@@ -151,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 = []
@@ -192,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:
@@ -213,63 +239,274 @@ def _resolve_instance_scale(obj, stream_scale: float) -> float:
return stream_scale
def _parse_transform(t: list, scale: float) -> tuple:
"""
Row-major 4x4 matrix.
Translation at t[3], t[7], t[11] — scaled to metres.
Local X axis = row 0, Local Z axis = row 2.
"""
tx = float(t[3]) * scale
ty = float(t[7]) * scale
tz = float(t[11]) * scale
x_axis = (float(t[0]), float(t[1]), float(t[2]))
z_axis = (float(t[8]), float(t[9]), float(t[10]))
return (tx, ty, tz), x_axis, z_axis
def _make_ifc_placement(ifc, tx, ty, tz, x_axis, z_axis):
origin = ifc.createIfcCartesianPoint([tx, ty, tz])
x_dir = ifc.createIfcDirection(list(x_axis))
z_dir = ifc.createIfcDirection(list(z_axis))
a2p = ifc.createIfcAxis2Placement3D(origin, z_dir, x_dir)
return ifc.createIfcLocalPlacement(PlacementRelTo=None, RelativePlacement=a2p)
# Stats
_stats = {"found": 0, "not_found": 0}
_dbg_cnt = [0]
_stats = {"found": 0, "not_found": 0}
# 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)
# 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.
def _apply_transform(t: list, vx: float, vy: float, vz: float, ts: float) -> tuple:
mesh_data_list: list of (verts_local, face_groups) tuples
material_key: string identifying the material (included in hash)
Returns: hex digest string
"""
Apply a row-major 4x4 transform to a single vertex.
ts = scale factor applied to the translation components only (not rotation).
For Revit mm data with IFC in mm: ts=1.0 (no conversion).
For IFC-format transforms (metres): ts=1000.0 (m→mm).
Rotation components are dimensionless and never scaled.
"""
x = t[0]*vx + t[1]*vy + t[2]*vz + t[3] * ts
y = t[4]*vx + t[5]*vy + t[6]*vz + t[7] * ts
z = t[8]*vx + t[9]*vy + t[10]*vz + t[11] * ts
return x, y, z
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.
"""
result = []
for mesh in meshes:
mesh_id = _get(mesh, "id") or _get(mesh, "applicationId")
if mesh_id and mesh_id in _mesh_data_cache:
verts_local, face_groups = _mesh_data_cache[mesh_id]
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))
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)
try:
face_groups = decode_faces(faces_raw)
except Exception as e:
print(f" Warning: Instance face decode: {e}")
continue
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
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:
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
geom_items.extend(mesh_facesets)
if not geom_items:
_geometry_hash_cache[geom_hash] = None
return None
shared = _get_shared(ifc)
a2p = ifc.createIfcAxis2Placement3D(shared["origin_0"], None, None)
mapped_rep = ifc.createIfcShapeRepresentation(
ContextOfItems=body_context,
RepresentationIdentifier="Body",
RepresentationType="Tessellation",
Items=geom_items,
)
rep_map = ifc.createIfcRepresentationMap(a2p, mapped_rep)
_geometry_hash_cache[geom_hash] = rep_map
return rep_map
# --------------------------------------------------------------------------- #
# 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)
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.).
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]))
s1 = _vec_magnitude(*ax1)
s2 = _vec_magnitude(*ax2)
s3 = _vec_magnitude(*ax3)
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)
# 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])
# 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 (explicit -- never derived)
s2, # Scale2
s3, # Scale3
)
# --------------------------------------------------------------------------- #
# 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: BAKE the full 4x4 transform into every vertex (world coordinates).
Creates one IfcFacetedBrep per definition mesh so each can carry its own
material style via renderMaterialProxies.
Strategy: create geometry once per definition as an IfcRepresentationMap,
then reference it via IfcMappedItem + IfcCartesianTransformationOperator3D
for each instance. This avoids duplicating geometry across instances.
"""
transform_raw = _get(obj, "transform")
if not transform_raw:
@@ -281,93 +518,122 @@ 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)
if _dbg_cnt[0] < 6:
_dbg_cnt[0] += 1
fmt = "IFC" if ifc_format else "Revit"
x_axis = (round(t[0],2), round(t[1],2), round(t[2],2))
z_axis = (round(t[8],2), round(t[9],2), round(t[10],2))
print(f" [INST {_dbg_cnt[0]} {fmt}] {definition_id[:40]}")
print(f" t[3]={t[3]:.1f} t[7]={t[7]:.1f} t[11]={t[11]:.1f} x={x_axis} z={z_axis}")
# 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)
a2p = ifc.createIfcAxis2Placement3D(shared["origin_0"], None, None)
_identity_placement_cache[fid] = ifc.createIfcLocalPlacement(PlacementRelTo=None, RelativePlacement=a2p)
placement = _identity_placement_cache[fid]
# World-origin placement (geometry is baked to world coords)
origin = ifc.createIfcCartesianPoint([0.0, 0.0, 0.0])
a2p = ifc.createIfcAxis2Placement3D(origin, None, None)
placement = ifc.createIfcLocalPlacement(PlacementRelTo=None, RelativePlacement=a2p)
# --- 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)
else:
meshes, extra_app_ids = _get_revit_meshes(definition_id, definition_map)
# Get definition meshes
if ifc_format:
meshes = _get_ifc_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:
_stats["not_found"] += 1
else:
meshes = _get_revit_meshes(definition_id, definition_map)
# Track stats even for cached definitions
if _rep_map_cache[definition_id] is not None:
_stats["found"] += 1
else:
_stats["not_found"] += 1
if not meshes:
_stats["not_found"] += 1
rep_map = _rep_map_cache[definition_id]
if rep_map is None:
return None, placement
_stats["found"] += 1
# One brep per mesh so each can have its own material style
brep_items = []
for mesh in meshes:
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))
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)
try:
face_groups = decode_faces(faces_raw)
except Exception as e:
print(f" ⚠️ Instance face decode: {e}")
continue
# Pre-compute world coords for all vertices in this mesh
verts_world = []
for vi in range(0, len(verts) - 2, 3):
lx = float(verts[vi]) * ms
ly = float(verts[vi+1]) * ms
lz = float(verts[vi+2]) * ms
wx, wy, wz = _apply_transform(t, lx, ly, lz, ts)
verts_world.append(wx)
verts_world.append(wy)
verts_world.append(wz)
mesh_breps = build_ifc_breps(ifc, verts_world, face_groups)
if not mesh_breps:
continue
# Apply material style to every component brep of this mesh
if material_manager:
mesh_app_id = _get(mesh, "applicationId")
if mesh_app_id:
for brep in mesh_breps:
material_manager.apply_to_item(brep, str(mesh_app_id))
brep_items.extend(mesh_breps)
if not brep_items:
# --- Build transform operator from instance's 4x4 matrix ---
transform_op = _make_transform_operator(ifc, t, ts)
if transform_op is None:
return None, placement
# --- Create IfcMappedItem referencing the shared geometry ---
mapped_item = ifc.createIfcMappedItem(rep_map, transform_op)
rep = ifc.createIfcShapeRepresentation(
ContextOfItems=body_context,
RepresentationIdentifier="Body",
RepresentationType="Brep",
Items=brep_items,
RepresentationType="MappedRepresentation",
Items=[mapped_item],
)
return rep, placement
def get_definition_object(obj: Base, definition_map: dict):
"""
Resolve the definition's source object for an InstanceProxy.
Returns the first object referenced by the definition proxy, which
carries the proper category/type info. Returns None if not found.
"""
definition_id = _get(obj, "definitionId") or ""
if not definition_id:
return None
return _get_definition_source_object(definition_id, definition_map)
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
+92 -69
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
# =============================================================================
@@ -36,6 +35,7 @@ BUILTIN_CATEGORY_MAP: dict[str, str] = {
# Architectural - Stairs / Ramps / Railings
"OST_Stairs": "IfcStair",
"OST_StairsRailing": "IfcRailing",
"OST_RailingTopRail": "IfcRailing",
"OST_Ramps": "IfcRamp",
"OST_StairsLandings": "IfcStairFlight",
"OST_StairsRuns": "IfcStairFlight",
@@ -62,6 +62,7 @@ BUILTIN_CATEGORY_MAP: dict[str, str] = {
"OST_StructuralStiffener": "IfcMember",
"OST_StructuralTruss": "IfcMember",
"OST_StructuralConnectionModel": "IfcMechanicalFastener",
"OST_StructConnections": "IfcMechanicalFastener",
"OST_Rebar": "IfcReinforcingBar",
"OST_FabricAreas": "IfcReinforcingMesh",
"OST_FabricReinforcement": "IfcReinforcingMesh",
@@ -81,6 +82,7 @@ BUILTIN_CATEGORY_MAP: dict[str, str] = {
"OST_PipeAccessory": "IfcPipeSegment",
"OST_FlexPipeCurves": "IfcPipeSegment",
"OST_PlumbingFixtures": "IfcSanitaryTerminal",
"OST_PlumbingEquipment": "IfcSanitaryTerminal",
"OST_Sprinklers": "IfcFireSuppressionTerminal",
# MEP - Electrical
@@ -101,6 +103,7 @@ BUILTIN_CATEGORY_MAP: dict[str, str] = {
# Site / Civil
"OST_Site": "IfcSite",
"OST_Topography": "IfcGeographicElement",
"OST_Toposolid": "IfcGeographicElement",
"OST_Roads": "IfcRoad",
"OST_Hardscape": "IfcPavement",
"OST_Planting": "IfcGeographicElement",
@@ -117,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",
@@ -173,6 +154,7 @@ CATEGORY_MAP: dict[str, str] = {
"Stairs": "IfcStair",
"Ramps": "IfcRamp",
"Railings": "IfcRailing",
"Top Rails": "IfcRailing",
"Curtain Panels": "IfcCurtainWall",
"Curtain Wall Mullions": "IfcMember",
"Doors": "IfcDoor",
@@ -181,13 +163,18 @@ 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",
"Toposolid": "IfcGeographicElement",
"Planting": "IfcGeographicElement",
"Site": "IfcSite",
"Parking": "IfcSpace",
"Generic Models": "IfcBuildingElementProxy",
@@ -196,65 +183,101 @@ CATEGORY_MAP: dict[str, str] = {
}
_bic_cache: dict[int, str | None] = {} # id(obj) → builtInCategory
def _get_builtin_category(obj) -> str | None:
"""
Read builtInCategory from obj.properties.builtInCategory.
Returns the OST_ string or None.
Returns the OST_ string or None. Cached per object.
"""
oid = id(obj)
if oid in _bic_cache:
return _bic_cache[oid]
result = None
try:
props = obj["properties"] or getattr(obj, "properties", None)
props = getattr(obj, "properties", None)
if props is None:
return None
if hasattr(props, "__getitem__"):
val = props["builtInCategory"]
else:
try:
props = obj["properties"]
except Exception:
pass
if props is not None:
val = getattr(props, "builtInCategory", None)
if val and isinstance(val, str):
return val.strip()
if val is None:
try:
val = props["builtInCategory"]
except Exception:
pass
if val and isinstance(val, str):
result = val.strip()
except Exception:
pass
return None
_bic_cache[oid] = result
return result
def classify(obj, category_name: str = "") -> str:
# 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()
]
# Classification cache: (obj_id, category_name) → ifc_class
_classify_cache: dict[tuple, 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
"""
# 1. builtInCategory — most reliable, direct Revit enum
cache_key = (id(obj), category_name)
if cache_key in _classify_cache:
return _classify_cache[cache_key]
result = _classify_impl(obj, category_name)
_classify_cache[cache_key] = result
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
# 1. builtInCategory — most reliable, direct Revit enum
if bic and bic in BUILTIN_CATEGORY_MAP:
return BUILTIN_CATEGORY_MAP[bic]
# 2. speckle_type
speckle_type = getattr(obj, "speckle_type", "") or ""
if speckle_type in SPECKLE_TYPE_MAP:
return SPECKLE_TYPE_MAP[speckle_type]
for key, ifc_class in SPECKLE_TYPE_MAP.items():
if speckle_type.startswith(key):
return ifc_class
# 3. category_name from traversal context
# 2. category_name from traversal context — exact match first
if category_name:
if category_name in CATEGORY_MAP:
return CATEGORY_MAP[category_name]
for key, ifc_class in CATEGORY_MAP.items():
if key.lower() in category_name.lower():
cat_lower = category_name.lower()
for key_lower, ifc_class in _CATEGORY_MAP_LOWER:
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:
return CATEGORY_MAP[obj_category]
for key, ifc_class in CATEGORY_MAP.items():
if key.lower() in obj_category.lower():
obj_cat_lower = obj_category.lower()
for key_lower, ifc_class in _CATEGORY_MAP_LOWER:
if key_lower in obj_cat_lower:
return ifc_class
return "IfcBuildingElementProxy"
return "IfcBuildingElementProxy"
def reset_caches():
"""Clear module-level caches (call at start of each export run)."""
_bic_cache.clear()
_classify_cache.clear()
+20 -1
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,8 +136,26 @@ 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. IfcFacetedBrep)."""
"""Assign the material style to a single IFC geometry item (e.g. IfcPolygonalFaceSet)."""
style = self.get_style(mesh_app_id)
if style is None:
return
+430 -61
View File
@@ -52,6 +52,11 @@ COMMON_PSET: dict[str, str] = {
"IfcLightFixture": "Pset_LightFixtureTypeCommon",
"IfcOpeningElement": "Pset_OpeningElementCommon",
"IfcPlate": "Pset_PlateCommon",
"IfcGeographicElement": "Pset_SiteCommon",
"IfcPipeFitting": "Pset_PipeFittingTypeCommon",
"IfcSanitaryTerminal": "Pset_SanitaryTerminalTypeCommon",
"IfcReinforcingBar": "Pset_ReinforcingBarBendingsBECCommon",
"IfcMechanicalFastener": "Pset_MechanicalFastenerTypeCommon",
}
# ---------------------------------------------------------------------------
@@ -129,50 +134,124 @@ EXTERNAL_CATEGORIES = {
# Helpers
# ---------------------------------------------------------------------------
_props_cache: dict[int, dict] = {} # id(obj) → props dict
def _get_props_dict(obj: Base) -> dict:
for key in ["properties", "@properties"]:
try:
p = obj[key]
if p is None:
"""Get properties as a plain dict. Cached per object to avoid repeated conversion."""
oid = id(obj)
if oid in _props_cache:
return _props_cache[oid]
# Try getattr first — matches the pattern that works in other Speckle scripts
p = getattr(obj, "properties", None)
if p is None:
for key in ["properties", "@properties"]:
try:
p = obj[key]
if p is not None:
break
except Exception:
continue
if hasattr(p, "get_dynamic_member_names"):
return {n: p[n] for n in p.get_dynamic_member_names()}
if isinstance(p, dict):
return p
except Exception:
pass
return {}
if p is None:
_props_cache[oid] = {}
return {}
result = _to_dict(p)
_props_cache[oid] = result
return result
def _get_nested(d: dict, *keys):
def _get_nested(d, *keys):
"""Safely walk nested dicts/objects."""
cur = d
for k in keys:
if cur is None:
return None
if isinstance(cur, dict):
cur = cur.get(k)
else:
try:
cur = cur[k]
except Exception:
return None
cur = _safe_get(cur, k)
return cur
def _param_value(params_block: dict, internal_name: str):
_to_dict_cache: dict[int, dict] = {} # id(obj) → converted dict
def _to_dict(obj) -> dict:
"""Convert a Speckle Base object or dict to a plain dict. Returns {} on failure.
Cached per object identity to avoid repeated conversion."""
if obj is None:
return {}
if isinstance(obj, dict):
return obj
oid = id(obj)
if oid in _to_dict_cache:
return _to_dict_cache[oid]
# Try .get_dynamic_member_names() for Speckle Base objects
if hasattr(obj, "get_dynamic_member_names"):
result = {}
try:
names = obj.get_dynamic_member_names()
except Exception:
_to_dict_cache[oid] = {}
return {}
for n in names:
try:
result[n] = obj[n]
except Exception:
pass
_to_dict_cache[oid] = result
return result
# Last resort: try common dict-like patterns
if hasattr(obj, "items"):
try:
result = dict(obj.items())
_to_dict_cache[oid] = result
return result
except Exception:
pass
_to_dict_cache[oid] = {}
return {}
def _safe_get(obj, key, default=None):
"""Safe key access for both dicts and Speckle Base objects."""
if obj is None:
return default
if isinstance(obj, dict):
return obj.get(key, default)
# Try getattr first (works reliably for Speckle Base)
try:
val = getattr(obj, key, None)
if val is not None:
return val
except Exception:
pass
# Fallback to bracket access
try:
val = obj[key]
if val is not None:
return val
except Exception:
pass
return default
def _param_value(params_block, internal_name: str):
"""
Search all groups in a parameter block for a param with the given
internalDefinitionName. Returns the raw value or None.
Handles both plain dicts and Speckle Base objects.
"""
if not isinstance(params_block, dict):
block = _to_dict(params_block)
if not block:
return None
for group in params_block.values():
if not isinstance(group, dict):
for group in block.values():
group_d = _to_dict(group)
if not group_d:
continue
for entry in group.values():
if isinstance(entry, dict) and entry.get("internalDefinitionName") == internal_name:
return entry.get("value")
for entry in group_d.values():
entry_d = _to_dict(entry)
if not entry_d:
continue
if entry_d.get("internalDefinitionName") == internal_name:
return entry_d.get("value")
return None
@@ -210,10 +289,8 @@ def build_element_name(obj: Base) -> str:
Build element name in Revit native IFC format: "Family:TypeName:ElementId"
Falls back gracefully if any part is missing.
"""
props = _get_props_dict(obj)
family = getattr(obj, "family", None) or ""
typ = getattr(obj, "type", None) or ""
elem_id = props.get("elementId", "") or getattr(obj, "applicationId", "") or ""
# Treat literal "none" (case-insensitive) the same as empty — Revit exports
# placeholder objects with family/type set to the string "none".
@@ -223,15 +300,13 @@ def build_element_name(obj: Base) -> str:
typ = ""
parts = [p for p in [family, typ] if p]
if elem_id:
parts.append(str(elem_id))
return ":".join(parts) if parts else (getattr(obj, "id", None) or "unnamed")
def get_element_tag(obj: Base) -> str | None:
"""Return Revit ElementId as the IFC Tag."""
props = _get_props_dict(obj)
elem_id = props.get("elementId")
elem_id = _safe_get(props, "elementId")
return str(elem_id) if elem_id else None
@@ -241,11 +316,12 @@ def get_ifc_guid(obj: Base) -> str | None:
Falls back to None (ifcopenshell will auto-generate a GUID).
"""
props = _get_props_dict(obj)
params = props.get("Parameters") or {}
inst = params.get("Instance Parameters") or {}
ifc_p = inst.get("IFC Parameters") or {}
entry = ifc_p.get("IfcGUID") or {}
val = entry.get("value") if isinstance(entry, dict) else None
params = _safe_get(props, "Parameters", {})
inst = _safe_get(params, "Instance Parameters", {})
ifc_p = _safe_get(inst, "IFC Parameters", {})
entry = _safe_get(ifc_p, "IfcGUID", {})
entry_d = _to_dict(entry) if not isinstance(entry, dict) else entry
val = entry_d.get("value") if entry_d else None
return str(val) if val else None
@@ -263,9 +339,9 @@ def write_common_pset(ifc, element, obj: Base, ifc_class: str, category_name: st
return
props = _get_props_dict(obj)
params = props.get("Parameters") or {}
type_params = params.get("Type Parameters") or {}
inst_params = params.get("Instance Parameters") or {}
params = _safe_get(props, "Parameters", {})
type_params = _safe_get(params, "Type Parameters", {})
inst_params = _safe_get(params, "Instance Parameters", {})
ifc_props = []
@@ -277,7 +353,7 @@ def write_common_pset(ifc, element, obj: Base, ifc_class: str, category_name: st
ifc_props.append(p)
# IsExternal — derive from builtInCategory or "Constraints" parameters
bic = props.get("builtInCategory", "")
bic = _safe_get(props, "builtInCategory", "")
is_external = bic in EXTERNAL_CATEGORIES
if not is_external:
# Some elements expose it directly as a parameter
@@ -352,9 +428,104 @@ 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)
# ---------------------------------------------------------------------------
@@ -384,19 +555,23 @@ def _safe_str(value) -> str | None:
return s or None
def _flatten_params(params_block: dict) -> dict:
"""Flatten Type or Instance parameter block into {name: display_value}."""
def _flatten_params(params_block) -> dict:
"""Flatten Type or Instance parameter block into {name: display_value}.
Handles both plain dicts and Speckle Base objects at every nesting level."""
result = {}
skip_units = {"", "None", "General", "Currency", "Integer"}
for group in params_block.values():
if not isinstance(group, dict):
block = _to_dict(params_block)
for group in block.values():
group_d = _to_dict(group)
if not group_d:
continue
for entry in group.values():
if not isinstance(entry, dict):
for entry in group_d.values():
entry_d = _to_dict(entry)
if not entry_d:
continue
name = entry.get("name")
value = entry.get("value")
units = entry.get("units", "") or ""
name = entry_d.get("name")
value = entry_d.get("value")
units = entry_d.get("units", "") or ""
if not name or value is None:
continue
val_str = _safe_str(value)
@@ -409,16 +584,17 @@ def _flatten_params(params_block: dict) -> dict:
def write_revit_params(ifc, element, obj: Base):
"""
Write remaining Revit parameters as two custom property sets
Write remaining Revit instance parameters as a custom property set
using the vendor prefix 'RVT_' (not 'Pset_' which is reserved):
RVT_TypeParameters — from Type Parameters
RVT_InstanceParameters — from Instance Parameters
Note: RVT_TypeParameters are written on the IfcTypeObject (via TypeManager),
not on individual elements, to avoid duplication.
"""
props = _get_props_dict(obj)
params = props.get("Parameters") or {}
params = _safe_get(props, "Parameters", {})
type_flat = _flatten_params(params.get("Type Parameters") or {})
inst_flat = _flatten_params(params.get("Instance Parameters") or {})
inst_flat = _flatten_params(_safe_get(params, "Instance Parameters", {}))
def build_str_props(flat: dict) -> list:
out = []
@@ -431,11 +607,8 @@ def write_revit_params(ifc, element, obj: Base):
pass
return out
type_props = build_str_props(type_flat)
inst_props = build_str_props(inst_flat)
if type_props:
_write_pset(ifc, element, "RVT_TypeParameters", type_props)
if inst_props:
_write_pset(ifc, element, "RVT_InstanceParameters", inst_props)
@@ -445,10 +618,10 @@ def write_revit_params(ifc, element, obj: Base):
val = getattr(obj, field, None)
if val and isinstance(val, str) and val.strip():
identity[field.capitalize()] = val.strip()
elem_id = props.get("elementId")
elem_id = _safe_get(props, "elementId")
if elem_id:
identity["ElementId"] = str(elem_id)
bic = props.get("builtInCategory")
bic = _safe_get(props, "builtInCategory")
if bic:
identity["BuiltInCategory"] = str(bic)
@@ -468,6 +641,193 @@ def write_revit_params(ifc, element, obj: Base):
# Public API — called from main.py
# ---------------------------------------------------------------------------
def write_material_quantities(ifc, element, obj: Base):
"""
Write Material Quantities from Revit as IfcElementQuantity sets.
Source: properties."Material Quantities".<MaterialName>.{area, volume, density,
materialName, materialClass, materialCategory}
Each material produces one IfcElementQuantity named "Qto_<MaterialName>BaseQuantities" with:
- GrossArea (IfcQuantityArea)
- GrossVolume (IfcQuantityVolume)
- Density (IfcPropertySingleValue — no standard IFC quantity type)
- MaterialClass (IfcPropertySingleValue)
- MaterialCategory (IfcPropertySingleValue)
"""
props = _get_props_dict(obj)
mat_quantities = _safe_get(props, "Material Quantities")
if mat_quantities is None:
return
mat_dict = _to_dict(mat_quantities)
if not mat_dict:
return
for mat_key, mat_data in mat_dict.items():
mat_d = _to_dict(mat_data)
if not mat_d:
continue
mat_name = mat_d.get("materialName") or mat_key
quantities = []
# Area → IfcQuantityArea
area_entry = _to_dict(mat_d.get("area"))
if area_entry and area_entry.get("value") is not None:
try:
q = ifc.create_entity(
"IfcQuantityArea",
Name="GrossArea",
AreaValue=float(area_entry["value"]),
)
quantities.append(q)
except Exception:
pass
# Volume → IfcQuantityVolume
vol_entry = _to_dict(mat_d.get("volume"))
if vol_entry and vol_entry.get("value") is not None:
try:
q = ifc.create_entity(
"IfcQuantityVolume",
Name="GrossVolume",
VolumeValue=float(vol_entry["value"]),
)
quantities.append(q)
except Exception:
pass
# Density → IfcQuantityWeight (mass per volume, stored as weight)
density_entry = _to_dict(mat_d.get("density"))
if density_entry and density_entry.get("value") is not None:
try:
q = ifc.create_entity(
"IfcQuantityWeight",
Name="Density",
WeightValue=float(density_entry["value"]),
)
quantities.append(q)
except Exception:
pass
if not quantities:
continue
# Create IfcElementQuantity and link via IfcRelDefinesByProperties
qto_name = f"Qto_{mat_name}BaseQuantities"
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}")
# ---------------------------------------------------------------------------
# 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:
@@ -476,12 +836,21 @@ 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)
"""
write_common_pset(ifc, element, obj, ifc_class, category_name)
write_environmental_pset(ifc, element, obj)
write_revit_params(ifc, element, obj)
write_element_quantities(ifc, element, obj, ifc_class)
write_material_quantities(ifc, element, obj)
def write_common_properties(ifc, element, obj: Base, category_name: str = ""):
"""Legacy shim — kept for compatibility with main.py call sites."""
pass # All handled by write_properties now
pass # All handled by write_properties now
def reset_caches():
"""Clear module-level caches (call at start of each export run)."""
_props_cache.clear()
_to_dict_cache.clear()
+16 -9
View File
@@ -3,29 +3,36 @@
# Connects to Speckle and receives the root Base object for a given version.
# =============================================================================
import os
from dotenv import load_dotenv
from specklepy.api.client import SpeckleClient
from specklepy.api.credentials import get_default_account
from specklepy.api import operations
from specklepy.transports.server import ServerTransport
import utils.config as config
load_dotenv()
SPECKLE_HOST = os.getenv("SPECKLE_SERVER_URL", "https://app.speckle.systems")
SPECKLE_TOKEN = os.getenv("SPECKLE_TOKEN", "")
DEFAULT_UNITS = "mm"
def get_client() -> SpeckleClient:
"""
Create and authenticate a SpeckleClient.
Uses a personal access token from config.py.
Uses a personal access token from the .env file.
To use your local Speckle Manager account instead, swap to get_default_account().
"""
client = SpeckleClient(host=config.SPECKLE_HOST)
client = SpeckleClient(host=SPECKLE_HOST)
if config.SPECKLE_TOKEN and config.SPECKLE_TOKEN != "YOUR_PERSONAL_ACCESS_TOKEN":
client.authenticate_with_token(config.SPECKLE_TOKEN)
if SPECKLE_TOKEN and SPECKLE_TOKEN != "YOUR_PERSONAL_ACCESS_TOKEN":
client.authenticate_with_token(SPECKLE_TOKEN)
else:
# Fallback: use account from Speckle Manager desktop app
account = get_default_account()
if account is None:
raise RuntimeError(
"No Speckle account found. Either set SPECKLE_TOKEN in config.py "
"No Speckle account found. Either set SPECKLE_TOKEN in .env "
"or log in via Speckle Manager."
)
client.authenticate_with_account(account)
@@ -46,11 +53,11 @@ def receive_version(project_id: str, version_id: str):
"""
client = get_client()
print(f"🔗 Connecting to {config.SPECKLE_HOST}...")
print(f"🔗 Connecting to {SPECKLE_HOST}...")
print(f"📦 Receiving project={project_id} version={version_id}")
# Get version metadata to find the referenced object ID
version = client.version.get(version_id,project_id)
version = client.version.get(version_id, project_id)
referenced_object_id = version.referenced_object
# Download the full object graph
@@ -58,7 +65,7 @@ def receive_version(project_id: str, version_id: str):
base = operations.receive(referenced_object_id, transport)
# Read units from the root object
units = getattr(base, "units", config.DEFAULT_UNITS) or config.DEFAULT_UNITS
units = getattr(base, "units", DEFAULT_UNITS) or DEFAULT_UNITS
# IFC file is declared in MILLIMETRES — no conversion needed.
# All geometry stays in source units (mm). scale=1.0 means "keep as-is".
+6 -8
View File
@@ -55,10 +55,10 @@ def get_prop(obj, key: str, default=None):
# speckle_type fragments that mark a non-exportable / spatial-structure object
_SKIP_TYPE_FRAGMENTS = {
"Collection", "Level", "Grid", "View", "RenderMaterial",
"Site", "Building", "Storey",
}
import re
_SKIP_TYPE_RE = re.compile(
r"Collection|Level|Grid|View|RenderMaterial|Site|Building|Storey"
)
def _is_valid_element(obj) -> bool:
@@ -70,10 +70,8 @@ def _is_valid_element(obj) -> bool:
return False
speckle_type = getattr(obj, "speckle_type", "") or ""
for fragment in _SKIP_TYPE_FRAGMENTS:
if fragment in speckle_type:
return False
if _SKIP_TYPE_RE.search(speckle_type):
return False
return True
+9 -14
View File
@@ -22,6 +22,7 @@ import ifcopenshell.api
from specklepy.objects.base import Base
from utils.properties import (
_get_props_dict, _get_nested, _param_value, _make_prop, _write_pset,
_safe_get, _to_dict,
COMMON_PSET, EXTERNAL_CATEGORIES, _flatten_params
)
@@ -112,9 +113,9 @@ class TypeManager:
obj: Base, ifc_class: str):
"""Instantiate the IfcTypeObject with name, tag, GlobalId, and psets."""
props = _get_props_dict(obj)
params = props.get("Parameters") or {}
type_params = params.get("Type Parameters") or {}
inst_params = params.get("Instance Parameters") or {}
params = _safe_get(props, "Parameters", {})
type_params = _safe_get(params, "Type Parameters", {})
inst_params = _safe_get(params, "Instance Parameters", {})
# Name: "Family:TypeName" (no ElementId)
name_parts = [p for p in [family, type_name] if p]
@@ -122,13 +123,13 @@ class TypeManager:
# Tag: Type's Revit ElementId
type_id_entry = _get_nested(inst_params, "Other", "Type Id")
tag = str(type_id_entry.get("value")) if isinstance(type_id_entry, dict) else None
type_id_d = _to_dict(type_id_entry)
tag = str(type_id_d.get("value")) if type_id_d.get("value") else None
# GlobalId: from Type IfcGUID parameter
type_guid_entry = _get_nested(type_params, "IFC Parameters", "Type IfcGUID")
guid = None
if isinstance(type_guid_entry, dict):
guid = type_guid_entry.get("value")
type_guid_d = _to_dict(type_guid_entry)
guid = type_guid_d.get("value") if type_guid_d else None
# Create type entity
type_obj = ifcopenshell.api.run(
@@ -163,7 +164,7 @@ class TypeManager:
type_ifc_props = []
# IsExternal (type-level)
bic = props.get("builtInCategory", "")
bic = _safe_get(props, "builtInCategory", "")
is_external = bic in EXTERNAL_CATEGORIES
if ifc_class not in {"IfcSpace", "IfcSite", "IfcBuildingStorey",
"IfcBuilding", "IfcFurnishingElement", "IfcOpeningElement"}:
@@ -197,12 +198,6 @@ class TypeManager:
if type_ifc_props:
_write_pset(ifc, type_obj, pset_name, type_ifc_props)
# ── Pset_EnvironmentalImpactIndicators on the type ─────────────────
if type_name:
p = _make_prop(ifc, "Reference", "IfcIdentifier", type_name)
if p:
_write_pset(ifc, type_obj, "Pset_EnvironmentalImpactIndicators", [p])
# ── RVT_TypeParameters — all type-level Revit params ──────────────
type_flat = _flatten_params(type_params)
if type_flat:
+52 -7
View File
@@ -9,16 +9,20 @@
import ifcopenshell
import ifcopenshell.api
import utils.config as config
def create_ifc_scaffold() -> tuple:
def create_ifc_scaffold(
project_name: str = "Default Project",
site_name: str = "Default Site",
building_name: str = "Default Building",
) -> tuple:
"""
Create the IFC file with the required project/site/building hierarchy.
Returns:
(ifc_file, building, body_context)
(ifc_file, site, building, body_context)
- ifc_file: The ifcopenshell file object
- site: The IfcSite entity
- building: The IfcBuilding entity (storeys are assigned under this)
- body_context: The Body geometry subcontext for shape representations
"""
@@ -28,7 +32,7 @@ def create_ifc_scaffold() -> tuple:
project = ifcopenshell.api.run(
"root.create_entity", ifc,
ifc_class="IfcProject",
name=config.IFC_PROJECT_NAME,
name=project_name,
)
# Units — millimetres (matching Revit/Speckle source data)
@@ -55,12 +59,12 @@ def create_ifc_scaffold() -> tuple:
site = ifcopenshell.api.run(
"root.create_entity", ifc,
ifc_class="IfcSite",
name=config.IFC_SITE_NAME,
name=site_name,
)
building = ifcopenshell.api.run(
"root.create_entity", ifc,
ifc_class="IfcBuilding",
name=config.IFC_BUILDING_NAME,
name=building_name,
)
ifcopenshell.api.run(
@@ -74,19 +78,26 @@ def create_ifc_scaffold() -> tuple:
products=[building],
)
return ifc, building, body_ctx
return ifc, site, building, body_ctx
class StoreyManager:
"""
Lazily creates IfcBuildingStorey entities as new level names are encountered.
Keeps storeys in insertion order so the IFC file is logically ordered.
Spatial containment is batched — call flush() after all elements are created
to write all IfcRelContainedInSpatialStructure / aggregate relationships at once.
"""
def __init__(self, ifc: ifcopenshell.file, building):
self.ifc = ifc
self.building = building
self._storeys: dict[str, object] = {} # level_name → IfcBuildingStorey
# Batched containment: storey_id → [element, ...]
self._contained: dict[int, list] = {}
# Batched aggregation (IfcSite etc.): storey_id → [element, ...]
self._aggregated: dict[int, list] = {}
def get_or_create(self, level_name: str):
"""Return existing storey or create a new one for this level name."""
@@ -106,6 +117,40 @@ class StoreyManager:
return self._storeys[level_name]
def queue_contain(self, storey, element):
"""Queue an element for spatial containment (batched flush)."""
sid = storey.id()
if sid not in self._contained:
self._contained[sid] = []
self._contained[sid].append(element)
def queue_aggregate(self, storey, element):
"""Queue an element for aggregation under storey (e.g. IfcSite)."""
sid = storey.id()
if sid not in self._aggregated:
self._aggregated[sid] = []
self._aggregated[sid].append(element)
def flush(self):
"""Write all batched spatial containment and aggregation relationships."""
ifc = self.ifc
for sid, elements in self._contained.items():
storey = ifc.by_id(sid)
ifcopenshell.api.run(
"spatial.assign_container", ifc,
relating_structure=storey,
products=elements,
)
for sid, elements in self._aggregated.items():
storey = ifc.by_id(sid)
ifcopenshell.api.run(
"aggregate.assign_object", ifc,
relating_object=storey,
products=elements,
)
self._contained.clear()
self._aggregated.clear()
@property
def count(self) -> int:
return len(self._storeys)