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

This commit is contained in:
NLSA
2026-03-11 22:53:34 +01:00
parent 63082a881c
commit f7aa6c29da
9 changed files with 567609 additions and 354 deletions
+60 -26
View File
@@ -6,16 +6,16 @@ import utils.config as config
from utils.materials import MaterialManager
from utils.traversal import traverse, print_tree
from utils.mapper import classify
from utils.mapper import classify, get_predefined_type, reset_caches as reset_mapper_caches
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
from utils.properties import write_properties, write_common_properties, build_element_name, get_element_tag, get_ifc_guid
from utils.instances import is_instance, instance_to_ifc, build_definition_map, print_instance_stats, get_definition_object
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
SPATIAL_STRUCTURE_TYPES = {
"IfcSite", "IfcBuilding", "IfcBuildingStorey",
"IfcBuilding", "IfcBuildingStorey",
"IfcSpace", "IfcExternalSpatialElement", "IfcSpatialZone",
"IfcGrid", "IfcAnnotation",
}
@@ -60,6 +60,10 @@ def automate_function(
print(" Speckle -> IFC4.3 Exporter")
print("=" * 60)
# Reset caches from any previous run
reset_props_caches()
reset_mapper_caches()
# ------------------------------------------------------------------ #
# 1. Receive
# ------------------------------------------------------------------ #
@@ -78,7 +82,7 @@ def automate_function(
# ------------------------------------------------------------------ #
# 3. Set up IFC
# ------------------------------------------------------------------ #
ifc, building, body_context = create_ifc_scaffold()
ifc, _site, building, body_context = create_ifc_scaffold()
storey_manager = StoreyManager(ifc, building)
# ------------------------------------------------------------------ #
@@ -113,12 +117,20 @@ def automate_function(
# Path A: Instance object (has transform + definitionId, no displayValue)
# ------------------------------------------------------------------ #
if is_instance(obj):
# Instances may lack category info — inherit from definition object
if ifc_class == "IfcBuildingElementProxy":
def_obj = get_definition_object(obj, definition_map)
if def_obj:
ifc_class = classify(def_obj, category_name)
rep, placement = instance_to_ifc(ifc, body_context, obj, definition_map, scale=scale, material_manager=material_manager)
if not rep:
no_geometry += 1
continue
element = _create_element(ifc, ifc_class, name, rep, placement, storey,
tag=get_element_tag(obj), guid=get_ifc_guid(obj))
tag=get_element_tag(obj), guid=get_ifc_guid(obj),
object_type=getattr(obj, "type", None),
predefined_type=get_predefined_type(obj))
write_properties(ifc, element, obj, ifc_class=ifc_class, category_name=category_name)
type_manager.assign(element, obj, ifc_class)
instance_count += 1
@@ -135,7 +147,9 @@ def automate_function(
rep, placement = mesh_to_ifc(ifc, body_context, obj, scale=scale, material_manager=material_manager)
if rep:
element = _create_element(ifc, ifc_class, name, rep, placement, storey,
tag=get_element_tag(obj), guid=get_ifc_guid(obj))
tag=get_element_tag(obj), guid=get_ifc_guid(obj),
object_type=getattr(obj, "type", None),
predefined_type=get_predefined_type(obj))
write_properties(ifc, element, obj, ifc_class=ifc_class, category_name=category_name)
type_manager.assign(element, obj, ifc_class)
total += 1
@@ -152,7 +166,10 @@ def automate_function(
no_geometry += 1
continue
inst_element = _create_element(
ifc, ifc_class, name, inst_rep, inst_placement, storey
ifc, ifc_class, name, inst_rep, inst_placement, storey,
tag=get_element_tag(obj), guid=None,
object_type=getattr(obj, "type", None),
predefined_type=get_predefined_type(obj),
)
write_properties(ifc, inst_element, obj, ifc_class=ifc_class, category_name=category_name)
type_manager.assign(inst_element, obj, ifc_class)
@@ -179,12 +196,12 @@ def automate_function(
ifc.write(ifc_filename)
print(f"\n💾 IFC file written: {ifc_filename}")
try:
automate_context.mark_run_success("Success! You can download the IFC file below.")
automate_context.store_file_result(f"./{ifc_filename}")
except Exception as 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}")
# try:
# automate_context.mark_run_success("Success! You can download the IF file below.")
# automate_context.store_file_result(f"./{ifc_filename}")
# except Exception as 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}")
print(f" Export complete!")
@@ -197,15 +214,23 @@ def automate_function(
print_instance_stats()
print(f"{'=' * 60}\n")
def _create_element(ifc, ifc_class, name, rep, placement, storey, tag=None, guid=None):
def _create_element(ifc, ifc_class, name, rep, placement, storey,
tag=None, guid=None, object_type=None, predefined_type=None):
"""Helper: create an IFC element, assign geometry + placement + container."""
element = ifcopenshell.api.run(
"root.create_entity", ifc,
ifc_class=ifc_class,
name=str(name),
)
kwargs = {"ifc_class": ifc_class, "name": str(name)}
if predefined_type:
kwargs["predefined_type"] = predefined_type
element = ifcopenshell.api.run("root.create_entity", ifc, **kwargs)
if tag:
element.Tag = str(tag)
try:
element.Tag = str(tag)
except AttributeError:
pass
if object_type:
try:
element.ObjectType = str(object_type)
except AttributeError:
pass
if guid:
try:
element.GlobalId = guid
@@ -221,11 +246,20 @@ def _create_element(ifc, ifc_class, name, rep, placement, storey, tag=None, guid
else:
element.ObjectPlacement = _make_placement(ifc, 0.0, 0.0, 0.0)
ifcopenshell.api.run(
"spatial.assign_container", ifc,
relating_structure=storey,
products=[element],
)
# IfcSite is a spatial structure element — can't use spatial.assign_container.
# Use aggregate.assign_object to nest it under the storey instead.
if ifc_class == "IfcSite":
ifcopenshell.api.run(
"aggregate.assign_object", ifc,
relating_object=storey,
products=[element],
)
else:
ifcopenshell.api.run(
"spatial.assign_container", ifc,
relating_structure=storey,
products=[element],
)
return element
# make sure to call the function with the executor
File diff suppressed because one or more lines are too long
+83 -133
View File
@@ -1,16 +1,17 @@
# =============================================================================
# geometry.py
# Converts Speckle DataObject geometry → IFC IfcFacetedBrep + IfcLocalPlacement
# Converts Speckle DataObject geometry → IFC IfcPolygonalFaceSet + IfcLocalPlacement
#
# Key facts:
# - After specklepy receive(), vertices and faces are FLAT Python lists
# - displayValue is an array of Mesh objects
# - Units are in mm (for Revit), scale to metres for IFC
# - Vertices are in absolute world coordinates
# - Uses IfcPolygonalFaceSet (indexed vertices) instead of IfcFacetedBrep
# for compact output — each vertex stored once, not once per face.
# =============================================================================
import ifcopenshell
from collections import defaultdict
from specklepy.objects.base import Base
@@ -25,7 +26,7 @@ _UNIT_SCALES = {
# --------------------------------------------------------------------------- #
# Geometry validation helpers (GEM111 + BRP002 fixes)
# Geometry validation helpers (GEM111 fix)
# --------------------------------------------------------------------------- #
# Minimum distance in mm below which two vertices are considered identical (GEM111).
@@ -37,126 +38,75 @@ def snap_coord(v: float) -> int:
return round(v / _VERTEX_MERGE_TOL)
def _find_connected_components(snapped_faces: list) -> list:
def build_ifc_facesets(ifc, verts_scaled: list, face_groups: list) -> list:
"""
Union-Find: group face indices into connected components.
Two faces are connected if they share an edge (pair of snapped vertex keys).
Returns list of components, each a list of face indices.
Build a list of IfcPolygonalFaceSet from scaled (x,y,z) vertices and face index groups.
BRP002 requires all faces in an IfcClosedShell to form ONE component.
If multiple components exist, each must become a separate IfcClosedShell.
"""
n = len(snapped_faces)
if n == 0:
return []
parent = list(range(n))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(a, b):
parent[find(a)] = find(b)
# Map each edge to the first face that used it, then union subsequent faces
edge_to_face = {}
for fi, keys in enumerate(snapped_faces):
for i in range(len(keys)):
edge = frozenset([keys[i], keys[(i + 1) % len(keys)]])
if edge in edge_to_face:
union(fi, edge_to_face[edge])
else:
edge_to_face[edge] = fi
groups: dict = defaultdict(list)
for fi in range(n):
groups[find(fi)].append(fi)
return list(groups.values())
def build_ifc_breps(ifc, verts_scaled: list, face_groups: list) -> list:
"""
Build a list of IfcFacetedBrep from scaled (x,y,z) vertices and face index groups.
Uses IfcCartesianPointList3D + IfcIndexedPolygonalFace for compact output.
Vertices are deduplicated via snap grid so each unique position is stored once.
GEM111 fix: skip faces with near-duplicate vertices (snapped to same grid cell).
BRP002 fix: split faces into connected components; each component → its own
IfcClosedShell → IfcFacetedBrep so every shell is arc-wise connected.
verts_scaled: flat list of already-scaled floats [x0,y0,z0, x1,y1,z1, ...]
face_groups: list of index lists [[i,j,k], [i,j,k,l], ...]
Returns: list of IfcFacetedBrep (one per connected component, never empty).
Returns: list of IfcPolygonalFaceSet (typically one, empty on failure).
"""
# Pass 1: validate faces and build snapped key lists for connectivity analysis
valid_faces = [] # list of (pts_raw, snapped_keys)
# Build deduplicated vertex list via snap grid
snap_to_idx = {} # snap_key → 0-based index in deduped_verts
deduped_verts = [] # [(x, y, z), ...]
def get_vertex_index(x, y, z):
key = (snap_coord(x), snap_coord(y), snap_coord(z))
if key in snap_to_idx:
return snap_to_idx[key], key
idx = len(deduped_verts)
snap_to_idx[key] = idx
deduped_verts.append((x, y, z))
return idx, key
# Validate faces and remap indices to deduplicated vertex list
valid_faces = [] # list of [idx0, idx1, idx2, ...] (0-based into deduped_verts)
for indices in face_groups:
try:
pts_raw = []
snapped = []
remapped = []
seen_snaps = set()
degenerate = False
seen = set()
for i in indices:
x = float(verts_scaled[i * 3])
y = float(verts_scaled[i * 3 + 1])
z = float(verts_scaled[i * 3 + 2])
key = (snap_coord(x), snap_coord(y), snap_coord(z))
if key in seen:
idx, snap_key = get_vertex_index(x, y, z)
if snap_key in seen_snaps:
degenerate = True
break
seen.add(key)
pts_raw.append((x, y, z))
snapped.append(key)
seen_snaps.add(snap_key)
remapped.append(idx)
if degenerate or len(pts_raw) < 3:
if degenerate or len(remapped) < 3:
continue
valid_faces.append((pts_raw, snapped))
valid_faces.append(remapped)
except Exception:
continue
if not valid_faces:
if not valid_faces or not deduped_verts:
return []
# Pass 2: split into connected components (BRP002)
snapped_only = [f[1] for f in valid_faces]
components = _find_connected_components(snapped_only)
# Pass 3: build one IfcFacetedBrep per component
breps = []
for component_indices in components:
# Build IFC entities
try:
point_list = ifc.createIfcCartesianPointList3D(
[list(v) for v in deduped_verts]
)
ifc_faces = []
for fi in component_indices:
pts_raw, _ = valid_faces[fi]
try:
pts = [ifc.createIfcCartesianPoint([x, y, z]) for x, y, z in pts_raw]
poly = ifc.createIfcPolyLoop(pts)
bound = ifc.createIfcFaceOuterBound(poly, True)
ifc_faces.append(ifc.createIfcFace([bound]))
except Exception:
continue
for face_indices in valid_faces:
# IfcIndexedPolygonalFace uses 1-based indices
coord_index = [idx + 1 for idx in face_indices]
ifc_faces.append(ifc.createIfcIndexedPolygonalFace(coord_index))
if not ifc_faces:
continue
shell = ifc.createIfcClosedShell(ifc_faces)
breps.append(ifc.createIfcFacetedBrep(shell))
return breps
# Keep old name as alias so instances.py import works unchanged
def build_ifc_faces(ifc, verts_scaled: list, face_groups: list) -> list:
"""Legacy wrapper — returns flat list of IfcFace (no connectivity splitting)."""
# Used only as a fallback; callers should prefer build_ifc_breps directly.
breps = build_ifc_breps(ifc, verts_scaled, face_groups)
# Return the faces from all shells combined (for callers that need face lists)
faces = []
for brep in breps:
faces.extend(brep.Outer.CfsFaces)
return faces
faceset = ifc.createIfcPolygonalFaceSet(point_list, None, ifc_faces, None)
return [faceset]
except Exception:
return []
# --------------------------------------------------------------------------- #
@@ -189,31 +139,32 @@ def unwrap_chunks(raw) -> list:
Handles two cases:
1. Already flat list of numbers (after specklepy receive deserializes)
[3, 0, 1, 2, 3, ...] returned as-is
→ returned as-is (fast path)
2. List of DataChunk objects (raw from server before deserialization)
→ each chunk's .data list is concatenated
Both cases are handled so this function is always safe to call.
"""
if not raw:
return []
# Fast path: if first item is a number, assume all items are numbers
first = raw[0]
if isinstance(first, (int, float)):
return raw
# Slow path: DataChunk objects or mixed content
result = []
for item in raw:
if item is None:
continue
# Plain number — already flat
if isinstance(item, (int, float)):
result.append(item)
continue
# DataChunk — unwrap .data
speckle_type = getattr(item, "speckle_type", "") or ""
if "DataChunk" in speckle_type:
chunk_data = _get(item, "data") or _get(item, "@data")
if chunk_data:
result.extend(list(chunk_data))
else:
# Unknown — try iterating (handles nested lists)
try:
result.extend(list(item))
except Exception:
@@ -318,17 +269,18 @@ def decode_faces(faces_raw: list) -> list:
"""
decoded = []
i = 0
while i < len(faces_raw):
total = len(faces_raw)
while i < total:
n = int(faces_raw[i])
if n == 0:
n = 3
elif n == 1:
n = 4
end = i + 1 + n
if end > len(faces_raw):
if end > total:
break
indices = [int(faces_raw[i + 1 + j]) for j in range(n)]
decoded.append(indices)
# Direct slice is faster than list comprehension with int()
decoded.append([int(v) for v in faces_raw[i + 1:end]])
i = end
return decoded
@@ -374,7 +326,7 @@ def mesh_to_ifc(
) -> tuple:
"""
Convert a Speckle DataObject → (IfcShapeRepresentation, IfcLocalPlacement).
Creates one IfcFacetedBrep 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.
"""
meshes = get_display_meshes(obj)
@@ -387,7 +339,7 @@ def mesh_to_ifc(
# Pass 1: unpack vertices once per mesh, collect all scaled coords
# to compute world origin. Cache (verts, ms) for Pass 2.
# ------------------------------------------------------------------ #
mesh_cache = [] # [(verts_list, ms)] or None per mesh
mesh_cache = [] # [(verts_list, ms, scaled)] or None per mesh
all_scaled = []
for mesh in meshes:
raw_verts = _get(mesh, "vertices") or []
@@ -396,13 +348,10 @@ def mesh_to_ifc(
mesh_cache.append(None)
continue
ms = _resolve_scale(mesh, obj_scale)
mesh_cache.append((verts, ms))
for i in range(0, len(verts) - 2, 3):
all_scaled.extend([
float(verts[i]) * ms,
float(verts[i+1]) * ms,
float(verts[i+2]) * ms,
])
# 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)
if not all_scaled:
return None, None
@@ -410,14 +359,14 @@ def mesh_to_ifc(
ox, oy, oz = compute_origin(all_scaled)
# ------------------------------------------------------------------ #
# Pass 2: one brep per mesh — reuse cached verts, only unpack faces
# Pass 2: one faceset per mesh — reuse cached verts, only unpack faces
# ------------------------------------------------------------------ #
brep_items = []
geom_items = []
for mesh, cached in zip(meshes, mesh_cache):
if cached is None:
continue
verts, ms = cached
verts, ms, scaled = cached
raw_faces = _get(mesh, "faces") or []
faces_raw = unwrap_chunks(list(raw_faces))
@@ -430,28 +379,29 @@ def mesh_to_ifc(
print(f" ⚠️ Face decode error: {e}")
continue
# Build pre-scaled vertex list (relative to origin) for this mesh
verts_scaled = []
for vi in range(0, len(verts) - 2, 3):
verts_scaled.append(float(verts[vi]) * ms - ox)
verts_scaled.append(float(verts[vi+1]) * ms - oy)
verts_scaled.append(float(verts[vi+2]) * ms - oz)
# Offset pre-scaled vertices relative to origin
n = len(scaled)
verts_scaled = [0.0] * n
for vi in range(0, n - 2, 3):
verts_scaled[vi] = scaled[vi] - ox
verts_scaled[vi + 1] = scaled[vi + 1] - oy
verts_scaled[vi + 2] = scaled[vi + 2] - oz
mesh_breps = build_ifc_breps(ifc, verts_scaled, face_groups)
mesh_facesets = build_ifc_facesets(ifc, verts_scaled, face_groups)
if not mesh_breps:
if not mesh_facesets:
continue
# Apply material style to every component brep of this mesh
# Apply material style to every faceset of this mesh
if material_manager:
mesh_app_id = _get(mesh, "applicationId")
if mesh_app_id:
for brep in mesh_breps:
material_manager.apply_to_item(brep, str(mesh_app_id))
for fs in mesh_facesets:
material_manager.apply_to_item(fs, str(mesh_app_id))
brep_items.extend(mesh_breps)
geom_items.extend(mesh_facesets)
if not brep_items:
if not geom_items:
return None, None
# ------------------------------------------------------------------ #
@@ -460,8 +410,8 @@ def mesh_to_ifc(
rep = ifc.createIfcShapeRepresentation(
ContextOfItems=body_context,
RepresentationIdentifier="Body",
RepresentationType="Brep",
Items=brep_items,
RepresentationType="Tessellation",
Items=geom_items,
)
placement = _make_placement(ifc, ox, oy, oz)
+223 -104
View File
@@ -15,10 +15,14 @@
# Definition geometry lives in root → Collection("definitionGeometry")
#
# We detect the format by the definitionId prefix.
#
# Performance: uses IfcRepresentationMap + IfcMappedItem so that all instances
# sharing the same definition reference a single copy of the geometry.
# =============================================================================
import math
from specklepy.objects.base import Base
from utils.geometry import _get, unwrap_chunks, decode_faces, _UNIT_SCALES, build_ifc_breps
from utils.geometry import _get, unwrap_chunks, decode_faces, _UNIT_SCALES, build_ifc_facesets
def is_instance(obj) -> bool:
@@ -212,36 +216,18 @@ def _resolve_instance_scale(obj, stream_scale: float) -> float:
return stream_scale
def _parse_transform(t: list, scale: float) -> tuple:
"""
Row-major 4x4 matrix.
Translation at t[3], t[7], t[11] — scaled to metres.
Local X axis = row 0, Local Z axis = row 2.
"""
tx = float(t[3]) * scale
ty = float(t[7]) * scale
tz = float(t[11]) * scale
x_axis = (float(t[0]), float(t[1]), float(t[2]))
z_axis = (float(t[8]), float(t[9]), float(t[10]))
return (tx, ty, tz), x_axis, z_axis
def _make_ifc_placement(ifc, tx, ty, tz, x_axis, z_axis):
origin = ifc.createIfcCartesianPoint([tx, ty, tz])
x_dir = ifc.createIfcDirection(list(x_axis))
z_dir = ifc.createIfcDirection(list(z_axis))
a2p = ifc.createIfcAxis2Placement3D(origin, z_dir, x_dir)
return ifc.createIfcLocalPlacement(PlacementRelTo=None, RelativePlacement=a2p)
# Stats
_stats = {"found": 0, "not_found": 0}
_dbg_cnt = [0]
# Cache: mesh id → (verts_flat, faces_raw_flat, ms) to avoid re-unpacking
# Cache: mesh id → (verts_flat, face_groups, ms) to avoid re-unpacking
# the same definition mesh across many instances that share it.
_mesh_data_cache: dict = {}
# Cache: definition_id → IfcRepresentationMap (or None if no geometry)
# All instances sharing the same definition reuse one geometry copy.
_rep_map_cache: dict = {}
_MM_SCALES = {
"mm": 1.0, "millimeter": 1.0, "millimeters": 1.0,
@@ -251,70 +237,19 @@ _MM_SCALES = {
}
def _apply_transform(t: list, vx: float, vy: float, vz: float, ts: float) -> tuple:
# --------------------------------------------------------------------------- #
# IfcRepresentationMap builder — geometry created once per definition
# --------------------------------------------------------------------------- #
def _build_rep_map(ifc, body_context, meshes: list, ifc_format: bool,
material_manager=None):
"""
Apply a row-major 4x4 transform to a single vertex.
ts = scale factor applied to the translation components only (not rotation).
For Revit mm data with IFC in mm: ts=1.0 (no conversion).
For IFC-format transforms (metres): ts=1000.0 (m→mm).
Rotation components are dimensionless and never scaled.
Build an IfcRepresentationMap from definition meshes.
Geometry is in local coordinates (mm, no instance transform applied).
Returns IfcRepresentationMap or None if no valid geometry.
"""
x = t[0]*vx + t[1]*vy + t[2]*vz + t[3] * ts
y = t[4]*vx + t[5]*vy + t[6]*vz + t[7] * ts
z = t[8]*vx + t[9]*vy + t[10]*vz + t[11] * ts
return x, y, z
geom_items = []
def instance_to_ifc(ifc, body_context, obj: Base, definition_map: dict,
scale: float = 1.0, material_manager=None):
"""
Convert a Speckle InstanceProxy → (IfcShapeRepresentation, IfcLocalPlacement).
Strategy: BAKE the full 4x4 transform into every vertex (world coordinates).
Creates one IfcFacetedBrep per definition mesh so each can carry its own
material style via renderMaterialProxies.
"""
transform_raw = _get(obj, "transform")
if not transform_raw:
return None, None
t = list(transform_raw)
if len(t) != 16:
return None, None
definition_id = _get(obj, "definitionId") or ""
ifc_format = _is_ifc_format(definition_id)
# Translation scale: IFC format transform is in metres → convert to mm
# Revit format transform is already in mm (same as IFC file units)
ts = 1000.0 if ifc_format else _resolve_instance_scale(obj, scale)
if _dbg_cnt[0] < 6:
_dbg_cnt[0] += 1
fmt = "IFC" if ifc_format else "Revit"
x_axis = (round(t[0],2), round(t[1],2), round(t[2],2))
z_axis = (round(t[8],2), round(t[9],2), round(t[10],2))
print(f" [INST {_dbg_cnt[0]} {fmt}] {definition_id[:40]}")
print(f" t[3]={t[3]:.1f} t[7]={t[7]:.1f} t[11]={t[11]:.1f} x={x_axis} z={z_axis}")
# World-origin placement (geometry is baked to world coords)
origin = ifc.createIfcCartesianPoint([0.0, 0.0, 0.0])
a2p = ifc.createIfcAxis2Placement3D(origin, None, None)
placement = ifc.createIfcLocalPlacement(PlacementRelTo=None, RelativePlacement=a2p)
# Get definition meshes
if ifc_format:
meshes = _get_ifc_meshes(definition_id, definition_map)
else:
meshes = _get_revit_meshes(definition_id, definition_map)
if not meshes:
_stats["not_found"] += 1
return None, placement
_stats["found"] += 1
# One brep per mesh so each can have its own material style
brep_items = []
for mesh in meshes:
mesh_id = _get(mesh, "id") or _get(mesh, "applicationId")
if mesh_id and mesh_id in _mesh_data_cache:
@@ -339,45 +274,229 @@ def instance_to_ifc(ifc, body_context, obj: Base, definition_map: dict,
if mesh_id:
_mesh_data_cache[mesh_id] = (verts, face_groups, ms)
# Pre-compute world coords for all vertices in this mesh
verts_world = []
# Scale vertices to mm (local coordinates, no instance transform)
verts_local = []
for vi in range(0, len(verts) - 2, 3):
lx = float(verts[vi]) * ms
ly = float(verts[vi+1]) * ms
lz = float(verts[vi+2]) * ms
wx, wy, wz = _apply_transform(t, lx, ly, lz, ts)
verts_world.append(wx)
verts_world.append(wy)
verts_world.append(wz)
verts_local.append(float(verts[vi]) * ms)
verts_local.append(float(verts[vi+1]) * ms)
verts_local.append(float(verts[vi+2]) * ms)
mesh_breps = build_ifc_breps(ifc, verts_world, face_groups)
mesh_facesets = build_ifc_facesets(ifc, verts_local, face_groups)
if not mesh_breps:
if not mesh_facesets:
continue
# Apply material style to every component brep of this mesh
# Apply material style to each faceset
if material_manager:
mesh_app_id = _get(mesh, "applicationId")
if mesh_app_id:
for brep in mesh_breps:
material_manager.apply_to_item(brep, str(mesh_app_id))
for fs in mesh_facesets:
material_manager.apply_to_item(fs, str(mesh_app_id))
brep_items.extend(mesh_breps)
geom_items.extend(mesh_facesets)
if not brep_items:
if not geom_items:
return None
# Mapping origin = identity (local coords origin)
origin = ifc.createIfcCartesianPoint([0.0, 0.0, 0.0])
a2p = ifc.createIfcAxis2Placement3D(origin, None, None)
# The mapped representation holds the actual geometry
mapped_rep = ifc.createIfcShapeRepresentation(
ContextOfItems=body_context,
RepresentationIdentifier="Body",
RepresentationType="Tessellation",
Items=geom_items,
)
return ifc.createIfcRepresentationMap(a2p, mapped_rep)
# --------------------------------------------------------------------------- #
# Transform → IfcCartesianTransformationOperator3D
# --------------------------------------------------------------------------- #
def _vec_magnitude(x, y, z):
return math.sqrt(x*x + y*y + z*z)
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])
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])
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
s1 = _vec_magnitude(*ax1)
s2 = _vec_magnitude(*ax2)
s3 = _vec_magnitude(*ax3)
if s1 < 1e-10 or s2 < 1e-10 or s3 < 1e-10:
return None # degenerate transform
# Normalized direction vectors
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])
# Translation, scaled to mm
tx = float(t[3]) * ts
ty = float(t[7]) * ts
tz = float(t[11]) * ts
origin = ifc.createIfcCartesianPoint([tx, ty, tz])
# Use non-uniform variant to handle mirrors and non-uniform scale
return ifc.createIfcCartesianTransformationOperator3DnonUniform(
d1, # Axis1
d2, # Axis2
origin, # LocalOrigin
s1, # Scale
d3, # Axis3
s2, # Scale2
s3, # Scale3
)
# --------------------------------------------------------------------------- #
# Main conversion — IfcMappedItem approach
# --------------------------------------------------------------------------- #
def instance_to_ifc(ifc, body_context, obj: Base, definition_map: dict,
scale: float = 1.0, material_manager=None):
"""
Convert a Speckle InstanceProxy → (IfcShapeRepresentation, IfcLocalPlacement).
Strategy: create geometry once per definition as an IfcRepresentationMap,
then reference it via IfcMappedItem + IfcCartesianTransformationOperator3D
for each instance. This avoids duplicating geometry across instances.
"""
transform_raw = _get(obj, "transform")
if not transform_raw:
return None, None
t = list(transform_raw)
if len(t) != 16:
return None, None
definition_id = _get(obj, "definitionId") or ""
ifc_format = _is_ifc_format(definition_id)
# Translation scale: IFC format transform is in metres → convert to mm
# Revit format transform is already in mm (same as IFC file units)
ts = 1000.0 if ifc_format else _resolve_instance_scale(obj, scale)
if _dbg_cnt[0] < 6:
_dbg_cnt[0] += 1
fmt = "IFC" if ifc_format else "Revit"
x_axis = (round(t[0],2), round(t[1],2), round(t[2],2))
z_axis = (round(t[8],2), round(t[9],2), round(t[10],2))
print(f" [INST {_dbg_cnt[0]} {fmt}] {definition_id[:40]}")
print(f" t[3]={t[3]:.1f} t[7]={t[7]:.1f} t[11]={t[11]:.1f} x={x_axis} z={z_axis}")
# Identity placement (transform is encoded in the MappedItem)
origin = ifc.createIfcCartesianPoint([0.0, 0.0, 0.0])
a2p = ifc.createIfcAxis2Placement3D(origin, None, None)
placement = ifc.createIfcLocalPlacement(PlacementRelTo=None, RelativePlacement=a2p)
# --- 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)
else:
meshes = _get_revit_meshes(definition_id, definition_map)
if not meshes:
_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:
_stats["found"] += 1
else:
_stats["not_found"] += 1
rep_map = _rep_map_cache[definition_id]
if rep_map is None:
return None, placement
# --- Build transform operator from instance's 4x4 matrix ---
transform_op = _make_transform_operator(ifc, t, ts)
if transform_op is None:
return None, placement
# --- Create IfcMappedItem referencing the shared geometry ---
mapped_item = ifc.createIfcMappedItem(rep_map, transform_op)
rep = ifc.createIfcShapeRepresentation(
ContextOfItems=body_context,
RepresentationIdentifier="Body",
RepresentationType="Brep",
Items=brep_items,
RepresentationType="MappedRepresentation",
Items=[mapped_item],
)
return rep, placement
def get_definition_object(obj: Base, definition_map: dict):
"""
Resolve the definition's source object for an InstanceProxy.
Returns the first object referenced by the definition proxy, which
carries the proper category/type info. Returns None if not found.
"""
definition_id = _get(obj, "definitionId") or ""
if not definition_id:
return None
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 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" ⚠️ {_stats['not_found']} instances had no definition geometry")
def reset_caches():
"""Reset module-level caches (call at start of each export run)."""
_mesh_data_cache.clear()
_rep_map_cache.clear()
_stats["found"] = 0
_stats["not_found"] = 0
_dbg_cnt[0] = 0
+74 -14
View File
@@ -36,6 +36,7 @@ BUILTIN_CATEGORY_MAP: dict[str, str] = {
# Architectural - Stairs / Ramps / Railings
"OST_Stairs": "IfcStair",
"OST_StairsRailing": "IfcRailing",
"OST_RailingTopRail": "IfcRailing",
"OST_Ramps": "IfcRamp",
"OST_StairsLandings": "IfcStairFlight",
"OST_StairsRuns": "IfcStairFlight",
@@ -101,6 +102,7 @@ BUILTIN_CATEGORY_MAP: dict[str, str] = {
# Site / Civil
"OST_Site": "IfcSite",
"OST_Topography": "IfcGeographicElement",
"OST_Toposolid": "IfcGeographicElement",
"OST_Roads": "IfcRoad",
"OST_Hardscape": "IfcPavement",
"OST_Planting": "IfcGeographicElement",
@@ -117,6 +119,12 @@ BUILTIN_CATEGORY_MAP: dict[str, str] = {
}
# --- OST_ BuiltInCategory → PredefinedType (where applicable) ---
BUILTIN_PREDEFINED_TYPE: dict[str, str] = {
"OST_RailingTopRail": "HANDRAIL",
}
# --- speckle_type → IFC class (secondary lookup) ---
SPECKLE_TYPE_MAP: dict[str, str] = {
"Objects.BuiltElements.Wall": "IfcWall",
@@ -173,6 +181,7 @@ CATEGORY_MAP: dict[str, str] = {
"Stairs": "IfcStair",
"Ramps": "IfcRamp",
"Railings": "IfcRailing",
"Top Rails": "IfcRailing",
"Curtain Panels": "IfcCurtainWall",
"Curtain Wall Mullions": "IfcMember",
"Doors": "IfcDoor",
@@ -188,6 +197,8 @@ CATEGORY_MAP: dict[str, str] = {
"Structural Foundations": "IfcFooting",
"Foundation Slabs": "IfcSlab",
"Topography": "IfcGeographicElement",
"Toposolid": "IfcGeographicElement",
"Planting": "IfcGeographicElement",
"Site": "IfcSite",
"Parking": "IfcSpace",
"Generic Models": "IfcBuildingElementProxy",
@@ -196,24 +207,55 @@ CATEGORY_MAP: dict[str, str] = {
}
def get_predefined_type(obj) -> str | None:
"""Return the IFC PredefinedType for an object based on its builtInCategory, or None."""
bic = _get_builtin_category(obj)
if bic and bic in BUILTIN_PREDEFINED_TYPE:
return BUILTIN_PREDEFINED_TYPE[bic]
return None
_bic_cache: dict[int, str | None] = {} # id(obj) → builtInCategory
def _get_builtin_category(obj) -> str | None:
"""
Read builtInCategory from obj.properties.builtInCategory.
Returns the OST_ string or None.
Returns the OST_ string or None. Cached per object.
"""
oid = id(obj)
if oid in _bic_cache:
return _bic_cache[oid]
result = None
try:
props = obj["properties"] or getattr(obj, "properties", None)
props = getattr(obj, "properties", None)
if props is None:
return None
if hasattr(props, "__getitem__"):
val = props["builtInCategory"]
else:
try:
props = obj["properties"]
except Exception:
pass
if props is not None:
val = getattr(props, "builtInCategory", None)
if val and isinstance(val, str):
return val.strip()
if val is None:
try:
val = props["builtInCategory"]
except Exception:
pass
if val and isinstance(val, str):
result = val.strip()
except Exception:
pass
return None
_bic_cache[oid] = result
return result
# Pre-computed lowercase category map for substring matching
_CATEGORY_MAP_LOWER: list[tuple[str, str]] = [
(k.lower(), v) for k, v in CATEGORY_MAP.items()
]
# Classification cache: (obj_id, category_name) → ifc_class
_classify_cache: dict[tuple, str] = {}
def classify(obj, category_name: str = "") -> str:
@@ -227,6 +269,16 @@ def classify(obj, category_name: str = "") -> str:
4. obj.category field
5. IfcBuildingElementProxy fallback
"""
cache_key = (id(obj), category_name)
if cache_key in _classify_cache:
return _classify_cache[cache_key]
result = _classify_impl(obj, category_name)
_classify_cache[cache_key] = result
return result
def _classify_impl(obj, category_name: str) -> str:
# 1. builtInCategory — most reliable, direct Revit enum
bic = _get_builtin_category(obj)
if bic and bic in BUILTIN_CATEGORY_MAP:
@@ -244,8 +296,9 @@ def classify(obj, category_name: str = "") -> str:
if category_name:
if category_name in CATEGORY_MAP:
return CATEGORY_MAP[category_name]
for key, ifc_class in CATEGORY_MAP.items():
if key.lower() in category_name.lower():
cat_lower = category_name.lower()
for key_lower, ifc_class in _CATEGORY_MAP_LOWER:
if key_lower in cat_lower:
return ifc_class
# 4. obj.category field
@@ -253,8 +306,15 @@ def classify(obj, category_name: str = "") -> str:
if obj_category and isinstance(obj_category, str):
if obj_category in CATEGORY_MAP:
return CATEGORY_MAP[obj_category]
for key, ifc_class in CATEGORY_MAP.items():
if key.lower() in obj_category.lower():
obj_cat_lower = obj_category.lower()
for key_lower, ifc_class in _CATEGORY_MAP_LOWER:
if key_lower in obj_cat_lower:
return ifc_class
return "IfcBuildingElementProxy"
return "IfcBuildingElementProxy"
def reset_caches():
"""Clear module-level caches (call at start of each export run)."""
_bic_cache.clear()
_classify_cache.clear()
+1 -1
View File
@@ -136,7 +136,7 @@ class MaterialManager:
return style
def apply_to_item(self, item, mesh_app_id: str):
"""Assign the material style to a single IFC geometry item (e.g. IfcFacetedBrep)."""
"""Assign the material style to a single IFC geometry item (e.g. IfcPolygonalFaceSet)."""
style = self.get_style(mesh_app_id)
if style is None:
return
+228 -61
View File
@@ -52,6 +52,7 @@ COMMON_PSET: dict[str, str] = {
"IfcLightFixture": "Pset_LightFixtureTypeCommon",
"IfcOpeningElement": "Pset_OpeningElementCommon",
"IfcPlate": "Pset_PlateCommon",
"IfcGeographicElement": "Pset_SiteCommon",
}
# ---------------------------------------------------------------------------
@@ -129,50 +130,124 @@ EXTERNAL_CATEGORIES = {
# Helpers
# ---------------------------------------------------------------------------
_props_cache: dict[int, dict] = {} # id(obj) → props dict
def _get_props_dict(obj: Base) -> dict:
for key in ["properties", "@properties"]:
try:
p = obj[key]
if p is None:
"""Get properties as a plain dict. Cached per object to avoid repeated conversion."""
oid = id(obj)
if oid in _props_cache:
return _props_cache[oid]
# Try getattr first — matches the pattern that works in other Speckle scripts
p = getattr(obj, "properties", None)
if p is None:
for key in ["properties", "@properties"]:
try:
p = obj[key]
if p is not None:
break
except Exception:
continue
if hasattr(p, "get_dynamic_member_names"):
return {n: p[n] for n in p.get_dynamic_member_names()}
if isinstance(p, dict):
return p
except Exception:
pass
return {}
if p is None:
_props_cache[oid] = {}
return {}
result = _to_dict(p)
_props_cache[oid] = result
return result
def _get_nested(d: dict, *keys):
def _get_nested(d, *keys):
"""Safely walk nested dicts/objects."""
cur = d
for k in keys:
if cur is None:
return None
if isinstance(cur, dict):
cur = cur.get(k)
else:
try:
cur = cur[k]
except Exception:
return None
cur = _safe_get(cur, k)
return cur
def _param_value(params_block: dict, internal_name: str):
_to_dict_cache: dict[int, dict] = {} # id(obj) → converted dict
def _to_dict(obj) -> dict:
"""Convert a Speckle Base object or dict to a plain dict. Returns {} on failure.
Cached per object identity to avoid repeated conversion."""
if obj is None:
return {}
if isinstance(obj, dict):
return obj
oid = id(obj)
if oid in _to_dict_cache:
return _to_dict_cache[oid]
# Try .get_dynamic_member_names() for Speckle Base objects
if hasattr(obj, "get_dynamic_member_names"):
result = {}
try:
names = obj.get_dynamic_member_names()
except Exception:
_to_dict_cache[oid] = {}
return {}
for n in names:
try:
result[n] = obj[n]
except Exception:
pass
_to_dict_cache[oid] = result
return result
# Last resort: try common dict-like patterns
if hasattr(obj, "items"):
try:
result = dict(obj.items())
_to_dict_cache[oid] = result
return result
except Exception:
pass
_to_dict_cache[oid] = {}
return {}
def _safe_get(obj, key, default=None):
"""Safe key access for both dicts and Speckle Base objects."""
if obj is None:
return default
if isinstance(obj, dict):
return obj.get(key, default)
# Try getattr first (works reliably for Speckle Base)
try:
val = getattr(obj, key, None)
if val is not None:
return val
except Exception:
pass
# Fallback to bracket access
try:
val = obj[key]
if val is not None:
return val
except Exception:
pass
return default
def _param_value(params_block, internal_name: str):
"""
Search all groups in a parameter block for a param with the given
internalDefinitionName. Returns the raw value or None.
Handles both plain dicts and Speckle Base objects.
"""
if not isinstance(params_block, dict):
block = _to_dict(params_block)
if not block:
return None
for group in params_block.values():
if not isinstance(group, dict):
for group in block.values():
group_d = _to_dict(group)
if not group_d:
continue
for entry in group.values():
if isinstance(entry, dict) and entry.get("internalDefinitionName") == internal_name:
return entry.get("value")
for entry in group_d.values():
entry_d = _to_dict(entry)
if not entry_d:
continue
if entry_d.get("internalDefinitionName") == internal_name:
return entry_d.get("value")
return None
@@ -210,10 +285,8 @@ def build_element_name(obj: Base) -> str:
Build element name in Revit native IFC format: "Family:TypeName:ElementId"
Falls back gracefully if any part is missing.
"""
props = _get_props_dict(obj)
family = getattr(obj, "family", None) or ""
typ = getattr(obj, "type", None) or ""
elem_id = props.get("elementId", "") or getattr(obj, "applicationId", "") or ""
# Treat literal "none" (case-insensitive) the same as empty — Revit exports
# placeholder objects with family/type set to the string "none".
@@ -223,15 +296,13 @@ def build_element_name(obj: Base) -> str:
typ = ""
parts = [p for p in [family, typ] if p]
if elem_id:
parts.append(str(elem_id))
return ":".join(parts) if parts else (getattr(obj, "id", None) or "unnamed")
def get_element_tag(obj: Base) -> str | None:
"""Return Revit ElementId as the IFC Tag."""
props = _get_props_dict(obj)
elem_id = props.get("elementId")
elem_id = _safe_get(props, "elementId")
return str(elem_id) if elem_id else None
@@ -241,11 +312,12 @@ def get_ifc_guid(obj: Base) -> str | None:
Falls back to None (ifcopenshell will auto-generate a GUID).
"""
props = _get_props_dict(obj)
params = props.get("Parameters") or {}
inst = params.get("Instance Parameters") or {}
ifc_p = inst.get("IFC Parameters") or {}
entry = ifc_p.get("IfcGUID") or {}
val = entry.get("value") if isinstance(entry, dict) else None
params = _safe_get(props, "Parameters", {})
inst = _safe_get(params, "Instance Parameters", {})
ifc_p = _safe_get(inst, "IFC Parameters", {})
entry = _safe_get(ifc_p, "IfcGUID", {})
entry_d = _to_dict(entry) if not isinstance(entry, dict) else entry
val = entry_d.get("value") if entry_d else None
return str(val) if val else None
@@ -263,9 +335,9 @@ def write_common_pset(ifc, element, obj: Base, ifc_class: str, category_name: st
return
props = _get_props_dict(obj)
params = props.get("Parameters") or {}
type_params = params.get("Type Parameters") or {}
inst_params = params.get("Instance Parameters") or {}
params = _safe_get(props, "Parameters", {})
type_params = _safe_get(params, "Type Parameters", {})
inst_params = _safe_get(params, "Instance Parameters", {})
ifc_props = []
@@ -277,7 +349,7 @@ def write_common_pset(ifc, element, obj: Base, ifc_class: str, category_name: st
ifc_props.append(p)
# IsExternal — derive from builtInCategory or "Constraints" parameters
bic = props.get("builtInCategory", "")
bic = _safe_get(props, "builtInCategory", "")
is_external = bic in EXTERNAL_CATEGORIES
if not is_external:
# Some elements expose it directly as a parameter
@@ -384,19 +456,23 @@ def _safe_str(value) -> str | None:
return s or None
def _flatten_params(params_block: dict) -> dict:
"""Flatten Type or Instance parameter block into {name: display_value}."""
def _flatten_params(params_block) -> dict:
"""Flatten Type or Instance parameter block into {name: display_value}.
Handles both plain dicts and Speckle Base objects at every nesting level."""
result = {}
skip_units = {"", "None", "General", "Currency", "Integer"}
for group in params_block.values():
if not isinstance(group, dict):
block = _to_dict(params_block)
for group in block.values():
group_d = _to_dict(group)
if not group_d:
continue
for entry in group.values():
if not isinstance(entry, dict):
for entry in group_d.values():
entry_d = _to_dict(entry)
if not entry_d:
continue
name = entry.get("name")
value = entry.get("value")
units = entry.get("units", "") or ""
name = entry_d.get("name")
value = entry_d.get("value")
units = entry_d.get("units", "") or ""
if not name or value is None:
continue
val_str = _safe_str(value)
@@ -409,16 +485,17 @@ def _flatten_params(params_block: dict) -> dict:
def write_revit_params(ifc, element, obj: Base):
"""
Write remaining Revit parameters as two custom property sets
Write remaining Revit instance parameters as a custom property set
using the vendor prefix 'RVT_' (not 'Pset_' which is reserved):
RVT_TypeParameters — from Type Parameters
RVT_InstanceParameters — from Instance Parameters
Note: RVT_TypeParameters are written on the IfcTypeObject (via TypeManager),
not on individual elements, to avoid duplication.
"""
props = _get_props_dict(obj)
params = props.get("Parameters") or {}
params = _safe_get(props, "Parameters", {})
type_flat = _flatten_params(params.get("Type Parameters") or {})
inst_flat = _flatten_params(params.get("Instance Parameters") or {})
inst_flat = _flatten_params(_safe_get(params, "Instance Parameters", {}))
def build_str_props(flat: dict) -> list:
out = []
@@ -431,11 +508,8 @@ def write_revit_params(ifc, element, obj: Base):
pass
return out
type_props = build_str_props(type_flat)
inst_props = build_str_props(inst_flat)
if type_props:
_write_pset(ifc, element, "RVT_TypeParameters", type_props)
if inst_props:
_write_pset(ifc, element, "RVT_InstanceParameters", inst_props)
@@ -445,10 +519,10 @@ def write_revit_params(ifc, element, obj: Base):
val = getattr(obj, field, None)
if val and isinstance(val, str) and val.strip():
identity[field.capitalize()] = val.strip()
elem_id = props.get("elementId")
elem_id = _safe_get(props, "elementId")
if elem_id:
identity["ElementId"] = str(elem_id)
bic = props.get("builtInCategory")
bic = _safe_get(props, "builtInCategory")
if bic:
identity["BuiltInCategory"] = str(bic)
@@ -468,6 +542,92 @@ def write_revit_params(ifc, element, obj: Base):
# Public API — called from main.py
# ---------------------------------------------------------------------------
def write_material_quantities(ifc, element, obj: Base):
"""
Write Material Quantities from Revit as IfcElementQuantity sets.
Source: properties."Material Quantities".<MaterialName>.{area, volume, density,
materialName, materialClass, materialCategory}
Each material produces one IfcElementQuantity named "Qto_<MaterialName>" with:
- GrossArea (IfcQuantityArea)
- GrossVolume (IfcQuantityVolume)
- Density (IfcPropertySingleValue — no standard IFC quantity type)
- MaterialClass (IfcPropertySingleValue)
- MaterialCategory (IfcPropertySingleValue)
"""
props = _get_props_dict(obj)
mat_quantities = _safe_get(props, "Material Quantities")
if mat_quantities is None:
return
mat_dict = _to_dict(mat_quantities)
if not mat_dict:
return
for mat_key, mat_data in mat_dict.items():
mat_d = _to_dict(mat_data)
if not mat_d:
continue
mat_name = mat_d.get("materialName") or mat_key
quantities = []
# Area → IfcQuantityArea
area_entry = _to_dict(mat_d.get("area"))
if area_entry and area_entry.get("value") is not None:
try:
q = ifc.create_entity(
"IfcQuantityArea",
Name="GrossArea",
AreaValue=float(area_entry["value"]),
)
quantities.append(q)
except Exception:
pass
# Volume → IfcQuantityVolume
vol_entry = _to_dict(mat_d.get("volume"))
if vol_entry and vol_entry.get("value") is not None:
try:
q = ifc.create_entity(
"IfcQuantityVolume",
Name="GrossVolume",
VolumeValue=float(vol_entry["value"]),
)
quantities.append(q)
except Exception:
pass
# Density → IfcQuantityWeight (mass per volume, stored as weight)
density_entry = _to_dict(mat_d.get("density"))
if density_entry and density_entry.get("value") is not None:
try:
q = ifc.create_entity(
"IfcQuantityWeight",
Name="Density",
WeightValue=float(density_entry["value"]),
)
quantities.append(q)
except Exception:
pass
if not quantities:
continue
# Create IfcElementQuantity and link via IfcRelDefinesByProperties
qto_name = f"Qto_{mat_name}"
try:
qto = ifcopenshell.api.run(
"pset.add_qto", ifc,
product=element,
name=qto_name,
)
qto.Quantities = quantities
except Exception as e:
print(f" ⚠️ {qto_name}: {e}")
def write_properties(ifc, element, obj: Base, ifc_class: str = "", category_name: str = ""):
"""
Write all property sets for an IFC element, matching Revit native IFC export structure:
@@ -476,12 +636,19 @@ def write_properties(ifc, element, obj: Base, ifc_class: str = "", category_name
3. RVT_TypeParameters — all remaining Revit type parameters
4. RVT_InstanceParameters — all remaining Revit instance parameters
5. RVT_Identity — family, type, elementId, builtInCategory
6. Qto_<MaterialName> — material quantities (area, volume, density)
"""
write_common_pset(ifc, element, obj, ifc_class, category_name)
write_environmental_pset(ifc, element, obj)
write_revit_params(ifc, element, obj)
write_material_quantities(ifc, element, obj)
def write_common_properties(ifc, element, obj: Base, category_name: str = ""):
"""Legacy shim — kept for compatibility with main.py call sites."""
pass # All handled by write_properties now
pass # All handled by write_properties now
def reset_caches():
"""Clear module-level caches (call at start of each export run)."""
_props_cache.clear()
_to_dict_cache.clear()
+9 -14
View File
@@ -22,6 +22,7 @@ import ifcopenshell.api
from specklepy.objects.base import Base
from utils.properties import (
_get_props_dict, _get_nested, _param_value, _make_prop, _write_pset,
_safe_get, _to_dict,
COMMON_PSET, EXTERNAL_CATEGORIES, _flatten_params
)
@@ -112,9 +113,9 @@ class TypeManager:
obj: Base, ifc_class: str):
"""Instantiate the IfcTypeObject with name, tag, GlobalId, and psets."""
props = _get_props_dict(obj)
params = props.get("Parameters") or {}
type_params = params.get("Type Parameters") or {}
inst_params = params.get("Instance Parameters") or {}
params = _safe_get(props, "Parameters", {})
type_params = _safe_get(params, "Type Parameters", {})
inst_params = _safe_get(params, "Instance Parameters", {})
# Name: "Family:TypeName" (no ElementId)
name_parts = [p for p in [family, type_name] if p]
@@ -122,13 +123,13 @@ class TypeManager:
# Tag: Type's Revit ElementId
type_id_entry = _get_nested(inst_params, "Other", "Type Id")
tag = str(type_id_entry.get("value")) if isinstance(type_id_entry, dict) else None
type_id_d = _to_dict(type_id_entry)
tag = str(type_id_d.get("value")) if type_id_d.get("value") else None
# GlobalId: from Type IfcGUID parameter
type_guid_entry = _get_nested(type_params, "IFC Parameters", "Type IfcGUID")
guid = None
if isinstance(type_guid_entry, dict):
guid = type_guid_entry.get("value")
type_guid_d = _to_dict(type_guid_entry)
guid = type_guid_d.get("value") if type_guid_d else None
# Create type entity
type_obj = ifcopenshell.api.run(
@@ -163,7 +164,7 @@ class TypeManager:
type_ifc_props = []
# IsExternal (type-level)
bic = props.get("builtInCategory", "")
bic = _safe_get(props, "builtInCategory", "")
is_external = bic in EXTERNAL_CATEGORIES
if ifc_class not in {"IfcSpace", "IfcSite", "IfcBuildingStorey",
"IfcBuilding", "IfcFurnishingElement", "IfcOpeningElement"}:
@@ -197,12 +198,6 @@ class TypeManager:
if type_ifc_props:
_write_pset(ifc, type_obj, pset_name, type_ifc_props)
# ── Pset_EnvironmentalImpactIndicators on the type ─────────────────
if type_name:
p = _make_prop(ifc, "Reference", "IfcIdentifier", type_name)
if p:
_write_pset(ifc, type_obj, "Pset_EnvironmentalImpactIndicators", [p])
# ── RVT_TypeParameters — all type-level Revit params ──────────────
type_flat = _flatten_params(type_params)
if type_flat:
+1 -1
View File
@@ -74,7 +74,7 @@ def create_ifc_scaffold() -> tuple:
products=[building],
)
return ifc, building, body_ctx
return ifc, site, building, body_ctx
class StoreyManager: