update instancing
build and deploy Speckle functions / publish-automate-function-version (push) Has been cancelled

This commit is contained in:
NLSA
2026-03-25 13:49:27 +01:00
parent bf63a73436
commit fad461c767
7 changed files with 801 additions and 374 deletions
+16 -12
View File
@@ -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.) - Correct IFC entity classification (IfcWall, IfcSlab, IfcColumn, etc.)
- Tessellated geometry (IfcPolygonalFaceSet) - 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 - Material colours from Speckle render materials
- Revit property sets (Common psets, instance/type parameters, material quantities) - Revit property sets (Common psets, instance/type parameters, material quantities)
- IFC type objects (IfcWallType, IfcSlabType, etc.) shared across instances - IFC type objects (IfcWallType, IfcSlabType, etc.) shared across instances
@@ -42,7 +42,7 @@ Speckle Model
4. Traverse object tree 4. Traverse object tree
│ For each leaf element: │ For each leaf element:
│ ├── Classify → IFC entity class (skip analytical categories) │ ├── Classify → IFC entity class (skip analytical categories)
│ ├── Convert geometry → IfcPolygonalFaceSet or IfcGeometricCurveSet │ ├── Convert geometry → IfcPolygonalFaceSet or IfcIndexedPolyCurve
│ ├── Create IFC element + placement │ ├── Create IFC element + placement
│ ├── Write property sets & quantities │ ├── Write property sets & quantities
│ └── Assign IFC type object │ └── Assign IFC type object
@@ -61,8 +61,10 @@ Speckle Model
| `main.py` | Entry point, orchestrates the full pipeline | | `main.py` | Entry point, orchestrates the full pipeline |
| `utils/traversal.py` | Walks the Speckle Collection tree (Project > Level > Category > Element) | | `utils/traversal.py` | Walks the Speckle Collection tree (Project > Level > Category > Element) |
| `utils/mapper.py` | Classifies Speckle objects into IFC entity types | | `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/helpers.py` | Shared utilities (`_get` safe accessor, `MM_SCALES` unit conversion) |
| `utils/instances.py` | Handles InstanceProxy objects with shared geometry (IfcMappedItem) | | `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/properties.py` | Writes IFC property sets and quantities from Revit parameters |
| `utils/type_manager.py` | Creates and caches IfcTypeObjects (IfcWallType, etc.) | | `utils/type_manager.py` | Creates and caches IfcTypeObjects (IfcWallType, etc.) |
| `utils/materials.py` | Maps Speckle render materials to IfcSurfaceStyle colours | | `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: 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 2. Scale vertices to millimetres based on the mesh's unit declaration
3. Deduplicate vertices via snap grid (0.01mm tolerance) to avoid IFC GEM111 errors 3. Deduplicate vertices via snap grid (0.01mm tolerance) to avoid IFC GEM111 errors
4. Build `IfcPolygonalFaceSet` with `IfcCartesianPointList3D` + `IfcIndexedPolygonalFace` 4. Round vertex coordinates to 0.001mm precision for smaller IFC file output
5. Compute bounding box origin for `IfcLocalPlacement`, offset vertices relative to it 5. Build `IfcPolygonalFaceSet` with `IfcCartesianPointList3D` + `IfcIndexedPolygonalFace`
6. Compute bounding box origin incrementally for `IfcLocalPlacement`, offset vertices relative to it
### Instance Objects (Path A / B2) ### 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 - **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 - **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) ### 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 - **Lines** → `IfcLineIndex` segments (start/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. - **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) ### Composite Objects (Path B2 — merged instances)
+15 -6
View File
@@ -1,3 +1,4 @@
import zipfile
from datetime import datetime from datetime import datetime
import ifcopenshell.api import ifcopenshell.api
@@ -5,8 +6,9 @@ import ifcopenshell.api
from utils.materials import MaterialManager from utils.materials import MaterialManager
from utils.traversal import traverse, print_tree from utils.traversal import traverse, print_tree
from utils.mapper import classify, reset_caches as reset_mapper_caches 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.geometry import mesh_to_ifc, get_display_instances, _make_placement
from utils.instances import is_instance, instance_to_ifc, build_definition_map, print_instance_stats, get_definition_object 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.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.writer import create_ifc_scaffold, StoreyManager
from utils.type_manager import TypeManager from utils.type_manager import TypeManager
@@ -61,6 +63,7 @@ def automate_function(
# Reset caches from any previous run # Reset caches from any previous run
reset_props_caches() reset_props_caches()
reset_mapper_caches() reset_mapper_caches()
reset_instance_caches()
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# 1. Receive # 1. Receive
@@ -199,7 +202,7 @@ def automate_function(
# B3: Curve geometry (Lines, Arcs in displayValue) # B3: Curve geometry (Lines, Arcs in displayValue)
if not rep and not nested_instances: 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: if curve_rep:
element = _create_element(ifc, ifc_class, name, curve_rep, curve_placement, storey, element = _create_element(ifc, ifc_class, name, curve_rep, curve_placement, storey,
storey_manager=storey_manager, storey_manager=storey_manager,
@@ -230,11 +233,17 @@ def automate_function(
ifc.write(ifc_filename) ifc.write(ifc_filename)
print(f"\n💾 IFC file written: {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: try:
automate_context.mark_run_success("Success! You can download the IF file below.") automate_context.mark_run_success("Success! You can download the IFC file below.")
automate_context.store_file_result(f"./{ifc_filename}") automate_context.store_file_result(f"./{zip_filename}")
except Exception as e: except Exception as e:
print(f" ⚠️ Could not upload file result (network issue?): {e}") print(f" Could not upload file result (network issue?): {e}")
automate_context.mark_run_failed(f"Something went wrong when storing file result. Exception detail: {e}") automate_context.mark_run_failed(f"Something went wrong when storing file result. Exception detail: {e}")
print(f"\n{'=' * 60}") print(f"\n{'=' * 60}")
+357
View File
@@ -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)
+77 -247
View File
@@ -1,6 +1,6 @@
# ============================================================================= # =============================================================================
# geometry.py # geometry.py
# Converts Speckle DataObject geometry IFC IfcPolygonalFaceSet + IfcLocalPlacement # Converts Speckle DataObject geometry -> IFC IfcPolygonalFaceSet + IfcLocalPlacement
# #
# Key facts: # Key facts:
# - After specklepy receive(), vertices and faces are FLAT Python lists # - 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 # - Units are in mm (for Revit), scale to metres for IFC
# - Vertices are in absolute world coordinates # - Vertices are in absolute world coordinates
# - Uses IfcPolygonalFaceSet (indexed vertices) instead of IfcFacetedBrep # - 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 import ifcopenshell
from specklepy.objects.base import Base from specklepy.objects.base import Base
from utils.helpers import _get, MM_SCALES as _UNIT_SCALES
# Scale factors → MILLIMETRES (IFC file is declared as mm)
_UNIT_SCALES = {
"mm": 1.0, "millimeter": 1.0, "millimeters": 1.0,
"cm": 10.0, "centimeter": 10.0, "centimeters": 10.0,
"m": 1000.0, "meter": 1000.0, "meters": 1000.0,
"ft": 304.8, "foot": 304.8, "feet": 304.8,
"in": 25.4, "inch": 25.4, "inches": 25.4,
}
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -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], ...] face_groups: list of index lists [[i,j,k], [i,j,k,l], ...]
Returns: list of IfcPolygonalFaceSet (typically one, empty on failure). Returns: list of IfcPolygonalFaceSet (typically one, empty on failure).
""" """
snap_to_idx = {} # snap_key 0-based index in deduped_verts snap_to_idx = {} # snap_key -> 0-based index in deduped_verts
deduped_verts = [] # [[x, y, z], ...] lists for direct IFC use deduped_verts = [] # [[x, y, z], ...] -- lists for direct IFC use
inv_tol = _INV_TOL inv_tol = _INV_TOL
# Validate faces and remap indices to deduplicated vertex list # 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: if not valid_faces or not deduped_verts:
return [] return []
# Round vertex coordinates to reduce IFC text file size
# 3 decimal places = 0.001mm precision (more than sufficient)
for v in deduped_verts:
v[0] = round(v[0], 3)
v[1] = round(v[1], 3)
v[2] = round(v[2], 3)
# Build IFC entities # Build IFC entities
try: try:
point_list = ifc.createIfcCartesianPointList3D(deduped_verts) point_list = ifc.createIfcCartesianPointList3D(deduped_verts)
@@ -99,39 +95,15 @@ def build_ifc_facesets(ifc, verts_scaled: list, face_groups: list) -> list:
return [] return []
# --------------------------------------------------------------------------- #
# Safe data access helpers
# --------------------------------------------------------------------------- #
def _get(obj, key, default=None):
"""
Safe access for specklepy Base objects.
Tries attribute access first, then bracket access.
"""
try:
val = getattr(obj, key, None)
if val is not None:
return val
except Exception:
pass
try:
val = obj[key]
if val is not None:
return val
except Exception:
pass
return default
def unwrap_chunks(raw) -> list: def unwrap_chunks(raw) -> list:
""" """
Flatten a Speckle data array into a plain Python list of numbers. Flatten a Speckle data array into a plain Python list of numbers.
Handles two cases: Handles two cases:
1. Already flat list of numbers (after specklepy receive deserializes) 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) 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: if not raw:
return [] return []
@@ -163,7 +135,7 @@ def unwrap_chunks(raw) -> list:
def _resolve_scale(obj, stream_scale: float) -> float: 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") units = _get(obj, "units")
if units and isinstance(units, str): if units and isinstance(units, str):
return _UNIT_SCALES.get(units.lower().strip(), stream_scale) 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: def _is_mesh(item) -> bool:
""" """
Detect if a specklepy object is a Mesh. 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: if item is None:
return False return False
@@ -190,23 +162,39 @@ def _is_mesh(item) -> bool:
return verts is not None and faces is not None 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. Collect Mesh objects from an object's displayValue.
displayValue is always an array per the Speckle schema docs. If an item is not a Mesh (e.g. BrepX, Brep), recursively check
its own displayValue for nested meshes.
""" """
meshes = [] meshes = []
for key in ["displayValue", "@displayValue", "_displayValue"]:
for key in ["displayValue", "@displayValue"]:
display = _get(obj, key) display = _get(obj, key)
if display is None: if display is None:
continue continue
items = display if isinstance(display, list) else [display] items = display if isinstance(display, list) else [display]
for item in items: for item in items:
if item is None:
continue
if _is_mesh(item): if _is_mesh(item):
meshes.append(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: 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 # Fallback: object itself is a Mesh
if not meshes and _is_mesh(obj): if not meshes and _is_mesh(obj):
@@ -227,10 +215,10 @@ def get_display_instances(obj: Base) -> list:
- definitionId: "DEFINITION:{meshAppId}" string - definitionId: "DEFINITION:{meshAppId}" string
- units: "m" - units: "m"
Raw meshes do NOT appear in displayValue in IFCSpeckle exports. Raw meshes do NOT appear in displayValue in IFC->Speckle exports.
""" """
instances = [] instances = []
for key in ["displayValue", "@displayValue"]: for key in ["displayValue", "@displayValue", "_displayValue"]:
display = _get(obj, key) display = _get(obj, key)
if display is None: if display is None:
continue continue
@@ -247,181 +235,6 @@ def get_display_instances(obj: Base) -> list:
return instances 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 # 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. Decode Speckle's run-length encoded face list into vertex index groups.
Format: [n, i0, i1, ..., n, i0, i1, ...] 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 = [] decoded = []
i = 0 i = 0
@@ -462,7 +275,7 @@ def compute_origin(flat_verts: list) -> tuple:
""" """
Compute placement origin from scaled vertex list (mm). Compute placement origin from scaled vertex list (mm).
X, Y = bounding box centroid 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. Single-pass to avoid creating 3 sliced copies of a large list.
""" """
x0 = flat_verts[0] x0 = flat_verts[0]
@@ -505,9 +318,9 @@ def _get_shared(ifc):
def _make_placement(ifc, x: float, y: float, z: float): def _make_placement(ifc, x: float, y: float, z: float):
"""Create an IfcLocalPlacement at absolute world coordinates (metres).""" """Create an IfcLocalPlacement at absolute world coordinates (mm)."""
shared = _get_shared(ifc) shared = _get_shared(ifc)
origin = ifc.createIfcCartesianPoint([x, y, z]) origin = ifc.createIfcCartesianPoint([round(x, 3), round(y, 3), round(z, 3)])
a2p = ifc.createIfcAxis2Placement3D(origin, shared["z_axis"], shared["x_axis"]) a2p = ifc.createIfcAxis2Placement3D(origin, shared["z_axis"], shared["x_axis"])
return ifc.createIfcLocalPlacement(PlacementRelTo=None, RelativePlacement=a2p) return ifc.createIfcLocalPlacement(PlacementRelTo=None, RelativePlacement=a2p)
@@ -524,7 +337,7 @@ def mesh_to_ifc(
material_manager=None, material_manager=None,
) -> tuple: ) -> 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. Creates one IfcPolygonalFaceSet per mesh so each can carry its own material style.
Returns (None, None) if no usable geometry is found. Returns (None, None) if no usable geometry is found.
""" """
@@ -532,14 +345,21 @@ def mesh_to_ifc(
if not meshes: if not meshes:
return None, None return None, None
# Parent object's applicationId -- used as fallback for material lookup
# when inner meshes (e.g. from BrepX) don't have their own applicationId
obj_app_id = _get(obj, "applicationId")
obj_scale = _resolve_scale(obj, scale) obj_scale = _resolve_scale(obj, scale)
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Pass 1: unpack vertices once per mesh, collect all scaled coords # Pass 1: unpack and scale vertices once per mesh, compute origin
# to compute world origin. Cache (verts, ms) for Pass 2. # incrementally without accumulating all vertices in memory.
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
mesh_cache = [] # [(verts_list, ms, scaled)] or None per mesh mesh_cache = [] # [scaled_verts_list] or None per mesh
all_scaled = [] xmin = ymin = zmin = float("inf")
xmax = ymax = float("-inf")
has_verts = False
for mesh in meshes: for mesh in meshes:
raw_verts = _get(mesh, "vertices") or [] raw_verts = _get(mesh, "vertices") or []
verts = unwrap_chunks(raw_verts if isinstance(raw_verts, list) else list(raw_verts)) verts = unwrap_chunks(raw_verts if isinstance(raw_verts, list) else list(raw_verts))
@@ -547,25 +367,34 @@ def mesh_to_ifc(
mesh_cache.append(None) mesh_cache.append(None)
continue continue
ms = _resolve_scale(mesh, obj_scale) ms = _resolve_scale(mesh, obj_scale)
# Pre-scale vertices once, reuse in Pass 2
scaled = [float(v) * ms for v in verts] scaled = [float(v) * ms for v in verts]
mesh_cache.append((verts, ms, scaled)) mesh_cache.append(scaled)
all_scaled.extend(scaled) has_verts = True
if not all_scaled: # Update bounding box from this mesh's scaled vertices
for i in range(0, len(scaled) - 2, 3):
x, y, z = scaled[i], scaled[i + 1], scaled[i + 2]
if x < xmin: xmin = x
if x > xmax: xmax = x
if y < ymin: ymin = y
if y > ymax: ymax = y
if z < zmin: zmin = z
if not has_verts:
return None, None return None, None
ox, oy, oz = compute_origin(all_scaled) ox = (xmin + xmax) / 2.0
oy = (ymin + ymax) / 2.0
oz = zmin
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Pass 2: one faceset per mesh reuse cached verts, only unpack faces # Pass 2: one faceset per mesh -- reuse cached verts, only unpack faces
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
geom_items = [] geom_items = []
for mesh, cached in zip(meshes, mesh_cache): for mesh, scaled in zip(meshes, mesh_cache):
if cached is None: if scaled is None:
continue continue
verts, ms, scaled = cached
raw_faces = _get(mesh, "faces") or [] raw_faces = _get(mesh, "faces") or []
faces_raw = unwrap_chunks(raw_faces if isinstance(raw_faces, list) else list(raw_faces)) faces_raw = unwrap_chunks(raw_faces if isinstance(raw_faces, list) else list(raw_faces))
@@ -575,7 +404,7 @@ def mesh_to_ifc(
try: try:
face_groups = decode_faces(faces_raw) face_groups = decode_faces(faces_raw)
except Exception as e: except Exception as e:
print(f" ⚠️ Face decode error: {e}") print(f" Warning: Face decode error: {e}")
continue continue
# Offset pre-scaled vertices relative to origin (flat list, no tuples) # Offset pre-scaled vertices relative to origin (flat list, no tuples)
@@ -592,8 +421,9 @@ def mesh_to_ifc(
continue continue
# Apply material style to every faceset of this mesh # Apply material style to every faceset of this mesh
# Inner meshes (from BrepX) may lack applicationId -- fall back to parent's
if material_manager: if material_manager:
mesh_app_id = _get(mesh, "applicationId") mesh_app_id = _get(mesh, "applicationId") or obj_app_id
if mesh_app_id: if mesh_app_id:
for fs in mesh_facesets: for fs in mesh_facesets:
material_manager.apply_to_item(fs, str(mesh_app_id)) material_manager.apply_to_item(fs, str(mesh_app_id))
+38
View File
@@ -0,0 +1,38 @@
# =============================================================================
# helpers.py
# Shared utilities used across the exporter modules.
# =============================================================================
def _get(obj, key, default=None):
"""
Safe access for specklepy Base objects, dicts, or any hybrid.
Tries attribute access first, then bracket access.
"""
if obj is None:
return default
if isinstance(obj, dict):
return obj.get(key, default)
try:
val = getattr(obj, key, None)
if val is not None:
return val
except Exception:
pass
try:
val = obj[key]
if val is not None:
return val
except Exception:
pass
return default
# Scale factors → MILLIMETRES (IFC file is declared as mm)
MM_SCALES = {
"mm": 1.0, "millimeter": 1.0, "millimeters": 1.0,
"cm": 10.0, "centimeter": 10.0, "centimeters": 10.0,
"m": 1000.0, "meter": 1000.0, "meters": 1000.0,
"ft": 304.8, "foot": 304.8, "feet": 304.8,
"in": 25.4, "inch": 25.4, "inches": 25.4,
}
+273 -103
View File
@@ -2,17 +2,17 @@
# instances.py # instances.py
# Handles Speckle InstanceProxy objects from both: # Handles Speckle InstanceProxy objects from both:
# #
# FORMAT A Revit connector (our actual use case): # FORMAT A -- Revit connector (our actual use case):
# _units = "mm" # _units = "mm"
# transform = 16 floats, row-major, translation in MM # transform = 16 floats, row-major, translation in MM
# definitionId = 64-char uppercase hex hash (matches object id[:32] in tree) # definitionId = 64-char uppercase hex hash (matches object id[:32] in tree)
# The definition object lives somewhere in the object tree. # The definition object lives somewhere in the object tree.
# #
# FORMAT B speckleifc IFCSpeckle converter: # FORMAT B -- speckleifc IFC->Speckle converter:
# units = "m" # units = "m"
# transform = 16 floats, row-major, translation in METRES # transform = 16 floats, row-major, translation in METRES
# definitionId = "DEFINITION:{meshAppId}" # 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. # We detect the format by the definitionId prefix.
# #
@@ -20,9 +20,14 @@
# sharing the same definition reference a single copy of the geometry. # sharing the same definition reference a single copy of the geometry.
# ============================================================================= # =============================================================================
import hashlib
import math import math
import struct
import ifcopenshell.api
from specklepy.objects.base import Base from specklepy.objects.base import Base
from utils.geometry import _get, unwrap_chunks, decode_faces, _UNIT_SCALES, build_ifc_facesets, _get_shared from utils.helpers import _get, MM_SCALES
from utils.geometry import unwrap_chunks, decode_faces, build_ifc_facesets, _get_shared, _is_mesh
from utils.curves import is_curve, build_curve_rep_map
def is_instance(obj) -> bool: 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. Build a unified definition map that handles both formats.
Returns dict with keys: Returns dict with keys:
"by_id" : {obj_id_lower[:32] object} for Revit format "by_id" : {obj_id_lower[:32] -> object} for Revit format
"by_app_id" : {applicationId_lower object} for Revit format "by_app_id" : {applicationId_lower -> object} for Revit format
"ifc_proxies" : {"DEFINITION:xxx" proxy} for IFC format "ifc_proxies" : {"DEFINITION:xxx" -> proxy} for IFC format
"ifc_meshes" : {meshAppId Mesh} for IFC format "ifc_meshes" : {meshAppId -> Mesh} for IFC format
"definition_sources": set of applicationId (lowercase) that are definition
geometry sources -- these should be skipped during export
""" """
by_id = {} by_id = {}
by_app_id = {} by_app_id = {}
ifc_proxies = {} ifc_proxies = {}
ifc_meshes = {} ifc_meshes = {}
definition_sources = set()
# --- Walk entire tree for Revit format --- # --- Walk entire tree for Revit format ---
_collect_all(root, by_id, by_app_id, depth=0) _collect_all(root, by_id, by_app_id, depth=0)
@@ -61,6 +69,11 @@ def build_definition_map(root: Base) -> dict:
if app_id: if app_id:
ifc_proxies[app_id] = proxy # original case (for IFC format) ifc_proxies[app_id] = proxy # original case (for IFC format)
ifc_proxies[app_id.lower()] = proxy # lowercase (for Revit format) ifc_proxies[app_id.lower()] = proxy # lowercase (for Revit format)
# Collect all objects referenced by this proxy as definition sources
object_ids = _get(proxy, "objects") or []
for oid in (object_ids if isinstance(object_ids, list) else [object_ids]):
if oid:
definition_sources.add(str(oid).lower())
elements = _get(root, "elements") or _get(root, "@elements") or [] elements = _get(root, "elements") or _get(root, "@elements") or []
for child in (elements if isinstance(elements, list) else []): for child in (elements if isinstance(elements, list) else []):
@@ -75,12 +88,14 @@ def build_definition_map(root: Base) -> dict:
print(f" Objects indexed by appId: {len(by_app_id)}") print(f" Objects indexed by appId: {len(by_app_id)}")
print(f" IFC definition proxies: {len(ifc_proxies)}") print(f" IFC definition proxies: {len(ifc_proxies)}")
print(f" IFC definition meshes: {len(ifc_meshes)}") print(f" IFC definition meshes: {len(ifc_meshes)}")
print(f" Definition sources: {len(definition_sources)}")
return { return {
"by_id": by_id, "by_id": by_id,
"by_app_id": by_app_id, "by_app_id": by_app_id,
"ifc_proxies": ifc_proxies, "ifc_proxies": ifc_proxies,
"ifc_meshes": ifc_meshes, "ifc_meshes": ifc_meshes,
"definition_sources": definition_sources,
} }
@@ -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): if obj_id and isinstance(obj_id, str):
key = obj_id.lower() key = obj_id.lower()
by_id[key] = obj 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: if len(key) == 32:
by_id[key] = obj by_id[key] = obj
elif len(key) > 32: 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): if app_id and isinstance(app_id, str):
by_app_id[app_id.lower()] = obj 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"]: "objects", "@objects", "definition", "@definition"]:
try: try:
children = obj[key] children = obj[key]
@@ -116,11 +132,29 @@ def _collect_all(obj, by_id: dict, by_app_id: dict, depth: int):
continue continue
def _get_revit_meshes(definition_id: str, definition_map: dict) -> list: def _get_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: Revit format:
definitionId (64-char hex) InstanceDefinitionProxy.applicationId definitionId (64-char hex) -> InstanceDefinitionProxy.applicationId
proxy.objects[0] is a UUID applicationId find mesh by applicationId proxy.objects[0] is a UUID applicationId -> find mesh by applicationId
Returns (meshes, app_ids) where app_ids are all applicationIds encountered
in the resolution chain (definition objects, geometry objects) for material fallback.
""" """
from utils.geometry import get_display_meshes from utils.geometry import get_display_meshes
@@ -128,40 +162,55 @@ def _get_revit_meshes(definition_id: str, definition_map: dict) -> list:
ifc_proxies = definition_map.get("ifc_proxies", {}) ifc_proxies = definition_map.get("ifc_proxies", {})
proxy = ifc_proxies.get(definition_id) or ifc_proxies.get(definition_id.lower()) proxy = ifc_proxies.get(definition_id) or ifc_proxies.get(definition_id.lower())
if proxy is None: if proxy is None:
return [] return [], []
# Step 2: get the mesh applicationIds from proxy.objects # Step 2: get the mesh applicationIds from proxy.objects
object_ids = _get(proxy, "objects") or [] object_ids = _get(proxy, "objects") or []
if not isinstance(object_ids, list): if not isinstance(object_ids, list):
object_ids = list(object_ids) object_ids = list(object_ids)
# Step 3: look up each mesh by applicationId # Step 3: look up each mesh by applicationId, collecting all encountered app IDs
by_app_id = definition_map.get("by_app_id", {}) by_app_id = definition_map.get("by_app_id", {})
meshes = [] meshes = []
encountered_app_ids = []
for oid in object_ids: for oid in object_ids:
obj = by_app_id.get(str(oid).lower()) obj = by_app_id.get(str(oid).lower())
if obj is not None: if obj is not None:
# Collect this object's applicationId
obj_aid = _get(obj, "applicationId")
if obj_aid:
encountered_app_ids.append(str(obj_aid))
# Also collect applicationIds from displayValue items (BrepX, etc.)
for key in ["displayValue", "@displayValue", "_displayValue"]:
display = _get(obj, key)
if display:
items = display if isinstance(display, list) else [display]
for item in items:
item_aid = _get(item, "applicationId")
if item_aid:
encountered_app_ids.append(str(item_aid))
break
# The found object may itself be a mesh, or contain displayValue meshes # The found object may itself be a mesh, or contain displayValue meshes
found_meshes = get_display_meshes(obj) found_meshes = get_display_meshes(obj)
if found_meshes: if found_meshes:
meshes.extend(found_meshes) meshes.extend(found_meshes)
else: elif _is_mesh(obj):
# It IS the mesh directly
meshes.append(obj) meshes.append(obj)
return meshes return meshes, encountered_app_ids
def _get_ifc_meshes(definition_id: str, definition_map: dict) -> list: def _get_ifc_meshes(definition_id: str, definition_map: dict) -> tuple:
""" """
IFC format: definitionId = "DEFINITION:224058_mat0" IFC format: definitionId = "DEFINITION:224058_mat0"
Look up proxy objects list meshes from ifc_meshes dict. Look up proxy -> objects list -> meshes from ifc_meshes dict.
Returns (meshes, []) -- no extra app_ids needed, mesh applicationIds match directly.
""" """
ifc_proxies = definition_map.get("ifc_proxies", {}) ifc_proxies = definition_map.get("ifc_proxies", {})
ifc_meshes = definition_map.get("ifc_meshes", {}) ifc_meshes = definition_map.get("ifc_meshes", {})
proxy = ifc_proxies.get(definition_id) proxy = ifc_proxies.get(definition_id)
if proxy is None: if proxy is None:
return [] return [], []
object_ids = _get(proxy, "objects") or [] object_ids = _get(proxy, "objects") or []
result = [] result = []
@@ -169,20 +218,20 @@ def _get_ifc_meshes(definition_id: str, definition_map: dict) -> list:
mesh = ifc_meshes.get(str(oid)) mesh = ifc_meshes.get(str(oid))
if mesh is not None: if mesh is not None:
result.append(mesh) result.append(mesh)
return result return result, []
def _resolve_instance_scale(obj, stream_scale: float) -> float: def _resolve_instance_scale(obj, stream_scale: float) -> float:
""" """
Resolve scale for the transform translation. Resolve scale for the transform translation.
Tries bracket access for '_units' (Revit uses underscore). 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"]: for key in ["units", "_units"]:
try: try:
units = obj[key] units = obj[key]
if units and isinstance(units, str): if units and isinstance(units, str):
s = _UNIT_SCALES.get(units.lower().strip()) s = MM_SCALES.get(units.lower().strip())
if s is not None: if s is not None:
return s return s
except Exception: except Exception:
@@ -193,39 +242,62 @@ def _resolve_instance_scale(obj, stream_scale: float) -> float:
# Stats # Stats
_stats = {"found": 0, "not_found": 0} _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. # AND re-scaling the same definition mesh across many instances that share it.
_mesh_data_cache: dict = {} _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. # All instances sharing the same definition reuse one geometry copy.
_rep_map_cache: dict = {} _rep_map_cache: dict = {}
# Cache: geometry content hash -> IfcRepresentationMap
# Enables sharing across different definitionIds that have identical geometry.
_geometry_hash_cache: dict = {}
# Shared identity placement for all instances (keyed by ifc file id) # Shared identity placement for all instances (keyed by ifc file id)
_identity_placement_cache: dict[int, object] = {} _identity_placement_cache: dict[int, object] = {}
_MM_SCALES = { # --------------------------------------------------------------------------- #
"mm": 1.0, "millimeter": 1.0, "millimeters": 1.0, # Geometry content hashing
"cm": 10.0, "centimeter": 10.0, # --------------------------------------------------------------------------- #
"m": 1000.0, "meter": 1000.0, "meters": 1000.0,
"ft": 304.8, "in": 25.4, def _hash_mesh_data(mesh_data_list: list, material_key: str = "") -> str:
} """Compute a content hash from mesh geometry data for deduplication.
mesh_data_list: list of (verts_local, face_groups) tuples
material_key: string identifying the material (included in hash)
Returns: hex digest string
"""
h = hashlib.md5(usedforsecurity=False)
for verts_local, face_groups in mesh_data_list:
# Hash rounded vertices as packed floats (faster than str conversion)
for i in range(0, len(verts_local), 3):
h.update(struct.pack("3f",
round(verts_local[i], 3),
round(verts_local[i+1], 3),
round(verts_local[i+2], 3),
))
# Hash face indices
for face in face_groups:
h.update(struct.pack(f"{len(face)}i", *face))
# Separator between meshes
h.update(b"|")
if material_key:
h.update(material_key.encode())
return h.hexdigest()
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# IfcRepresentationMap builder geometry created once per definition # IfcRepresentationMap builder -- geometry created once per definition
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def _build_rep_map(ifc, body_context, meshes: list, ifc_format: bool, def _collect_mesh_data(meshes: list, ifc_format: bool) -> list:
material_manager=None): """Unpack, scale, and cache mesh vertex/face data.
"""
Build an IfcRepresentationMap from definition meshes.
Geometry is in local coordinates (mm, no instance transform applied).
Returns IfcRepresentationMap or None if no valid geometry.
"""
geom_items = []
Returns list of (mesh_obj, verts_local, face_groups) tuples.
"""
result = []
for mesh in meshes: for mesh in meshes:
mesh_id = _get(mesh, "id") or _get(mesh, "applicationId") mesh_id = _get(mesh, "id") or _get(mesh, "applicationId")
if mesh_id and mesh_id in _mesh_data_cache: if mesh_id and mesh_id in _mesh_data_cache:
@@ -233,48 +305,103 @@ def _build_rep_map(ifc, body_context, meshes: list, ifc_format: bool,
else: else:
raw_verts = _get(mesh, "vertices") or [] raw_verts = _get(mesh, "vertices") or []
raw_faces = _get(mesh, "faces") or [] raw_faces = _get(mesh, "faces") or []
verts = unwrap_chunks(list(raw_verts)) verts = unwrap_chunks(raw_verts if isinstance(raw_verts, list) else list(raw_verts))
faces_raw = unwrap_chunks(list(raw_faces)) faces_raw = unwrap_chunks(raw_faces if isinstance(raw_faces, list) else list(raw_faces))
if not verts or not faces_raw: if not verts or not faces_raw:
continue continue
mesh_units = _get(mesh, "units") or _get(mesh, "_units") or ("m" if ifc_format else "mm") mesh_units = _get(mesh, "units") or _get(mesh, "_units") or ("m" if ifc_format else "mm")
ms = _MM_SCALES.get(mesh_units.lower().strip(), 1.0) ms = MM_SCALES.get(mesh_units.lower().strip(), 1.0)
try: try:
face_groups = decode_faces(faces_raw) face_groups = decode_faces(faces_raw)
except Exception as e: except Exception as e:
print(f" ⚠️ Instance face decode: {e}") print(f" Warning: Instance face decode: {e}")
continue continue
# Scale vertices once and cache the result
verts_local = [float(v) * ms for v in verts] verts_local = [float(v) * ms for v in verts]
if mesh_id: if mesh_id:
_mesh_data_cache[mesh_id] = (verts_local, face_groups) _mesh_data_cache[mesh_id] = (verts_local, face_groups)
mesh_facesets = build_ifc_facesets(ifc, verts_local, face_groups) result.append((mesh, verts_local, face_groups))
return result
def _resolve_material_key(meshes_data: list, material_manager, fallback_app_ids, definition_id) -> str:
"""Build a material cache key string for geometry hashing."""
if not material_manager:
return ""
parts = []
for mesh, _, _ in meshes_data:
mesh_app_id = _get(mesh, "applicationId")
style = material_manager.get_style_with_fallbacks(
primary_app_id=str(mesh_app_id) if mesh_app_id else None,
fallback_app_ids=fallback_app_ids,
definition_id=definition_id,
)
parts.append(str(id(style)) if style else "")
return "|".join(parts)
def _build_rep_map(ifc, body_context, meshes: list, ifc_format: bool,
material_manager=None, fallback_app_ids: list = None,
definition_id: str = None):
"""
Build an IfcRepresentationMap from definition meshes.
Uses content-based hashing to reuse identical geometry across different
definitionIds. Returns IfcRepresentationMap or None if no valid geometry.
"""
# Step 1: Collect and cache raw mesh data (no IFC entities created yet)
meshes_data = _collect_mesh_data(meshes, ifc_format)
if not meshes_data:
return None
# Step 2: Compute content hash to check for identical geometry
mat_key = _resolve_material_key(meshes_data, material_manager, fallback_app_ids, definition_id)
geom_hash = _hash_mesh_data(
[(verts, faces) for _, verts, faces in meshes_data],
material_key=mat_key,
)
if geom_hash in _geometry_hash_cache:
return _geometry_hash_cache[geom_hash]
# Step 3: No match -- build IFC geometry entities
geom_items = []
for mesh, verts_local, face_groups in meshes_data:
mesh_facesets = build_ifc_facesets(ifc, verts_local, face_groups)
if not mesh_facesets: if not mesh_facesets:
continue continue
# Apply material style to each faceset
if material_manager: if material_manager:
mesh_app_id = _get(mesh, "applicationId") 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: for fs in mesh_facesets:
material_manager.apply_to_item(fs, str(mesh_app_id)) try:
ifcopenshell.api.run(
"style.assign_item_style", ifc,
item=fs, style=style,
)
material_manager._apply_count += 1
except Exception:
pass
geom_items.extend(mesh_facesets) geom_items.extend(mesh_facesets)
if not geom_items: if not geom_items:
_geometry_hash_cache[geom_hash] = None
return None return None
# Mapping origin = identity (local coords origin) — reuse shared origin
shared = _get_shared(ifc) shared = _get_shared(ifc)
a2p = ifc.createIfcAxis2Placement3D(shared["origin_0"], None, None) a2p = ifc.createIfcAxis2Placement3D(shared["origin_0"], None, None)
# The mapped representation holds the actual geometry
mapped_rep = ifc.createIfcShapeRepresentation( mapped_rep = ifc.createIfcShapeRepresentation(
ContextOfItems=body_context, ContextOfItems=body_context,
RepresentationIdentifier="Body", RepresentationIdentifier="Body",
@@ -282,41 +409,57 @@ def _build_rep_map(ifc, body_context, meshes: list, ifc_format: bool,
Items=geom_items, Items=geom_items,
) )
return ifc.createIfcRepresentationMap(a2p, mapped_rep) rep_map = ifc.createIfcRepresentationMap(a2p, mapped_rep)
_geometry_hash_cache[geom_hash] = rep_map
return rep_map
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Transform IfcCartesianTransformationOperator3D # Transform -> IfcCartesianTransformationOperator3D
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def _vec_magnitude(x, y, z): def _vec_magnitude(x, y, z):
return math.sqrt(x*x + y*y + z*z) return math.sqrt(x*x + y*y + z*z)
# Cache: rounded direction tuple -> IfcDirection entity (keyed by ifc file id)
_direction_cache: dict[int, dict] = {}
def _get_or_create_direction(ifc, dx, dy, dz):
"""Return a cached IfcDirection or create and cache a new one."""
fid = id(ifc)
if fid not in _direction_cache:
_direction_cache[fid] = {}
cache = _direction_cache[fid]
# Round to 6 decimals -- sufficient for unit vectors
key = (round(dx, 6), round(dy, 6), round(dz, 6))
if key not in cache:
cache[key] = ifc.createIfcDirection([key[0], key[1], key[2]])
return cache[key]
def _make_transform_operator(ifc, t: list, ts: float): def _make_transform_operator(ifc, t: list, ts: float):
""" """
Convert a row-major 4x4 matrix + translation scale into an Convert a row-major 4x4 matrix + translation scale into an
IfcCartesianTransformationOperator3DnonUniform. IfcCartesianTransformationOperator3DnonUniform.
t: 16 floats, row-major [r00,r01,r02,tx, r10,r11,r12,ty, r20,r21,r22,tz, 0,0,0,1] t: 16 floats, row-major [r00,r01,r02,tx, r10,r11,r12,ty, r20,r21,r22,tz, 0,0,0,1]
ts: scale factor for translation components (e.g. 1000.0 for mmm) ts: scale factor for translation components (e.g. 1000.0 for m->mm)
The matrix acts as: p' = M * p + translation, where M rows are:
row0 = (t[0], t[1], t[2])
row1 = (t[4], t[5], t[6])
row2 = (t[8], t[9], t[10])
IfcCartesianTransformationOperator axes represent the COLUMNS of M: IfcCartesianTransformationOperator axes represent the COLUMNS of M:
Axis1 = column 0 = where local X maps (t[0], t[4], t[8]) Axis1 = column 0 = where local X maps -> (t[0], t[4], t[8])
Axis2 = column 1 = where local Y maps (t[1], t[5], t[9]) Axis2 = column 1 = where local Y maps -> (t[1], t[5], t[9])
Axis3 = column 2 = where local Z maps (t[2], t[6], t[10]) Axis3 = column 2 = where local Z maps -> (t[2], t[6], t[10])
Always uses the non-uniform variant with explicit Axis3 to ensure
correct orientation for all transform types (mirrors, non-orthogonal, etc.).
Returns the IFC entity, or None if the transform is degenerate. Returns the IFC entity, or None if the transform is degenerate.
""" """
# Extract COLUMNS of the 3x3 rotation/scale sub-matrix # Extract COLUMNS of the 3x3 rotation/scale sub-matrix
ax1 = (float(t[0]), float(t[4]), float(t[8])) # column 0: X-axis direction ax1 = (float(t[0]), float(t[4]), float(t[8]))
ax2 = (float(t[1]), float(t[5]), float(t[9])) # column 1: Y-axis direction ax2 = (float(t[1]), float(t[5]), float(t[9]))
ax3 = (float(t[2]), float(t[6]), float(t[10])) # column 2: Z-axis direction ax3 = (float(t[2]), float(t[6]), float(t[10]))
s1 = _vec_magnitude(*ax1) s1 = _vec_magnitude(*ax1)
s2 = _vec_magnitude(*ax2) s2 = _vec_magnitude(*ax2)
@@ -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: if s1 < 1e-10 or s2 < 1e-10 or s3 < 1e-10:
return None # degenerate transform return None # degenerate transform
# Normalized direction vectors # Normalized direction vectors -- reuse cached IfcDirection entities
d1 = ifc.createIfcDirection([ax1[0]/s1, ax1[1]/s1, ax1[2]/s1]) d1 = _get_or_create_direction(ifc, ax1[0]/s1, ax1[1]/s1, ax1[2]/s1)
d2 = ifc.createIfcDirection([ax2[0]/s2, ax2[1]/s2, ax2[2]/s2]) d2 = _get_or_create_direction(ifc, ax2[0]/s2, ax2[1]/s2, ax2[2]/s2)
d3 = ifc.createIfcDirection([ax3[0]/s3, ax3[1]/s3, ax3[2]/s3]) d3 = _get_or_create_direction(ifc, ax3[0]/s3, ax3[1]/s3, ax3[2]/s3)
# Translation, scaled to mm # Translation, scaled and rounded to mm
tx = float(t[3]) * ts tx = round(float(t[3]) * ts, 3)
ty = float(t[7]) * ts ty = round(float(t[7]) * ts, 3)
tz = float(t[11]) * ts tz = round(float(t[11]) * ts, 3)
origin = ifc.createIfcCartesianPoint([tx, ty, tz]) origin = ifc.createIfcCartesianPoint([tx, ty, tz])
# Use non-uniform variant to handle mirrors and non-uniform scale # Round scales for cleaner output
s1 = round(s1, 6)
s2 = round(s2, 6)
s3 = round(s3, 6)
return ifc.createIfcCartesianTransformationOperator3DnonUniform( return ifc.createIfcCartesianTransformationOperator3DnonUniform(
d1, # Axis1 d1, # Axis1
d2, # Axis2 d2, # Axis2
origin, # LocalOrigin origin, # LocalOrigin
s1, # Scale s1, # Scale
d3, # Axis3 d3, # Axis3 (explicit -- never derived)
s2, # Scale2 s2, # Scale2
s3, # Scale3 s3, # Scale3
) )
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Main conversion IfcMappedItem approach # Main conversion -- IfcMappedItem approach
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def instance_to_ifc(ifc, body_context, obj: Base, definition_map: dict, def instance_to_ifc(ifc, body_context, obj: Base, definition_map: dict,
scale: float = 1.0, material_manager=None): 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, Strategy: create geometry once per definition as an IfcRepresentationMap,
then reference it via IfcMappedItem + IfcCartesianTransformationOperator3D 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 "" definition_id = _get(obj, "definitionId") or ""
ifc_format = _is_ifc_format(definition_id) 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) # Revit format transform is already in mm (same as IFC file units)
ts = 1000.0 if ifc_format else _resolve_instance_scale(obj, scale) 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) fid = id(ifc)
if fid not in _identity_placement_cache: if fid not in _identity_placement_cache:
shared = _get_shared(ifc) 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) --- # --- Get or build IfcRepresentationMap (cached per definition_id) ---
if definition_id not in _rep_map_cache: if definition_id not in _rep_map_cache:
if ifc_format: if ifc_format:
meshes = _get_ifc_meshes(definition_id, definition_map) meshes, extra_app_ids = _get_ifc_meshes(definition_id, definition_map)
else: else:
meshes = _get_revit_meshes(definition_id, definition_map) meshes, extra_app_ids = _get_revit_meshes(definition_id, definition_map)
if not meshes: # Build fallback app_id list: instance's own + definition chain IDs
_stats["not_found"] += 1 instance_app_id = _get(obj, "applicationId")
_rep_map_cache[definition_id] = None fallback_ids = []
return None, placement if instance_app_id:
fallback_ids.append(str(instance_app_id))
fallback_ids.extend(extra_app_ids)
_stats["found"] += 1 rep_map_result = None
_rep_map_cache[definition_id] = _build_rep_map( if meshes:
ifc, body_context, meshes, ifc_format, material_manager 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: else:
# Track stats even for cached definitions # Track stats even for cached definitions
if _rep_map_cache[definition_id] is not None: 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 "" definition_id = _get(obj, "definitionId") or ""
if not definition_id: if not definition_id:
return None 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 [] def is_definition_source(obj, definition_map: dict) -> bool:
if not isinstance(object_ids, list): """Return True if this object is a definition geometry source (should not be exported standalone)."""
object_ids = list(object_ids) app_id = _get(obj, "applicationId")
if not object_ids: if not app_id:
return None return False
return str(app_id).lower() in definition_map.get("definition_sources", set())
by_app_id = definition_map.get("by_app_id", {})
source = by_app_id.get(str(object_ids[0]).lower())
return source
def print_instance_stats(): def print_instance_stats():
total = _stats["found"] + _stats["not_found"] total = _stats["found"] + _stats["not_found"]
print(f" Instance resolution: {_stats['found']}/{total} definitions found") print(f" Instance resolution: {_stats['found']}/{total} definitions found")
if _stats["not_found"] > 0: if _stats["not_found"] > 0:
print(f" ⚠️ {_stats['not_found']} instances had no definition geometry") print(f" Warning: {_stats['not_found']} instances had no definition geometry")
unique_defs = len(_rep_map_cache)
unique_geom = len([v for v in _geometry_hash_cache.values() if v is not None])
if unique_defs > unique_geom:
print(f" Geometry dedup: {unique_defs} definitions -> {unique_geom} unique geometries")
def reset_caches(): def reset_caches():
"""Reset module-level caches (call at start of each export run).""" """Reset module-level caches (call at start of each export run)."""
_mesh_data_cache.clear() _mesh_data_cache.clear()
_rep_map_cache.clear() _rep_map_cache.clear()
_geometry_hash_cache.clear()
_identity_placement_cache.clear() _identity_placement_cache.clear()
_direction_cache.clear()
_stats["found"] = 0 _stats["found"] = 0
_stats["not_found"] = 0 _stats["not_found"] = 0
+19
View File
@@ -64,6 +64,7 @@ class MaterialManager:
self._style_map: dict[str, object] = {} self._style_map: dict[str, object] = {}
# name → IfcSurfaceStyle (cache to avoid duplicates) # name → IfcSurfaceStyle (cache to avoid duplicates)
self._style_cache: dict[str, object] = {} self._style_cache: dict[str, object] = {}
self._apply_count: int = 0
self._build(root) self._build(root)
def _build(self, root: Base): def _build(self, root: Base):
@@ -135,6 +136,24 @@ class MaterialManager:
self._style_map[key] = style self._style_map[key] = style
return 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): def apply_to_item(self, item, mesh_app_id: str):
"""Assign the material style to a single IFC geometry item (e.g. IfcPolygonalFaceSet).""" """Assign the material style to a single IFC geometry item (e.g. IfcPolygonalFaceSet)."""
style = self.get_style(mesh_app_id) style = self.get_style(mesh_app_id)