Tutorial: Routing Zones#

The zone rules let you define arbitrary 3D zones that shape where pipes are routed — independent of the CAD geometry of the assembly itself. You can make the router:

Zones are defined by any volmdlr solid or VolumeModel — a box, a cone, a sphere, or an imported CAD body.

Overview#

from routing.core.design_rules import ForbiddenZone, ForcedZone, WeightedZone

Rule

Behaviour inside the shape

When to use

WeightedZone(shape, coefficient), coefficient < 1

Weight × coefficient (lower = stronger)

Support corridors, preferred routeways

WeightedZone(shape, coefficient), coefficient > 1

Weight × coefficient (higher = stronger)

Hot zones, vibration areas (avoidable)

ForbiddenZone(shape, cut_ports)

Weight = 0 (non-traversable)

Strict constraints: no routing allowed

ForcedZone(shape)

Every route passes through, in stages

Grommets, bulkhead penetrations, imposed passages

Zone rules are global — pass them in design_rules to from_project_inputs(), not to individual RuledVolume objects.

Note

ShapedWeightingRule and its attractive_zone / repulsive_zone / forbidden_zone / forced_zone constructors are deprecated: they build the corresponding zone above and emit a DeprecationWarning. See Migrating to v3.0.0.

Creating Shapes#

Zones can be defined using any volmdlr solid. The most common approach is to build a simple primitive from a bounding box:

from volmdlr.core import BoundingBox
from volmdlr.shapes import Solid

# A rectangular box zone defined by its extent
bbox = BoundingBox(xmin=0.3, xmax=0.5, ymin=0.5, ymax=0.8, zmin=0.1, zmax=0.3)

box_shape = Solid.make_box(
    length=bbox.x_length,
    width=bbox.y_length,
    height=bbox.z_length,
    frame=bbox.to_frame(),
    frame_centered=True,
    name="my_zone",
)

# A cone-shaped zone
cone_shape = Solid.make_cone(
    radius1=0.05,
    radius2=0.15,
    height=0.3,
    frame=bbox.to_frame(),
    name="cone_zone",
)

You can also use a solid imported from a STEP file for complex zone shapes.

Note

The zone is built from the triangulated mesh of the shape, so the shape must expose triangulation() or to_mesh(): volmdlr.shapes.Solid, volmdlr.shapes.Shell, VolumeModel and the deprecated volmdlr.primitives3d.Block / volmdlr.shells.ClosedShell3D all qualify. Containment does not read the mesh orientation, so the inconsistent normals of the deprecated types are harmless here.

A weighted or forced zone covers the voxels whose centre lies in the shape: the shape is voxelized filled on the routing lattice, and its outer shell is judged centre by centre. A forbidden zone stays conservative and claims every voxel its surface crosses, up to one voxel wider than its CAD shape on every face. Both the weighting and the cut of a port extension read that same voxel set, so a port cut by a forbidden zone (ForbiddenZone(..., cut_ports=True)) stops on the last voxel the zone does not claim and keeps a traversable neighbourhood. Expect the cut length to be a voxel shorter than the distance from the port to the CAD surface of the zone.

Attractive Zone#

A weighted zone with a coefficient below 1 reduces the routing cost inside the shape — the router will prefer to pass through this area.

from routing.core.design_rules import WeightedZone
from volmdlr.core import BoundingBox
from volmdlr.shapes import Solid

# Define a support corridor
bbox = BoundingBox(xmin=0.7, xmax=0.8, ymin=0.5, ymax=0.6, zmin=0.0, zmax=0.3)
support_shape = Solid.make_cone(
    radius1=0.05, radius2=0.15, height=0.3,
    frame=bbox.to_frame(), name="support_zone",
)

attractive_rule = WeightedZone(
    support_shape,
    coefficient=1e-6,   # near-zero multiplier: very strongly attractive
    name="support_corridor",
)

Parameter:

  • coefficient (float, 0 < coefficient < 1): Weight multiplier inside the zone. A value of 0.1 makes routing through the zone 10× cheaper. 1e-6 makes it almost free (the path will almost always pass through if reachable).

Tip

Use coefficient=0.1 for a mild preference. To guarantee the passage, use a forced zone instead of a near-zero coefficient: a cheap zone still loses to a shorter route.

