update instancing

This commit is contained in:
NLSA
2026-03-25 11:05:18 +01:00
parent 4394f4c104
commit 4270864bf1
8 changed files with 1262 additions and 93 deletions
+10 -6
View File
@@ -96,12 +96,12 @@ Speckle Model
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `main.py` | Entry point, orchestrates the full pipeline | | `main.py` | Entry point, orchestrates the full pipeline |
| `utils/helpers.py` | Shared utilities: safe attribute access (`_get`) and unit scale constants | | `utils/helpers.py` | Shared utilities: safe attribute access (`_get`), unit scale constants, and `resolve_scale` |
| `utils/traversal.py` | Walks the Speckle collection tree (Root > Collection* > DataObject) | | `utils/traversal.py` | Walks the Speckle collection tree (Root > Collection* > DataObject) |
| `utils/mapper.py` | Reads IFC entity class from `properties.Attributes.type` | | `utils/mapper.py` | Reads IFC entity class from `properties.Attributes.type` |
| `utils/geometry.py` | Converts Speckle Mesh/Brep/BrepX geometry to IfcPolygonalFaceSet | | `utils/geometry.py` | Converts Speckle Mesh/Brep/BrepX geometry to IfcPolygonalFaceSet |
| `utils/curves.py` | Converts Speckle 2D curve geometry (Polycurve, Line, Arc) to IfcIndexedPolyCurve | | `utils/curves.py` | Converts Speckle 2D curve geometry (Polycurve, Line, Arc) to IfcIndexedPolyCurve |
| `utils/instances.py` | Handles InstanceProxy objects with shared geometry (IfcMappedItem) | | `utils/instances.py` | Handles InstanceProxy objects with shared geometry (IfcMappedItem), content-based geometry dedup |
| `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, supports both explicit and derived type classes | | `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 |
@@ -150,21 +150,25 @@ Curves are typically found wrapped inside `DataObject.displayValue`, following t
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
3. Deduplicate vertices via snap grid (0.01mm tolerance) to avoid IFC GEM111 errors 3. Deduplicate vertices via snap grid (0.01mm tolerance) to avoid IFC GEM111 errors
4. Build `IfcPolygonalFaceSet` with `IfcCartesianPointList3D` + `IfcIndexedPolygonalFace` 4. Round coordinates to 0.001mm precision for compact IFC file output
5. Compute bounding box origin for `IfcLocalPlacement`, offset vertices relative to it 5. Build `IfcPolygonalFaceSet` with `IfcCartesianPointList3D` + `IfcIndexedPolygonalFace`
6. Compute bounding box origin for `IfcLocalPlacement`, offset vertices relative to it
### 2D Curve Conversion ### 2D Curve Conversion
1. Extract curve segments from the object or its `displayValue` 1. Extract curve segments from the object or its `displayValue`
2. Parse each segment type (Line → start/end, Arc → start/mid/end, Polyline → point sequence) 2. Parse each segment type (Line → start/end, Arc → start/mid/end, Polyline → point sequence)
3. Deduplicate points via snap grid (0.01mm tolerance) 3. Deduplicate points via snap grid (0.01mm tolerance)
4. Build `IfcIndexedPolyCurve` with `IfcCartesianPointList3D` + `IfcLineIndex` / `IfcArcIndex` segments 4. Round coordinates to 0.001mm precision for compact IFC file output
5. Compute bounding box origin for placement, offset points relative to it 5. Build `IfcIndexedPolyCurve` with `IfcCartesianPointList3D` + `IfcLineIndex` / `IfcArcIndex` segments
6. Compute bounding box origin for placement, offset points relative to it
### Instance Objects (Path A / B2) ### Instance Objects (Path A / B2)
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/curve data across hundreds of identical elements. Both mesh and curve definitions are supported. 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/curve data across hundreds of identical elements. Both mesh and curve definitions are supported.
**Content-based geometry deduplication**: Instance definitions with identical vertex/face data and materials are detected via MD5 content hashing and share a single `IfcRepresentationMap`, even if they have different `definitionId`s. Direction vectors for transform operators are also cached and reused across instances.
## Material Handling ## 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. 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.
+10 -3
View File
@@ -1,3 +1,4 @@
import zipfile
from datetime import datetime from datetime import datetime
import ifcopenshell.api import ifcopenshell.api
@@ -217,11 +218,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}")
+447
View File
@@ -0,0 +1,447 @@
# =============================================================================
# geometry.py
# Converts Speckle DataObject geometry → IFC IfcPolygonalFaceSet + IfcLocalPlacement
#
# Key facts:
# - After specklepy receive(), vertices and faces are FLAT Python lists
# - displayValue is an array of Mesh objects
# - Units are in mm (for Revit), scale to metres for IFC
# - Vertices are in absolute world coordinates
# - Uses IfcPolygonalFaceSet (indexed vertices) instead of IfcFacetedBrep
# for compact output — each vertex stored once, not once per face.
# =============================================================================
import ifcopenshell
from specklepy.objects.base import Base
from utils.helpers import _get, MM_SCALES as _UNIT_SCALES
# --------------------------------------------------------------------------- #
# Geometry validation helpers (GEM111 fix)
# --------------------------------------------------------------------------- #
# Minimum distance in mm below which two vertices are considered identical (GEM111).
_VERTEX_MERGE_TOL = 0.01 # 0.01 mm
_INV_TOL = 1.0 / _VERTEX_MERGE_TOL # pre-computed: multiply instead of divide
def build_ifc_facesets(ifc, verts_scaled: list, face_groups: list) -> list:
"""
Build a list of IfcPolygonalFaceSet from scaled (x,y,z) vertices and face index groups.
Uses IfcCartesianPointList3D + IfcIndexedPolygonalFace for compact output.
Vertices are deduplicated via snap grid so each unique position is stored once.
GEM111 fix: skip faces with near-duplicate vertices (snapped to same grid cell).
verts_scaled: flat list of already-scaled floats [x0,y0,z0, x1,y1,z1, ...]
face_groups: list of index lists [[i,j,k], [i,j,k,l], ...]
Returns: list of IfcPolygonalFaceSet (typically one, empty on failure).
"""
snap_to_idx = {} # snap_key → 0-based index in deduped_verts
deduped_verts = [] # [[x, y, z], ...] — lists for direct IFC use
inv_tol = _INV_TOL
# Validate faces and remap indices to deduplicated vertex list
valid_faces = [] # list of (idx0+1, idx1+1, ...) tuples (1-based for IFC)
for indices in face_groups:
try:
remapped = []
seen_snaps = set()
degenerate = False
for i in indices:
i3 = i * 3
x = verts_scaled[i3]
y = verts_scaled[i3 + 1]
z = verts_scaled[i3 + 2]
key = (round(x * inv_tol), round(y * inv_tol), round(z * inv_tol))
if key in seen_snaps:
degenerate = True
break
seen_snaps.add(key)
idx = snap_to_idx.get(key)
if idx is None:
idx = len(deduped_verts)
snap_to_idx[key] = idx
deduped_verts.append([x, y, z])
remapped.append(idx + 1) # 1-based for IFC
if degenerate or len(remapped) < 3:
continue
valid_faces.append(remapped)
except Exception:
continue
if not valid_faces or not deduped_verts:
return []
# 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
try:
point_list = ifc.createIfcCartesianPointList3D(deduped_verts)
ifc_faces = [
ifc.createIfcIndexedPolygonalFace(fi) for fi in valid_faces
]
faceset = ifc.createIfcPolygonalFaceSet(point_list, None, ifc_faces, None)
return [faceset]
except Exception:
return []
def unwrap_chunks(raw) -> list:
"""
Flatten a Speckle data array into a plain Python list of numbers.
Handles two cases:
1. Already flat list of numbers (after specklepy receive deserializes)
→ returned as-is (fast path)
2. List of DataChunk objects (raw from server before deserialization)
→ each chunk's .data list is concatenated
"""
if not raw:
return []
# Fast path: if first item is a number, assume all items are numbers
first = raw[0]
if isinstance(first, (int, float)):
return raw
# Slow path: DataChunk objects or mixed content
result = []
for item in raw:
if item is None:
continue
if isinstance(item, (int, float)):
result.append(item)
continue
speckle_type = getattr(item, "speckle_type", "") or ""
if "DataChunk" in speckle_type:
chunk_data = _get(item, "data") or _get(item, "@data")
if chunk_data:
result.extend(list(chunk_data))
else:
try:
result.extend(list(item))
except Exception:
pass
return result
def _resolve_scale(obj, stream_scale: float) -> float:
"""Resolve unit scale: obj.units → stream fallback."""
units = _get(obj, "units")
if units and isinstance(units, str):
return _UNIT_SCALES.get(units.lower().strip(), stream_scale)
return stream_scale
# --------------------------------------------------------------------------- #
# Mesh extraction
# --------------------------------------------------------------------------- #
def _is_mesh(item) -> bool:
"""
Detect if a specklepy object is a Mesh.
Uses speckle_type string — more reliable than hasattr on Base objects.
"""
if item is None:
return False
speckle_type = _get(item, "speckle_type") or ""
if "Mesh" in speckle_type:
return True
# Fallback: has both vertices and faces data
verts = _get(item, "vertices")
faces = _get(item, "faces")
return verts is not None and faces is not None
def _collect_meshes_from_display(obj) -> list:
"""
Collect Mesh objects from an object's displayValue.
If an item is not a Mesh (e.g. BrepX, Brep), recursively check
its own displayValue for nested meshes.
"""
meshes = []
for key in ["displayValue", "@displayValue", "_displayValue"]:
display = _get(obj, key)
if display is None:
continue
items = display if isinstance(display, list) else [display]
for item in items:
if item is None:
continue
if _is_mesh(item):
meshes.append(item)
else:
# BrepX / Brep / other geometry types may carry a nested
# displayValue with the tessellated mesh representation
meshes.extend(_collect_meshes_from_display(item))
if meshes:
break
return meshes
def get_display_meshes(obj: Base) -> list:
"""
Extract all Mesh objects from a DataObject's displayValue.
Handles nested geometry types (BrepX, Brep) that wrap meshes
inside their own displayValue.
"""
meshes = _collect_meshes_from_display(obj)
# Fallback: object itself is a Mesh
if not meshes and _is_mesh(obj):
speckle_type = _get(obj, "speckle_type") or ""
if "Mesh" in speckle_type:
meshes.append(obj)
return meshes
def get_display_instances(obj: Base) -> list:
"""
Extract InstanceProxy objects from a DataObject's displayValue.
Per the official speckleifc converter, every IFC element's displayValue
contains InstanceProxy objects (not raw meshes). Each InstanceProxy has:
- transform: 16-float row-major matrix, translation in metres
- definitionId: "DEFINITION:{meshAppId}" string
- units: "m"
Raw meshes do NOT appear in displayValue in IFC→Speckle exports.
"""
instances = []
for key in ["displayValue", "@displayValue", "_displayValue"]:
display = _get(obj, key)
if display is None:
continue
items = display if isinstance(display, list) else [display]
for item in items:
if item is None:
continue
transform = _get(item, "transform")
definition_id = _get(item, "definitionId")
if transform is not None and definition_id is not None:
instances.append(item)
if instances:
break
return instances
# --------------------------------------------------------------------------- #
# Face decoding
# --------------------------------------------------------------------------- #
def decode_faces(faces_raw: list) -> list:
"""
Decode Speckle's run-length encoded face list into vertex index groups.
Format: [n, i0, i1, ..., n, i0, i1, ...]
n=0 → triangle (legacy), n=1 → quad (legacy), n≥3 → n-gon
"""
decoded = []
i = 0
total = len(faces_raw)
# Check if values are already ints (common after unwrap_chunks)
already_int = total > 0 and isinstance(faces_raw[0], int)
while i < total:
n = faces_raw[i] if already_int else int(faces_raw[i])
if n == 0:
n = 3
elif n == 1:
n = 4
end = i + 1 + n
if end > total:
break
if already_int:
decoded.append(faces_raw[i + 1:end])
else:
decoded.append([int(v) for v in faces_raw[i + 1:end]])
i = end
return decoded
# --------------------------------------------------------------------------- #
# Bounding box + placement
# --------------------------------------------------------------------------- #
def compute_origin(flat_verts: list) -> tuple:
"""
Compute placement origin from scaled vertex list (mm).
X, Y = bounding box centroid
Z = minimum Z (bottom face of element — more natural for IFC)
Single-pass to avoid creating 3 sliced copies of a large list.
"""
x0 = flat_verts[0]
y0 = flat_verts[1]
z0 = flat_verts[2]
xmin = xmax = x0
ymin = ymax = y0
zmin = z0
for i in range(3, len(flat_verts) - 2, 3):
x = flat_verts[i]
y = flat_verts[i + 1]
z = flat_verts[i + 2]
if x < xmin:
xmin = x
elif x > xmax:
xmax = x
if y < ymin:
ymin = y
elif y > ymax:
ymax = y
if z < zmin:
zmin = z
return (xmin + xmax) / 2.0, (ymin + ymax) / 2.0, zmin
# Cache for shared IFC direction/point entities (keyed by ifc file id)
_shared_entities: dict[int, dict] = {}
def _get_shared(ifc):
"""Return (or create) shared IfcDirection and IfcCartesianPoint entities for this file."""
fid = id(ifc)
if fid not in _shared_entities:
_shared_entities[fid] = {
"z_axis": ifc.createIfcDirection([0.0, 0.0, 1.0]),
"x_axis": ifc.createIfcDirection([1.0, 0.0, 0.0]),
"origin_0": ifc.createIfcCartesianPoint([0.0, 0.0, 0.0]),
}
return _shared_entities[fid]
def _make_placement(ifc, x: float, y: float, z: float):
"""Create an IfcLocalPlacement at absolute world coordinates (mm)."""
shared = _get_shared(ifc)
origin = ifc.createIfcCartesianPoint([round(x, 3), round(y, 3), round(z, 3)])
a2p = ifc.createIfcAxis2Placement3D(origin, shared["z_axis"], shared["x_axis"])
return ifc.createIfcLocalPlacement(PlacementRelTo=None, RelativePlacement=a2p)
# --------------------------------------------------------------------------- #
# Main conversion
# --------------------------------------------------------------------------- #
def mesh_to_ifc(
ifc: ifcopenshell.file,
body_context,
obj: Base,
scale: float = 0.001,
material_manager=None,
) -> tuple:
"""
Convert a Speckle DataObject → (IfcShapeRepresentation, IfcLocalPlacement).
Creates one IfcPolygonalFaceSet per mesh so each can carry its own material style.
Returns (None, None) if no usable geometry is found.
"""
meshes = get_display_meshes(obj)
if not meshes:
return None, None
# 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)
# ------------------------------------------------------------------ #
# Pass 1: unpack and scale vertices once per mesh, compute origin
# incrementally without accumulating all vertices in memory.
# ------------------------------------------------------------------ #
mesh_cache = [] # [scaled_verts_list] or None per mesh
xmin = ymin = zmin = float("inf")
xmax = ymax = float("-inf")
has_verts = False
for mesh in meshes:
raw_verts = _get(mesh, "vertices") or []
verts = unwrap_chunks(raw_verts if isinstance(raw_verts, list) else list(raw_verts))
if not verts:
mesh_cache.append(None)
continue
ms = _resolve_scale(mesh, obj_scale)
scaled = [float(v) * ms for v in verts]
mesh_cache.append(scaled)
has_verts = True
# 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
ox = (xmin + xmax) / 2.0
oy = (ymin + ymax) / 2.0
oz = zmin
# ------------------------------------------------------------------ #
# Pass 2: one faceset per mesh — reuse cached verts, only unpack faces
# ------------------------------------------------------------------ #
geom_items = []
for mesh, scaled in zip(meshes, mesh_cache):
if scaled is None:
continue
raw_faces = _get(mesh, "faces") or []
faces_raw = unwrap_chunks(raw_faces if isinstance(raw_faces, list) else list(raw_faces))
if not faces_raw:
continue
try:
face_groups = decode_faces(faces_raw)
except Exception as e:
print(f" Warning: Face decode error: {e}")
continue
# Offset pre-scaled vertices relative to origin (flat list, no tuples)
n = len(scaled)
verts_scaled = [0.0] * n
for vi in range(0, n, 3):
verts_scaled[vi] = scaled[vi] - ox
verts_scaled[vi + 1] = scaled[vi + 1] - oy
verts_scaled[vi + 2] = scaled[vi + 2] - oz
mesh_facesets = build_ifc_facesets(ifc, verts_scaled, face_groups)
if not mesh_facesets:
continue
# Apply material style to every faceset of this mesh
# Inner meshes (from BrepX) may lack applicationId — fall back to parent's
if material_manager:
mesh_app_id = _get(mesh, "applicationId") or obj_app_id
if mesh_app_id:
for fs in mesh_facesets:
material_manager.apply_to_item(fs, str(mesh_app_id))
geom_items.extend(mesh_facesets)
if not geom_items:
return None, None
# ------------------------------------------------------------------ #
# Assemble IfcShapeRepresentation + IfcLocalPlacement
# ------------------------------------------------------------------ #
rep = ifc.createIfcShapeRepresentation(
ContextOfItems=body_context,
RepresentationIdentifier="Body",
RepresentationType="Tessellation",
Items=geom_items,
)
placement = _make_placement(ifc, ox, oy, oz)
return rep, placement
+640
View File
@@ -0,0 +1,640 @@
# =============================================================================
# instances.py
# Handles Speckle InstanceProxy objects from both:
#
# FORMAT A — Revit connector (our actual use case):
# _units = "mm"
# transform = 16 floats, row-major, translation in MM
# definitionId = 64-char uppercase hex hash (matches object id[:32] in tree)
# The definition object lives somewhere in the object tree.
#
# FORMAT B — speckleifc IFC→Speckle converter:
# units = "m"
# transform = 16 floats, row-major, translation in METRES
# definitionId = "DEFINITION:{meshAppId}"
# Definition geometry lives in root → Collection("definitionGeometry")
#
# We detect the format by the definitionId prefix.
#
# Performance: uses IfcRepresentationMap + IfcMappedItem so that all instances
# sharing the same definition reference a single copy of the geometry.
# =============================================================================
import hashlib
import math
import struct
import ifcopenshell.api
from specklepy.objects.base import Base
from utils.helpers import _get, MM_SCALES
from utils.geometry import unwrap_chunks, decode_faces, build_ifc_facesets, _get_shared, _is_mesh
from utils.curves import is_curve, build_curve_rep_map
def is_instance(obj) -> bool:
"""Returns True if this object is a Speckle InstanceProxy."""
return _get(obj, "transform") is not None and _get(obj, "definitionId") is not None
def _is_ifc_format(definition_id: str) -> bool:
"""True if this is speckleifc format (definitionId starts with 'DEFINITION:')."""
return definition_id.startswith("DEFINITION:")
def build_definition_map(root: Base) -> dict:
"""
Build a unified definition map that handles both formats.
Returns dict with keys:
"by_id" : {obj_id_lower[:32] → object} for Revit format
"by_app_id" : {applicationId_lower → object} for Revit format
"ifc_proxies" : {"DEFINITION:xxx" → proxy} for IFC format
"ifc_meshes" : {meshAppId → Mesh} for IFC format
"definition_sources": set of applicationId (lowercase) that are definition
geometry sources — these should be skipped during export
"""
by_id = {}
by_app_id = {}
ifc_proxies = {}
ifc_meshes = {}
definition_sources = set() # applicationIds used as definition geometry (skip during export)
# --- Walk entire tree for Revit format ---
_collect_all(root, by_id, by_app_id, depth=0)
# --- Extract speckleifc structures for IFC format ---
proxies_raw = _get(root, "instanceDefinitionProxies")
if proxies_raw:
for proxy in (proxies_raw if isinstance(proxies_raw, list) else [proxies_raw]):
app_id = _get(proxy, "applicationId")
if app_id:
ifc_proxies[app_id] = proxy # original case (for IFC format)
ifc_proxies[app_id.lower()] = proxy # lowercase (for Revit format)
# 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 []
for child in (elements if isinstance(elements, list) else []):
if (_get(child, "name") or "") == "definitionGeometry":
geom_elements = _get(child, "elements") or _get(child, "@elements") or []
for mesh in (geom_elements if isinstance(geom_elements, list) else []):
mesh_app_id = _get(mesh, "applicationId")
if mesh_app_id:
ifc_meshes[mesh_app_id] = mesh
print(f" Objects indexed by id: {len(by_id)}")
print(f" Objects indexed by appId: {len(by_app_id)}")
print(f" IFC definition proxies: {len(ifc_proxies)}")
print(f" IFC definition meshes: {len(ifc_meshes)}")
print(f" Definition sources: {len(definition_sources)}")
return {
"by_id": by_id,
"by_app_id": by_app_id,
"ifc_proxies": ifc_proxies,
"ifc_meshes": ifc_meshes,
"definition_sources": definition_sources,
}
def _collect_all(obj, by_id: dict, by_app_id: dict, depth: int):
if obj is None or depth > 25:
return
obj_id = _get(obj, "id")
if obj_id and isinstance(obj_id, str):
key = obj_id.lower()
by_id[key] = obj
# Also store truncated — definitionId (64 chars) matches id (32 chars)
if len(key) == 32:
by_id[key] = obj
elif len(key) > 32:
by_id[key[:32]] = obj
app_id = _get(obj, "applicationId")
if app_id and isinstance(app_id, str):
by_app_id[app_id.lower()] = obj
for key in ["elements", "@elements", "_elements",
"displayValue", "@displayValue", "_displayValue",
"objects", "@objects", "definition", "@definition"]:
try:
children = obj[key]
if children is None:
continue
if not isinstance(children, list):
children = [children]
for child in children:
_collect_all(child, by_id, by_app_id, depth + 1)
except Exception:
continue
def _get_definition_source_object(definition_id: str, definition_map: dict):
"""Resolve the first source object referenced by a definition proxy."""
ifc_proxies = definition_map.get("ifc_proxies", {})
proxy = ifc_proxies.get(definition_id) or ifc_proxies.get(definition_id.lower())
if proxy is None:
return None
object_ids = _get(proxy, "objects") or []
if not isinstance(object_ids, list):
object_ids = list(object_ids)
if not object_ids:
return None
by_app_id = definition_map.get("by_app_id", {})
return by_app_id.get(str(object_ids[0]).lower())
def _get_revit_meshes(definition_id: str, definition_map: dict) -> tuple:
"""
Revit format:
definitionId (64-char hex) → InstanceDefinitionProxy.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
# Step 1: find the InstanceDefinitionProxy by its applicationId (case-insensitive)
ifc_proxies = definition_map.get("ifc_proxies", {})
proxy = ifc_proxies.get(definition_id) or ifc_proxies.get(definition_id.lower())
if proxy is None:
return [], []
# Step 2: get the mesh applicationIds from proxy.objects
object_ids = _get(proxy, "objects") or []
if not isinstance(object_ids, list):
object_ids = list(object_ids)
# Step 3: look up each mesh by applicationId, collecting all encountered app IDs
by_app_id = definition_map.get("by_app_id", {})
meshes = []
encountered_app_ids = []
for oid in object_ids:
obj = by_app_id.get(str(oid).lower())
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
found_meshes = get_display_meshes(obj)
if found_meshes:
meshes.extend(found_meshes)
elif _is_mesh(obj):
# Object itself is a mesh (no displayValue wrapping)
meshes.append(obj)
return meshes, encountered_app_ids
def _get_ifc_meshes(definition_id: str, definition_map: dict) -> tuple:
"""
IFC format: definitionId = "DEFINITION:224058_mat0"
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_meshes = definition_map.get("ifc_meshes", {})
proxy = ifc_proxies.get(definition_id)
if proxy is None:
return [], []
object_ids = _get(proxy, "objects") or []
result = []
for oid in (object_ids if isinstance(object_ids, list) else [object_ids]):
mesh = ifc_meshes.get(str(oid))
if mesh is not None:
result.append(mesh)
return result, []
def _resolve_instance_scale(obj, stream_scale: float) -> float:
"""
Resolve scale for the transform translation.
Tries bracket access for '_units' (Revit uses underscore).
IFC format instances have units="m" → scale=1.0 (no scaling).
"""
for key in ["units", "_units"]:
try:
units = obj[key]
if units and isinstance(units, str):
s = MM_SCALES.get(units.lower().strip())
if s is not None:
return s
except Exception:
pass
return stream_scale
# Stats
_stats = {"found": 0, "not_found": 0}
# Cache: mesh id → (verts_scaled, face_groups) to avoid re-unpacking
# AND re-scaling the same definition mesh across many instances that share it.
_mesh_data_cache: dict = {}
# Cache: definition_id → IfcRepresentationMap (or None if no geometry)
# All instances sharing the same definition reuse one geometry copy.
_rep_map_cache: dict = {}
# 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)
_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
# --------------------------------------------------------------------------- #
def _collect_mesh_data(meshes: list, ifc_format: bool) -> list:
"""Unpack, scale, and cache mesh vertex/face data.
Returns list of (mesh_obj, verts_local, face_groups) tuples.
"""
result = []
for mesh in meshes:
mesh_id = _get(mesh, "id") or _get(mesh, "applicationId")
if mesh_id and mesh_id in _mesh_data_cache:
verts_local, face_groups = _mesh_data_cache[mesh_id]
else:
raw_verts = _get(mesh, "vertices") or []
raw_faces = _get(mesh, "faces") or []
verts = unwrap_chunks(raw_verts if isinstance(raw_verts, list) else list(raw_verts))
faces_raw = unwrap_chunks(raw_faces if isinstance(raw_faces, list) else list(raw_faces))
if not verts or not faces_raw:
continue
mesh_units = _get(mesh, "units") or _get(mesh, "_units") or ("m" if ifc_format else "mm")
ms = MM_SCALES.get(mesh_units.lower().strip(), 1.0)
try:
face_groups = decode_faces(faces_raw)
except Exception as e:
print(f" Warning: Instance face decode: {e}")
continue
verts_local = [float(v) * ms for v in verts]
if mesh_id:
_mesh_data_cache[mesh_id] = (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:
continue
if material_manager:
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,
)
if style:
for fs in mesh_facesets:
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)
if not geom_items:
_geometry_hash_cache[geom_hash] = None
return None
shared = _get_shared(ifc)
a2p = ifc.createIfcAxis2Placement3D(shared["origin_0"], None, None)
mapped_rep = ifc.createIfcShapeRepresentation(
ContextOfItems=body_context,
RepresentationIdentifier="Body",
RepresentationType="Tessellation",
Items=geom_items,
)
rep_map = ifc.createIfcRepresentationMap(a2p, mapped_rep)
_geometry_hash_cache[geom_hash] = rep_map
return rep_map
# --------------------------------------------------------------------------- #
# Transform → IfcCartesianTransformationOperator3D
# --------------------------------------------------------------------------- #
def _vec_magnitude(x, y, 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):
"""
Convert a row-major 4x4 matrix + translation scale into an
IfcCartesianTransformationOperator3DnonUniform.
t: 16 floats, row-major [r00,r01,r02,tx, r10,r11,r12,ty, r20,r21,r22,tz, 0,0,0,1]
ts: scale factor for translation components (e.g. 1000.0 for m→mm)
IfcCartesianTransformationOperator axes represent the COLUMNS of M:
Axis1 = column 0 = where local X maps → (t[0], t[4], t[8])
Axis2 = column 1 = where local Y maps → (t[1], t[5], t[9])
Axis3 = column 2 = where local Z maps → (t[2], t[6], t[10])
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.
"""
# Extract COLUMNS of the 3x3 rotation/scale sub-matrix
ax1 = (float(t[0]), float(t[4]), float(t[8]))
ax2 = (float(t[1]), float(t[5]), float(t[9]))
ax3 = (float(t[2]), float(t[6]), float(t[10]))
s1 = _vec_magnitude(*ax1)
s2 = _vec_magnitude(*ax2)
s3 = _vec_magnitude(*ax3)
if s1 < 1e-10 or s2 < 1e-10 or s3 < 1e-10:
return None # degenerate transform
# Normalized direction vectors — reuse cached IfcDirection entities
d1 = _get_or_create_direction(ifc, ax1[0]/s1, ax1[1]/s1, ax1[2]/s1)
d2 = _get_or_create_direction(ifc, ax2[0]/s2, ax2[1]/s2, ax2[2]/s2)
d3 = _get_or_create_direction(ifc, ax3[0]/s3, ax3[1]/s3, ax3[2]/s3)
# Translation, scaled and rounded to mm
tx = round(float(t[3]) * ts, 3)
ty = round(float(t[7]) * ts, 3)
tz = round(float(t[11]) * ts, 3)
origin = ifc.createIfcCartesianPoint([tx, ty, tz])
# Round scales for cleaner output
s1 = round(s1, 6)
s2 = round(s2, 6)
s3 = round(s3, 6)
return ifc.createIfcCartesianTransformationOperator3DnonUniform(
d1, # Axis1
d2, # Axis2
origin, # LocalOrigin
s1, # Scale
d3, # Axis3 (explicit — never derived)
s2, # Scale2
s3, # Scale3
)
# --------------------------------------------------------------------------- #
# Main conversion — IfcMappedItem approach
# --------------------------------------------------------------------------- #
def instance_to_ifc(ifc, body_context, obj: Base, definition_map: dict,
scale: float = 1.0, material_manager=None):
"""
Convert a Speckle InstanceProxy → (IfcShapeRepresentation, IfcLocalPlacement).
Strategy: create geometry once per definition as an IfcRepresentationMap,
then reference it via IfcMappedItem + IfcCartesianTransformationOperator3D
for each instance. This avoids duplicating geometry across instances.
"""
transform_raw = _get(obj, "transform")
if not transform_raw:
return None, None
t = list(transform_raw)
if len(t) != 16:
return None, None
definition_id = _get(obj, "definitionId") or ""
ifc_format = _is_ifc_format(definition_id)
# Translation scale: IFC format transform is in metres → convert to mm
# Revit format transform is already in mm (same as IFC file units)
ts = 1000.0 if ifc_format else _resolve_instance_scale(obj, scale)
# Identity placement (transform is encoded in the MappedItem) — shared across all instances
fid = id(ifc)
if fid not in _identity_placement_cache:
shared = _get_shared(ifc)
a2p = ifc.createIfcAxis2Placement3D(shared["origin_0"], None, None)
_identity_placement_cache[fid] = ifc.createIfcLocalPlacement(PlacementRelTo=None, RelativePlacement=a2p)
placement = _identity_placement_cache[fid]
# --- Get or build IfcRepresentationMap (cached per definition_id) ---
if definition_id not in _rep_map_cache:
if ifc_format:
meshes, extra_app_ids = _get_ifc_meshes(definition_id, definition_map)
else:
meshes, extra_app_ids = _get_revit_meshes(definition_id, definition_map)
# 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_result = None
if meshes:
rep_map_result = _build_rep_map(
ifc, body_context, meshes, ifc_format, material_manager,
fallback_app_ids=fallback_ids,
definition_id=definition_id,
)
# If no mesh geometry produced, try curve geometry from the definition object
if rep_map_result is None:
curve_obj = _get_definition_source_object(definition_id, definition_map)
if curve_obj and is_curve(curve_obj):
curve_scale = _resolve_instance_scale(curve_obj, 1.0)
rep_map_result = build_curve_rep_map(
ifc, body_context, curve_obj, scale=curve_scale,
material_manager=material_manager,
fallback_app_ids=fallback_ids,
definition_id=definition_id,
)
_rep_map_cache[definition_id] = rep_map_result
if rep_map_result is not None:
_stats["found"] += 1
else:
_stats["not_found"] += 1
else:
# Track stats even for cached definitions
if _rep_map_cache[definition_id] is not None:
_stats["found"] += 1
else:
_stats["not_found"] += 1
rep_map = _rep_map_cache[definition_id]
if rep_map is None:
return None, placement
# --- Build transform operator from instance's 4x4 matrix ---
transform_op = _make_transform_operator(ifc, t, ts)
if transform_op is None:
return None, placement
# --- Create IfcMappedItem referencing the shared geometry ---
mapped_item = ifc.createIfcMappedItem(rep_map, transform_op)
rep = ifc.createIfcShapeRepresentation(
ContextOfItems=body_context,
RepresentationIdentifier="Body",
RepresentationType="MappedRepresentation",
Items=[mapped_item],
)
return rep, placement
def get_definition_object(obj: Base, definition_map: dict):
"""
Resolve the definition's source object for an InstanceProxy.
Returns the first object referenced by the definition proxy, which
carries the proper category/type info. Returns None if not found.
"""
definition_id = _get(obj, "definitionId") or ""
if not definition_id:
return None
return _get_definition_source_object(definition_id, definition_map)
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():
total = _stats["found"] + _stats["not_found"]
print(f" Instance resolution: {_stats['found']}/{total} definitions found")
if _stats["not_found"] > 0:
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():
"""Reset module-level caches (call at start of each export run)."""
_mesh_data_cache.clear()
_rep_map_cache.clear()
_geometry_hash_cache.clear()
_identity_placement_cache.clear()
_direction_cache.clear()
_stats["found"] = 0
_stats["not_found"] = 0
+4 -10
View File
@@ -15,7 +15,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, MM_SCALES from utils.helpers import _get, resolve_scale as _resolve_scale
from utils.geometry import _get_shared, _make_placement from utils.geometry import _get_shared, _make_placement
@@ -29,14 +29,6 @@ def is_curve(obj) -> bool:
return any(ct in speckle_type for ct in _CURVE_TYPES) return any(ct in speckle_type for ct in _CURVE_TYPES)
def _resolve_scale(obj, fallback: float) -> float:
"""Resolve unit scale for a curve object."""
units = _get(obj, "units")
if units and isinstance(units, str):
return MM_SCALES.get(units.lower().strip(), fallback)
return fallback
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."""
x = float(_get(pt, "x") or 0) * scale x = float(_get(pt, "x") or 0) * scale
@@ -183,7 +175,9 @@ def build_ifc_curve(ifc, points: list, segments: list):
if not points or not segments: if not points or not segments:
return None return None
point_list = ifc.createIfcCartesianPointList3D(points) # Round coordinates for smaller IFC file size (0.001mm precision)
rounded = [[round(p[0], 3), round(p[1], 3), round(p[2], 3)] for p in points]
point_list = ifc.createIfcCartesianPointList3D(rounded)
ifc_segments = [] ifc_segments = []
for seg_type, indices in segments: for seg_type, indices in segments:
+9 -40
View File
@@ -13,7 +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 from utils.helpers import _get, resolve_scale as _resolve_scale
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -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)
@@ -127,14 +134,6 @@ def unwrap_chunks(raw) -> list:
return result return result
def _resolve_scale(obj, stream_scale: float) -> float:
"""Resolve unit scale: obj.units → stream fallback."""
units = _get(obj, "units")
if units and isinstance(units, str):
return _UNIT_SCALES.get(units.lower().strip(), stream_scale)
return stream_scale
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Mesh extraction # Mesh extraction
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -264,36 +263,6 @@ def decode_faces(faces_raw: list) -> list:
# Bounding box + placement # Bounding box + placement
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def compute_origin(flat_verts: list) -> tuple:
"""
Compute placement origin from scaled vertex list (mm).
X, Y = bounding box centroid
Z = minimum Z (bottom face of element — more natural for IFC)
Single-pass to avoid creating 3 sliced copies of a large list.
"""
x0 = flat_verts[0]
y0 = flat_verts[1]
z0 = flat_verts[2]
xmin = xmax = x0
ymin = ymax = y0
zmin = z0
for i in range(3, len(flat_verts) - 2, 3):
x = flat_verts[i]
y = flat_verts[i + 1]
z = flat_verts[i + 2]
if x < xmin:
xmin = x
elif x > xmax:
xmax = x
if y < ymin:
ymin = y
elif y > ymax:
ymax = y
if z < zmin:
zmin = z
return (xmin + xmax) / 2.0, (ymin + ymax) / 2.0, zmin
# Cache for shared IFC direction/point entities (keyed by ifc file id) # Cache for shared IFC direction/point entities (keyed by ifc file id)
_shared_entities: dict[int, dict] = {} _shared_entities: dict[int, dict] = {}
@@ -313,7 +282,7 @@ 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 (metres)."""
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)
+8
View File
@@ -36,3 +36,11 @@ MM_SCALES = {
"ft": 304.8, "foot": 304.8, "feet": 304.8, "ft": 304.8, "foot": 304.8, "feet": 304.8,
"in": 25.4, "inch": 25.4, "inches": 25.4, "in": 25.4, "inch": 25.4, "inches": 25.4,
} }
def resolve_scale(obj, fallback: float) -> float:
"""Resolve unit scale: obj.units → fallback."""
units = _get(obj, "units")
if units and isinstance(units, str):
return MM_SCALES.get(units.lower().strip(), fallback)
return fallback
+134 -34
View File
@@ -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