This commit is contained in:
NLSA
2026-03-13 08:56:29 +01:00
parent f8c9d4237d
commit 673c024ac5
11 changed files with 380 additions and 358 deletions
+1 -1
View File
@@ -30,4 +30,4 @@ jobs:
speckle_function_id: ${{ secrets.SPECKLE_FUNCTION_ID }} speckle_function_id: ${{ secrets.SPECKLE_FUNCTION_ID }}
speckle_function_input_schema_file_path: ${{ env.FUNCTION_SCHEMA_FILE_NAME }} speckle_function_input_schema_file_path: ${{ env.FUNCTION_SCHEMA_FILE_NAME }}
speckle_function_command: "python -u main.py run" speckle_function_command: "python -u main.py run"
speckle_function_recommended_memory_mi: 5000 speckle_function_recommended_memory_mi: 8000
+162 -133
View File
@@ -1,174 +1,203 @@
# Speckle Automate function template - Python # Speckle to IFC 4.3 Exporter
This template repository is for a Speckle Automate function written in Python 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/).
using the [specklepy](https://pypi.org/project/specklepy/) SDK to interact with Speckle data.
This template contains the full scaffolding required to publish a function to the Automate environment. ## What It Does
It also has some sane defaults for development environment setups.
## 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. - Correct IFC entity classification (IfcWall, IfcSlab, IfcColumn, etc.)
1. Register the function - 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: ```
Speckle Model
**For packages your function needs to run** (like pandas, requests, etc.):
```toml
dependencies = [ 1. Receive version (specklepy)
"specklepy==3.0.0",
"pandas==2.1.0", # Add production dependencies here
] 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): ## Module Structure
```toml
[project.optional-dependencies]
dev = [
"black==23.12.1",
"pytest-mock==3.11.1", # Add development dependencies here
# ... other dev tools
]
```
**How to decide which section?** | File | Purpose |
- 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` | `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: ## Mapping Logic
```python
# In your main.py
import pandas as pd # ← This goes in dependencies
import specklepy # ← This goes in dependencies
# You won't import these in main.py: Classification of Speckle objects to IFC entity types follows a priority chain with three lookup tables. The first match wins.
# pytest, black, mypy ← These go in [project.optional-dependencies].dev
```
### 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. For typed Speckle objects, the `speckle_type` string is matched. Exact match is tried first, then longest-prefix match.
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`.
## 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. ### Priority 3: Category name (display string)
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.
## 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: Examples:
- [Python 3.11+](https://www.python.org/downloads/) | Category Name | IFC Class |
1. Run the following to set up your development environment: |---|---|
```bash | `Walls` | `IfcWall` |
python -m venv .venv | `Structural Columns` | `IfcColumn` |
# On Windows | `Plumbing Fixtures` | `IfcSanitaryTerminal` |
.venv\Scripts\activate | `Lighting Fixtures` | `IfcLightFixture` |
# On macOS/Linux
source .venv/bin/activate
pip install --upgrade pip ### Priority 4: `obj.category` field
pip install .[dev]
```
**What this installs:** Same lookup as Priority 3, but using the object's own `category` attribute.
- All the packages your function needs to run (`dependencies`)
- Plus development tools like testing and code formatting (`[project.optional-dependencies].dev`)
**Why separate sections?** ### Fallback
- `dependencies`: Only what gets deployed with your function (lightweight)
- `dev` dependencies: Extra tools to help you write better code locally
## 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:
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
### Instance Objects (Path A / B2)
Speckle `InstanceProxy` objects reference shared definition geometry via `definitionId`. The exporter supports two formats:
- **Revit format**: `definitionId` is a 64-char hex hash; geometry is found by walking the object tree
- **IFC format**: `definitionId` starts with `DEFINITION:`; geometry is in `definitionGeometry` collection
Performance optimisation: geometry is built once as an `IfcRepresentationMap`, then each instance references it via `IfcMappedItem` + `IfcCartesianTransformationOperator3DnonUniform`. This avoids duplicating vertex data across hundreds of identical elements (e.g. chairs, light fixtures, curtain wall panels).
## Property Sets
The exporter writes property sets matching Revit's native IFC export structure:
| Property Set | Content |
|---|---|
| `Pset_<Entity>Common` | Standard IFC properties: Reference, IsExternal, LoadBearing, ThermalTransmittance |
| `RVT_TypeParameters` | All Revit type parameters (written on the IfcTypeObject) |
| `RVT_InstanceParameters` | All Revit instance parameters |
| `RVT_Identity` | Family, Type, ElementId, BuiltInCategory |
| `Qto_<MaterialName>` | Material quantities: area, volume, density |
## Getting Started
### Prerequisites
- Python 3.11+
- A Speckle account and project with a Revit model
### Setup
#### Using Poetry
```bash ```bash
poetry install # Automatically reads pyproject.toml python -m venv .venv
# Windows
.venv\Scripts\activate
# macOS/Linux
source .venv/bin/activate
pip install --upgrade pip
pip install .[dev]
``` ```
#### Using uv ### Running Locally
Configure your Speckle Automate credentials, then:
```bash ```bash
uv sync # Automatically reads pyproject.toml python main.py
``` ```
#### Using pip-tools ### Deploying to Speckle Automate
```bash
pip-compile pyproject.toml # Generate requirements.txt from pyproject.toml
pip install -r requirements.txt
```
#### Using pdm 1. [Create](https://automate.speckle.dev/) a new Speckle Automation
```bash 2. Select your Speckle Project and Model
pdm install # Automatically reads pyproject.toml 3. Select this function
``` 4. Configure the inputs (file name, project/site/building names)
5. Click Create Automation
**Advantage**: All tools read the same `pyproject.toml` file, so there's no need to keep multiple files in sync! ## Function Inputs
### Building and running the Docker Container Image | Input | Description |
|---|---|
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. | `file_name` | Output IFC filename (timestamp is appended automatically) |
| `IFC_PROJECT_NAME` | Name for the IfcProject entity |
#### Building the Docker Container Image | `IFC_SITE_NAME` | Name for the IfcSite entity |
| `IFC_BUILDING_NAME` | Name for the IfcBuilding entity |
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.
Once you have Docker running on your local machine:
1. Open a terminal
1. Navigate to the directory in which you cloned this repository
1. Run the following command:
```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.
## Resources ## 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)
+32 -37
View File
@@ -2,11 +2,9 @@ from datetime import datetime
import ifcopenshell.api import ifcopenshell.api
import utils.config as config
from utils.materials import MaterialManager from utils.materials import MaterialManager
from utils.traversal import traverse, print_tree 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.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.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.properties import write_properties, write_common_properties, build_element_name, get_element_tag, get_ifc_guid, reset_caches as reset_props_caches
@@ -75,20 +73,22 @@ def automate_function(
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# 2. Build definition map (for instance resolution) # 2. Build definition map (for instance resolution)
# ------------------------------------------------------------------ # # ----------------------------------------------
print("\n🔍 Building definition map...")
definition_map = build_definition_map(base) definition_map = build_definition_map(base)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# 3. Set up IFC # 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) storey_manager = StoreyManager(ifc, building)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# 3b. Build material map from renderMaterialProxies # 3b. Build material map from renderMaterialProxies
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
print("\n🎨 Building material map...")
material_manager = MaterialManager(ifc, base) material_manager = MaterialManager(ifc, base)
type_manager = TypeManager(ifc) type_manager = TypeManager(ifc)
@@ -128,9 +128,10 @@ def automate_function(
no_geometry += 1 no_geometry += 1
continue continue
element = _create_element(ifc, ifc_class, name, rep, placement, storey, element = _create_element(ifc, ifc_class, name, rep, placement, storey,
storey_manager=storey_manager,
tag=get_element_tag(obj), guid=get_ifc_guid(obj), tag=get_element_tag(obj), guid=get_ifc_guid(obj),
object_type=getattr(obj, "type", None), 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) write_properties(ifc, element, obj, ifc_class=ifc_class, category_name=category_name)
type_manager.assign(element, obj, ifc_class) type_manager.assign(element, obj, ifc_class)
instance_count += 1 instance_count += 1
@@ -147,9 +148,10 @@ def automate_function(
rep, placement = mesh_to_ifc(ifc, body_context, obj, scale=scale, material_manager=material_manager) rep, placement = mesh_to_ifc(ifc, body_context, obj, scale=scale, material_manager=material_manager)
if rep: if rep:
element = _create_element(ifc, ifc_class, name, rep, placement, storey, element = _create_element(ifc, ifc_class, name, rep, placement, storey,
storey_manager=storey_manager,
tag=get_element_tag(obj), guid=get_ifc_guid(obj), tag=get_element_tag(obj), guid=get_ifc_guid(obj),
object_type=getattr(obj, "type", None), 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) write_properties(ifc, element, obj, ifc_class=ifc_class, category_name=category_name)
type_manager.assign(element, obj, ifc_class) type_manager.assign(element, obj, ifc_class)
total += 1 total += 1
@@ -167,9 +169,9 @@ def automate_function(
continue continue
inst_element = _create_element( inst_element = _create_element(
ifc, ifc_class, name, inst_rep, inst_placement, storey, ifc, ifc_class, name, inst_rep, inst_placement, storey,
storey_manager=storey_manager,
tag=get_element_tag(obj), guid=None, tag=get_element_tag(obj), guid=None,
object_type=getattr(obj, "type", 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) write_properties(ifc, inst_element, obj, ifc_class=ifc_class, category_name=category_name)
type_manager.assign(inst_element, obj, ifc_class) type_manager.assign(inst_element, obj, ifc_class)
@@ -186,7 +188,9 @@ def automate_function(
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# 5. Write output # 5. Write output
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
print("\n🔗 Flushing type relationships...") print("\n🔗 Flushing spatial containment...")
storey_manager.flush()
print("🔗 Flushing type relationships...")
type_manager.flush() type_manager.flush()
file_name = function_inputs.file_name file_name = function_inputs.file_name
@@ -196,12 +200,12 @@ def automate_function(
ifc.write(ifc_filename) ifc.write(ifc_filename)
print(f"\n💾 IFC file written: {ifc_filename}") print(f"\n💾 IFC file written: {ifc_filename}")
try: # try:
automate_context.mark_run_success("Success! You can download the IF file below.") # automate_context.mark_run_success("Success! You can download the IF file below.")
automate_context.store_file_result(f"./{ifc_filename}") # automate_context.store_file_result(f"./{ifc_filename}")
except Exception as e: # except Exception as e:
print(f" ⚠️ Could not upload file result (network issue?): {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}") # automate_context.mark_run_failed(f"Something went wrong when storing file result. Exception detail: {e}")
print(f"\n{'=' * 60}") print(f"\n{'=' * 60}")
print(f" Export complete!") print(f" Export complete!")
@@ -215,12 +219,11 @@ def automate_function(
print(f"{'=' * 60}\n") print(f"{'=' * 60}\n")
def _create_element(ifc, ifc_class, name, rep, placement, storey, def _create_element(ifc, ifc_class, name, rep, placement, storey,
tag=None, guid=None, object_type=None, predefined_type=None): storey_manager=None,
"""Helper: create an IFC element, assign geometry + placement + container.""" tag=None, guid=None, object_type=None):
kwargs = {"ifc_class": ifc_class, "name": str(name)} """Helper: create an IFC element, assign geometry + placement, queue containment."""
if predefined_type: element = ifcopenshell.api.run("root.create_entity", ifc,
kwargs["predefined_type"] = predefined_type ifc_class=ifc_class, name=str(name))
element = ifcopenshell.api.run("root.create_entity", ifc, **kwargs)
if tag: if tag:
try: try:
element.Tag = str(tag) element.Tag = str(tag)
@@ -246,20 +249,12 @@ def _create_element(ifc, ifc_class, name, rep, placement, storey,
else: else:
element.ObjectPlacement = _make_placement(ifc, 0.0, 0.0, 0.0) element.ObjectPlacement = _make_placement(ifc, 0.0, 0.0, 0.0)
# IfcSite is a spatial structure element — can't use spatial.assign_container. # Queue spatial assignment (batched flush at end for performance)
# Use aggregate.assign_object to nest it under the storey instead. if storey_manager:
if ifc_class == "IfcSite": if ifc_class == "IfcSite":
ifcopenshell.api.run( storey_manager.queue_aggregate(storey, element)
"aggregate.assign_object", ifc, else:
relating_object=storey, storey_manager.queue_contain(storey, element)
products=[element],
)
else:
ifcopenshell.api.run(
"spatial.assign_container", ifc,
relating_structure=storey,
products=[element],
)
return element return element
# make sure to call the function with the executor # make sure to call the function with the executor
+3 -2
View File
@@ -7,10 +7,11 @@ maintainers = [{ name = "Speckle Systems", email = "hello@speckle.systems" }]
description = "A Speckle Automate function template using specklepy" description = "A Speckle Automate function template using specklepy"
readme = "README.md" readme = "README.md"
license = "Apache-2.0" 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",] "ifcopenshell==0.8.4.post1",
"python-dotenv>=1.0.0",]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
-34
View File
@@ -1,34 +0,0 @@
# =============================================================================
# config.py
# All user-facing settings. Edit this file before running main.py.
# =============================================================================
# --- Speckle Connection ---
SPECKLE_HOST = "app.speckle.systems" # or your self-hosted server URL
SPECKLE_TOKEN = "****" # from app.speckle.systems/profile
# --- Speckle Project ---
PROJECT_ID = "d7d987146d" # the stream/project ID from the URL
VERSION_ID = "d59178f01e" # the specific version/commit to export
# --- IFC Output ---
OUTPUT_PATH = "output3.ifc" # where to write the IFC file
IFC_SCHEMA = "IFC4X3" # IFC4X3 = IFC4.3
# --- Project Metadata (written into the IFC file) ---
IFC_PROJECT_NAME = "Speckle Export"
IFC_SITE_NAME = "Site"
IFC_BUILDING_NAME = "Building"
# --- Units ---
# Speckle unit → metres scale factor
# The exporter reads units from the root object automatically,
# but this is the fallback if units are not set on the stream.
DEFAULT_UNITS = "mm"
UNIT_SCALE = {
"mm": 0.001,
"cm": 0.01,
"m": 1.0,
"ft": 0.3048,
"in": 0.0254,
}
+73 -51
View File
@@ -31,11 +31,7 @@ _UNIT_SCALES = {
# Minimum distance in mm below which two vertices are considered identical (GEM111). # Minimum distance in mm below which two vertices are considered identical (GEM111).
_VERTEX_MERGE_TOL = 0.01 # 0.01 mm _VERTEX_MERGE_TOL = 0.01 # 0.01 mm
_INV_TOL = 1.0 / _VERTEX_MERGE_TOL # pre-computed: multiply instead of divide
def snap_coord(v: float) -> int:
"""Snap a coordinate to integer grid at _VERTEX_MERGE_TOL resolution."""
return round(v / _VERTEX_MERGE_TOL)
def build_ifc_facesets(ifc, verts_scaled: list, face_groups: list) -> list: 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], ...] face_groups: list of index lists [[i,j,k], [i,j,k,l], ...]
Returns: list of IfcPolygonalFaceSet (typically one, empty on failure). 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 snap_to_idx = {} # snap_key → 0-based index in deduped_verts
deduped_verts = [] # [(x, y, z), ...] deduped_verts = [] # [[x, y, z], ...] — lists for direct IFC use
inv_tol = _INV_TOL
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
# Validate faces and remap indices to deduplicated vertex list # 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: for indices in face_groups:
try: try:
remapped = [] remapped = []
@@ -73,15 +60,21 @@ def build_ifc_facesets(ifc, verts_scaled: list, face_groups: list) -> list:
degenerate = False degenerate = False
for i in indices: for i in indices:
x = float(verts_scaled[i * 3]) i3 = i * 3
y = float(verts_scaled[i * 3 + 1]) x = verts_scaled[i3]
z = float(verts_scaled[i * 3 + 2]) y = verts_scaled[i3 + 1]
idx, snap_key = get_vertex_index(x, y, z) z = verts_scaled[i3 + 2]
if snap_key in seen_snaps: key = (round(x * inv_tol), round(y * inv_tol), round(z * inv_tol))
if key in seen_snaps:
degenerate = True degenerate = True
break break
seen_snaps.add(snap_key) seen_snaps.add(key)
remapped.append(idx) 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: if degenerate or len(remapped) < 3:
continue continue
@@ -94,15 +87,10 @@ def build_ifc_facesets(ifc, verts_scaled: list, face_groups: list) -> list:
# Build IFC entities # Build IFC entities
try: try:
point_list = ifc.createIfcCartesianPointList3D( point_list = ifc.createIfcCartesianPointList3D(deduped_verts)
[list(v) for v in deduped_verts] ifc_faces = [
) ifc.createIfcIndexedPolygonalFace(fi) for fi in valid_faces
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))
faceset = ifc.createIfcPolygonalFaceSet(point_list, None, ifc_faces, None) faceset = ifc.createIfcPolygonalFaceSet(point_list, None, ifc_faces, None)
return [faceset] return [faceset]
except Exception: except Exception:
@@ -270,8 +258,10 @@ def decode_faces(faces_raw: list) -> list:
decoded = [] decoded = []
i = 0 i = 0
total = len(faces_raw) 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: while i < total:
n = int(faces_raw[i]) n = faces_raw[i] if already_int else int(faces_raw[i])
if n == 0: if n == 0:
n = 3 n = 3
elif n == 1: elif n == 1:
@@ -279,8 +269,10 @@ def decode_faces(faces_raw: list) -> list:
end = i + 1 + n end = i + 1 + n
if end > total: if end > total:
break break
# Direct slice is faster than list comprehension with int() if already_int:
decoded.append([int(v) for v in faces_raw[i + 1:end]]) decoded.append(faces_raw[i + 1:end])
else:
decoded.append([int(v) for v in faces_raw[i + 1:end]])
i = end i = end
return decoded return decoded
@@ -291,25 +283,55 @@ def decode_faces(faces_raw: list) -> list:
def compute_origin(flat_verts: list) -> tuple: 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 X, Y = bounding box centroid
Z = minimum Z (bottom face of element — more natural for IFC) Z = minimum Z (bottom face of element — more natural for IFC)
Single-pass to avoid creating 3 sliced copies of a large list.
""" """
xs = flat_verts[0::3] x0 = flat_verts[0]
ys = flat_verts[1::3] y0 = flat_verts[1]
zs = flat_verts[2::3] z0 = flat_verts[2]
cx = (min(xs) + max(xs)) / 2.0 xmin = xmax = x0
cy = (min(ys) + max(ys)) / 2.0 ymin = ymax = y0
cz = min(zs) zmin = z0
return cx, cy, cz 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): def _make_placement(ifc, x: float, y: float, z: float):
"""Create an IfcLocalPlacement at absolute world coordinates (metres).""" """Create an IfcLocalPlacement at absolute world coordinates (metres)."""
shared = _get_shared(ifc)
origin = ifc.createIfcCartesianPoint([x, y, z]) origin = ifc.createIfcCartesianPoint([x, y, z])
z_axis = ifc.createIfcDirection([0.0, 0.0, 1.0]) a2p = ifc.createIfcAxis2Placement3D(origin, shared["z_axis"], shared["x_axis"])
x_axis = ifc.createIfcDirection([1.0, 0.0, 0.0])
a2p = ifc.createIfcAxis2Placement3D(origin, z_axis, x_axis)
return ifc.createIfcLocalPlacement(PlacementRelTo=None, RelativePlacement=a2p) return ifc.createIfcLocalPlacement(PlacementRelTo=None, RelativePlacement=a2p)
@@ -343,7 +365,7 @@ def mesh_to_ifc(
all_scaled = [] all_scaled = []
for mesh in meshes: for mesh in meshes:
raw_verts = _get(mesh, "vertices") or [] 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: if not verts:
mesh_cache.append(None) mesh_cache.append(None)
continue continue
@@ -368,7 +390,7 @@ def mesh_to_ifc(
continue continue
verts, ms, scaled = cached verts, ms, scaled = cached
raw_faces = _get(mesh, "faces") or [] 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: if not faces_raw:
continue continue
@@ -379,10 +401,10 @@ def mesh_to_ifc(
print(f" ⚠️ Face decode error: {e}") print(f" ⚠️ Face decode error: {e}")
continue continue
# Offset pre-scaled vertices relative to origin # Offset pre-scaled vertices relative to origin (flat list, no tuples)
n = len(scaled) n = len(scaled)
verts_scaled = [0.0] * n 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] = scaled[vi] - ox
verts_scaled[vi + 1] = scaled[vi + 1] - oy verts_scaled[vi + 1] = scaled[vi + 1] - oy
verts_scaled[vi + 2] = scaled[vi + 2] - oz verts_scaled[vi + 2] = scaled[vi + 2] - oz
+23 -56
View File
@@ -22,7 +22,7 @@
import math import math
from specklepy.objects.base import Base 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: 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 proxies: {len(ifc_proxies)}")
print(f" IFC definition meshes: {len(ifc_meshes)}") 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 { return {
"by_id": by_id, "by_id": by_id,
"by_app_id": by_app_id, "by_app_id": by_app_id,
@@ -217,17 +191,19 @@ def _resolve_instance_scale(obj, stream_scale: float) -> float:
# Stats # Stats
_stats = {"found": 0, "not_found": 0} _stats = {"found": 0, "not_found": 0}
_dbg_cnt = [0]
# Cache: mesh id → (verts_flat, face_groups, ms) to avoid re-unpacking # Cache: mesh id → (verts_scaled, face_groups) to avoid re-unpacking
# the same definition mesh across many instances that share it. # AND re-scaling the same definition mesh across many instances that share it.
_mesh_data_cache: dict = {} _mesh_data_cache: dict = {}
# Cache: definition_id → IfcRepresentationMap (or None if no geometry) # Cache: definition_id → IfcRepresentationMap (or None if no geometry)
# All instances sharing the same definition reuse one geometry copy. # All instances sharing the same definition reuse one geometry copy.
_rep_map_cache: dict = {} _rep_map_cache: dict = {}
# Shared identity placement for all instances (keyed by ifc file id)
_identity_placement_cache: dict[int, object] = {}
_MM_SCALES = { _MM_SCALES = {
"mm": 1.0, "millimeter": 1.0, "millimeters": 1.0, "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: for mesh in meshes:
mesh_id = _get(mesh, "id") or _get(mesh, "applicationId") mesh_id = _get(mesh, "id") or _get(mesh, "applicationId")
if mesh_id and mesh_id in _mesh_data_cache: 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: else:
raw_verts = _get(mesh, "vertices") or [] raw_verts = _get(mesh, "vertices") or []
raw_faces = _get(mesh, "faces") 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}") print(f" ⚠️ Instance face decode: {e}")
continue continue
if mesh_id: # Scale vertices once and cache the result
_mesh_data_cache[mesh_id] = (verts, face_groups, ms) verts_local = [float(v) * ms for v in verts]
# Scale vertices to mm (local coordinates, no instance transform) if mesh_id:
verts_local = [] _mesh_data_cache[mesh_id] = (verts_local, face_groups)
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)
mesh_facesets = build_ifc_facesets(ifc, 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: if not geom_items:
return None return None
# Mapping origin = identity (local coords origin) # Mapping origin = identity (local coords origin) — reuse shared origin
origin = ifc.createIfcCartesianPoint([0.0, 0.0, 0.0]) shared = _get_shared(ifc)
a2p = ifc.createIfcAxis2Placement3D(origin, None, None) a2p = ifc.createIfcAxis2Placement3D(shared["origin_0"], None, None)
# The mapped representation holds the actual geometry # The mapped representation holds the actual geometry
mapped_rep = ifc.createIfcShapeRepresentation( 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) # Revit format transform is already in mm (same as IFC file units)
ts = 1000.0 if ifc_format else _resolve_instance_scale(obj, scale) ts = 1000.0 if ifc_format else _resolve_instance_scale(obj, scale)
if _dbg_cnt[0] < 6: # Identity placement (transform is encoded in the MappedItem) — shared across all instances
_dbg_cnt[0] += 1 fid = id(ifc)
fmt = "IFC" if ifc_format else "Revit" if fid not in _identity_placement_cache:
x_axis = (round(t[0],2), round(t[1],2), round(t[2],2)) shared = _get_shared(ifc)
z_axis = (round(t[8],2), round(t[9],2), round(t[10],2)) a2p = ifc.createIfcAxis2Placement3D(shared["origin_0"], None, None)
print(f" [INST {_dbg_cnt[0]} {fmt}] {definition_id[:40]}") _identity_placement_cache[fid] = ifc.createIfcLocalPlacement(PlacementRelTo=None, RelativePlacement=a2p)
print(f" t[3]={t[3]:.1f} t[7]={t[7]:.1f} t[11]={t[11]:.1f} x={x_axis} z={z_axis}") placement = _identity_placement_cache[fid]
# 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)
# --- Get or build IfcRepresentationMap (cached per definition_id) --- # --- Get or build IfcRepresentationMap (cached per definition_id) ---
if definition_id not in _rep_map_cache: 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).""" """Reset module-level caches (call at start of each export run)."""
_mesh_data_cache.clear() _mesh_data_cache.clear()
_rep_map_cache.clear() _rep_map_cache.clear()
_identity_placement_cache.clear()
_stats["found"] = 0 _stats["found"] = 0
_stats["not_found"] = 0 _stats["not_found"] = 0
_dbg_cnt[0] = 0
+13 -21
View File
@@ -119,12 +119,6 @@ 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 → IFC class (secondary lookup) ---
SPECKLE_TYPE_MAP: dict[str, str] = { SPECKLE_TYPE_MAP: dict[str, str] = {
"Objects.BuiltElements.Wall": "IfcWall", "Objects.BuiltElements.Wall": "IfcWall",
@@ -207,14 +201,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 _bic_cache: dict[int, str | None] = {} # id(obj) → builtInCategory
@@ -249,6 +235,11 @@ def _get_builtin_category(obj) -> str | None:
return 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 # Pre-computed lowercase category map for substring matching
_CATEGORY_MAP_LOWER: list[tuple[str, str]] = [ _CATEGORY_MAP_LOWER: list[tuple[str, str]] = [
(k.lower(), v) for k, v in CATEGORY_MAP.items() (k.lower(), v) for k, v in CATEGORY_MAP.items()
@@ -284,15 +275,16 @@ def _classify_impl(obj, category_name: str) -> str:
if bic and bic in BUILTIN_CATEGORY_MAP: if bic and bic in BUILTIN_CATEGORY_MAP:
return BUILTIN_CATEGORY_MAP[bic] return BUILTIN_CATEGORY_MAP[bic]
# 2. speckle_type # 2. speckle_type — exact match first, then longest-prefix match
speckle_type = getattr(obj, "speckle_type", "") or "" speckle_type = getattr(obj, "speckle_type", "") or ""
if speckle_type in SPECKLE_TYPE_MAP: if speckle_type:
return SPECKLE_TYPE_MAP[speckle_type] if speckle_type in SPECKLE_TYPE_MAP:
for key, ifc_class in SPECKLE_TYPE_MAP.items(): return SPECKLE_TYPE_MAP[speckle_type]
if speckle_type.startswith(key): for prefix, ifc_class in _SPECKLE_PREFIXES:
return ifc_class if speckle_type.startswith(prefix):
return ifc_class
# 3. category_name from traversal context # 3. category_name from traversal context — exact match first
if category_name: if category_name:
if category_name in CATEGORY_MAP: if category_name in CATEGORY_MAP:
return CATEGORY_MAP[category_name] return CATEGORY_MAP[category_name]
+16 -9
View File
@@ -3,29 +3,36 @@
# Connects to Speckle and receives the root Base object for a given version. # 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.client import SpeckleClient
from specklepy.api.credentials import get_default_account from specklepy.api.credentials import get_default_account
from specklepy.api import operations from specklepy.api import operations
from specklepy.transports.server import ServerTransport 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: def get_client() -> SpeckleClient:
""" """
Create and authenticate a 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(). 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": if SPECKLE_TOKEN and SPECKLE_TOKEN != "YOUR_PERSONAL_ACCESS_TOKEN":
client.authenticate_with_token(config.SPECKLE_TOKEN) client.authenticate_with_token(SPECKLE_TOKEN)
else: else:
# Fallback: use account from Speckle Manager desktop app # Fallback: use account from Speckle Manager desktop app
account = get_default_account() account = get_default_account()
if account is None: if account is None:
raise RuntimeError( 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." "or log in via Speckle Manager."
) )
client.authenticate_with_account(account) client.authenticate_with_account(account)
@@ -46,11 +53,11 @@ def receive_version(project_id: str, version_id: str):
""" """
client = get_client() 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}") print(f"📦 Receiving project={project_id} version={version_id}")
# Get version metadata to find the referenced object 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 referenced_object_id = version.referenced_object
# Download the full object graph # 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) base = operations.receive(referenced_object_id, transport)
# Read units from the root object # 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. # IFC file is declared in MILLIMETRES — no conversion needed.
# All geometry stays in source units (mm). scale=1.0 means "keep as-is". # All geometry stays in source units (mm). scale=1.0 means "keep as-is".
+6 -8
View File
@@ -55,10 +55,10 @@ def get_prop(obj, key: str, default=None):
# speckle_type fragments that mark a non-exportable / spatial-structure object # speckle_type fragments that mark a non-exportable / spatial-structure object
_SKIP_TYPE_FRAGMENTS = { import re
"Collection", "Level", "Grid", "View", "RenderMaterial", _SKIP_TYPE_RE = re.compile(
"Site", "Building", "Storey", r"Collection|Level|Grid|View|RenderMaterial|Site|Building|Storey"
} )
def _is_valid_element(obj) -> bool: def _is_valid_element(obj) -> bool:
@@ -70,10 +70,8 @@ def _is_valid_element(obj) -> bool:
return False return False
speckle_type = getattr(obj, "speckle_type", "") or "" speckle_type = getattr(obj, "speckle_type", "") or ""
if _SKIP_TYPE_RE.search(speckle_type):
for fragment in _SKIP_TYPE_FRAGMENTS: return False
if fragment in speckle_type:
return False
return True return True
+51 -6
View File
@@ -9,16 +9,20 @@
import ifcopenshell import ifcopenshell
import ifcopenshell.api 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. Create the IFC file with the required project/site/building hierarchy.
Returns: Returns:
(ifc_file, building, body_context) (ifc_file, site, building, body_context)
- ifc_file: The ifcopenshell file object - ifc_file: The ifcopenshell file object
- site: The IfcSite entity
- building: The IfcBuilding entity (storeys are assigned under this) - building: The IfcBuilding entity (storeys are assigned under this)
- body_context: The Body geometry subcontext for shape representations - body_context: The Body geometry subcontext for shape representations
""" """
@@ -28,7 +32,7 @@ def create_ifc_scaffold() -> tuple:
project = ifcopenshell.api.run( project = ifcopenshell.api.run(
"root.create_entity", ifc, "root.create_entity", ifc,
ifc_class="IfcProject", ifc_class="IfcProject",
name=config.IFC_PROJECT_NAME, name=project_name,
) )
# Units — millimetres (matching Revit/Speckle source data) # Units — millimetres (matching Revit/Speckle source data)
@@ -55,12 +59,12 @@ def create_ifc_scaffold() -> tuple:
site = ifcopenshell.api.run( site = ifcopenshell.api.run(
"root.create_entity", ifc, "root.create_entity", ifc,
ifc_class="IfcSite", ifc_class="IfcSite",
name=config.IFC_SITE_NAME, name=site_name,
) )
building = ifcopenshell.api.run( building = ifcopenshell.api.run(
"root.create_entity", ifc, "root.create_entity", ifc,
ifc_class="IfcBuilding", ifc_class="IfcBuilding",
name=config.IFC_BUILDING_NAME, name=building_name,
) )
ifcopenshell.api.run( ifcopenshell.api.run(
@@ -81,12 +85,19 @@ class StoreyManager:
""" """
Lazily creates IfcBuildingStorey entities as new level names are encountered. Lazily creates IfcBuildingStorey entities as new level names are encountered.
Keeps storeys in insertion order so the IFC file is logically ordered. 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): def __init__(self, ifc: ifcopenshell.file, building):
self.ifc = ifc self.ifc = ifc
self.building = building self.building = building
self._storeys: dict[str, object] = {} # level_name → IfcBuildingStorey 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): def get_or_create(self, level_name: str):
"""Return existing storey or create a new one for this level name.""" """Return existing storey or create a new one for this level name."""
@@ -106,6 +117,40 @@ class StoreyManager:
return self._storeys[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 @property
def count(self) -> int: def count(self) -> int:
return len(self._storeys) return len(self._storeys)