Tutorial: Engineering Design Rules#

Design rules control how the router weights the space around your CAD geometry. They let you encode engineering constraints — minimum bend radii, gravity drainage slopes, clamping requirements, clearance zones — directly into the routing cost function.

Overview#

Where a rule goes depends on what it does:

  • Distance rules — WeightingRule and DistanceAttributeToggler — go on a RuledVolume: they act relative to its surface.

  • Rules that price moves — TurnPenalizer, GravityRule, StraightLengthRule, ClampingConstraint, the branch rules, a ConstraintRule of your own — go in the global design_rules of from_project_inputs() (every pipe), or on one pipe type through PipeType(user_constraints=[...]). Placed on a RuledVolume, such a rule never reaches the search.

Rule class

Purpose

WeightingRule

Penalize or prefer paths at certain distances from a surface

TurnPenalizer

Add a fixed cost to every direction change

DistanceAttributeToggler

Tag voxels in a distance band with a boolean attribute

GravityRule

Enforce minimum drainage slope (gravity-drained pipes)

StraightLengthRule

Require a minimum straight run before each turn

ClampingConstraint

Mark where pipe clamps can be placed and their spacing

BranchPortDistanceRule

Keep branch junctions spaced apart and away from ports

BranchClampDistanceRule

Enforce clamp spacing where branches enter or leave shared zones

ConstraintRule

Base class for custom user-defined constraint rules

All rule classes live in routing.core.design_rules.

from routing.core.design_rules import (
    WeightingRule, TurnPenalizer, DistanceAttributeToggler,
    GravityRule, StraightLengthRule, ClampingConstraint,
)

WeightingRule#

WeightingRule assigns a cost multiplier to voxels based on their distance from a CAD surface. Higher cost = less preferred path.

Use it to make pipes hug the structure rather than floating in free space.

from routing.core.design_rules import WeightingRule

# Weight rises linearly from 1 (at the surface) to 10 (at 200 mm away)
weighting_rule = WeightingRule(
    function="linear",
    min_length=0.0,   # apply from distance 0 m
    max_length=0.2,   # stop applying at 0.2 m
    min_value=1,      # cost at min_length (near surface)
    max_value=10,     # cost at max_length (far from surface)
)

Parameters:

  • function (str): Shape of the cost curve between min_length and max_length. Choices: "linear", "sqrt" (square root), "constant".

  • min_length / max_length (float): Distance range (metres) over which the rule applies. Outside this range the rule has no effect.

  • min_value / max_value (float): Cost multiplier at the near and far ends of the range. A value of 1.0 is neutral; higher values increase path cost.

Typical patterns:

# Attract — low cost near surface, higher cost away from it
attract_rule = WeightingRule("linear", 0.0, 0.2, min_value=1, max_value=10)

# Repel — high cost close to a hot or vibrating surface
repel_rule = WeightingRule("linear", 0.0, 0.05, min_value=100, max_value=1)

# Constant clearance zone — uniform high cost within 30 mm of an obstacle
clearance_rule = WeightingRule("constant", 0.0, 0.03, min_value=50, max_value=50)

Attach the rule to a RuledVolume:

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

hot_surface = RuledVolume.from_volume_model(
    VolumeModel([engine_primitive]),
    design_rules=[repel_rule],
    name="engine_block",
)

TurnPenalizer#

TurnPenalizer adds a fixed cost to every direction change in the path. It encourages straight runs and discourages unnecessary bends.

Pass it as a global rule (applies to all pipes):

from routing.core.design_rules import TurnPenalizer

turn_penalty = TurnPenalizer(cost=3.0)

cadmap = CadMap.from_project_inputs(
    design_rules=[turn_penalty],
    ruled_volumes=[...],
    ...
)

Parameter:

  • cost (float, default 50.0): Cost added per turn, in units of a single straight-voxel traversal. cost=3.0 means a turn costs as much as traversing 3 more voxels in a straight line. The default of 50.0 strongly discourages turns; lower values allow more bends.

DistanceAttributeToggler#

DistanceAttributeToggler tags all voxels within a distance band with a boolean attribute. The tagged attribute is then used by constraint rules (e.g. ClampingConstraint) to identify valid locations.

from routing.core.design_rules import DistanceAttributeToggler

# Tag voxels 0–80 mm from the surface as "clampable"
clamping_toggler = DistanceAttributeToggler(
    attribute="clampable",
    min_distance=0.0,
    max_distance=0.08,
)

