Skip to content

Routing with Playdough

Playdough routes photonic waveguides using a high-quality manhattan initializer, with optional gradient-based finetuning for length matching and collision avoidance. The API follows the same pattern as DoRoute — add_route_bundle() modifies a component in-place.

This notebook shows: 1. Fast manhattan routing (no optimization) 2. Finetuning with FinetuneConfig 3. Two-step workflow: route → inspect → finetune 4. Steering the initializer with waypoints

1. Create a Scene

A 3-waveguide bundle with two rectangular obstacles, all on WG (1, 0).

import warnings

import gdsfactory as gf
import playdough as pld
from IPython.core.interactiveshell import InteractiveShell
InteractiveShell.ast_node_interactivity = "all"

# Warnings carry useful geometry info, but the default format prints an absolute
# path from whichever machine ran the notebook. Keep the message, drop the path.
warnings.formatwarning = lambda msg, cat, *a, **k: f"{cat.__name__}: {msg}\n"

pld.config.BACKEND = "hlo"  # use compiled backend (no factory needed)
gf.gpdk.PDK.activate()

# Build the scene
c = gf.Component()
waveguides = gf.components.array(gf.c.straight, columns=1, rows=3, row_pitch=3)
left = c << waveguides
right = c << waveguides
right.move((100, 100))

# Add two obstacles
obstacle = gf.components.rectangle(size=(100, 10))
obs1 = c << obstacle
obs2 = c << obstacle
obs1.ymin = 40
obs2.xmin = 35

c.plot()

png

2. Route

2.a Manhattan Routing

route_bundle() uses the manhattan initializer by default — no optimization, instant results. The returned Router can be finetuned afterwards.

context_component + obstacle_layers extracts keep-out bboxes straight from the scene, so you don't list them by hand.

router = pld.route_bundle(
    start_ports=list(left.ports.filter(orientation=0)),
    end_ports=list(right.ports.filter(orientation=180)),
    context_component=c,        # scan this component for keep-outs...
    obstacle_layers=(1, 0),     # ...on this layer (a list of layers also works)
    min_radius=5.0,
    target_radius=5.0,  # fix bends at 5µm (without this each bend grows to fit → variable radius)
)
print(f"{len(router.obstacles)} obstacle bboxes extracted from the scene")

# Show the manhattan-only result (no optimization)
c1 = c.dup()
c1 << router.build_gds(as_single=True)
c1.plot()
8 obstacle bboxes extracted from the scene





Unnamed_0$1: ports ['r0o1', 'r0o2', 'r1o1', 'r1o2', 'r2o1', 'r2o2'], KCell(name=batch_routes_cad6a2bb5cee466a883003b23222e741, ports=['r0o1', 'r0o2', 'r1o1', 'r1o2', 'r2o1', 'r2o2'], pins=[], instances=[], locked=False, kcl=DEFAULT)

png

routes_df, groups_df = router.length_matching_table()
routes_df
groups_df
    gds route length (µm) actual offset (µm) bends
group route      
0 0 216.030 0.000 6
1 216.030 0.000 6
2 216.030 0.000 6
  gds mean (µm) gds spread (µm) bend spread
group      
0 216.030 0.000 0

2.b Further Finetune with Jax (can be optional)

If you are satisfied with the Mahattan routes, you can stop here and accept the generated routes.

But if there are extra constraints to satisfy, such as path-length-matching or DRC rules, you can enter the finetune stage to optimize for satisfying these constraints.

# router.finetune(lr=0.2)
router.finetune(lr=0.002, non_manhattan=5, length_matching=4, internal_pivots=11)

c1_ft = c.dup()
c1_ft << router.build_gds(as_single=True)
c1_ft.plot()
fit:   0%|          | 0/2000 [00:00<?, ?it/s]





Unnamed_0$2: ports ['r0o1', 'r0o2', 'r1o1', 'r1o2', 'r2o1', 'r2o2'], KCell(name=batch_routes_73e7f1ea76f84420940e51d08465b789, ports=['r0o1', 'r0o2', 'r1o1', 'r1o2', 'r2o1', 'r2o2'], pins=[], instances=[], locked=False, kcl=DEFAULT)

