4 Commits

Author SHA1 Message Date
NLSA 11acb02fd1 Update main.yml time
build and deploy Speckle functions / publish-automate-function-version (push) Has been cancelled
2026-03-11 23:02:39 +01:00
NLSA f7aa6c29da performance update
build and deploy Speckle functions / publish-automate-function-version (push) Has been cancelled
2026-03-11 22:53:34 +01:00
NLSA 63082a881c update memory and performance 2026-03-10 15:22:59 +01:00
NLSA bdd030ba86 Update props and instances
build and deploy Speckle functions / publish-automate-function-version (push) Has been cancelled
2026-03-06 14:54:44 +01:00
11 changed files with 1824821 additions and 455 deletions
+1
View File
@@ -30,3 +30,4 @@ jobs:
speckle_function_id: ${{ secrets.SPECKLE_FUNCTION_ID }}
speckle_function_input_schema_file_path: ${{ env.FUNCTION_SCHEMA_FILE_NAME }}
speckle_function_command: "python -u main.py run"
speckle_function_recommended_memory_mi: 5000
+94 -37
View File
@@ -6,15 +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
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",
}
@@ -59,7 +60,9 @@ def automate_function(
print(" Speckle -> IFC4.3 Exporter")
print("=" * 60)
#version_root_object = automate_context.receive_version()
# Reset caches from any previous run
reset_props_caches()
reset_mapper_caches()
# ------------------------------------------------------------------ #
# 1. Receive
@@ -79,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)
# ------------------------------------------------------------------ #
@@ -87,6 +90,7 @@ def automate_function(
# ------------------------------------------------------------------ #
print("\n🎨 Building material map...")
material_manager = MaterialManager(ifc, base)
type_manager = TypeManager(ifc)
# ------------------------------------------------------------------ #
# 4. Traverse & export
@@ -106,21 +110,31 @@ def automate_function(
skipped_spatial += 1
continue
name = getattr(obj, "name", None) or getattr(obj, "applicationId", None) or getattr(obj, "id", "unnamed")
name = build_element_name(obj)
storey = storey_manager.get_or_create(level_name)
# ------------------------------------------------------------------ #
# 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)
element = _create_element(ifc, ifc_class, name, rep, placement, storey)
write_common_properties(ifc, element, obj, category_name)
write_properties(ifc, element, obj)
instance_count += 1
total += 1
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),
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
total += 1
else:
# ------------------------------------------------------------------ #
@@ -131,12 +145,14 @@ def automate_function(
# B1: Mesh geometry on the parent object
rep, placement = mesh_to_ifc(ifc, body_context, obj, scale=scale, material_manager=material_manager)
element = _create_element(ifc, ifc_class, name, rep, placement, storey)
write_common_properties(ifc, element, obj, category_name)
write_properties(ifc, element, obj)
total += 1
if not rep:
no_geometry += 1
if rep:
element = _create_element(ifc, ifc_class, name, rep, placement, storey,
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
# B2: Instance objects nested inside displayValue
# Each becomes its own IFC element (same class as parent)
@@ -146,15 +162,23 @@ def automate_function(
inst_rep, inst_placement = instance_to_ifc(
ifc, body_context, inst, definition_map, scale=scale, material_manager=material_manager
)
inst_element = _create_element(
ifc, ifc_class, name, inst_rep, inst_placement, storey
)
write_common_properties(ifc, inst_element, obj, category_name)
write_properties(ifc, inst_element, obj)
instance_count += 1
total += 1
if not inst_rep:
no_geometry += 1
continue
inst_element = _create_element(
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)
instance_count += 1
total += 1
# Track if neither path produced geometry
if not rep and not nested_instances:
no_geometry += 1
if total % 100 == 0:
print(f" ... processed {total} elements")
@@ -162,13 +186,22 @@ def automate_function(
# ------------------------------------------------------------------ #
# 5. Write output
# ------------------------------------------------------------------ #
print("\n🔗 Flushing type relationships...")
type_manager.flush()
file_name = function_inputs.file_name
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
ifc_filename = f"{file_name}_{timestamp}.ifc"
ifc.write(ifc_filename)
automate_context.store_file_result(f"./{ifc_filename}")
print(f"\n💾 IFC file written: {ifc_filename}")
# 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!")
@@ -181,13 +214,28 @@ def automate_function(
print_instance_stats()
print(f"{'=' * 60}\n")
def _create_element(ifc, ifc_class, name, rep, placement, storey):
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:
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
except Exception:
pass
if rep and placement:
element.Representation = ifc.createIfcProductDefinitionShape(
Representations=(rep,)
@@ -198,11 +246,20 @@ def _create_element(ifc, ifc_class, name, rep, placement, storey):
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
File diff suppressed because one or more lines are too long
+90 -137
View File
@@ -1,12 +1,14 @@
# =============================================================================
# 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
@@ -24,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).
@@ -36,127 +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
from collections import defaultdict
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)
@@ -384,21 +336,22 @@ def mesh_to_ifc(
obj_scale = _resolve_scale(obj, scale)
# ------------------------------------------------------------------ #
# Pass 1: collect all scaled vertices to compute world origin
# 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, scaled)] or None per mesh
all_scaled = []
for mesh in meshes:
raw_verts = _get(mesh, "vertices") or []
verts = unwrap_chunks(list(raw_verts))
if not verts:
mesh_cache.append(None)
continue
ms = _resolve_scale(mesh, obj_scale)
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
@@ -406,49 +359,49 @@ def mesh_to_ifc(
ox, oy, oz = compute_origin(all_scaled)
# ------------------------------------------------------------------ #
# Pass 2: one brep per mesh (so each can have its own material style)
# Pass 2: one faceset per mesh — reuse cached verts, only unpack faces
# ------------------------------------------------------------------ #
brep_items = []
geom_items = []
for mesh in meshes:
raw_verts = _get(mesh, "vertices") or []
for mesh, cached in zip(meshes, mesh_cache):
if cached is None:
continue
verts, ms, scaled = cached
raw_faces = _get(mesh, "faces") or []
verts = unwrap_chunks(list(raw_verts))
faces_raw = unwrap_chunks(list(raw_faces))
if not verts or not faces_raw:
if not faces_raw:
continue
ms = _resolve_scale(mesh, obj_scale)
try:
face_groups = decode_faces(faces_raw)
except Exception as e:
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
# ------------------------------------------------------------------ #
@@ -457,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)
+226 -97
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:
@@ -74,9 +78,8 @@ def build_definition_map(root: Base) -> dict:
# Diagnostic: dump first 3 instanceDefinitionProxies to understand structure
print("\n [PROXY DIAG] First 3 instanceDefinitionProxies from root:")
proxies_raw2 = _get(root, "instanceDefinitionProxies")
if proxies_raw2:
sample = proxies_raw2 if isinstance(proxies_raw2, list) else [proxies_raw2]
if proxies_raw:
sample = proxies_raw if isinstance(proxies_raw, list) else [proxies_raw]
for i, proxy in enumerate(sample[:3]):
app_id = _get(proxy, "applicationId") or "?"
name = _get(proxy, "name") or "?"
@@ -213,32 +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, 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,
@@ -248,28 +237,157 @@ _MM_SCALES = {
}
def _apply_transform(t: list, vx: float, vy: float, vz: float, ts: float) -> tuple:
"""
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.
"""
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
# --------------------------------------------------------------------------- #
# 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 = []
for mesh in meshes:
mesh_id = _get(mesh, "id") or _get(mesh, "applicationId")
if mesh_id and mesh_id in _mesh_data_cache:
verts, face_groups, ms = _mesh_data_cache[mesh_id]
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))
if not verts or not faces_raw:
continue
mesh_units = _get(mesh, "units") or _get(mesh, "_units") or ("m" if ifc_format else "mm")
ms = _MM_SCALES.get(mesh_units.lower().strip(), 1.0)
try:
face_groups = decode_faces(faces_raw)
except Exception as e:
print(f" ⚠️ Instance face decode: {e}")
continue
if mesh_id:
_mesh_data_cache[mesh_id] = (verts, face_groups, ms)
# Scale vertices to mm (local coordinates, no instance transform)
verts_local = []
for vi in range(0, len(verts) - 2, 3):
verts_local.append(float(verts[vi]) * ms)
verts_local.append(float(verts[vi+1]) * ms)
verts_local.append(float(verts[vi+2]) * ms)
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:
for fs in mesh_facesets:
material_manager.apply_to_item(fs, str(mesh_app_id))
geom_items.extend(mesh_facesets)
if not geom_items:
return None
# 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: 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.
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:
@@ -293,81 +411,92 @@ def instance_to_ifc(ifc, body_context, obj: Base, definition_map: dict,
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)
# 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 definition meshes
if ifc_format:
meshes = _get_ifc_meshes(definition_id, definition_map)
# --- 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:
meshes = _get_revit_meshes(definition_id, definition_map)
# Track stats even for cached definitions
if _rep_map_cache[definition_id] is not None:
_stats["found"] += 1
else:
_stats["not_found"] += 1
if not meshes:
_stats["not_found"] += 1
rep_map = _rep_map_cache[definition_id]
if rep_map is None:
return None, placement
_stats["found"] += 1
# One brep per mesh so each can have its own material style
brep_items = []
for mesh in meshes:
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))
if not verts or not faces_raw:
continue
mesh_units = _get(mesh, "units") or _get(mesh, "_units") or ("m" if ifc_format else "mm")
ms = _MM_SCALES.get(mesh_units.lower().strip(), 1.0)
try:
face_groups = decode_faces(faces_raw)
except Exception as e:
print(f" ⚠️ Instance face decode: {e}")
continue
# Pre-compute world coords for all vertices in this mesh
verts_world = []
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)
mesh_breps = build_ifc_breps(ifc, verts_world, face_groups)
if not mesh_breps:
continue
# Apply material style to every component brep 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))
brep_items.extend(mesh_breps)
if not brep_items:
# --- 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")
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
+226 -43
View File
@@ -1,16 +1,131 @@
# =============================================================================
# mapper.py
# Maps Speckle speckle_type strings and Revit category names → IFC entity classes.
# Maps Speckle objects → IFC entity classes.
#
# Strategy:
# 1. Try to match speckle_type exactly or by prefix
# 2. Fall back to Revit category name (e.g. "Floors" → IfcSlab)
# 3. Fall back to IfcBuildingElementProxy if nothing matches
# Strategy (priority order):
# 1. builtInCategory (OST_ enum from properties.builtInCategory) — most reliable
# 2. speckle_type prefix match — for typed Speckle objects
# 3. category_name string (traversal context) — display name fallback
# 4. IfcBuildingElementProxy — last resort
#
# builtInCategory values: https://www.revitapidocs.com/2019/ba1c5b30-242f-5fdc-8ea9-ec3b61e6e722.htm
# =============================================================================
# --- speckle_type → IFC class ---
# Covers Objects.BuiltElements.* from the Speckle Objects kit
# --- OST_ BuiltInCategory → IFC class (primary lookup) ---
BUILTIN_CATEGORY_MAP: dict[str, str] = {
# Architectural - Walls
"OST_Walls": "IfcWall",
"OST_CurtainWallPanels": "IfcCurtainWall",
"OST_CurtainWallMullions": "IfcMember",
"OST_Fascia": "IfcCovering",
"OST_Gutters": "IfcPipeSegment",
# Architectural - Floors / Roofs / Ceilings
"OST_Floors": "IfcSlab",
"OST_Roofs": "IfcRoof",
"OST_Ceilings": "IfcCovering",
"OST_RoofSoffit": "IfcCovering",
# Architectural - Doors / Windows / Openings
"OST_Doors": "IfcDoor",
"OST_Windows": "IfcWindow",
"OST_CurtainWallFamilies": "IfcCurtainWall",
"OST_Skylights": "IfcWindow",
# Architectural - Stairs / Ramps / Railings
"OST_Stairs": "IfcStair",
"OST_StairsRailing": "IfcRailing",
"OST_RailingTopRail": "IfcRailing",
"OST_Ramps": "IfcRamp",
"OST_StairsLandings": "IfcStairFlight",
"OST_StairsRuns": "IfcStairFlight",
"OST_StairsSupports": "IfcMember",
# Architectural - Rooms / Spaces
"OST_Rooms": "IfcSpace",
"OST_Parking": "IfcSpace",
"OST_Areas": "IfcSpace",
# Architectural - Furniture / Casework
"OST_Furniture": "IfcFurnishingElement",
"OST_FurnitureSystems": "IfcFurnishingElement",
"OST_Casework": "IfcFurnishingElement",
"OST_SpecialtyEquipment": "IfcFurnishingElement",
"OST_Entourage": "IfcFurnishingElement",
# Structural
"OST_StructuralColumns": "IfcColumn",
"OST_Columns": "IfcColumn",
"OST_StructuralFraming": "IfcBeam",
"OST_StructuralFoundation": "IfcFooting",
"OST_FoundationSlab": "IfcSlab",
"OST_StructuralStiffener": "IfcMember",
"OST_StructuralTruss": "IfcMember",
"OST_StructuralConnectionModel": "IfcMechanicalFastener",
"OST_Rebar": "IfcReinforcingBar",
"OST_FabricAreas": "IfcReinforcingMesh",
"OST_FabricReinforcement": "IfcReinforcingMesh",
# MEP - HVAC
"OST_DuctCurves": "IfcDuctSegment",
"OST_DuctFitting": "IfcDuctFitting",
"OST_DuctAccessory": "IfcDuctSegment",
"OST_DuctTerminal": "IfcAirTerminal",
"OST_FlexDuctCurves": "IfcDuctSegment",
"OST_MechanicalEquipment": "IfcUnitaryEquipment",
"OST_AirTerminal": "IfcAirTerminal",
# MEP - Plumbing
"OST_PipeCurves": "IfcPipeSegment",
"OST_PipeFitting": "IfcPipeFitting",
"OST_PipeAccessory": "IfcPipeSegment",
"OST_FlexPipeCurves": "IfcPipeSegment",
"OST_PlumbingFixtures": "IfcSanitaryTerminal",
"OST_Sprinklers": "IfcFireSuppressionTerminal",
# MEP - Electrical
"OST_ElectricalEquipment": "IfcElectricDistributionBoard",
"OST_ElectricalFixtures": "IfcElectricAppliance",
"OST_LightingFixtures": "IfcLightFixture",
"OST_LightingDevices": "IfcLightFixture",
"OST_CableTray": "IfcCableCarrierSegment",
"OST_CableTrayFitting": "IfcCableCarrierFitting",
"OST_Conduit": "IfcCableCarrierSegment",
"OST_ConduitFitting": "IfcCableCarrierFitting",
"OST_CommunicationDevices": "IfcElectricAppliance",
"OST_DataDevices": "IfcElectricAppliance",
"OST_FireAlarmDevices": "IfcAlarm",
"OST_SecurityDevices": "IfcAlarm",
"OST_NurseCallDevices": "IfcElectricAppliance",
# Site / Civil
"OST_Site": "IfcSite",
"OST_Topography": "IfcGeographicElement",
"OST_Toposolid": "IfcGeographicElement",
"OST_Roads": "IfcRoad",
"OST_Hardscape": "IfcPavement",
"OST_Planting": "IfcGeographicElement",
"OST_SiteSurface": "IfcGeographicElement",
# Generic / Annotation (skip or proxy)
"OST_GenericModel": "IfcBuildingElementProxy",
"OST_Mass": "IfcBuildingElementProxy",
"OST_DetailComponents": "IfcAnnotation",
"OST_Lines": "IfcAnnotation",
"OST_Grids": "IfcGrid",
"OST_Levels": "IfcBuildingStorey",
"OST_Views": "IfcAnnotation",
}
# --- 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",
"Objects.BuiltElements.Floor": "IfcSlab",
@@ -47,7 +162,7 @@ SPECKLE_TYPE_MAP: dict[str, str] = {
"Objects.Geometry.Brep": "IfcBuildingElementProxy",
}
# --- Revit category name → IFC class (fallback) ---
# --- Display category name → IFC class (tertiary fallback) ---
CATEGORY_MAP: dict[str, str] = {
"Walls": "IfcWall",
"Floors": "IfcSlab",
@@ -66,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",
@@ -81,57 +197,124 @@ CATEGORY_MAP: dict[str, str] = {
"Structural Foundations": "IfcFooting",
"Foundation Slabs": "IfcSlab",
"Topography": "IfcGeographicElement",
"Toposolid": "IfcGeographicElement",
"Planting": "IfcGeographicElement",
"Site": "IfcSite",
"Parking": "IfcSpace",
"Generic Models": "IfcBuildingElementProxy",
"Mass": "IfcBuildingElementProxy",
"Specialty Equipment": "IfcBuildingElementProxy",
"Specialty Equipment": "IfcFurnishingElement",
}
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. Cached per object.
"""
oid = id(obj)
if oid in _bic_cache:
return _bic_cache[oid]
result = None
try:
props = getattr(obj, "properties", None)
if props is None:
try:
props = obj["properties"]
except Exception:
pass
if props is not None:
val = getattr(props, "builtInCategory", None)
if val is None:
try:
val = props["builtInCategory"]
except Exception:
pass
if val and isinstance(val, str):
result = val.strip()
except Exception:
pass
_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:
"""
Determine the IFC class for a Speckle object.
With the new Objects.Data.DataObject:Objects.Data.RevitObject speckle_type,
category name is now the primary classification signal.
Args:
obj: A specklepy Base object (leaf element).
category_name: The Revit category string from the traversal context
e.g. "Floors", "Walls", "Structural Columns"
Returns:
An IFC class name string e.g. "IfcWall"
Priority:
1. properties.builtInCategory (OST_ enum) — definitive Revit classification
2. speckle_type prefix match
3. category_name from traversal context (display string)
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:
return BUILTIN_CATEGORY_MAP[bic]
# 2. speckle_type
speckle_type = getattr(obj, "speckle_type", "") or ""
# 1. Category name — PRIMARY lookup for RevitObject types
if category_name:
# Exact match
if category_name in CATEGORY_MAP:
return CATEGORY_MAP[category_name]
# Partial match handles Revit appending IDs e.g. "Structural Framing [12345]"
for key, ifc_class in CATEGORY_MAP.items():
if key.lower() in category_name.lower():
return ifc_class
# 2. Read 'category' directly off the object itself
# Per docs: category is a TOP-LEVEL field on RevitObject, not inside properties
obj_category = getattr(obj, "category", None)
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():
return ifc_class
# 3. speckle_type — fallback for non-RevitObject types (geometry, structural, etc.)
if speckle_type in SPECKLE_TYPE_MAP:
return SPECKLE_TYPE_MAP[speckle_type]
for key, ifc_class in SPECKLE_TYPE_MAP.items():
if speckle_type.startswith(key):
return ifc_class
# 4. Last resort
return "IfcBuildingElementProxy"
# 3. category_name from traversal context
if category_name:
if category_name in CATEGORY_MAP:
return CATEGORY_MAP[category_name]
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
obj_category = getattr(obj, "category", None)
if obj_category and isinstance(obj_category, str):
if obj_category in CATEGORY_MAP:
return CATEGORY_MAP[obj_category]
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"
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
+615 -138
View File
@@ -1,177 +1,654 @@
# =============================================================================
# properties.py
# Extracts Revit data from Speckle DataObjects and writes IFC property sets.
# Writes IFC property sets matching the structure of Revit's native IFC export.
#
# Revit parameter structure from the Speckle connector:
# obj.properties = {
# "elementId": "704282",
# "Parameters": {
# "Type Parameters": {
# "Dimensions": {
# "Thickness": {"name": "Thickness", "value": 25.4, "units": "Millimeters", ...}
# },
# ...
# },
# "Instance Parameters": {
# "Constraints": {
# "Level": {"name": "Level", "value": "Level 1", ...}
# },
# ...
# }
# }
# }
# Revit native IFC export produces:
# - Element Name: "Family:TypeName:ElementId" e.g. "Basic Roof:SG Metal Panels roof:243274"
# - Element Tag: ElementId string e.g. "243274"
# - Element GlobalId: from IFC Parameters.IfcGUID
# - Pset_<EntityType>Common with typed properties (IfcBoolean, IfcIdentifier, etc.)
# - Pset_EnvironmentalImpactIndicators with Reference = TypeName
#
# We flatten this into two IFC property sets:
# Pset_RevitTypeParameters — from "Type Parameters"
# Pset_RevitInstanceParameters — from "Instance Parameters"
# Our Speckle source fields:
# obj.family → Family name
# obj.type → Type name (= Reference in all Common psets)
# properties.elementId → Revit ElementId → Tag
# properties.Parameters.Instance Parameters.IFC Parameters.IfcGUID.value → GlobalId
# properties.Parameters.Type Parameters.* → typed IFC properties
# properties.Parameters.Instance Parameters.* → typed IFC properties
# =============================================================================
import ifcopenshell.api
from specklepy.objects.base import Base
def _safe_val(value) -> str | None:
"""Convert a value to a clean IFC-safe string."""
# ---------------------------------------------------------------------------
# IFC entity → standard Common pset name
# ---------------------------------------------------------------------------
COMMON_PSET: dict[str, str] = {
"IfcWall": "Pset_WallCommon",
"IfcWallStandardCase": "Pset_WallCommon",
"IfcSlab": "Pset_SlabCommon",
"IfcRoof": "Pset_RoofCommon",
"IfcColumn": "Pset_ColumnCommon",
"IfcBeam": "Pset_BeamCommon",
"IfcMember": "Pset_MemberCommon",
"IfcDoor": "Pset_DoorCommon",
"IfcWindow": "Pset_WindowCommon",
"IfcStair": "Pset_StairCommon",
"IfcStairFlight": "Pset_StairFlightCommon",
"IfcRamp": "Pset_RampCommon",
"IfcRailing": "Pset_RailingCommon",
"IfcCovering": "Pset_CoveringCommon",
"IfcCurtainWall": "Pset_CurtainWallCommon",
"IfcFooting": "Pset_FootingCommon",
"IfcPile": "Pset_PileCommon",
"IfcSpace": "Pset_SpaceCommon",
"IfcSite": "Pset_SiteCommon",
"IfcBuildingStorey": "Pset_BuildingStoreyCommon",
"IfcBuilding": "Pset_BuildingCommon",
"IfcBuildingElementProxy": "Pset_BuildingElementProxyCommon",
"IfcFurnishingElement": "Pset_FurnitureTypeCommon",
"IfcLightFixture": "Pset_LightFixtureTypeCommon",
"IfcOpeningElement": "Pset_OpeningElementCommon",
"IfcPlate": "Pset_PlateCommon",
"IfcGeographicElement": "Pset_SiteCommon",
}
# ---------------------------------------------------------------------------
# Revit parameter internal names → (IFC pset property name, IFC value factory)
# These are harvested from the Common psets Revit native export produces.
# ---------------------------------------------------------------------------
def _bool(v):
return ("IfcBoolean", bool(v))
def _identifier(v):
return ("IfcIdentifier", str(v))
def _label(v):
return ("IfcLabel", str(v))
def _real(v):
return ("IfcReal", float(v))
def _thermal(v):
return ("IfcThermalTransmittanceMeasure", float(v))
def _length(v):
return ("IfcPositiveLengthMeasure", float(v))
def _count(v):
return ("IfcCountMeasure", int(v))
def _angle(v):
return ("IfcPlaneAngleMeasure", float(v))
# Map: Revit internalDefinitionName → (IFC property name, value factory fn)
REVIT_PARAM_TO_IFC: dict[str, tuple] = {
# Wall
"WALL_ATTR_ROOM_BOUNDING": ("IsExternal", _bool),
"WALL_STRUCTURAL_SIGNIFICANT": ("LoadBearing", _bool),
"WALL_STRUCTURAL_USAGE_PARAM": ("LoadBearing", _bool),
"ANALYTICAL_THERMAL_RESISTANCE": ("ThermalTransmittance", _thermal),
"ANALYTICAL_HEAT_TRANSFER_COEFFICIENT": ("ThermalTransmittance", _thermal),
# Slab / Roof / Floor
"HOST_AREA_COMPUTED": ("NetArea", _real),
"HOST_VOLUME_COMPUTED": ("NetVolume", _real),
"ROOF_SLOPE": ("PitchAngle", _angle),
# Stair
"STAIR_RISER_HEIGHT": ("RiserHeight", _length),
"STAIR_TREAD_DEPTH": ("TreadLength", _length),
"STAIR_NUMBER_OF_RISERS": ("NumberOfRiser", _count),
"STAIR_NUMBER_OF_TREADS": ("NumberOfTreads", _count),
"STAIR_NOSING_LENGTH": ("NosingLength", _length),
# Railing
"RAILING_HEIGHT": ("Height", _length),
# Door / Window
"DOOR_FIRE_RATING": ("FireExit", _bool),
# General identity
"ALL_MODEL_FAMILY_NAME": ("Reference", _identifier),
"ALL_MODEL_TYPE_NAME": ("Reference", _identifier),
"ASSEMBLY_CODE": ("Reference", _identifier),
}
# External category OST_ codes (used to infer IsExternal)
EXTERNAL_CATEGORIES = {
"OST_Walls", "OST_Roofs", "OST_Windows", "OST_Doors",
"OST_CurtainWallPanels", "OST_CurtainWallMullions",
"OST_StructuralColumns", "OST_StructuralFraming",
"OST_Stairs", "OST_StairsRailing", "OST_Ramps",
}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_props_cache: dict[int, dict] = {} # id(obj) → props dict
def _get_props_dict(obj: Base) -> dict:
"""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 p is None:
_props_cache[oid] = {}
return {}
result = _to_dict(p)
_props_cache[oid] = result
return result
def _get_nested(d, *keys):
"""Safely walk nested dicts/objects."""
cur = d
for k in keys:
if cur is None:
return None
cur = _safe_get(cur, k)
return cur
_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.
"""
block = _to_dict(params_block)
if not block:
return None
for group in block.values():
group_d = _to_dict(group)
if not group_d:
continue
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
def _make_prop(ifc, name: str, ifc_type: str, value) -> object | None:
"""Create an IfcPropertySingleValue with the correct IFC measure type."""
try:
nominal = ifc.create_entity(ifc_type, wrappedValue=value)
return ifc.create_entity(
"IfcPropertySingleValue",
Name=name,
NominalValue=nominal,
)
except Exception as e:
return None
def _write_pset(ifc, element, pset_name: str, props: list):
"""Write an IfcPropertySet with the given list of IfcProperty objects."""
if not props:
return
try:
pset = ifcopenshell.api.run("pset.add_pset", ifc, product=element, name=pset_name)
# Directly attach the pre-built property objects
pset.HasProperties = props
except Exception as e:
print(f" ⚠️ {pset_name}: {e}")
# ---------------------------------------------------------------------------
# Element name + tag (matching Revit native IFC format)
# ---------------------------------------------------------------------------
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.
"""
family = getattr(obj, "family", None) or ""
typ = getattr(obj, "type", None) or ""
# Treat literal "none" (case-insensitive) the same as empty — Revit exports
# placeholder objects with family/type set to the string "none".
if family.strip().lower() == "none":
family = ""
if typ.strip().lower() == "none":
typ = ""
parts = [p for p in [family, typ] if p]
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 = _safe_get(props, "elementId")
return str(elem_id) if elem_id else None
def get_ifc_guid(obj: Base) -> str | None:
"""
Read IfcGUID from the Revit IFC Parameters.
Falls back to None (ifcopenshell will auto-generate a GUID).
"""
props = _get_props_dict(obj)
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
# ---------------------------------------------------------------------------
# Standard Common pset (Pset_WallCommon etc.)
# ---------------------------------------------------------------------------
def write_common_pset(ifc, element, obj: Base, ifc_class: str, category_name: str = ""):
"""
Write the standard Pset_<Entity>Common property set, matching Revit native export.
Properties: Reference (TypeName), IsExternal, LoadBearing, ThermalTransmittance, etc.
"""
pset_name = COMMON_PSET.get(ifc_class)
if not pset_name:
return
props = _get_props_dict(obj)
params = _safe_get(props, "Parameters", {})
type_params = _safe_get(params, "Type Parameters", {})
inst_params = _safe_get(params, "Instance Parameters", {})
ifc_props = []
# Reference = TypeName (always present in Revit IFC)
type_name = getattr(obj, "type", None) or ""
if type_name:
p = _make_prop(ifc, "Reference", "IfcIdentifier", type_name)
if p:
ifc_props.append(p)
# IsExternal — derive from builtInCategory or "Constraints" parameters
bic = _safe_get(props, "builtInCategory", "")
is_external = bic in EXTERNAL_CATEGORIES
if not is_external:
# Some elements expose it directly as a parameter
ext_val = _param_value(inst_params, "WALL_ATTR_ROOM_BOUNDING")
if ext_val is not None:
is_external = bool(ext_val)
if ifc_class not in {"IfcSpace", "IfcSite", "IfcBuildingStorey", "IfcBuilding",
"IfcFurnishingElement", "IfcOpeningElement"}:
p = _make_prop(ifc, "IsExternal", "IfcBoolean", is_external)
if p:
ifc_props.append(p)
# LoadBearing — walls, columns, beams, slabs
if ifc_class in {"IfcWall", "IfcWallStandardCase", "IfcSlab", "IfcColumn", "IfcBeam"}:
lb_val = (_param_value(inst_params, "WALL_STRUCTURAL_SIGNIFICANT") or
_param_value(inst_params, "WALL_STRUCTURAL_USAGE_PARAM") or
_param_value(type_params, "WALL_STRUCTURAL_SIGNIFICANT"))
lb = bool(lb_val) if lb_val is not None else False
p = _make_prop(ifc, "LoadBearing", "IfcBoolean", lb)
if p:
ifc_props.append(p)
# ThermalTransmittance — walls, roofs, slabs, doors, windows
if ifc_class in {"IfcWall", "IfcWallStandardCase", "IfcRoof", "IfcSlab",
"IfcDoor", "IfcWindow"}:
u_val = (_param_value(type_params, "ANALYTICAL_HEAT_TRANSFER_COEFFICIENT") or
_param_value(inst_params, "ANALYTICAL_HEAT_TRANSFER_COEFFICIENT"))
if u_val is not None:
try:
p = _make_prop(ifc, "ThermalTransmittance", "IfcThermalTransmittanceMeasure", float(u_val))
if p:
ifc_props.append(p)
except Exception:
pass
# PitchAngle — roofs/slabs
if ifc_class in {"IfcRoof", "IfcSlab"}:
slope = _param_value(inst_params, "ROOF_SLOPE")
if slope is not None:
try:
p = _make_prop(ifc, "PitchAngle", "IfcPlaneAngleMeasure", float(slope))
if p:
ifc_props.append(p)
except Exception:
pass
# Stair-specific
if ifc_class in {"IfcStair", "IfcStairFlight"}:
for internal, prop_name, factory in [
("STAIR_RISER_HEIGHT", "RiserHeight", "IfcPositiveLengthMeasure"),
("STAIR_TREAD_DEPTH", "TreadLength", "IfcPositiveLengthMeasure"),
("STAIR_NUMBER_OF_RISERS","NumberOfRiser", "IfcCountMeasure"),
("STAIR_NUMBER_OF_TREADS","NumberOfTreads", "IfcCountMeasure"),
]:
v = _param_value(inst_params, internal) or _param_value(type_params, internal)
if v is not None:
try:
p = _make_prop(ifc, prop_name, factory, float(v) if "Measure" in factory else int(v))
if p:
ifc_props.append(p)
except Exception:
pass
# Railing height
if ifc_class == "IfcRailing":
h = _param_value(inst_params, "RAILING_HEIGHT") or _param_value(type_params, "RAILING_HEIGHT")
if h is not None:
try:
p = _make_prop(ifc, "Height", "IfcPositiveLengthMeasure", float(h))
if p:
ifc_props.append(p)
except Exception:
pass
_write_pset(ifc, element, pset_name, ifc_props)
# ---------------------------------------------------------------------------
# Pset_EnvironmentalImpactIndicators (always written, Reference = TypeName)
# ---------------------------------------------------------------------------
def write_environmental_pset(ifc, element, obj: Base):
"""Write Pset_EnvironmentalImpactIndicators with Reference = TypeName."""
type_name = getattr(obj, "type", None) or ""
if not type_name:
return
p = _make_prop(ifc, "Reference", "IfcIdentifier", type_name)
if p:
_write_pset(ifc, element, "Pset_EnvironmentalImpactIndicators", [p])
# ---------------------------------------------------------------------------
# Custom Revit parameters pset (all remaining instance + type params)
# ---------------------------------------------------------------------------
def _safe_str(value) -> str | None:
if value is None:
return None
if isinstance(value, bool):
return "Yes" if value else "No"
if isinstance(value, float):
# Trim excessive decimals
return f"{value:.6g}"
if isinstance(value, (int, str)):
s = str(value).strip()
return s if s else None
return str(value).strip() or None
s = str(value).strip()
return s or None
def _extract_param(entry) -> tuple[str, str] | None:
"""
Given a Revit parameter entry dict like:
{"name": "Thickness", "value": 25.4, "units": "Millimeters", ...}
Returns (display_name, display_value) or None if unusable.
"""
if not isinstance(entry, dict):
return None
name = entry.get("name")
value = entry.get("value")
if not name or value is None:
return None
units = entry.get("units", "")
# Skip non-informative unit labels
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"}
val_str = _safe_val(value)
if val_str is None:
return None
if units and units not in skip_units:
display = f"{val_str} {units}"
else:
display = val_str
return str(name), display
def _flatten_param_group(group: dict) -> dict:
"""
Flatten one parameter group (e.g. "Dimensions", "Constraints") dict.
Each value is a Revit parameter entry {"name":..., "value":..., "units":...}.
Returns {display_name: display_value}.
"""
result = {}
if not isinstance(group, dict):
return result
for _internal_key, entry in group.items():
pair = _extract_param(entry)
if pair:
name, val = pair
result[name] = val
return result
def _extract_parameter_block(params_block: dict) -> dict:
"""
Flatten all groups in a parameter block (Type Parameters or Instance Parameters).
Returns a merged {display_name: display_value} dict.
"""
result = {}
if not isinstance(params_block, dict):
return result
for _group_name, group in params_block.items():
result.update(_flatten_param_group(group))
return result
def _get_properties_dict(obj: Base) -> dict:
"""Extract the raw properties dict from a DataObject."""
for key in ["properties", "@properties", "_properties"]:
try:
props = obj[key]
if props is None:
continue
if hasattr(props, "get_dynamic_member_names"):
names = props.get_dynamic_member_names()
return {n: props[n] for n in names}
if isinstance(props, dict):
return props
except Exception:
block = _to_dict(params_block)
for group in block.values():
group_d = _to_dict(group)
if not group_d:
continue
return {}
for entry in group_d.values():
entry_d = _to_dict(entry)
if not entry_d:
continue
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)
if val_str is None:
continue
display = f"{val_str} {units}".strip() if units not in skip_units else val_str
result[name] = display
return result
def _write_pset(ifc, element, pset_name: str, props: dict):
"""Write a property set if there are any properties."""
if not props:
return
try:
pset = ifcopenshell.api.run("pset.add_pset", ifc, product=element, name=pset_name)
ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties=props)
except Exception as e:
print(f" ⚠️ {pset_name}: {e}")
def write_properties(ifc, element, obj: Base):
def write_revit_params(ifc, element, obj: Base):
"""
Write Revit parameters as IFC property sets.
Creates separate psets for Type and Instance parameters.
Write remaining Revit instance parameters as a custom property set
using the vendor prefix 'RVT_' (not 'Pset_' which is reserved):
RVT_InstanceParameters — from Instance Parameters
Note: RVT_TypeParameters are written on the IfcTypeObject (via TypeManager),
not on individual elements, to avoid duplication.
"""
props_dict = _get_properties_dict(obj)
parameters = props_dict.get("Parameters") or {}
props = _get_props_dict(obj)
params = _safe_get(props, "Parameters", {})
# Type Parameters → Pset_RevitTypeParameters
type_params = parameters.get("Type Parameters") or {}
type_flat = _extract_parameter_block(type_params)
_write_pset(ifc, element, "RVT_TypeParameters", type_flat)
inst_flat = _flatten_params(_safe_get(params, "Instance Parameters", {}))
# Instance Parameters → Pset_RevitInstanceParameters
inst_params = parameters.get("Instance Parameters") or {}
inst_flat = _extract_parameter_block(inst_params)
_write_pset(ifc, element, "RVT_InstanceParameters", inst_flat)
def build_str_props(flat: dict) -> list:
out = []
for name, val in flat.items():
try:
nominal = ifc.create_entity("IfcLabel", wrappedValue=val)
p = ifc.create_entity("IfcPropertySingleValue", Name=name, NominalValue=nominal)
out.append(p)
except Exception:
pass
return out
# Top-level semantic fields → Pset_RevitIdentity
inst_props = build_str_props(inst_flat)
if inst_props:
_write_pset(ifc, element, "RVT_InstanceParameters", inst_props)
# Identity: family, type, elementId, builtInCategory
identity = {}
for field in ["type", "family", "category", "level"]:
for field in ["family", "type", "category"]:
val = getattr(obj, field, None)
if val and isinstance(val, str) and val.strip():
identity[field.capitalize()] = val.strip()
# Also include elementId if present
elem_id = props_dict.get("elementId")
elem_id = _safe_get(props, "elementId")
if elem_id:
identity["ElementId"] = str(elem_id)
bic = _safe_get(props, "builtInCategory")
if bic:
identity["BuiltInCategory"] = str(bic)
_write_pset(ifc, element, "RVT_Identity", identity)
id_props = []
for name, val in identity.items():
try:
nominal = ifc.create_entity("IfcLabel", wrappedValue=val)
p = ifc.create_entity("IfcPropertySingleValue", Name=name, NominalValue=nominal)
id_props.append(p)
except Exception:
pass
if id_props:
_write_pset(ifc, element, "RVT_Identity", id_props)
# ---------------------------------------------------------------------------
# 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:
1. Pset_<Entity>Common — standard typed properties (Reference, IsExternal, etc.)
2. Pset_EnvironmentalImpactIndicators — Reference = TypeName
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_revit_params(ifc, element, obj)
write_material_quantities(ifc, element, obj)
def write_common_properties(ifc, element, obj: Base, category_name: str = ""):
"""
Write Pset_SpeckleData for traceability back to the Speckle source object.
"""
props = {}
speckle_id = getattr(obj, "id", None)
app_id = getattr(obj, "applicationId", None)
speckle_type = getattr(obj, "speckle_type", None)
"""Legacy shim — kept for compatibility with main.py call sites."""
pass # All handled by write_properties now
if speckle_id: props["SpeckleId"] = str(speckle_id)
if app_id: props["ApplicationId"] = str(app_id)
if speckle_type: props["SpeckleType"] = str(speckle_type)
if category_name: props["RevitCategory"] = str(category_name)
_write_pset(ifc, element, "RVT_SpeckleData", props)
def reset_caches():
"""Clear module-level caches (call at start of each export run)."""
_props_cache.clear()
_to_dict_cache.clear()
+214
View File
@@ -0,0 +1,214 @@
# =============================================================================
# type_manager.py
# Creates and caches IfcTypeObjects (IfcWallType, IfcRoofType, etc.) and
# links element instances to them via IfcRelDefinesByType.
#
# Revit native IFC export pattern:
# IfcWallType
# Name = "Family:TypeName" (no ElementId)
# Tag = Type's Revit ElementId (from Instance Parameters > Other > Type Id)
# GlobalId = from Type IfcGUID param (from Type Parameters > IFC Parameters > Type IfcGUID)
# HasPropertySets:
# Pset_WallCommon: IsExternal, ThermalTransmittance (type-level)
# Pset_EnvironmentalImpactIndicators: Reference = TypeName
# RVT_TypeParameters: all remaining type params
#
# Type objects are SHARED — multiple instances of the same Revit type
# map to one IfcTypeObject, keyed by (ifc_class, family, type_name).
# =============================================================================
import ifcopenshell
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
)
# IFC element class → IFC type class
TYPE_CLASS_MAP: dict[str, str] = {
"IfcWall": "IfcWallType",
"IfcWallStandardCase": "IfcWallType",
"IfcSlab": "IfcSlabType",
"IfcRoof": "IfcRoofType",
"IfcColumn": "IfcColumnType",
"IfcBeam": "IfcBeamType",
"IfcMember": "IfcMemberType",
"IfcDoor": "IfcDoorType",
"IfcWindow": "IfcWindowType",
"IfcStair": "IfcStairType",
"IfcStairFlight": "IfcStairFlightType",
"IfcRamp": "IfcRampType",
"IfcRailing": "IfcRailingType",
"IfcCovering": "IfcCoveringType",
"IfcCurtainWall": "IfcCurtainWallType",
"IfcFooting": "IfcFootingType",
"IfcBuildingElementProxy": "IfcBuildingElementProxyType",
"IfcFurnishingElement": "IfcFurnitureType",
"IfcLightFixture": "IfcLightFixtureType",
"IfcElectricAppliance": "IfcElectricApplianceType",
"IfcElectricDistributionBoard": "IfcElectricDistributionBoardType",
"IfcSanitaryTerminal": "IfcSanitaryTerminalType",
"IfcUnitaryEquipment": "IfcUnitaryEquipmentType",
"IfcDuctSegment": "IfcDuctSegmentType",
"IfcPipeSegment": "IfcPipeSegmentType",
"IfcCableCarrierSegment": "IfcCableCarrierSegmentType",
"IfcPlate": "IfcPlateType",
}
class TypeManager:
"""
Creates IfcTypeObjects on demand and caches them by (ifc_class, family, type_name).
Call assign(element, obj, ifc_class) for each exported element.
"""
def __init__(self, ifc: ifcopenshell.file):
self._ifc = ifc
# key: (ifc_class, family, type_name) → IfcTypeObject
self._cache: dict[tuple, object] = {}
# type_object → [element, ...] (for batched IfcRelDefinesByType)
self._pending: dict[int, list] = {}
def assign(self, element, obj: Base, ifc_class: str):
"""Create (or retrieve cached) type object and queue the assignment."""
type_class = TYPE_CLASS_MAP.get(ifc_class)
if not type_class:
return
family = getattr(obj, "family", None) or ""
type_name = getattr(obj, "type", None) or ""
if not type_name:
return
cache_key = (ifc_class, family, type_name)
if cache_key not in self._cache:
type_obj = self._create_type(type_class, family, type_name, obj, ifc_class)
self._cache[cache_key] = type_obj
type_obj = self._cache[cache_key]
type_id = type_obj.id()
if type_id not in self._pending:
self._pending[type_id] = []
self._pending[type_id].append(element)
def flush(self):
"""Write all IfcRelDefinesByType relationships."""
for type_id, elements in self._pending.items():
type_obj = self._ifc.by_id(type_id)
ifcopenshell.api.run(
"type.assign_type", self._ifc,
related_objects=elements,
relating_type=type_obj,
)
self._pending.clear()
print(f" Type objects created: {len(self._cache)}")
# -----------------------------------------------------------------------
def _create_type(self, type_class: str, family: str, type_name: str,
obj: Base, ifc_class: str):
"""Instantiate the IfcTypeObject with name, tag, GlobalId, and psets."""
props = _get_props_dict(obj)
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]
name = ":".join(name_parts)
# Tag: Type's Revit ElementId
type_id_entry = _get_nested(inst_params, "Other", "Type Id")
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")
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(
"root.create_entity", self._ifc,
ifc_class=type_class,
name=name,
)
if tag:
try:
type_obj.Tag = str(tag)
except Exception:
pass
if guid:
try:
type_obj.GlobalId = str(guid)
except Exception:
pass
# Write type-level property sets
self._write_type_psets(type_obj, obj, ifc_class, type_name, props,
type_params, inst_params)
return type_obj
def _write_type_psets(self, type_obj, obj, ifc_class, type_name,
props, type_params, inst_params):
"""Write psets on the type object (type-level parameters only)."""
ifc = self._ifc
pset_name = COMMON_PSET.get(ifc_class)
# ── Standard Common pset on the type ──────────────────────────────
if pset_name:
type_ifc_props = []
# IsExternal (type-level)
bic = _safe_get(props, "builtInCategory", "")
is_external = bic in EXTERNAL_CATEGORIES
if ifc_class not in {"IfcSpace", "IfcSite", "IfcBuildingStorey",
"IfcBuilding", "IfcFurnishingElement", "IfcOpeningElement"}:
p = _make_prop(ifc, "IsExternal", "IfcBoolean", is_external)
if p:
type_ifc_props.append(p)
# ThermalTransmittance (from type parameters)
if ifc_class in {"IfcWall", "IfcWallStandardCase", "IfcRoof",
"IfcSlab", "IfcDoor", "IfcWindow"}:
u_val = _param_value(type_params,
"ANALYTICAL_HEAT_TRANSFER_COEFFICIENT")
if u_val is not None:
try:
p = _make_prop(ifc, "ThermalTransmittance",
"IfcThermalTransmittanceMeasure", float(u_val))
if p:
type_ifc_props.append(p)
except Exception:
pass
# LoadBearing (from type parameters)
if ifc_class in {"IfcWall", "IfcWallStandardCase", "IfcColumn",
"IfcBeam", "IfcSlab"}:
lb_val = _param_value(type_params, "WALL_STRUCTURAL_SIGNIFICANT")
if lb_val is not None:
p = _make_prop(ifc, "LoadBearing", "IfcBoolean", bool(lb_val))
if p:
type_ifc_props.append(p)
if type_ifc_props:
_write_pset(ifc, type_obj, pset_name, type_ifc_props)
# ── RVT_TypeParameters — all type-level Revit params ──────────────
type_flat = _flatten_params(type_params)
if type_flat:
type_str_props = []
for name_p, val in type_flat.items():
try:
nominal = ifc.create_entity("IfcLabel", wrappedValue=val)
prop = ifc.create_entity("IfcPropertySingleValue",
Name=name_p, NominalValue=nominal)
type_str_props.append(prop)
except Exception:
pass
if type_str_props:
_write_pset(ifc, type_obj, "RVT_TypeParameters", type_str_props)
+2 -2
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:
@@ -112,4 +112,4 @@ class StoreyManager:
@property
def names(self) -> list[str]:
return list(self._storeys.keys())
return list(self._storeys.keys())