# Tag voxels 50–200 mm from the surface as "user_defined"
zone_toggler = DistanceAttributeToggler(
    attribute="user_defined",
    min_distance=0.05,
    max_distance=0.20,
)

Parameters:

  • attribute (str): Name of the boolean attribute to set on matching voxels. Standard names: "clampable" (used by ClampingConstraint), "user_defined" (available for custom constraint rules).

  • min_distance / max_distance (float): Distance range (metres) from the surface. Only voxels within this band receive the attribute.

Attach it to the RuledVolume whose surface it measures from, next to the volume’s WeightingRule:

structural_beam = RuledVolume.from_volume_model(
    VolumeModel([beam_primitive]),
    design_rules=[weighting_rule, clamping_toggler],
    name="main_beam",
)

A toggler weighs nothing: a volume carrying only togglers switches attributes on around it and leaves the weights to the volumes that carry a WeightingRule.

GravityRule#

GravityRule enforces a drainage slope for gravity-drained pipes (e.g. drain lines, coolant returns). Slopes are measured from the start port and are negative downward: a path that climbs, or descends less steeply than min_value or more steeply than max_value, receives a high cost. The end port must not lie above the start port. To add a GravityRule common to all pipes, create an instance and add it to the design_rules of the problem’s CadMap.

from routing.core.design_rules import GravityRule

gravity_rule = GravityRule(
    min_value=-0.02,         # descend at least 2 % (0.02 m/m)
    max_value=-0.5,          # descend at most 50 % (optional cap)
    distance_multiplier=1.0, # weight multiplier for violations
)

To get a GravityRule specific to a PipeType, set the corresponding PipeType’s min_slope and/or max_slope attributes so the router adapts routing constraint for concerned pipes:

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

drain_pipe = PipeType(
    section=piping.Section(radius_equivalent=0.025),
    name="DrainLine",
    radius_of_curvature_ratio_min=3.0,
    min_slope=-0.02,   # descend at least 2 %
    color=(0, 180, 100),
    type_="Drain",
)

Parameters:

  • min_value (float, optional): The gentlest descent allowed, in m/m, negative or zero (-0.02 = 2 % down; 0.0 allows level runs); None checks no minimum slope. A positive value forces no descent, and raises ValueError when the routing starts if max_value is set.

  • max_value (float, optional): The steepest descent allowed, in m/m, below min_value (-0.5 = 50 % down); None for no limit. A path never climbs under a GravityRule.

Note

GravityRule is an automatic rule: each conduit builds its own from its PipeType, with min_value=min_slope, max_value=max_slope and distance_multiplier=19.0, as soon as min_slope is negative or max_slope is set. A positive min_slope builds none. To apply the same GravityRule to every conduit, pass it as a global rule in design_rules to CadMap.from_project_inputs, not to individual RuledVolume objects.

StraightLengthRule#

StraightLengthRule penalizes turns that occur before a minimum straight segment. Use this when your pipe or fitting catalogue requires a certain run-in distance before each bend.

from routing.core.design_rules import StraightLengthRule

straight_rule = StraightLengthRule(
    min_value=0.030,           # 30 mm straight required before each turn
    distance_multiplier=2.0,   # violation cost multiplier
)

Pass it in the global design_rules to apply it to every pipe. Each conduit also builds its own from its PipeType: see the note below.

rigid_pipe = PipeType(
    section=piping.Section(radius_equivalent=0.010),
    name="RigidTube",
    radius_of_curvature_ratio_min=5.0,
    length_before_turn_min=0.030,    # 30 mm before each turn, plus two bend radii
    color=(200, 100, 0),
    type_="Rigid",
)

Parameters:

  • min_value (float): Minimum straight run (metres) required before each direction change.

  • distance_multiplier (float, default 500.0): Scales the cost for violating the straight-length requirement.

Note

StraightLengthRule is also an automatic rule: each conduit builds its own from its PipeType, with min_value = length_before_turn_min + 2 × bend radius and distance_multiplier=19.0, whenever that sum is not zero — in practice as soon as radius_of_curvature_ratio_min is set. To apply the same StraightLengthRule to every conduit, pass it as a global rule in design_rules to CadMap.from_project_inputs, not to individual RuledVolume objects.

ClampingConstraint#

ClampingConstraint makes sure a routed pipe can be supported by clamps (pipe supports) at regular intervals. It works in combination with a DistanceAttributeToggler that marks which voxels are close enough to a surface to accept a clamp.

from routing.core.design_rules import ClampingConstraint

