diff --git a/README.md b/README.md index 9e5c0d8..18eaa41 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ The exporter receives a Speckle model version, walks its object tree, and produc - Correct IFC entity classification (IfcWall, IfcSlab, IfcColumn, etc.) - Tessellated geometry (IfcPolygonalFaceSet) -- Curve geometry for Lines and Arcs (IfcGeometricCurveSet with IfcPolyline) +- Curve geometry for Lines, Arcs, and Polycurves (IfcIndexedPolyCurve with IfcLineIndex/IfcArcIndex) - Material colours from Speckle render materials - Revit property sets (Common psets, instance/type parameters, material quantities) - IFC type objects (IfcWallType, IfcSlabType, etc.) shared across instances @@ -42,7 +42,7 @@ Speckle Model 4. Traverse object tree │ For each leaf element: │ ├── Classify → IFC entity class (skip analytical categories) - │ ├── Convert geometry → IfcPolygonalFaceSet or IfcGeometricCurveSet + │ ├── Convert geometry → IfcPolygonalFaceSet or IfcIndexedPolyCurve │ ├── Create IFC element + placement │ ├── Write property sets & quantities │ └── Assign IFC type object @@ -61,8 +61,10 @@ Speckle Model | `main.py` | Entry point, orchestrates the full pipeline | | `utils/traversal.py` | Walks the Speckle Collection tree (Project > Level > Category > Element) | | `utils/mapper.py` | Classifies Speckle objects into IFC entity types | -| `utils/geometry.py` | Converts Speckle meshes to IfcPolygonalFaceSet and Lines/Arcs to IfcGeometricCurveSet geometry | -| `utils/instances.py` | Handles InstanceProxy objects with shared geometry (IfcMappedItem) | +| `utils/helpers.py` | Shared utilities (`_get` safe accessor, `MM_SCALES` unit conversion) | +| `utils/geometry.py` | Converts Speckle meshes to IfcPolygonalFaceSet geometry (handles nested BrepX) | +| `utils/curves.py` | Converts Lines, Arcs, and Polycurves to IfcIndexedPolyCurve geometry | +| `utils/instances.py` | Handles InstanceProxy objects with shared geometry (IfcMappedItem), content-based deduplication | | `utils/properties.py` | Writes IFC property sets and quantities from Revit parameters | | `utils/type_manager.py` | Creates and caches IfcTypeObjects (IfcWallType, etc.) | | `utils/materials.py` | Maps Speckle render materials to IfcSurfaceStyle colours | @@ -134,11 +136,12 @@ If none of the above match, the object is classified as `IfcBuildingElementProxy Objects with `displayValue` containing Mesh objects are converted directly: -1. Extract vertices and faces from each mesh in `displayValue` +1. Extract vertices and faces from each mesh in `displayValue` (recursively handles nested BrepX/Brep objects) 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 vertex coordinates to 0.001mm precision for smaller IFC file output +5. Build `IfcPolygonalFaceSet` with `IfcCartesianPointList3D` + `IfcIndexedPolygonalFace` +6. Compute bounding box origin incrementally for `IfcLocalPlacement`, offset vertices relative to it ### Instance Objects (Path A / B2) @@ -147,16 +150,17 @@ Speckle `InstanceProxy` objects reference shared definition geometry via `defini - **Revit format**: `definitionId` is a 64-char hex hash; geometry is found by walking the object tree - **IFC format**: `definitionId` starts with `DEFINITION:`; geometry is in `definitionGeometry` collection -Performance optimisation: geometry is built once as an `IfcRepresentationMap`, then each instance references it via `IfcMappedItem` + `IfcCartesianTransformationOperator3DnonUniform`. This avoids duplicating vertex data across hundreds of identical elements. +Performance optimisation: geometry is built once as an `IfcRepresentationMap`, then each instance references it via `IfcMappedItem` + `IfcCartesianTransformationOperator3DnonUniform`. This avoids duplicating vertex data across hundreds of identical elements. Content-based hashing further deduplicates definitions that share identical geometry. ### Curve Geometry (Path B3) -Objects whose `displayValue` contains `Objects.Geometry.Line` or `Objects.Geometry.Arc` items (and no meshes or instances) are exported as curve geometry: +Objects whose `displayValue` contains `Objects.Geometry.Line`, `Objects.Geometry.Arc`, or `Objects.Geometry.Polycurve` items (and no meshes or instances) are exported as curve geometry using native IFC curve types: -- **Lines** → `IfcPolyline` with start and end points -- **Arcs** → `IfcPolyline` approximated with 8 segments, sampled parametrically from the arc's plane origin, radius, and domain angles. Falls back to start/mid/end points if plane data is unavailable. +- **Lines** → `IfcLineIndex` segments (start/end points) +- **Arcs** → `IfcArcIndex` segments (start/mid/end points) +- **Polycurves** → Mixed `IfcLineIndex` and `IfcArcIndex` segments from the polycurve's segment list (supports Line, Arc, and Polyline sub-segments) -All curves are wrapped in an `IfcGeometricCurveSet` inside an `IfcShapeRepresentation` with `RepresentationType="GeometricCurveSet"`. +All curves use `IfcIndexedPolyCurve` with `IfcCartesianPointList3D` for compact, deduplicated point storage. The representation uses `RepresentationType="Curve3D"`. ### Composite Objects (Path B2 — merged instances) diff --git a/main.py b/main.py index efeb6e8..ef062b1 100644 --- a/main.py +++ b/main.py @@ -1,3 +1,4 @@ +import zipfile from datetime import datetime import ifcopenshell.api @@ -5,8 +6,9 @@ import ifcopenshell.api from utils.materials import MaterialManager from utils.traversal import traverse, print_tree from utils.mapper import classify, reset_caches as reset_mapper_caches -from utils.geometry import mesh_to_ifc, get_display_instances, curves_to_ifc, _make_placement -from utils.instances import is_instance, instance_to_ifc, build_definition_map, print_instance_stats, get_definition_object +from utils.geometry import mesh_to_ifc, get_display_instances, _make_placement +from utils.curves import curve_to_ifc +from utils.instances import is_instance, instance_to_ifc, build_definition_map, print_instance_stats, get_definition_object, reset_caches as reset_instance_caches from utils.properties import write_properties, write_common_properties, build_element_name, get_element_tag, get_ifc_guid, reset_caches as reset_props_caches from utils.writer import create_ifc_scaffold, StoreyManager from utils.type_manager import TypeManager @@ -61,6 +63,7 @@ def automate_function( # Reset caches from any previous run reset_props_caches() reset_mapper_caches() + reset_instance_caches() # ------------------------------------------------------------------ # # 1. Receive @@ -199,7 +202,7 @@ def automate_function( # B3: Curve geometry (Lines, Arcs in displayValue) if not rep and not nested_instances: - curve_rep, curve_placement = curves_to_ifc(ifc, body_context, obj, scale=scale, material_manager=material_manager) + curve_rep, curve_placement = curve_to_ifc(ifc, body_context, obj, scale=scale, material_manager=material_manager) if curve_rep: element = _create_element(ifc, ifc_class, name, curve_rep, curve_placement, storey, storey_manager=storey_manager, @@ -230,11 +233,17 @@ def automate_function( ifc.write(ifc_filename) print(f"\n💾 IFC 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/utils/curves.py b/utils/curves.py new file mode 100644 index 0000000..aea7ba8 --- /dev/null +++ b/utils/curves.py @@ -0,0 +1,357 @@ +# ============================================================================= +# curves.py +# Converts Speckle 2D curve geometry (Polycurve, Line, Arc, Circle, Polyline) +# into IFC IfcIndexedPolyCurve representations. +# +# Curve types in segments: +# - Objects.Geometry.Line → start/end Points → IfcLineIndex +# - Objects.Geometry.Arc → startPoint/midPoint/endPoint → IfcArcIndex +# - Objects.Geometry.Circle → converted to arc segments +# - Objects.Geometry.Polyline → point sequence → IfcLineIndex chains +# +# The result is an IfcIndexedPolyCurve with IfcCartesianPointList3D. +# ============================================================================= + +import ifcopenshell +import ifcopenshell.api +from specklepy.objects.base import Base +from utils.helpers import _get, MM_SCALES +from utils.geometry import _get_shared, _make_placement + + +# Speckle types that are curve geometry +_CURVE_TYPES = {"Line", "Arc", "Circle", "Ellipse", "Polycurve", "Polyline", "Curve"} + + +def is_curve(obj) -> bool: + """Return True if this object is a Speckle curve type.""" + speckle_type = _get(obj, "speckle_type") or "" + 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 and rounded.""" + x = round(float(_get(pt, "x") or 0) * scale, 3) + y = round(float(_get(pt, "y") or 0) * scale, 3) + z = round(float(_get(pt, "z") or 0) * scale, 3) + return x, y, z + + +def _extract_polycurve(obj, scale: float) -> tuple: + """ + Extract points and segment indices from a Polycurve. + + Returns (points_3d, segments) where: + points_3d: list of [x, y, z] coordinate lists + segments: list of IfcLineIndex/IfcArcIndex-compatible tuples + ("line", [i, j]) or ("arc", [i, mid, j]) (1-based) + """ + segments_raw = _get(obj, "segments") or [] + if not isinstance(segments_raw, list): + segments_raw = list(segments_raw) + if not segments_raw: + return [], [] + + obj_scale = _resolve_scale(obj, scale) + points = [] # list of [x, y, z] + point_map = {} # (rounded_x, rounded_y, rounded_z) -> 1-based index + ifc_segments = [] + + def _add_point(pt, seg_scale: float) -> int: + """Add a point and return its 1-based index (deduplicating nearby points).""" + x, y, z = _point_coords(pt, seg_scale) + # Snap to 0.01mm grid for deduplication + key = (round(x * 100), round(y * 100), round(z * 100)) + if key in point_map: + return point_map[key] + idx = len(points) + 1 # 1-based for IFC + points.append([x, y, z]) + point_map[key] = idx + return idx + + for seg in segments_raw: + if seg is None: + continue + seg_type = (_get(seg, "speckle_type") or "").split(".")[-1] + seg_scale = _resolve_scale(seg, obj_scale) + + if seg_type == "Line": + start_pt = _get(seg, "start") + end_pt = _get(seg, "end") + if start_pt is None or end_pt is None: + continue + i = _add_point(start_pt, seg_scale) + j = _add_point(end_pt, seg_scale) + if i != j: + ifc_segments.append(("line", [i, j])) + + elif seg_type == "Arc": + start_pt = _get(seg, "startPoint") + mid_pt = _get(seg, "midPoint") + end_pt = _get(seg, "endPoint") + if start_pt is None or mid_pt is None or end_pt is None: + continue + i = _add_point(start_pt, seg_scale) + m = _add_point(mid_pt, seg_scale) + j = _add_point(end_pt, seg_scale) + if i != j and i != m and m != j: + ifc_segments.append(("arc", [i, m, j])) + + elif seg_type == "Polyline": + raw_value = _get(seg, "value") or [] + if not raw_value: + continue + values = list(raw_value) if not isinstance(raw_value, list) else raw_value + indices = [] + for vi in range(0, len(values) - 2, 3): + x = round(float(values[vi]) * seg_scale, 3) + y = round(float(values[vi + 1]) * seg_scale, 3) + z = round(float(values[vi + 2]) * seg_scale, 3) + key = (round(x * 100), round(y * 100), round(z * 100)) + if key in point_map: + idx = point_map[key] + else: + idx = len(points) + 1 + points.append([x, y, z]) + point_map[key] = idx + indices.append(idx) + if len(indices) >= 2: + ifc_segments.append(("line", indices)) + + return points, ifc_segments + + +def _extract_single_line(obj, scale: float) -> tuple: + """Extract a single Line as points + segment.""" + obj_scale = _resolve_scale(obj, scale) + start_pt = _get(obj, "start") + end_pt = _get(obj, "end") + if start_pt is None or end_pt is None: + return [], [] + sx, sy, sz = _point_coords(start_pt, obj_scale) + ex, ey, ez = _point_coords(end_pt, obj_scale) + return [[sx, sy, sz], [ex, ey, ez]], [("line", [1, 2])] + + +def _extract_single_arc(obj, scale: float) -> tuple: + """Extract a single Arc as points + segment.""" + obj_scale = _resolve_scale(obj, scale) + start_pt = _get(obj, "startPoint") + mid_pt = _get(obj, "midPoint") + end_pt = _get(obj, "endPoint") + if start_pt is None or mid_pt is None or end_pt is None: + return [], [] + sx, sy, sz = _point_coords(start_pt, obj_scale) + mx, my, mz = _point_coords(mid_pt, obj_scale) + ex, ey, ez = _point_coords(end_pt, obj_scale) + return [[sx, sy, sz], [mx, my, mz], [ex, ey, ez]], [("arc", [1, 2, 3])] + + +def extract_curve_data(obj, scale: float = 1.0) -> tuple: + """ + Extract curve points and segments from any supported curve type. + Returns (points_3d, segments) or ([], []) if not a curve. + """ + speckle_type = (_get(obj, "speckle_type") or "").split(".")[-1] + + if speckle_type == "Polycurve": + return _extract_polycurve(obj, scale) + elif speckle_type == "Line": + return _extract_single_line(obj, scale) + elif speckle_type == "Arc": + return _extract_single_arc(obj, scale) + return [], [] + + +def build_ifc_curve(ifc, points: list, segments: list): + """ + Build an IfcIndexedPolyCurve from points and segment descriptors. + + points: list of [x, y, z] coordinates + segments: list of ("line", [indices]) or ("arc", [indices]) + + Returns IfcIndexedPolyCurve or None. + """ + if not points or not segments: + return None + + point_list = ifc.createIfcCartesianPointList3D(points) + + ifc_segments = [] + for seg_type, indices in segments: + if seg_type == "arc": + ifc_segments.append(ifc.create_entity("IfcArcIndex", indices)) + else: + ifc_segments.append(ifc.create_entity("IfcLineIndex", indices)) + + if not ifc_segments: + return None + + return ifc.createIfcIndexedPolyCurve( + Points=point_list, + Segments=ifc_segments, + SelfIntersect=False, + ) + + +def get_display_curves(obj) -> list: + """ + Collect curve objects from an object's displayValue, or the object itself. + Returns a list of curve objects (Polycurve, Line, Arc, etc.). + """ + curves = [] + 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 not None and is_curve(item): + curves.append(item) + if curves: + break + + # Fallback: the object itself is a curve + if not curves and is_curve(obj): + curves.append(obj) + + return curves + + +def curve_to_ifc( + ifc: ifcopenshell.file, + body_context, + obj: Base, + scale: float = 1.0, + material_manager=None, +) -> tuple: + """ + Convert a Speckle object with curve geometry -> (IfcShapeRepresentation, IfcLocalPlacement). + Looks for curves in displayValue first, then checks the object itself. + Creates one IfcIndexedPolyCurve per curve item. + Returns (None, None) if no usable curve geometry. + """ + curves = get_display_curves(obj) + if not curves: + return None, None + + obj_app_id = _get(obj, "applicationId") + obj_scale = _resolve_scale(obj, scale) + + # Collect curve data and compute origin incrementally + curve_cache = [] + xmin = ymin = zmin = float("inf") + xmax = ymax = float("-inf") + has_points = False + + for curve_obj in curves: + points, segments = extract_curve_data(curve_obj, obj_scale) + if points and segments: + curve_cache.append((points, segments)) + has_points = True + for p in points: + x, y, z = p[0], p[1], p[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 + else: + curve_cache.append(None) + + if not has_points: + return None, None + + ox = (xmin + xmax) / 2.0 + oy = (ymin + ymax) / 2.0 + oz = zmin + + # Build IFC curve entities + geom_items = [] + for i, cached in enumerate(curve_cache): + if cached is None: + continue + points, segments = cached + + offset_points = [ + [p[0] - ox, p[1] - oy, p[2] - oz] for p in points + ] + + curve_entity = build_ifc_curve(ifc, offset_points, segments) + if curve_entity is None: + continue + + # Apply material + if material_manager: + curve_app_id = _get(curves[i], "applicationId") or obj_app_id + if curve_app_id: + material_manager.apply_to_item(curve_entity, str(curve_app_id)) + + geom_items.append(curve_entity) + + if not geom_items: + return None, None + + rep = ifc.createIfcShapeRepresentation( + ContextOfItems=body_context, + RepresentationIdentifier="Body", + RepresentationType="Curve3D", + Items=geom_items, + ) + placement = _make_placement(ifc, ox, oy, oz) + return rep, placement + + +def build_curve_rep_map(ifc, body_context, obj, scale: float = 1.0, + material_manager=None, fallback_app_ids: list = None, + definition_id: str = None): + """ + Build an IfcRepresentationMap from a curve definition object. + Used for instance-based curve geometry (shared across instances). + Returns IfcRepresentationMap or None. + """ + points, segments = extract_curve_data(obj, scale) + if not points or not segments: + return None + + curve_entity = build_ifc_curve(ifc, points, segments) + if curve_entity is None: + return None + + # Apply material (3-tier: object app_id -> fallbacks -> definition) + if material_manager: + app_id = _get(obj, "applicationId") + style = material_manager.get_style_with_fallbacks( + primary_app_id=str(app_id) if app_id else None, + fallback_app_ids=fallback_app_ids, + definition_id=definition_id, + ) + if style: + try: + ifcopenshell.api.run( + "style.assign_item_style", ifc, + item=curve_entity, style=style, + ) + material_manager._apply_count += 1 + except Exception: + pass + + shared = _get_shared(ifc) + a2p = ifc.createIfcAxis2Placement3D(shared["origin_0"], None, None) + + mapped_rep = ifc.createIfcShapeRepresentation( + ContextOfItems=body_context, + RepresentationIdentifier="Body", + RepresentationType="Curve3D", + Items=[curve_entity], + ) + + return ifc.createIfcRepresentationMap(a2p, mapped_rep) diff --git a/utils/geometry.py b/utils/geometry.py index 2a8f957..c3320a8 100644 --- a/utils/geometry.py +++ b/utils/geometry.py @@ -1,6 +1,6 @@ # ============================================================================= # geometry.py -# Converts Speckle DataObject geometry → IFC IfcPolygonalFaceSet + IfcLocalPlacement +# Converts Speckle DataObject geometry -> IFC IfcPolygonalFaceSet + IfcLocalPlacement # # Key facts: # - After specklepy receive(), vertices and faces are FLAT Python lists @@ -8,23 +8,12 @@ # - 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. +# for compact output -- each vertex stored once, not once per face. # ============================================================================= -import math - import ifcopenshell from specklepy.objects.base import Base - - -# Scale factors → MILLIMETRES (IFC file is declared as mm) -_UNIT_SCALES = { - "mm": 1.0, "millimeter": 1.0, "millimeters": 1.0, - "cm": 10.0, "centimeter": 10.0, "centimeters": 10.0, - "m": 1000.0, "meter": 1000.0, "meters": 1000.0, - "ft": 304.8, "foot": 304.8, "feet": 304.8, - "in": 25.4, "inch": 25.4, "inches": 25.4, -} +from utils.helpers import _get, MM_SCALES as _UNIT_SCALES # --------------------------------------------------------------------------- # @@ -49,8 +38,8 @@ def build_ifc_facesets(ifc, verts_scaled: list, face_groups: list) -> list: 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 + 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 @@ -87,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) @@ -99,39 +95,15 @@ def build_ifc_facesets(ifc, verts_scaled: list, face_groups: list) -> list: return [] -# --------------------------------------------------------------------------- # -# Safe data access helpers -# --------------------------------------------------------------------------- # - -def _get(obj, key, default=None): - """ - Safe access for specklepy Base objects. - Tries attribute access first, then bracket access. - """ - try: - val = getattr(obj, key, None) - if val is not None: - return val - except Exception: - pass - try: - val = obj[key] - if val is not None: - return val - except Exception: - pass - return default - - def unwrap_chunks(raw) -> list: """ 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) + -> returned as-is (fast path) 2. List of DataChunk objects (raw from server before deserialization) - → each chunk's .data list is concatenated + -> each chunk's .data list is concatenated """ if not raw: return [] @@ -163,7 +135,7 @@ def unwrap_chunks(raw) -> list: def _resolve_scale(obj, stream_scale: float) -> float: - """Resolve unit scale: obj.units → stream fallback.""" + """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) @@ -177,7 +149,7 @@ def _resolve_scale(obj, stream_scale: float) -> float: def _is_mesh(item) -> bool: """ Detect if a specklepy object is a Mesh. - Uses speckle_type string — more reliable than hasattr on Base objects. + Uses speckle_type string -- more reliable than hasattr on Base objects. """ if item is None: return False @@ -190,23 +162,39 @@ def _is_mesh(item) -> bool: return verts is not None and faces is not None -def get_display_meshes(obj: Base) -> list: +def _collect_meshes_from_display(obj) -> list: """ - Extract all Mesh objects from a DataObject's displayValue. - displayValue is always an array per the Speckle schema docs. + 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"]: + 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 # found meshes, don't check @displayValue too + 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): @@ -227,10 +215,10 @@ def get_display_instances(obj: Base) -> list: - definitionId: "DEFINITION:{meshAppId}" string - units: "m" - Raw meshes do NOT appear in displayValue in IFC→Speckle exports. + Raw meshes do NOT appear in displayValue in IFC->Speckle exports. """ instances = [] - for key in ["displayValue", "@displayValue"]: + for key in ["displayValue", "@displayValue", "_displayValue"]: display = _get(obj, key) if display is None: continue @@ -247,181 +235,6 @@ def get_display_instances(obj: Base) -> list: return instances -# --------------------------------------------------------------------------- # -# Curve detection & extraction (Lines, Arcs) -# --------------------------------------------------------------------------- # - -def _is_line(item) -> bool: - """Detect Objects.Geometry.Line (but not Polyline).""" - if item is None: - return False - st = _get(item, "speckle_type") or "" - return "Line" in st and "Polyline" not in st - - -def _is_arc(item) -> bool: - """Detect Objects.Geometry.Arc.""" - if item is None: - return False - st = _get(item, "speckle_type") or "" - return "Arc" in st - - -def get_display_curves(obj: Base) -> list: - """Extract Line and Arc objects from a DataObject's displayValue.""" - curves = [] - for key in ["displayValue", "@displayValue"]: - display = _get(obj, key) - if display is None: - continue - items = display if isinstance(display, list) else [display] - for item in items: - if _is_line(item) or _is_arc(item): - curves.append(item) - if curves: - break - return curves - - -def _point_coords(pt, fallback_scale: float) -> tuple: - """Extract (x, y, z) from a Speckle Point, scaled to mm.""" - scale = _resolve_scale(pt, fallback_scale) - x = float(_get(pt, "x") or 0.0) * scale - y = float(_get(pt, "y") or 0.0) * scale - z = float(_get(pt, "z") or 0.0) * scale - return x, y, z - - -def _arc_to_points(arc, scale: float, num_segments: int = 8) -> list: - """ - Approximate a Speckle Arc as a list of (x, y, z) points in mm. - Uses plane origin (center), radius, and domain angles for parametric sampling. - Falls back to start/mid/end points if plane data is missing. - """ - plane = _get(arc, "plane") - radius = _get(arc, "radius") - domain = _get(arc, "domain") - - if not plane or not radius or not domain: - points = [] - for key in ["startPoint", "midPoint", "endPoint"]: - pt = _get(arc, key) - if pt: - points.append(_point_coords(pt, scale)) - return points if len(points) >= 2 else [] - - origin = _get(plane, "origin") - xdir = _get(plane, "xdir") - ydir = _get(plane, "ydir") - - if not origin or not xdir or not ydir: - points = [] - for key in ["startPoint", "midPoint", "endPoint"]: - pt = _get(arc, key) - if pt: - points.append(_point_coords(pt, scale)) - return points if len(points) >= 2 else [] - - cx, cy, cz = _point_coords(origin, scale) - # Direction vectors are unitless — do not scale - dxx = float(_get(xdir, "x") or 0.0) - dxy = float(_get(xdir, "y") or 0.0) - dxz = float(_get(xdir, "z") or 0.0) - dyx = float(_get(ydir, "x") or 0.0) - dyy = float(_get(ydir, "y") or 0.0) - dyz = float(_get(ydir, "z") or 0.0) - - r = float(radius) * scale - t_start = float(_get(domain, "start") or 0.0) - t_end = float(_get(domain, "end") or 0.0) - - points = [] - for i in range(num_segments + 1): - t = t_start + (t_end - t_start) * i / num_segments - cos_t = math.cos(t) - sin_t = math.sin(t) - x = cx + r * (cos_t * dxx + sin_t * dyx) - y = cy + r * (cos_t * dxy + sin_t * dyy) - z = cz + r * (cos_t * dxz + sin_t * dyz) - points.append((x, y, z)) - return points - - -def curves_to_ifc( - ifc: ifcopenshell.file, - body_context, - obj: Base, - scale: float = 0.001, - material_manager=None, -) -> tuple: - """ - Convert Speckle Line/Arc objects in displayValue to IFC curve geometry. - Lines → IfcPolyline (2 points), Arcs → IfcPolyline (sampled points). - Wrapped in IfcGeometricCurveSet. - Returns (IfcShapeRepresentation, IfcLocalPlacement) or (None, None). - """ - curves = get_display_curves(obj) - if not curves: - return None, None - - obj_scale = _resolve_scale(obj, scale) - polylines = [] - all_points = [] - - for curve in curves: - cs = _resolve_scale(curve, obj_scale) - - if _is_line(curve): - start = _get(curve, "start") - end = _get(curve, "end") - if not start or not end: - continue - p1 = _point_coords(start, cs) - p2 = _point_coords(end, cs) - all_points.extend([p1, p2]) - polylines.append([p1, p2]) - - elif _is_arc(curve): - pts = _arc_to_points(curve, cs) - if len(pts) >= 2: - all_points.extend(pts) - polylines.append(pts) - - if not polylines or not all_points: - return None, None - - # Compute origin from all curve points - xs = [p[0] for p in all_points] - ys = [p[1] for p in all_points] - zs = [p[2] for p in all_points] - ox = (min(xs) + max(xs)) / 2.0 - oy = (min(ys) + max(ys)) / 2.0 - oz = min(zs) - - # Build IfcPolylines offset from origin - ifc_polylines = [] - for pts in polylines: - ifc_points = [ - ifc.createIfcCartesianPoint([p[0] - ox, p[1] - oy, p[2] - oz]) - for p in pts - ] - ifc_polylines.append(ifc.createIfcPolyline(ifc_points)) - - if not ifc_polylines: - return None, None - - curve_set = ifc.createIfcGeometricCurveSet(ifc_polylines) - - rep = ifc.createIfcShapeRepresentation( - ContextOfItems=body_context, - RepresentationIdentifier="Body", - RepresentationType="GeometricCurveSet", - Items=[curve_set], - ) - placement = _make_placement(ifc, ox, oy, oz) - return rep, placement - - # --------------------------------------------------------------------------- # # Face decoding # --------------------------------------------------------------------------- # @@ -430,7 +243,7 @@ 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 + n=0 -> triangle (legacy), n=1 -> quad (legacy), n>=3 -> n-gon """ decoded = [] i = 0 @@ -462,7 +275,7 @@ 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) + 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] @@ -505,9 +318,9 @@ def _get_shared(ifc): def _make_placement(ifc, x: float, y: float, z: float): - """Create an IfcLocalPlacement at absolute world coordinates (metres).""" + """Create an IfcLocalPlacement at absolute world coordinates (mm).""" shared = _get_shared(ifc) - 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) @@ -524,7 +337,7 @@ def mesh_to_ifc( material_manager=None, ) -> tuple: """ - Convert a Speckle DataObject → (IfcShapeRepresentation, IfcLocalPlacement). + 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. """ @@ -532,14 +345,21 @@ def mesh_to_ifc( 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 vertices once per mesh, collect all scaled coords - # to compute world origin. Cache (verts, ms) for Pass 2. + # Pass 1: unpack and scale vertices once per mesh, compute origin + # incrementally without accumulating all vertices in memory. # ------------------------------------------------------------------ # - mesh_cache = [] # [(verts_list, ms, scaled)] or None per mesh - all_scaled = [] + 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)) @@ -547,25 +367,34 @@ def mesh_to_ifc( mesh_cache.append(None) continue ms = _resolve_scale(mesh, obj_scale) - # Pre-scale vertices once, reuse in Pass 2 scaled = [float(v) * ms for v in verts] - mesh_cache.append((verts, ms, scaled)) - all_scaled.extend(scaled) + mesh_cache.append(scaled) + has_verts = True - if not all_scaled: + # Update bounding box from this mesh's scaled vertices + for i in range(0, len(scaled) - 2, 3): + x, y, z = scaled[i], scaled[i + 1], scaled[i + 2] + if x < xmin: xmin = x + if x > xmax: xmax = x + if y < ymin: ymin = y + if y > ymax: ymax = y + if z < zmin: zmin = z + + if not has_verts: return None, None - ox, oy, oz = compute_origin(all_scaled) + ox = (xmin + xmax) / 2.0 + oy = (ymin + ymax) / 2.0 + oz = zmin # ------------------------------------------------------------------ # - # Pass 2: one faceset per mesh — reuse cached verts, only unpack faces + # Pass 2: one faceset per mesh -- reuse cached verts, only unpack faces # ------------------------------------------------------------------ # geom_items = [] - for mesh, cached in zip(meshes, mesh_cache): - if cached is None: + for mesh, scaled in zip(meshes, mesh_cache): + if scaled is None: continue - verts, ms, scaled = cached raw_faces = _get(mesh, "faces") or [] faces_raw = unwrap_chunks(raw_faces if isinstance(raw_faces, list) else list(raw_faces)) @@ -575,7 +404,7 @@ def mesh_to_ifc( try: face_groups = decode_faces(faces_raw) except Exception as e: - print(f" ⚠️ Face decode error: {e}") + print(f" Warning: Face decode error: {e}") continue # Offset pre-scaled vertices relative to origin (flat list, no tuples) @@ -592,8 +421,9 @@ def mesh_to_ifc( 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") + 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)) @@ -614,4 +444,4 @@ def mesh_to_ifc( ) placement = _make_placement(ifc, ox, oy, oz) - return rep, placement \ No newline at end of file + return rep, placement diff --git a/utils/helpers.py b/utils/helpers.py new file mode 100644 index 0000000..94ddbd3 --- /dev/null +++ b/utils/helpers.py @@ -0,0 +1,38 @@ +# ============================================================================= +# helpers.py +# Shared utilities used across the exporter modules. +# ============================================================================= + + +def _get(obj, key, default=None): + """ + Safe access for specklepy Base objects, dicts, or any hybrid. + Tries attribute access first, then bracket access. + """ + if obj is None: + return default + if isinstance(obj, dict): + return obj.get(key, default) + try: + val = getattr(obj, key, None) + if val is not None: + return val + except Exception: + pass + try: + val = obj[key] + if val is not None: + return val + except Exception: + pass + return default + + +# Scale factors → MILLIMETRES (IFC file is declared as mm) +MM_SCALES = { + "mm": 1.0, "millimeter": 1.0, "millimeters": 1.0, + "cm": 10.0, "centimeter": 10.0, "centimeters": 10.0, + "m": 1000.0, "meter": 1000.0, "meters": 1000.0, + "ft": 304.8, "foot": 304.8, "feet": 304.8, + "in": 25.4, "inch": 25.4, "inches": 25.4, +} diff --git a/utils/instances.py b/utils/instances.py index a68754f..8c8ebe9 100644 --- a/utils/instances.py +++ b/utils/instances.py @@ -2,17 +2,17 @@ # instances.py # Handles Speckle InstanceProxy objects from both: # -# FORMAT A — Revit connector (our actual use case): +# 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: +# 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") +# Definition geometry lives in root -> Collection("definitionGeometry") # # We detect the format by the definitionId prefix. # @@ -20,9 +20,14 @@ # 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.geometry import _get, unwrap_chunks, decode_faces, _UNIT_SCALES, build_ifc_facesets, _get_shared +from utils.helpers import _get, MM_SCALES +from utils.geometry import unwrap_chunks, decode_faces, build_ifc_facesets, _get_shared, _is_mesh +from utils.curves import is_curve, build_curve_rep_map def is_instance(obj) -> bool: @@ -40,15 +45,18 @@ 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 + "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() # --- Walk entire tree for Revit format --- _collect_all(root, by_id, by_app_id, depth=0) @@ -61,6 +69,11 @@ def build_definition_map(root: Base) -> dict: 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 []): @@ -75,12 +88,14 @@ def build_definition_map(root: Base) -> dict: 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, + "by_id": by_id, + "by_app_id": by_app_id, + "ifc_proxies": ifc_proxies, + "ifc_meshes": ifc_meshes, + "definition_sources": definition_sources, } @@ -92,7 +107,7 @@ def _collect_all(obj, by_id: dict, by_app_id: dict, depth: int): 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) + # Also store truncated -- definitionId (64 chars) matches id (32 chars) if len(key) == 32: by_id[key] = obj elif len(key) > 32: @@ -102,7 +117,8 @@ def _collect_all(obj, by_id: dict, by_app_id: dict, depth: int): if app_id and isinstance(app_id, str): by_app_id[app_id.lower()] = obj - for key in ["elements", "@elements", "displayValue", "@displayValue", + for key in ["elements", "@elements", "_elements", + "displayValue", "@displayValue", "_displayValue", "objects", "@objects", "definition", "@definition"]: try: children = obj[key] @@ -116,11 +132,29 @@ def _collect_all(obj, by_id: dict, by_app_id: dict, depth: int): continue -def _get_revit_meshes(definition_id: str, definition_map: dict) -> list: +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 + 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 @@ -128,40 +162,55 @@ def _get_revit_meshes(definition_id: str, definition_map: dict) -> list: 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 [] + 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 + # 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) - else: - # It IS the mesh directly + elif _is_mesh(obj): meshes.append(obj) - return meshes + return meshes, encountered_app_ids -def _get_ifc_meshes(definition_id: str, definition_map: dict) -> list: +def _get_ifc_meshes(definition_id: str, definition_map: dict) -> tuple: """ IFC format: definitionId = "DEFINITION:224058_mat0" - Look up proxy → objects list → meshes from ifc_meshes dict. + Look up proxy -> objects list -> meshes from ifc_meshes dict. + Returns (meshes, []) -- no extra app_ids needed, mesh applicationIds match directly. """ ifc_proxies = definition_map.get("ifc_proxies", {}) ifc_meshes = definition_map.get("ifc_meshes", {}) proxy = ifc_proxies.get(definition_id) if proxy is None: - return [] + return [], [] object_ids = _get(proxy, "objects") or [] result = [] @@ -169,20 +218,20 @@ def _get_ifc_meshes(definition_id: str, definition_map: dict) -> list: mesh = ifc_meshes.get(str(oid)) if mesh is not None: result.append(mesh) - return result + 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). + 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 = _UNIT_SCALES.get(units.lower().strip()) + s = MM_SCALES.get(units.lower().strip()) if s is not None: return s except Exception: @@ -193,39 +242,62 @@ def _resolve_instance_scale(obj, stream_scale: float) -> float: # Stats _stats = {"found": 0, "not_found": 0} -# Cache: mesh id → (verts_scaled, face_groups) to avoid re-unpacking +# 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) +# 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] = {} -_MM_SCALES = { - "mm": 1.0, "millimeter": 1.0, "millimeters": 1.0, - "cm": 10.0, "centimeter": 10.0, - "m": 1000.0, "meter": 1000.0, "meters": 1000.0, - "ft": 304.8, "in": 25.4, -} +# --------------------------------------------------------------------------- # +# Geometry content hashing +# --------------------------------------------------------------------------- # + +def _hash_mesh_data(mesh_data_list: list, material_key: str = "") -> str: + """Compute a content hash from mesh geometry data for deduplication. + + mesh_data_list: list of (verts_local, face_groups) tuples + material_key: string identifying the material (included in hash) + Returns: hex digest string + """ + h = hashlib.md5(usedforsecurity=False) + for verts_local, face_groups in mesh_data_list: + # Hash rounded vertices as packed floats (faster than str conversion) + for i in range(0, len(verts_local), 3): + h.update(struct.pack("3f", + round(verts_local[i], 3), + round(verts_local[i+1], 3), + round(verts_local[i+2], 3), + )) + # Hash face indices + for face in face_groups: + h.update(struct.pack(f"{len(face)}i", *face)) + # Separator between meshes + h.update(b"|") + if material_key: + h.update(material_key.encode()) + return h.hexdigest() # --------------------------------------------------------------------------- # -# IfcRepresentationMap builder — geometry created once per definition +# IfcRepresentationMap builder -- geometry created once per definition # --------------------------------------------------------------------------- # -def _build_rep_map(ifc, body_context, meshes: list, ifc_format: bool, - material_manager=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: @@ -233,48 +305,103 @@ def _build_rep_map(ifc, body_context, meshes: list, ifc_format: bool, else: raw_verts = _get(mesh, "vertices") or [] raw_faces = _get(mesh, "faces") or [] - verts = unwrap_chunks(list(raw_verts)) - faces_raw = unwrap_chunks(list(raw_faces)) + 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) + ms = MM_SCALES.get(mesh_units.lower().strip(), 1.0) try: face_groups = decode_faces(faces_raw) except Exception as e: - print(f" ⚠️ Instance face decode: {e}") + 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 if material_manager: mesh_app_id = _get(mesh, "applicationId") - if mesh_app_id: + 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: - material_manager.apply_to_item(fs, str(mesh_app_id)) + try: + ifcopenshell.api.run( + "style.assign_item_style", ifc, + item=fs, style=style, + ) + material_manager._apply_count += 1 + except Exception: + pass geom_items.extend(mesh_facesets) 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", @@ -282,41 +409,57 @@ 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 # --------------------------------------------------------------------------- # -# Transform → IfcCartesianTransformationOperator3D +# 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) - - 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]) + 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]) + 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) @@ -325,37 +468,41 @@ 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 ) # --------------------------------------------------------------------------- # -# Main conversion — IfcMappedItem approach +# 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). + Convert a Speckle InstanceProxy -> (IfcShapeRepresentation, IfcLocalPlacement). Strategy: create geometry once per definition as an IfcRepresentationMap, then reference it via IfcMappedItem + IfcCartesianTransformationOperator3D @@ -371,11 +518,11 @@ def instance_to_ifc(ifc, body_context, obj: Base, definition_map: dict, definition_id = _get(obj, "definitionId") or "" ifc_format = _is_ifc_format(definition_id) - # Translation scale: IFC format transform is in metres → convert to mm + # 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 + # 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) @@ -386,19 +533,42 @@ def instance_to_ifc(ifc, body_context, obj: Base, definition_map: dict, # --- Get or build IfcRepresentationMap (cached per definition_id) --- if definition_id not in _rep_map_cache: if ifc_format: - meshes = _get_ifc_meshes(definition_id, definition_map) + meshes, extra_app_ids = _get_ifc_meshes(definition_id, definition_map) else: - meshes = _get_revit_meshes(definition_id, definition_map) + meshes, extra_app_ids = _get_revit_meshes(definition_id, definition_map) - if not meshes: + # 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 - _rep_map_cache[definition_id] = None - return None, placement - - _stats["found"] += 1 - _rep_map_cache[definition_id] = _build_rep_map( - ifc, body_context, meshes, ifc_format, material_manager - ) else: # Track stats even for cached definitions if _rep_map_cache[definition_id] is not None: @@ -436,34 +606,34 @@ def get_definition_object(obj: Base, definition_map: dict): definition_id = _get(obj, "definitionId") or "" if not definition_id: return None + return _get_definition_source_object(definition_id, definition_map) - 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", {}) - source = by_app_id.get(str(object_ids[0]).lower()) - return source +def is_definition_source(obj, definition_map: dict) -> bool: + """Return True if this object is a definition geometry source (should not be exported standalone).""" + app_id = _get(obj, "applicationId") + if not app_id: + return False + return str(app_id).lower() in definition_map.get("definition_sources", set()) def print_instance_stats(): total = _stats["found"] + _stats["not_found"] print(f" Instance resolution: {_stats['found']}/{total} definitions found") if _stats["not_found"] > 0: - print(f" ⚠️ {_stats['not_found']} instances had no definition geometry") + print(f" Warning: {_stats['not_found']} instances had no definition geometry") + 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/materials.py b/utils/materials.py index d2a9511..cdf5d92 100644 --- a/utils/materials.py +++ b/utils/materials.py @@ -64,6 +64,7 @@ class MaterialManager: self._style_map: dict[str, object] = {} # name → IfcSurfaceStyle (cache to avoid duplicates) self._style_cache: dict[str, object] = {} + self._apply_count: int = 0 self._build(root) def _build(self, root: Base): @@ -135,6 +136,24 @@ class MaterialManager: self._style_map[key] = style return style + def get_style_with_fallbacks(self, primary_app_id: str = None, + fallback_app_ids: list = None, + definition_id: str = None): + """Try primary app_id first, then each fallback, then definition_id. Return style or None.""" + if primary_app_id: + style = self.get_style(primary_app_id) + if style: + return style + for aid in (fallback_app_ids or []): + style = self.get_style(aid) + if style: + return style + if definition_id: + style = self.get_style(definition_id) + if style: + return style + return None + def apply_to_item(self, item, mesh_app_id: str): """Assign the material style to a single IFC geometry item (e.g. IfcPolygonalFaceSet).""" style = self.get_style(mesh_app_id)