User Guide: Core Concepts#

This page explains the main objects you interact with in every routing workflow. Understanding these classes and how they fit together is the foundation for using routing effectively.

../_images/user_guide.svg

Overview#

Every routing workflow follows the same structure:

  1. Define inputs: Port, PipeType, Specification, and TeeSpecification for a conduit that taps another one

  2. Define the routing space: RuledVolume, CadMap

  3. Find paths: RoutePlanner, which routes the specifications in the order a RoutePlanning gives

  4. Read results: RoutingResults

Here is how they relate:

Port (start) ──┐
               ├──► Specification ──┐
Port (end)   ──┘                    │
PipeType ──────────────────────────►│
                                    │
VolumeModel ──► RuledVolume ──────►CadMap ──► RoutePlanner.generate(specs) ──► RoutingResults
DesignRules ──►────────────────────►│
                                    │
(optional) ──────── RoutePlanner.optimize() ──► refined RoutingResults

Port#

A Port is a fixed connection point in 3D space where a pipe starts or ends. It has a position, an orientation, and an optional minimum straight length.

from routing.core.core import Port

port = Port(
    coordinates=(0.05, -0.4, 0.1),    # (x, y, z) in metres
    direction=(0, 1, 0),               # pipe exits in the +Y direction
    length=0.1,                        # 100 mm straight before the first turn
    name="inlet_port",
)

Parameters:

  • coordinates (tuple of 3 floats): The (x, y, z) position of the port in the global coordinate frame (metres).

  • direction (tuple of 3 floats): The direction the pipe must leave this port (for a start port) or arrive (for an end port). Does not need to be a unit vector — it is normalized automatically.

  • length (float, default 0.0): Minimum straight segment length at the port before the first direction change. Required for pipes with minimum bend radius constraints.

  • len_adaptable_with_environment (bool, default False): When True, the router will automatically extend the port length if the immediate environment would cause collisions. This attribute is not used in generic mode, it is an attribute for custom algorithms.

  • permitted_departure_angle (list of float, optional): If set, restricts the departure direction to a list of allowed angles (in radians). Leave as None to permit all directions. This attribute is not used in generic mode, it is an attribute for custom algorithms.

Importing ports from Excel:

For large assemblies, define ports in an Excel spreadsheet and import them:

# Excel columns: name | coordinates_x | coordinates_y | coordinates_z
#                    | direction_x | direction_y | direction_z | length
with open("ports.xlsx", "rb") as f:
    ports = Port.from_xlsx_stream(f)

PipeType#

A PipeType defines the physical properties of a class of pipe. You reuse the same PipeType for all pipes of that specification.

import routing.core.legacy_piping as piping
from routing.core.core import PipeType

section = piping.Section(radius_equivalent=0.015)  # 30 mm outer diameter

pipe_type = PipeType(
    section=section,
    name="HydraulicLine",
    radius_of_curvature_ratio_min=1.5,    # bend radius ≥ 1.5 × outer radius
    length_before_turn_min=0.030,         # 30 mm straight before any turn
    color=(0, 100, 200),                  # visualization colour (RGB 0–255)
    type_="Hydraulic",                    # group label for pooling
)

Key parameters:

  • section: A piping.Section object specifying the outer radius of the pipe (radius_equivalent).

  • radius_of_curvature_ratio_min (float): Minimum bend radius as a multiple of the pipe outer radius (section.radius_equivalent).

  • length_before_turn_min (float): Minimum straight length required before any direction change. Set to 0 for fully flexible tubing.

  • type_ (str): A group label. Pipes with the same type_ can be pooled together (see User Guide: Pipe Pooling (Bundling)).

  • min_slope / max_slope (float, optional): For gravity-drained pipes, set min_slope to the minimum required slope (e.g. 0.02 = 2% grade). See User Guide: Design Rules Reference for GravityRule.

  • priority_index (int, optional): Routing priority. Lower index = higher priority (routed first when ordering matters). This attribute is not used in generic mode, it is an attribute for custom algorithms.

  • color: RGB tuple, either in 0–1 or in 0–255. Used by the visualization layer.

