This guide builds on traversal fundamentals from the Core Concepts. We focus on practical patterns for finding and extracting data from real-world Speckle projects.
Speckle data is often deeply nested - buildings contain levels, levels contain rooms, rooms contain elements. You need to visit every object in the tree to process or analyze them.
Terminology Note: When we use terms like “levels”, “rooms”, or reference property names in examples, these are for illustrative purposes. Real BIM data from connectors (Revit, Rhino, etc.) uses proxy structures - for example, Revit levels are represented as LevelProxy objects in dedicated collections, not as direct hierarchy. See the Proxification guide and BIM Data Patterns for actual BIM data structures.
Basic manual traversal - here’s the fundamental pattern:
Copy
from specklepy.objects import Basedef traverse(obj): """Visit every object in the tree.""" if not isinstance(obj, Base): return # Process current object name = getattr(obj, "name", "unnamed") print(f"Visiting: {name}") # Recurse through all properties for prop_name in obj.get_member_names(): value = getattr(obj, prop_name, None) if isinstance(value, Base): # Single nested object traverse(value) elif isinstance(value, list): # List of objects for item in value: if isinstance(item, Base): traverse(item)# Use ittraverse(root)
The SDK provides three key components for traversal:
GraphTraversal - The main traversal engine
Creates the traversal instance: GraphTraversal([rules])
Executes traversal: .traverse(root) returns an iterator
Pass [] for default behavior (traverse everything)
TraversalContext - Information about each visited object
.current - The current Base object being visited
.member_name - Property name from parent (e.g., “elements”, “displayValue”)
.parent - Parent TraversalContext (or None if root)
TraversalRule - Optional rules to control behavior
_conditions - When does this rule apply? (list of predicates)
_members_to_traverse - What properties to traverse? (function returning list)
_should_return_to_output - Include objects in results? (boolean)
In the examples below, look for comments showing where each component is used.
Copy
from specklepy.objects.graph_traversal.traversal import GraphTraversal# GraphTraversal: Create traversal engine with# default rules (traverse everything)traversal = GraphTraversal([])# Traverse and visit every object# Returns Iterator[TraversalContext]for context in traversal.traverse(root): # TraversalContext: current Base object obj = context.current name = getattr(obj, "name", "unnamed") print(f"Visiting: {name}")
Understanding the traversal flow:
Copy
# Visual Flow:## GraphTraversal([]) ← You create the engine# ↓# .traverse(root) ← Call traverse on root object# ↓# Iterator[TraversalContext] ← Returns iterator of contexts# ↓# for context in ...: ← Loop through each context# context.current ← Access the Base object# context.parent ← Access parent context (optional)# context.member_name ← Access property name (optional)
Why use GraphTraversal?
✅ Handles all edge cases (dicts, lists, nested Base objects)
✅ Provides context (parent object, property name)
✅ Supports custom rules for filtering during traversal
✅ Memory efficient (uses iterators, not lists)
✅ Battle-tested in production SDK code
The traversal pattern has three parts:
Base case: Check if object is a Base instance
Process: Do something with the current object
Recurse: Visit all child objects via get_member_names()
Why get_member_names()? It returns all property names on the
object, already filters out private members (_) and methods, and
works with both typed and dynamic properties.
Copy
# get_member_names() handles the filteringfor name in obj.get_member_names(): value = getattr(obj, name, None) # Value is always a data property, never a method
Collecting objects during traversal using GraphTraversal:Instead of just visiting objects, you often want to collect them
into a list. Here are two approaches - collecting all at once or
processing as you go:
Copy
from specklepy.objects.graph_traversal.traversal import GraphTraversaltraversal = GraphTraversal([])# Collect all objectsall_objects = [ context.current for context in traversal.traverse(root)]print(f"Found {len(all_objects)} objects")# Or process while traversing (memory efficient)for context in traversal.traverse(root): obj = context.current if hasattr(obj, "properties"): category = obj.properties.get("category") if category: obj_name = getattr(obj, 'name', 'unnamed') print(f"Found {category}: {obj_name}")
Understanding TraversalContext:Each context provides information about where you are in the
object tree - the current object, what property it came from,
and its parent:
Copy
from specklepy.objects.graph_traversal.traversal import GraphTraversaltraversal = GraphTraversal([])for context in traversal.traverse(root): # TraversalContext provides three properties: obj = context.current # Current object prop_name = context.member_name # Property name parent_context = context.parent # Parent context if parent_context: parent_obj = parent_context.current print(f"{obj.name} is in {parent_obj.name}.{prop_name}")
Using TraversalRule to control traversal:Rules give you fine-grained control over what gets traversed
and returned. They’re useful when you want to skip certain
properties or limit results during traversal:
Copy
from specklepy.objects.graph_traversal.traversal import ( GraphTraversal, TraversalRule # Controls what gets traversed)# Define function to skip displayValue propertydef get_members_without_display(obj): return [ name for name in obj.get_member_names() if name != "displayValue" ]# TraversalRule: Create rule with three parametersskip_display_rule = TraversalRule( # When: applies to all objects _conditions=[lambda obj: True], # What: which properties to traverse _members_to_traverse=get_members_without_display, # Whether: include in results _should_return_to_output=True)# Use the rule in GraphTraversaltraversal = GraphTraversal([skip_display_rule])for context in traversal.traverse(root): obj = context.current print(f"Visiting: {getattr(obj, 'name', 'unnamed')}") # displayValue won't be traversed due to our rule
Simpler approach - filter after traversal:If you just need to skip certain objects, filtering after
traversal is often clearer than writing a custom rule:
Copy
# Simpler approach: Filter after traversal instead of using rulestraversal = GraphTraversal([])for context in traversal.traverse(root): obj = context.current # Skip if this came from displayValue property if context.member_name == "displayValue": continue print(f"Visiting: {getattr(obj, 'name', 'unnamed')}")
Comprehensive example - all three components together:
Copy
from specklepy.objects.graph_traversal.traversal import ( GraphTraversal, TraversalRule, TraversalContext)# Define a custom rule to skip geometry propertiesdef get_non_geometry_members(obj): """Skip geometry properties to focus on structure.""" skip_props = [ "displayValue", "renderMaterial", "@displayValue" ] return [ name for name in obj.get_member_names() if name not in skip_props ]structure_only_rule = TraversalRule( _conditions=[lambda obj: True], _members_to_traverse=get_non_geometry_members, _should_return_to_output=True)# Create traversal with the custom ruletraversal = GraphTraversal([structure_only_rule])# Traverse and collect context informationresults = []for context in traversal.traverse(root): obj = context.current # Get parent information parent_name = "root" if context.parent: parent_obj = context.parent.current parent_name = getattr(parent_obj, "name", "unnamed") came_from = context.member_name or "root" results.append({ "object": obj, "name": getattr(obj, "name", "unnamed"), "parent": parent_name, "property": came_from })# Display results showing the traversal pathfor item in results[:5]: # Show first 5 parent = item['parent'] prop = item['property'] print(f"{item['name']} (from {parent}.{prop})")
Output example:
Copy
Building A (from root.root)Level 1 (from Building A.elements)Room 101 (from Level 1.elements)Wall-001 (from Room 101.elements)
Don’t process the same property twice! When handling
elements arrays specifically, make sure you don’t also
process them in the general get_member_names() loop:
Copy
# ❌ Bad - processes elements twicedef traverse(obj): # First time: explicit elements handling if hasattr(obj, "elements"): for element in obj.elements: traverse(element) # Second time: get_member_names includes "elements"! for name in obj.get_member_names(): value = getattr(obj, name) traverse(value)# ✅ Good - process each property oncedef traverse(obj): for name in obj.get_member_names(): value = getattr(obj, name, None) if isinstance(value, Base): traverse(value) elif isinstance(value, list): for item in value: if isinstance(item, Base): traverse(item)
You need to find all objects matching certain criteria - for example, all walls, all objects on a specific level, or all elements with a particular property value.Filter while traversing by checking conditions and collecting matches:
Copy
from specklepy.objects.graph_traversal.traversal import GraphTraversaldef find_by_category(obj, category_name): """Find all objects with matching category.""" traversal = GraphTraversal([]) results = [] for context in traversal.traverse(obj): current = context.current has_props = (hasattr(current, "properties") and current.properties) if has_props: cat = current.properties.get("category") if cat == category_name: results.append(current) return results# Find all wallswalls = find_by_category(root, "Walls")print(f"Found {len(walls)} walls")
About “category” property: When we reference
properties["category"] in examples, this demonstrates the
pattern. Real BIM data may organize categories differently -
Revit data, for instance, uses both properties["category"]
on individual objects AND category-based proxy collections.
See BIM Data Patterns
for production patterns.
Multiple filter criteria:When you need to match objects on several properties at once
(e.g., walls that are also concrete), pass multiple key-value
pairs to check all conditions:
Copy
from specklepy.objects.graph_traversal.traversal import GraphTraversaldef find_by_properties(obj, **criteria): """Find objects matching multiple property criteria. Example: find_by_properties( root, category="Walls", material="Concrete" ) """ traversal = GraphTraversal([]) results = [] for context in traversal.traverse(obj): current = context.current # Check if all criteria match has_props = (hasattr(current, "properties") and current.properties) if has_props: matches = all( current.properties.get(key) == value for key, value in criteria.items() ) if matches: results.append(current) return results# Find concrete wallsconcrete_walls = find_by_properties( root, category="Walls", material="Concrete")
Using custom filter functions:For complex filtering logic (numeric comparisons, nested
properties, combined conditions), pass a custom function
that returns True for objects you want to keep:
Copy
from specklepy.objects.graph_traversal.traversal import GraphTraversaldef find_by_filter(obj, filter_func): """Find objects matching custom filter function. Args: obj: Root object to search filter_func: Function that takes an object and returns True/False """ traversal = GraphTraversal([]) results = [ context.current for context in traversal.traverse(obj) if filter_func(context.current) ] return results# Find large walls (area > 10 m²)def is_large_wall(obj): if not hasattr(obj, "properties"): return False is_wall = obj.properties.get("category") == "Walls" is_large = obj.properties.get("area", 0) > 10 return is_wall and is_largelarge_walls = find_by_filter(root, is_large_wall)# Find load-bearing elementsdef is_load_bearing(obj): if not hasattr(obj, "properties"): return False return obj.properties.get("isLoadBearing", False)load_bearing = find_by_filter(root, is_load_bearing)
The filtering pattern: (1) Traverse the entire tree recursively, (2) Check each object against your criteria, (3) Collect matching objects in a results list, (4) Return the accumulated results. This pattern works for any filtering criteria - category, property values, object types, etc.
BIM objects from connectors (Revit, Rhino, etc.) contain geometry in the displayValue property. You need to extract these meshes for visualization or analysis.Check for displayValue and collect geometry objects:
Copy
from specklepy.objects.geometry import Meshfrom specklepy.objects.graph_traversal.traversal import GraphTraversaldef extract_display_meshes(obj): """Extract all displayValue meshes from object tree.""" traversal = GraphTraversal([]) meshes = [] for context in traversal.traverse(obj): current = context.current # Check for displayValue if hasattr(current, "displayValue"): display = current.displayValue # displayValue can be a single mesh or a list if isinstance(display, Mesh): meshes.append(display) elif isinstance(display, list): # Filter list for Mesh objects meshes.extend([d for d in display if isinstance(d, Mesh)]) return meshes# Extract all meshesmeshes = extract_display_meshes(root)print(f"Found {len(meshes)} meshes")
Extracting geometry with metadata:Often you need to know which object each mesh came from.
Use TraversalContext to track source objects and their
properties:
Copy
from specklepy.objects.geometry import Meshfrom specklepy.objects.graph_traversal.traversal import GraphTraversaldef extract_meshes_with_metadata(obj): """Extract meshes with source object metadata.""" traversal = GraphTraversal([]) results = [] for context in traversal.traverse(obj): current = context.current # Extract displayValue if hasattr(current, "displayValue"): display = current.displayValue meshes = [] if isinstance(display, Mesh): meshes = [display] elif isinstance(display, list): meshes = [ d for d in display if isinstance(d, Mesh) ] # Add metadata for each mesh for mesh in meshes: results.append({ "mesh": mesh, "source_name": getattr( current, "name", "unnamed" ), "category": ( current.properties.get("category") if hasattr(current, "properties") else None ), "properties": ( current.properties if hasattr(current, "properties") else {} ), "parent": context.parent.current if context.parent else None }) return results# Extract with contextmesh_data = extract_meshes_with_metadata(root)for item in mesh_data: print(f"Mesh from: {item['source_name']}") print(f" Category: {item['category']}") vert_count = len(item['mesh'].vertices) / 3 print(f" Vertices: {vert_count}")
Handling different geometry types:Not all geometry is meshes - you might also encounter
points, lines, and polylines. Check for all geometry types
you’re interested in:
Copy
from specklepy.objects.geometry import ( Mesh, Point, Line, Polyline)def extract_all_geometry(obj): """Extract all geometry types from displayValue.""" geometry = [] if not isinstance(obj, Base): return geometry # Check for displayValue if hasattr(obj, "displayValue"): display = obj.displayValue # Handle single object geom_types = (Mesh, Point, Line, Polyline) if isinstance(display, geom_types): geometry.append(display) # Handle list elif isinstance(display, list): geometry.extend([ d for d in display if isinstance(d, geom_types) ]) # Check if object itself is geometry if isinstance(obj, geom_types): geometry.append(obj) # Recurse for name in obj.get_member_names(): if name != "displayValue": value = getattr(obj, name, None) if isinstance(value, Base): geometry.extend(extract_all_geometry(value)) elif isinstance(value, list): for item in value: if isinstance(item, Base): geometry.extend(extract_all_geometry(item)) return geometry
Don’t recurse into displayValue! The displayValue property often contains the same geometry as the object, leading to duplicates:
Copy
# ❌ Bad - processes displayValue twicefor name in obj.get_member_names(): # includes "displayValue" value = getattr(obj, name) if isinstance(value, Mesh): meshes.append(value) # Adds mesh if isinstance(value, Base): extract_meshes(value) # Adds same mesh again!# ✅ Good - skip displayValue in recursionif name != "displayValue": # Only recurse into non-geometry properties
BIM data often has hierarchical structures: Building → Levels → Rooms → Elements. You need to process these hierarchies while maintaining context about where each object came from.
Real BIM structures use proxies! When working with
actual connector data (Revit, Rhino, ArchiCAD), “Levels”
aren’t nested hierarchies - they’re represented as
LevelProxy collections that reference objects by ID.
The examples here show conceptual hierarchies for
learning. For production code with real BIM data, see:
Collecting objects by level:To analyze your data by depth in the tree (e.g., root
objects vs. deeply nested objects), organize objects by
their hierarchy level:
Copy
def collect_by_hierarchy_level(obj): """Collect objects organized by hierarchy level.""" levels = {} def traverse(current_obj, level=0): if not isinstance(current_obj, Base): return # Add to level dictionary if level not in levels: levels[level] = [] levels[level].append(current_obj) # Recurse for name in current_obj.get_member_names(): value = getattr(current_obj, name, None) if isinstance(value, Base): traverse(value, level + 1) elif isinstance(value, list): for item in value: if isinstance(item, Base): traverse(item, level + 1) traverse(obj) return levels# Collect by levelby_level = collect_by_hierarchy_level(root)for level, objects in sorted(by_level.items()): print(f"Level {level}: {len(objects)} objects")
Flattening vs. preserving hierarchy:Choose between flattening (all objects in one list) or
preserving structure (nested dictionaries). Flatten when
you just need to process objects; preserve when hierarchy
matters:
Copy
# Flatten to a simple list:def flatten_hierarchy(obj): """Flatten entire hierarchy to a single list.""" results = [] def collect(current_obj): if not isinstance(current_obj, Base): return results.append(current_obj) for name in current_obj.get_member_names(): value = getattr(current_obj, name, None) if isinstance(value, Base): collect(value) elif isinstance(value, list): for item in value: if isinstance(item, Base): collect(item) collect(obj) return results# Get flat listall_objects = flatten_hierarchy(root)# Preserve hierarchy as nested structure:def preserve_hierarchy(obj): """Convert to nested dictionary preserving hierarchy.""" if not isinstance(obj, Base): return None result = { "name": getattr(obj, "name", "unnamed"), "properties": obj.properties if hasattr(obj, "properties") else {}, "children": [] } # Collect children if hasattr(obj, "elements"): for element in obj.elements: if isinstance(element, Base): child = preserve_hierarchy(element) if child: result["children"].append(child) return result# Get hierarchical structurehierarchy = preserve_hierarchy(root)
def find_walls_on_level(root, level_name): """Find all walls on a specific level.""" walls = [] current_level = None def traverse(obj): nonlocal current_level if not isinstance(obj, Base): return # Track when we enter/exit levels has_props = hasattr(obj, "properties") is_level = (has_props and obj.properties.get("category") == "Levels") if is_level: obj_name = getattr(obj, "name", "") if obj_name == level_name: current_level = obj_name # Collect walls when in target level if current_level == level_name: has_props = hasattr(obj, "properties") is_wall = (has_props and obj.properties.get("category") == "Walls") if is_wall: walls.append(obj) # Recurse for name in obj.get_member_names(): value = getattr(obj, name, None) if isinstance(value, Base): traverse(value) elif isinstance(value, list): for item in value: if isinstance(item, Base): traverse(item) # Reset level when exiting if is_level and obj_name == level_name: current_level = None traverse(root) return walls# Find walls on Level 2level_2_walls = find_walls_on_level(root, "Level 2")print(f"Found {len(level_2_walls)} walls on Level 2")
from specklepy.objects.geometry import Meshdef extract_geometry_by_category(root, category): """Extract all meshes for objects in a category.""" results = [] def traverse(obj): if not isinstance(obj, Base): return # Check if object is in target category has_props = hasattr(obj, "properties") is_match = (has_props and obj.properties.get("category") == category) # Extract geometry if category matches if is_match and hasattr(obj, "displayValue"): display = obj.displayValue obj_name = getattr(obj, "name", "unnamed") meshes = [] if isinstance(display, Mesh): meshes = [display] elif isinstance(display, list): meshes = [d for d in display if isinstance(d, Mesh)] for mesh in meshes: results.append({ "name": obj_name, "mesh": mesh, "properties": obj.properties }) # Recurse (skip displayValue) for name in obj.get_member_names(): if name != "displayValue": value = getattr(obj, name, None) if isinstance(value, Base): traverse(value) elif isinstance(value, list): for item in value: if isinstance(item, Base): traverse(item) traverse(root) return results# Extract all wall mesheswall_meshes = extract_geometry_by_category(root, "Walls")for item in wall_meshes: print(f"Wall: {item['name']}") print(f" Material: {item['properties'].get('material', 'Unknown')}") print(f" Vertices: {len(item['mesh'].vertices) / 3}")