update instancing
build and deploy Speckle functions / publish-automate-function-version (push) Has been cancelled
build and deploy Speckle functions / publish-automate-function-version (push) Has been cancelled
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
+357
@@ -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)
|
||||
+78
-248
@@ -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
|
||||
return rep, placement
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
+278
-108
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user