Compact creation with Specification.from_complete_definition:

If you only have a few pipes and don’t need to reuse PipeType objects, you can skip creating them explicitly:

from routing.core.core import Specification

spec = Specification.from_complete_definition(
    start=port_start,
    end=port_end,
    radius=0.015,                        # pipe outer radius
    name="HydraulicLine_1",
    radius_of_curvature_ratio=1.5,
    min_straight_length=0.030,
    pooling_type="Hydraulic",
    color=(0, 100, 200),
)

Specification#

A Specification is a routing requirement: it pairs a start port, end port, and pipe type into one object that RoutePlanner.generate() processes.

from routing.core.core import Specification

# Standard construction
spec = Specification(
    start=port_start,
    end=port_end,
    pipe_type=pipe_type,
    name="HydraulicLine_route_1",
)

# Shorthand: use pipe_type.name automatically
spec = Specification.from_ports(port_start, port_end, pipe_type)

Create one Specification per pipe to route. Pass a list of them to generate().

A tee is a conduit from its own port to anywhere along another conduit. Declare it with a TeeSpecification: its port, the conduit it taps — that conduit’s specification, or a conduit already routed — and its pipe type. The router routes it after the conduit it taps and chooses where it taps, on the straight parts of that conduit; you may restrict it to declared sites (tap_sites) or have it routed once per site to compare them (explored_step):

from routing.core.core import TapSite, TeeSpecification

branch = TeeSpecification(
    port=branch_port,         # the tee's own port
    tapped=spec,              # the conduit it taps
    pipe_type=branch_type,
    tap_sites=[TapSite(0.3)], # optional: tap 0.3 m along the conduit, nowhere else
    name="branch",
)

A tee and the conduit it taps go in the same list, in any order. See Tutorial: Tees — Tapping a Routed Conduit.

RoutePlanning#

A RoutePlanning is the order a list of specifications is routed in, with one Solution per conduit — planned, not routed yet. generate() plans the list it receives by itself; plan it yourself to read the order before routing, to know which tees hang on a conduit, or to keep the plan:

from routing.core.route_planner import RoutePlanning

planning = RoutePlanning.from_specifications([branch, spec])
print([solution.name for solution in planning.solutions])
# ['HydraulicLine_route_1', 'branch']: the tee after the conduit it taps

result = route_planner.generate(planning)

The order follows three rules:

  • a conduit is routed after the conduits it depends on: a tee after the conduit it taps, a tee of a tee after that tee;

  • the order you give is kept otherwise: an entry whose conduit is not placed yet waits, and each placement releases the waiting entries in their order. Each move is reported at INFO level (branch is routed after the conduit it taps.);

  • a tee whose conduit is missing from the list, or tees tapping one another in a cycle, raise ValueError before anything is routed.

Each solution takes the name of its specification, else the name of its pipe type; a name several specifications share takes an index suffix in planning order (hydraulic_1, hydraulic_2), with a warning when you gave that name twice.

planning.dependents(solution) lists the tees built on a conduit, directly or through other tees, in planning order: what moves when that conduit changes. A planning serializes like any DessiaObject (to_dict, dict_to_object), each tee keeping its link to the solution of the conduit it taps.

RuledVolume#

A RuledVolume wraps a 3D CAD volume (a VolumeModel from volmdlr) and attaches a list of design rules to it. The design rules control how the routing algorithm weights the space near that volume.

from routing.core.core import RuledVolume
from routing.core.design_rules import WeightingRule
from volmdlr.model import VolumeModel

# A weighting rule: routes prefer to stay close to this volume
weighting_rule = WeightingRule(
    function="linear",
    min_length=0.0, max_length=0.2,
    min_value=1,    max_value=10,
)

