diff --git a/README.md b/README.md index 3b73001..4585520 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/main.py b/main.py index ed02ba5..728fd22 100644 --- a/main.py +++ b/main.py @@ -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}") diff --git a/sample_files/geometry.py b/sample_files/geometry.py new file mode 100644 index 0000000..cfc1404 --- /dev/null +++ b/sample_files/geometry.py @@ -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 \ No newline at end of file diff --git a/sample_files/instances.py b/sample_files/instances.py new file mode 100644 index 0000000..23c298e --- /dev/null +++ b/sample_files/instances.py @@ -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 diff --git a/utils/curves.py b/utils/curves.py index a31fa71..c9168a6 100644 --- a/utils/curves.py +++ b/utils/curves.py @@ -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: diff --git a/utils/geometry.py b/utils/geometry.py index eb1644d..e035724 100644 --- a/utils/geometry.py +++ b/utils/geometry.py @@ -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) diff --git a/utils/helpers.py b/utils/helpers.py index 94ddbd3..33514c2 100644 --- a/utils/helpers.py +++ b/utils/helpers.py @@ -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 diff --git a/utils/instances.py b/utils/instances.py index 7a97f50..23c298e 100644 --- a/utils/instances.py +++ b/utils/instances.py @@ -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