clamping_rule = ClampingConstraint(
    clamp_length=0.04,         # a clamp needs 40 mm of clampable run
    clamp_step=0.25,           # at most 250 mm between two clampable runs
    distance_multiplier=19.0,  # weight multiplier for clamping cost
)

The DistanceAttributeToggler that sets the "clampable" attribute goes on the volume the clamps hold to; the constraint goes on the pipe types that need clamps, or in the global design_rules for every pipe:

support_frame = RuledVolume.from_volume_model(
    VolumeModel([frame_primitive]),
    design_rules=[weighting_rule, DistanceAttributeToggler("clampable", 0.0, 0.08)],
    name="support_frame",
)

clamped_pipe = PipeType(
    section=piping.Section(radius_equivalent=0.008),
    name="ClampedPipe",
    radius_of_curvature_ratio_min=1.5,
    type_="TypeA",
    user_constraints=[ClampingConstraint(clamp_length=0.04, clamp_step=0.25)],
)

Parameters:

  • clamp_length (float): The shortest clampable run a clamp can use (metres); a shorter clampable stretch does not count as a clamping point. Should match the physical clamp geometry.

  • clamp_step (float): The longest run allowed without a clampable stretch (metres).

  • distance_multiplier (float, default 19.0): Scaling factor for the clamping cost contribution.

Custom Constraint Rules#

You can subclass ConstraintRule to define your own constraint logic. There are two distinct methods to override, each serving a different purpose:

  • compute_heuristic is called at every move, by every finder (Theta* included). It steers the pathfinder in real time — returning a penalty causes the algorithm to avoid that move. Without this method the pathfinder will happily explore violating paths and only detect the problem afterward. During the search, the node it receives is a SearchStateNode, a state of the compiled search (see User Guide: Design Rules Reference).

  • fast_check is called on a complete candidate path during post-routing optimization. It validates a batch of voxels once the route has been found.

A robust custom constraint implements both:

import numpy as np

from routing.core.design_rules import ConstraintRule

class UserDefinedConstraint(ConstraintRule):
    """Only allow routing through voxels tagged 'user_defined'."""

    def __init__(self, name: str = ""):
        super().__init__(attribute="user_defined", name=name)

    def compute_heuristic(self, node, neighbor, vector):
        """Real-time enforcement: penalize moves toward un-tagged voxels."""
        if not neighbor.get_custom_attr(self.attribute):
            return self.distance_multiplier
        return 0.0

    def fast_check(self, voxels, voxel_size, bool_matrix):
        """Post-routing validation: penalize a path running through un-tagged voxels."""
        # bool_matrix is True where the 'user_defined' attribute is set
        voxels = np.asarray(voxels, dtype=int).reshape(-1, 3)
        if len(voxels) and not bool_matrix[tuple(voxels.T)].all():
            return self.distance_multiplier
        return 0.0

Use your constraint exactly like the built-in ones: the DistanceAttributeToggler on the volume, the constraint on the pipe types it applies to, or in the global design_rules. The attribute name passed to super().__init__() must match the toggler’s:

structure = RuledVolume.from_volume_model(
    VolumeModel([structure_primitive]),
    design_rules=[weighting_rule, DistanceAttributeToggler("user_defined", 0.05, 0.20)],
    name="structure",
)

guided_pipe = PipeType(
    section=piping.Section(radius_equivalent=0.008),
    name="GuidedPipe",
    radius_of_curvature_ratio_min=1.5,
    type_="TypeA",
    user_constraints=[UserDefinedConstraint()],
)

A toggler on the volume steers the search only: the optimizer calls fast_check for an attribute a pipe type toggles itself, through PipeType(user_properties=[UserProperty("user_defined", 0.05, 0.20, ruled_volumes=[structure])]).

Branch-Distance Rules#

When several pipes share a route they bundle together and split apart at branch junctions (bifurcations). Two rules govern the spacing of those junctions. Both are global rules — add them to the design_rules list passed to from_project_inputs().

BranchPortDistanceRule#

BranchPortDistanceRule keeps branch junctions a minimum distance from one another, and keeps branches a minimum distance from connector ports. This stops bifurcations from piling up on top of each other, or forming right at a port where there is no room for the fitting.

from routing.core.design_rules import BranchPortDistanceRule

branch_rule = BranchPortDistanceRule(
    branches_min_distance=0.1,      # >= 100 mm between two junctions
    port_branch_min_distance=0.06,  # >= 60 mm between a branch and any port
)

You do not wire anything up yourself: the router converts the distances to its grid and refreshes the rule between routing iterations. Just place the rule in the global design_rules list alongside your other rules:

