Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 38e05ccc28 | |||
| f9193ee3a0 | |||
| 112f031608 | |||
| 673c024ac5 | |||
| f8c9d4237d | |||
| 11acb02fd1 | |||
| f7aa6c29da | |||
| 63082a881c | |||
| bdd030ba86 | |||
| 0af7eaf91d | |||
| ea7ea434ab |
@@ -1,4 +0,0 @@
|
||||
SPECKLE_TOKEN="mytoken"
|
||||
SPECKLE_SERVER_URL="http://127.0.0.1:3000"
|
||||
SPECKLE_PROJECT_ID=""
|
||||
SPECKLE_AUTOMATION_ID=""
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5.0.0
|
||||
- uses: actions/setup-python@v6
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- name: Install dependencies
|
||||
@@ -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
|
||||
|
||||
@@ -1,174 +1,177 @@
|
||||
# Speckle Automate function template - Python
|
||||
# Speckle to IFC 4.3 Exporter
|
||||
|
||||
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.
|
||||
A [Speckle Automate](https://automate.speckle.dev/) function that converts Speckle BIM models (primarily from Revit) into IFC 4.3 files using [ifcopenshell](https://ifcopenshell.org/).
|
||||
|
||||
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.
|
||||
## What It Does
|
||||
|
||||
## Getting started
|
||||
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:
|
||||
|
||||
1. Use this template repository to create a new repository in your own / organization's profile.
|
||||
1. Register the function
|
||||
- Correct IFC entity classification (IfcWall, IfcSlab, IfcColumn, etc.)
|
||||
- Tessellated geometry (IfcPolygonalFaceSet)
|
||||
- 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)
|
||||
|
||||
### Add new dependencies
|
||||
## Pipeline Overview
|
||||
|
||||
To add new Python package dependencies to the project, edit the `pyproject.toml` file:
|
||||
|
||||
**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
|
||||
]
|
||||
```
|
||||
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
|
||||
│ ├── Convert geometry → IfcPolygonalFaceSet
|
||||
│ ├── Create IFC element + placement
|
||||
│ ├── Write property sets
|
||||
│ └── 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/geometry.py` | Converts Speckle meshes to IfcPolygonalFaceSet geometry |
|
||||
| `utils/instances.py` | Handles InstanceProxy objects with shared geometry (IfcMappedItem) |
|
||||
| `utils/properties.py` | Writes IFC property sets from Revit parameters |
|
||||
| `utils/type_manager.py` | Creates and caches IfcTypeObjects (IfcWallType, etc.) |
|
||||
| `utils/materials.py` | Maps Speckle render materials to IfcSurfaceStyle colours |
|
||||
| `utils/writer.py` | Creates the IFC file scaffold and manages storey creation |
|
||||
| `utils/config.py` | Project/site/building name configuration |
|
||||
|
||||
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 with three lookup tables. 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. This is a direct Revit classification and maps unambiguously to IFC.
|
||||
|
||||
### 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_LightingFixtures` | `IfcLightFixture` |
|
||||
| `OST_Furniture` | `IfcFurnishingElement` |
|
||||
|
||||
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
|
||||
### Priority 2: `speckle_type` prefix
|
||||
|
||||
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`.
|
||||
For typed Speckle objects, the `speckle_type` string is matched. Exact match is tried first, then longest-prefix match.
|
||||
|
||||
## Getting Started with Creating Your Own Speckle Function
|
||||
Examples:
|
||||
| speckle_type | IFC Class |
|
||||
|---|---|
|
||||
| `Objects.BuiltElements.Wall` | `IfcWall` |
|
||||
| `Objects.BuiltElements.Floor` | `IfcSlab` |
|
||||
| `Objects.BuiltElements.Revit.RevitWall` | `IfcWall` |
|
||||
| `Objects.BuiltElements.Revit.RevitColumn` | `IfcColumn` |
|
||||
| `Objects.Geometry.Mesh` | `IfcBuildingElementProxy` |
|
||||
|
||||
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 3: 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` |
|
||||
| `Lighting Fixtures` | `IfcLightFixture` |
|
||||
|
||||
pip install --upgrade pip
|
||||
pip install .[dev]
|
||||
```
|
||||
### Priority 4: `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 3, 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`
|
||||
2. Scale vertices to millimetres based on the mesh's unit declaration
|
||||
3. Deduplicate vertices via snap grid (0.01mm tolerance) to avoid IFC GEM111 errors
|
||||
4. Build `IfcPolygonalFaceSet` with `IfcCartesianPointList3D` + `IfcIndexedPolygonalFace`
|
||||
5. Compute bounding box origin for `IfcLocalPlacement`, offset vertices relative to it
|
||||
|
||||
#### 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 (e.g. chairs, light fixtures, curtain wall panels).
|
||||
|
||||
### Building and running the Docker Container Image
|
||||
## Property Sets
|
||||
|
||||
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.
|
||||
The exporter writes property sets matching Revit's native IFC export structure:
|
||||
|
||||
#### Building the Docker Container Image
|
||||
| Property Set | Content |
|
||||
|---|---|
|
||||
| `Pset_<Entity>Common` | Standard IFC properties: Reference, IsExternal, LoadBearing, ThermalTransmittance |
|
||||
| `RVT_TypeParameters` | All Revit type parameters (written on the IfcTypeObject) |
|
||||
| `RVT_InstanceParameters` | All Revit instance parameters |
|
||||
| `RVT_Identity` | Family, Type, ElementId, BuiltInCategory |
|
||||
| `Qto_<MaterialName>` | Material quantities: area, volume, density |
|
||||
|
||||
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.
|
||||
|
||||
To build the Docker Container Image, you must have [Docker](https://docs.docker.com/get-docker/) installed.
|
||||
## Function Inputs
|
||||
|
||||
Once you have Docker running on your local machine:
|
||||
| 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 |
|
||||
|
||||
1. Open a terminal
|
||||
1. Navigate to the directory in which you cloned this repository
|
||||
1. Run the following command:
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
docker build -f ./Dockerfile -t speckle_automate_python_example .
|
||||
```
|
||||
|
||||
#### Running the Docker Container Image
|
||||
|
||||
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.
|
||||
|
||||
1. To then run the Docker Container Image, run the following command:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
Let's explain this in more detail:
|
||||
|
||||
`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.
|
||||
|
||||
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:
|
||||
|
||||
- `'{"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.
|
||||
| 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)
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
"""This module contains the function's business logic.
|
||||
from datetime import datetime
|
||||
|
||||
Use the automation_context module to wrap your function in an Automate context helper.
|
||||
"""
|
||||
import ifcopenshell.api
|
||||
|
||||
from utils.materials import MaterialManager
|
||||
from utils.traversal import traverse, print_tree
|
||||
from utils.mapper import classify, reset_caches as reset_mapper_caches
|
||||
from utils.geometry import mesh_to_ifc, get_display_instances, _make_placement
|
||||
from utils.instances import is_instance, instance_to_ifc, build_definition_map, print_instance_stats, get_definition_object
|
||||
from utils.properties import write_properties, write_common_properties, build_element_name, get_element_tag, get_ifc_guid, reset_caches as reset_props_caches
|
||||
from utils.writer import create_ifc_scaffold, StoreyManager
|
||||
from utils.type_manager import TypeManager
|
||||
|
||||
|
||||
SPATIAL_STRUCTURE_TYPES = {
|
||||
"IfcBuilding", "IfcBuildingStorey",
|
||||
"IfcExternalSpatialElement", "IfcSpatialZone",
|
||||
"IfcGrid", "IfcAnnotation",
|
||||
}
|
||||
|
||||
from pydantic import Field, SecretStr
|
||||
from speckle_automate import (
|
||||
@@ -10,9 +25,6 @@ from speckle_automate import (
|
||||
execute_automate_function,
|
||||
)
|
||||
|
||||
from flatten import flatten_base
|
||||
|
||||
|
||||
class FunctionInputs(AutomateBase):
|
||||
"""These are function author-defined values.
|
||||
|
||||
@@ -20,77 +32,237 @@ class FunctionInputs(AutomateBase):
|
||||
Please use the pydantic model schema to define your inputs:
|
||||
https://docs.pydantic.dev/latest/usage/models/
|
||||
"""
|
||||
|
||||
# An example of how to use secret values.
|
||||
whisper_message: SecretStr = Field(title="This is a secret message")
|
||||
forbidden_speckle_type: str = Field(
|
||||
title="Forbidden speckle type",
|
||||
description=(
|
||||
"If a object has the following speckle_type,"
|
||||
" it will be marked with an error."
|
||||
),
|
||||
file_name: str = Field(
|
||||
title="File Name",
|
||||
description="The name of the IFC file.",
|
||||
)
|
||||
IFC_PROJECT_NAME : str = Field(
|
||||
title="IFC Project Name",
|
||||
description="The name of the IFC project.",
|
||||
)
|
||||
IFC_SITE_NAME : str = Field(
|
||||
title="IFC Site Name",
|
||||
description="The name of the IFC site.",
|
||||
)
|
||||
IFC_BUILDING_NAME : str = Field(
|
||||
title="IFC Building Name",
|
||||
description="The name of the IFC building.",
|
||||
)
|
||||
|
||||
|
||||
def automate_function(
|
||||
automate_context: AutomationContext,
|
||||
function_inputs: FunctionInputs,
|
||||
) -> None:
|
||||
"""This is an example Speckle Automate function.
|
||||
print("=" * 60)
|
||||
print(" Speckle -> IFC4.3 Exporter")
|
||||
print("=" * 60)
|
||||
|
||||
Args:
|
||||
automate_context: A context-helper object that carries relevant information
|
||||
about the runtime context of this function.
|
||||
It gives access to the Speckle project data that triggered this run.
|
||||
It also has convenient methods for attaching results to the Speckle model.
|
||||
function_inputs: An instance object matching the defined schema.
|
||||
"""
|
||||
# The context provides a convenient way to receive the triggering version.
|
||||
version_root_object = automate_context.receive_version()
|
||||
# Reset caches from any previous run
|
||||
reset_props_caches()
|
||||
reset_mapper_caches()
|
||||
|
||||
objects_with_forbidden_speckle_type = [
|
||||
b
|
||||
for b in flatten_base(version_root_object)
|
||||
if b.speckle_type == function_inputs.forbidden_speckle_type
|
||||
]
|
||||
count = len(objects_with_forbidden_speckle_type)
|
||||
# ------------------------------------------------------------------ #
|
||||
# 1. Receive
|
||||
# ------------------------------------------------------------------ #
|
||||
base = automate_context.receive_version()
|
||||
scale = 1.0
|
||||
|
||||
if count > 0:
|
||||
# This is how a run is marked with a failure cause.
|
||||
automate_context.attach_error_to_objects(
|
||||
category="Forbidden speckle_type"
|
||||
f" ({function_inputs.forbidden_speckle_type})",
|
||||
affected_objects=objects_with_forbidden_speckle_type,
|
||||
message="This project should not contain the type: "
|
||||
f"{function_inputs.forbidden_speckle_type}",
|
||||
# Uncomment to debug object tree:
|
||||
# print_tree(base)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 2. Build definition map (for instance resolution)
|
||||
# ----------------------------------------------
|
||||
definition_map = build_definition_map(base)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 3. Set up IFC
|
||||
# ------------------------------------------------------------------ #
|
||||
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
|
||||
# ------------------------------------------------------------------ #
|
||||
material_manager = MaterialManager(ifc, base)
|
||||
type_manager = TypeManager(ifc)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 4. Traverse & export
|
||||
# ------------------------------------------------------------------ #
|
||||
total = 0
|
||||
no_geometry = 0
|
||||
skipped_spatial = 0
|
||||
instance_count = 0
|
||||
|
||||
print(f"\n📐 Processing elements (scale={scale})...\n")
|
||||
|
||||
for obj, level_name, category_name in traverse(base):
|
||||
|
||||
ifc_class = classify(obj, category_name)
|
||||
|
||||
if ifc_class in SPATIAL_STRUCTURE_TYPES:
|
||||
skipped_spatial += 1
|
||||
continue
|
||||
|
||||
# IfcSpace uses the Speckle object name (e.g. "Rooms - Live/Work Unit 507")
|
||||
# instead of Family:Type (which is "none:none" for Revit rooms)
|
||||
if ifc_class == "IfcSpace":
|
||||
name = getattr(obj, "name", None) or build_element_name(obj)
|
||||
else:
|
||||
name = build_element_name(obj)
|
||||
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,
|
||||
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
|
||||
|
||||
else:
|
||||
# ------------------------------------------------------------------ #
|
||||
# Path B: Normal DataObject — may have:
|
||||
# B1. Direct mesh geometry in displayValue
|
||||
# B2. Instance objects in displayValue (the hidden case!)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
# B1: Mesh geometry on the parent object
|
||||
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,
|
||||
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
|
||||
nested_instances = get_display_instances(obj)
|
||||
for inst in nested_instances:
|
||||
inst_rep, inst_placement = instance_to_ifc(
|
||||
ifc, body_context, inst, definition_map, scale=scale, material_manager=material_manager
|
||||
)
|
||||
if not inst_rep:
|
||||
no_geometry += 1
|
||||
continue
|
||||
inst_element = _create_element(
|
||||
ifc, ifc_class, name, inst_rep, inst_placement, storey,
|
||||
storey_manager=storey_manager,
|
||||
tag=get_element_tag(obj), guid=None,
|
||||
object_type=getattr(obj, "type", None),
|
||||
)
|
||||
write_properties(ifc, inst_element, obj, ifc_class=ifc_class, category_name=category_name)
|
||||
type_manager.assign(inst_element, obj, ifc_class)
|
||||
instance_count += 1
|
||||
total += 1
|
||||
|
||||
# Track if neither path produced geometry
|
||||
if not rep and not nested_instances:
|
||||
no_geometry += 1
|
||||
|
||||
if total % 100 == 0:
|
||||
print(f" ... processed {total} elements")
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 5. Write output
|
||||
# ------------------------------------------------------------------ #
|
||||
print("\n🔗 Flushing spatial containment...")
|
||||
storey_manager.flush()
|
||||
print("🔗 Flushing type relationships...")
|
||||
type_manager.flush()
|
||||
|
||||
file_name = function_inputs.file_name
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
ifc_filename = f"{file_name}_{timestamp}.ifc"
|
||||
|
||||
ifc.write(ifc_filename)
|
||||
print(f"\n💾 IFC file written: {ifc_filename}")
|
||||
try:
|
||||
automate_context.mark_run_success("Success! You can download the IF file below.")
|
||||
automate_context.store_file_result(f"./{ifc_filename}")
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Could not upload file result (network issue?): {e}")
|
||||
automate_context.mark_run_failed(f"Something went wrong when storing file result. Exception detail: {e}")
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" Export complete!")
|
||||
print(f" Total exported : {total}")
|
||||
print(f" Instances : {instance_count}")
|
||||
print(f" Without geometry : {no_geometry}")
|
||||
print(f" Skipped (spatial) : {skipped_spatial}")
|
||||
print(f" Storeys created : {storey_manager.count}")
|
||||
print(f" Levels : {', '.join(storey_manager.names)}")
|
||||
print_instance_stats()
|
||||
print(f"{'=' * 60}\n")
|
||||
|
||||
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:
|
||||
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
|
||||
except Exception:
|
||||
pass
|
||||
if rep and placement:
|
||||
element.Representation = ifc.createIfcProductDefinitionShape(
|
||||
Representations=(rep,)
|
||||
)
|
||||
automate_context.mark_run_failed(
|
||||
"Automation failed: "
|
||||
f"Found {count} object that have one of the forbidden speckle types: "
|
||||
f"{function_inputs.forbidden_speckle_type}"
|
||||
)
|
||||
|
||||
# Set the automation context view to the original model/version view
|
||||
# to show the offending objects.
|
||||
automate_context.set_context_view()
|
||||
|
||||
element.ObjectPlacement = placement
|
||||
elif placement:
|
||||
element.ObjectPlacement = placement
|
||||
else:
|
||||
automate_context.mark_run_success("No forbidden types found.")
|
||||
|
||||
# If the function generates file results, this is how it can be
|
||||
# attached to the Speckle project/model
|
||||
# automate_context.store_file_result("./report.pdf")
|
||||
|
||||
|
||||
def automate_function_without_inputs(automate_context: AutomationContext) -> None:
|
||||
"""A function example without inputs.
|
||||
|
||||
If your function does not need any input variables,
|
||||
besides what the automation context provides,
|
||||
the inputs argument can be omitted.
|
||||
"""
|
||||
pass
|
||||
element.ObjectPlacement = _make_placement(ifc, 0.0, 0.0, 0.0)
|
||||
|
||||
# Queue spatial assignment (batched flush at end for performance)
|
||||
# IfcSpace is a spatial structure element — must be decomposed (aggregated)
|
||||
# under its IfcBuildingStorey, not spatially contained.
|
||||
if storey_manager:
|
||||
if ifc_class in ("IfcSite", "IfcSpace"):
|
||||
storey_manager.queue_aggregate(storey, element)
|
||||
else:
|
||||
storey_manager.queue_contain(storey, element)
|
||||
return element
|
||||
|
||||
# make sure to call the function with the executor
|
||||
if __name__ == "__main__":
|
||||
|
||||
+5
-2
@@ -7,15 +7,18 @@ 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"]
|
||||
dependencies = ["specklepy==3.1.0",
|
||||
"ifcopenshell==0.8.4.post1",
|
||||
"python-dotenv>=1.0.0",]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"mypy==1.13.0",
|
||||
"pytest==7.4.4",
|
||||
"ruff==0.11.12",
|
||||
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -23,8 +23,10 @@ def test_function_run(
|
||||
automation_context,
|
||||
automate_function,
|
||||
FunctionInputs(
|
||||
forbidden_speckle_type="None",
|
||||
whisper_message=SecretStr("testing automatically"),
|
||||
file_name="test_output.ifc",
|
||||
IFC_PROJECT_NAME = "Speckle Export",
|
||||
IFC_SITE_NAME = "Site",
|
||||
IFC_BUILDING_NAME = "Building"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
# =============================================================================
|
||||
# geometry.py
|
||||
# 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,
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 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 build_ifc_facesets(ifc, verts_scaled: list, face_groups: list) -> list:
|
||||
"""
|
||||
Build a list of IfcPolygonalFaceSet 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).
|
||||
|
||||
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 IfcPolygonalFaceSet (typically one, empty on failure).
|
||||
"""
|
||||
snap_to_idx = {} # snap_key → 0-based index in deduped_verts
|
||||
deduped_verts = [] # [[x, y, z], ...] — lists for direct IFC use
|
||||
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:
|
||||
remapped = []
|
||||
seen_snaps = set()
|
||||
degenerate = False
|
||||
|
||||
for i in indices:
|
||||
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_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(remapped) < 3:
|
||||
continue
|
||||
valid_faces.append(remapped)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not valid_faces or not deduped_verts:
|
||||
return []
|
||||
|
||||
# Build IFC entities
|
||||
try:
|
||||
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:
|
||||
return []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Safe data access helpers
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _get(obj, key, default=None):
|
||||
"""
|
||||
Safe access for specklepy Base objects.
|
||||
Tries attribute access first, then bracket access.
|
||||
"""
|
||||
try:
|
||||
val = getattr(obj, key, None)
|
||||
if val is not None:
|
||||
return val
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
val = obj[key]
|
||||
if val is not None:
|
||||
return val
|
||||
except Exception:
|
||||
pass
|
||||
return default
|
||||
|
||||
|
||||
def unwrap_chunks(raw) -> list:
|
||||
"""
|
||||
Flatten a Speckle data array into a plain Python list of numbers.
|
||||
|
||||
Handles two cases:
|
||||
1. Already flat list of numbers (after specklepy receive deserializes)
|
||||
→ returned as-is (fast path)
|
||||
2. List of DataChunk objects (raw from server before deserialization)
|
||||
→ 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
|
||||
if isinstance(item, (int, float)):
|
||||
result.append(item)
|
||||
continue
|
||||
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:
|
||||
try:
|
||||
result.extend(list(item))
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
def _resolve_scale(obj, stream_scale: float) -> float:
|
||||
"""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)
|
||||
return stream_scale
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Mesh extraction
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _is_mesh(item) -> bool:
|
||||
"""
|
||||
Detect if a specklepy object is a Mesh.
|
||||
Uses speckle_type string — more reliable than hasattr on Base objects.
|
||||
"""
|
||||
if item is None:
|
||||
return False
|
||||
speckle_type = _get(item, "speckle_type") or ""
|
||||
if "Mesh" in speckle_type:
|
||||
return True
|
||||
# Fallback: has both vertices and faces data
|
||||
verts = _get(item, "vertices")
|
||||
faces = _get(item, "faces")
|
||||
return verts is not None and faces is not None
|
||||
|
||||
|
||||
def get_display_meshes(obj: Base) -> list:
|
||||
"""
|
||||
Extract all Mesh objects from a DataObject's displayValue.
|
||||
displayValue is always an array per the Speckle schema docs.
|
||||
"""
|
||||
meshes = []
|
||||
|
||||
for key in ["displayValue", "@displayValue"]:
|
||||
display = _get(obj, key)
|
||||
if display is None:
|
||||
continue
|
||||
items = display if isinstance(display, list) else [display]
|
||||
for item in items:
|
||||
if _is_mesh(item):
|
||||
meshes.append(item)
|
||||
if meshes:
|
||||
break # found meshes, don't check @displayValue too
|
||||
|
||||
# Fallback: object itself is a Mesh
|
||||
if not meshes and _is_mesh(obj):
|
||||
speckle_type = _get(obj, "speckle_type") or ""
|
||||
if "Mesh" in speckle_type:
|
||||
meshes.append(obj)
|
||||
|
||||
return meshes
|
||||
|
||||
|
||||
def get_display_instances(obj: Base) -> list:
|
||||
"""
|
||||
Extract InstanceProxy objects from a DataObject's displayValue.
|
||||
|
||||
Per the official speckleifc converter, every IFC element's displayValue
|
||||
contains InstanceProxy objects (not raw meshes). Each InstanceProxy has:
|
||||
- transform: 16-float row-major matrix, translation in metres
|
||||
- definitionId: "DEFINITION:{meshAppId}" string
|
||||
- units: "m"
|
||||
|
||||
Raw meshes do NOT appear in displayValue in IFC→Speckle exports.
|
||||
"""
|
||||
instances = []
|
||||
for key in ["displayValue", "@displayValue"]:
|
||||
display = _get(obj, key)
|
||||
if display is None:
|
||||
continue
|
||||
items = display if isinstance(display, list) else [display]
|
||||
for item in items:
|
||||
if item is None:
|
||||
continue
|
||||
transform = _get(item, "transform")
|
||||
definition_id = _get(item, "definitionId")
|
||||
if transform is not None and definition_id is not None:
|
||||
instances.append(item)
|
||||
if instances:
|
||||
break
|
||||
return instances
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Face decoding
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
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
|
||||
"""
|
||||
decoded = []
|
||||
i = 0
|
||||
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 > total:
|
||||
break
|
||||
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
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Bounding box + placement
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def compute_origin(flat_verts: list) -> tuple:
|
||||
"""
|
||||
Compute placement origin from scaled vertex list (mm).
|
||||
X, Y = bounding box centroid
|
||||
Z = minimum Z (bottom face of element — more natural for IFC)
|
||||
Single-pass to avoid creating 3 sliced copies of a large list.
|
||||
"""
|
||||
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)."""
|
||||
shared = _get_shared(ifc)
|
||||
origin = ifc.createIfcCartesianPoint([x, y, z])
|
||||
a2p = ifc.createIfcAxis2Placement3D(origin, shared["z_axis"], shared["x_axis"])
|
||||
return ifc.createIfcLocalPlacement(PlacementRelTo=None, RelativePlacement=a2p)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Main conversion
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def mesh_to_ifc(
|
||||
ifc: ifcopenshell.file,
|
||||
body_context,
|
||||
obj: Base,
|
||||
scale: float = 0.001,
|
||||
material_manager=None,
|
||||
) -> tuple:
|
||||
"""
|
||||
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
|
||||
|
||||
obj_scale = _resolve_scale(obj, scale)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Pass 1: unpack vertices once per mesh, collect all scaled coords
|
||||
# to compute world origin. Cache (verts, ms) for Pass 2.
|
||||
# ------------------------------------------------------------------ #
|
||||
mesh_cache = [] # [(verts_list, ms, scaled)] or None per mesh
|
||||
all_scaled = []
|
||||
for mesh in meshes:
|
||||
raw_verts = _get(mesh, "vertices") or []
|
||||
verts = unwrap_chunks(raw_verts if isinstance(raw_verts, list) else list(raw_verts))
|
||||
if not verts:
|
||||
mesh_cache.append(None)
|
||||
continue
|
||||
ms = _resolve_scale(mesh, obj_scale)
|
||||
# Pre-scale vertices once, reuse in Pass 2
|
||||
scaled = [float(v) * ms for v in verts]
|
||||
mesh_cache.append((verts, ms, scaled))
|
||||
all_scaled.extend(scaled)
|
||||
|
||||
if not all_scaled:
|
||||
return None, None
|
||||
|
||||
ox, oy, oz = compute_origin(all_scaled)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Pass 2: one faceset per mesh — reuse cached verts, only unpack faces
|
||||
# ------------------------------------------------------------------ #
|
||||
geom_items = []
|
||||
|
||||
for mesh, cached in zip(meshes, mesh_cache):
|
||||
if cached is None:
|
||||
continue
|
||||
verts, ms, scaled = cached
|
||||
raw_faces = _get(mesh, "faces") or []
|
||||
faces_raw = unwrap_chunks(raw_faces if isinstance(raw_faces, list) else list(raw_faces))
|
||||
|
||||
if not faces_raw:
|
||||
continue
|
||||
|
||||
try:
|
||||
face_groups = decode_faces(faces_raw)
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Face decode error: {e}")
|
||||
continue
|
||||
|
||||
# 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_facesets = build_ifc_facesets(ifc, verts_scaled, face_groups)
|
||||
|
||||
if not mesh_facesets:
|
||||
continue
|
||||
|
||||
# Apply material style to every faceset of this mesh
|
||||
if material_manager:
|
||||
mesh_app_id = _get(mesh, "applicationId")
|
||||
if mesh_app_id:
|
||||
for fs in mesh_facesets:
|
||||
material_manager.apply_to_item(fs, str(mesh_app_id))
|
||||
|
||||
geom_items.extend(mesh_facesets)
|
||||
|
||||
if not geom_items:
|
||||
return None, None
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Assemble IfcShapeRepresentation + IfcLocalPlacement
|
||||
# ------------------------------------------------------------------ #
|
||||
rep = ifc.createIfcShapeRepresentation(
|
||||
ContextOfItems=body_context,
|
||||
RepresentationIdentifier="Body",
|
||||
RepresentationType="Tessellation",
|
||||
Items=geom_items,
|
||||
)
|
||||
placement = _make_placement(ifc, ox, oy, oz)
|
||||
|
||||
return rep, placement
|
||||
@@ -0,0 +1,469 @@
|
||||
# =============================================================================
|
||||
# instances.py
|
||||
# Handles Speckle InstanceProxy objects from both:
|
||||
#
|
||||
# FORMAT A — Revit connector (our actual use case):
|
||||
# _units = "mm"
|
||||
# transform = 16 floats, row-major, translation in MM
|
||||
# definitionId = 64-char uppercase hex hash (matches object id[:32] in tree)
|
||||
# The definition object lives somewhere in the object tree.
|
||||
#
|
||||
# FORMAT B — speckleifc IFC→Speckle converter:
|
||||
# units = "m"
|
||||
# transform = 16 floats, row-major, translation in METRES
|
||||
# definitionId = "DEFINITION:{meshAppId}"
|
||||
# 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 math
|
||||
from specklepy.objects.base import Base
|
||||
from utils.geometry import _get, unwrap_chunks, decode_faces, _UNIT_SCALES, build_ifc_facesets, _get_shared
|
||||
|
||||
|
||||
def is_instance(obj) -> bool:
|
||||
"""Returns True if this object is a Speckle InstanceProxy."""
|
||||
return _get(obj, "transform") is not None and _get(obj, "definitionId") is not None
|
||||
|
||||
|
||||
def _is_ifc_format(definition_id: str) -> bool:
|
||||
"""True if this is speckleifc format (definitionId starts with 'DEFINITION:')."""
|
||||
return definition_id.startswith("DEFINITION:")
|
||||
|
||||
|
||||
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 = {}
|
||||
by_app_id = {}
|
||||
ifc_proxies = {}
|
||||
ifc_meshes = {}
|
||||
|
||||
# --- Walk entire tree for Revit format ---
|
||||
_collect_all(root, by_id, by_app_id, depth=0)
|
||||
|
||||
# --- Extract speckleifc structures for IFC format ---
|
||||
proxies_raw = _get(root, "instanceDefinitionProxies")
|
||||
if proxies_raw:
|
||||
for proxy in (proxies_raw if isinstance(proxies_raw, list) else [proxies_raw]):
|
||||
app_id = _get(proxy, "applicationId")
|
||||
if app_id:
|
||||
ifc_proxies[app_id] = proxy # original case (for IFC format)
|
||||
ifc_proxies[app_id.lower()] = proxy # lowercase (for Revit format)
|
||||
|
||||
elements = _get(root, "elements") or _get(root, "@elements") or []
|
||||
for child in (elements if isinstance(elements, list) else []):
|
||||
if (_get(child, "name") or "") == "definitionGeometry":
|
||||
geom_elements = _get(child, "elements") or _get(child, "@elements") or []
|
||||
for mesh in (geom_elements if isinstance(geom_elements, list) else []):
|
||||
mesh_app_id = _get(mesh, "applicationId")
|
||||
if mesh_app_id:
|
||||
ifc_meshes[mesh_app_id] = mesh
|
||||
|
||||
print(f" Objects indexed by id: {len(by_id)}")
|
||||
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)}")
|
||||
|
||||
return {
|
||||
"by_id": by_id,
|
||||
"by_app_id": by_app_id,
|
||||
"ifc_proxies": ifc_proxies,
|
||||
"ifc_meshes": ifc_meshes,
|
||||
}
|
||||
|
||||
|
||||
def _collect_all(obj, by_id: dict, by_app_id: dict, depth: int):
|
||||
if obj is None or depth > 25:
|
||||
return
|
||||
|
||||
obj_id = _get(obj, "id")
|
||||
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)
|
||||
if len(key) == 32:
|
||||
by_id[key] = obj
|
||||
elif len(key) > 32:
|
||||
by_id[key[:32]] = obj
|
||||
|
||||
app_id = _get(obj, "applicationId")
|
||||
if app_id and isinstance(app_id, str):
|
||||
by_app_id[app_id.lower()] = obj
|
||||
|
||||
for key in ["elements", "@elements", "displayValue", "@displayValue",
|
||||
"objects", "@objects", "definition", "@definition"]:
|
||||
try:
|
||||
children = obj[key]
|
||||
if children is None:
|
||||
continue
|
||||
if not isinstance(children, list):
|
||||
children = [children]
|
||||
for child in children:
|
||||
_collect_all(child, by_id, by_app_id, depth + 1)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
def _get_revit_meshes(definition_id: str, definition_map: dict) -> list:
|
||||
"""
|
||||
Revit format:
|
||||
definitionId (64-char hex) → InstanceDefinitionProxy.applicationId
|
||||
proxy.objects[0] is a UUID applicationId → find mesh by applicationId
|
||||
"""
|
||||
from utils.geometry import get_display_meshes
|
||||
|
||||
# Step 1: find the InstanceDefinitionProxy by its applicationId (case-insensitive)
|
||||
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 []
|
||||
|
||||
# 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
|
||||
by_app_id = definition_map.get("by_app_id", {})
|
||||
meshes = []
|
||||
for oid in object_ids:
|
||||
obj = by_app_id.get(str(oid).lower())
|
||||
if obj is not None:
|
||||
# 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
|
||||
meshes.append(obj)
|
||||
return meshes
|
||||
|
||||
|
||||
def _get_ifc_meshes(definition_id: str, definition_map: dict) -> list:
|
||||
"""
|
||||
IFC format: definitionId = "DEFINITION:224058_mat0"
|
||||
Look up proxy → objects list → meshes from ifc_meshes dict.
|
||||
"""
|
||||
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 []
|
||||
|
||||
object_ids = _get(proxy, "objects") or []
|
||||
result = []
|
||||
for oid in (object_ids if isinstance(object_ids, list) else [object_ids]):
|
||||
mesh = ifc_meshes.get(str(oid))
|
||||
if mesh is not None:
|
||||
result.append(mesh)
|
||||
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).
|
||||
"""
|
||||
for key in ["units", "_units"]:
|
||||
try:
|
||||
units = obj[key]
|
||||
if units and isinstance(units, str):
|
||||
s = _UNIT_SCALES.get(units.lower().strip())
|
||||
if s is not None:
|
||||
return s
|
||||
except Exception:
|
||||
pass
|
||||
return stream_scale
|
||||
|
||||
|
||||
# Stats
|
||||
_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 = {}
|
||||
|
||||
# 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,
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# IfcRepresentationMap builder — geometry created once per definition
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _build_rep_map(ifc, body_context, meshes: list, ifc_format: bool,
|
||||
material_manager=None):
|
||||
"""
|
||||
Build an IfcRepresentationMap from definition meshes.
|
||||
Geometry is in local coordinates (mm, no instance transform applied).
|
||||
Returns IfcRepresentationMap or None if no valid geometry.
|
||||
"""
|
||||
geom_items = []
|
||||
|
||||
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(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
|
||||
|
||||
# Scale vertices once and cache the result
|
||||
verts_local = [float(v) * ms for v in verts]
|
||||
|
||||
if mesh_id:
|
||||
_mesh_data_cache[mesh_id] = (verts_local, face_groups)
|
||||
|
||||
mesh_facesets = build_ifc_facesets(ifc, verts_local, face_groups)
|
||||
|
||||
if not mesh_facesets:
|
||||
continue
|
||||
|
||||
# Apply material style to each faceset
|
||||
if material_manager:
|
||||
mesh_app_id = _get(mesh, "applicationId")
|
||||
if mesh_app_id:
|
||||
for fs in mesh_facesets:
|
||||
material_manager.apply_to_item(fs, str(mesh_app_id))
|
||||
|
||||
geom_items.extend(mesh_facesets)
|
||||
|
||||
if not geom_items:
|
||||
return None
|
||||
|
||||
# Mapping origin = identity (local coords origin) — reuse shared origin
|
||||
shared = _get_shared(ifc)
|
||||
a2p = ifc.createIfcAxis2Placement3D(shared["origin_0"], None, None)
|
||||
|
||||
# The mapped representation holds the actual geometry
|
||||
mapped_rep = ifc.createIfcShapeRepresentation(
|
||||
ContextOfItems=body_context,
|
||||
RepresentationIdentifier="Body",
|
||||
RepresentationType="Tessellation",
|
||||
Items=geom_items,
|
||||
)
|
||||
|
||||
return ifc.createIfcRepresentationMap(a2p, mapped_rep)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Transform → IfcCartesianTransformationOperator3D
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _vec_magnitude(x, y, z):
|
||||
return math.sqrt(x*x + y*y + z*z)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
The matrix acts as: p' = M * p + translation, where M rows are:
|
||||
row0 = (t[0], t[1], t[2])
|
||||
row1 = (t[4], t[5], t[6])
|
||||
row2 = (t[8], t[9], t[10])
|
||||
|
||||
IfcCartesianTransformationOperator axes represent the COLUMNS of M:
|
||||
Axis1 = column 0 = where local X maps → (t[0], t[4], t[8])
|
||||
Axis2 = column 1 = where local Y maps → (t[1], t[5], t[9])
|
||||
Axis3 = column 2 = where local Z maps → (t[2], t[6], t[10])
|
||||
|
||||
Returns the IFC entity, or None if the transform is degenerate.
|
||||
"""
|
||||
# Extract COLUMNS of the 3x3 rotation/scale sub-matrix
|
||||
ax1 = (float(t[0]), float(t[4]), float(t[8])) # column 0: X-axis direction
|
||||
ax2 = (float(t[1]), float(t[5]), float(t[9])) # column 1: Y-axis direction
|
||||
ax3 = (float(t[2]), float(t[6]), float(t[10])) # column 2: Z-axis direction
|
||||
|
||||
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
|
||||
d1 = ifc.createIfcDirection([ax1[0]/s1, ax1[1]/s1, ax1[2]/s1])
|
||||
d2 = ifc.createIfcDirection([ax2[0]/s2, ax2[1]/s2, ax2[2]/s2])
|
||||
d3 = ifc.createIfcDirection([ax3[0]/s3, ax3[1]/s3, ax3[2]/s3])
|
||||
|
||||
# Translation, scaled to mm
|
||||
tx = float(t[3]) * ts
|
||||
ty = float(t[7]) * ts
|
||||
tz = float(t[11]) * ts
|
||||
origin = ifc.createIfcCartesianPoint([tx, ty, tz])
|
||||
|
||||
# Use non-uniform variant to handle mirrors and non-uniform scale
|
||||
return ifc.createIfcCartesianTransformationOperator3DnonUniform(
|
||||
d1, # Axis1
|
||||
d2, # Axis2
|
||||
origin, # LocalOrigin
|
||||
s1, # Scale
|
||||
d3, # Axis3
|
||||
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).
|
||||
|
||||
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:
|
||||
return None, None
|
||||
t = list(transform_raw)
|
||||
if len(t) != 16:
|
||||
return None, None
|
||||
|
||||
definition_id = _get(obj, "definitionId") or ""
|
||||
ifc_format = _is_ifc_format(definition_id)
|
||||
|
||||
# Translation scale: IFC format transform is in metres → convert to mm
|
||||
# Revit format transform is already in mm (same as IFC file units)
|
||||
ts = 1000.0 if ifc_format else _resolve_instance_scale(obj, scale)
|
||||
|
||||
# Identity placement (transform is encoded in the MappedItem) — shared across all instances
|
||||
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]
|
||||
|
||||
# --- Get or build IfcRepresentationMap (cached per definition_id) ---
|
||||
if definition_id not in _rep_map_cache:
|
||||
if ifc_format:
|
||||
meshes = _get_ifc_meshes(definition_id, definition_map)
|
||||
else:
|
||||
meshes = _get_revit_meshes(definition_id, definition_map)
|
||||
|
||||
if not meshes:
|
||||
_stats["not_found"] += 1
|
||||
_rep_map_cache[definition_id] = None
|
||||
return None, placement
|
||||
|
||||
_stats["found"] += 1
|
||||
_rep_map_cache[definition_id] = _build_rep_map(
|
||||
ifc, body_context, meshes, ifc_format, material_manager
|
||||
)
|
||||
else:
|
||||
# Track stats even for cached definitions
|
||||
if _rep_map_cache[definition_id] is not None:
|
||||
_stats["found"] += 1
|
||||
else:
|
||||
_stats["not_found"] += 1
|
||||
|
||||
rep_map = _rep_map_cache[definition_id]
|
||||
if rep_map is None:
|
||||
return None, placement
|
||||
|
||||
# --- 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="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
|
||||
|
||||
ifc_proxies = definition_map.get("ifc_proxies", {})
|
||||
proxy = ifc_proxies.get(definition_id) or ifc_proxies.get(definition_id.lower())
|
||||
if proxy is None:
|
||||
return None
|
||||
|
||||
object_ids = _get(proxy, "objects") or []
|
||||
if not isinstance(object_ids, list):
|
||||
object_ids = list(object_ids)
|
||||
if not object_ids:
|
||||
return None
|
||||
|
||||
by_app_id = definition_map.get("by_app_id", {})
|
||||
source = by_app_id.get(str(object_ids[0]).lower())
|
||||
return source
|
||||
|
||||
|
||||
def 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")
|
||||
|
||||
|
||||
def reset_caches():
|
||||
"""Reset module-level caches (call at start of each export run)."""
|
||||
_mesh_data_cache.clear()
|
||||
_rep_map_cache.clear()
|
||||
_identity_placement_cache.clear()
|
||||
_stats["found"] = 0
|
||||
_stats["not_found"] = 0
|
||||
+312
@@ -0,0 +1,312 @@
|
||||
# =============================================================================
|
||||
# mapper.py
|
||||
# Maps Speckle objects → IFC entity classes.
|
||||
#
|
||||
# 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
|
||||
#
|
||||
# builtInCategory values: https://www.revitapidocs.com/2019/ba1c5b30-242f-5fdc-8ea9-ec3b61e6e722.htm
|
||||
# =============================================================================
|
||||
|
||||
|
||||
# --- OST_ BuiltInCategory → IFC class (primary lookup) ---
|
||||
BUILTIN_CATEGORY_MAP: dict[str, str] = {
|
||||
# Architectural - Walls
|
||||
"OST_Walls": "IfcWall",
|
||||
"OST_CurtainWallPanels": "IfcCurtainWall",
|
||||
"OST_CurtainWallMullions": "IfcMember",
|
||||
"OST_Fascia": "IfcCovering",
|
||||
"OST_Gutters": "IfcPipeSegment",
|
||||
|
||||
# Architectural - Floors / Roofs / Ceilings
|
||||
"OST_Floors": "IfcSlab",
|
||||
"OST_Roofs": "IfcRoof",
|
||||
"OST_Ceilings": "IfcCovering",
|
||||
"OST_RoofSoffit": "IfcCovering",
|
||||
|
||||
# Architectural - Doors / Windows / Openings
|
||||
"OST_Doors": "IfcDoor",
|
||||
"OST_Windows": "IfcWindow",
|
||||
"OST_CurtainWallFamilies": "IfcCurtainWall",
|
||||
"OST_Skylights": "IfcWindow",
|
||||
|
||||
# Architectural - Stairs / Ramps / Railings
|
||||
"OST_Stairs": "IfcStair",
|
||||
"OST_StairsRailing": "IfcRailing",
|
||||
"OST_RailingTopRail": "IfcRailing",
|
||||
"OST_Ramps": "IfcRamp",
|
||||
"OST_StairsLandings": "IfcStairFlight",
|
||||
"OST_StairsRuns": "IfcStairFlight",
|
||||
"OST_StairsSupports": "IfcMember",
|
||||
|
||||
# Architectural - Rooms / Spaces
|
||||
"OST_Rooms": "IfcSpace",
|
||||
"OST_Parking": "IfcSpace",
|
||||
"OST_Areas": "IfcSpace",
|
||||
|
||||
# Architectural - Furniture / Casework
|
||||
"OST_Furniture": "IfcFurnishingElement",
|
||||
"OST_FurnitureSystems": "IfcFurnishingElement",
|
||||
"OST_Casework": "IfcFurnishingElement",
|
||||
"OST_SpecialtyEquipment": "IfcFurnishingElement",
|
||||
"OST_Entourage": "IfcFurnishingElement",
|
||||
|
||||
# Structural
|
||||
"OST_StructuralColumns": "IfcColumn",
|
||||
"OST_Columns": "IfcColumn",
|
||||
"OST_StructuralFraming": "IfcBeam",
|
||||
"OST_StructuralFoundation": "IfcFooting",
|
||||
"OST_FoundationSlab": "IfcSlab",
|
||||
"OST_StructuralStiffener": "IfcMember",
|
||||
"OST_StructuralTruss": "IfcMember",
|
||||
"OST_StructuralConnectionModel": "IfcMechanicalFastener",
|
||||
"OST_Rebar": "IfcReinforcingBar",
|
||||
"OST_FabricAreas": "IfcReinforcingMesh",
|
||||
"OST_FabricReinforcement": "IfcReinforcingMesh",
|
||||
|
||||
# MEP - HVAC
|
||||
"OST_DuctCurves": "IfcDuctSegment",
|
||||
"OST_DuctFitting": "IfcDuctFitting",
|
||||
"OST_DuctAccessory": "IfcDuctSegment",
|
||||
"OST_DuctTerminal": "IfcAirTerminal",
|
||||
"OST_FlexDuctCurves": "IfcDuctSegment",
|
||||
"OST_MechanicalEquipment": "IfcUnitaryEquipment",
|
||||
"OST_AirTerminal": "IfcAirTerminal",
|
||||
|
||||
# MEP - Plumbing
|
||||
"OST_PipeCurves": "IfcPipeSegment",
|
||||
"OST_PipeFitting": "IfcPipeFitting",
|
||||
"OST_PipeAccessory": "IfcPipeSegment",
|
||||
"OST_FlexPipeCurves": "IfcPipeSegment",
|
||||
"OST_PlumbingFixtures": "IfcSanitaryTerminal",
|
||||
"OST_Sprinklers": "IfcFireSuppressionTerminal",
|
||||
|
||||
# MEP - Electrical
|
||||
"OST_ElectricalEquipment": "IfcElectricDistributionBoard",
|
||||
"OST_ElectricalFixtures": "IfcElectricAppliance",
|
||||
"OST_LightingFixtures": "IfcLightFixture",
|
||||
"OST_LightingDevices": "IfcLightFixture",
|
||||
"OST_CableTray": "IfcCableCarrierSegment",
|
||||
"OST_CableTrayFitting": "IfcCableCarrierFitting",
|
||||
"OST_Conduit": "IfcCableCarrierSegment",
|
||||
"OST_ConduitFitting": "IfcCableCarrierFitting",
|
||||
"OST_CommunicationDevices": "IfcElectricAppliance",
|
||||
"OST_DataDevices": "IfcElectricAppliance",
|
||||
"OST_FireAlarmDevices": "IfcAlarm",
|
||||
"OST_SecurityDevices": "IfcAlarm",
|
||||
"OST_NurseCallDevices": "IfcElectricAppliance",
|
||||
|
||||
# Site / Civil
|
||||
"OST_Site": "IfcSite",
|
||||
"OST_Topography": "IfcGeographicElement",
|
||||
"OST_Toposolid": "IfcGeographicElement",
|
||||
"OST_Roads": "IfcRoad",
|
||||
"OST_Hardscape": "IfcPavement",
|
||||
"OST_Planting": "IfcGeographicElement",
|
||||
"OST_SiteSurface": "IfcGeographicElement",
|
||||
|
||||
# Generic / Annotation (skip or proxy)
|
||||
"OST_GenericModel": "IfcBuildingElementProxy",
|
||||
"OST_Mass": "IfcBuildingElementProxy",
|
||||
"OST_DetailComponents": "IfcAnnotation",
|
||||
"OST_Lines": "IfcAnnotation",
|
||||
"OST_Grids": "IfcGrid",
|
||||
"OST_Levels": "IfcBuildingStorey",
|
||||
"OST_Views": "IfcAnnotation",
|
||||
}
|
||||
|
||||
|
||||
# --- speckle_type → IFC class (secondary lookup) ---
|
||||
SPECKLE_TYPE_MAP: dict[str, str] = {
|
||||
"Objects.BuiltElements.Wall": "IfcWall",
|
||||
"Objects.BuiltElements.Floor": "IfcSlab",
|
||||
"Objects.BuiltElements.Roof": "IfcRoof",
|
||||
"Objects.BuiltElements.Column": "IfcColumn",
|
||||
"Objects.BuiltElements.Beam": "IfcBeam",
|
||||
"Objects.BuiltElements.Brace": "IfcMember",
|
||||
"Objects.BuiltElements.Duct": "IfcDuctSegment",
|
||||
"Objects.BuiltElements.Pipe": "IfcPipeSegment",
|
||||
"Objects.BuiltElements.Wire": "IfcCableCarrierSegment",
|
||||
"Objects.BuiltElements.Opening": "IfcOpeningElement",
|
||||
"Objects.BuiltElements.Room": "IfcSpace",
|
||||
"Objects.BuiltElements.Ceiling": "IfcCovering",
|
||||
"Objects.BuiltElements.Stair": "IfcStair",
|
||||
"Objects.BuiltElements.Ramp": "IfcRamp",
|
||||
"Objects.BuiltElements.Foundation": "IfcFooting",
|
||||
"Objects.BuiltElements.Grid": "IfcGrid",
|
||||
"Objects.BuiltElements.Level": "IfcBuildingStorey",
|
||||
"Objects.BuiltElements.Revit.RevitWall": "IfcWall",
|
||||
"Objects.BuiltElements.Revit.RevitFloor": "IfcSlab",
|
||||
"Objects.BuiltElements.Revit.RevitRoof": "IfcRoof",
|
||||
"Objects.BuiltElements.Revit.RevitColumn": "IfcColumn",
|
||||
"Objects.BuiltElements.Revit.RevitBeam": "IfcBeam",
|
||||
"Objects.BuiltElements.Revit.RevitBrace": "IfcMember",
|
||||
"Objects.BuiltElements.Revit.RevitDuct": "IfcDuctSegment",
|
||||
"Objects.BuiltElements.Revit.RevitPipe": "IfcPipeSegment",
|
||||
"Objects.BuiltElements.Revit.RevitRoom": "IfcSpace",
|
||||
"Objects.BuiltElements.Revit.RevitStair": "IfcStair",
|
||||
"Objects.BuiltElements.Revit.RevitRailing": "IfcRailing",
|
||||
"Objects.BuiltElements.Revit.RevitCeiling": "IfcCovering",
|
||||
"Objects.BuiltElements.Revit.RevitTopography": "IfcGeographicElement",
|
||||
"Objects.BuiltElements.Revit.RevitElementType": "IfcBuildingElementProxy",
|
||||
"Objects.Geometry.Mesh": "IfcBuildingElementProxy",
|
||||
"Objects.Geometry.Brep": "IfcBuildingElementProxy",
|
||||
}
|
||||
|
||||
# --- Display category name → IFC class (tertiary fallback) ---
|
||||
CATEGORY_MAP: dict[str, str] = {
|
||||
"Walls": "IfcWall",
|
||||
"Floors": "IfcSlab",
|
||||
"Roofs": "IfcRoof",
|
||||
"Structural Columns": "IfcColumn",
|
||||
"Columns": "IfcColumn",
|
||||
"Structural Framing": "IfcBeam",
|
||||
"Beams": "IfcBeam",
|
||||
"Ducts": "IfcDuctSegment",
|
||||
"Pipes": "IfcPipeSegment",
|
||||
"Conduits": "IfcCableCarrierSegment",
|
||||
"Cable Trays": "IfcCableCarrierSegment",
|
||||
"Rooms": "IfcSpace",
|
||||
"Spaces": "IfcSpace",
|
||||
"Ceilings": "IfcCovering",
|
||||
"Stairs": "IfcStair",
|
||||
"Ramps": "IfcRamp",
|
||||
"Railings": "IfcRailing",
|
||||
"Top Rails": "IfcRailing",
|
||||
"Curtain Panels": "IfcCurtainWall",
|
||||
"Curtain Wall Mullions": "IfcMember",
|
||||
"Doors": "IfcDoor",
|
||||
"Windows": "IfcWindow",
|
||||
"Furniture": "IfcFurnishingElement",
|
||||
"Furniture Systems": "IfcFurnishingElement",
|
||||
"Casework": "IfcFurnishingElement",
|
||||
"Plumbing Fixtures": "IfcSanitaryTerminal",
|
||||
"Electrical Fixtures": "IfcElectricAppliance",
|
||||
"Lighting Fixtures": "IfcLightFixture",
|
||||
"Mechanical Equipment": "IfcUnitaryEquipment",
|
||||
"Electrical Equipment": "IfcElectricDistributionBoard",
|
||||
"Structural Foundations": "IfcFooting",
|
||||
"Foundation Slabs": "IfcSlab",
|
||||
"Topography": "IfcGeographicElement",
|
||||
"Toposolid": "IfcGeographicElement",
|
||||
"Planting": "IfcGeographicElement",
|
||||
"Site": "IfcSite",
|
||||
"Parking": "IfcSpace",
|
||||
"Generic Models": "IfcBuildingElementProxy",
|
||||
"Mass": "IfcBuildingElementProxy",
|
||||
"Specialty Equipment": "IfcFurnishingElement",
|
||||
}
|
||||
|
||||
|
||||
_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. Cached per object.
|
||||
"""
|
||||
oid = id(obj)
|
||||
if oid in _bic_cache:
|
||||
return _bic_cache[oid]
|
||||
result = None
|
||||
try:
|
||||
props = getattr(obj, "properties", None)
|
||||
if props is None:
|
||||
try:
|
||||
props = obj["properties"]
|
||||
except Exception:
|
||||
pass
|
||||
if props is not None:
|
||||
val = getattr(props, "builtInCategory", None)
|
||||
if val is None:
|
||||
try:
|
||||
val = props["builtInCategory"]
|
||||
except Exception:
|
||||
pass
|
||||
if val and isinstance(val, str):
|
||||
result = val.strip()
|
||||
except Exception:
|
||||
pass
|
||||
_bic_cache[oid] = result
|
||||
return result
|
||||
|
||||
|
||||
# Pre-computed: sorted prefixes longest-first for early exit on prefix match
|
||||
_SPECKLE_PREFIXES: list[tuple[str, str]] = sorted(
|
||||
SPECKLE_TYPE_MAP.items(), key=lambda x: len(x[0]), reverse=True
|
||||
)
|
||||
|
||||
# Pre-computed lowercase category map for substring matching
|
||||
_CATEGORY_MAP_LOWER: list[tuple[str, str]] = [
|
||||
(k.lower(), v) for k, v in CATEGORY_MAP.items()
|
||||
]
|
||||
|
||||
# Classification cache: (obj_id, category_name) → ifc_class
|
||||
_classify_cache: dict[tuple, str] = {}
|
||||
|
||||
|
||||
def classify(obj, category_name: str = "") -> str:
|
||||
"""
|
||||
Determine the IFC class for a Speckle object.
|
||||
|
||||
Priority:
|
||||
1. properties.builtInCategory (OST_ enum) — definitive Revit classification
|
||||
2. speckle_type prefix match
|
||||
3. category_name from traversal context (display string)
|
||||
4. obj.category field
|
||||
5. IfcBuildingElementProxy fallback
|
||||
"""
|
||||
cache_key = (id(obj), category_name)
|
||||
if cache_key in _classify_cache:
|
||||
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:
|
||||
# 1. builtInCategory — most reliable, direct Revit enum
|
||||
bic = _get_builtin_category(obj)
|
||||
if bic and bic in BUILTIN_CATEGORY_MAP:
|
||||
return BUILTIN_CATEGORY_MAP[bic]
|
||||
|
||||
# 2. speckle_type — exact match first, then longest-prefix match
|
||||
speckle_type = getattr(obj, "speckle_type", "") or ""
|
||||
if speckle_type:
|
||||
if speckle_type in SPECKLE_TYPE_MAP:
|
||||
return SPECKLE_TYPE_MAP[speckle_type]
|
||||
for prefix, ifc_class in _SPECKLE_PREFIXES:
|
||||
if speckle_type.startswith(prefix):
|
||||
return ifc_class
|
||||
|
||||
# 3. category_name from traversal context — exact match first
|
||||
if category_name:
|
||||
if category_name in CATEGORY_MAP:
|
||||
return CATEGORY_MAP[category_name]
|
||||
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
|
||||
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]
|
||||
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"
|
||||
|
||||
|
||||
def reset_caches():
|
||||
"""Clear module-level caches (call at start of each export run)."""
|
||||
_bic_cache.clear()
|
||||
_classify_cache.clear()
|
||||
@@ -0,0 +1,151 @@
|
||||
# =============================================================================
|
||||
# materials.py
|
||||
# Reads renderMaterialProxies from the Speckle root object and applies
|
||||
# IfcSurfaceStyle colours to IFC geometry.
|
||||
#
|
||||
# Structure of renderMaterialProxies:
|
||||
# root.renderMaterialProxies = [
|
||||
# {
|
||||
# id: "636259b3..."
|
||||
# value: RenderMaterial {
|
||||
# name: "Glass"
|
||||
# diffuse: -16744256 ← ARGB packed int (A=255, R=0, G=128, B=192)
|
||||
# opacity: 0.1 ← 0=transparent, 1=opaque
|
||||
# }
|
||||
# objects: ["a1a6b0c2-...", "d5dd3127-...", ...] ← mesh applicationIds
|
||||
# },
|
||||
# ...
|
||||
# ]
|
||||
#
|
||||
# Usage:
|
||||
# mgr = MaterialManager(ifc, root)
|
||||
# mgr.apply_to_item(brep_item, mesh_app_id)
|
||||
# =============================================================================
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api
|
||||
from specklepy.objects.base import Base
|
||||
|
||||
|
||||
def _argb_to_rgb(argb_int: int) -> tuple[float, float, float]:
|
||||
"""Unpack a signed ARGB int to normalised (R, G, B) floats 0..1."""
|
||||
unsigned = argb_int & 0xFFFFFFFF
|
||||
r = ((unsigned >> 16) & 0xFF) / 255.0
|
||||
g = ((unsigned >> 8) & 0xFF) / 255.0
|
||||
b = (unsigned & 0xFF) / 255.0
|
||||
return r, g, b
|
||||
|
||||
|
||||
def _get(obj, key, default=None):
|
||||
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
|
||||
|
||||
|
||||
class MaterialManager:
|
||||
"""
|
||||
Builds a lookup from mesh applicationId → IfcSurfaceStyle,
|
||||
then applies styles to IFC geometry items.
|
||||
"""
|
||||
|
||||
def __init__(self, ifc: ifcopenshell.file, root: Base):
|
||||
self._ifc = ifc
|
||||
# mesh applicationId (lowercase) → IfcSurfaceStyle (populated lazily)
|
||||
self._style_map: dict[str, object] = {}
|
||||
# name → IfcSurfaceStyle (cache to avoid duplicates)
|
||||
self._style_cache: dict[str, object] = {}
|
||||
self._build(root)
|
||||
|
||||
def _build(self, root: Base):
|
||||
"""
|
||||
Parse renderMaterialProxies and store raw material data keyed by mesh applicationId.
|
||||
IFC styles are created lazily (only when actually assigned to geometry) to avoid
|
||||
orphaned IfcSurfaceStyle instances that would fail IFC105 validation.
|
||||
"""
|
||||
proxies = _get(root, "renderMaterialProxies") or []
|
||||
if not isinstance(proxies, list):
|
||||
proxies = list(proxies) if proxies else []
|
||||
|
||||
# mesh applicationId (lowercase) → (name, diffuse_argb, transparency)
|
||||
self._material_data: dict[str, tuple] = {}
|
||||
|
||||
for proxy in proxies:
|
||||
material = _get(proxy, "value")
|
||||
if material is None:
|
||||
continue
|
||||
name = _get(material, "name") or "Unnamed"
|
||||
diffuse = _get(material, "diffuse")
|
||||
opacity = _get(material, "opacity")
|
||||
if diffuse is None:
|
||||
continue
|
||||
opacity_val = float(opacity) if opacity is not None else 1.0
|
||||
transparency = max(0.0, min(1.0, 1.0 - opacity_val))
|
||||
|
||||
objects = _get(proxy, "objects") or []
|
||||
for app_id in (objects if isinstance(objects, list) else []):
|
||||
if app_id:
|
||||
self._material_data[str(app_id).lower()] = (name, int(diffuse), transparency)
|
||||
|
||||
print(f" Materials: {len(self._material_data)} mesh mappings (styles created on demand)")
|
||||
|
||||
def _get_or_create_style(self, name: str, diffuse_argb: int, transparency: float):
|
||||
"""Return cached style or create a new IfcSurfaceStyle."""
|
||||
cache_key = f"{name}|{diffuse_argb}|{transparency:.4f}"
|
||||
if cache_key in self._style_cache:
|
||||
return self._style_cache[cache_key]
|
||||
|
||||
r, g, b = _argb_to_rgb(diffuse_argb)
|
||||
style = ifcopenshell.api.run("style.add_style", self._ifc, name=name)
|
||||
ifcopenshell.api.run(
|
||||
"style.add_surface_style",
|
||||
self._ifc,
|
||||
style=style,
|
||||
ifc_class="IfcSurfaceStyleRendering",
|
||||
attributes={
|
||||
"SurfaceColour": {"Name": None, "Red": r, "Green": g, "Blue": b},
|
||||
"Transparency": transparency,
|
||||
"ReflectanceMethod": "NOTDEFINED",
|
||||
},
|
||||
)
|
||||
self._style_cache[cache_key] = style
|
||||
return style
|
||||
|
||||
def get_style(self, mesh_app_id: str):
|
||||
"""Return the IfcSurfaceStyle for a mesh applicationId (created on demand), or None."""
|
||||
key = str(mesh_app_id).lower()
|
||||
# Return already-created style if cached
|
||||
if key in self._style_map:
|
||||
return self._style_map[key]
|
||||
# Create style now only if this mesh has material data
|
||||
data = self._material_data.get(key)
|
||||
if data is None:
|
||||
return None
|
||||
name, diffuse, transparency = data
|
||||
style = self._get_or_create_style(name, diffuse, transparency)
|
||||
self._style_map[key] = style
|
||||
return style
|
||||
|
||||
def apply_to_item(self, item, mesh_app_id: str):
|
||||
"""Assign the material style to a single IFC geometry item (e.g. IfcPolygonalFaceSet)."""
|
||||
style = self.get_style(mesh_app_id)
|
||||
if style is None:
|
||||
return
|
||||
try:
|
||||
ifcopenshell.api.run(
|
||||
"style.assign_item_style",
|
||||
self._ifc,
|
||||
item=item,
|
||||
style=style,
|
||||
)
|
||||
except Exception as e:
|
||||
pass # Non-fatal — geometry still exports without colour
|
||||
@@ -0,0 +1,749 @@
|
||||
# =============================================================================
|
||||
# properties.py
|
||||
# Writes IFC property sets matching the structure of Revit's native IFC export.
|
||||
#
|
||||
# Revit native IFC export produces:
|
||||
# - Element Name: "Family:TypeName:ElementId" e.g. "Basic Roof:SG Metal Panels roof:243274"
|
||||
# - Element Tag: ElementId string e.g. "243274"
|
||||
# - Element GlobalId: from IFC Parameters.IfcGUID
|
||||
# - Pset_<EntityType>Common with typed properties (IfcBoolean, IfcIdentifier, etc.)
|
||||
# - Pset_EnvironmentalImpactIndicators with Reference = TypeName
|
||||
#
|
||||
# Our Speckle source fields:
|
||||
# obj.family → Family name
|
||||
# obj.type → Type name (= Reference in all Common psets)
|
||||
# properties.elementId → Revit ElementId → Tag
|
||||
# properties.Parameters.Instance Parameters.IFC Parameters.IfcGUID.value → GlobalId
|
||||
# properties.Parameters.Type Parameters.* → typed IFC properties
|
||||
# properties.Parameters.Instance Parameters.* → typed IFC properties
|
||||
# =============================================================================
|
||||
|
||||
import ifcopenshell.api
|
||||
from specklepy.objects.base import Base
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IFC entity → standard Common pset name
|
||||
# ---------------------------------------------------------------------------
|
||||
COMMON_PSET: dict[str, str] = {
|
||||
"IfcWall": "Pset_WallCommon",
|
||||
"IfcWallStandardCase": "Pset_WallCommon",
|
||||
"IfcSlab": "Pset_SlabCommon",
|
||||
"IfcRoof": "Pset_RoofCommon",
|
||||
"IfcColumn": "Pset_ColumnCommon",
|
||||
"IfcBeam": "Pset_BeamCommon",
|
||||
"IfcMember": "Pset_MemberCommon",
|
||||
"IfcDoor": "Pset_DoorCommon",
|
||||
"IfcWindow": "Pset_WindowCommon",
|
||||
"IfcStair": "Pset_StairCommon",
|
||||
"IfcStairFlight": "Pset_StairFlightCommon",
|
||||
"IfcRamp": "Pset_RampCommon",
|
||||
"IfcRailing": "Pset_RailingCommon",
|
||||
"IfcCovering": "Pset_CoveringCommon",
|
||||
"IfcCurtainWall": "Pset_CurtainWallCommon",
|
||||
"IfcFooting": "Pset_FootingCommon",
|
||||
"IfcPile": "Pset_PileCommon",
|
||||
"IfcSpace": "Pset_SpaceCommon",
|
||||
"IfcSite": "Pset_SiteCommon",
|
||||
"IfcBuildingStorey": "Pset_BuildingStoreyCommon",
|
||||
"IfcBuilding": "Pset_BuildingCommon",
|
||||
"IfcBuildingElementProxy": "Pset_BuildingElementProxyCommon",
|
||||
"IfcFurnishingElement": "Pset_FurnitureTypeCommon",
|
||||
"IfcLightFixture": "Pset_LightFixtureTypeCommon",
|
||||
"IfcOpeningElement": "Pset_OpeningElementCommon",
|
||||
"IfcPlate": "Pset_PlateCommon",
|
||||
"IfcGeographicElement": "Pset_SiteCommon",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Revit parameter internal names → (IFC pset property name, IFC value factory)
|
||||
# These are harvested from the Common psets Revit native export produces.
|
||||
# ---------------------------------------------------------------------------
|
||||
def _bool(v):
|
||||
return ("IfcBoolean", bool(v))
|
||||
|
||||
def _identifier(v):
|
||||
return ("IfcIdentifier", str(v))
|
||||
|
||||
def _label(v):
|
||||
return ("IfcLabel", str(v))
|
||||
|
||||
def _real(v):
|
||||
return ("IfcReal", float(v))
|
||||
|
||||
def _thermal(v):
|
||||
return ("IfcThermalTransmittanceMeasure", float(v))
|
||||
|
||||
def _length(v):
|
||||
return ("IfcPositiveLengthMeasure", float(v))
|
||||
|
||||
def _count(v):
|
||||
return ("IfcCountMeasure", int(v))
|
||||
|
||||
def _angle(v):
|
||||
return ("IfcPlaneAngleMeasure", float(v))
|
||||
|
||||
|
||||
# Map: Revit internalDefinitionName → (IFC property name, value factory fn)
|
||||
REVIT_PARAM_TO_IFC: dict[str, tuple] = {
|
||||
# Wall
|
||||
"WALL_ATTR_ROOM_BOUNDING": ("IsExternal", _bool),
|
||||
"WALL_STRUCTURAL_SIGNIFICANT": ("LoadBearing", _bool),
|
||||
"WALL_STRUCTURAL_USAGE_PARAM": ("LoadBearing", _bool),
|
||||
"ANALYTICAL_THERMAL_RESISTANCE": ("ThermalTransmittance", _thermal),
|
||||
"ANALYTICAL_HEAT_TRANSFER_COEFFICIENT": ("ThermalTransmittance", _thermal),
|
||||
|
||||
# Slab / Roof / Floor
|
||||
"HOST_AREA_COMPUTED": ("NetArea", _real),
|
||||
"HOST_VOLUME_COMPUTED": ("NetVolume", _real),
|
||||
"ROOF_SLOPE": ("PitchAngle", _angle),
|
||||
|
||||
# Stair
|
||||
"STAIR_RISER_HEIGHT": ("RiserHeight", _length),
|
||||
"STAIR_TREAD_DEPTH": ("TreadLength", _length),
|
||||
"STAIR_NUMBER_OF_RISERS": ("NumberOfRiser", _count),
|
||||
"STAIR_NUMBER_OF_TREADS": ("NumberOfTreads", _count),
|
||||
"STAIR_NOSING_LENGTH": ("NosingLength", _length),
|
||||
|
||||
# Railing
|
||||
"RAILING_HEIGHT": ("Height", _length),
|
||||
|
||||
# Door / Window
|
||||
"DOOR_FIRE_RATING": ("FireExit", _bool),
|
||||
|
||||
# General identity
|
||||
"ALL_MODEL_FAMILY_NAME": ("Reference", _identifier),
|
||||
"ALL_MODEL_TYPE_NAME": ("Reference", _identifier),
|
||||
"ASSEMBLY_CODE": ("Reference", _identifier),
|
||||
}
|
||||
|
||||
# External category OST_ codes (used to infer IsExternal)
|
||||
EXTERNAL_CATEGORIES = {
|
||||
"OST_Walls", "OST_Roofs", "OST_Windows", "OST_Doors",
|
||||
"OST_CurtainWallPanels", "OST_CurtainWallMullions",
|
||||
"OST_StructuralColumns", "OST_StructuralFraming",
|
||||
"OST_Stairs", "OST_StairsRailing", "OST_Ramps",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_props_cache: dict[int, dict] = {} # id(obj) → props dict
|
||||
|
||||
|
||||
def _get_props_dict(obj: Base) -> dict:
|
||||
"""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 p is None:
|
||||
_props_cache[oid] = {}
|
||||
return {}
|
||||
result = _to_dict(p)
|
||||
_props_cache[oid] = result
|
||||
return result
|
||||
|
||||
|
||||
def _get_nested(d, *keys):
|
||||
"""Safely walk nested dicts/objects."""
|
||||
cur = d
|
||||
for k in keys:
|
||||
if cur is None:
|
||||
return None
|
||||
cur = _safe_get(cur, k)
|
||||
return cur
|
||||
|
||||
|
||||
_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.
|
||||
"""
|
||||
block = _to_dict(params_block)
|
||||
if not block:
|
||||
return None
|
||||
for group in block.values():
|
||||
group_d = _to_dict(group)
|
||||
if not group_d:
|
||||
continue
|
||||
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
|
||||
|
||||
|
||||
def _make_prop(ifc, name: str, ifc_type: str, value) -> object | None:
|
||||
"""Create an IfcPropertySingleValue with the correct IFC measure type."""
|
||||
try:
|
||||
nominal = ifc.create_entity(ifc_type, wrappedValue=value)
|
||||
return ifc.create_entity(
|
||||
"IfcPropertySingleValue",
|
||||
Name=name,
|
||||
NominalValue=nominal,
|
||||
)
|
||||
except Exception as e:
|
||||
return None
|
||||
|
||||
|
||||
def _write_pset(ifc, element, pset_name: str, props: list):
|
||||
"""Write an IfcPropertySet with the given list of IfcProperty objects."""
|
||||
if not props:
|
||||
return
|
||||
try:
|
||||
pset = ifcopenshell.api.run("pset.add_pset", ifc, product=element, name=pset_name)
|
||||
# Directly attach the pre-built property objects
|
||||
pset.HasProperties = props
|
||||
except Exception as e:
|
||||
print(f" ⚠️ {pset_name}: {e}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Element name + tag (matching Revit native IFC format)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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.
|
||||
"""
|
||||
family = getattr(obj, "family", None) or ""
|
||||
typ = getattr(obj, "type", None) or ""
|
||||
|
||||
# Treat literal "none" (case-insensitive) the same as empty — Revit exports
|
||||
# placeholder objects with family/type set to the string "none".
|
||||
if family.strip().lower() == "none":
|
||||
family = ""
|
||||
if typ.strip().lower() == "none":
|
||||
typ = ""
|
||||
|
||||
parts = [p for p in [family, typ] if p]
|
||||
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 = _safe_get(props, "elementId")
|
||||
return str(elem_id) if elem_id else None
|
||||
|
||||
|
||||
def get_ifc_guid(obj: Base) -> str | None:
|
||||
"""
|
||||
Read IfcGUID from the Revit IFC Parameters.
|
||||
Falls back to None (ifcopenshell will auto-generate a GUID).
|
||||
"""
|
||||
props = _get_props_dict(obj)
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Standard Common pset (Pset_WallCommon etc.)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def write_common_pset(ifc, element, obj: Base, ifc_class: str, category_name: str = ""):
|
||||
"""
|
||||
Write the standard Pset_<Entity>Common property set, matching Revit native export.
|
||||
Properties: Reference (TypeName), IsExternal, LoadBearing, ThermalTransmittance, etc.
|
||||
"""
|
||||
pset_name = COMMON_PSET.get(ifc_class)
|
||||
if not pset_name:
|
||||
return
|
||||
|
||||
props = _get_props_dict(obj)
|
||||
params = _safe_get(props, "Parameters", {})
|
||||
type_params = _safe_get(params, "Type Parameters", {})
|
||||
inst_params = _safe_get(params, "Instance Parameters", {})
|
||||
|
||||
ifc_props = []
|
||||
|
||||
# Reference = TypeName (always present in Revit IFC)
|
||||
type_name = getattr(obj, "type", None) or ""
|
||||
if type_name:
|
||||
p = _make_prop(ifc, "Reference", "IfcIdentifier", type_name)
|
||||
if p:
|
||||
ifc_props.append(p)
|
||||
|
||||
# IsExternal — derive from builtInCategory or "Constraints" parameters
|
||||
bic = _safe_get(props, "builtInCategory", "")
|
||||
is_external = bic in EXTERNAL_CATEGORIES
|
||||
if not is_external:
|
||||
# Some elements expose it directly as a parameter
|
||||
ext_val = _param_value(inst_params, "WALL_ATTR_ROOM_BOUNDING")
|
||||
if ext_val is not None:
|
||||
is_external = bool(ext_val)
|
||||
if ifc_class not in {"IfcSpace", "IfcSite", "IfcBuildingStorey", "IfcBuilding",
|
||||
"IfcFurnishingElement", "IfcOpeningElement"}:
|
||||
p = _make_prop(ifc, "IsExternal", "IfcBoolean", is_external)
|
||||
if p:
|
||||
ifc_props.append(p)
|
||||
|
||||
# LoadBearing — walls, columns, beams, slabs
|
||||
if ifc_class in {"IfcWall", "IfcWallStandardCase", "IfcSlab", "IfcColumn", "IfcBeam"}:
|
||||
lb_val = (_param_value(inst_params, "WALL_STRUCTURAL_SIGNIFICANT") or
|
||||
_param_value(inst_params, "WALL_STRUCTURAL_USAGE_PARAM") or
|
||||
_param_value(type_params, "WALL_STRUCTURAL_SIGNIFICANT"))
|
||||
lb = bool(lb_val) if lb_val is not None else False
|
||||
p = _make_prop(ifc, "LoadBearing", "IfcBoolean", lb)
|
||||
if p:
|
||||
ifc_props.append(p)
|
||||
|
||||
# ThermalTransmittance — walls, roofs, slabs, doors, windows
|
||||
if ifc_class in {"IfcWall", "IfcWallStandardCase", "IfcRoof", "IfcSlab",
|
||||
"IfcDoor", "IfcWindow"}:
|
||||
u_val = (_param_value(type_params, "ANALYTICAL_HEAT_TRANSFER_COEFFICIENT") or
|
||||
_param_value(inst_params, "ANALYTICAL_HEAT_TRANSFER_COEFFICIENT"))
|
||||
if u_val is not None:
|
||||
try:
|
||||
p = _make_prop(ifc, "ThermalTransmittance", "IfcThermalTransmittanceMeasure", float(u_val))
|
||||
if p:
|
||||
ifc_props.append(p)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# PitchAngle — roofs/slabs
|
||||
if ifc_class in {"IfcRoof", "IfcSlab"}:
|
||||
slope = _param_value(inst_params, "ROOF_SLOPE")
|
||||
if slope is not None:
|
||||
try:
|
||||
p = _make_prop(ifc, "PitchAngle", "IfcPlaneAngleMeasure", float(slope))
|
||||
if p:
|
||||
ifc_props.append(p)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Stair-specific
|
||||
if ifc_class in {"IfcStair", "IfcStairFlight"}:
|
||||
for internal, prop_name, factory in [
|
||||
("STAIR_RISER_HEIGHT", "RiserHeight", "IfcPositiveLengthMeasure"),
|
||||
("STAIR_TREAD_DEPTH", "TreadLength", "IfcPositiveLengthMeasure"),
|
||||
("STAIR_NUMBER_OF_RISERS","NumberOfRiser", "IfcCountMeasure"),
|
||||
("STAIR_NUMBER_OF_TREADS","NumberOfTreads", "IfcCountMeasure"),
|
||||
]:
|
||||
v = _param_value(inst_params, internal) or _param_value(type_params, internal)
|
||||
if v is not None:
|
||||
try:
|
||||
p = _make_prop(ifc, prop_name, factory, float(v) if "Measure" in factory else int(v))
|
||||
if p:
|
||||
ifc_props.append(p)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Railing height
|
||||
if ifc_class == "IfcRailing":
|
||||
h = _param_value(inst_params, "RAILING_HEIGHT") or _param_value(type_params, "RAILING_HEIGHT")
|
||||
if h is not None:
|
||||
try:
|
||||
p = _make_prop(ifc, "Height", "IfcPositiveLengthMeasure", float(h))
|
||||
if p:
|
||||
ifc_props.append(p)
|
||||
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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def write_environmental_pset(ifc, element, obj: Base):
|
||||
"""Write Pset_EnvironmentalImpactIndicators with Reference = TypeName."""
|
||||
type_name = getattr(obj, "type", None) or ""
|
||||
if not type_name:
|
||||
return
|
||||
p = _make_prop(ifc, "Reference", "IfcIdentifier", type_name)
|
||||
if p:
|
||||
_write_pset(ifc, element, "Pset_EnvironmentalImpactIndicators", [p])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Custom Revit parameters pset (all remaining instance + type params)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _safe_str(value) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
return "Yes" if value else "No"
|
||||
if isinstance(value, float):
|
||||
return f"{value:.6g}"
|
||||
s = str(value).strip()
|
||||
return s or None
|
||||
|
||||
|
||||
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"}
|
||||
block = _to_dict(params_block)
|
||||
for group in block.values():
|
||||
group_d = _to_dict(group)
|
||||
if not group_d:
|
||||
continue
|
||||
for entry in group_d.values():
|
||||
entry_d = _to_dict(entry)
|
||||
if not entry_d:
|
||||
continue
|
||||
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)
|
||||
if val_str is None:
|
||||
continue
|
||||
display = f"{val_str} {units}".strip() if units not in skip_units else val_str
|
||||
result[name] = display
|
||||
return result
|
||||
|
||||
|
||||
def write_revit_params(ifc, element, obj: Base):
|
||||
"""
|
||||
Write remaining Revit instance parameters as a custom property set
|
||||
using the vendor prefix 'RVT_' (not 'Pset_' which is reserved):
|
||||
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 = _safe_get(props, "Parameters", {})
|
||||
|
||||
inst_flat = _flatten_params(_safe_get(params, "Instance Parameters", {}))
|
||||
|
||||
def build_str_props(flat: dict) -> list:
|
||||
out = []
|
||||
for name, val in flat.items():
|
||||
try:
|
||||
nominal = ifc.create_entity("IfcLabel", wrappedValue=val)
|
||||
p = ifc.create_entity("IfcPropertySingleValue", Name=name, NominalValue=nominal)
|
||||
out.append(p)
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
inst_props = build_str_props(inst_flat)
|
||||
|
||||
if inst_props:
|
||||
_write_pset(ifc, element, "RVT_InstanceParameters", inst_props)
|
||||
|
||||
# Identity: family, type, elementId, builtInCategory
|
||||
identity = {}
|
||||
for field in ["family", "type", "category"]:
|
||||
val = getattr(obj, field, None)
|
||||
if val and isinstance(val, str) and val.strip():
|
||||
identity[field.capitalize()] = val.strip()
|
||||
elem_id = _safe_get(props, "elementId")
|
||||
if elem_id:
|
||||
identity["ElementId"] = str(elem_id)
|
||||
bic = _safe_get(props, "builtInCategory")
|
||||
if bic:
|
||||
identity["BuiltInCategory"] = str(bic)
|
||||
|
||||
id_props = []
|
||||
for name, val in identity.items():
|
||||
try:
|
||||
nominal = ifc.create_entity("IfcLabel", wrappedValue=val)
|
||||
p = ifc.create_entity("IfcPropertySingleValue", Name=name, NominalValue=nominal)
|
||||
id_props.append(p)
|
||||
except Exception:
|
||||
pass
|
||||
if id_props:
|
||||
_write_pset(ifc, element, "RVT_Identity", id_props)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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>" 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}"
|
||||
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:
|
||||
1. Pset_<Entity>Common — standard typed properties (Reference, IsExternal, etc.)
|
||||
2. Pset_EnvironmentalImpactIndicators — Reference = TypeName
|
||||
3. RVT_TypeParameters — all remaining Revit type parameters
|
||||
4. RVT_InstanceParameters — all remaining Revit instance parameters
|
||||
5. RVT_Identity — family, type, elementId, builtInCategory
|
||||
6. Qto_<MaterialName> — material quantities (area, volume, density)
|
||||
"""
|
||||
write_common_pset(ifc, element, obj, ifc_class, category_name)
|
||||
write_revit_params(ifc, element, obj)
|
||||
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
|
||||
|
||||
|
||||
def reset_caches():
|
||||
"""Clear module-level caches (call at start of each export run)."""
|
||||
_props_cache.clear()
|
||||
_to_dict_cache.clear()
|
||||
@@ -0,0 +1,75 @@
|
||||
# =============================================================================
|
||||
# receiver.py
|
||||
# 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
|
||||
|
||||
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 the .env file.
|
||||
To use your local Speckle Manager account instead, swap to get_default_account().
|
||||
"""
|
||||
client = SpeckleClient(host=SPECKLE_HOST)
|
||||
|
||||
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 .env "
|
||||
"or log in via Speckle Manager."
|
||||
)
|
||||
client.authenticate_with_account(account)
|
||||
|
||||
return client
|
||||
|
||||
|
||||
def receive_version(project_id: str, version_id: str):
|
||||
"""
|
||||
Receive the root Base object from a Speckle version.
|
||||
|
||||
Args:
|
||||
project_id: The Speckle project (stream) ID.
|
||||
version_id: The version (commit) ID to receive.
|
||||
|
||||
Returns:
|
||||
A specklepy Base object — the root of the object graph.
|
||||
"""
|
||||
client = get_client()
|
||||
|
||||
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)
|
||||
referenced_object_id = version.referenced_object
|
||||
|
||||
# Download the full object graph
|
||||
transport = ServerTransport(stream_id=project_id, client=client)
|
||||
base = operations.receive(referenced_object_id, transport)
|
||||
|
||||
# Read units from the root object
|
||||
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".
|
||||
scale = 1.0
|
||||
|
||||
print(f"✅ Received root object units={units} scale=1.0 (IFC declared as mm)")
|
||||
return base, scale
|
||||
@@ -0,0 +1,244 @@
|
||||
# =============================================================================
|
||||
# traversal.py
|
||||
# Walks the nested Speckle Collection tree from a Revit export.
|
||||
#
|
||||
# Expected structure (from your screenshot):
|
||||
# root
|
||||
# └── elements[]
|
||||
# └── Collection (project)
|
||||
# └── elements[]
|
||||
# └── Collection (Level 18, Level 19, ...) ← storeys
|
||||
# └── elements[]
|
||||
# └── Collection (Floors, Walls, ...) ← categories
|
||||
# └── elements[]
|
||||
# └── Base object ← real BIM element
|
||||
# =============================================================================
|
||||
|
||||
from typing import Generator, Tuple
|
||||
from specklepy.objects.base import Base
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Low-level helpers
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def is_collection(obj) -> bool:
|
||||
"""Returns True if this object is a Speckle Collection node (not a leaf element)."""
|
||||
speckle_type = getattr(obj, "speckle_type", "") or ""
|
||||
return "Collection" in speckle_type
|
||||
|
||||
|
||||
def get_children(obj) -> list:
|
||||
"""
|
||||
Safely get the 'elements' list from a Base/Collection object.
|
||||
Handles both 'elements' and '@elements' (detached) variants.
|
||||
"""
|
||||
for key in ["elements", "@elements"]:
|
||||
try:
|
||||
val = obj[key]
|
||||
if val is not None:
|
||||
return list(val)
|
||||
except Exception:
|
||||
continue
|
||||
return []
|
||||
|
||||
|
||||
def get_prop(obj, key: str, default=None):
|
||||
"""Safe property access for Speckle Base objects — avoids AttributeError."""
|
||||
try:
|
||||
val = getattr(obj, key, None)
|
||||
if val is None:
|
||||
val = obj[key]
|
||||
return val
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
# speckle_type fragments that mark a non-exportable / spatial-structure object
|
||||
import re
|
||||
_SKIP_TYPE_RE = re.compile(
|
||||
r"Collection|Level|Grid|View|RenderMaterial|Site|Building|Storey"
|
||||
)
|
||||
|
||||
|
||||
def _is_valid_element(obj) -> bool:
|
||||
"""
|
||||
Returns True only for leaf objects that should become IFC elements.
|
||||
Filters out Collections, spatial structure types, and other non-geometry nodes.
|
||||
"""
|
||||
if obj is None:
|
||||
return False
|
||||
|
||||
speckle_type = getattr(obj, "speckle_type", "") or ""
|
||||
if _SKIP_TYPE_RE.search(speckle_type):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Helpers
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _element_level(obj) -> str:
|
||||
"""
|
||||
Try to read the level/storey name directly from an element's properties.
|
||||
Handles both flat and deeply nested Revit property structures.
|
||||
"""
|
||||
# Top-level field (Revit connector puts it here for parent elements)
|
||||
level = get_prop(obj, "level") or get_prop(obj, "Level")
|
||||
if level and isinstance(level, str) and level.strip():
|
||||
return level.strip()
|
||||
|
||||
props = get_prop(obj, "properties")
|
||||
if isinstance(props, dict):
|
||||
# Flat key
|
||||
for key in ["Level", "level", "Building Storey"]:
|
||||
val = props.get(key)
|
||||
if val and isinstance(val, str) and val.strip():
|
||||
return val.strip()
|
||||
|
||||
# Nested: properties.Instance Parameters.Constraints.Level.value
|
||||
# (used by curtain wall children / panels / mullions)
|
||||
instance_params = props.get("Instance Parameters") or {}
|
||||
constraints = instance_params.get("Constraints") or {}
|
||||
level_entry = constraints.get("Level") or {}
|
||||
if isinstance(level_entry, dict):
|
||||
val = level_entry.get("value")
|
||||
if val and isinstance(val, str) and val.strip():
|
||||
return val.strip()
|
||||
|
||||
# Also check Identity Data
|
||||
identity = props.get("Identity Data") or {}
|
||||
for key in ["Level", "level"]:
|
||||
val = identity.get(key)
|
||||
if val and isinstance(val, str) and val.strip():
|
||||
return val.strip()
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _yield_element_and_children(obj, level_name: str, category_name: str):
|
||||
"""
|
||||
Yield a leaf element, then recursively yield any DataObject children
|
||||
from its elements[] list (e.g. curtain wall panels and mullions).
|
||||
Children have their own level and displayValue geometry.
|
||||
"""
|
||||
yield obj, level_name, category_name
|
||||
|
||||
children = get_children(obj)
|
||||
for child in children:
|
||||
if child is None or is_collection(child):
|
||||
continue
|
||||
if not _is_valid_element(child):
|
||||
continue
|
||||
# Get child's own level, fall back to parent's level
|
||||
child_level = _element_level(child) or level_name
|
||||
if child_level and child_level != "Unknown Level":
|
||||
child_category = getattr(child, "category", None) or category_name
|
||||
yield from _yield_element_and_children(child, child_level, child_category)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Main traversal
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def traverse(
|
||||
root: Base,
|
||||
) -> Generator[Tuple[Base, str, str], None, None]:
|
||||
"""
|
||||
Walk the full Speckle object tree from the root Base object.
|
||||
|
||||
Yields:
|
||||
(element, level_name, category_name) for every leaf BIM element found.
|
||||
level_name — e.g. "Level 18"
|
||||
category_name — e.g. "Floors", "Walls", "Structural Columns"
|
||||
"""
|
||||
root_children = get_children(root)
|
||||
|
||||
if not root_children:
|
||||
if _is_valid_element(root):
|
||||
yield root, "Unknown Level", "Unknown Category"
|
||||
return
|
||||
|
||||
for child in root_children:
|
||||
if is_collection(child):
|
||||
yield from _walk_level(child)
|
||||
else:
|
||||
if _is_valid_element(child):
|
||||
level = _element_level(child)
|
||||
if level:
|
||||
yield child, level, "Unknown Category"
|
||||
|
||||
|
||||
def _walk_level(project_collection: Base):
|
||||
"""Walk the project collection → level collections."""
|
||||
for level_obj in get_children(project_collection):
|
||||
level_name = getattr(level_obj, "name", None) or ""
|
||||
|
||||
if is_collection(level_obj):
|
||||
# Only walk into this level if it has a real name
|
||||
if level_name and level_name != "Unknown Level":
|
||||
yield from _walk_category(level_obj, level_name)
|
||||
else:
|
||||
if _is_valid_element(level_obj):
|
||||
level = _element_level(level_obj) or level_name
|
||||
if level and level != "Unknown Level":
|
||||
yield from _yield_element_and_children(level_obj, level, "Unknown Category")
|
||||
|
||||
|
||||
def _walk_category(level_obj: Base, level_name: str):
|
||||
"""Walk level collection → category collections → leaf elements."""
|
||||
for category_obj in get_children(level_obj):
|
||||
category_name = getattr(category_obj, "name", "Unknown Category") or "Unknown Category"
|
||||
|
||||
if is_collection(category_obj):
|
||||
for element in get_children(category_obj):
|
||||
if is_collection(element):
|
||||
# One extra nesting level (e.g. sub-families)
|
||||
for sub_element in get_children(element):
|
||||
if _is_valid_element(sub_element):
|
||||
level = _element_level(sub_element) or level_name
|
||||
if level and level != "Unknown Level":
|
||||
yield from _yield_element_and_children(sub_element, level, category_name)
|
||||
else:
|
||||
if _is_valid_element(element):
|
||||
level = _element_level(element) or level_name
|
||||
if level and level != "Unknown Level":
|
||||
yield from _yield_element_and_children(element, level, category_name)
|
||||
else:
|
||||
if _is_valid_element(category_obj):
|
||||
level = _element_level(category_obj) or level_name
|
||||
if level and level != "Unknown Level":
|
||||
yield from _yield_element_and_children(category_obj, level, "Unknown Category")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Debug helper
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def print_tree(obj: Base, indent: int = 0, max_depth: int = 5):
|
||||
"""
|
||||
Print the object tree structure for debugging.
|
||||
Call this on the root object to understand your data before exporting.
|
||||
|
||||
Usage:
|
||||
from traversal import print_tree
|
||||
print_tree(base)
|
||||
"""
|
||||
if indent > max_depth:
|
||||
return
|
||||
|
||||
prefix = " " * indent
|
||||
name = getattr(obj, "name", None) or ""
|
||||
speckle_type = getattr(obj, "speckle_type", "") or ""
|
||||
children = get_children(obj)
|
||||
child_count = f" ({len(children)} children)" if children else ""
|
||||
|
||||
print(f"{prefix}├─ [{speckle_type}] name={name!r}{child_count}")
|
||||
|
||||
for child in children[:5]: # limit to first 5 per level to avoid spam
|
||||
print_tree(child, indent + 1, max_depth)
|
||||
|
||||
if len(children) > 5:
|
||||
print(f"{prefix} ... and {len(children) - 5} more")
|
||||
@@ -0,0 +1,214 @@
|
||||
# =============================================================================
|
||||
# type_manager.py
|
||||
# Creates and caches IfcTypeObjects (IfcWallType, IfcRoofType, etc.) and
|
||||
# links element instances to them via IfcRelDefinesByType.
|
||||
#
|
||||
# Revit native IFC export pattern:
|
||||
# IfcWallType
|
||||
# Name = "Family:TypeName" (no ElementId)
|
||||
# Tag = Type's Revit ElementId (from Instance Parameters > Other > Type Id)
|
||||
# GlobalId = from Type IfcGUID param (from Type Parameters > IFC Parameters > Type IfcGUID)
|
||||
# HasPropertySets:
|
||||
# Pset_WallCommon: IsExternal, ThermalTransmittance (type-level)
|
||||
# Pset_EnvironmentalImpactIndicators: Reference = TypeName
|
||||
# RVT_TypeParameters: all remaining type params
|
||||
#
|
||||
# Type objects are SHARED — multiple instances of the same Revit type
|
||||
# map to one IfcTypeObject, keyed by (ifc_class, family, type_name).
|
||||
# =============================================================================
|
||||
|
||||
import ifcopenshell
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
# IFC element class → IFC type class
|
||||
TYPE_CLASS_MAP: dict[str, str] = {
|
||||
"IfcWall": "IfcWallType",
|
||||
"IfcWallStandardCase": "IfcWallType",
|
||||
"IfcSlab": "IfcSlabType",
|
||||
"IfcRoof": "IfcRoofType",
|
||||
"IfcColumn": "IfcColumnType",
|
||||
"IfcBeam": "IfcBeamType",
|
||||
"IfcMember": "IfcMemberType",
|
||||
"IfcDoor": "IfcDoorType",
|
||||
"IfcWindow": "IfcWindowType",
|
||||
"IfcStair": "IfcStairType",
|
||||
"IfcStairFlight": "IfcStairFlightType",
|
||||
"IfcRamp": "IfcRampType",
|
||||
"IfcRailing": "IfcRailingType",
|
||||
"IfcCovering": "IfcCoveringType",
|
||||
"IfcCurtainWall": "IfcCurtainWallType",
|
||||
"IfcFooting": "IfcFootingType",
|
||||
"IfcBuildingElementProxy": "IfcBuildingElementProxyType",
|
||||
"IfcFurnishingElement": "IfcFurnitureType",
|
||||
"IfcLightFixture": "IfcLightFixtureType",
|
||||
"IfcElectricAppliance": "IfcElectricApplianceType",
|
||||
"IfcElectricDistributionBoard": "IfcElectricDistributionBoardType",
|
||||
"IfcSanitaryTerminal": "IfcSanitaryTerminalType",
|
||||
"IfcUnitaryEquipment": "IfcUnitaryEquipmentType",
|
||||
"IfcDuctSegment": "IfcDuctSegmentType",
|
||||
"IfcPipeSegment": "IfcPipeSegmentType",
|
||||
"IfcCableCarrierSegment": "IfcCableCarrierSegmentType",
|
||||
"IfcPlate": "IfcPlateType",
|
||||
}
|
||||
|
||||
|
||||
class TypeManager:
|
||||
"""
|
||||
Creates IfcTypeObjects on demand and caches them by (ifc_class, family, type_name).
|
||||
Call assign(element, obj, ifc_class) for each exported element.
|
||||
"""
|
||||
|
||||
def __init__(self, ifc: ifcopenshell.file):
|
||||
self._ifc = ifc
|
||||
# key: (ifc_class, family, type_name) → IfcTypeObject
|
||||
self._cache: dict[tuple, object] = {}
|
||||
# type_object → [element, ...] (for batched IfcRelDefinesByType)
|
||||
self._pending: dict[int, list] = {}
|
||||
|
||||
def assign(self, element, obj: Base, ifc_class: str):
|
||||
"""Create (or retrieve cached) type object and queue the assignment."""
|
||||
type_class = TYPE_CLASS_MAP.get(ifc_class)
|
||||
if not type_class:
|
||||
return
|
||||
|
||||
family = getattr(obj, "family", None) or ""
|
||||
type_name = getattr(obj, "type", None) or ""
|
||||
if not type_name:
|
||||
return
|
||||
|
||||
cache_key = (ifc_class, family, type_name)
|
||||
|
||||
if cache_key not in self._cache:
|
||||
type_obj = self._create_type(type_class, family, type_name, obj, ifc_class)
|
||||
self._cache[cache_key] = type_obj
|
||||
|
||||
type_obj = self._cache[cache_key]
|
||||
type_id = type_obj.id()
|
||||
|
||||
if type_id not in self._pending:
|
||||
self._pending[type_id] = []
|
||||
self._pending[type_id].append(element)
|
||||
|
||||
def flush(self):
|
||||
"""Write all IfcRelDefinesByType relationships."""
|
||||
for type_id, elements in self._pending.items():
|
||||
type_obj = self._ifc.by_id(type_id)
|
||||
ifcopenshell.api.run(
|
||||
"type.assign_type", self._ifc,
|
||||
related_objects=elements,
|
||||
relating_type=type_obj,
|
||||
)
|
||||
self._pending.clear()
|
||||
print(f" Type objects created: {len(self._cache)}")
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
def _create_type(self, type_class: str, family: str, type_name: str,
|
||||
obj: Base, ifc_class: str):
|
||||
"""Instantiate the IfcTypeObject with name, tag, GlobalId, and psets."""
|
||||
props = _get_props_dict(obj)
|
||||
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]
|
||||
name = ":".join(name_parts)
|
||||
|
||||
# Tag: Type's Revit ElementId
|
||||
type_id_entry = _get_nested(inst_params, "Other", "Type Id")
|
||||
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")
|
||||
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(
|
||||
"root.create_entity", self._ifc,
|
||||
ifc_class=type_class,
|
||||
name=name,
|
||||
)
|
||||
if tag:
|
||||
try:
|
||||
type_obj.Tag = str(tag)
|
||||
except Exception:
|
||||
pass
|
||||
if guid:
|
||||
try:
|
||||
type_obj.GlobalId = str(guid)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Write type-level property sets
|
||||
self._write_type_psets(type_obj, obj, ifc_class, type_name, props,
|
||||
type_params, inst_params)
|
||||
return type_obj
|
||||
|
||||
def _write_type_psets(self, type_obj, obj, ifc_class, type_name,
|
||||
props, type_params, inst_params):
|
||||
"""Write psets on the type object (type-level parameters only)."""
|
||||
ifc = self._ifc
|
||||
pset_name = COMMON_PSET.get(ifc_class)
|
||||
|
||||
# ── Standard Common pset on the type ──────────────────────────────
|
||||
if pset_name:
|
||||
type_ifc_props = []
|
||||
|
||||
# IsExternal (type-level)
|
||||
bic = _safe_get(props, "builtInCategory", "")
|
||||
is_external = bic in EXTERNAL_CATEGORIES
|
||||
if ifc_class not in {"IfcSpace", "IfcSite", "IfcBuildingStorey",
|
||||
"IfcBuilding", "IfcFurnishingElement", "IfcOpeningElement"}:
|
||||
p = _make_prop(ifc, "IsExternal", "IfcBoolean", is_external)
|
||||
if p:
|
||||
type_ifc_props.append(p)
|
||||
|
||||
# ThermalTransmittance (from type parameters)
|
||||
if ifc_class in {"IfcWall", "IfcWallStandardCase", "IfcRoof",
|
||||
"IfcSlab", "IfcDoor", "IfcWindow"}:
|
||||
u_val = _param_value(type_params,
|
||||
"ANALYTICAL_HEAT_TRANSFER_COEFFICIENT")
|
||||
if u_val is not None:
|
||||
try:
|
||||
p = _make_prop(ifc, "ThermalTransmittance",
|
||||
"IfcThermalTransmittanceMeasure", float(u_val))
|
||||
if p:
|
||||
type_ifc_props.append(p)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# LoadBearing (from type parameters)
|
||||
if ifc_class in {"IfcWall", "IfcWallStandardCase", "IfcColumn",
|
||||
"IfcBeam", "IfcSlab"}:
|
||||
lb_val = _param_value(type_params, "WALL_STRUCTURAL_SIGNIFICANT")
|
||||
if lb_val is not None:
|
||||
p = _make_prop(ifc, "LoadBearing", "IfcBoolean", bool(lb_val))
|
||||
if p:
|
||||
type_ifc_props.append(p)
|
||||
|
||||
if type_ifc_props:
|
||||
_write_pset(ifc, type_obj, pset_name, type_ifc_props)
|
||||
|
||||
# ── RVT_TypeParameters — all type-level Revit params ──────────────
|
||||
type_flat = _flatten_params(type_params)
|
||||
if type_flat:
|
||||
type_str_props = []
|
||||
for name_p, val in type_flat.items():
|
||||
try:
|
||||
nominal = ifc.create_entity("IfcLabel", wrappedValue=val)
|
||||
prop = ifc.create_entity("IfcPropertySingleValue",
|
||||
Name=name_p, NominalValue=nominal)
|
||||
type_str_props.append(prop)
|
||||
except Exception:
|
||||
pass
|
||||
if type_str_props:
|
||||
_write_pset(ifc, type_obj, "RVT_TypeParameters", type_str_props)
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
# =============================================================================
|
||||
# writer.py
|
||||
# Creates and manages the IFC file structure:
|
||||
# IfcProject → IfcSite → IfcBuilding → IfcBuildingStorey (one per level)
|
||||
#
|
||||
# Also provides StoreyManager which lazily creates storeys on demand
|
||||
# as the traversal encounters new level names.
|
||||
# =============================================================================
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api
|
||||
|
||||
|
||||
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, 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
|
||||
"""
|
||||
ifc = ifcopenshell.file(schema="IFC4X3")
|
||||
|
||||
# Project
|
||||
project = ifcopenshell.api.run(
|
||||
"root.create_entity", ifc,
|
||||
ifc_class="IfcProject",
|
||||
name=project_name,
|
||||
)
|
||||
|
||||
# Units — millimetres (matching Revit/Speckle source data)
|
||||
# This avoids any mm→m conversion errors and keeps coordinates at full precision
|
||||
ifcopenshell.api.run(
|
||||
"unit.assign_unit", ifc,
|
||||
length={"is_metric": True, "raw": "MILLIMETRES"},
|
||||
)
|
||||
|
||||
# Geometry contexts
|
||||
model_ctx = ifcopenshell.api.run(
|
||||
"context.add_context", ifc,
|
||||
context_type="Model",
|
||||
)
|
||||
body_ctx = ifcopenshell.api.run(
|
||||
"context.add_context", ifc,
|
||||
context_type="Model",
|
||||
context_identifier="Body",
|
||||
target_view="MODEL_VIEW",
|
||||
parent=model_ctx,
|
||||
)
|
||||
|
||||
# Spatial hierarchy
|
||||
site = ifcopenshell.api.run(
|
||||
"root.create_entity", ifc,
|
||||
ifc_class="IfcSite",
|
||||
name=site_name,
|
||||
)
|
||||
building = ifcopenshell.api.run(
|
||||
"root.create_entity", ifc,
|
||||
ifc_class="IfcBuilding",
|
||||
name=building_name,
|
||||
)
|
||||
|
||||
ifcopenshell.api.run(
|
||||
"aggregate.assign_object", ifc,
|
||||
relating_object=project,
|
||||
products=[site],
|
||||
)
|
||||
ifcopenshell.api.run(
|
||||
"aggregate.assign_object", ifc,
|
||||
relating_object=site,
|
||||
products=[building],
|
||||
)
|
||||
|
||||
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."""
|
||||
if level_name not in self._storeys:
|
||||
storey = ifcopenshell.api.run(
|
||||
"root.create_entity", self.ifc,
|
||||
ifc_class="IfcBuildingStorey",
|
||||
name=level_name,
|
||||
)
|
||||
ifcopenshell.api.run(
|
||||
"aggregate.assign_object", self.ifc,
|
||||
relating_object=self.building,
|
||||
products=[storey],
|
||||
)
|
||||
self._storeys[level_name] = storey
|
||||
print(f" 🏢 Created storey: {level_name}")
|
||||
|
||||
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)
|
||||
|
||||
@property
|
||||
def names(self) -> list[str]:
|
||||
return list(self._storeys.keys())
|
||||
Reference in New Issue
Block a user