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

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
from piping_3d import 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",
)

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
from piping_3d import 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#