Repulsive Zone#

A weighted zone with a coefficient above 1 increases the routing cost inside the shape — the router avoids it but can still route through if there is no other option.

bbox = BoundingBox(xmin=0.3, xmax=0.5, ymin=0.5, ymax=0.8, zmin=0.1, zmax=0.3)
hot_shape = Solid.make_box(
    length=bbox.x_length, width=bbox.y_length, height=bbox.z_length,
    frame=bbox.to_frame(), frame_centered=True, name="hot_zone",
)

repulsive_rule = WeightedZone(
    hot_shape,
    coefficient=1000,   # weight × 1000 inside: very strongly avoided
    name="hot_zone",
)

Parameter:

  • coefficient (float, > 1): Weight multiplier inside the zone. coefficient=5 makes routing through the zone 5× more expensive. coefficient=1000 makes it very unlikely but not impossible.

Repulsive zones are suitable for areas that are undesirable but not strictly forbidden (e.g., thermal hot-spots, vibration sources).

Forbidden Zone#

A forbidden zone blocks all routing inside it — voxels the shape touches are marked as non-traversable.

from routing.core.design_rules import ForbiddenZone

bbox = BoundingBox(xmin=0.35, xmax=0.65, ymin=-0.3, ymax=-0.15, zmin=0.2, zmax=0.45)
forbidden_shape = Solid.make_box(
    length=bbox.x_length, width=bbox.y_length, height=bbox.z_length,
    frame=bbox.to_frame(), frame_centered=True, name="vibration_zone",
)

forbidden_rule = ForbiddenZone(
    forbidden_shape,
    cut_ports=False,   # raise an error if a port extension enters the zone
    name="vibration_constraint",
)

Parameter:

  • cut_ports (bool, default False): Controls what happens when a port’s straight extension passes through the forbidden zone.

    • False: raise a ValueError if the port extension enters the zone.

    • True: truncate the port extension on the last voxel before the zone.

Warning

If a port itself (not just its extension) is inside the forbidden zone, a ValueError is always raised regardless of cut_ports. Move the port or the zone.

Forced Zone#

A forced zone is a mandatory passage: generate() routes every conduit through each forced zone of the map. The passage is not a matter of weights — the route is searched in stages: from the start port to the nearest reachable zone, from there to the next zone left, and from the last zone to the end port. The resulting route is one path.

from routing.core.design_rules import ForcedZone

bbox = BoundingBox(xmin=0.65, xmax=0.735, ymin=-0.065, ymax=0.0, zmin=0.168, zmax=0.336)
grommet_shape = Solid.make_box(
    length=bbox.x_length, width=bbox.y_length, height=bbox.z_length,
    frame=bbox.to_frame(), frame_centered=True, name="tunnel_grommet",
)

forced_rule = ForcedZone(grommet_shape, name="tunnel_grommet")

Forced zones are suitable for pre-defined passage points — a grommet, a bulkhead penetration, a duct every conduit must run through.

How the passage is searched:

  • Doors. On the routing grids, a zone is a set of cells (CadMap.grid_forced_zones lays each ForcedZone as a GridForcedZone). Its doors are the cells of the zone that open on a free cell outside it; only a part of the zone holding at least two doors — an entry and a distinct exit — is targeted, so a pocket the CAD or the packs enclose is never aimed at.

  • Stages. Each stage searches from the head of the route toward the doors of every zone left, at once; the first door reached ends the stage, and its zone is counted as passed. The cells the route already holds are closed for the next stages: a route never runs twice over one cell. The last stage joins the end port.

  • Order. With several forced zones, the order of the rules in design_rules does not matter: the zones are passed in the order the search reaches them, the nearest first. A route is not permuted afterwards — a zone touched first is passed first.

  • One path. Through forced zones only the best path is searched: a conduit gets one candidate whatever n_iterations, and n_iterations of 1 or more logs a message at INFO level.

  • Optimization. The forced zones hold the generated path only: the optimizer does not hold a conduit inside its forced zones yet, and an optimized geometry may leave a zone the generated path crossed. A fix is planned for 3.1.0.

Note

