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 |
|------|---------|
| `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/mapper.py` | Reads IFC entity class from `properties.Attributes.type` |
| `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/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/type_manager.py` | Creates and caches IfcTypeObjects, supports both explicit and derived type classes |
| `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`
2. Scale vertices to millimetres based on the mesh's unit declaration
3. Deduplicate vertices via snap grid (0.01mm tolerance) to avoid IFC GEM111 errors
4. Build `IfcPolygonalFaceSet` with `IfcCartesianPointList3D` + `IfcIndexedPolygonalFace`
5. Compute bounding box origin for `IfcLocalPlacement`, offset vertices relative to it
4. Round coordinates to 0.001mm precision for compact IFC file output
5. Build `IfcPolygonalFaceSet` with `IfcCartesianPointList3D` + `IfcIndexedPolygonalFace`
6. Compute bounding box origin for `IfcLocalPlacement`, offset vertices relative to it
### 2D Curve Conversion
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)
3. Deduplicate points via snap grid (0.01mm tolerance)
4. Build `IfcIndexedPolyCurve` with `IfcCartesianPointList3D` + `IfcLineIndex` / `IfcArcIndex` segments
5. Compute bounding box origin for placement, offset points relative to it
4. Round coordinates to 0.001mm precision for compact IFC file output
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)
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
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
import ifcopenshell.api
@@ -217,11 +218,17 @@ def automate_function(
ifc.write(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:
automate_context.mark_run_success("Success! You can download the IF file below.")
automate_context.store_file_result(f"./{ifc_filename}")
automate_context.mark_run_success("Success! You can download the IFC file below.")
automate_context.store_file_result(f"./{zip_filename}")
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}")
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.api
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
@@ -29,14 +29,6 @@ def is_curve(obj) -> bool:
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:
"""Extract (x, y, z) from a Speckle Point, scaled to mm."""
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:
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 = []
for seg_type, indices in segments:
+9 -40
View File
@@ -13,7 +13,7 @@
import ifcopenshell
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:
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)
@@ -127,14 +134,6 @@ def unwrap_chunks(raw) -> list:
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
# --------------------------------------------------------------------------- #
@@ -264,36 +263,6 @@ def decode_faces(faces_raw: list) -> list:
# 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] = {}
@@ -313,7 +282,7 @@ def _get_shared(ifc):
def _make_placement(ifc, x: float, y: float, z: float):
"""Create an IfcLocalPlacement at absolute world coordinates (metres)."""
shared = _get_shared(ifc)
origin = ifc.createIfcCartesianPoint([x, y, z])
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)
+8
View File
@@ -36,3 +36,11 @@ MM_SCALES = {
"ft": 304.8, "foot": 304.8, "feet": 304.8,
"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.
# =============================================================================
import hashlib
import math
import struct
import ifcopenshell.api
from specklepy.objects.base import Base
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.
_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 _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.
Geometry is in local coordinates (mm, no instance transform applied).
Returns IfcRepresentationMap or None if no valid geometry.
"""
geom_items = []
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:
@@ -288,19 +320,62 @@ def _build_rep_map(ifc, body_context, meshes: list, ifc_format: bool,
print(f" Warning: Instance face decode: {e}")
continue
# Scale vertices once and cache the result
verts_local = [float(v) * ms for v in verts]
if mesh_id:
_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:
continue
# Apply material style to each faceset
# Try: mesh applicationId → fallback IDs → definitionId mapping
if material_manager:
mesh_app_id = _get(mesh, "applicationId")
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)
if not geom_items:
_geometry_hash_cache[geom_hash] = None
return None
# Mapping origin = identity (local coords origin) — reuse shared origin
shared = _get_shared(ifc)
a2p = ifc.createIfcAxis2Placement3D(shared["origin_0"], None, None)
# The mapped representation holds the actual geometry
mapped_rep = ifc.createIfcShapeRepresentation(
ContextOfItems=body_context,
RepresentationIdentifier="Body",
@@ -336,7 +410,9 @@ def _build_rep_map(ifc, body_context, meshes: list, ifc_format: bool,
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)
# 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
@@ -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]
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:
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])) # column 0: X-axis direction
ax2 = (float(t[1]), float(t[5]), float(t[9])) # column 1: Y-axis direction
ax3 = (float(t[2]), float(t[6]), float(t[10])) # column 2: Z-axis direction
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)
@@ -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:
return None # degenerate transform
# Normalized direction vectors
d1 = ifc.createIfcDirection([ax1[0]/s1, ax1[1]/s1, ax1[2]/s1])
d2 = ifc.createIfcDirection([ax2[0]/s2, ax2[1]/s2, ax2[2]/s2])
d3 = ifc.createIfcDirection([ax3[0]/s3, ax3[1]/s3, ax3[2]/s3])
# 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 to mm
tx = float(t[3]) * ts
ty = float(t[7]) * ts
tz = float(t[11]) * ts
# 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])
# 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(
d1, # Axis1
d2, # Axis2
origin, # LocalOrigin
s1, # Scale
d3, # Axis3
d3, # Axis3 (explicit — never derived)
s2, # Scale2
s3, # Scale3
)
@@ -529,12 +623,18 @@ def print_instance_stats():
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