Files
IFC-Exporter/utils/traversal.py
T
2026-03-13 08:56:29 +01:00

244 lines
9.0 KiB
Python

# =============================================================================
# traversal.py
# Walks the nested Speckle Collection tree from a Revit export.
#
# Expected structure (from your screenshot):
# root
# └── elements[]
# └── Collection (project)
# └── elements[]
# └── Collection (Level 18, Level 19, ...) ← storeys
# └── elements[]
# └── Collection (Floors, Walls, ...) ← categories
# └── elements[]
# └── Base object ← real BIM element
# =============================================================================
from typing import Generator, Tuple
from specklepy.objects.base import Base
# --------------------------------------------------------------------------- #
# Low-level helpers
# --------------------------------------------------------------------------- #
def is_collection(obj) -> bool:
"""Returns True if this object is a Speckle Collection node (not a leaf element)."""
speckle_type = getattr(obj, "speckle_type", "") or ""
return "Collection" in speckle_type
def get_children(obj) -> list:
"""
Safely get the 'elements' list from a Base/Collection object.
Handles both 'elements' and '@elements' (detached) variants.
"""
for key in ["elements", "@elements"]:
try:
val = obj[key]
if val is not None:
return list(val)
except Exception:
continue
return []
def get_prop(obj, key: str, default=None):
"""Safe property access for Speckle Base objects — avoids AttributeError."""
try:
val = getattr(obj, key, None)
if val is None:
val = obj[key]
return val
except Exception:
return default
# speckle_type fragments that mark a non-exportable / spatial-structure object
import re
_SKIP_TYPE_RE = re.compile(
r"Collection|Level|Grid|View|RenderMaterial|Site|Building|Storey"
)
def _is_valid_element(obj) -> bool:
"""
Returns True only for leaf objects that should become IFC elements.
Filters out Collections, spatial structure types, and other non-geometry nodes.
"""
if obj is None:
return False
speckle_type = getattr(obj, "speckle_type", "") or ""
if _SKIP_TYPE_RE.search(speckle_type):
return False
return True
# --------------------------------------------------------------------------- #
# Helpers
# --------------------------------------------------------------------------- #
def _element_level(obj) -> str:
"""
Try to read the level/storey name directly from an element's properties.
Handles both flat and deeply nested Revit property structures.
"""
# Top-level field (Revit connector puts it here for parent elements)
level = get_prop(obj, "level") or get_prop(obj, "Level")
if level and isinstance(level, str) and level.strip():
return level.strip()
props = get_prop(obj, "properties")
if isinstance(props, dict):
# Flat key
for key in ["Level", "level", "Building Storey"]:
val = props.get(key)
if val and isinstance(val, str) and val.strip():
return val.strip()
# Nested: properties.Instance Parameters.Constraints.Level.value
# (used by curtain wall children / panels / mullions)
instance_params = props.get("Instance Parameters") or {}
constraints = instance_params.get("Constraints") or {}
level_entry = constraints.get("Level") or {}
if isinstance(level_entry, dict):
val = level_entry.get("value")
if val and isinstance(val, str) and val.strip():
return val.strip()
# Also check Identity Data
identity = props.get("Identity Data") or {}
for key in ["Level", "level"]:
val = identity.get(key)
if val and isinstance(val, str) and val.strip():
return val.strip()
return ""
def _yield_element_and_children(obj, level_name: str, category_name: str):
"""
Yield a leaf element, then recursively yield any DataObject children
from its elements[] list (e.g. curtain wall panels and mullions).
Children have their own level and displayValue geometry.
"""
yield obj, level_name, category_name
children = get_children(obj)
for child in children:
if child is None or is_collection(child):
continue
if not _is_valid_element(child):
continue
# Get child's own level, fall back to parent's level
child_level = _element_level(child) or level_name
if child_level and child_level != "Unknown Level":
child_category = getattr(child, "category", None) or category_name
yield from _yield_element_and_children(child, child_level, child_category)
# --------------------------------------------------------------------------- #
# Main traversal
# --------------------------------------------------------------------------- #
def traverse(
root: Base,
) -> Generator[Tuple[Base, str, str], None, None]:
"""
Walk the full Speckle object tree from the root Base object.
Yields:
(element, level_name, category_name) for every leaf BIM element found.
level_name — e.g. "Level 18"
category_name — e.g. "Floors", "Walls", "Structural Columns"
"""
root_children = get_children(root)
if not root_children:
if _is_valid_element(root):
yield root, "Unknown Level", "Unknown Category"
return
for child in root_children:
if is_collection(child):
yield from _walk_level(child)
else:
if _is_valid_element(child):
level = _element_level(child)
if level:
yield child, level, "Unknown Category"
def _walk_level(project_collection: Base):
"""Walk the project collection → level collections."""
for level_obj in get_children(project_collection):
level_name = getattr(level_obj, "name", None) or ""
if is_collection(level_obj):
# Only walk into this level if it has a real name
if level_name and level_name != "Unknown Level":
yield from _walk_category(level_obj, level_name)
else:
if _is_valid_element(level_obj):
level = _element_level(level_obj) or level_name
if level and level != "Unknown Level":
yield from _yield_element_and_children(level_obj, level, "Unknown Category")
def _walk_category(level_obj: Base, level_name: str):
"""Walk level collection → category collections → leaf elements."""
for category_obj in get_children(level_obj):
category_name = getattr(category_obj, "name", "Unknown Category") or "Unknown Category"
if is_collection(category_obj):
for element in get_children(category_obj):
if is_collection(element):
# One extra nesting level (e.g. sub-families)
for sub_element in get_children(element):
if _is_valid_element(sub_element):
level = _element_level(sub_element) or level_name
if level and level != "Unknown Level":
yield from _yield_element_and_children(sub_element, level, category_name)
else:
if _is_valid_element(element):
level = _element_level(element) or level_name
if level and level != "Unknown Level":
yield from _yield_element_and_children(element, level, category_name)
else:
if _is_valid_element(category_obj):
level = _element_level(category_obj) or level_name
if level and level != "Unknown Level":
yield from _yield_element_and_children(category_obj, level, "Unknown Category")
# --------------------------------------------------------------------------- #
# Debug helper
# --------------------------------------------------------------------------- #
def print_tree(obj: Base, indent: int = 0, max_depth: int = 5):
"""
Print the object tree structure for debugging.
Call this on the root object to understand your data before exporting.
Usage:
from traversal import print_tree
print_tree(base)
"""
if indent > max_depth:
return
prefix = " " * indent
name = getattr(obj, "name", None) or ""
speckle_type = getattr(obj, "speckle_type", "") or ""
children = get_children(obj)
child_count = f" ({len(children)} children)" if children else ""
print(f"{prefix}├─ [{speckle_type}] name={name!r}{child_count}")
for child in children[:5]: # limit to first 5 per level to avoid spam
print_tree(child, indent + 1, max_depth)
if len(children) > 5:
print(f"{prefix} ... and {len(children) - 5} more")