User Guide: Pipe Pooling (Bundling)#

Pooling — also called bundling or packs — lets the router group compatible pipes together so that they run side-by-side as a harness. You can also force pipes of incompatible types to stay apart (segregation).

Pooling is controlled by a PoolingRule that you pass as a global design rule when building the CadMap.

How Pooling Works#

When a PoolingRule is active, every routed pipe joins a pack: the pipes running side by side in one conduit. Before a new pipe is routed, the packs paint their influence on the voxel grid for that pipe’s type: the cells the pipe cannot run through, the first row of cells in contact with the pack, priced down so the new pipe hugs it, and the pack’s own lanes where a pipe still fits. The strength of this attraction (or repulsion for segregation) is controlled by the coefficient of the PoolingMode.

The packs also leave a corridor on the grid: one box around each pack and the row of cells around it. A pipe may join a harness through its corridor, travel along it, leave it and bridge to another harness it never touched. With allow_loops=False, a move that rejoins a harness the pipe has left pays the rule’s cost: it would close a loop between the pipe and the harness. See Loops: Leaving and Rejoining a Harness below. The corridors are also the shared zone, the is_shared attribute, that BranchClampDistanceRule and BranchPortDistanceRule read.

Three objects work together:

  1. PoolingMode — defines the strength and direction (pool vs segregate)

  2. PoolingSpecification — pairs a list of pipe types with a pooling mode

  3. PoolingRule — holds all specifications and is passed to CadMap

PoolingMode#

PoolingMode encodes the strength of the pooling or segregation constraint through a coefficient:

Factory method

Default coeff

Behaviour

PoolingMode.strong_pooling()

1e-14

Near-zero cost alongside existing pipes — very strong attraction

PoolingMode.moderate_pooling()

0.5

Moderate preference to bundle with compatible pipes

PoolingMode.intermediate_pooling()

0.75

Weak preference to bundle

PoolingMode.weak_pooling()

0.99

Almost neutral; slight attraction

PoolingMode.segregation()

0

Strict: pipes must stay apart; no co-routing

PoolingMode.permissive_segregation()

10

Soft segregation: can pass through but not bundle

PoolingMode.expert_mode()

0 (set manually)

No restrictions; set .coeff to any value

PoolingMode.custom(name, description, default_coeff, valid_range)

user-defined

Fully custom mode

from routing.core.design_rules import PoolingMode

mode = PoolingMode.strong_pooling()

# Optionally override the coefficient (outside valid_range it is kept, with a warning)
mode.coeff = 0.01   # slightly weaker than the default 1e-14

The coefficient is a weight multiplier applied to voxels near an existing compatible pipe:

  • 0 < coeff < 1: pooling — cost is reduced → pipes attracted together

  • coeff == 0: strict segregation — voxels occupied by an incompatible pipe become non-traversable

  • coeff > 1: permissive segregation — cost is increased → pipes repelled

PoolingSpecification#

PoolingSpecification specifies which pipe types should be pooled (or segregated) together, and with what mode and minimum distance.

from routing.core.design_rules import PoolingMode, PoolingSpecification

# Pool "TypeA" pipes with "TypeB" pipes — strong bundling
spec_ab = PoolingSpecification(
    types=["TypeA", "TypeB"],
    pooling_mode=PoolingMode.strong_pooling(),
    min_distance=0.0,     # allow pipes to be directly adjacent
    parallel=False,       # generic packs: the pipes gather in a bundle
)

# Keep "TypeC" pipes 50 mm apart from one another
spec_c = PoolingSpecification(
    types=["TypeC"],      # one type: the rule holds between two TypeC pipes
    pooling_mode=PoolingMode.segregation(),
    min_distance=0.05,    # keep 50 mm apart
)

Parameters:

Parameter

Type

Description

types

list of str

Pipe type labels (PipeType.type_) to which this specification applies. Use ["TypeA", "TypeB"] to pool A, A ; B, B and A, B together. Use ["TypeA"] for a self-rule (A with A). With a segregating mode, ["TypeA", "TypeB"] keeps A from B only, and ["TypeA"] keeps A pipes from one another.

pooling_mode

PoolingMode

Strength and direction of the constraint.

min_distance

float

Minimum separation between pipes (metres). 0.0 = pipes can touch.

parallel

bool

False (default): generic packs, the pipes gather in a bundle. True: trays, the pipes lie side by side on the volume they run along (see Parallel Packs: Trays).

max_layers

int or None

Trays only: the layers a tray may stack. Default 2; None for no limit.

aspect

float or None

Trays only: the width over height beyond which the first layer of a tray stops widening. Default None: no limit.

PoolingRule#

PoolingRule assembles multiple PoolingSpecification objects and is passed to CadMap.from_project_inputs.

