Tutorial: Routing Pipes Through a CAD Assembly#
This tutorial walks through a complete routing workflow using a window frame assembly — a real mechanical component with two nested volumes that pipes must navigate through. By the end, you will have routed multiple pipes of different types through the assembly and visualized the results.
What you will learn:
Loading a STEP CAD file and preparing volumes
Defining port connection points
Creating pipe types with physical properties
Building a CadMap (the voxelized routing space)
Running the pathfinding step
Accessing and visualizing routing results
Running the optional geometry optimization step
Source script: scripts/simple_test_cases/window_test_case.py
Prerequisites#
Make sure routing is installed:
pip install routing
You also need a STEP file of your assembly. This tutorial uses
data/step/Cas_test_windows_V5.step (included in the repository).
Step 1 — Load the CAD Model#
Load the STEP file using volmdlr and convert it to a VolumeModel:
import os
from pathlib import Path
from volmdlr.step import Step
from volmdlr.model import VolumeModel
step_file = "data/step/Cas_test_windows_V5.step"
model = Step.from_file(step_file)
volume_model = model.to_volume_model()
# Optional: set transparency for visualization
volume_model.primitives[0].alpha = 0.5 # border volume
volume_model.primitives[1].alpha = 0.5 # outer volume
The window model has two volumes:
primitives[0]— the border frame (inner structure)primitives[1]— the outer shell
Pipes will be routed through and around both of these.
Step 2 — Define Weighting Rules#
WeightingRule tells the router how to weight paths
based on their distance from a CAD volume. A higher weight means the router avoids that
region; lower weight means it prefers it.
from routing.core.design_rules import WeightingRule
weighting_rule = WeightingRule(
function="linear", # weight increases linearly with distance
min_length=0.0, # apply from distance 0 m
max_length=0.2, # apply up to distance 0.2 m
min_value=1, # weight = 1 at distance 0 (near surface)
max_value=10, # weight = 10 at distance 0.2 m (far from surface)
)
This rule makes paths prefer to stay close to the CAD surfaces rather than floating in open space.
Available functions: "linear", "sqrt" (square root), "constant".
Step 3 — Wrap Volumes as RuledVolumes#
RuledVolume attaches design rules to a CAD volume. Each
volume independently contributes its rules to the overall routing cost:
from routing.core.core import RuledVolume
outer_volume = RuledVolume.from_volume_model(
VolumeModel([volume_model.primitives[1]]),
design_rules=[weighting_rule],
name="outer",
)
border_volume = RuledVolume.from_volume_model(
VolumeModel([volume_model.primitives[0]]),
design_rules=[weighting_rule],
name="border",
)
Tip
Different volumes can have different rules. For example, you might use a stricter
WeightingRule near moving parts and a gentler one near static structures.
Step 4 — Define Pipe Types#
PipeType defines the physical properties of a pipe.
The most important parameters are the outer radius and the bend radius ratio
(minimum bend radius expressed as a multiple of the pipe diameter):
from piping_3d import piping
from routing.core.core import PipeType
# Large pipe: 30 mm outer diameter
big_section = piping.Section(radius_equivalent=0.030 / 2)
pipe_type_large = PipeType(
section=big_section,
name="LargePipe",
radius_of_curvature_ratio_min=1.5, # bend radius ≥ 1.5 × diameter
length_before_turn_min=0.0, # no mandatory straight before turn
color=(0, 150, 200), # display color (RGB 0–255)
type_="TypeA", # group label (used for pooling)
)
# Small pipe: 10 mm outer diameter, needs 30 mm straight before each turn
small_section = piping.Section(radius_equivalent=0.010 / 2)
pipe_type_small = PipeType(
section=small_section,
name="SmallPipe",
radius_of_curvature_ratio_min=1.5,
length_before_turn_min=0.030, # 30 mm straight before turns
color=(200, 0, 0),
type_="TypeB",
)
Key parameters:
radius_of_curvature_ratio_min: The pipe cannot bend tighter than this multiple of its diameter. Common values: 1.5× for flexible hoses, 3–5× for rigid pipes.length_before_turn_min: Minimum straight length at both ends of every turn. Set to 0 for fully flexible tubing.type_: A string label used to group pipes for pooling (bundling).type_stored in the samePoolingSpecificationwill follow the samePoolingMode.min_slope: Optional minimum slope (metres/metre) for gravity-drained pipes.
Step 5 — Define Ports#
A Port is a connection point in 3D space. Each port has a
position, an orientation (the direction the pipe must exit or enter), and an optional
minimum straight length before the first turn:
from routing.core.core import Port
# A port at (50 mm, -400 mm, 100 mm) pointing in +Y direction
port_in = Port(
coordinates=(0.05, -0.4, 0.1), # (x, y, z) in metres
direction=(0, 1, 0), # pipe exits in +Y
length=0.1, # 100 mm mandatory straight at the port
name="inlet",
)
port_out = Port(
coordinates=(0.05, 0.8, 0.1),
direction=(0, -1, 0), # pipe enters from +Y (points inward)
length=0.1,
name="outlet",
)
Note
The direction does not need to be a unit vector — it is normalized automatically.
The convention is: the direction points away from the port along the pipe.
Step 6 — Create Specifications#
A Specification pairs a start port, end port, and pipe type.
Create one per pipe to route:
from routing.core.core import Specification
spec_large = Specification(
start=port_in,
end=port_out,
pipe_type=pipe_type_large,
name="route_large",
)
For many pipes, it is convenient to define ports as PortCouple objects and build
specifications in a loop:
from routing.core.core import PortCouple
# All port pairs for the assembly
port_pairs = {
"PipeA": PortCouple(
Port(coordinates=(0.05, -0.4, 0.1), direction=(0, 1, 0), length=0.1),
Port(coordinates=(0.05, 0.8, 0.1), direction=(0, -1, 0), length=0.1),
),
"PipeB": PortCouple(
Port(coordinates=(0.37, -0.4, 0.3), direction=(0, 1, 0), length=0.08),
Port(coordinates=(0.45, 0.8, 0.18), direction=(0, -1, 0), length=0.04),
),
}
pipe_types = {"PipeA": pipe_type_large, "PipeB": pipe_type_small}
specifications = [
Specification(port_pairs[name].start, port_pairs[name].end, pipe_types[name], name)
for name in port_pairs
]
Step 7 — Build the CadMap#
The CadMap is the central object: it voxelizes the routing
space, computes distance fields from the CAD surfaces, and holds all design rules.
from routing.core.cadmap import CadMap
from routing.core.design_rules import TurnPenalizer
voxel_size = 0.021 # 21 mm voxels — about 0.7× the large pipe diameter
# Global rules applied to all pipe types
global_rules = [
TurnPenalizer(cost=3.0), # each 90° turn costs 3× a straight voxel
]
# Automatically compute a tight bounding box around all volumes
bounding_box = CadMap.build_bounding_volume(
ruled_volumes=[outer_volume, border_volume],
border_rules=[],
voxel_size=voxel_size,
voxel_margin=1, # add 1 voxel of margin around the bounding box
)
cadmap = CadMap.from_project_inputs(
design_rules=global_rules,
ruled_volumes=[outer_volume, border_volume],
bounding_box=bounding_box,
voxel_size=voxel_size,
exact_distances=True, # use exact CAD mesh distances (slower but accurate)
build_cad=False, # do not rebuild CAD during voxelization
)
Choosing voxel_size:
The voxel size controls the trade-off between accuracy and speed:
Too large: paths may miss narrow passages or violate bend radius constraints
Too small: voxelization becomes slow and memory-intensive
A good starting point:
voxel_size = 0.5 × (largest pipe radius)
exact_distances: When True, distances are computed from the actual CAD mesh
triangles (accurate but slower). When False, distances use the voxel grid
approximation (fast but slightly less accurate for weighting rules).
Step 8 — Find Routing Paths#
Create a RoutePlanner with your CadMap and a
pathfinding algorithm, then call generate():
from routing.core.finders import SmartFinder
from routing.core.route_planner import RoutePlanner
# A* with Manhattan distance heuristic, no diagonal movement
finder = SmartFinder("AStar", "manhattan", "never")
route_planner = RoutePlanner(cadmap, finder)
routing_result = route_planner.generate(
specifications=specifications,
n_iterations=1, # 0 = single best path (default); N = best + N alternatives
steady_mode=False, # False = stop at first failure; True = continue on failures
)
Available finders:
"AStar"— standard A* (fast, grid-aligned paths)"ThetaStar"— Theta* (smoother paths, slightly slower)"Dijkstra"— Dijkstra’s algorithm (slower, always finds shortest path)
Heuristics (second argument): "manhattan", "euclidean", "octile"
Diagonal movement (third argument): "never", "always", "only_when_no_obstacle"
Step 9 — Access Results#
The routing_result object contains all found paths:
# Visualize the generated (grid-aligned) paths
routing_result.plot_data_generated().plot()
# Collect pipe bundles (packs) and display them in 3D
packs = routing_result.collect_packs(only_optimized=False)
routing_result.specific_packs_cad_view(packs)
# Find bifurcation points (where bundled pipes split)
bifurcation_pts = routing_result.cadmap.get_bifurcation_points(
packs=packs,
tolerance=voxel_size * 2,
with_ports=False,
)
Step 10 — Optimize Path Geometry (optional)#
The generated paths follow the voxel grid and can look “staircase-like”. The optimization step refines them into smooth, tangent-continuous geometry:
from routing.core.optimizer import Costs, Settings
costs = Costs(
length=1000, # penalize total pipe length
sideways=100, # penalize lateral deviations
short_line=5000, # penalize very short pipe segments
gravity=5000, # penalize slope violations (for gravity-drained pipes)
bend=250, # penalize tight bends
constraints=5000, # penalize constraint violations
interferences=1e8, # strongly penalize CAD interferences
)
settings = Settings(
voxel_size=0.01, # fine voxel size for optimization domain
max_shift=0.1, # max displacement of any control point (m)
stabilization_threshold=5.0, # stop when cost improvement < this threshold
n_iterations=3, # number of refinement passes
max_iter=1000, # max optimizer iterations per pipe
)
routing_result = route_planner.optimize(
costs=costs,
settings=settings,
picked_path="best", # optimize the best-found path (vs "last")
max_refinements=0, # 0 disables re-routing of problem segments (default is 5)
)
# Display optimized paths
routing_result.plot_data_optimized().plot()
packs_opt = routing_result.collect_packs(only_optimized=True)
routing_result.specific_packs_cad_view(packs_opt)
Full Script#
Here is the complete script:
import os
from pathlib import Path
from volmdlr.step import Step
from volmdlr.model import VolumeModel
from piping_3d import piping
from routing.core.cadmap import CadMap
from routing.core.core import Port, PortCouple, PipeType, RuledVolume, Specification
from routing.core.design_rules import TurnPenalizer, WeightingRule
from routing.core.finders import SmartFinder
from routing.core.optimizer import Costs, Settings
from routing.core.route_planner import RoutePlanner
from routing.core.verbose import VerboseLevel, verbose
verbose.set_level(VerboseLevel.INFO)
# Load CAD
model = Step.from_file("data/step/Cas_test_windows_V5.step")
volume_model = model.to_volume_model()
# Design rules
weighting_rule = WeightingRule("linear", 0.0, 0.2, 1, 10)
outer_volume = RuledVolume.from_volume_model(
VolumeModel([volume_model.primitives[1]]), [weighting_rule], name="outer"
)
border_volume = RuledVolume.from_volume_model(
VolumeModel([volume_model.primitives[0]]), [weighting_rule], name="border"
)
# Pipe types
section = piping.Section(radius_equivalent=0.015)
pipe_type = PipeType(section=section, name="PipeA", radius_of_curvature_ratio_min=1.5,
color=(0, 150, 200), type_="TypeA")
# Ports and specifications
spec = Specification(
start=Port((0.05, -0.4, 0.1), (0, 1, 0), length=0.1),
end=Port((0.05, 0.8, 0.1), (0, -1, 0), length=0.1),
pipe_type=pipe_type,
name="route_A",
)
# CadMap
voxel_size = 0.021
bounding_box = CadMap.build_bounding_volume([outer_volume, border_volume], [], voxel_size)
cadmap = CadMap.from_project_inputs(
design_rules=[TurnPenalizer(cost=3.0)],
ruled_volumes=[outer_volume, border_volume],
bounding_box=bounding_box,
voxel_size=voxel_size,
exact_distances=True,
)
# Route
route_planner = RoutePlanner(cadmap, SmartFinder("AStar", "manhattan", "never"))
result = route_planner.generate(specifications=[spec], n_iterations=1) # best + 1 alternative
# Display
result.plot_data_generated().plot()
Next Steps#
Tutorial: Engineering Design Rules — add engineering constraints (gravity slope, clamping, straight lengths)
Tutorial: Shaped Routing Zones — define zones that routing must avoid, prefer, or pass through
User Guide: Pipe Pooling (Bundling) — bundle compatible pipes into organized packs
User Guide: Path Geometry Optimization — tune the geometry optimizer