# Wrap the CAD volume
ruled_volume = RuledVolume.from_volume_model(
    volume_model=VolumeModel([my_cad_primitive]),
    design_rules=[weighting_rule],
    name="main_structure",
)

Factory methods:

  • RuledVolume.from_volume_model(volume_model, design_rules, name) — the standard way to create a RuledVolume from a volmdlr VolumeModel.

  • RuledVolume.from_ruled_volume(existing_rv, design_rules) — copy an existing RuledVolume and override its global rules.

in_border parameter (default True): the volume counts in the bounding box CadMap.build_bounding_volume() computes for the routing space; set in_border=False to leave it out.

overwrite_distance parameter: up to this distance (metres) from its surface, the volume overrides the weights the CadMap composed from all its volumes with its own, whatever weights_operator gives there — even where another volume is closer. Beyond that distance, the volume weighs like any other. Where two overriding volumes overlap, the lowest weight wins, so a forbidden band (weight 0) is never lifted by a neighbour’s preference. 0.0 (the default) overrides nothing. Typical use: a keep-out band around a component, or a graded band that must hold against the surrounding volumes:

component = RuledVolume.from_volume_model(
    volume_model=VolumeModel([component_primitive]),
    design_rules=[
        WeightingRule(function="linear", min_length=0.0, max_length=0.1, min_value=30, max_value=10),
        WeightingRule(function="constant", min_length=0.1, max_length=0.3, min_value=4),
    ],
    overwrite_distance=0.1,  # the first band holds against every other volume
    name="component",
)

Like the rules, overwrite_distance is read when the CadMap composes its weights: set it before building the map.

support parameter: set support=True on the volumes trays may rest on, such as a floor, a wall or a ramp (see Parallel Packs: Trays). When no volume is flagged, trays rest on every volume.

See User Guide: Design Rules Reference for all available design rule types.

CadMap#

CadMap is the central routing space object. It:

  1. Takes all your RuledVolume objects and voxelizes the free space between them

  2. Computes distance fields from the CAD surfaces to every voxel

  3. Applies the design rules to compute a weight matrix

  4. Stores the result as a compact grid ready for pathfinding

from routing.core.cadmap import CadMap
from routing.core.design_rules import TurnPenalizer

voxel_size = 0.021   # 21 mm — the window example's resolution

# Global rules applied to all pipes
global_rules = [TurnPenalizer(cost=3.0)]

# Auto-compute a bounding box from the volumes
bounding_box = CadMap.build_bounding_volume(
    ruled_volumes=[ruled_vol_1, ruled_vol_2],
    border_rules=[],
    voxel_size=voxel_size,
    voxel_margin=1,
)

# Build the CadMap (this does the voxelization + distance computation)
cadmap = CadMap.from_project_inputs(
    design_rules=global_rules,
    ruled_volumes=[ruled_vol_1, ruled_vol_2],
    bounding_box=bounding_box,
    voxel_size=voxel_size,
    exact_distances=True,
)

Key parameters of from_project_inputs:

Parameter

Default

Description

design_rules

required ([] for none)

Global rules applied to every pipe type (e.g. TurnPenalizer)

ruled_volumes

required

List of RuledVolume objects to voxelize against

bounding_box

None

Bounding RuledVolume; auto-computed if None

voxel_size

0.1

Voxel edge length in metres. A project decision — always set it explicitly (see Choosing voxel_size below).

exact_distances

False

Exact mesh distances (accurate, slower) vs voxel approximation (fast)

weights_operator

"min"

How to combine weights from multiple volumes. "min_distance" is recommended. A volume’s overwrite_distance overrides the result around that volume.

crop_point

None

Keep only the navigable component containing this world point — cuts memory on large assemblies where the routing space is one room among many

build_cad

False

Build visual box primitives per voxel (expensive; use only for debugging)