from routing.core.design_rules import TurnPenalizer

design_rules = [
    TurnPenalizer(cost=3.0),
    pooling_rule,   # a PoolingRule — see the pooling guide
    branch_rule,
]

cadmap = CadMap.from_project_inputs(
    design_rules=design_rules,
    ruled_volumes=[outer_volume, border_volume],
    bounding_box=bounding_box,
    voxel_size=voxel_size,
    exact_distances=True,
)

After routing you can inspect the junctions the router produced:

result = route_planner.generate(specifications=specifications, n_iterations=1)
collected_packs = result.collect_packs(only_optimized=False)

bifurcation_points = result.cadmap.get_bifurcation_points(
    packs=collected_packs, tolerance=voxel_size * 2, with_ports=True
)

See BranchPortDistanceRule for the full parameter table, including max_pipes_per_branch and neutral_fiber_condition.

BranchClampDistanceRule#

BranchClampDistanceRule enforces clamp spacing where a branch enters or leaves a shared zone. Like ClampingConstraint, it reads the clampable voxel attribute, so it must be paired with a DistanceAttributeToggler that sets "clampable" on the relevant volume.

Note

Both branch rules read the shared zone as the corridors of the packs (is_shared): the box around each pack and its row of contact cells, one voxel longer at each end — 3.6 to 6.9 times as many cells as the cells the packs attract, on the window case.

from routing.core.design_rules import BranchClampDistanceRule

clamp_rule = BranchClampDistanceRule(
    branch_clamp_min_distance=0.05,  # clamp engagement at a shared-zone boundary
    clamps_min_distance=0.1,         # shortest allowed clampable segment
    clamps_max_distance=0.3,         # longest gap allowed without a clampable point
)

See BranchClampDistanceRule for details.

Putting It All Together#

Here is a complete example that combines several design rules for a multi-pipe scenario with gravity drainage and clamping requirements:

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

from routing.core.cadmap import CadMap
from routing.core.core import Port, PipeType, RuledVolume, Specification
from routing.core.design_rules import (
    ClampingConstraint,
    DistanceAttributeToggler,
    GravityRule,
    StraightLengthRule,
    TurnPenalizer,
    WeightingRule,
)
from routing.core.finders import SmartFinder
from routing.core.route_planner import RoutePlanner

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

# --- Per-volume rules: distances to the surface ---
structure_volume = RuledVolume.from_volume_model(
    VolumeModel([volume_model.primitives[0]]),
    design_rules=[
        # Prefer routing near the surface
        WeightingRule("linear", 0.0, 0.2, min_value=1, max_value=8),
        # Tag near-surface voxels as clampable
        DistanceAttributeToggler("clampable", 0.0, 0.08),
    ],
    name="main_structure",
)

hot_component = RuledVolume.from_volume_model(
    VolumeModel([volume_model.primitives[1]]),
    design_rules=[
        # Keep pipes at least 50 mm away from this hot component
        WeightingRule("constant", 0.0, 0.05, min_value=500, max_value=500),
    ],
    name="hot_component",
)

# --- Global rules ---
global_rules = [
    TurnPenalizer(cost=3.0),
    GravityRule(min_value=-0.02),       # descend at least 2 %
    StraightLengthRule(min_value=0.03),
]

# --- Drain pipe type (requires slope and clamps) ---
drain_pipe = PipeType(
    section=piping.Section(radius_equivalent=0.012),
    name="DrainLine",
    radius_of_curvature_ratio_min=3.0,
    length_before_turn_min=0.03,
    min_slope=-0.02,                    # descend at least 2 %
    color=(0, 180, 100),
    type_="Drain",
    # a clamp needs 40 mm of clampable run, at most 250 mm between two
    user_constraints=[ClampingConstraint(clamp_length=0.04, clamp_step=0.25)],
)

# --- Ports and specification ---
spec = Specification(
    start=Port((0.05, -0.3, 0.2), (0, 1, 0), length=0.05, name="drain_in"),
    end=Port((0.3, 0.8, 0.05), (0, -1, 0), length=0.05, name="drain_out"),
    pipe_type=drain_pipe,
    name="drain_route",
)

# --- Build CadMap ---
voxel_size = 0.018
bounding_box = CadMap.build_bounding_volume(
    [structure_volume, hot_component], [], voxel_size
)
cadmap = CadMap.from_project_inputs(
    design_rules=global_rules,
    ruled_volumes=[structure_volume, hot_component],
    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)
result.plot_data_generated().plot()

Next Steps#