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 —
WeightingRuleandDistanceAttributeToggler— go on aRuledVolume: they act relative to its surface.Rules that price moves —
TurnPenalizer,GravityRule,StraightLengthRule,ClampingConstraint, the branch rules, aConstraintRuleof your own — go in the globaldesign_rulesoffrom_project_inputs()(every pipe), or on one pipe type throughPipeType(user_constraints=[...]). Placed on aRuledVolume, such a rule never reaches the search.
Rule class |
Purpose |
|---|---|
Penalize or prefer paths at certain distances from a surface |
|
Add a fixed cost to every direction change |
|
Tag voxels in a distance band with a boolean attribute |
|
Enforce minimum drainage slope (gravity-drained pipes) |
|
Require a minimum straight run before each turn |
|
Mark where pipe clamps can be placed and their spacing |
|
Keep branch junctions spaced apart and away from ports |
|
Enforce clamp spacing where branches enter or leave shared zones |
|
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_lengthandmax_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.0means a turn costs as much as traversing 3 more voxels in a straight line. The default of50.0strongly 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 byClampingConstraint),"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.0allows level runs);Nonechecks no minimum slope. A positive value forces no descent, and raisesValueErrorwhen the routing starts ifmax_valueis set.max_value (float, optional): The steepest descent allowed, in m/m, below
min_value(-0.5= 50 % down);Nonefor no limit. A path never climbs under aGravityRule.
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_heuristicis 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 aSearchStateNode, a state of the compiled search (see User Guide: Design Rules Reference).fast_checkis 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#
Tutorial: Routing Zones — define spatial zones that routing must avoid, prefer, or pass through
User Guide: Design Rules Reference — complete parameter reference for all rule types
User Guide: Pipe Pooling (Bundling) — bundle compatible pipes into organized packs
User Guide: Path Geometry Optimization — tune the geometry optimizer