png

routes_df, groups_df = router.length_matching_table()
routes_df
groups_df
    gds route length (µm) actual offset (µm) bends
group route      
0 0 225.063 6.869 6
1 222.275 4.081 6
2 218.194 0.000 6
  gds mean (µm) gds spread (µm) bend spread
group      
0 221.844 6.869 0

3. Direct Finetune

Pass finetune=FinetuneConfig(...) to run gradient-based optimization immediately after the Manhattan initialization in one go. Use this method when you know you always want things to be fine tuned.

Cost weights — higher = stronger pull toward that objective:

Cost What it penalizes
non_manhattan Segments not aligned to x/y axes
bending Non-90-degree angles between segments
route_collision Routes overlapping each other
obstacle_collision Routes entering obstacle keep-out zones
port_straightening Non-tangent exits from ports
length_matching Unequal route lengths in the bundle
min_radius_violation Bends tighter than min_radius
lengthening Unnecessary route length (prefers shorter)

Schedules can be a constant (2.0) or a step-dependent ramp ({0: 2.0, 500: 5.0} = start at 2, ramp to 5 around step 500).

config = pld.FinetuneConfig(
    non_manhattan=0.0,
    lengthening=0.01,
    route_collision=1.0,  # keep the rails from sliding past each other
    obstacle_collision=1.0,
    length_matching=100,
    internal_pivots=6,
    lr=1,
    max_steps=1000,
)

c2 = c.dup()
router2 = pld.add_route_bundle(
    c2,
    start_ports=list(left.ports.filter(orientation=0)),
    end_ports=list(right.ports.filter(orientation=180)),
    obstacle_layers=(1, 0),  # context_component=c2 is implied by add_route_bundle()
    min_radius=5.0,
    target_radius=5.0,  # fix bends at 5µm
    finetune=config,
)
print(f"Finetuned: {router2.steps} steps, loss {router2.fit_result['loss_history'][-1]:.4f}")
c2.plot()
fit:   0%|          | 0/1000 [00:00<?, ?it/s]


Finetuned: 1000 steps, loss 0.0741

png

routes_df, groups_df = router2.length_matching_table()
routes_df
groups_df
    gds route length (µm) actual offset (µm) bends
group route      
0 0 255.794 0.415 6
1 255.379 0.000 6
2 255.554 0.175 6
  gds mean (µm) gds spread (µm) bend spread
group      
0 255.576 0.415 0

4. Waypoints

waypoints are hard points the manhattan initializer routes through. A point is (x, y) or kdb.DPoint; kdb.DTrans or ((x, y), degrees) also pins the heading the route takes through it.

import math


def mark_waypoint(comp, x, y, angle=None, size=8.0, layer="DRC_MARKER"):
    """Draw an "x" at a free waypoint, or an arrow when the waypoint pins a heading."""
    shapes = comp.shapes(gf.get_layer(layer))
    w, d = size / 8, size / 2

    def stroke(pts):
        shapes.insert(gf.kdb.DPath([gf.kdb.DPoint(px, py) for px, py in pts], w))

    if angle is None:
        stroke([(x - d, y - d), (x + d, y + d)])
        stroke([(x - d, y + d), (x + d, y - d)])
        return
    a = math.radians(angle)
    tip = (x + d * math.cos(a), y + d * math.sin(a))
    stroke([(x - d * math.cos(a), y - d * math.sin(a)), tip])
    for barb in (135, -135):
        b = a + math.radians(barb)
        stroke([tip, (tip[0] + 0.5 * d * math.cos(b), tip[1] + 0.5 * d * math.sin(b))])

4.1 Bundle-Level, Direction Pinned

A flat list hints the whole bundle — one route becomes the backbone and hits the point exactly, the rest run parallel. Pinning the heading to 0° forces a horizontal pass, which costs a corner versus leaving the same point free (4 bends → 6).

hint = gf.kdb.DTrans(0, False, -10.0, 70.0)  # (rot x90, mirror, x, y) — pass through heading +x