from routing.core.design_rules import PoolingRule

pooling_rule = PoolingRule(
    pooling_specs=[spec_ab, spec_c],
    min_distance=0.0,          # default separation when no spec matches
    cost=500,                  # cost of a move closing a loop
    allow_loops=False,         # price the moves that rejoin a harness the pipe has left
    name="pooling_rule",
)

Parameters:

Parameter

Type

Description

pooling_specs

list

The list of PoolingSpecification objects.

min_distance

float

Default minimum separation (m) between pipes whose types have no explicit spec. Defaults to 0.0.

cost

float

Cost of a move that rejoins a harness the pipe has left, charged when allow_loops is False (see Loops: Leaving and Rejoining a Harness). Default: 500.0.

allow_loops

bool

True (default): a pipe may rejoin a harness it has left for free. False: such moves pay cost.

Pass the rule as a global design rule:

cadmap = CadMap.from_project_inputs(
    design_rules=[TurnPenalizer(cost=3.0), pooling_rule],
    ruled_volumes=[...],
    # bounding_box=..., voxel_size=...
)

Parallel Packs: Trays#

A specification with parallel=True lays its pipes as in a cable tray: side by side on the face of the volume they run along, then in layers on top of each other. Use it where pipes must lie flat against a floor, a wall or a ramp; generic packs gather compatible pipes in a round bundle instead. A tray only takes the pipes of types pooled as trays, and a generic pack never takes them.

The support. A tray rests on the face its pipes run along. Flag the volumes trays may rest on with support=True; when no volume is flagged, every volume is a support:

floor = RuledVolume.from_volume_model(
    VolumeModel([floor_slab]), [weighting_rule], support=True, name="floor"
)

The map reads the support along the pipes of the tray, from each point of their axes to the nearest point of a support face. The pipes rest on that face when at least half of their length lies within three cells of it, cells of the coarsest grid of the map, and when three readings out of four point the same way, to 30°. A tray whose pipes run away from every support, or whose readings disagree, pools as a generic pack, and reads its support again when it takes a new pipe. Once found, the support is kept. A face met only at the end of the tray, such as the floor under a riser, is not a support.

Layers. On each cut across a tray, the pipes are grouped by the height of their axis over the support, to half a cell of the grid. The lowest group is the first layer, the one of the tray’s first pipe. A higher group is a layer when one of its pipes lies over a pipe of the layer below. A pipe higher than the layers with nothing under it is left out: it counts for nothing and invites no pipe, but its clearance still holds.

Where the next pipe is invited. A cell attracts the next pipe of a tray type when it is a seat of the tray, on the first row of cells in contact with its pipes:

Seat

Invited

Beside the pipes of the first layer

Always: only the first layer widens

Beside a pipe of a higher layer, with a pipe of the layer below or above it

Always

Over the last layer, on its pipes

While the tray has fewer than max_layers layers

Over the last layer, once the tray has max_layers layers

No: the cells there weigh ten times more, up to the new pipe’s contact plus two cells. A pipe still passes there if it must

Under the first layer, overhanging a layer, between two layers

No: the cell keeps its weight

With aspect set, the first layer stops widening once the tray is wider than aspect times its height, and the next pipes stack instead.

trays = PoolingSpecification(
    types=["Cable"],
    pooling_mode=PoolingMode.strong_pooling(),
    min_distance=0.0,
    parallel=True,
    max_layers=2,   # at most two layers; None for no limit
    aspect=None,    # no limit on the width of the first layer
)

Route the largest pipes first. The first layer lies at the height of the first pipe’s axis. A larger pipe routed after smaller ones has its axis higher: it finds no seat on their layer and founds a tray of its own beside them.

Choose the voxel size from the diameters. The router lays each pipe’s axis at a cell centre, so two pipes much thinner than a cell may share one, axis on axis. A voxel size close to the diameters of the tray pipes, or a finer grid where the trays run (see User Guide: Multi-Scale and Multi-Grid Routing), lays them side by side.

The scripts of scripts/parallel_packs/ route trays in three cases: ramp_room_test_case.py, a room with a floor, two walls, a ramp up to a mezzanine and obstacles, which compares the trays with generic packs through model; window_parallel_packs.py, the window case; and large_room_with_obstacles.py, the labyrinth of Tutorial: Routing in Large Spaces with Obstacles.

Loops: Leaving and Rejoining a Harness#

Packs that share a pipe form one component of the harness: a trunk with its branches. Each voxel of the grid carries the set of components whose corridors cover it, 0 outside every corridor. At each cell a route enters, the rule looks at the components the move takes on, those of the entered cell that the left cell did not carry:

Move

Cost

Reading

Stays in a corridor, or takes on no new component