Routing through a solid body is declared on the volume, not here: RuledVolume.from_volume_model(..., voxel_inside=True) fills the enclosed cavities of that body (see Tutorial: Routing Inside a Closed Volume).

Choosing voxel_size:

There is no universal multiplier — pick the resolution from the narrowest passages your pipes must thread, then validate on the routed result:

  • Halve it if paths look inaccurate or miss narrow passages

  • Double it if voxelization takes too long

  • The window example assembly runs at voxel_size = 0.021 (21 mm)

Memory: cadmap.nbytes reports total memory usage. Large assemblies with fine voxels can require several GB.

RoutePlanner#

RoutePlanner coordinates the pathfinding and optimization. It takes a CadMap and a SmartFinder and exposes two main methods: generate() and optimize().

from routing.core.finders import SmartFinder
from routing.core.route_planner import RoutePlanner

finder = SmartFinder(
    algorithm="AStar",      # "AStar", "ThetaStar", "Dijkstra"
    heuristic="manhattan",  # "manhattan", "euclidean", "octile"
    diagonal="never",       # "never", "always", "only_when_no_obstacle"
)

route_planner = RoutePlanner(cadmap, finder)

generate() — find routing paths:

routing_result = route_planner.generate(
    specifications=[spec1, spec2, spec3],
    n_iterations=1,      # k-shortest paths; 1 = only the best path per pipe
    steady_mode=False,   # True = keep routing the others when one raises
)

Pipes are processed in the order they appear in specifications, except that a tee is routed after the conduit it taps (see RoutePlanning; generate also accepts a planning). Earlier pipes influence the grid weights for later pipes (through the packs they lay, see User Guide: Pipe Pooling (Bundling)). A conduit no path joins is kept as not routed, with a warning, whatever steady_mode (see User Guide: Accessing Routing Results). n_iterations given to generate becomes the planner’s (RoutePlanner(cadmap, finder, n_iterations=...)): it also serves when optimize routes a tee again.

optimize() — refine path geometry:

from routing.core.optimizer import Costs, Settings

routing_result = route_planner.optimize(
    costs=Costs(length=1000, sideways=100, short_line=5000,
                gravity=5000, bend=250, constraints=5000, interferences=1e8),
    settings=Settings(voxel_size=0.01, max_shift=0.1, n_iterations=3, max_iter=1000),
    picked_path="best",    # "best" (the path picked, else the cheapest) or "last" (most recent)
    max_refinements=0,     # extra passes to fix constraint violations
)

The solutions are optimized in the order they were routed, each against the conduits finalized before it; a tee is routed again on the optimized conduit it taps, then optimized. solutions=[...] restricts the optimization to some conduits and keeps the others as they stand. See User Guide: Path Geometry Optimization for a full guide to the optimizer.

explore() — compare the sites of a tee: route_planner.explore(tee_specification, step) routes a tee once per site along the conduit it taps, without laying it, and returns its solution; pick one route and optimize it to lay the tee (see Tutorial: Tees — Tapping a Routed Conduit).

RoutingResults#

RoutingResults is the output of generate() and optimize(). It contains all routing solutions and exposes visualization methods:

# Plot all generated paths (before optimization)
routing_result.plot_data_generated().plot()

# Plot all optimized paths
routing_result.plot_data_optimized().plot()

# Collect pipe bundles (groups of pipes routed together)
packs = routing_result.collect_packs(only_optimized=False)
routing_result.specific_packs_display_3d(packs)

# Get bifurcation points (where bundles split)
bifurcation_pts = routing_result.cadmap.get_bifurcation_points(packs, tolerance=0.04)

# One line per conduit, and one solution by its name
print(routing_result.summary())
solution = routing_result.solution("HydraulicLine_route_1")

Each Solution keeps every route found for its conduit as an Alternative, and stands by one: the cheapest, until you pick another. See User Guide: Accessing Routing Results for a complete guide to reading and exporting results.

Next Steps#