router_wp = pld.route_bundle(
    start_ports=list(left.ports.filter(orientation=0)),
    end_ports=list(right.ports.filter(orientation=180)),
    context_component=c,
    obstacle_layers=(1, 0),
    waypoints=[hint],
    min_radius=5.0,
    target_radius=5.0,
)

for i, pl in enumerate(router_wp.get_polylines()):
    through = any(
        min(a[0], b[0]) - 1e-6 <= -10.0 <= max(a[0], b[0]) + 1e-6
        and min(a[1], b[1]) - 1e-6 <= 70.0 <= max(a[1], b[1]) + 1e-6
        for a, b in zip(pl[:-1], pl[1:])
    )
    print(f"route {i}: {len(pl) - 2} bends, passes through the hint: {through}")

c_wp = c.dup()
c_wp << router_wp.build_gds(as_single=True)
mark_waypoint(c_wp, -10.0, 70.0, angle=0)  # arrow = pinned heading
c_wp.plot()
route 0: 6 bends, passes through the hint: False
route 1: 6 bends, passes through the hint: True
route 2: 6 bends, passes through the hint: False





Unnamed_0$4: ports ['r0o1', 'r0o2', 'r1o1', 'r1o2', 'r2o1', 'r2o2'], KCell(name=batch_routes_3d62c20e7e6b4444af6807f2d77f399e, ports=['r0o1', 'r0o2', 'r1o1', 'r1o2', 'r2o1', 'r2o2'], pins=[], instances=[], locked=False, kcl=DEFAULT)

png

4.2 Per-Route Hints

A list of lists gives one hint set per route; None leaves a route unhinted. Route 1's hint pulls it wide, so it comes back with 4 more bends than the other two (6, 6, 6 with no waypoints).

waypoints = [
    # None,
    [gf.kdb.DTrans(0, False, 120.0, -20.0)],
    [(100.0, 30.0)],
    # [(100.0, 80.0)],
    None,
]

router_pr = pld.route_bundle(
    start_ports=list(left.ports.filter(orientation=0)),
    end_ports=list(right.ports.filter(orientation=180)),
    context_component=c,
    obstacle_layers=(1, 0),
    waypoints=waypoints,
    min_radius=5.0,
    target_radius=5.0,
    length_matching_groups=[[0, 1, 2]],  # match_bend_count needs a declared group
)
print("bends after init:", [len(pl) - 2 for pl in router_pr.get_polylines()])

c_pr = c.dup()
c_pr << router_pr.build_gds(as_single=True)
mark_waypoint(c_pr, 100, 30.0)            # x     = free point
mark_waypoint(c_pr, 120, -20.0, angle=0)   # arrow = pinned heading
c_pr.plot()
bends after init: [6, 10, 6]


UserWarning: 2 bend(s) on route(s) [1, 2] cannot reach min_radius=5 um; the tightest is drawn at 0.735 um. The segments hosting them are too short — widen route_spacing, lower min_radius, or give the router more room around the obstacles.





Unnamed_0$5: ports ['r0o1', 'r0o2', 'r1o1', 'r1o2', 'r2o1', 'r2o2'], KCell(name=batch_routes_466a675669cc454f9b657b706ea2095c, ports=['r0o1', 'r0o2', 'r1o1', 'r1o2', 'r2o1', 'r2o2'], pins=[], instances=[], locked=False, kcl=DEFAULT)

png

routes_df, groups_df = router_pr.length_matching_table()
groups_df
  gds mean (µm) gds spread (µm) bend spread
group      
0 352.842 177.541 4

Finetuning with length_matching closes both spreads at once. Lengths are matched by sliding rails, and match_bend_count (on by default) adds detours to the two 6-bend routes to bring them up to 10 — it never removes bends from the longest.

router_pr.finetune(
    length_matching=50.0,
    lengthening=0.01,
    route_collision=10.0,
    obstacle_collision=1.0,
    min_radius_violation=10.0,  # anchored routes have less room; 1.0 here gives a 1.9 um bend
    # anchor_proximity=1.0,      # hold the waypoints; 0 (default) lets the routes drift off them
    lr=0.2,
    max_steps=2000,
)
print("bends after finetune:", [len(pl) - 2 for pl in router_pr.get_polylines()])

