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#

Design rules are attached either to a RuledVolume (so they apply relative to a specific CAD surface) or passed globally to from_project_inputs() (so they apply everywhere).

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 to a RuledVolume like any other rule:

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

GravityRule#

GravityRule enforces a minimum drainage slope for gravity-drained pipes (e.g. drain lines, coolant returns). Paths that slope insufficiently receive a high cost. 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,          # minimum 2% slope (0.02 m/m)
    max_value=0.5,           # maximum 50% slope (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,    # minimum 2% grade — must match the GravityRule
    color=(0, 180, 100),
    type_="Drain",
)

Parameters:

  • min_value (float): Minimum acceptable slope in m/m (e.g. 0.02 = 2%).

  • max_value (float, optional): Maximum acceptable slope in m/m. Set to None to allow any upward slope.

Note

GravityRule is an automatic rule — it is automatically instantitated by the CadMap object when interpreting PipeType. So to use a GravityRule for a specific Specification just specify min and max slopes in PipeType. In order to use the same GravityRule for all Specification, one can build a GravityRule as a global rule — pass it 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
)

Also set length_before_turn_min on the PipeType:

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,    # same value as StraightLengthRule
    color=(200, 100, 0),
    type_="Rigid",
)

Parameters:

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

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

Note

StraightLengthRule is also an automatic rule — it is automatically instantitated by the CadMap object when interpreting PipeType. So to use a StraightLengthRule for a specific Specification just specify the min_straight_length attribute of PipeType. In order to use the same StraightLengthRule for all Specification, one can build a StraightLengthRule as a global rule — pass it in design_rules to CadMap.from_project_inputs, not to individual RuledVolume objects.

ClampingConstraint#

ClampingConstraint specifies how clamps (pipe supports) are to be placed along the routed pipe. 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,         # clamp point is 40 mm from the surface
    clamp_step=0.25,           # clamps must be spaced ≥ 250 mm apart
    distance_multiplier=1.0,   # weight multiplier for clamping cost
)

This rule is attached to the same RuledVolume as the DistanceAttributeToggler that sets the "clampable" attribute:

support_frame = RuledVolume.from_volume_model(
    VolumeModel([frame_primitive]),
    design_rules=[
        DistanceAttributeToggler("clampable", 0.0, 0.08),
        ClampingConstraint(clamp_length=0.04, clamp_step=0.25),
    ],
    name="support_frame",
)

Parameters:

  • clamp_length (float): Distance from the surface at which a clamp sits (metres). Should match the physical clamp geometry.

  • clamp_step (float): Minimum spacing between successive clamps along the pipe (metres).

  • distance_multiplier (float, default 1.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 A* step during pathfinding. 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.

  • ``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:

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 any segment missing the attribute."""
        # bool_matrix is True where the 'user_defined' attribute is set
        if any(bool_matrix[v] for v in voxels):
            return self.distance_multiplier
        return 0.0

Use your constraint exactly like the built-in ones — attach it to a RuledVolume. The attribute name passed to super().__init__() must match the DistanceAttributeToggler on the same volume:

structure = RuledVolume.from_volume_model(
    VolumeModel([structure_primitive]),
    design_rules=[
        DistanceAttributeToggler("user_defined", 0.05, 0.20),
        UserDefinedConstraint(),
    ],
    name="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.

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 ---
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),
        # Clamp spacing: 250 mm apart, 40 mm from surface
        ClampingConstraint(clamp_length=0.04, clamp_step=0.25),
    ],
    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),
    StraightLengthRule(min_value=0.03),
]

# --- Drain pipe type (requires slope) ---
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,
    color=(0, 180, 100),
    type_="Drain",
)

# --- 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#