A conduit that cannot reach a door, or the end port from the last zone, is not routed: generate records its Solution with is_routed false, warns, and leaves it out of pooling, optimization and harness generation (RoutingResults.routed_solutions). In 3D it is drawn as its two port stubs, in the pipe type colour. The usual cause is a zone walled in by the packs of the conduits routed before: a small zone at a tunnel mouth fills up. A zone with no door at all is dropped with a warning and the conduit is routed without it.

Combining Multiple Zones#

Pass all zone rules together with other global rules to CadMap.from_project_inputs():

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

design_rules = [
    TurnPenalizer(cost=1.0),
    attractive_rule,
    forced_rule,
    forbidden_rule,
    repulsive_rule,
]

cadmap = CadMap.from_project_inputs(
    design_rules=design_rules,
    ruled_volumes=[outer_volume, border_volume],
    bounding_box=bounding_box,
    voxel_size=0.021,
    exact_distances=True,
    weights_operator="min_distance",  # recommended for routing; the default is "min"
)

Weighted and forbidden zones are applied after the distance-based weighting rules: they multiply the base weights inside the zone, by coefficient, by zero for a forbidden zone. Forced zones weigh nothing: they drive the search.

Complete Example#

from volmdlr.core import BoundingBox
from volmdlr.model import VolumeModel
from volmdlr.shapes import Solid
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 (
    ForbiddenZone,
    ForcedZone,
    TurnPenalizer,
    WeightedZone,
    WeightingRule,
)
from routing.core.finders import SmartFinder
from routing.core.route_planner import RoutePlanner
import routing.core.legacy_piping as piping

# --- Load assembly ---
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"
)

# --- Define zones ---
# 1. Attractive corridor (cone)
bbox1 = BoundingBox(xmin=0.7, xmax=0.8, ymin=0.5, ymax=0.6, zmin=0.0, zmax=0.3)
attractive_rule = WeightedZone(
    Solid.make_cone(0.05, 0.15, 0.3, frame=bbox1.to_frame(), name="cone"),
    coefficient=1e-6,
    name="support_corridor",
)

# 2. Forbidden region (box)
bbox2 = BoundingBox(xmin=0.35, xmax=0.65, ymin=-0.3, ymax=-0.15, zmin=0.2, zmax=0.45)
forbidden_rule = ForbiddenZone(
    Solid.make_box(
        bbox2.x_length, bbox2.y_length, bbox2.z_length,
        frame=bbox2.to_frame(), frame_centered=True, name="forbidden_box",
    ),
    cut_ports=True,
    name="vibration_zone",
)

# 3. Mandatory passage (box at the mouth of a tunnel)
bbox3 = BoundingBox(xmin=0.65, xmax=0.735, ymin=-0.065, ymax=0.0, zmin=0.168, zmax=0.336)
forced_rule = ForcedZone(
    Solid.make_box(
        bbox3.x_length, bbox3.y_length, bbox3.z_length,
        frame=bbox3.to_frame(), frame_centered=True, name="grommet",
    ),
    name="tunnel_grommet",
)

# --- Pipe and specification ---
pipe_type = PipeType(
    section=piping.Section(radius_equivalent=0.015),
    name="PipeA",
    radius_of_curvature_ratio_min=1.5,
    color=(0, 100, 200),
    type_="TypeA",
)
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 with zones as global rules ---
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=1.0), attractive_rule, forbidden_rule, forced_rule],
    ruled_volumes=[outer_volume, border_volume],
    bounding_box=bounding_box,
    voxel_size=voxel_size,
    exact_distances=True,
    weights_operator="min_distance",  # recommended for routing; the default is "min"
)

# --- Route ---
route_planner = RoutePlanner(cadmap, SmartFinder("AStar", "manhattan", "never"))
result = route_planner.generate(specifications=[spec], n_iterations=0)  # single best path
for solution in result.solutions:
    if not solution.is_routed:
        print(f"{solution.name}: no route through the forced zones")
result.display_3d()  # the CAD and the route; a conduit not routed shows its two port stubs

See the full working examples in scripts/simple_test_cases/shaped_zone_example.py (one grid), shaped_zone_example_multi.py (several grids) and shaped_zone_example_multi_scale.py (coarse and fine grids), and the window case forced through three zones in scripts/simple_test_cases/window_forced_zones.py.

Next Steps#