c_pr_ft = c.dup()
c_pr_ft << router_pr.build_gds(as_single=True)
c_pr_ft.plot()
fit:   0%|          | 0/2000 [00:00<?, ?it/s]



fit:   0%|          | 0/2000 [00:00<?, ?it/s]


bends after finetune: [10, 10, 10]





Unnamed_0$6: ports ['r0o1', 'r0o2', 'r1o1', 'r1o2', 'r2o1', 'r2o2'], KCell(name=batch_routes_7e1b18a9b694419eb8f6dbad554ba473, ports=['r0o1', 'r0o2', 'r1o1', 'r1o2', 'r2o1', 'r2o2'], pins=[], instances=[], locked=False, kcl=DEFAULT)

png

routes_df, groups_df = router_pr.length_matching_table()
routes_df
groups_df
    gds route length (µm) actual offset (µm) bends
group route      
0 0 363.612 0.174 10
1 364.313 0.875 10
2 363.438 0.000 10
  gds mean (µm) gds spread (µm) bend spread
group      
0 363.788 0.875 0

5. Mixed Orientations

9 independent routes combining the original 4 mixed-orientation pairs with 5 stress-test cases from the rail_bridge_v2 suite (same-direction collinear, orthogonal, tight offsets, facing).

_L = gf.kcl.layer(1, 0)

# 9 routes in a 3x3 grid: 4 original mixed-orientation + 5 rail_bridge stress cases
# Grid spacing just enough for each case to not interfere
G = 20  # grid cell size (µm) — cases span ~10µm max
# min_radius chosen so initial_offset = delta = 2.0 (matching rail_bridge_v2 script)
_DELTA = 2.0
_MIN_R = _DELTA / 1.4162503480911255

# (sp, ep, s_orient, e_orient, label)
cases = [
    # row 0: original mixed-orientation
    ((0, 0),  (10, 0),   0,   0,    "same-dir straight"),
    ((0, 0),  (10, 5),   0,   90,   "right to up"),
    ((0, 0),  (10, 0),   180, 0,    "U-turn to straight"),
    # row 1
    ((0, 0),  (10, 0),   0,   180,  "opposing"),
    ((0, 0),  (3, 1),    0,   0,    "same-close (rb11)"),
    ((0, 0),  (3, -1.5), 0,   90,   "ortho-J-tight (rb15)"),
    # row 2
    ((0, 0),  (10, 0.5), 0,   180,  "tight-opp-y (rb17)"),
    ((0, 0),  (10, 0.5), 0,   0,    "tight-same-y (rb18)"),
    ((0, 0),  (0.5, 10), 90,  90,   "same-vert-close (rb20)"),
]

starts, ends = [], []
for i, (sp, ep, so, eo, label) in enumerate(cases):
    col, row = i % 3, i // 3
    ox, oy = col * G, -row * G
    starts.append(gf.Port(name=f"s{i}", center=(sp[0]+ox, sp[1]+oy), orientation=so, layer=_L, width=0.5))
    ends.append(gf.Port(name=f"e{i}", center=(ep[0]+ox, ep[1]+oy), orientation=eo, layer=_L, width=0.5))

# target_radius=_MIN_R fixes every bend at the min radius (else bends grow to fit → variable radius)
router3 = pld.route_bundle(starts, ends, min_radius=_MIN_R, target_radius=_MIN_R, internal_pivots=4)
c3_init = gf.Component()
c3_init << router3.build_gds(as_single=True)
c3_init.plot()
UserWarning: 1 bend(s) on route(s) [8] cannot reach min_radius=1.41218 um; the tightest is drawn at 1.05 um. The segments hosting them are too short — widen route_spacing, lower min_radius, or give the router more room around the obstacles.





