Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fb8ceb73a | |||
| 09dc8eb8c8 | |||
| 38e05ccc28 | |||
| f9193ee3a0 | |||
| 112f031608 | |||
| 673c024ac5 |
@@ -30,4 +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: 5000
|
||||
speckle_function_recommended_memory_mi: 8000
|
||||
|
||||
@@ -1,174 +1,213 @@
|
||||
# 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)
|
||||
- IfcSpace elements aggregated under storeys with Room properties
|
||||
|
||||
### 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 & quantities
|
||||
│ └── Assign IFC type object
|
||||
│
|
||||
▼
|
||||
5. Flush spatial containment & type relationships
|
||||
│
|
||||
▼
|
||||
6. Write .ifc file
|
||||
```
|
||||
|
||||
**For development tools** (like testing or formatting tools):
|
||||
```toml
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"black==23.12.1",
|
||||
"pytest-mock==3.11.1", # Add development dependencies here
|
||||
# ... other dev tools
|
||||
]
|
||||
```
|
||||
## Module Structure
|
||||
|
||||
**How to decide which section?**
|
||||
- If your `main.py` (or other function logic) imports it → `dependencies`
|
||||
- If it's just a tool to help you code → `[project.optional-dependencies].dev`
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `main.py` | Entry point, orchestrates the full pipeline |
|
||||
| `utils/traversal.py` | Walks the Speckle Collection tree (Project > Level > Category > Element) |
|
||||
| `utils/mapper.py` | Classifies Speckle objects into IFC entity types |
|
||||
| `utils/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 and quantities from Revit parameters |
|
||||
| `utils/type_manager.py` | Creates and caches IfcTypeObjects (IfcWallType, etc.) |
|
||||
| `utils/materials.py` | Maps Speckle render materials to IfcSurfaceStyle colours |
|
||||
| `utils/writer.py` | Creates the IFC file scaffold and manages storey creation |
|
||||
| `utils/receiver.py` | Connects to Speckle server and receives model data (uses `.env`) |
|
||||
|
||||
Example:
|
||||
```python
|
||||
# In your main.py
|
||||
import pandas as pd # ← This goes in dependencies
|
||||
import specklepy # ← This goes in dependencies
|
||||
## Mapping Logic
|
||||
|
||||
# You won't import these in main.py:
|
||||
# pytest, black, mypy ← These go in [project.optional-dependencies].dev
|
||||
```
|
||||
Classification of Speckle objects to IFC entity types follows a priority chain. The first match wins.
|
||||
|
||||
### Change launch variables
|
||||
### Priority 1: `builtInCategory` (OST_ enum)
|
||||
|
||||
Describe how the launch.json should be edited.
|
||||
The most reliable source. Read from `obj.properties.builtInCategory`, which contains the Revit `BuiltInCategory` enum value.
|
||||
|
||||
### GitHub Codespaces
|
||||
Examples:
|
||||
| builtInCategory | IFC Class |
|
||||
|---|---|
|
||||
| `OST_Walls` | `IfcWall` |
|
||||
| `OST_Floors` | `IfcSlab` |
|
||||
| `OST_StructuralColumns` | `IfcColumn` |
|
||||
| `OST_StructuralFraming` | `IfcBeam` |
|
||||
| `OST_Doors` | `IfcDoor` |
|
||||
| `OST_Windows` | `IfcWindow` |
|
||||
| `OST_Roofs` | `IfcRoof` |
|
||||
| `OST_CurtainWallPanels` | `IfcCurtainWall` |
|
||||
| `OST_DuctCurves` | `IfcDuctSegment` |
|
||||
| `OST_PipeCurves` | `IfcPipeSegment` |
|
||||
| `OST_LightingFixtures` | `IfcLightFixture` |
|
||||
| `OST_Furniture` | `IfcFurnishingElement` |
|
||||
| `OST_Rooms` | `IfcSpace` |
|
||||
|
||||
Create a new repo from this template, and use the create new code.
|
||||
The full table covers ~70 Revit categories across Architectural, Structural, MEP (HVAC, Plumbing, Electrical), and Site/Civil disciplines.
|
||||
|
||||
### Using this Speckle Function
|
||||
### Priority 2: Category name (display string)
|
||||
|
||||
1. [Create](https://automate.speckle.dev/) a new Speckle Automation.
|
||||
1. Select your Speckle Project and Speckle Model.
|
||||
1. Select the deployed Speckle Function.
|
||||
1. Enter a phrase to use in the comment.
|
||||
1. Click `Create Automation`.
|
||||
The category name from the traversal context (the name of the parent Collection in the Speckle tree). Exact match first, then case-insensitive substring match.
|
||||
|
||||
## Getting Started with Creating Your Own Speckle Function
|
||||
Examples:
|
||||
| Category Name | IFC Class |
|
||||
|---|---|
|
||||
| `Walls` | `IfcWall` |
|
||||
| `Structural Columns` | `IfcColumn` |
|
||||
| `Plumbing Fixtures` | `IfcSanitaryTerminal` |
|
||||
| `Lighting Fixtures` | `IfcLightFixture` |
|
||||
|
||||
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: `obj.category` field
|
||||
|
||||
## Developer Requirements
|
||||
Same lookup as Priority 2, but using the object's own `category` attribute.
|
||||
|
||||
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
|
||||
### Fallback
|
||||
|
||||
pip install --upgrade pip
|
||||
pip install .[dev]
|
||||
```
|
||||
If none of the above match, the object is classified as `IfcBuildingElementProxy`.
|
||||
|
||||
**What this installs:**
|
||||
- All the packages your function needs to run (`dependencies`)
|
||||
- Plus development tools like testing and code formatting (`[project.optional-dependencies].dev`)
|
||||
## Geometry Handling
|
||||
|
||||
**Why separate sections?**
|
||||
- `dependencies`: Only what gets deployed with your function (lightweight)
|
||||
- `dev` dependencies: Extra tools to help you write better code locally
|
||||
### Direct Meshes (Path B1)
|
||||
|
||||
## Building and Testing
|
||||
Objects with `displayValue` containing Mesh objects are converted directly:
|
||||
|
||||
The code can be tested locally by running `pytest`.
|
||||
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
|
||||
|
||||
### Alternative dependency managers
|
||||
### Instance Objects (Path A / B2)
|
||||
|
||||
This template uses the modern **PEP 621** standard in `pyproject.toml`, which works with all modern Python dependency managers:
|
||||
Speckle `InstanceProxy` objects reference shared definition geometry via `definitionId`. The exporter supports two formats:
|
||||
|
||||
#### Using Poetry
|
||||
```bash
|
||||
poetry 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
|
||||
|
||||
#### Using uv
|
||||
```bash
|
||||
uv sync # Automatically reads pyproject.toml
|
||||
```
|
||||
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.
|
||||
|
||||
#### Using pip-tools
|
||||
```bash
|
||||
pip-compile pyproject.toml # Generate requirements.txt from pyproject.toml
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
### Composite Objects (Path B2 — merged instances)
|
||||
|
||||
#### Using pdm
|
||||
```bash
|
||||
pdm install # Automatically reads pyproject.toml
|
||||
```
|
||||
Objects like Windows and Doors may have multiple `InstanceProxy` items in their `displayValue` (e.g. frame, glass, sill). These are **not** separate IFC elements — all instance geometries are merged into a single `IfcShapeRepresentation` with combined `IfcMappedItem` entries, producing one IFC element per Speckle object.
|
||||
|
||||
**Advantage**: All tools read the same `pyproject.toml` file, so there's no need to keep multiple files in sync!
|
||||
## Property Sets
|
||||
|
||||
### Building and running the Docker Container Image
|
||||
The exporter writes property sets matching Revit's native IFC export structure:
|
||||
|
||||
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.
|
||||
| Property Set | Content |
|
||||
|---|---|
|
||||
| `Pset_<Entity>Common` | Standard IFC properties: Reference, IsExternal, LoadBearing, ThermalTransmittance |
|
||||
| `Pset_SpaceCommon` | Room-specific: Reference, RoomNumber, RoomName, Category (Occupant) |
|
||||
| `RVT_InstanceParameters` | All Revit instance parameters |
|
||||
| `RVT_Identity` | Family, Type, ElementId, BuiltInCategory |
|
||||
|
||||
#### Building the Docker Container Image
|
||||
## Quantities
|
||||
|
||||
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.
|
||||
Quantities follow the IFC standard naming convention: `Qto_<EntityType>BaseQuantities` and `Qto_<MaterialName>BaseQuantities`.
|
||||
|
||||
To build the Docker Container Image, you must have [Docker](https://docs.docker.com/get-docker/) installed.
|
||||
| Quantity Set | Content |
|
||||
|---|---|
|
||||
| `Qto_<EntityType>BaseQuantities` | Element-level quantities from Revit computed parameters (area, volume, length, width, height, perimeter) |
|
||||
| `Qto_SpaceBaseQuantities` | Room quantities: NetFloorArea, NetVolume |
|
||||
| `Qto_<MaterialName>BaseQuantities` | Per-material quantities: GrossArea, GrossVolume, Density |
|
||||
|
||||
Once you have Docker running on your local machine:
|
||||
### Element Quantity Mapping
|
||||
|
||||
1. Open a terminal
|
||||
1. Navigate to the directory in which you cloned this repository
|
||||
1. Run the following command:
|
||||
| IFC Quantity | Revit Parameter(s) |
|
||||
|---|---|
|
||||
| GrossArea | `HOST_AREA_COMPUTED` |
|
||||
| GrossVolume | `HOST_VOLUME_COMPUTED` |
|
||||
| Length | `CURVE_ELEM_LENGTH`, `INSTANCE_LENGTH_PARAM` |
|
||||
| Height | `WALL_USER_HEIGHT_PARAM`, `FAMILY_HEIGHT_PARAM`, `INSTANCE_HEAD_HEIGHT_PARAM` |
|
||||
| Width | `INSTANCE_WIDTH_PARAM`, `FURNITURE_WIDTH`, `FLOOR_ATTR_THICKNESS_PARAM` |
|
||||
| Perimeter | `HOST_PERIMETER_COMPUTED` |
|
||||
|
||||
```bash
|
||||
docker build -f ./Dockerfile -t speckle_automate_python_example .
|
||||
```
|
||||
### Supported Entity Qto Sets
|
||||
|
||||
#### Running the Docker Container Image
|
||||
`Qto_WallBaseQuantities`, `Qto_SlabBaseQuantities`, `Qto_ColumnBaseQuantities`, `Qto_BeamBaseQuantities`, `Qto_DoorBaseQuantities`, `Qto_WindowBaseQuantities`, `Qto_RoofBaseQuantities`, `Qto_CoveringBaseQuantities`, `Qto_RailingBaseQuantities`, `Qto_StairBaseQuantities`, `Qto_RampBaseQuantities`, `Qto_MemberBaseQuantities`, `Qto_FootingBaseQuantities`, `Qto_CurtainWallBaseQuantities`, `Qto_BuildingElementProxyBaseQuantities`
|
||||
|
||||
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.
|
||||
## IfcSpace (Rooms)
|
||||
|
||||
1. To then run the Docker Container Image, run the following command:
|
||||
Revit Rooms (`OST_Rooms`) are exported as `IfcSpace` elements with special handling:
|
||||
|
||||
```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
|
||||
```
|
||||
- **Spatial relationship**: Aggregated under `IfcBuildingStorey` via `IfcRelAggregates` (not contained)
|
||||
- **Naming**: Uses the Speckle object `name` attribute (not Family:Type which is "none:none" for rooms)
|
||||
- **IfcSpace.Name**: Set to `ROOM_NUMBER`
|
||||
- **IfcSpace.LongName**: Set to `ROOM_NAME`
|
||||
- **Geometry**: Converted from `displayValue` meshes like any other element
|
||||
|
||||
Let's explain this in more detail:
|
||||
## Function Inputs
|
||||
|
||||
`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.
|
||||
| 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 |
|
||||
|
||||
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:
|
||||
## Environment Variables
|
||||
|
||||
- `'{"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.
|
||||
For local testing via `receiver.py`, configure a `.env` file:
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `SPECKLE_SERVER_URL` | Speckle server URL (default: `https://app.speckle.systems`) |
|
||||
| `SPECKLE_TOKEN` | Personal access token for authentication |
|
||||
| `SPECKLE_PROJECT_ID` | Project (stream) ID |
|
||||
|
||||
## Testing
|
||||
|
||||
| Model Name | Revit Size | IFC Size | Conversion Time |
|
||||
|----------------------------------|------------|----------|-----------------|
|
||||
| Huge confidential model | 450 MB | 391 MB | 2h 30m |
|
||||
| Snowdon Towers (Architecture) | 93.2 MB | 118 MB | 8m 37s |
|
||||
| Speckle Tower | 51 MB | 45 MB | 3m |
|
||||
| Rac Basic Sample Model | 18.8 MB | 12 MB | 12s |
|
||||
|
||||
## Resources
|
||||
|
||||
- [Learn](https://speckle.guide/dev/python.html) more about SpecklePy and interacting with Speckle from Python.
|
||||
- [Speckle Developer Docs](https://speckle.guide/dev/python.html)
|
||||
- [ifcopenshell Documentation](https://ifcopenshell.org/)
|
||||
- [IFC 4.3 Schema](https://standards.buildingsmart.org/IFC/RELEASE/IFC4x3/HTML/)
|
||||
- [Revit BuiltInCategory Reference](https://www.revitapidocs.com/2019/ba1c5b30-242f-5fdc-8ea9-ec3b61e6e722.htm)
|
||||
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
"""Helper module for a simple speckle object tree flattening."""
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
from specklepy.objects import Base
|
||||
|
||||
|
||||
def flatten_base(base: Base) -> Iterable[Base]:
|
||||
"""Flatten a base object into an iterable of bases.
|
||||
|
||||
This function recursively traverses the `elements` or `@elements` attribute of the
|
||||
base object, yielding each nested base object.
|
||||
|
||||
Args:
|
||||
base (Base): The base object to flatten.
|
||||
|
||||
Yields:
|
||||
Base: Each nested base object in the hierarchy.
|
||||
"""
|
||||
# Attempt to get the elements attribute, fallback to @elements if necessary
|
||||
elements = getattr(base, "elements", getattr(base, "@elements", None))
|
||||
|
||||
if elements is not None:
|
||||
for element in elements:
|
||||
yield from flatten_base(element)
|
||||
|
||||
yield base
|
||||
@@ -2,11 +2,9 @@ from datetime import datetime
|
||||
|
||||
import ifcopenshell.api
|
||||
|
||||
import utils.config as config
|
||||
|
||||
from utils.materials import MaterialManager
|
||||
from utils.traversal import traverse, print_tree
|
||||
from utils.mapper import classify, get_predefined_type, reset_caches as reset_mapper_caches
|
||||
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
|
||||
@@ -16,7 +14,7 @@ from utils.type_manager import TypeManager
|
||||
|
||||
SPATIAL_STRUCTURE_TYPES = {
|
||||
"IfcBuilding", "IfcBuildingStorey",
|
||||
"IfcSpace", "IfcExternalSpatialElement", "IfcSpatialZone",
|
||||
"IfcExternalSpatialElement", "IfcSpatialZone",
|
||||
"IfcGrid", "IfcAnnotation",
|
||||
}
|
||||
|
||||
@@ -75,20 +73,22 @@ def automate_function(
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 2. Build definition map (for instance resolution)
|
||||
# ------------------------------------------------------------------ #
|
||||
print("\n🔍 Building definition map...")
|
||||
# ----------------------------------------------
|
||||
definition_map = build_definition_map(base)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 3. Set up IFC
|
||||
# ------------------------------------------------------------------ #
|
||||
ifc, _site, building, body_context = create_ifc_scaffold()
|
||||
ifc, _site, building, body_context = create_ifc_scaffold(
|
||||
project_name=function_inputs.IFC_PROJECT_NAME,
|
||||
site_name=function_inputs.IFC_SITE_NAME,
|
||||
building_name=function_inputs.IFC_BUILDING_NAME,
|
||||
)
|
||||
storey_manager = StoreyManager(ifc, building)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 3b. Build material map from renderMaterialProxies
|
||||
# ------------------------------------------------------------------ #
|
||||
print("\n🎨 Building material map...")
|
||||
material_manager = MaterialManager(ifc, base)
|
||||
type_manager = TypeManager(ifc)
|
||||
|
||||
@@ -110,7 +110,12 @@ def automate_function(
|
||||
skipped_spatial += 1
|
||||
continue
|
||||
|
||||
name = build_element_name(obj)
|
||||
# IfcSpace uses the Speckle object name (e.g. "Rooms - Live/Work Unit 507")
|
||||
# instead of Family:Type (which is "none:none" for Revit rooms)
|
||||
if ifc_class == "IfcSpace":
|
||||
name = getattr(obj, "name", None) or build_element_name(obj)
|
||||
else:
|
||||
name = build_element_name(obj)
|
||||
storey = storey_manager.get_or_create(level_name)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -128,9 +133,10 @@ def automate_function(
|
||||
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),
|
||||
predefined_type=get_predefined_type(obj))
|
||||
)
|
||||
write_properties(ifc, element, obj, ifc_class=ifc_class, category_name=category_name)
|
||||
type_manager.assign(element, obj, ifc_class)
|
||||
instance_count += 1
|
||||
@@ -147,34 +153,46 @@ def automate_function(
|
||||
rep, placement = mesh_to_ifc(ifc, body_context, obj, scale=scale, material_manager=material_manager)
|
||||
if rep:
|
||||
element = _create_element(ifc, ifc_class, name, rep, placement, storey,
|
||||
storey_manager=storey_manager,
|
||||
tag=get_element_tag(obj), guid=get_ifc_guid(obj),
|
||||
object_type=getattr(obj, "type", None),
|
||||
predefined_type=get_predefined_type(obj))
|
||||
)
|
||||
write_properties(ifc, element, obj, ifc_class=ifc_class, category_name=category_name)
|
||||
type_manager.assign(element, obj, ifc_class)
|
||||
total += 1
|
||||
|
||||
# B2: Instance objects nested inside displayValue
|
||||
# Each becomes its own IFC element (same class as parent)
|
||||
# Use the parent object's name — the InstanceProxy has no meaningful name
|
||||
# All instances are parts of the SAME element (e.g. window frame + glass + sill)
|
||||
# Merge all into a single IFC element with combined geometry
|
||||
nested_instances = get_display_instances(obj)
|
||||
for inst in nested_instances:
|
||||
inst_rep, inst_placement = instance_to_ifc(
|
||||
ifc, body_context, inst, definition_map, scale=scale, material_manager=material_manager
|
||||
)
|
||||
if not inst_rep:
|
||||
no_geometry += 1
|
||||
continue
|
||||
inst_element = _create_element(
|
||||
ifc, ifc_class, name, inst_rep, inst_placement, storey,
|
||||
tag=get_element_tag(obj), guid=None,
|
||||
object_type=getattr(obj, "type", None),
|
||||
predefined_type=get_predefined_type(obj),
|
||||
)
|
||||
write_properties(ifc, inst_element, obj, ifc_class=ifc_class, category_name=category_name)
|
||||
type_manager.assign(inst_element, obj, ifc_class)
|
||||
instance_count += 1
|
||||
total += 1
|
||||
if nested_instances:
|
||||
mapped_items = []
|
||||
inst_placement = None
|
||||
for inst in nested_instances:
|
||||
inst_rep, inst_pl = instance_to_ifc(
|
||||
ifc, body_context, inst, definition_map, scale=scale, material_manager=material_manager
|
||||
)
|
||||
if inst_rep:
|
||||
mapped_items.extend(inst_rep.Items)
|
||||
if inst_placement is None:
|
||||
inst_placement = inst_pl
|
||||
if mapped_items:
|
||||
combined_rep = ifc.createIfcShapeRepresentation(
|
||||
ContextOfItems=body_context,
|
||||
RepresentationIdentifier="Body",
|
||||
RepresentationType="MappedRepresentation",
|
||||
Items=mapped_items,
|
||||
)
|
||||
element = _create_element(
|
||||
ifc, ifc_class, name, combined_rep, inst_placement, storey,
|
||||
storey_manager=storey_manager,
|
||||
tag=get_element_tag(obj), guid=get_ifc_guid(obj),
|
||||
object_type=getattr(obj, "type", None),
|
||||
)
|
||||
write_properties(ifc, element, obj, ifc_class=ifc_class, category_name=category_name)
|
||||
type_manager.assign(element, obj, ifc_class)
|
||||
instance_count += 1
|
||||
total += 1
|
||||
|
||||
# Track if neither path produced geometry
|
||||
if not rep and not nested_instances:
|
||||
@@ -186,7 +204,9 @@ def automate_function(
|
||||
# ------------------------------------------------------------------ #
|
||||
# 5. Write output
|
||||
# ------------------------------------------------------------------ #
|
||||
print("\n🔗 Flushing type relationships...")
|
||||
print("\n🔗 Flushing spatial containment...")
|
||||
storey_manager.flush()
|
||||
print("🔗 Flushing type relationships...")
|
||||
type_manager.flush()
|
||||
|
||||
file_name = function_inputs.file_name
|
||||
@@ -215,12 +235,11 @@ def automate_function(
|
||||
print(f"{'=' * 60}\n")
|
||||
|
||||
def _create_element(ifc, ifc_class, name, rep, placement, storey,
|
||||
tag=None, guid=None, object_type=None, predefined_type=None):
|
||||
"""Helper: create an IFC element, assign geometry + placement + container."""
|
||||
kwargs = {"ifc_class": ifc_class, "name": str(name)}
|
||||
if predefined_type:
|
||||
kwargs["predefined_type"] = predefined_type
|
||||
element = ifcopenshell.api.run("root.create_entity", ifc, **kwargs)
|
||||
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)
|
||||
@@ -246,20 +265,14 @@ def _create_element(ifc, ifc_class, name, rep, placement, storey,
|
||||
else:
|
||||
element.ObjectPlacement = _make_placement(ifc, 0.0, 0.0, 0.0)
|
||||
|
||||
# IfcSite is a spatial structure element — can't use spatial.assign_container.
|
||||
# Use aggregate.assign_object to nest it under the storey instead.
|
||||
if ifc_class == "IfcSite":
|
||||
ifcopenshell.api.run(
|
||||
"aggregate.assign_object", ifc,
|
||||
relating_object=storey,
|
||||
products=[element],
|
||||
)
|
||||
else:
|
||||
ifcopenshell.api.run(
|
||||
"spatial.assign_container", ifc,
|
||||
relating_structure=storey,
|
||||
products=[element],
|
||||
)
|
||||
# Queue spatial assignment (batched flush at end for performance)
|
||||
# IfcSpace is a spatial structure element — must be decomposed (aggregated)
|
||||
# under its IfcBuildingStorey, not spatially contained.
|
||||
if storey_manager:
|
||||
if ifc_class in ("IfcSite", "IfcSpace"):
|
||||
storey_manager.queue_aggregate(storey, element)
|
||||
else:
|
||||
storey_manager.queue_contain(storey, element)
|
||||
return element
|
||||
|
||||
# make sure to call the function with the executor
|
||||
|
||||
+3
-2
@@ -7,10 +7,11 @@ maintainers = [{ name = "Speckle Systems", email = "hello@speckle.systems" }]
|
||||
description = "A Speckle Automate function template using specklepy"
|
||||
readme = "README.md"
|
||||
license = "Apache-2.0"
|
||||
keywords = ["speckle", "automate", "bim", "aec"]
|
||||
keywords = ["speckle", "automate", "bim", "aec", "ifc", "export", "revit"]
|
||||
|
||||
dependencies = ["specklepy==3.1.0",
|
||||
"ifcopenshell==0.8.4.post1",]
|
||||
"ifcopenshell==0.8.4.post1",
|
||||
"python-dotenv>=1.0.0",]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
# =============================================================================
|
||||
# config.py
|
||||
# All user-facing settings. Edit this file before running main.py.
|
||||
# =============================================================================
|
||||
|
||||
# --- Speckle Connection ---
|
||||
SPECKLE_HOST = "app.speckle.systems" # or your self-hosted server URL
|
||||
SPECKLE_TOKEN = "****" # from app.speckle.systems/profile
|
||||
|
||||
# --- Speckle Project ---
|
||||
PROJECT_ID = "d7d987146d" # the stream/project ID from the URL
|
||||
VERSION_ID = "d59178f01e" # the specific version/commit to export
|
||||
|
||||
# --- IFC Output ---
|
||||
OUTPUT_PATH = "output3.ifc" # where to write the IFC file
|
||||
IFC_SCHEMA = "IFC4X3" # IFC4X3 = IFC4.3
|
||||
|
||||
# --- Project Metadata (written into the IFC file) ---
|
||||
IFC_PROJECT_NAME = "Speckle Export"
|
||||
IFC_SITE_NAME = "Site"
|
||||
IFC_BUILDING_NAME = "Building"
|
||||
|
||||
# --- Units ---
|
||||
# Speckle unit → metres scale factor
|
||||
# The exporter reads units from the root object automatically,
|
||||
# but this is the fallback if units are not set on the stream.
|
||||
DEFAULT_UNITS = "mm"
|
||||
UNIT_SCALE = {
|
||||
"mm": 0.001,
|
||||
"cm": 0.01,
|
||||
"m": 1.0,
|
||||
"ft": 0.3048,
|
||||
"in": 0.0254,
|
||||
}
|
||||
+73
-51
@@ -31,11 +31,7 @@ _UNIT_SCALES = {
|
||||
|
||||
# Minimum distance in mm below which two vertices are considered identical (GEM111).
|
||||
_VERTEX_MERGE_TOL = 0.01 # 0.01 mm
|
||||
|
||||
|
||||
def snap_coord(v: float) -> int:
|
||||
"""Snap a coordinate to integer grid at _VERTEX_MERGE_TOL resolution."""
|
||||
return round(v / _VERTEX_MERGE_TOL)
|
||||
_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:
|
||||
@@ -51,21 +47,12 @@ def build_ifc_facesets(ifc, verts_scaled: list, face_groups: list) -> list:
|
||||
face_groups: list of index lists [[i,j,k], [i,j,k,l], ...]
|
||||
Returns: list of IfcPolygonalFaceSet (typically one, empty on failure).
|
||||
"""
|
||||
# Build deduplicated vertex list via snap grid
|
||||
snap_to_idx = {} # snap_key → 0-based index in deduped_verts
|
||||
deduped_verts = [] # [(x, y, z), ...]
|
||||
|
||||
def get_vertex_index(x, y, z):
|
||||
key = (snap_coord(x), snap_coord(y), snap_coord(z))
|
||||
if key in snap_to_idx:
|
||||
return snap_to_idx[key], key
|
||||
idx = len(deduped_verts)
|
||||
snap_to_idx[key] = idx
|
||||
deduped_verts.append((x, y, z))
|
||||
return idx, key
|
||||
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, idx1, idx2, ...] (0-based into deduped_verts)
|
||||
valid_faces = [] # list of (idx0+1, idx1+1, ...) tuples (1-based for IFC)
|
||||
for indices in face_groups:
|
||||
try:
|
||||
remapped = []
|
||||
@@ -73,15 +60,21 @@ def build_ifc_facesets(ifc, verts_scaled: list, face_groups: list) -> list:
|
||||
degenerate = False
|
||||
|
||||
for i in indices:
|
||||
x = float(verts_scaled[i * 3])
|
||||
y = float(verts_scaled[i * 3 + 1])
|
||||
z = float(verts_scaled[i * 3 + 2])
|
||||
idx, snap_key = get_vertex_index(x, y, z)
|
||||
if snap_key in seen_snaps:
|
||||
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(snap_key)
|
||||
remapped.append(idx)
|
||||
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
|
||||
@@ -94,15 +87,10 @@ def build_ifc_facesets(ifc, verts_scaled: list, face_groups: list) -> list:
|
||||
|
||||
# Build IFC entities
|
||||
try:
|
||||
point_list = ifc.createIfcCartesianPointList3D(
|
||||
[list(v) for v in deduped_verts]
|
||||
)
|
||||
ifc_faces = []
|
||||
for face_indices in valid_faces:
|
||||
# IfcIndexedPolygonalFace uses 1-based indices
|
||||
coord_index = [idx + 1 for idx in face_indices]
|
||||
ifc_faces.append(ifc.createIfcIndexedPolygonalFace(coord_index))
|
||||
|
||||
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:
|
||||
@@ -270,8 +258,10 @@ def decode_faces(faces_raw: list) -> list:
|
||||
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 = int(faces_raw[i])
|
||||
n = faces_raw[i] if already_int else int(faces_raw[i])
|
||||
if n == 0:
|
||||
n = 3
|
||||
elif n == 1:
|
||||
@@ -279,8 +269,10 @@ def decode_faces(faces_raw: list) -> list:
|
||||
end = i + 1 + n
|
||||
if end > total:
|
||||
break
|
||||
# Direct slice is faster than list comprehension with int()
|
||||
decoded.append([int(v) for v in faces_raw[i + 1:end]])
|
||||
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
|
||||
|
||||
@@ -291,25 +283,55 @@ def decode_faces(faces_raw: list) -> list:
|
||||
|
||||
def compute_origin(flat_verts: list) -> tuple:
|
||||
"""
|
||||
Compute placement origin from scaled vertex list (metres).
|
||||
Compute placement origin from scaled vertex list (mm).
|
||||
X, Y = bounding box centroid
|
||||
Z = minimum Z (bottom face of element — more natural for IFC)
|
||||
Single-pass to avoid creating 3 sliced copies of a large list.
|
||||
"""
|
||||
xs = flat_verts[0::3]
|
||||
ys = flat_verts[1::3]
|
||||
zs = flat_verts[2::3]
|
||||
cx = (min(xs) + max(xs)) / 2.0
|
||||
cy = (min(ys) + max(ys)) / 2.0
|
||||
cz = min(zs)
|
||||
return cx, cy, cz
|
||||
x0 = flat_verts[0]
|
||||
y0 = flat_verts[1]
|
||||
z0 = flat_verts[2]
|
||||
xmin = xmax = x0
|
||||
ymin = ymax = y0
|
||||
zmin = z0
|
||||
for i in range(3, len(flat_verts) - 2, 3):
|
||||
x = flat_verts[i]
|
||||
y = flat_verts[i + 1]
|
||||
z = flat_verts[i + 2]
|
||||
if x < xmin:
|
||||
xmin = x
|
||||
elif x > xmax:
|
||||
xmax = x
|
||||
if y < ymin:
|
||||
ymin = y
|
||||
elif y > ymax:
|
||||
ymax = y
|
||||
if z < zmin:
|
||||
zmin = z
|
||||
return (xmin + xmax) / 2.0, (ymin + ymax) / 2.0, zmin
|
||||
|
||||
|
||||
# Cache for shared IFC direction/point entities (keyed by ifc file id)
|
||||
_shared_entities: dict[int, dict] = {}
|
||||
|
||||
|
||||
def _get_shared(ifc):
|
||||
"""Return (or create) shared IfcDirection and IfcCartesianPoint entities for this file."""
|
||||
fid = id(ifc)
|
||||
if fid not in _shared_entities:
|
||||
_shared_entities[fid] = {
|
||||
"z_axis": ifc.createIfcDirection([0.0, 0.0, 1.0]),
|
||||
"x_axis": ifc.createIfcDirection([1.0, 0.0, 0.0]),
|
||||
"origin_0": ifc.createIfcCartesianPoint([0.0, 0.0, 0.0]),
|
||||
}
|
||||
return _shared_entities[fid]
|
||||
|
||||
|
||||
def _make_placement(ifc, x: float, y: float, z: float):
|
||||
"""Create an IfcLocalPlacement at absolute world coordinates (metres)."""
|
||||
shared = _get_shared(ifc)
|
||||
origin = ifc.createIfcCartesianPoint([x, y, z])
|
||||
z_axis = ifc.createIfcDirection([0.0, 0.0, 1.0])
|
||||
x_axis = ifc.createIfcDirection([1.0, 0.0, 0.0])
|
||||
a2p = ifc.createIfcAxis2Placement3D(origin, z_axis, x_axis)
|
||||
a2p = ifc.createIfcAxis2Placement3D(origin, shared["z_axis"], shared["x_axis"])
|
||||
return ifc.createIfcLocalPlacement(PlacementRelTo=None, RelativePlacement=a2p)
|
||||
|
||||
|
||||
@@ -343,7 +365,7 @@ def mesh_to_ifc(
|
||||
all_scaled = []
|
||||
for mesh in meshes:
|
||||
raw_verts = _get(mesh, "vertices") or []
|
||||
verts = unwrap_chunks(list(raw_verts))
|
||||
verts = unwrap_chunks(raw_verts if isinstance(raw_verts, list) else list(raw_verts))
|
||||
if not verts:
|
||||
mesh_cache.append(None)
|
||||
continue
|
||||
@@ -368,7 +390,7 @@ def mesh_to_ifc(
|
||||
continue
|
||||
verts, ms, scaled = cached
|
||||
raw_faces = _get(mesh, "faces") or []
|
||||
faces_raw = unwrap_chunks(list(raw_faces))
|
||||
faces_raw = unwrap_chunks(raw_faces if isinstance(raw_faces, list) else list(raw_faces))
|
||||
|
||||
if not faces_raw:
|
||||
continue
|
||||
@@ -379,10 +401,10 @@ def mesh_to_ifc(
|
||||
print(f" ⚠️ Face decode error: {e}")
|
||||
continue
|
||||
|
||||
# Offset pre-scaled vertices relative to origin
|
||||
# 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 - 2, 3):
|
||||
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
|
||||
|
||||
+23
-56
@@ -22,7 +22,7 @@
|
||||
|
||||
import math
|
||||
from specklepy.objects.base import Base
|
||||
from utils.geometry import _get, unwrap_chunks, decode_faces, _UNIT_SCALES, build_ifc_facesets
|
||||
from utils.geometry import _get, unwrap_chunks, decode_faces, _UNIT_SCALES, build_ifc_facesets, _get_shared
|
||||
|
||||
|
||||
def is_instance(obj) -> bool:
|
||||
@@ -76,32 +76,6 @@ def build_definition_map(root: Base) -> dict:
|
||||
print(f" IFC definition proxies: {len(ifc_proxies)}")
|
||||
print(f" IFC definition meshes: {len(ifc_meshes)}")
|
||||
|
||||
# Diagnostic: dump first 3 instanceDefinitionProxies to understand structure
|
||||
print("\n [PROXY DIAG] First 3 instanceDefinitionProxies from root:")
|
||||
if proxies_raw:
|
||||
sample = proxies_raw if isinstance(proxies_raw, list) else [proxies_raw]
|
||||
for i, proxy in enumerate(sample[:3]):
|
||||
app_id = _get(proxy, "applicationId") or "?"
|
||||
name = _get(proxy, "name") or "?"
|
||||
objects = _get(proxy, "objects") or []
|
||||
obj_ids = list(objects)[:3] if objects else []
|
||||
print(f" [{i}] appId={app_id}")
|
||||
print(f" name={name}")
|
||||
print(f" objects={obj_ids} (len={len(list(objects)) if objects else 0})")
|
||||
# Check if first object is found in our maps
|
||||
if obj_ids:
|
||||
oid = str(obj_ids[0])
|
||||
in_by_id = oid.lower()[:32] in by_id
|
||||
in_by_app_id = oid.lower() in by_app_id
|
||||
print(f" objects[0]='{oid}' → in by_id: {in_by_id}, in by_app_id: {in_by_app_id}")
|
||||
else:
|
||||
print(" [PROXY DIAG] No instanceDefinitionProxies found on root!")
|
||||
# Check where they might be
|
||||
for key in ["@instanceDefinitionProxies", "instancedefinitionproxies"]:
|
||||
val = _get(root, key)
|
||||
if val:
|
||||
print(f" Found under key '{key}': {type(val)}")
|
||||
|
||||
return {
|
||||
"by_id": by_id,
|
||||
"by_app_id": by_app_id,
|
||||
@@ -217,17 +191,19 @@ def _resolve_instance_scale(obj, stream_scale: float) -> float:
|
||||
|
||||
|
||||
# Stats
|
||||
_stats = {"found": 0, "not_found": 0}
|
||||
_dbg_cnt = [0]
|
||||
_stats = {"found": 0, "not_found": 0}
|
||||
|
||||
# Cache: mesh id → (verts_flat, face_groups, ms) to avoid re-unpacking
|
||||
# the same definition mesh across many instances that share it.
|
||||
# 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,
|
||||
@@ -253,7 +229,7 @@ def _build_rep_map(ifc, body_context, meshes: list, ifc_format: bool,
|
||||
for mesh in meshes:
|
||||
mesh_id = _get(mesh, "id") or _get(mesh, "applicationId")
|
||||
if mesh_id and mesh_id in _mesh_data_cache:
|
||||
verts, face_groups, ms = _mesh_data_cache[mesh_id]
|
||||
verts_local, face_groups = _mesh_data_cache[mesh_id]
|
||||
else:
|
||||
raw_verts = _get(mesh, "vertices") or []
|
||||
raw_faces = _get(mesh, "faces") or []
|
||||
@@ -271,15 +247,11 @@ def _build_rep_map(ifc, body_context, meshes: list, ifc_format: bool,
|
||||
print(f" ⚠️ Instance face decode: {e}")
|
||||
continue
|
||||
|
||||
if mesh_id:
|
||||
_mesh_data_cache[mesh_id] = (verts, face_groups, ms)
|
||||
# Scale vertices once and cache the result
|
||||
verts_local = [float(v) * ms for v in verts]
|
||||
|
||||
# Scale vertices to mm (local coordinates, no instance transform)
|
||||
verts_local = []
|
||||
for vi in range(0, len(verts) - 2, 3):
|
||||
verts_local.append(float(verts[vi]) * ms)
|
||||
verts_local.append(float(verts[vi+1]) * ms)
|
||||
verts_local.append(float(verts[vi+2]) * ms)
|
||||
if mesh_id:
|
||||
_mesh_data_cache[mesh_id] = (verts_local, face_groups)
|
||||
|
||||
mesh_facesets = build_ifc_facesets(ifc, verts_local, face_groups)
|
||||
|
||||
@@ -298,9 +270,9 @@ def _build_rep_map(ifc, body_context, meshes: list, ifc_format: bool,
|
||||
if not geom_items:
|
||||
return None
|
||||
|
||||
# Mapping origin = identity (local coords origin)
|
||||
origin = ifc.createIfcCartesianPoint([0.0, 0.0, 0.0])
|
||||
a2p = ifc.createIfcAxis2Placement3D(origin, None, 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(
|
||||
@@ -403,18 +375,13 @@ def instance_to_ifc(ifc, body_context, obj: Base, definition_map: dict,
|
||||
# Revit format transform is already in mm (same as IFC file units)
|
||||
ts = 1000.0 if ifc_format else _resolve_instance_scale(obj, scale)
|
||||
|
||||
if _dbg_cnt[0] < 6:
|
||||
_dbg_cnt[0] += 1
|
||||
fmt = "IFC" if ifc_format else "Revit"
|
||||
x_axis = (round(t[0],2), round(t[1],2), round(t[2],2))
|
||||
z_axis = (round(t[8],2), round(t[9],2), round(t[10],2))
|
||||
print(f" [INST {_dbg_cnt[0]} {fmt}] {definition_id[:40]}")
|
||||
print(f" t[3]={t[3]:.1f} t[7]={t[7]:.1f} t[11]={t[11]:.1f} x={x_axis} z={z_axis}")
|
||||
|
||||
# Identity placement (transform is encoded in the MappedItem)
|
||||
origin = ifc.createIfcCartesianPoint([0.0, 0.0, 0.0])
|
||||
a2p = ifc.createIfcAxis2Placement3D(origin, None, None)
|
||||
placement = ifc.createIfcLocalPlacement(PlacementRelTo=None, RelativePlacement=a2p)
|
||||
# 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:
|
||||
@@ -497,6 +464,6 @@ 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
|
||||
_dbg_cnt[0] = 0
|
||||
|
||||
+8
-69
@@ -4,9 +4,8 @@
|
||||
#
|
||||
# Strategy (priority order):
|
||||
# 1. builtInCategory (OST_ enum from properties.builtInCategory) — most reliable
|
||||
# 2. speckle_type prefix match — for typed Speckle objects
|
||||
# 3. category_name string (traversal context) — display name fallback
|
||||
# 4. IfcBuildingElementProxy — last resort
|
||||
# 2. category_name string (traversal context) — display name fallback
|
||||
# 3. IfcBuildingElementProxy — last resort
|
||||
#
|
||||
# builtInCategory values: https://www.revitapidocs.com/2019/ba1c5b30-242f-5fdc-8ea9-ec3b61e6e722.htm
|
||||
# =============================================================================
|
||||
@@ -119,50 +118,7 @@ BUILTIN_CATEGORY_MAP: dict[str, str] = {
|
||||
}
|
||||
|
||||
|
||||
# --- OST_ BuiltInCategory → PredefinedType (where applicable) ---
|
||||
BUILTIN_PREDEFINED_TYPE: dict[str, str] = {
|
||||
"OST_RailingTopRail": "HANDRAIL",
|
||||
}
|
||||
|
||||
|
||||
# --- 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) ---
|
||||
# --- Display category name → IFC class (secondary fallback) ---
|
||||
CATEGORY_MAP: dict[str, str] = {
|
||||
"Walls": "IfcWall",
|
||||
"Floors": "IfcSlab",
|
||||
@@ -207,14 +163,6 @@ CATEGORY_MAP: dict[str, str] = {
|
||||
}
|
||||
|
||||
|
||||
def get_predefined_type(obj) -> str | None:
|
||||
"""Return the IFC PredefinedType for an object based on its builtInCategory, or None."""
|
||||
bic = _get_builtin_category(obj)
|
||||
if bic and bic in BUILTIN_PREDEFINED_TYPE:
|
||||
return BUILTIN_PREDEFINED_TYPE[bic]
|
||||
return None
|
||||
|
||||
|
||||
_bic_cache: dict[int, str | None] = {} # id(obj) → builtInCategory
|
||||
|
||||
|
||||
@@ -264,10 +212,9 @@ def classify(obj, category_name: str = "") -> str:
|
||||
|
||||
Priority:
|
||||
1. properties.builtInCategory (OST_ enum) — definitive Revit classification
|
||||
2. speckle_type prefix match
|
||||
3. category_name from traversal context (display string)
|
||||
4. obj.category field
|
||||
5. IfcBuildingElementProxy fallback
|
||||
2. category_name from traversal context (display string)
|
||||
3. obj.category field
|
||||
4. IfcBuildingElementProxy fallback
|
||||
"""
|
||||
cache_key = (id(obj), category_name)
|
||||
if cache_key in _classify_cache:
|
||||
@@ -284,15 +231,7 @@ def _classify_impl(obj, category_name: str) -> str:
|
||||
if bic and bic in BUILTIN_CATEGORY_MAP:
|
||||
return BUILTIN_CATEGORY_MAP[bic]
|
||||
|
||||
# 2. speckle_type
|
||||
speckle_type = getattr(obj, "speckle_type", "") or ""
|
||||
if speckle_type in SPECKLE_TYPE_MAP:
|
||||
return SPECKLE_TYPE_MAP[speckle_type]
|
||||
for key, ifc_class in SPECKLE_TYPE_MAP.items():
|
||||
if speckle_type.startswith(key):
|
||||
return ifc_class
|
||||
|
||||
# 3. category_name from traversal context
|
||||
# 2. category_name from traversal context — exact match first
|
||||
if category_name:
|
||||
if category_name in CATEGORY_MAP:
|
||||
return CATEGORY_MAP[category_name]
|
||||
@@ -301,7 +240,7 @@ def _classify_impl(obj, category_name: str) -> str:
|
||||
if key_lower in cat_lower:
|
||||
return ifc_class
|
||||
|
||||
# 4. obj.category field
|
||||
# 3. obj.category field
|
||||
obj_category = getattr(obj, "category", None)
|
||||
if obj_category and isinstance(obj_category, str):
|
||||
if obj_category in CATEGORY_MAP:
|
||||
|
||||
+197
-3
@@ -424,9 +424,104 @@ def write_common_pset(ifc, element, obj: Base, ifc_class: str, category_name: st
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# IfcSpace-specific: set Name, LongName, Category, and BaseQuantities
|
||||
if ifc_class == "IfcSpace":
|
||||
_write_space_properties(ifc, element, obj, ifc_props)
|
||||
|
||||
_write_pset(ifc, element, pset_name, ifc_props)
|
||||
|
||||
|
||||
def _write_space_properties(ifc, element, obj: Base, ifc_props: list):
|
||||
"""
|
||||
Set IfcSpace attributes and BaseQuantities from Revit Room parameters.
|
||||
|
||||
Uses internalDefinitionName to find values:
|
||||
ROOM_NUMBER → IfcSpace.Name + Pset_SpaceCommon.Reference
|
||||
ROOM_NAME → IfcSpace.LongName
|
||||
Occupant → Pset_SpaceCommon.Category
|
||||
ROOM_AREA → Qto_SpaceBaseQuantities.NetFloorArea
|
||||
ROOM_VOLUME → Qto_SpaceBaseQuantities.NetVolume
|
||||
"""
|
||||
props = _get_props_dict(obj)
|
||||
params = _safe_get(props, "Parameters", {})
|
||||
inst_params = _safe_get(params, "Instance Parameters", {})
|
||||
|
||||
# --- Room Number → IfcSpace.Name + Pset_SpaceCommon.Reference ---
|
||||
room_number = _param_value(inst_params, "ROOM_NUMBER")
|
||||
if room_number:
|
||||
room_number = str(room_number).strip()
|
||||
element.Name = room_number
|
||||
# Replace any existing Reference in ifc_props
|
||||
ifc_props[:] = [p for p in ifc_props if p.Name != "Reference"]
|
||||
p = _make_prop(ifc, "Reference", "IfcIdentifier", room_number)
|
||||
if p:
|
||||
ifc_props.append(p)
|
||||
# Also add as explicit RoomNumber in the pset
|
||||
p = _make_prop(ifc, "RoomNumber", "IfcLabel", room_number)
|
||||
if p:
|
||||
ifc_props.append(p)
|
||||
|
||||
# --- Room Name → IfcSpace.LongName + Pset_SpaceCommon.RoomName ---
|
||||
room_name = _param_value(inst_params, "ROOM_NAME")
|
||||
if not room_name:
|
||||
# Fallback to the Speckle object's own name
|
||||
room_name = getattr(obj, "name", None)
|
||||
if room_name:
|
||||
room_name = str(room_name).strip()
|
||||
try:
|
||||
element.LongName = room_name
|
||||
except AttributeError:
|
||||
pass
|
||||
p = _make_prop(ifc, "RoomName", "IfcLabel", room_name)
|
||||
if p:
|
||||
ifc_props.append(p)
|
||||
|
||||
# --- Occupant → Pset_SpaceCommon.Category ---
|
||||
occupant = _param_value(inst_params, "Occupant")
|
||||
if occupant:
|
||||
p = _make_prop(ifc, "Category", "IfcLabel", str(occupant).strip())
|
||||
if p:
|
||||
ifc_props.append(p)
|
||||
|
||||
# --- Area & Volume → Qto_SpaceBaseQuantities ---
|
||||
quantities = []
|
||||
|
||||
area_val = _param_value(inst_params, "ROOM_AREA")
|
||||
if area_val is not None:
|
||||
try:
|
||||
q = ifc.create_entity(
|
||||
"IfcQuantityArea",
|
||||
Name="NetFloorArea",
|
||||
AreaValue=float(area_val),
|
||||
)
|
||||
quantities.append(q)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
volume_val = _param_value(inst_params, "ROOM_VOLUME")
|
||||
if volume_val is not None:
|
||||
try:
|
||||
q = ifc.create_entity(
|
||||
"IfcQuantityVolume",
|
||||
Name="NetVolume",
|
||||
VolumeValue=float(volume_val),
|
||||
)
|
||||
quantities.append(q)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if quantities:
|
||||
try:
|
||||
qto = ifcopenshell.api.run(
|
||||
"pset.add_qto", ifc,
|
||||
product=element,
|
||||
name="Qto_SpaceBaseQuantities",
|
||||
)
|
||||
qto.Quantities = quantities
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Qto_SpaceBaseQuantities: {e}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pset_EnvironmentalImpactIndicators (always written, Reference = TypeName)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -549,7 +644,7 @@ def write_material_quantities(ifc, element, obj: Base):
|
||||
Source: properties."Material Quantities".<MaterialName>.{area, volume, density,
|
||||
materialName, materialClass, materialCategory}
|
||||
|
||||
Each material produces one IfcElementQuantity named "Qto_<MaterialName>" with:
|
||||
Each material produces one IfcElementQuantity named "Qto_<MaterialName>BaseQuantities" with:
|
||||
- GrossArea (IfcQuantityArea)
|
||||
- GrossVolume (IfcQuantityVolume)
|
||||
- Density (IfcPropertySingleValue — no standard IFC quantity type)
|
||||
@@ -616,7 +711,7 @@ def write_material_quantities(ifc, element, obj: Base):
|
||||
continue
|
||||
|
||||
# Create IfcElementQuantity and link via IfcRelDefinesByProperties
|
||||
qto_name = f"Qto_{mat_name}"
|
||||
qto_name = f"Qto_{mat_name}BaseQuantities"
|
||||
try:
|
||||
qto = ifcopenshell.api.run(
|
||||
"pset.add_qto", ifc,
|
||||
@@ -628,6 +723,103 @@ def write_material_quantities(ifc, element, obj: Base):
|
||||
print(f" ⚠️ {qto_name}: {e}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Qto_<EntityType>BaseQuantities — standard element-level quantities
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# IFC entity → Qto name (only entities with standard Qto sets)
|
||||
_ENTITY_QTO_NAME: dict[str, str] = {
|
||||
"IfcWall": "Qto_WallBaseQuantities",
|
||||
"IfcWallStandardCase": "Qto_WallBaseQuantities",
|
||||
"IfcSlab": "Qto_SlabBaseQuantities",
|
||||
"IfcColumn": "Qto_ColumnBaseQuantities",
|
||||
"IfcBeam": "Qto_BeamBaseQuantities",
|
||||
"IfcDoor": "Qto_DoorBaseQuantities",
|
||||
"IfcWindow": "Qto_WindowBaseQuantities",
|
||||
"IfcRoof": "Qto_RoofBaseQuantities",
|
||||
"IfcCovering": "Qto_CoveringBaseQuantities",
|
||||
"IfcRailing": "Qto_RailingBaseQuantities",
|
||||
"IfcStair": "Qto_StairBaseQuantities",
|
||||
"IfcRamp": "Qto_RampBaseQuantities",
|
||||
"IfcMember": "Qto_MemberBaseQuantities",
|
||||
"IfcFooting": "Qto_FootingBaseQuantities",
|
||||
"IfcCurtainWall": "Qto_CurtainWallBaseQuantities",
|
||||
"IfcBuildingElementProxy": "Qto_BuildingElementProxyBaseQuantities",
|
||||
}
|
||||
|
||||
# IFC quantity name → (IFC entity type, value attribute, [Revit param fallbacks])
|
||||
# First matching Revit param wins for each quantity name.
|
||||
_ELEMENT_QUANTITY_DEFS: list[tuple[str, str, str, list[str]]] = [
|
||||
("GrossArea", "IfcQuantityArea", "AreaValue", ["HOST_AREA_COMPUTED"]),
|
||||
("GrossVolume", "IfcQuantityVolume", "VolumeValue", ["HOST_VOLUME_COMPUTED"]),
|
||||
("Length", "IfcQuantityLength", "LengthValue", [
|
||||
"CURVE_ELEM_LENGTH", "INSTANCE_LENGTH_PARAM",
|
||||
]),
|
||||
("Height", "IfcQuantityLength", "LengthValue", [
|
||||
"WALL_USER_HEIGHT_PARAM", "FAMILY_HEIGHT_PARAM",
|
||||
"INSTANCE_HEAD_HEIGHT_PARAM",
|
||||
]),
|
||||
("Width", "IfcQuantityLength", "LengthValue", [
|
||||
"INSTANCE_WIDTH_PARAM", "FURNITURE_WIDTH",
|
||||
"FLOOR_ATTR_THICKNESS_PARAM",
|
||||
]),
|
||||
("Perimeter", "IfcQuantityLength", "LengthValue", [
|
||||
"HOST_PERIMETER_COMPUTED",
|
||||
]),
|
||||
]
|
||||
|
||||
|
||||
def write_element_quantities(ifc, element, obj: Base, ifc_class: str = ""):
|
||||
"""
|
||||
Write Qto_<EntityType>BaseQuantities from Revit computed instance parameters.
|
||||
|
||||
Reads HOST_AREA_COMPUTED, HOST_VOLUME_COMPUTED, CURVE_ELEM_LENGTH,
|
||||
FURNITURE_WIDTH, FAMILY_HEIGHT_PARAM, etc.
|
||||
IfcSpace is handled separately in _write_space_properties.
|
||||
"""
|
||||
if ifc_class == "IfcSpace":
|
||||
return # Already handled by Qto_SpaceBaseQuantities
|
||||
|
||||
qto_name = _ENTITY_QTO_NAME.get(ifc_class)
|
||||
if not qto_name:
|
||||
return
|
||||
|
||||
props = _get_props_dict(obj)
|
||||
params = _safe_get(props, "Parameters", {})
|
||||
inst_params = _safe_get(params, "Instance Parameters", {})
|
||||
if not inst_params:
|
||||
return
|
||||
|
||||
quantities = []
|
||||
|
||||
for qty_name, ifc_entity, value_attr, revit_params in _ELEMENT_QUANTITY_DEFS:
|
||||
val = None
|
||||
for internal_name in revit_params:
|
||||
val = _param_value(inst_params, internal_name)
|
||||
if val is not None:
|
||||
break
|
||||
if val is None:
|
||||
continue
|
||||
try:
|
||||
q = ifc.create_entity(ifc_entity, Name=qty_name, **{value_attr: float(val)})
|
||||
quantities.append(q)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not quantities:
|
||||
return
|
||||
|
||||
try:
|
||||
qto = ifcopenshell.api.run(
|
||||
"pset.add_qto", ifc,
|
||||
product=element,
|
||||
name=qto_name,
|
||||
)
|
||||
qto.Quantities = quantities
|
||||
except Exception as e:
|
||||
print(f" ⚠️ {qto_name}: {e}")
|
||||
|
||||
|
||||
def write_properties(ifc, element, obj: Base, ifc_class: str = "", category_name: str = ""):
|
||||
"""
|
||||
Write all property sets for an IFC element, matching Revit native IFC export structure:
|
||||
@@ -636,10 +828,12 @@ def write_properties(ifc, element, obj: Base, ifc_class: str = "", category_name
|
||||
3. RVT_TypeParameters — all remaining Revit type parameters
|
||||
4. RVT_InstanceParameters — all remaining Revit instance parameters
|
||||
5. RVT_Identity — family, type, elementId, builtInCategory
|
||||
6. Qto_<MaterialName> — material quantities (area, volume, density)
|
||||
6. Qto_<EntityType>BaseQuantities — element-level quantities (area, volume, length)
|
||||
7. Qto_<MaterialName>BaseQuantities — material quantities (area, volume, density)
|
||||
"""
|
||||
write_common_pset(ifc, element, obj, ifc_class, category_name)
|
||||
write_revit_params(ifc, element, obj)
|
||||
write_element_quantities(ifc, element, obj, ifc_class)
|
||||
write_material_quantities(ifc, element, obj)
|
||||
|
||||
|
||||
|
||||
+16
-9
@@ -3,29 +3,36 @@
|
||||
# Connects to Speckle and receives the root Base object for a given version.
|
||||
# =============================================================================
|
||||
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from specklepy.api.client import SpeckleClient
|
||||
from specklepy.api.credentials import get_default_account
|
||||
from specklepy.api import operations
|
||||
from specklepy.transports.server import ServerTransport
|
||||
import utils.config as config
|
||||
|
||||
load_dotenv()
|
||||
|
||||
SPECKLE_HOST = os.getenv("SPECKLE_SERVER_URL", "https://app.speckle.systems")
|
||||
SPECKLE_TOKEN = os.getenv("SPECKLE_TOKEN", "")
|
||||
DEFAULT_UNITS = "mm"
|
||||
|
||||
|
||||
def get_client() -> SpeckleClient:
|
||||
"""
|
||||
Create and authenticate a SpeckleClient.
|
||||
Uses a personal access token from config.py.
|
||||
Uses a personal access token from the .env file.
|
||||
To use your local Speckle Manager account instead, swap to get_default_account().
|
||||
"""
|
||||
client = SpeckleClient(host=config.SPECKLE_HOST)
|
||||
client = SpeckleClient(host=SPECKLE_HOST)
|
||||
|
||||
if config.SPECKLE_TOKEN and config.SPECKLE_TOKEN != "YOUR_PERSONAL_ACCESS_TOKEN":
|
||||
client.authenticate_with_token(config.SPECKLE_TOKEN)
|
||||
if SPECKLE_TOKEN and SPECKLE_TOKEN != "YOUR_PERSONAL_ACCESS_TOKEN":
|
||||
client.authenticate_with_token(SPECKLE_TOKEN)
|
||||
else:
|
||||
# Fallback: use account from Speckle Manager desktop app
|
||||
account = get_default_account()
|
||||
if account is None:
|
||||
raise RuntimeError(
|
||||
"No Speckle account found. Either set SPECKLE_TOKEN in config.py "
|
||||
"No Speckle account found. Either set SPECKLE_TOKEN in .env "
|
||||
"or log in via Speckle Manager."
|
||||
)
|
||||
client.authenticate_with_account(account)
|
||||
@@ -46,11 +53,11 @@ def receive_version(project_id: str, version_id: str):
|
||||
"""
|
||||
client = get_client()
|
||||
|
||||
print(f"🔗 Connecting to {config.SPECKLE_HOST}...")
|
||||
print(f"🔗 Connecting to {SPECKLE_HOST}...")
|
||||
print(f"📦 Receiving project={project_id} version={version_id}")
|
||||
|
||||
# Get version metadata to find the referenced object ID
|
||||
version = client.version.get(version_id,project_id)
|
||||
version = client.version.get(version_id, project_id)
|
||||
referenced_object_id = version.referenced_object
|
||||
|
||||
# Download the full object graph
|
||||
@@ -58,7 +65,7 @@ def receive_version(project_id: str, version_id: str):
|
||||
base = operations.receive(referenced_object_id, transport)
|
||||
|
||||
# Read units from the root object
|
||||
units = getattr(base, "units", config.DEFAULT_UNITS) or config.DEFAULT_UNITS
|
||||
units = getattr(base, "units", DEFAULT_UNITS) or DEFAULT_UNITS
|
||||
|
||||
# IFC file is declared in MILLIMETRES — no conversion needed.
|
||||
# All geometry stays in source units (mm). scale=1.0 means "keep as-is".
|
||||
|
||||
+6
-8
@@ -55,10 +55,10 @@ def get_prop(obj, key: str, default=None):
|
||||
|
||||
|
||||
# speckle_type fragments that mark a non-exportable / spatial-structure object
|
||||
_SKIP_TYPE_FRAGMENTS = {
|
||||
"Collection", "Level", "Grid", "View", "RenderMaterial",
|
||||
"Site", "Building", "Storey",
|
||||
}
|
||||
import re
|
||||
_SKIP_TYPE_RE = re.compile(
|
||||
r"Collection|Level|Grid|View|RenderMaterial|Site|Building|Storey"
|
||||
)
|
||||
|
||||
|
||||
def _is_valid_element(obj) -> bool:
|
||||
@@ -70,10 +70,8 @@ def _is_valid_element(obj) -> bool:
|
||||
return False
|
||||
|
||||
speckle_type = getattr(obj, "speckle_type", "") or ""
|
||||
|
||||
for fragment in _SKIP_TYPE_FRAGMENTS:
|
||||
if fragment in speckle_type:
|
||||
return False
|
||||
if _SKIP_TYPE_RE.search(speckle_type):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
+51
-6
@@ -9,16 +9,20 @@
|
||||
|
||||
import ifcopenshell
|
||||
import ifcopenshell.api
|
||||
import utils.config as config
|
||||
|
||||
|
||||
def create_ifc_scaffold() -> tuple:
|
||||
def create_ifc_scaffold(
|
||||
project_name: str = "Default Project",
|
||||
site_name: str = "Default Site",
|
||||
building_name: str = "Default Building",
|
||||
) -> tuple:
|
||||
"""
|
||||
Create the IFC file with the required project/site/building hierarchy.
|
||||
|
||||
Returns:
|
||||
(ifc_file, building, body_context)
|
||||
(ifc_file, site, building, body_context)
|
||||
- ifc_file: The ifcopenshell file object
|
||||
- site: The IfcSite entity
|
||||
- building: The IfcBuilding entity (storeys are assigned under this)
|
||||
- body_context: The Body geometry subcontext for shape representations
|
||||
"""
|
||||
@@ -28,7 +32,7 @@ def create_ifc_scaffold() -> tuple:
|
||||
project = ifcopenshell.api.run(
|
||||
"root.create_entity", ifc,
|
||||
ifc_class="IfcProject",
|
||||
name=config.IFC_PROJECT_NAME,
|
||||
name=project_name,
|
||||
)
|
||||
|
||||
# Units — millimetres (matching Revit/Speckle source data)
|
||||
@@ -55,12 +59,12 @@ def create_ifc_scaffold() -> tuple:
|
||||
site = ifcopenshell.api.run(
|
||||
"root.create_entity", ifc,
|
||||
ifc_class="IfcSite",
|
||||
name=config.IFC_SITE_NAME,
|
||||
name=site_name,
|
||||
)
|
||||
building = ifcopenshell.api.run(
|
||||
"root.create_entity", ifc,
|
||||
ifc_class="IfcBuilding",
|
||||
name=config.IFC_BUILDING_NAME,
|
||||
name=building_name,
|
||||
)
|
||||
|
||||
ifcopenshell.api.run(
|
||||
@@ -81,12 +85,19 @@ class StoreyManager:
|
||||
"""
|
||||
Lazily creates IfcBuildingStorey entities as new level names are encountered.
|
||||
Keeps storeys in insertion order so the IFC file is logically ordered.
|
||||
|
||||
Spatial containment is batched — call flush() after all elements are created
|
||||
to write all IfcRelContainedInSpatialStructure / aggregate relationships at once.
|
||||
"""
|
||||
|
||||
def __init__(self, ifc: ifcopenshell.file, building):
|
||||
self.ifc = ifc
|
||||
self.building = building
|
||||
self._storeys: dict[str, object] = {} # level_name → IfcBuildingStorey
|
||||
# Batched containment: storey_id → [element, ...]
|
||||
self._contained: dict[int, list] = {}
|
||||
# Batched aggregation (IfcSite etc.): storey_id → [element, ...]
|
||||
self._aggregated: dict[int, list] = {}
|
||||
|
||||
def get_or_create(self, level_name: str):
|
||||
"""Return existing storey or create a new one for this level name."""
|
||||
@@ -106,6 +117,40 @@ class StoreyManager:
|
||||
|
||||
return self._storeys[level_name]
|
||||
|
||||
def queue_contain(self, storey, element):
|
||||
"""Queue an element for spatial containment (batched flush)."""
|
||||
sid = storey.id()
|
||||
if sid not in self._contained:
|
||||
self._contained[sid] = []
|
||||
self._contained[sid].append(element)
|
||||
|
||||
def queue_aggregate(self, storey, element):
|
||||
"""Queue an element for aggregation under storey (e.g. IfcSite)."""
|
||||
sid = storey.id()
|
||||
if sid not in self._aggregated:
|
||||
self._aggregated[sid] = []
|
||||
self._aggregated[sid].append(element)
|
||||
|
||||
def flush(self):
|
||||
"""Write all batched spatial containment and aggregation relationships."""
|
||||
ifc = self.ifc
|
||||
for sid, elements in self._contained.items():
|
||||
storey = ifc.by_id(sid)
|
||||
ifcopenshell.api.run(
|
||||
"spatial.assign_container", ifc,
|
||||
relating_structure=storey,
|
||||
products=elements,
|
||||
)
|
||||
for sid, elements in self._aggregated.items():
|
||||
storey = ifc.by_id(sid)
|
||||
ifcopenshell.api.run(
|
||||
"aggregate.assign_object", ifc,
|
||||
relating_object=storey,
|
||||
products=elements,
|
||||
)
|
||||
self._contained.clear()
|
||||
self._aggregated.clear()
|
||||
|
||||
@property
|
||||
def count(self) -> int:
|
||||
return len(self._storeys)
|
||||
|
||||
Reference in New Issue
Block a user