0

The pipe travels along the harness

Leaves every corridor

0

The pipe goes its own way

Takes on a component it never held

0

A bridge: this is how a harness branches

Takes on again a component it has held and left

cost

A loop between the pipe and the strand it left

Loops are allowed by default (allow_loops=True): the table applies once the rule is built with allow_loops=False. The memory is the route’s own path; a Theta* segment is read cell by cell and charged once. The loop is priced, never forbidden: a pipe with no other way through is still routed. Two strands of one component are one component, so a pipe sliding from one to the other inside their corridors is not charged.

Pipe Ordering Matters#

Pipes are routed in the order they appear in the specifications list — a tee excepted, routed after the conduit it taps (see Tutorial: Tees — Tapping a Routed Conduit). Earlier pipes establish the corridor; later pipes with compatible types follow it. Plan your specification order accordingly:

# Route "TypeA" pipes first, then "TypeB" pipes follow their corridors
specifications = type_a_specs + type_b_specs

result = route_planner.generate(
    specifications=specifications,
    n_iterations=1,
)

Accessing Pack Results#

After routing, call collect_packs() to retrieve the grouped pipe bundles:

packs = routing_result.collect_packs(only_optimized=False)

# Visualize the bundles in 3D
routing_result.specific_packs_display_3d(packs)

# Find bifurcation points (where bundles split)
bifurcation_pts = routing_result.cadmap.get_bifurcation_points(
    packs=packs,
    tolerance=0.04,     # grouping tolerance in metres
    with_ports=False,
)

collect_packs returns a list of pack objects. Each pack contains the set of pipes that share a common sub-path. get_bifurcation_points returns the 3D coordinates where packs split or merge.

Complete Example#

import routing.core.legacy_piping as piping
from volmdlr.model import VolumeModel
from volmdlr.step import Step

from routing.core.cadmap import CadMap
from routing.core.core import Port, PipeType, RuledVolume, Specification
from routing.core.design_rules import (
    PoolingMode, PoolingRule, PoolingSpecification, TurnPenalizer, WeightingRule
)
from routing.core.finders import SmartFinder
from routing.core.route_planner import RoutePlanner

# --- Load CAD ---
model = Step.from_file("data/step/Cas_test_windows_V5.step")
volume_model = model.to_volume_model()

weighting_rule = WeightingRule("linear", 0.0, 0.2, min_value=1, max_value=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"
)

# --- Pooling: group TypeA and TypeB together ---
pooling_specs = [
    PoolingSpecification(
        types=["TypeA", "TypeB"],
        pooling_mode=PoolingMode.strong_pooling(),
        min_distance=0.0,
        parallel=False,
    )
]
pooling_rule = PoolingRule(
    pooling_specs=pooling_specs,
    min_distance=0.0,
    cost=500,
    name="pooling_rule",
)

# --- Pipe types ---
section_a = piping.Section(radius_equivalent=0.015)
section_b = piping.Section(radius_equivalent=0.008)
pipe_a = PipeType(section_a, "PipeA", radius_of_curvature_ratio_min=1.5,
                  color=(0, 150, 200), type_="TypeA")
pipe_b = PipeType(section_b, "PipeB", radius_of_curvature_ratio_min=1.5,
                  color=(200, 50, 0), type_="TypeB")

# --- Specifications ---
specs = [
    Specification(
        Port((0.05, -0.4, 0.1), (0, 1, 0), length=0.1),
        Port((0.05,  0.8, 0.1), (0, -1, 0), length=0.1),
        pipe_a, "route_A",
    ),
    Specification(
        Port((0.37, -0.4, 0.3), (0, 1, 0), length=0.08),
        Port((0.45,  0.8, 0.18), (0, -1, 0), length=0.04),
        pipe_b, "route_B",
    ),
]

# --- CadMap with pooling ---
voxel_size = 0.021
bounding_box = CadMap.build_bounding_volume(
    [outer_volume, border_volume], [], voxel_size, voxel_margin=1
)
cadmap = CadMap.from_project_inputs(
    design_rules=[TurnPenalizer(cost=3.0), pooling_rule],
    ruled_volumes=[outer_volume, border_volume],
    bounding_box=bounding_box,
    voxel_size=voxel_size,
    exact_distances=True,
)

# --- Route and inspect packs ---
route_planner = RoutePlanner(cadmap, SmartFinder("AStar", "manhattan", "never"))
result = route_planner.generate(specifications=specs, n_iterations=1)

packs = result.collect_packs(only_optimized=False)
result.specific_packs_display_3d(packs)

bifurcation_pts = result.cadmap.get_bifurcation_points(packs, tolerance=0.04)
print("Bifurcation points:", bifurcation_pts)

Next Steps#