Unnamed_185: ports ['r0o1', 'r0o2', 'r1o1', 'r1o2', 'r2o1', 'r2o2', 'r3o1', 'r3o2', 'r4o1', 'r4o2', 'r5o1', 'r5o2', 'r6o1', 'r6o2', 'r7o1', 'r7o2', 'r8o1', 'r8o2'], KCell(name=batch_routes_e453e993d62045929718593a69eb5aec, ports=['r0o1', 'r0o2', 'r1o1', 'r1o2', 'r2o1', 'r2o2', 'r3o1', 'r3o2', 'r4o1', 'r4o2', 'r5o1', 'r5o2', 'r6o1', 'r6o2', 'r7o1', 'r7o2', 'r8o1', 'r8o2'], pins=[], instances=[], locked=False, kcl=DEFAULT)

png

6. Obstacle Avoidance: U-trap

Start port inside a U-shaped enclosure facing down, end port outside facing up. The route must escape the trap before reaching the destination. (rail_bridge_v2 stress test case 51)

_L = gf.kcl.layer(1, 0)
_DELTA = 2.0
_MIN_R = _DELTA / 1.4162503480911255

c5 = gf.Component()

# U-shaped enclosure opens UP: walls = bottom + left + right
obs_specs = [(-8, 3, 8, 6), (-8, 6, -5, 25), (5, 6, 8, 25)]
obs_refs = []
for x0, y0, x1, y1 in obs_specs:
    ref = c5 << gf.c.rectangle(size=(x1 - x0, y1 - y0))
    ref.move((x0, y0))
    obs_refs.append(ref)

s5 = [gf.Port(name="s", center=(0, 10), orientation=270, layer=_L, width=0.5)]
e5 = [gf.Port(name="e", center=(0, -15), orientation=90, layer=_L, width=0.5)]

# target_radius=_MIN_R fixes every bend at the min radius (else bends grow to fit → variable radius)
router4 = pld.add_route_bundle(c5, s5, e5, obstacles=obs_refs, min_radius=_MIN_R, target_radius=_MIN_R)
c5.plot()
Unnamed_210: ports ['e1', 'e2', 'e3', 'e4'], KCell(name=rectangle_gdsfactorypcomponentspshapesprectangle_S16_3__dc011b5e, ports=['e1', 'e2', 'e3', 'e4'], pins=['pad'], instances=[], locked=True, kcl=DEFAULT)






Unnamed_210: ports ['e1', 'e2', 'e3', 'e4'], KCell(name=rectangle_gdsfactorypcomponentspshapesprectangle_S3_19__17cb651f, ports=['e1', 'e2', 'e3', 'e4'], pins=['pad'], instances=[], locked=True, kcl=DEFAULT)






Unnamed_210: ports ['e1', 'e2', 'e3', 'e4'], KCell(name=rectangle_gdsfactorypcomponentspshapesprectangle_S3_19__17cb651f, ports=['e1', 'e2', 'e3', 'e4'], pins=['pad'], instances=[], locked=True, kcl=DEFAULT)



UserWarning: 2 bend(s) on route(s) [0] cannot reach min_radius=1.41218 um; the tightest is drawn at 1.06 um. The segments hosting them are too short — widen route_spacing, lower min_radius, or give the router more room around the obstacles.

png

7. Grouped Ports (Multi-Bundle)

With all 3 ports in one flat list, the router treats them as a single bundle. Route 0 needs a U-turn due to tight pitch, resulting in a tight bend.

Here we pass a cross_section to specify the waveguide details — routing parameters like min_radius, target_radius, and collision width are derived from it automatically:

_L = gf.kcl.layer(1, 0)

# gf_tight_pitch_3: wide pitch (10µm) left, tight pitch (2µm) right
left_ports = [
    gf.Port("L0", center=(-50, 0), width=0.5, orientation=0, layer=_L),
    gf.Port("L1", center=(-50, 10), width=0.5, orientation=0, layer=_L),
    gf.Port("L2", center=(-50, 20), width=0.5, orientation=0, layer=_L),
]
right_ports = [
    gf.Port("R0", center=(0, -3), width=0.5, orientation=180, layer=_L),
    gf.Port("R1", center=(0, -1), width=0.5, orientation=180, layer=_L),
    gf.Port("R2", center=(0, 1), width=0.5, orientation=180, layer=_L),
]

