update materials and instances

This commit is contained in:
NLSA
2026-03-20 09:46:23 +01:00
parent b433b91902
commit 69945259d2
8 changed files with 311 additions and 154 deletions
+74 -34
View File
@@ -6,30 +6,46 @@ A [Speckle Automate](https://automate.speckle.dev/) function that converts Speck
The exporter receives a Speckle model version, walks its nested collection tree, and produces a standards-compliant IFC 4.3 file. Each Speckle object becomes an IFC element with: The exporter receives a Speckle model version, walks its nested collection tree, and produces a standards-compliant IFC 4.3 file. Each Speckle object becomes an IFC element with:
- Correct IFC entity classification read from `_properties.Attributes.type` - Correct IFC entity classification read from `properties.Attributes.type`
- Tessellated geometry (IfcPolygonalFaceSet) - Tessellated geometry (IfcPolygonalFaceSet) from Mesh, Brep, or BrepX objects
- Material colours from Speckle render materials - Material colours from `renderMaterialProxies` applied as IfcSurfaceStyle
- All property sets cloned from `_properties.Property Sets` - All property sets cloned from `properties.Property Sets`
- All quantity sets cloned from `_properties.Quantities` - All quantity sets cloned from `properties.Quantities` (supports both `{name, units, value}` dicts and plain numeric values)
- IFC type objects created from `_properties.Element Type Attributes` - IFC type objects from `properties.Element Type Attributes` and/or `properties.Element Type Property Sets`
- Building storeys derived from `_properties.Building Storey` - Building storeys derived from `properties.Building Storey`
- Spatial structure (IfcProject > IfcSite > IfcBuilding > IfcBuildingStorey) - Spatial structure (IfcProject > IfcSite > IfcBuilding > IfcBuildingStorey)
Objects that serve as instance definition geometry sources are automatically skipped during export — their geometry is shared via IfcRepresentationMap.
## Object Structure ## Object Structure
The exporter expects Speckle objects with the following `_properties` layout: The exporter expects Speckle `DataObject` elements with a `properties` dict:
``` ```
_properties properties
├── Attributes → IFC element attributes (type, GlobalId, Name, Tag, etc.) ├── Attributes → IFC element attributes (type, GlobalId, Name, Tag, etc.)
├── Property Sets → dict of {pset_name: {prop_name: value}} ├── Property Sets → {pset_name: {prop_name: value}}
├── Quantities → dict of {qto_name: {qty_name: {name, units, value}}} ├── Quantities → {qto_name: {qty_name: value_or_dict}}
├── Building Storey → string, used for storey assignment ├── Building Storey → string, used for storey assignment
├── Element Type Attributes → type class, Name, GlobalId (creates IfcTypeObject) ├── Element Type Attributes → (optional) type class, Name, GlobalId (creates IfcTypeObject)
└── Element Type Property Sets → property sets written on the IfcTypeObject └── Element Type Property Sets → (optional) property sets written on the IfcTypeObject
``` ```
The nested collection tree is expected as: `Root Collection > Collection > ... > Object` The nested collection tree is expected as: `Root Collection > Collection > ... > DataObject`
### Type Object Handling
Two formats are supported for creating IFC type objects:
- **Format A** — `Element Type Attributes` contains explicit type info (`type`, `Name`, `GlobalId`, etc.) and `Element Type Property Sets` contains the type's property sets.
- **Format B** — Only `Element Type Property Sets` exists (no `Element Type Attributes`). The type class is derived from the element class (e.g. `IfcColumn``IfcColumnType`).
### Quantity Formats
Quantities support two value formats:
- **Dict format**: `{'name': 'Length', 'units': 'Millimetre', 'value': 3000}` — unit is used to select the correct IFC quantity type (IfcQuantityLength, IfcQuantityArea, IfcQuantityVolume, etc.)
- **Plain format**: `{'Length': 3000, 'GrossVolume': 0.27}` — quantity type is inferred from name keywords (e.g. "Length" → IfcQuantityLength, "Area" → IfcQuantityArea, "Volume" → IfcQuantityVolume). Falls back to IfcQuantityCount if no keyword matches.
## Pipeline Overview ## Pipeline Overview
@@ -40,26 +56,30 @@ Speckle Model
1. Receive version (specklepy) 1. Receive version (specklepy)
2. Build definition map (for instance geometry reuse) 2. Build definition map (for instance geometry reuse + definition source detection)
3. Create IFC scaffold (Project → Site → Building) 3. Create IFC scaffold (Project → Site → Building)
4. Traverse collection tree 4. Initialize material manager (parse renderMaterialProxies)
5. Traverse collection tree
│ For each leaf element: │ For each leaf element:
│ ├── Classify → IFC entity class (from _properties.Attributes.type) │ ├── Skip spatial structure types and definition geometry sources
│ ├── Convert geometry → IfcPolygonalFaceSet │ ├── Classify → IFC entity class (from properties.Attributes.type)
│ ├── Convert geometry → IfcPolygonalFaceSet (with material colours)
│ ├── Create IFC element + placement │ ├── Create IFC element + placement
│ ├── Clone all properties & quantities │ ├── Clone all properties & quantities
│ ├── Assign to Building Storey (from _properties.Building Storey) │ ├── Assign to Building Storey (from properties.Building Storey)
│ └── Assign IFC type object (from Element Type Attributes) │ └── Assign IFC type object
5. Flush spatial containment & type relationships 6. Flush spatial containment & type relationships
6. Write .ifc file 7. Write .ifc file
``` ```
## Module Structure ## Module Structure
@@ -67,19 +87,20 @@ Speckle Model
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `main.py` | Entry point, orchestrates the full pipeline | | `main.py` | Entry point, orchestrates the full pipeline |
| `utils/traversal.py` | Walks the Speckle collection tree (Root > Collection* > Object) | | `utils/helpers.py` | Shared utilities: safe attribute access (`_get`) and unit scale constants |
| `utils/mapper.py` | Reads IFC entity class from `_properties.Attributes.type` | | `utils/traversal.py` | Walks the Speckle collection tree (Root > Collection* > DataObject) |
| `utils/geometry.py` | Converts Speckle meshes to IfcPolygonalFaceSet geometry | | `utils/mapper.py` | Reads IFC entity class from `properties.Attributes.type` |
| `utils/geometry.py` | Converts Speckle Mesh/Brep/BrepX geometry to IfcPolygonalFaceSet |
| `utils/instances.py` | Handles InstanceProxy objects with shared geometry (IfcMappedItem) | | `utils/instances.py` | Handles InstanceProxy objects with shared geometry (IfcMappedItem) |
| `utils/properties.py` | Clones all properties, quantities, and attributes into IFC entities | | `utils/properties.py` | Clones all properties, quantities, and attributes into IFC entities |
| `utils/type_manager.py` | Creates and caches IfcTypeObjects from Element Type Attributes | | `utils/type_manager.py` | Creates and caches IfcTypeObjects, supports both explicit and derived type classes |
| `utils/materials.py` | Maps Speckle render materials to IfcSurfaceStyle colours | | `utils/materials.py` | Maps Speckle render materials to IfcSurfaceStyle colours |
| `utils/writer.py` | Creates the IFC file scaffold and manages storey creation | | `utils/writer.py` | Creates the IFC file scaffold and manages storey creation |
| `utils/receiver.py` | Standalone Speckle model receiver utility | | `utils/receiver.py` | Standalone Speckle model receiver utility |
## Classification ## Classification
IFC entity classification is read directly from `_properties.Attributes.type` on each object. For example, an object with `Attributes.type = "IfcWall"` becomes an `IfcWall` element. If the field is missing, the object falls back to `IfcBuildingElementProxy`. IFC entity classification is read from `properties.Attributes.type` on each object. For example, `Attributes.type = "IfcWall"` produces an `IfcWall` element. Falls back to `IfcBuildingElementProxy` if missing.
For instance proxy objects without their own type, the exporter looks up the definition object's `Attributes.type`. For instance proxy objects without their own type, the exporter looks up the definition object's `Attributes.type`.
@@ -89,19 +110,24 @@ All properties are cloned generically — no source-application-specific logic:
| Source | IFC Target | | Source | IFC Target |
|--------|------------| |--------|------------|
| `_properties.Attributes` | Element attributes: GlobalId, Name, Tag, ObjectType, Description, PredefinedType | | `properties.Attributes` | Element attributes: GlobalId, Name, Tag, ObjectType, Description, PredefinedType |
| `_properties.Property Sets.*` | IfcPropertySet per sub-dict (e.g. `Pset_WallCommon` IfcPropertySet with IfcPropertySingleValue entries) | | `properties.Property Sets.*` | IfcPropertySet per sub-dict (e.g. `Pset_WallCommon` → IfcPropertySingleValue entries) |
| `_properties.Quantities.*` | IfcElementQuantity per sub-dict, with automatic unit detection (mm → IfcQuantityLength, m² → IfcQuantityArea, m³ → IfcQuantityVolume) | | `properties.Quantities.*` | IfcElementQuantity per sub-dict, with automatic unit detection (mm → IfcQuantityLength, m² → IfcQuantityArea, m³ → IfcQuantityVolume) and name-based inference (Length, Width, Height, Area, Volume, Weight) |
| `_properties.Element Type Attributes` | Shared IfcTypeObject (e.g. IfcWallType), cached by GlobalId | | `properties.Element Type Attributes` | Shared IfcTypeObject (e.g. IfcWallType), cached by GlobalId |
| `_properties.Element Type Property Sets` | Property sets on the IfcTypeObject | | `properties.Element Type Property Sets` | Property sets on the IfcTypeObject |
Property values are auto-typed: `bool` → IfcBoolean, `int` → IfcInteger, `float` → IfcReal, `str` → IfcLabel, `list` → comma-joined IfcLabel. Property values are auto-typed: `bool` → IfcBoolean, `int` → IfcInteger, `float` → IfcReal, `str` → IfcLabel, `list` → comma-joined IfcLabel.
## Geometry Handling ## Geometry Handling
### Direct Meshes (Path B1) ### Supported Geometry Types
Objects with `displayValue` containing Mesh objects are converted directly: The exporter handles three geometry types found in `displayValue`:
- **Mesh** — converted directly (vertices + faces)
- **Brep / BrepX** — recursively resolved to their inner tessellated mesh representation via nested `displayValue`
### Conversion Steps
1. Extract vertices and faces from each mesh in `displayValue` 1. Extract vertices and faces from each mesh in `displayValue`
2. Scale vertices to millimetres based on the mesh's unit declaration 2. Scale vertices to millimetres based on the mesh's unit declaration
@@ -113,6 +139,20 @@ Objects with `displayValue` containing Mesh objects are converted directly:
Speckle `InstanceProxy` objects reference shared definition geometry via `definitionId`. 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. Speckle `InstanceProxy` objects reference shared definition geometry via `definitionId`. 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.
## Material Handling
Materials are read from `root.renderMaterialProxies` and applied as `IfcSurfaceStyle` on geometry items. Each proxy contains a `RenderMaterial` (name, diffuse colour as ARGB packed int, opacity) and a list of object references.
The material resolution uses a multi-strategy lookup since `renderMaterialProxies.objects` references different IDs depending on the source format:
| Format | What `objects` references | Resolution strategy |
|--------|--------------------------|-------------------|
| **IFC format** (speckleifc) | Mesh applicationIds directly | Direct lookup by mesh applicationId |
| **Grasshopper format** | Inner InstanceProxy applicationIds | Map via definitionId → material |
| **Direct mesh/BrepX** | Parent DataObject applicationIds | Fall back to parent object's applicationId |
IFC styles are created lazily (only when actually assigned to geometry) to avoid orphaned IfcSurfaceStyle entities.
## Function Inputs ## Function Inputs
| Input | Description | | Input | Description |
+7 -1
View File
@@ -6,7 +6,7 @@ from utils.traversal import traverse, print_tree
from utils.mapper import classify from utils.mapper import classify
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 ( from utils.instances import (
is_instance, instance_to_ifc, build_definition_map, is_instance, is_definition_source, instance_to_ifc, build_definition_map,
print_instance_stats, get_definition_object, print_instance_stats, get_definition_object,
) )
from utils.properties import ( from utils.properties import (
@@ -86,6 +86,7 @@ def automate_function(
) )
storey_manager = StoreyManager(ifc, building) storey_manager = StoreyManager(ifc, building)
material_manager = MaterialManager(ifc, base) material_manager = MaterialManager(ifc, base)
material_manager.build_definition_material_map(definition_map)
type_manager = TypeManager(ifc) type_manager = TypeManager(ifc)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -106,6 +107,10 @@ def automate_function(
skipped_spatial += 1 skipped_spatial += 1
continue continue
# Skip objects that serve as instance definition geometry sources
if is_definition_source(obj, definition_map):
continue
# Get building storey from properties # Get building storey from properties
storey_name = get_building_storey(obj) storey_name = get_building_storey(obj)
storey = storey_manager.get_or_create(storey_name) storey = storey_manager.get_or_create(storey_name)
@@ -207,6 +212,7 @@ def automate_function(
print(f" Storeys created : {storey_manager.count}") print(f" Storeys created : {storey_manager.count}")
print(f" Levels : {', '.join(storey_manager.names)}") print(f" Levels : {', '.join(storey_manager.names)}")
print_instance_stats() print_instance_stats()
material_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,
+28 -43
View File
@@ -13,16 +13,7 @@
import ifcopenshell import ifcopenshell
from specklepy.objects.base import Base from specklepy.objects.base import Base
from utils.helpers import _get, MM_SCALES as _UNIT_SCALES
# Scale factors → MILLIMETRES (IFC file is declared as mm)
_UNIT_SCALES = {
"mm": 1.0, "millimeter": 1.0, "millimeters": 1.0,
"cm": 10.0, "centimeter": 10.0, "centimeters": 10.0,
"m": 1000.0, "meter": 1000.0, "meters": 1000.0,
"ft": 304.8, "foot": 304.8, "feet": 304.8,
"in": 25.4, "inch": 25.4, "inches": 25.4,
}
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -97,30 +88,6 @@ def build_ifc_facesets(ifc, verts_scaled: list, face_groups: list) -> list:
return [] return []
# --------------------------------------------------------------------------- #
# Safe data access helpers
# --------------------------------------------------------------------------- #
def _get(obj, key, default=None):
"""
Safe access for specklepy Base objects.
Tries attribute access first, then bracket access.
"""
try:
val = getattr(obj, key, None)
if val is not None:
return val
except Exception:
pass
try:
val = obj[key]
if val is not None:
return val
except Exception:
pass
return default
def unwrap_chunks(raw) -> list: def unwrap_chunks(raw) -> list:
""" """
Flatten a Speckle data array into a plain Python list of numbers. Flatten a Speckle data array into a plain Python list of numbers.
@@ -371,14 +338,21 @@ def mesh_to_ifc(
if not meshes: if not meshes:
return None, None return None, None
# Parent object's applicationId — used as fallback for material lookup
# when inner meshes (e.g. from BrepX) don't have their own applicationId
obj_app_id = _get(obj, "applicationId")
obj_scale = _resolve_scale(obj, scale) obj_scale = _resolve_scale(obj, scale)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Pass 1: unpack vertices once per mesh, collect all scaled coords # Pass 1: unpack and scale vertices once per mesh, compute origin
# to compute world origin. Cache (verts, ms) for Pass 2. # incrementally without accumulating all vertices in memory.
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
mesh_cache = [] # [(verts_list, ms, scaled)] or None per mesh mesh_cache = [] # [(verts_list, ms, scaled)] or None per mesh
all_scaled = [] xmin = ymin = zmin = float("inf")
xmax = ymax = float("-inf")
has_verts = False
for mesh in meshes: for mesh in meshes:
raw_verts = _get(mesh, "vertices") or [] raw_verts = _get(mesh, "vertices") or []
verts = unwrap_chunks(raw_verts if isinstance(raw_verts, list) else list(raw_verts)) verts = unwrap_chunks(raw_verts if isinstance(raw_verts, list) else list(raw_verts))
@@ -386,15 +360,25 @@ def mesh_to_ifc(
mesh_cache.append(None) mesh_cache.append(None)
continue continue
ms = _resolve_scale(mesh, obj_scale) ms = _resolve_scale(mesh, obj_scale)
# Pre-scale vertices once, reuse in Pass 2
scaled = [float(v) * ms for v in verts] scaled = [float(v) * ms for v in verts]
mesh_cache.append((verts, ms, scaled)) mesh_cache.append((verts, ms, scaled))
all_scaled.extend(scaled) has_verts = True
if not all_scaled: # Update bounding box from this mesh's scaled vertices
for i in range(0, len(scaled) - 2, 3):
x, y, z = scaled[i], scaled[i + 1], scaled[i + 2]
if x < xmin: xmin = x
if x > xmax: xmax = x
if y < ymin: ymin = y
if y > ymax: ymax = y
if z < zmin: zmin = z
if not has_verts:
return None, None return None, None
ox, oy, oz = compute_origin(all_scaled) ox = (xmin + xmax) / 2.0
oy = (ymin + ymax) / 2.0
oz = zmin
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Pass 2: one faceset per mesh — reuse cached verts, only unpack faces # Pass 2: one faceset per mesh — reuse cached verts, only unpack faces
@@ -414,7 +398,7 @@ def mesh_to_ifc(
try: try:
face_groups = decode_faces(faces_raw) face_groups = decode_faces(faces_raw)
except Exception as e: except Exception as e:
print(f" ⚠️ Face decode error: {e}") print(f" Warning: Face decode error: {e}")
continue continue
# Offset pre-scaled vertices relative to origin (flat list, no tuples) # Offset pre-scaled vertices relative to origin (flat list, no tuples)
@@ -431,8 +415,9 @@ def mesh_to_ifc(
continue continue
# Apply material style to every faceset of this mesh # Apply material style to every faceset of this mesh
# Inner meshes (from BrepX) may lack applicationId — fall back to parent's
if material_manager: if material_manager:
mesh_app_id = _get(mesh, "applicationId") mesh_app_id = _get(mesh, "applicationId") or obj_app_id
if mesh_app_id: if mesh_app_id:
for fs in mesh_facesets: for fs in mesh_facesets:
material_manager.apply_to_item(fs, str(mesh_app_id)) material_manager.apply_to_item(fs, str(mesh_app_id))
+38
View File
@@ -0,0 +1,38 @@
# =============================================================================
# helpers.py
# Shared utilities used across the exporter modules.
# =============================================================================
def _get(obj, key, default=None):
"""
Safe access for specklepy Base objects, dicts, or any hybrid.
Tries attribute access first, then bracket access.
"""
if obj is None:
return default
if isinstance(obj, dict):
return obj.get(key, default)
try:
val = getattr(obj, key, None)
if val is not None:
return val
except Exception:
pass
try:
val = obj[key]
if val is not None:
return val
except Exception:
pass
return default
# Scale factors → MILLIMETRES (IFC file is declared as mm)
MM_SCALES = {
"mm": 1.0, "millimeter": 1.0, "millimeters": 1.0,
"cm": 10.0, "centimeter": 10.0, "centimeters": 10.0,
"m": 1000.0, "meter": 1000.0, "meters": 1000.0,
"ft": 304.8, "foot": 304.8, "feet": 304.8,
"in": 25.4, "inch": 25.4, "inches": 25.4,
}
+94 -35
View File
@@ -21,8 +21,10 @@
# ============================================================================= # =============================================================================
import math import math
import ifcopenshell.api
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, _get_shared from utils.helpers import _get, MM_SCALES
from utils.geometry import unwrap_chunks, decode_faces, build_ifc_facesets, _get_shared
def is_instance(obj) -> bool: def is_instance(obj) -> bool:
@@ -40,15 +42,18 @@ def build_definition_map(root: Base) -> dict:
Build a unified definition map that handles both formats. Build a unified definition map that handles both formats.
Returns dict with keys: Returns dict with keys:
"by_id" : {obj_id_lower[:32] → object} for Revit format "by_id" : {obj_id_lower[:32] → object} for Revit format
"by_app_id" : {applicationId_lower → object} for Revit format "by_app_id" : {applicationId_lower → object} for Revit format
"ifc_proxies" : {"DEFINITION:xxx" → proxy} for IFC format "ifc_proxies" : {"DEFINITION:xxx" → proxy} for IFC format
"ifc_meshes" : {meshAppId → Mesh} for IFC format "ifc_meshes" : {meshAppId → Mesh} for IFC format
"definition_sources": set of applicationId (lowercase) that are definition
geometry sources — these should be skipped during export
""" """
by_id = {} by_id = {}
by_app_id = {} by_app_id = {}
ifc_proxies = {} ifc_proxies = {}
ifc_meshes = {} ifc_meshes = {}
definition_sources = set() # applicationIds used as definition geometry (skip during export)
# --- Walk entire tree for Revit format --- # --- Walk entire tree for Revit format ---
_collect_all(root, by_id, by_app_id, depth=0) _collect_all(root, by_id, by_app_id, depth=0)
@@ -61,6 +66,11 @@ def build_definition_map(root: Base) -> dict:
if app_id: if app_id:
ifc_proxies[app_id] = proxy # original case (for IFC format) ifc_proxies[app_id] = proxy # original case (for IFC format)
ifc_proxies[app_id.lower()] = proxy # lowercase (for Revit format) ifc_proxies[app_id.lower()] = proxy # lowercase (for Revit format)
# Collect all objects referenced by this proxy as definition sources
object_ids = _get(proxy, "objects") or []
for oid in (object_ids if isinstance(object_ids, list) else [object_ids]):
if oid:
definition_sources.add(str(oid).lower())
elements = _get(root, "elements") or _get(root, "@elements") or [] elements = _get(root, "elements") or _get(root, "@elements") or []
for child in (elements if isinstance(elements, list) else []): for child in (elements if isinstance(elements, list) else []):
@@ -75,12 +85,14 @@ def build_definition_map(root: Base) -> dict:
print(f" Objects indexed by appId: {len(by_app_id)}") print(f" Objects indexed by appId: {len(by_app_id)}")
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)}")
print(f" Definition sources: {len(definition_sources)}")
return { return {
"by_id": by_id, "by_id": by_id,
"by_app_id": by_app_id, "by_app_id": by_app_id,
"ifc_proxies": ifc_proxies, "ifc_proxies": ifc_proxies,
"ifc_meshes": ifc_meshes, "ifc_meshes": ifc_meshes,
"definition_sources": definition_sources,
} }
@@ -117,11 +129,14 @@ def _collect_all(obj, by_id: dict, by_app_id: dict, depth: int):
continue continue
def _get_revit_meshes(definition_id: str, definition_map: dict) -> list: def _get_revit_meshes(definition_id: str, definition_map: dict) -> tuple:
""" """
Revit format: Revit format:
definitionId (64-char hex) → InstanceDefinitionProxy.applicationId definitionId (64-char hex) → InstanceDefinitionProxy.applicationId
proxy.objects[0] is a UUID applicationId → find mesh by applicationId proxy.objects[0] is a UUID applicationId → find mesh by applicationId
Returns (meshes, app_ids) where app_ids are all applicationIds encountered
in the resolution chain (definition objects, geometry objects) for material fallback.
""" """
from utils.geometry import get_display_meshes from utils.geometry import get_display_meshes
@@ -129,19 +144,34 @@ def _get_revit_meshes(definition_id: str, definition_map: dict) -> list:
ifc_proxies = definition_map.get("ifc_proxies", {}) ifc_proxies = definition_map.get("ifc_proxies", {})
proxy = ifc_proxies.get(definition_id) or ifc_proxies.get(definition_id.lower()) proxy = ifc_proxies.get(definition_id) or ifc_proxies.get(definition_id.lower())
if proxy is None: if proxy is None:
return [] return [], []
# Step 2: get the mesh applicationIds from proxy.objects # Step 2: get the mesh applicationIds from proxy.objects
object_ids = _get(proxy, "objects") or [] object_ids = _get(proxy, "objects") or []
if not isinstance(object_ids, list): if not isinstance(object_ids, list):
object_ids = list(object_ids) object_ids = list(object_ids)
# Step 3: look up each mesh by applicationId # Step 3: look up each mesh by applicationId, collecting all encountered app IDs
by_app_id = definition_map.get("by_app_id", {}) by_app_id = definition_map.get("by_app_id", {})
meshes = [] meshes = []
encountered_app_ids = []
for oid in object_ids: for oid in object_ids:
obj = by_app_id.get(str(oid).lower()) obj = by_app_id.get(str(oid).lower())
if obj is not None: if obj is not None:
# Collect this object's applicationId
obj_aid = _get(obj, "applicationId")
if obj_aid:
encountered_app_ids.append(str(obj_aid))
# Also collect applicationIds from displayValue items (BrepX, etc.)
for key in ["displayValue", "@displayValue", "_displayValue"]:
display = _get(obj, key)
if display:
items = display if isinstance(display, list) else [display]
for item in items:
item_aid = _get(item, "applicationId")
if item_aid:
encountered_app_ids.append(str(item_aid))
break
# The found object may itself be a mesh, or contain displayValue meshes # The found object may itself be a mesh, or contain displayValue meshes
found_meshes = get_display_meshes(obj) found_meshes = get_display_meshes(obj)
if found_meshes: if found_meshes:
@@ -149,20 +179,21 @@ def _get_revit_meshes(definition_id: str, definition_map: dict) -> list:
else: else:
# It IS the mesh directly # It IS the mesh directly
meshes.append(obj) meshes.append(obj)
return meshes return meshes, encountered_app_ids
def _get_ifc_meshes(definition_id: str, definition_map: dict) -> list: def _get_ifc_meshes(definition_id: str, definition_map: dict) -> tuple:
""" """
IFC format: definitionId = "DEFINITION:224058_mat0" IFC format: definitionId = "DEFINITION:224058_mat0"
Look up proxy → objects list → meshes from ifc_meshes dict. Look up proxy → objects list → meshes from ifc_meshes dict.
Returns (meshes, []) — no extra app_ids needed, mesh applicationIds match directly.
""" """
ifc_proxies = definition_map.get("ifc_proxies", {}) ifc_proxies = definition_map.get("ifc_proxies", {})
ifc_meshes = definition_map.get("ifc_meshes", {}) ifc_meshes = definition_map.get("ifc_meshes", {})
proxy = ifc_proxies.get(definition_id) proxy = ifc_proxies.get(definition_id)
if proxy is None: if proxy is None:
return [] return [], []
object_ids = _get(proxy, "objects") or [] object_ids = _get(proxy, "objects") or []
result = [] result = []
@@ -170,7 +201,7 @@ def _get_ifc_meshes(definition_id: str, definition_map: dict) -> list:
mesh = ifc_meshes.get(str(oid)) mesh = ifc_meshes.get(str(oid))
if mesh is not None: if mesh is not None:
result.append(mesh) result.append(mesh)
return result return result, []
def _resolve_instance_scale(obj, stream_scale: float) -> float: def _resolve_instance_scale(obj, stream_scale: float) -> float:
@@ -183,7 +214,7 @@ def _resolve_instance_scale(obj, stream_scale: float) -> float:
try: try:
units = obj[key] units = obj[key]
if units and isinstance(units, str): if units and isinstance(units, str):
s = _UNIT_SCALES.get(units.lower().strip()) s = MM_SCALES.get(units.lower().strip())
if s is not None: if s is not None:
return s return s
except Exception: except Exception:
@@ -206,20 +237,13 @@ _rep_map_cache: dict = {}
_identity_placement_cache: dict[int, object] = {} _identity_placement_cache: dict[int, object] = {}
_MM_SCALES = {
"mm": 1.0, "millimeter": 1.0, "millimeters": 1.0,
"cm": 10.0, "centimeter": 10.0,
"m": 1000.0, "meter": 1000.0, "meters": 1000.0,
"ft": 304.8, "in": 25.4,
}
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# IfcRepresentationMap builder — geometry created once per definition # IfcRepresentationMap builder — geometry created once per definition
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def _build_rep_map(ifc, body_context, meshes: list, ifc_format: bool, def _build_rep_map(ifc, body_context, meshes: list, ifc_format: bool,
material_manager=None): material_manager=None, fallback_app_ids: list = None,
definition_id: str = None):
""" """
Build an IfcRepresentationMap from definition meshes. Build an IfcRepresentationMap from definition meshes.
Geometry is in local coordinates (mm, no instance transform applied). Geometry is in local coordinates (mm, no instance transform applied).
@@ -234,18 +258,18 @@ def _build_rep_map(ifc, body_context, meshes: list, ifc_format: bool,
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 []
verts = unwrap_chunks(list(raw_verts)) verts = unwrap_chunks(raw_verts if isinstance(raw_verts, list) else list(raw_verts))
faces_raw = unwrap_chunks(list(raw_faces)) faces_raw = unwrap_chunks(raw_faces if isinstance(raw_faces, list) else list(raw_faces))
if not verts or not faces_raw: if not verts or not faces_raw:
continue continue
mesh_units = _get(mesh, "units") or _get(mesh, "_units") or ("m" if ifc_format else "mm") mesh_units = _get(mesh, "units") or _get(mesh, "_units") or ("m" if ifc_format else "mm")
ms = _MM_SCALES.get(mesh_units.lower().strip(), 1.0) ms = MM_SCALES.get(mesh_units.lower().strip(), 1.0)
try: try:
face_groups = decode_faces(faces_raw) face_groups = decode_faces(faces_raw)
except Exception as e: except Exception as e:
print(f" ⚠️ Instance face decode: {e}") print(f" Warning: Instance face decode: {e}")
continue continue
# Scale vertices once and cache the result # Scale vertices once and cache the result
@@ -260,11 +284,29 @@ def _build_rep_map(ifc, body_context, meshes: list, ifc_format: bool,
continue continue
# Apply material style to each faceset # 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 = None
if mesh_app_id: if mesh_app_id:
style = material_manager.get_style(str(mesh_app_id))
if not style and fallback_app_ids:
for fid in fallback_app_ids:
style = material_manager.get_style(fid)
if style:
break
if not style and definition_id:
style = material_manager.get_style_by_definition(definition_id)
if style:
for fs in mesh_facesets: for fs in mesh_facesets:
material_manager.apply_to_item(fs, str(mesh_app_id)) try:
ifcopenshell.api.run(
"style.assign_item_style", ifc,
item=fs, style=style,
)
material_manager._apply_count += 1
except Exception:
pass
geom_items.extend(mesh_facesets) geom_items.extend(mesh_facesets)
@@ -387,9 +429,9 @@ def instance_to_ifc(ifc, body_context, obj: Base, definition_map: dict,
# --- 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:
if ifc_format: if ifc_format:
meshes = _get_ifc_meshes(definition_id, definition_map) meshes, extra_app_ids = _get_ifc_meshes(definition_id, definition_map)
else: else:
meshes = _get_revit_meshes(definition_id, definition_map) meshes, extra_app_ids = _get_revit_meshes(definition_id, definition_map)
if not meshes: if not meshes:
_stats["not_found"] += 1 _stats["not_found"] += 1
@@ -397,8 +439,17 @@ def instance_to_ifc(ifc, body_context, obj: Base, definition_map: dict,
return None, placement return None, placement
_stats["found"] += 1 _stats["found"] += 1
# Build fallback app_id list: instance's own + definition chain IDs
instance_app_id = _get(obj, "applicationId")
fallback_ids = []
if instance_app_id:
fallback_ids.append(str(instance_app_id))
fallback_ids.extend(extra_app_ids)
_rep_map_cache[definition_id] = _build_rep_map( _rep_map_cache[definition_id] = _build_rep_map(
ifc, body_context, meshes, ifc_format, material_manager ifc, body_context, meshes, ifc_format, material_manager,
fallback_app_ids=fallback_ids,
definition_id=definition_id,
) )
else: else:
# Track stats even for cached definitions # Track stats even for cached definitions
@@ -454,11 +505,19 @@ def get_definition_object(obj: Base, definition_map: dict):
return source return source
def is_definition_source(obj, definition_map: dict) -> bool:
"""Return True if this object is a definition geometry source (should not be exported standalone)."""
app_id = _get(obj, "applicationId")
if not app_id:
return False
return str(app_id).lower() in definition_map.get("definition_sources", set())
def print_instance_stats(): def print_instance_stats():
total = _stats["found"] + _stats["not_found"] total = _stats["found"] + _stats["not_found"]
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" ⚠️ {_stats['not_found']} instances had no definition geometry") print(f" Warning: {_stats['not_found']} instances had no definition geometry")
def reset_caches(): def reset_caches():
+41 -17
View File
@@ -25,6 +25,7 @@
import ifcopenshell import ifcopenshell
import ifcopenshell.api import ifcopenshell.api
from specklepy.objects.base import Base from specklepy.objects.base import Base
from utils.helpers import _get
def _argb_to_rgb(argb_int: int) -> tuple[float, float, float]: def _argb_to_rgb(argb_int: int) -> tuple[float, float, float]:
@@ -36,22 +37,6 @@ def _argb_to_rgb(argb_int: int) -> tuple[float, float, float]:
return r, g, b return r, g, b
def _get(obj, key, default=None):
try:
val = getattr(obj, key, None)
if val is not None:
return val
except Exception:
pass
try:
val = obj[key]
if val is not None:
return val
except Exception:
pass
return default
class MaterialManager: class MaterialManager:
""" """
Builds a lookup from mesh applicationId → IfcSurfaceStyle, Builds a lookup from mesh applicationId → IfcSurfaceStyle,
@@ -64,6 +49,8 @@ class MaterialManager:
self._style_map: dict[str, object] = {} self._style_map: dict[str, object] = {}
# name → IfcSurfaceStyle (cache to avoid duplicates) # name → IfcSurfaceStyle (cache to avoid duplicates)
self._style_cache: dict[str, object] = {} self._style_cache: dict[str, object] = {}
self._apply_count = 0
self._miss_count = 0
self._build(root) self._build(root)
def _build(self, root: Base): def _build(self, root: Base):
@@ -139,6 +126,7 @@ class MaterialManager:
"""Assign the material style to a single IFC geometry item (e.g. IfcPolygonalFaceSet).""" """Assign the material style to a single IFC geometry item (e.g. IfcPolygonalFaceSet)."""
style = self.get_style(mesh_app_id) style = self.get_style(mesh_app_id)
if style is None: if style is None:
self._miss_count += 1
return return
try: try:
ifcopenshell.api.run( ifcopenshell.api.run(
@@ -147,5 +135,41 @@ class MaterialManager:
item=item, item=item,
style=style, style=style,
) )
self._apply_count += 1
except Exception as e: except Exception as e:
pass # Non-fatal — geometry still exports without colour pass # Non-fatal — geometry still exports without colour
def build_definition_material_map(self, definition_map: dict):
"""
Build a mapping from definitionId → material data by resolving which
InstanceProxy objects the material proxy references and finding their definitionId.
This handles the case where renderMaterialProxies.objects references inner
InstanceProxy applicationIds rather than the top-level element applicationIds.
"""
by_app_id = definition_map.get("by_app_id", {})
self._definition_material: dict[str, tuple] = {} # definitionId → (name, diffuse, transparency)
for app_id_key, mat_data in self._material_data.items():
obj = by_app_id.get(app_id_key)
if obj is None:
continue
def_id = _get(obj, "definitionId")
if def_id and isinstance(def_id, str):
self._definition_material[def_id.lower()] = mat_data
if self._definition_material:
print(f" Material definitionId mappings: {len(self._definition_material)}")
def get_style_by_definition(self, definition_id: str):
"""Return IfcSurfaceStyle for a definitionId (created on demand), or None."""
if not hasattr(self, '_definition_material'):
return None
key = str(definition_id).lower()
data = self._definition_material.get(key)
if data is None:
return None
name, diffuse, transparency = data
return self._get_or_create_style(name, diffuse, transparency)
def print_stats(self):
print(f" Materials applied: {self._apply_count}, missed: {self._miss_count}")
+28 -23
View File
@@ -14,33 +14,13 @@
import ifcopenshell import ifcopenshell
import ifcopenshell.api import ifcopenshell.api
from specklepy.objects.base import Base from specklepy.objects.base import Base
from utils.helpers import _get
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Safe access helpers # Safe access helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _get(obj, key, default=None):
"""Safe access for both dicts and Speckle Base objects."""
if obj is None:
return default
if isinstance(obj, dict):
return obj.get(key, default)
try:
val = getattr(obj, key, None)
if val is not None:
return val
except Exception:
pass
try:
val = obj[key]
if val is not None:
return val
except Exception:
pass
return default
def _to_dict(obj) -> dict: def _to_dict(obj) -> dict:
"""Convert a Speckle Base object or dict to a plain dict.""" """Convert a Speckle Base object or dict to a plain dict."""
if obj is None: if obj is None:
@@ -150,6 +130,20 @@ _UNIT_QTY_MAP = {
"degree": ("IfcQuantityCount", "CountValue"), "degree": ("IfcQuantityCount", "CountValue"),
} }
# Name keyword → IFC quantity type (used when no units are provided)
_NAME_QTY_MAP = {
"length": ("IfcQuantityLength", "LengthValue"),
"width": ("IfcQuantityLength", "LengthValue"),
"height": ("IfcQuantityLength", "LengthValue"),
"depth": ("IfcQuantityLength", "LengthValue"),
"perimeter": ("IfcQuantityLength", "LengthValue"),
"area": ("IfcQuantityArea", "AreaValue"),
"volume": ("IfcQuantityVolume", "VolumeValue"),
"volumn": ("IfcQuantityVolume", "VolumeValue"), # common typo
"weight": ("IfcQuantityWeight", "WeightValue"),
"mass": ("IfcQuantityWeight", "WeightValue"),
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Set IFC element attributes from _properties.Attributes # Set IFC element attributes from _properties.Attributes
@@ -240,7 +234,10 @@ def _try_float(value):
def write_quantity_sets(ifc, element, obj): def write_quantity_sets(ifc, element, obj):
"""Write all quantity sets from _properties.Quantities.""" """Write all quantity sets from _properties.Quantities."""
props = get_properties(obj) props = get_properties(obj)
quantities_section = _to_dict(props.get("Quantities")) quantities_raw = props.get("Quantities")
if quantities_raw is None:
return
quantities_section = _to_dict(quantities_raw)
if not quantities_section: if not quantities_section:
return return
@@ -273,14 +270,22 @@ def write_quantity_sets(ifc, element, obj):
try: try:
mapping = _UNIT_QTY_MAP.get(units) mapping = _UNIT_QTY_MAP.get(units)
if not mapping:
# Infer quantity type from name keywords
name_lower = name.lower()
for keyword, m in _NAME_QTY_MAP.items():
if keyword in name_lower:
mapping = m
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( qty = ifc.create_entity(
"IfcQuantityCount", Name=name, CountValue=value "IfcQuantityCount", Name=name, CountValue=int(value)
) )
quantities.append(qty) quantities.append(qty)
except Exception as e: except Exception as e:
+1 -1
View File
@@ -113,7 +113,7 @@ class StoreyManager:
products=[storey], products=[storey],
) )
self._storeys[level_name] = storey self._storeys[level_name] = storey
print(f" 🏢 Created storey: {level_name}") print(f" Created storey: {level_name}")
return self._storeys[level_name] return self._storeys[level_name]