update instance and add zip
build and deploy Speckle functions / publish-automate-function-version (push) Has been cancelled
build and deploy Speckle functions / publish-automate-function-version (push) Has been cancelled
This commit is contained in:
@@ -1,8 +1,6 @@
|
|||||||
# Speckle → IFC 4.3 Exporter (Rhino)
|
# Speckle → IFC 4.3 Exporter (Rhino)
|
||||||
|
|
||||||
A [Speckle Automate](https://automate.speckle.dev/) function that converts Speckle models (from Rhino and other authoring tools) into IFC 4.3 files.
|
A [Speckle Automate](https://automate.speckle.dev/) function that converts Speckle models (from Rhino and other authoring tools) into IFC 4X3 files.
|
||||||
|
|
||||||
🚫 **BLOCKER:** fIles that are bigger than 100MB are failing to upload because automate uses the /api/streams/:streamId/blobs endpoint (proxied through our server, which has a 100MB request size limit imposed by cloudflare) - we havea ticket to resolve this TBD.
|
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
@@ -11,7 +9,19 @@ A [Speckle Automate](https://automate.speckle.dev/) function that converts Speck
|
|||||||
3. **Classifies** each element into its IFC type (e.g. `IfcColumn`, `IfcWall`) using the `Attributes.type` property.
|
3. **Classifies** each element into its IFC type (e.g. `IfcColumn`, `IfcWall`) using the `Attributes.type` property.
|
||||||
4. **Exports geometry** — meshes, instances (block definitions), and curves — into IFC representations.
|
4. **Exports geometry** — meshes, instances (block definitions), and curves — into IFC representations.
|
||||||
5. **Clones properties** — attributes, property sets, and quantities — from the Speckle object onto the IFC entity.
|
5. **Clones properties** — attributes, property sets, and quantities — from the Speckle object onto the IFC entity.
|
||||||
6. **Writes** the resulting `.ifc` file.
|
6. **Writes** the resulting `.ifc` file, compressed as a `.zip`.
|
||||||
|
|
||||||
|
## Performance Optimizations
|
||||||
|
|
||||||
|
The exporter is optimized for file size and speed:
|
||||||
|
|
||||||
|
- **Geometry deduplication** — identical meshes are hashed (MD5 of vertex + face data) and shared via `IfcRepresentationMap` + `IfcMappedItem`, so instances reuse a single geometry copy.
|
||||||
|
- **Shared property sets** — identical `IfcPropertySet` / `IfcElementQuantity` entities are created once and linked to all elements via batched `IfcRelDefinesByProperties`.
|
||||||
|
- **Batched spatial containment** — `IfcRelContainedInSpatialStructure` and type assignments are written in bulk at the end, not per-element.
|
||||||
|
- **Vertex deduplication & rounding** — near-coincident vertices are merged (0.01mm tolerance) and coordinates rounded to 3 decimal places.
|
||||||
|
- **Direction & value caching** — `IfcDirection`, `IfcCartesianPoint`, and `IfcNominalValue` entities are reused across the file.
|
||||||
|
- **Lazy material creation** — `IfcSurfaceStyle` entities are only created when actually assigned to geometry.
|
||||||
|
- **ZIP compression** — output is compressed before upload.
|
||||||
|
|
||||||
## Supported Property Formats
|
## Supported Property Formats
|
||||||
|
|
||||||
@@ -45,6 +55,16 @@ properties:
|
|||||||
|
|
||||||
The exporter automatically detects the format and unflattens dot-notation keys into the nested structure before processing.
|
The exporter automatically detects the format and unflattens dot-notation keys into the nested structure before processing.
|
||||||
|
|
||||||
|
## Instance Handling
|
||||||
|
|
||||||
|
Speckle InstanceProxy objects (block instances) are exported using the IFC mapped representation pattern:
|
||||||
|
|
||||||
|
- Each unique block definition becomes an `IfcRepresentationMap` (geometry stored once).
|
||||||
|
- Each instance becomes an `IfcMappedItem` with an `IfcCartesianTransformationOperator3DnonUniform` encoding the full 4x4 transform (explicit Axis3 for correct orientation with mirrors and non-orthogonal transforms).
|
||||||
|
- Content-based geometry hashing ensures that different definition IDs with identical geometry share the same `IfcRepresentationMap`.
|
||||||
|
|
||||||
|
Supports both Revit-format instances (hex hash definitionId, mm units) and speckleifc-format instances (`DEFINITION:` prefix, metre units).
|
||||||
|
|
||||||
## Function Inputs
|
## Function Inputs
|
||||||
|
|
||||||
| Input | Description |
|
| Input | Description |
|
||||||
@@ -62,19 +82,18 @@ utils/
|
|||||||
traversal.py # Walks the Speckle Collection tree
|
traversal.py # Walks the Speckle Collection tree
|
||||||
mapper.py # Maps Speckle objects → IFC entity classes
|
mapper.py # Maps Speckle objects → IFC entity classes
|
||||||
properties.py # Extracts & writes attributes, property sets, quantities
|
properties.py # Extracts & writes attributes, property sets, quantities
|
||||||
geometry.py # Mesh → IFC geometry conversion
|
geometry.py # Mesh → IFC geometry conversion (IfcPolygonalFaceSet)
|
||||||
instances.py # Block instance / definition handling
|
instances.py # Block instance / definition handling (RepMap + MappedItem)
|
||||||
curves.py # Curve geometry (Polycurve, Line, Arc)
|
curves.py # Curve geometry (Polycurve, Line, Arc → IfcIndexedPolyCurve)
|
||||||
writer.py # IFC scaffold creation, storey management
|
writer.py # IFC scaffold creation, storey management
|
||||||
type_manager.py # IfcTypeObject creation & assignment
|
type_manager.py # IfcTypeObject creation & assignment
|
||||||
materials.py # Material mapping
|
materials.py # Material colour mapping (IfcSurfaceStyle)
|
||||||
helpers.py # Shared utilities (_get, unit scales)
|
helpers.py # Shared utilities (_get, unit scales)
|
||||||
receiver.py # Speckle server connection & data retrieval
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
## Using with Speckle Automate
|
### Using with Speckle Automate
|
||||||
|
|
||||||
1. Go to the Automations tab in your project
|
1. Go to the Automations tab in your project
|
||||||
2. Click New Automation
|
2. Click New Automation
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import zipfile
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import ifcopenshell.api
|
import ifcopenshell.api
|
||||||
@@ -11,6 +12,7 @@ from utils.instances import (
|
|||||||
)
|
)
|
||||||
from utils.properties import (
|
from utils.properties import (
|
||||||
get_building_storey, get_element_name, write_all_properties,
|
get_building_storey, get_element_name, write_all_properties,
|
||||||
|
PropertySetManager,
|
||||||
)
|
)
|
||||||
from utils.curves import curve_to_ifc
|
from utils.curves import curve_to_ifc
|
||||||
from utils.writer import create_ifc_scaffold, StoreyManager
|
from utils.writer import create_ifc_scaffold, StoreyManager
|
||||||
@@ -89,6 +91,7 @@ def automate_function(
|
|||||||
material_manager = MaterialManager(ifc, base)
|
material_manager = MaterialManager(ifc, base)
|
||||||
material_manager.build_definition_material_map(definition_map)
|
material_manager.build_definition_material_map(definition_map)
|
||||||
type_manager = TypeManager(ifc)
|
type_manager = TypeManager(ifc)
|
||||||
|
property_manager = PropertySetManager(ifc)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# 4. Traverse & export
|
# 4. Traverse & export
|
||||||
@@ -138,7 +141,7 @@ def automate_function(
|
|||||||
continue
|
continue
|
||||||
element = _create_element(ifc, ifc_class, name, rep, placement,
|
element = _create_element(ifc, ifc_class, name, rep, placement,
|
||||||
storey, storey_manager=storey_manager)
|
storey, storey_manager=storey_manager)
|
||||||
write_all_properties(ifc, element, obj)
|
write_all_properties(ifc, element, obj, property_manager)
|
||||||
type_manager.assign(element, obj, ifc_class)
|
type_manager.assign(element, obj, ifc_class)
|
||||||
instance_count += 1
|
instance_count += 1
|
||||||
total += 1
|
total += 1
|
||||||
@@ -159,7 +162,7 @@ def automate_function(
|
|||||||
if rep:
|
if rep:
|
||||||
element = _create_element(ifc, ifc_class, name, rep, placement,
|
element = _create_element(ifc, ifc_class, name, rep, placement,
|
||||||
storey, storey_manager=storey_manager)
|
storey, storey_manager=storey_manager)
|
||||||
write_all_properties(ifc, element, obj)
|
write_all_properties(ifc, element, obj, property_manager)
|
||||||
type_manager.assign(element, obj, ifc_class)
|
type_manager.assign(element, obj, ifc_class)
|
||||||
total += 1
|
total += 1
|
||||||
|
|
||||||
@@ -177,7 +180,7 @@ def automate_function(
|
|||||||
ifc, ifc_class, name, inst_rep, inst_placement,
|
ifc, ifc_class, name, inst_rep, inst_placement,
|
||||||
storey, storey_manager=storey_manager,
|
storey, storey_manager=storey_manager,
|
||||||
)
|
)
|
||||||
write_all_properties(ifc, inst_element, obj)
|
write_all_properties(ifc, inst_element, obj, property_manager)
|
||||||
type_manager.assign(inst_element, obj, ifc_class)
|
type_manager.assign(inst_element, obj, ifc_class)
|
||||||
instance_count += 1
|
instance_count += 1
|
||||||
total += 1
|
total += 1
|
||||||
@@ -191,7 +194,7 @@ def automate_function(
|
|||||||
if rep:
|
if rep:
|
||||||
element = _create_element(ifc, ifc_class, name, rep, placement,
|
element = _create_element(ifc, ifc_class, name, rep, placement,
|
||||||
storey, storey_manager=storey_manager)
|
storey, storey_manager=storey_manager)
|
||||||
write_all_properties(ifc, element, obj)
|
write_all_properties(ifc, element, obj, property_manager)
|
||||||
type_manager.assign(element, obj, ifc_class)
|
type_manager.assign(element, obj, ifc_class)
|
||||||
total += 1
|
total += 1
|
||||||
|
|
||||||
@@ -209,6 +212,8 @@ def automate_function(
|
|||||||
storey_manager.flush()
|
storey_manager.flush()
|
||||||
print("Flushing type relationships...")
|
print("Flushing type relationships...")
|
||||||
type_manager.flush()
|
type_manager.flush()
|
||||||
|
print("Flushing shared property sets...")
|
||||||
|
property_manager.flush()
|
||||||
|
|
||||||
file_name = function_inputs.file_name
|
file_name = function_inputs.file_name
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
@@ -217,11 +222,17 @@ def automate_function(
|
|||||||
|
|
||||||
ifc.write(ifc_filename)
|
ifc.write(ifc_filename)
|
||||||
print(f"\nIFC file written: {ifc_filename}")
|
print(f"\nIFC file written: {ifc_filename}")
|
||||||
|
|
||||||
|
zip_filename = f"{file_name}_{timestamp}.zip"
|
||||||
|
with zipfile.ZipFile(zip_filename, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
zf.write(ifc_filename)
|
||||||
|
print(f"Zipped: {zip_filename}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
automate_context.mark_run_success("Success! You can download the IF file below.")
|
automate_context.mark_run_success("Success! You can download the IFC file below.")
|
||||||
automate_context.store_file_result(f"./{ifc_filename}")
|
automate_context.store_file_result(f"./{zip_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}")
|
||||||
@@ -234,6 +245,7 @@ def automate_function(
|
|||||||
print(f" Levels : {', '.join(storey_manager.names)}")
|
print(f" Levels : {', '.join(storey_manager.names)}")
|
||||||
print_instance_stats()
|
print_instance_stats()
|
||||||
material_manager.print_stats()
|
material_manager.print_stats()
|
||||||
|
property_manager.print_stats()
|
||||||
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,
|
||||||
|
|||||||
+7
-7
@@ -38,10 +38,10 @@ def _resolve_scale(obj, fallback: float) -> float:
|
|||||||
|
|
||||||
|
|
||||||
def _point_coords(pt, scale: float) -> tuple:
|
def _point_coords(pt, scale: float) -> tuple:
|
||||||
"""Extract (x, y, z) from a Speckle Point, scaled to mm."""
|
"""Extract (x, y, z) from a Speckle Point, scaled to mm and rounded."""
|
||||||
x = float(_get(pt, "x") or 0) * scale
|
x = round(float(_get(pt, "x") or 0) * scale, 3)
|
||||||
y = float(_get(pt, "y") or 0) * scale
|
y = round(float(_get(pt, "y") or 0) * scale, 3)
|
||||||
z = float(_get(pt, "z") or 0) * scale
|
z = round(float(_get(pt, "z") or 0) * scale, 3)
|
||||||
return x, y, z
|
return x, y, z
|
||||||
|
|
||||||
|
|
||||||
@@ -112,9 +112,9 @@ def _extract_polycurve(obj, scale: float) -> tuple:
|
|||||||
values = list(raw_value) if not isinstance(raw_value, list) else raw_value
|
values = list(raw_value) if not isinstance(raw_value, list) else raw_value
|
||||||
indices = []
|
indices = []
|
||||||
for vi in range(0, len(values) - 2, 3):
|
for vi in range(0, len(values) - 2, 3):
|
||||||
x = float(values[vi]) * seg_scale
|
x = round(float(values[vi]) * seg_scale, 3)
|
||||||
y = float(values[vi + 1]) * seg_scale
|
y = round(float(values[vi + 1]) * seg_scale, 3)
|
||||||
z = float(values[vi + 2]) * seg_scale
|
z = round(float(values[vi + 2]) * seg_scale, 3)
|
||||||
key = (round(x * 100), round(y * 100), round(z * 100))
|
key = (round(x * 100), round(y * 100), round(z * 100))
|
||||||
if key in point_map:
|
if key in point_map:
|
||||||
idx = point_map[key]
|
idx = point_map[key]
|
||||||
|
|||||||
+9
-2
@@ -76,6 +76,13 @@ def build_ifc_facesets(ifc, verts_scaled: list, face_groups: list) -> list:
|
|||||||
if not valid_faces or not deduped_verts:
|
if not valid_faces or not deduped_verts:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
# Round vertex coordinates to reduce IFC text file size
|
||||||
|
# 3 decimal places = 0.001mm precision (more than sufficient)
|
||||||
|
for v in deduped_verts:
|
||||||
|
v[0] = round(v[0], 3)
|
||||||
|
v[1] = round(v[1], 3)
|
||||||
|
v[2] = round(v[2], 3)
|
||||||
|
|
||||||
# Build IFC entities
|
# Build IFC entities
|
||||||
try:
|
try:
|
||||||
point_list = ifc.createIfcCartesianPointList3D(deduped_verts)
|
point_list = ifc.createIfcCartesianPointList3D(deduped_verts)
|
||||||
@@ -311,9 +318,9 @@ def _get_shared(ifc):
|
|||||||
|
|
||||||
|
|
||||||
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 (mm)."""
|
||||||
shared = _get_shared(ifc)
|
shared = _get_shared(ifc)
|
||||||
origin = ifc.createIfcCartesianPoint([x, y, z])
|
origin = ifc.createIfcCartesianPoint([round(x, 3), round(y, 3), round(z, 3)])
|
||||||
a2p = ifc.createIfcAxis2Placement3D(origin, shared["z_axis"], shared["x_axis"])
|
a2p = ifc.createIfcAxis2Placement3D(origin, shared["z_axis"], shared["x_axis"])
|
||||||
return ifc.createIfcLocalPlacement(PlacementRelTo=None, RelativePlacement=a2p)
|
return ifc.createIfcLocalPlacement(PlacementRelTo=None, RelativePlacement=a2p)
|
||||||
|
|
||||||
|
|||||||
+134
-34
@@ -20,7 +20,9 @@
|
|||||||
# sharing the same definition reference a single copy of the geometry.
|
# sharing the same definition reference a single copy of the geometry.
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import math
|
import math
|
||||||
|
import struct
|
||||||
import ifcopenshell.api
|
import ifcopenshell.api
|
||||||
from specklepy.objects.base import Base
|
from specklepy.objects.base import Base
|
||||||
from utils.helpers import _get, MM_SCALES
|
from utils.helpers import _get, MM_SCALES
|
||||||
@@ -249,24 +251,54 @@ _mesh_data_cache: dict = {}
|
|||||||
# 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 = {}
|
||||||
|
|
||||||
|
# Cache: geometry content hash → IfcRepresentationMap
|
||||||
|
# Enables sharing across different definitionIds that have identical geometry.
|
||||||
|
_geometry_hash_cache: dict = {}
|
||||||
|
|
||||||
# Shared identity placement for all instances (keyed by ifc file id)
|
# Shared identity placement for all instances (keyed by ifc file id)
|
||||||
_identity_placement_cache: dict[int, object] = {}
|
_identity_placement_cache: dict[int, object] = {}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Geometry content hashing
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def _hash_mesh_data(mesh_data_list: list, material_key: str = "") -> str:
|
||||||
|
"""Compute a content hash from mesh geometry data for deduplication.
|
||||||
|
|
||||||
|
mesh_data_list: list of (verts_local, face_groups) tuples
|
||||||
|
material_key: string identifying the material (included in hash)
|
||||||
|
Returns: hex digest string
|
||||||
|
"""
|
||||||
|
h = hashlib.md5(usedforsecurity=False)
|
||||||
|
for verts_local, face_groups in mesh_data_list:
|
||||||
|
# Hash rounded vertices as packed floats (faster than str conversion)
|
||||||
|
for i in range(0, len(verts_local), 3):
|
||||||
|
h.update(struct.pack("3f",
|
||||||
|
round(verts_local[i], 3),
|
||||||
|
round(verts_local[i+1], 3),
|
||||||
|
round(verts_local[i+2], 3),
|
||||||
|
))
|
||||||
|
# Hash face indices
|
||||||
|
for face in face_groups:
|
||||||
|
h.update(struct.pack(f"{len(face)}i", *face))
|
||||||
|
# Separator between meshes
|
||||||
|
h.update(b"|")
|
||||||
|
if material_key:
|
||||||
|
h.update(material_key.encode())
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# IfcRepresentationMap builder — geometry created once per definition
|
# IfcRepresentationMap builder — geometry created once per definition
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
def _build_rep_map(ifc, body_context, meshes: list, ifc_format: bool,
|
def _collect_mesh_data(meshes: list, ifc_format: bool) -> list:
|
||||||
material_manager=None, fallback_app_ids: list = None,
|
"""Unpack, scale, and cache mesh vertex/face data.
|
||||||
definition_id: str = None):
|
|
||||||
"""
|
|
||||||
Build an IfcRepresentationMap from definition meshes.
|
|
||||||
Geometry is in local coordinates (mm, no instance transform applied).
|
|
||||||
Returns IfcRepresentationMap or None if no valid geometry.
|
|
||||||
"""
|
|
||||||
geom_items = []
|
|
||||||
|
|
||||||
|
Returns list of (mesh_obj, verts_local, face_groups) tuples.
|
||||||
|
"""
|
||||||
|
result = []
|
||||||
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:
|
||||||
@@ -288,19 +320,62 @@ def _build_rep_map(ifc, body_context, meshes: list, ifc_format: bool,
|
|||||||
print(f" Warning: Instance face decode: {e}")
|
print(f" Warning: Instance face decode: {e}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Scale vertices once and cache the result
|
|
||||||
verts_local = [float(v) * ms for v in verts]
|
verts_local = [float(v) * ms for v in verts]
|
||||||
|
|
||||||
if mesh_id:
|
if mesh_id:
|
||||||
_mesh_data_cache[mesh_id] = (verts_local, face_groups)
|
_mesh_data_cache[mesh_id] = (verts_local, face_groups)
|
||||||
|
|
||||||
mesh_facesets = build_ifc_facesets(ifc, verts_local, face_groups)
|
result.append((mesh, verts_local, face_groups))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_material_key(meshes_data: list, material_manager, fallback_app_ids, definition_id) -> str:
|
||||||
|
"""Build a material cache key string for geometry hashing."""
|
||||||
|
if not material_manager:
|
||||||
|
return ""
|
||||||
|
parts = []
|
||||||
|
for mesh, _, _ in meshes_data:
|
||||||
|
mesh_app_id = _get(mesh, "applicationId")
|
||||||
|
style = material_manager.get_style_with_fallbacks(
|
||||||
|
primary_app_id=str(mesh_app_id) if mesh_app_id else None,
|
||||||
|
fallback_app_ids=fallback_app_ids,
|
||||||
|
definition_id=definition_id,
|
||||||
|
)
|
||||||
|
parts.append(str(id(style)) if style else "")
|
||||||
|
return "|".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_rep_map(ifc, body_context, meshes: list, ifc_format: bool,
|
||||||
|
material_manager=None, fallback_app_ids: list = None,
|
||||||
|
definition_id: str = None):
|
||||||
|
"""
|
||||||
|
Build an IfcRepresentationMap from definition meshes.
|
||||||
|
Uses content-based hashing to reuse identical geometry across different
|
||||||
|
definitionIds. Returns IfcRepresentationMap or None if no valid geometry.
|
||||||
|
"""
|
||||||
|
# Step 1: Collect and cache raw mesh data (no IFC entities created yet)
|
||||||
|
meshes_data = _collect_mesh_data(meshes, ifc_format)
|
||||||
|
if not meshes_data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Step 2: Compute content hash to check for identical geometry
|
||||||
|
mat_key = _resolve_material_key(meshes_data, material_manager, fallback_app_ids, definition_id)
|
||||||
|
geom_hash = _hash_mesh_data(
|
||||||
|
[(verts, faces) for _, verts, faces in meshes_data],
|
||||||
|
material_key=mat_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
if geom_hash in _geometry_hash_cache:
|
||||||
|
return _geometry_hash_cache[geom_hash]
|
||||||
|
|
||||||
|
# Step 3: No match — build IFC geometry entities
|
||||||
|
geom_items = []
|
||||||
|
|
||||||
|
for mesh, verts_local, face_groups in meshes_data:
|
||||||
|
mesh_facesets = build_ifc_facesets(ifc, verts_local, face_groups)
|
||||||
if not mesh_facesets:
|
if not mesh_facesets:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Apply material style to each faceset
|
|
||||||
# Try: mesh applicationId → fallback IDs → definitionId mapping
|
|
||||||
if material_manager:
|
if material_manager:
|
||||||
mesh_app_id = _get(mesh, "applicationId")
|
mesh_app_id = _get(mesh, "applicationId")
|
||||||
style = material_manager.get_style_with_fallbacks(
|
style = material_manager.get_style_with_fallbacks(
|
||||||
@@ -322,13 +397,12 @@ def _build_rep_map(ifc, body_context, meshes: list, ifc_format: bool,
|
|||||||
geom_items.extend(mesh_facesets)
|
geom_items.extend(mesh_facesets)
|
||||||
|
|
||||||
if not geom_items:
|
if not geom_items:
|
||||||
|
_geometry_hash_cache[geom_hash] = None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Mapping origin = identity (local coords origin) — reuse shared origin
|
|
||||||
shared = _get_shared(ifc)
|
shared = _get_shared(ifc)
|
||||||
a2p = ifc.createIfcAxis2Placement3D(shared["origin_0"], None, None)
|
a2p = ifc.createIfcAxis2Placement3D(shared["origin_0"], None, None)
|
||||||
|
|
||||||
# The mapped representation holds the actual geometry
|
|
||||||
mapped_rep = ifc.createIfcShapeRepresentation(
|
mapped_rep = ifc.createIfcShapeRepresentation(
|
||||||
ContextOfItems=body_context,
|
ContextOfItems=body_context,
|
||||||
RepresentationIdentifier="Body",
|
RepresentationIdentifier="Body",
|
||||||
@@ -336,7 +410,9 @@ def _build_rep_map(ifc, body_context, meshes: list, ifc_format: bool,
|
|||||||
Items=geom_items,
|
Items=geom_items,
|
||||||
)
|
)
|
||||||
|
|
||||||
return ifc.createIfcRepresentationMap(a2p, mapped_rep)
|
rep_map = ifc.createIfcRepresentationMap(a2p, mapped_rep)
|
||||||
|
_geometry_hash_cache[geom_hash] = rep_map
|
||||||
|
return rep_map
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -347,6 +423,22 @@ def _vec_magnitude(x, y, z):
|
|||||||
return math.sqrt(x*x + y*y + z*z)
|
return math.sqrt(x*x + y*y + z*z)
|
||||||
|
|
||||||
|
|
||||||
|
# Cache: rounded direction tuple → IfcDirection entity (keyed by ifc file id)
|
||||||
|
_direction_cache: dict[int, dict] = {}
|
||||||
|
|
||||||
|
def _get_or_create_direction(ifc, dx, dy, dz):
|
||||||
|
"""Return a cached IfcDirection or create and cache a new one."""
|
||||||
|
fid = id(ifc)
|
||||||
|
if fid not in _direction_cache:
|
||||||
|
_direction_cache[fid] = {}
|
||||||
|
cache = _direction_cache[fid]
|
||||||
|
# Round to 6 decimals — sufficient for unit vectors
|
||||||
|
key = (round(dx, 6), round(dy, 6), round(dz, 6))
|
||||||
|
if key not in cache:
|
||||||
|
cache[key] = ifc.createIfcDirection([key[0], key[1], key[2]])
|
||||||
|
return cache[key]
|
||||||
|
|
||||||
|
|
||||||
def _make_transform_operator(ifc, t: list, ts: float):
|
def _make_transform_operator(ifc, t: list, ts: float):
|
||||||
"""
|
"""
|
||||||
Convert a row-major 4x4 matrix + translation scale into an
|
Convert a row-major 4x4 matrix + translation scale into an
|
||||||
@@ -355,22 +447,20 @@ def _make_transform_operator(ifc, t: list, ts: float):
|
|||||||
t: 16 floats, row-major [r00,r01,r02,tx, r10,r11,r12,ty, r20,r21,r22,tz, 0,0,0,1]
|
t: 16 floats, row-major [r00,r01,r02,tx, r10,r11,r12,ty, r20,r21,r22,tz, 0,0,0,1]
|
||||||
ts: scale factor for translation components (e.g. 1000.0 for m→mm)
|
ts: scale factor for translation components (e.g. 1000.0 for m→mm)
|
||||||
|
|
||||||
The matrix acts as: p' = M * p + translation, where M rows are:
|
|
||||||
row0 = (t[0], t[1], t[2])
|
|
||||||
row1 = (t[4], t[5], t[6])
|
|
||||||
row2 = (t[8], t[9], t[10])
|
|
||||||
|
|
||||||
IfcCartesianTransformationOperator axes represent the COLUMNS of M:
|
IfcCartesianTransformationOperator axes represent the COLUMNS of M:
|
||||||
Axis1 = column 0 = where local X maps → (t[0], t[4], t[8])
|
Axis1 = column 0 = where local X maps → (t[0], t[4], t[8])
|
||||||
Axis2 = column 1 = where local Y maps → (t[1], t[5], t[9])
|
Axis2 = column 1 = where local Y maps → (t[1], t[5], t[9])
|
||||||
Axis3 = column 2 = where local Z maps → (t[2], t[6], t[10])
|
Axis3 = column 2 = where local Z maps → (t[2], t[6], t[10])
|
||||||
|
|
||||||
|
Always uses the non-uniform variant with explicit Axis3 to ensure
|
||||||
|
correct orientation for all transform types (mirrors, non-orthogonal, etc.).
|
||||||
|
|
||||||
Returns the IFC entity, or None if the transform is degenerate.
|
Returns the IFC entity, or None if the transform is degenerate.
|
||||||
"""
|
"""
|
||||||
# Extract COLUMNS of the 3x3 rotation/scale sub-matrix
|
# Extract COLUMNS of the 3x3 rotation/scale sub-matrix
|
||||||
ax1 = (float(t[0]), float(t[4]), float(t[8])) # column 0: X-axis direction
|
ax1 = (float(t[0]), float(t[4]), float(t[8]))
|
||||||
ax2 = (float(t[1]), float(t[5]), float(t[9])) # column 1: Y-axis direction
|
ax2 = (float(t[1]), float(t[5]), float(t[9]))
|
||||||
ax3 = (float(t[2]), float(t[6]), float(t[10])) # column 2: Z-axis direction
|
ax3 = (float(t[2]), float(t[6]), float(t[10]))
|
||||||
|
|
||||||
s1 = _vec_magnitude(*ax1)
|
s1 = _vec_magnitude(*ax1)
|
||||||
s2 = _vec_magnitude(*ax2)
|
s2 = _vec_magnitude(*ax2)
|
||||||
@@ -379,24 +469,28 @@ def _make_transform_operator(ifc, t: list, ts: float):
|
|||||||
if s1 < 1e-10 or s2 < 1e-10 or s3 < 1e-10:
|
if s1 < 1e-10 or s2 < 1e-10 or s3 < 1e-10:
|
||||||
return None # degenerate transform
|
return None # degenerate transform
|
||||||
|
|
||||||
# Normalized direction vectors
|
# Normalized direction vectors — reuse cached IfcDirection entities
|
||||||
d1 = ifc.createIfcDirection([ax1[0]/s1, ax1[1]/s1, ax1[2]/s1])
|
d1 = _get_or_create_direction(ifc, ax1[0]/s1, ax1[1]/s1, ax1[2]/s1)
|
||||||
d2 = ifc.createIfcDirection([ax2[0]/s2, ax2[1]/s2, ax2[2]/s2])
|
d2 = _get_or_create_direction(ifc, ax2[0]/s2, ax2[1]/s2, ax2[2]/s2)
|
||||||
d3 = ifc.createIfcDirection([ax3[0]/s3, ax3[1]/s3, ax3[2]/s3])
|
d3 = _get_or_create_direction(ifc, ax3[0]/s3, ax3[1]/s3, ax3[2]/s3)
|
||||||
|
|
||||||
# Translation, scaled to mm
|
# Translation, scaled and rounded to mm
|
||||||
tx = float(t[3]) * ts
|
tx = round(float(t[3]) * ts, 3)
|
||||||
ty = float(t[7]) * ts
|
ty = round(float(t[7]) * ts, 3)
|
||||||
tz = float(t[11]) * ts
|
tz = round(float(t[11]) * ts, 3)
|
||||||
origin = ifc.createIfcCartesianPoint([tx, ty, tz])
|
origin = ifc.createIfcCartesianPoint([tx, ty, tz])
|
||||||
|
|
||||||
# Use non-uniform variant to handle mirrors and non-uniform scale
|
# Round scales for cleaner output
|
||||||
|
s1 = round(s1, 6)
|
||||||
|
s2 = round(s2, 6)
|
||||||
|
s3 = round(s3, 6)
|
||||||
|
|
||||||
return ifc.createIfcCartesianTransformationOperator3DnonUniform(
|
return ifc.createIfcCartesianTransformationOperator3DnonUniform(
|
||||||
d1, # Axis1
|
d1, # Axis1
|
||||||
d2, # Axis2
|
d2, # Axis2
|
||||||
origin, # LocalOrigin
|
origin, # LocalOrigin
|
||||||
s1, # Scale
|
s1, # Scale
|
||||||
d3, # Axis3
|
d3, # Axis3 (explicit — never derived)
|
||||||
s2, # Scale2
|
s2, # Scale2
|
||||||
s3, # Scale3
|
s3, # Scale3
|
||||||
)
|
)
|
||||||
@@ -529,12 +623,18 @@ def print_instance_stats():
|
|||||||
print(f" Instance resolution: {_stats['found']}/{total} definitions found")
|
print(f" Instance resolution: {_stats['found']}/{total} definitions found")
|
||||||
if _stats["not_found"] > 0:
|
if _stats["not_found"] > 0:
|
||||||
print(f" Warning: {_stats['not_found']} instances had no definition geometry")
|
print(f" Warning: {_stats['not_found']} instances had no definition geometry")
|
||||||
|
unique_defs = len(_rep_map_cache)
|
||||||
|
unique_geom = len([v for v in _geometry_hash_cache.values() if v is not None])
|
||||||
|
if unique_defs > unique_geom:
|
||||||
|
print(f" Geometry dedup: {unique_defs} definitions -> {unique_geom} unique geometries")
|
||||||
|
|
||||||
|
|
||||||
def reset_caches():
|
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()
|
||||||
|
_geometry_hash_cache.clear()
|
||||||
_identity_placement_cache.clear()
|
_identity_placement_cache.clear()
|
||||||
|
_direction_cache.clear()
|
||||||
_stats["found"] = 0
|
_stats["found"] = 0
|
||||||
_stats["not_found"] = 0
|
_stats["not_found"] = 0
|
||||||
|
|||||||
+290
-92
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
import ifcopenshell
|
import ifcopenshell
|
||||||
import ifcopenshell.api
|
import ifcopenshell.api
|
||||||
|
import ifcopenshell.guid
|
||||||
from specklepy.objects.base import Base
|
from specklepy.objects.base import Base
|
||||||
from utils.helpers import _get
|
from utils.helpers import _get
|
||||||
|
|
||||||
@@ -121,17 +122,34 @@ def get_element_name(obj) -> str:
|
|||||||
# IFC value creation
|
# IFC value creation
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_ifc_value_cache: dict[int, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
def _make_ifc_value(ifc, value):
|
def _make_ifc_value(ifc, value):
|
||||||
"""Create an IFC nominal value entity from a Python value, detecting type."""
|
"""Create a cached IFC nominal value entity from a Python value, detecting type.
|
||||||
|
|
||||||
|
Identical values reuse the same IFC entity to reduce file size.
|
||||||
|
"""
|
||||||
|
fid = id(ifc)
|
||||||
|
if fid not in _ifc_value_cache:
|
||||||
|
_ifc_value_cache[fid] = {}
|
||||||
|
cache = _ifc_value_cache[fid]
|
||||||
|
|
||||||
if isinstance(value, bool):
|
if isinstance(value, bool):
|
||||||
return ifc.create_entity("IfcBoolean", wrappedValue=value)
|
cache_key = ("IfcBoolean", value)
|
||||||
if isinstance(value, int):
|
elif isinstance(value, int):
|
||||||
return ifc.create_entity("IfcInteger", wrappedValue=value)
|
cache_key = ("IfcInteger", value)
|
||||||
if isinstance(value, float):
|
elif isinstance(value, float):
|
||||||
return ifc.create_entity("IfcReal", wrappedValue=value)
|
cache_key = ("IfcReal", value)
|
||||||
if isinstance(value, list):
|
elif isinstance(value, list):
|
||||||
return ifc.create_entity("IfcLabel", wrappedValue=", ".join(str(v) for v in value))
|
cache_key = ("IfcLabel", ", ".join(str(v) for v in value))
|
||||||
return ifc.create_entity("IfcLabel", wrappedValue=str(value))
|
else:
|
||||||
|
cache_key = ("IfcLabel", str(value))
|
||||||
|
|
||||||
|
if cache_key not in cache:
|
||||||
|
entity_type, wrapped = cache_key
|
||||||
|
cache[cache_key] = ifc.create_entity(entity_type, wrappedValue=wrapped)
|
||||||
|
return cache[cache_key]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -202,50 +220,7 @@ def set_element_attributes(ifc, element, obj):
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Write property sets from _properties.Property Sets
|
# PropertySetManager — shared property sets across elements
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def write_property_sets(ifc, element, obj):
|
|
||||||
"""Write all property sets from _properties.Property Sets."""
|
|
||||||
props = get_properties(obj)
|
|
||||||
properties_section = _to_dict(props.get("Property Sets"))
|
|
||||||
if not properties_section:
|
|
||||||
return
|
|
||||||
|
|
||||||
for pset_name, pset_data in properties_section.items():
|
|
||||||
pset_dict = _to_dict(pset_data) if not isinstance(pset_data, dict) else pset_data
|
|
||||||
if not pset_dict:
|
|
||||||
continue
|
|
||||||
|
|
||||||
ifc_props = []
|
|
||||||
for prop_name, prop_value in pset_dict.items():
|
|
||||||
if prop_name == "id":
|
|
||||||
continue
|
|
||||||
if prop_value is None:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
nominal = _make_ifc_value(ifc, prop_value)
|
|
||||||
p = ifc.create_entity(
|
|
||||||
"IfcPropertySingleValue",
|
|
||||||
Name=str(prop_name),
|
|
||||||
NominalValue=nominal,
|
|
||||||
)
|
|
||||||
ifc_props.append(p)
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if ifc_props:
|
|
||||||
try:
|
|
||||||
pset = ifcopenshell.api.run(
|
|
||||||
"pset.add_pset", ifc, product=element, name=pset_name
|
|
||||||
)
|
|
||||||
pset.HasProperties = ifc_props
|
|
||||||
except Exception as e:
|
|
||||||
print(f" Warning: {pset_name}: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Write quantity sets from _properties.Quantities
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _try_float(value):
|
def _try_float(value):
|
||||||
@@ -260,8 +235,261 @@ def _try_float(value):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def write_quantity_sets(ifc, element, obj):
|
def _pset_content_key(pset_name: str, items: list) -> str:
|
||||||
"""Write all quantity sets from _properties.Quantities."""
|
"""Build a hashable key from pset name + sorted property name-value pairs."""
|
||||||
|
return repr((pset_name, sorted(items)))
|
||||||
|
|
||||||
|
|
||||||
|
def _qto_content_key(qto_name: str, items: list) -> str:
|
||||||
|
"""Build a hashable key from qto name + sorted quantity tuples."""
|
||||||
|
return repr((qto_name, sorted(items)))
|
||||||
|
|
||||||
|
|
||||||
|
class PropertySetManager:
|
||||||
|
"""Creates shared IfcPropertySet / IfcElementQuantity entities.
|
||||||
|
|
||||||
|
Instead of creating one pset per element, identical psets are created
|
||||||
|
once and linked to all elements that share them via a single
|
||||||
|
IfcRelDefinesByProperties (written at flush time).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, ifc: ifcopenshell.file):
|
||||||
|
self._ifc = ifc
|
||||||
|
# content_key → IfcPropertySet / IfcElementQuantity entity
|
||||||
|
self._pset_cache: dict[str, object] = {}
|
||||||
|
# pset entity id → [element, ...]
|
||||||
|
self._pending: dict[int, list] = {}
|
||||||
|
self._pset_count = 0
|
||||||
|
self._shared_count = 0
|
||||||
|
|
||||||
|
def queue_property_sets(self, element, obj):
|
||||||
|
"""Extract Property Sets from obj and queue shared assignment to element."""
|
||||||
|
props = get_properties(obj)
|
||||||
|
properties_section = _to_dict(props.get("Property Sets"))
|
||||||
|
if not properties_section:
|
||||||
|
return
|
||||||
|
|
||||||
|
ifc = self._ifc
|
||||||
|
for pset_name, pset_data in properties_section.items():
|
||||||
|
pset_dict = _to_dict(pset_data) if not isinstance(pset_data, dict) else pset_data
|
||||||
|
if not pset_dict:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Build content items for hashing (skip id and None values)
|
||||||
|
content_items = []
|
||||||
|
for prop_name, prop_value in pset_dict.items():
|
||||||
|
if prop_name == "id" or prop_value is None:
|
||||||
|
continue
|
||||||
|
content_items.append((str(prop_name), repr(prop_value)))
|
||||||
|
|
||||||
|
if not content_items:
|
||||||
|
continue
|
||||||
|
|
||||||
|
key = _pset_content_key(pset_name, content_items)
|
||||||
|
|
||||||
|
if key not in self._pset_cache:
|
||||||
|
# Create the shared IfcPropertySet entity
|
||||||
|
ifc_props = []
|
||||||
|
for prop_name, prop_value in pset_dict.items():
|
||||||
|
if prop_name == "id" or prop_value is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
nominal = _make_ifc_value(ifc, prop_value)
|
||||||
|
p = ifc.create_entity(
|
||||||
|
"IfcPropertySingleValue",
|
||||||
|
Name=str(prop_name),
|
||||||
|
NominalValue=nominal,
|
||||||
|
)
|
||||||
|
ifc_props.append(p)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not ifc_props:
|
||||||
|
continue
|
||||||
|
|
||||||
|
pset = ifc.create_entity(
|
||||||
|
"IfcPropertySet",
|
||||||
|
GlobalId=ifcopenshell.guid.new(),
|
||||||
|
Name=pset_name,
|
||||||
|
HasProperties=ifc_props,
|
||||||
|
)
|
||||||
|
self._pset_cache[key] = pset
|
||||||
|
self._pset_count += 1
|
||||||
|
else:
|
||||||
|
self._shared_count += 1
|
||||||
|
|
||||||
|
pset = self._pset_cache[key]
|
||||||
|
pid = pset.id()
|
||||||
|
if pid not in self._pending:
|
||||||
|
self._pending[pid] = []
|
||||||
|
self._pending[pid].append(element)
|
||||||
|
|
||||||
|
def queue_quantity_sets(self, element, obj):
|
||||||
|
"""Extract Quantities from obj and queue shared assignment to element."""
|
||||||
|
props = get_properties(obj)
|
||||||
|
quantities_raw = props.get("Quantities")
|
||||||
|
if quantities_raw is None:
|
||||||
|
return
|
||||||
|
quantities_section = _to_dict(quantities_raw)
|
||||||
|
if not quantities_section:
|
||||||
|
return
|
||||||
|
|
||||||
|
ifc = self._ifc
|
||||||
|
for qto_name, qto_data in quantities_section.items():
|
||||||
|
qto_dict = _to_dict(qto_data) if not isinstance(qto_data, dict) else qto_data
|
||||||
|
if not qto_dict:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Build content items for hashing
|
||||||
|
content_items = []
|
||||||
|
for qty_key, qty_entry in qto_dict.items():
|
||||||
|
if qty_key == "id":
|
||||||
|
continue
|
||||||
|
if isinstance(qty_entry, dict):
|
||||||
|
name = qty_entry.get("name", qty_key)
|
||||||
|
units = (qty_entry.get("units") or "").strip().lower()
|
||||||
|
value = _try_float(qty_entry.get("value"))
|
||||||
|
else:
|
||||||
|
value = _try_float(qty_entry)
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
name = qty_key
|
||||||
|
units = ""
|
||||||
|
if value is not None:
|
||||||
|
content_items.append((name, units, value))
|
||||||
|
|
||||||
|
if not content_items:
|
||||||
|
continue
|
||||||
|
|
||||||
|
key = _qto_content_key(qto_name, content_items)
|
||||||
|
|
||||||
|
if key not in self._pset_cache:
|
||||||
|
quantities = []
|
||||||
|
for qty_key, qty_entry in qto_dict.items():
|
||||||
|
if qty_key == "id":
|
||||||
|
continue
|
||||||
|
if isinstance(qty_entry, dict):
|
||||||
|
name = qty_entry.get("name", qty_key)
|
||||||
|
units = (qty_entry.get("units") or "").strip().lower()
|
||||||
|
value = _try_float(qty_entry.get("value"))
|
||||||
|
else:
|
||||||
|
value = _try_float(qty_entry)
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
name = qty_key
|
||||||
|
units = ""
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
mapping = _UNIT_QTY_MAP.get(units)
|
||||||
|
if not mapping:
|
||||||
|
name_lower = name.lower()
|
||||||
|
for keyword, m in _NAME_QTY_MAP.items():
|
||||||
|
if keyword in name_lower:
|
||||||
|
mapping = m
|
||||||
|
break
|
||||||
|
if mapping:
|
||||||
|
qty_type, value_attr = mapping
|
||||||
|
qty = ifc.create_entity(
|
||||||
|
qty_type, Name=name, **{value_attr: value}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
qty = ifc.create_entity(
|
||||||
|
"IfcQuantityCount", Name=name, CountValue=int(value)
|
||||||
|
)
|
||||||
|
quantities.append(qty)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Warning: quantity {name}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not quantities:
|
||||||
|
continue
|
||||||
|
|
||||||
|
qto = ifc.create_entity(
|
||||||
|
"IfcElementQuantity",
|
||||||
|
GlobalId=ifcopenshell.guid.new(),
|
||||||
|
Name=qto_name,
|
||||||
|
Quantities=quantities,
|
||||||
|
)
|
||||||
|
self._pset_cache[key] = qto
|
||||||
|
self._pset_count += 1
|
||||||
|
else:
|
||||||
|
self._shared_count += 1
|
||||||
|
|
||||||
|
qto = self._pset_cache[key]
|
||||||
|
qid = qto.id()
|
||||||
|
if qid not in self._pending:
|
||||||
|
self._pending[qid] = []
|
||||||
|
self._pending[qid].append(element)
|
||||||
|
|
||||||
|
def flush(self):
|
||||||
|
"""Write all batched IfcRelDefinesByProperties relationships."""
|
||||||
|
ifc = self._ifc
|
||||||
|
for pset_id, elements in self._pending.items():
|
||||||
|
pset = ifc.by_id(pset_id)
|
||||||
|
try:
|
||||||
|
ifc.create_entity(
|
||||||
|
"IfcRelDefinesByProperties",
|
||||||
|
GlobalId=ifcopenshell.guid.new(),
|
||||||
|
RelatedObjects=elements,
|
||||||
|
RelatingPropertyDefinition=pset,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" Warning: pset rel: {e}")
|
||||||
|
self._pending.clear()
|
||||||
|
|
||||||
|
def print_stats(self):
|
||||||
|
total = self._pset_count + self._shared_count
|
||||||
|
print(f" Property sets: {self._pset_count} unique / {total} total ({self._shared_count} shared)")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API — called from main.py
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def write_all_properties(ifc, element, obj, property_manager=None):
|
||||||
|
"""Write all properties, quantities, and attributes from _properties."""
|
||||||
|
set_element_attributes(ifc, element, obj)
|
||||||
|
if property_manager:
|
||||||
|
property_manager.queue_property_sets(element, obj)
|
||||||
|
property_manager.queue_quantity_sets(element, obj)
|
||||||
|
else:
|
||||||
|
# Fallback: direct per-element creation (no sharing)
|
||||||
|
_write_property_sets_direct(ifc, element, obj)
|
||||||
|
_write_quantity_sets_direct(ifc, element, obj)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_property_sets_direct(ifc, element, obj):
|
||||||
|
"""Legacy per-element property set writing (fallback)."""
|
||||||
|
props = get_properties(obj)
|
||||||
|
properties_section = _to_dict(props.get("Property Sets"))
|
||||||
|
if not properties_section:
|
||||||
|
return
|
||||||
|
for pset_name, pset_data in properties_section.items():
|
||||||
|
pset_dict = _to_dict(pset_data) if not isinstance(pset_data, dict) else pset_data
|
||||||
|
if not pset_dict:
|
||||||
|
continue
|
||||||
|
ifc_props = []
|
||||||
|
for prop_name, prop_value in pset_dict.items():
|
||||||
|
if prop_name == "id" or prop_value is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
nominal = _make_ifc_value(ifc, prop_value)
|
||||||
|
p = ifc.create_entity("IfcPropertySingleValue", Name=str(prop_name), NominalValue=nominal)
|
||||||
|
ifc_props.append(p)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if ifc_props:
|
||||||
|
try:
|
||||||
|
pset = ifcopenshell.api.run("pset.add_pset", ifc, product=element, name=pset_name)
|
||||||
|
pset.HasProperties = ifc_props
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _write_quantity_sets_direct(ifc, element, obj):
|
||||||
|
"""Legacy per-element quantity set writing (fallback)."""
|
||||||
props = get_properties(obj)
|
props = get_properties(obj)
|
||||||
quantities_raw = props.get("Quantities")
|
quantities_raw = props.get("Quantities")
|
||||||
if quantities_raw is None:
|
if quantities_raw is None:
|
||||||
@@ -269,20 +497,14 @@ def write_quantity_sets(ifc, element, obj):
|
|||||||
quantities_section = _to_dict(quantities_raw)
|
quantities_section = _to_dict(quantities_raw)
|
||||||
if not quantities_section:
|
if not quantities_section:
|
||||||
return
|
return
|
||||||
|
|
||||||
for qto_name, qto_data in quantities_section.items():
|
for qto_name, qto_data in quantities_section.items():
|
||||||
qto_dict = _to_dict(qto_data) if not isinstance(qto_data, dict) else qto_data
|
qto_dict = _to_dict(qto_data) if not isinstance(qto_data, dict) else qto_data
|
||||||
if not qto_dict:
|
if not qto_dict:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
quantities = []
|
quantities = []
|
||||||
for qty_key, qty_entry in qto_dict.items():
|
for qty_key, qty_entry in qto_dict.items():
|
||||||
if qty_key == "id":
|
if qty_key == "id":
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Quantity entries can be:
|
|
||||||
# - {name, units, value} dicts (ArchiCAD / IFC-native)
|
|
||||||
# - plain numbers or numeric strings (Grasshopper)
|
|
||||||
if isinstance(qty_entry, dict):
|
if isinstance(qty_entry, dict):
|
||||||
name = qty_entry.get("name", qty_key)
|
name = qty_entry.get("name", qty_key)
|
||||||
units = (qty_entry.get("units") or "").strip().lower()
|
units = (qty_entry.get("units") or "").strip().lower()
|
||||||
@@ -293,50 +515,26 @@ def write_quantity_sets(ifc, element, obj):
|
|||||||
continue
|
continue
|
||||||
name = qty_key
|
name = qty_key
|
||||||
units = ""
|
units = ""
|
||||||
|
|
||||||
if value is None:
|
if value is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
mapping = _UNIT_QTY_MAP.get(units)
|
mapping = _UNIT_QTY_MAP.get(units)
|
||||||
if not mapping:
|
if not mapping:
|
||||||
# Infer quantity type from name keywords
|
|
||||||
name_lower = name.lower()
|
|
||||||
for keyword, m in _NAME_QTY_MAP.items():
|
for keyword, m in _NAME_QTY_MAP.items():
|
||||||
if keyword in name_lower:
|
if keyword in name.lower():
|
||||||
mapping = m
|
mapping = m
|
||||||
break
|
break
|
||||||
if mapping:
|
if mapping:
|
||||||
qty_type, value_attr = mapping
|
qty_type, value_attr = mapping
|
||||||
qty = ifc.create_entity(
|
qty = ifc.create_entity(qty_type, Name=name, **{value_attr: value})
|
||||||
qty_type, Name=name, **{value_attr: value}
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
# CountValue requires int
|
qty = ifc.create_entity("IfcQuantityCount", Name=name, CountValue=int(value))
|
||||||
qty = ifc.create_entity(
|
|
||||||
"IfcQuantityCount", Name=name, CountValue=int(value)
|
|
||||||
)
|
|
||||||
quantities.append(qty)
|
quantities.append(qty)
|
||||||
except Exception as e:
|
except Exception:
|
||||||
print(f" Warning: quantity {name}: {e}")
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if quantities:
|
if quantities:
|
||||||
try:
|
try:
|
||||||
qto = ifcopenshell.api.run(
|
qto = ifcopenshell.api.run("pset.add_qto", ifc, product=element, name=qto_name)
|
||||||
"pset.add_qto", ifc, product=element, name=qto_name
|
|
||||||
)
|
|
||||||
qto.Quantities = quantities
|
qto.Quantities = quantities
|
||||||
except Exception as e:
|
except Exception:
|
||||||
print(f" Warning: {qto_name}: {e}")
|
pass
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Public API — called from main.py
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def write_all_properties(ifc, element, obj):
|
|
||||||
"""Write all properties, quantities, and attributes from _properties."""
|
|
||||||
set_element_attributes(ifc, element, obj)
|
|
||||||
write_property_sets(ifc, element, obj)
|
|
||||||
write_quantity_sets(ifc, element, obj)
|
|
||||||
|
|||||||
Reference in New Issue
Block a user