# Cross-section drives routing params: radius → target_radius, radius_min → min_radius
xs = gf.cross_section.cross_section(width=0.5, radius=3.0, radius_min=3.0)

# All in one bundle — route 0's U-turn causes tight bends
router_flat = pld.route_bundle(left_ports, right_ports, cross_section=xs, maximize_radius=False)
c_flat = gf.Component()
c_flat << router_flat.build_gds(as_single=True)
c_flat.plot()
UserWarning: 2 bend(s) on route(s) [0] cannot reach min_radius=3 um; the tightest is drawn at 1.06 um. The segments hosting them are too short — widen route_spacing, lower min_radius, or give the router more room around the obstacles.





Unnamed_223: ports ['r0o1', 'r0o2', 'r1o1', 'r1o2', 'r2o1', 'r2o2'], KCell(name=batch_routes_2b7bdc64b4bd41efa3a5ff9b7fb3cab9, ports=['r0o1', 'r0o2', 'r1o1', 'r1o2', 'r2o1', 'r2o2'], pins=[], instances=[], locked=False, kcl=DEFAULT)

png

Passing nested lists of ports [[port0], [port1, port2]] routes each group as an independent bundle. Route 0 can now U-turn freely without being constrained by routes 1–2:

# Same ports, but grouped [[0], [1, 2]] — route 0 can U-turn independently
router6 = pld.route_bundle(
    start_ports=[[left_ports[0]], [left_ports[1], left_ports[2]]],
    end_ports=[[right_ports[0]], [right_ports[1], right_ports[2]]],
    cross_section=xs, maximize_radius=False,
)
c6 = gf.Component()
c6 << router6.build_gds(as_single=True)
c6.plot()
UserWarning: 2 bend(s) on route(s) [0] cannot reach min_radius=3 um; the tightest is drawn at 1.94 um. The segments hosting them are too short — widen route_spacing, lower min_radius, or give the router more room around the obstacles.





Unnamed_234: ports ['r0o1', 'r0o2', 'r1o1', 'r1o2', 'r2o1', 'r2o2'], KCell(name=batch_routes_aede266291164e81bae31af52d4ed9b9, ports=['r0o1', 'r0o2', 'r1o1', 'r1o2', 'r2o1', 'r2o2'], pins=[], instances=[], locked=False, kcl=DEFAULT)

png

8. Maze Navigation

A procedurally generated 7x7 maze with sealed walls — the route must navigate through corridors with many turns. The maze is created by a DFS backtracker (perfect maze: exactly one path between any two cells).

import sys
from types import SimpleNamespace
sys.path.insert(0, "../benchmarks")
from scenario_registry import _generate_maze_passages, _maze_to_obstacles
import kfactory as kf

_L = gf.kcl.layer(1, 0)
_DELTA = 2.0
_MIN_R = _DELTA / 1.4162503480911255

passages = _generate_maze_passages(rows=7, cols=7, seed=308)
maze = _maze_to_obstacles(rows=7, cols=7, passages=passages, cell_size=8.0, wall_thickness=2.0)

obs = [SimpleNamespace(xmin=x0, ymin=y0, xmax=x1, ymax=y1) for x0, y0, x1, y1 in maze["obs"]]

s7 = [gf.Port(name="s", center=maze["sp"], orientation=0, layer=_L, width=0.5)]
e7 = [gf.Port(name="e", center=maze["ep"], orientation=180, layer=_L, width=0.5)]

router7 = pld.route_bundle(
    s7, e7, obstacles=obs,
    min_radius=_MIN_R, target_radius=_MIN_R, maximize_radius=False,
)
print(f"Maze 7x7: {len(obs)} obstacles")

c7 = gf.Component()
layer_idx = c7.kcl.layer(1, 0)
shapes = c7.shapes(layer_idx)
_ = [shapes.insert(kf.kdb.DBox(o.xmin, o.ymin, o.xmax, o.ymax)) for o in obs]
_ = c7 << router7.build_gds(as_single=True)
c7.plot()
Maze 7x7: 28 obstacles

png