diff --git a/Project.toml b/Project.toml
index 7972a6c2d..8657f02fb 100644
--- a/Project.toml
+++ b/Project.toml
@@ -4,6 +4,7 @@ version = "1.13.0"
[deps]
Accessors = "7d9f7c33-5ae7-4f3b-8dc6-eff91059b697"
+BipartiteMatching = "79040ab4-24c8-4c92-950c-d48b5991a0f6"
Cairo = "159f3aea-2a34-519c-b102-8c37f9878175"
Clipper = "c8f6d549-b3ab-5508-a0d1-48fe138e8cc1"
ColorSchemes = "35d6a980-a343-548e-a6ea-1d62b119f2f4"
@@ -31,6 +32,7 @@ PrecompileTools = "aea7be01-6a6a-4083-8856-8a6e6704d82a"
Preferences = "21216c6a-2e73-6563-6e65-726566657250"
QuadGK = "1fd47b50-473d-5c70-9696-f719f8f3bcdc"
Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
+SparseArrays = "2f01184e-e22b-5df5-ae63-d93ebab69eaf"
SpatialIndexing = "d4ead438-fe20-5cc5-a293-4fd39a41b74c"
StaticArrays = "90137ffa-7385-5640-81b9-e52037218182"
ThreadSafeDicts = "4239201d-c60e-5e0a-9702-85d713665ba7"
@@ -47,6 +49,7 @@ SchematicGraphMakieExt = "GraphMakie"
[compat]
Accessors = "0.1"
Aqua = "0.8"
+BipartiteMatching = "0.1.1"
Cairo = "1.0"
Clipper = "0.6"
ColorSchemes = "3"
diff --git a/docs/make.jl b/docs/make.jl
index 1975611a4..4cf0237c6 100644
--- a/docs/make.jl
+++ b/docs/make.jl
@@ -60,6 +60,7 @@ makedocs(
"Texts" => "concepts/texts.md",
"Paths" => "concepts/paths.md",
"Routes" => "concepts/routes.md",
+ "Channel Autorouter" => "concepts/channel_autorouter.md",
"Rendering and Export" => "concepts/render.md",
"Solid Models (3D Geometry)" => "concepts/solidmodels.md",
"Schematic-Driven Design" => "concepts/schematic_driven_design.md",
@@ -71,7 +72,8 @@ makedocs(
"Examples" => [
"ExamplePDK" => "examples/examplepdk.md",
"Quantum Processor" => "examples/qpu17.md",
- "Single-Transmon Simulation" => "examples/singletransmon.md"
+ "Single-Transmon Simulation" => "examples/singletransmon.md",
+ "Channel Autorouter" => "examples/autorouter.md"
],
"Reference" => [
"Overview" => "reference/index.md",
diff --git a/docs/src/concepts/channel_autorouter.md b/docs/src/concepts/channel_autorouter.md
new file mode 100644
index 000000000..d7ab2a818
--- /dev/null
+++ b/docs/src/concepts/channel_autorouter.md
@@ -0,0 +1,152 @@
+# Channel Autorouter
+
+The channel autorouter connects pairs of pins by choosing paths through a user-defined
+network of [`Paths.RouteChannel`](@ref)s. It is the multi-net, multi-channel
+counterpart to the [`Paths.SingleChannelRouting`](@ref) rule described in
+[Routes](./routes.md#Channel-routing): rather than having the user assign each route a
+channel and a track by hand, the autorouter decides which channels each wire passes
+through and which track it occupies within each channel.
+
+!!! warning
+
+ Autorouting solutions may change between minor DeviceLayout.jl versions. Only breaking
+ changes to the autorouter API, not its output, will force major version bumps.
+ Autorouting is under active development, so upcoming minor versions are especially
+ likely to see changes in solutions for fixed routing problems.
+
+## Problem setup
+
+A channel-routing problem is described by three pieces of data:
+
+ - **Channels**: a list of [`Paths.RouteChannel`](@ref)s. Channels can be straight,
+ curved, tapered, or composite — any `Path` that is valid as a `RouteChannel` is
+ valid as an autorouter channel. Channels intersect where their centerlines cross;
+ the autorouter precomputes these intersections and treats them as the graph along
+ which wires may travel.
+ - **Pins**: a list of `PointHook`s giving the location and direction of
+ each pin. A pin's ray along its direction must hit a channel; the first
+ channel it hits becomes the pin's entry or exit channel.
+ - **Nets**: pairs `(i, j)` of pin indices that should be connected.
+
+The autorouter then attempts to connect each net by routing a wire from its source
+pin, through a sequence of channels, to its destination pin.
+
+## How routing works
+
+Routing happens in two stages:
+
+ 1. **Channel assignment** picks, for each net, the sequence of channels its wire will
+ traverse. This is a shortest-path search in a graph whose vertices are channels and
+ pins and whose edges are channel intersections, weighted by physical distance
+ between successive intersections along each channel. Nets are processed one at a
+ time. Channel assignment does **not** currently take into account crossings,
+ congestion, or channel capacity.
+ 2. **Track assignment** proceeds channel by channel. Within a channel, every wire
+ segment occupies an interval of arclength between its entry and exit. Segments
+ whose intervals overlap must be placed on distinct tracks. The autorouter builds a
+ vertical-constraint graph (which segment must be "above" which, given the crossings
+ that occur entering or leaving the channel at the interval endpoints) and picks a
+ feasible track order that uses as few tracks as possible, merging non-overlapping
+ segments onto shared tracks when compatible.
+
+The algorithms follow the classical channel-routing literature (Hashimoto & Stevens;
+Yoshimura & Kuh), with a crossing-aware variant due to Condrat, Kalla, & Blair to
+handle channels shared by nets with avoidable crossings. See [References](#References) below.
+
+Once channels and tracks have been assigned, the autorouter builds a
+[`Paths.Route`](@ref) for each net. The
+`Route`'s rule is a `Paths.AutoChannelRouting` that uses a user-supplied
+**transition rule** (for the short legs between pins and channels, and between
+adjacent channels) and a **margin** (how much room to leave for bends at each
+transition). The transition rule is typically [`Paths.StraightAnd90`](@ref),
+[`Paths.StraightAnd45`](@ref), or [`Paths.BSplineRouting`](@ref).
+
+## Geometry-level usage
+
+At the geometry level, construct a `Paths.ChannelRouter` from nets, pins, and
+channels, then call `Paths.autoroute!`:
+
+```julia
+using DeviceLayout, .PreferredUnits
+import DeviceLayout.Paths:
+ ChannelRouter, RouteChannel, autoroute!, visualize_router_state
+
+# Two horizontal channels and one vertical channel, with crossed nets
+channels = RouteChannel.([
+ (p = Path(5.0, -1.0; α0 = 90°); straight!(p, 8.0, Paths.Trace(2.0)); p),
+ (p = Path(-1.0, 0.0); straight!(p, 10.0, Paths.Trace(2.0)); p),
+ (p = Path(-1.0, 6.0); straight!(p, 10.0, Paths.Trace(2.0)); p)
+])
+
+hooks = [
+ PointHook(Point(2.0, -0.5), 270°), # below h_bot
+ PointHook(Point(8.0, -0.5), 270°), # below h_bot
+ PointHook(Point(2.0, 6.5), 90°), # above h_top
+ PointHook(Point(8.0, 6.5), 90°) # above h_top
+]
+nets = [(1, 4), (2, 3)] # crossed
+
+ar = ChannelRouter(nets, hooks, channels)
+routes = autoroute!(ar, Paths.StraightAnd90(0.1), 0.1) # transition rule, margin
+
+c = visualize_router_state(ar; wire_width = 0.05)
+```
+
+The returned `routes` are [`Paths.Route`](@ref) values; convert any of them to a drawn
+path with `Path(route, style)`. `visualize_router_state` returns a `Cell` that overlays
+the channels, tracks, pin labels, and route geometry — useful for debugging an
+unexpected assignment. To check whether every route actually reaches its destination,
+call `Paths.validate_routes`.
+
+Worked examples for common topologies (parallel, crossing, fan-in/fan-out, grid,
+angled, B-spline, dense, many-net fan-out) live in the
+[Channel Autorouter examples page](@ref channel-autorouter-examples).
+
+## Schematic-level usage
+
+At the schematic level, the autorouter is exposed as a [`Paths.RouteRule`](@ref):
+construct a `Paths.AutoChannelRouting` from a `ChannelRouter` (or directly from a
+vector of channels) and use it as the rule in
+[`route!`](@ref route!(::SchematicDrivenLayout.SchematicGraph, ::Paths.RouteRule, ::Pair{SchematicDrivenLayout.ComponentNode, Symbol}, ::Pair{SchematicDrivenLayout.ComponentNode, Symbol}, ::Any, ::Any)).
+Every route that shares a channel set should use the **same** rule instance, so that
+the underlying router sees all nets:
+
+```julia
+ar = ChannelRouter(channels)
+rule = Paths.AutoChannelRouting(ar, Paths.StraightAnd90(0.1mm), 0.1mm)
+
+route!(g, rule, node1 => :port_a, node2 => :port_b, Paths.Trace(5μm), meta)
+route!(g, rule, node3 => :port_a, node4 => :port_b, Paths.Trace(5μm), meta)
+
+sch = plan(g)
+```
+
+Pin positions and directions are pulled from the component hooks at each route's
+endpoints during `plan`, and channel assignment + track assignment run once these have
+been set for the last route that uses `rule`. Schematic-level autorouting does not allow the user to
+pre-assign tracks—while [`Paths.SingleChannelRouting`](@ref) uses the `track` keyword in `route!`
+(defaulting to incrementing by 1 for each new route), `AutoChannelRouting` makes all
+track assignments automatically during `plan`.
+
+Unlike with `SingleChannelRouting`, the autorouter does not look for channels in the
+schematic to find their global coordinates. Channels must be provided to the autorouter in global coordinates. Channels may still be added to a schematic, but this has no effect on routing.
+
+## Limitations
+
+ - A pin's outward ray must hit exactly one channel. If no channel is in the ray's
+ path, or the pin points the wrong way, graph construction fails.
+ - Channels are assumed not to self-intersect; self-intersections are ignored with an
+ `@info` message.
+ - Two channels may intersect at most once. Re-entering the same channel pair is not
+ supported.
+ - Every net in a single `AutoChannelRouting` rule must start at a distinct pin. Nets
+ that share a source pin should be split into separate rules (or merged upstream).
+ - Track assignment does not care about proximity of slightly misaligned tracks
+ entering from different channels or pins; only exact alignment of entry/exit segments
+ and topologically avoidable crossings constrain track assignment.
+
+## References
+
+ - Hashimoto & Stevens, ["Wire routing by optimizing channel assignment within large apertures"](https://cs.baylor.edu/~maurer/CSI5346/originalCR.pdf), *DAC '71: Proceedings of the 8th Design Automation Workshop* (1971).
+ - Yoshimura & Kuh, ["Efficient Algorithms for Channel Routing"](https://my.ece.utah.edu/~kalla/phy_des/yk.pdf), *IEEE Transactions on Computer-Aided Design of Integrated Circuits and Systems* (1982).
+ - Condrat, Kalla, & Blair, ["Crossing-aware Channel Routing for Photonic Waveguides"](https://my.ece.utah.edu/~kalla/papers/condrat_crossing-aware_channel_routing_for_photonic_waveguides.pdf), 2013 IEEE 56th International Midwest Symposium on Circuits and Systems (MWSCAS) (2013).
diff --git a/docs/src/concepts/index.md b/docs/src/concepts/index.md
index 51e6970b3..035465875 100644
--- a/docs/src/concepts/index.md
+++ b/docs/src/concepts/index.md
@@ -15,6 +15,7 @@ How DeviceLayout handles geometric objects and their metadata.
- [Texts](texts.md): Text elements as geometric entities
- [Paths](paths.md): Transmission lines and other path-based geometry
- [Routes](routes.md): Defining Paths implicitly based on routing rules
+- [Channel Autorouter](channel_autorouter.md): Multi-net routing through a channel network
- [Rendering and File Export](render.md): How geometry becomes output data
- [Solid Models](solidmodels.md): 3D geometry and meshing
diff --git a/docs/src/concepts/routes.md b/docs/src/concepts/routes.md
index c8bce99b5..c3ada0df6 100644
--- a/docs/src/concepts/routes.md
+++ b/docs/src/concepts/routes.md
@@ -150,6 +150,8 @@ nothing; # hide
```
+For multi-net routing through a network of channels, where the router decides which channels each wire passes through and which track it occupies, see [Concepts: Channel Autorouter](./channel_autorouter.md).
+
## Routing in Schematics
We can add `Route`s between components in a schematic using [`route!(g::SchematicGraph, ...)`](@ref route!(::SchematicDrivenLayout.SchematicGraph, ::Paths.RouteRule, ::Pair{SchematicDrivenLayout.ComponentNode, Symbol}, ::Pair{SchematicDrivenLayout.ComponentNode, Symbol}, ::Any, ::Any)), creating flexible connections that are only resolved after floorplanning has determined the positions of the components to be connected. (For more about the schematic workflow, see [Concepts: Schematic-Driven Design](./schematic_driven_design.md))
diff --git a/docs/src/examples/autorouter.md b/docs/src/examples/autorouter.md
new file mode 100644
index 000000000..45ea1787f
--- /dev/null
+++ b/docs/src/examples/autorouter.md
@@ -0,0 +1,144 @@
+# [Channel Autorouter](@id channel-autorouter-examples)
+
+The channel autorouter connects pairs of pins by routing wires through a user-defined network of channels. Routing proceeds in two steps: **channel assignment** (choosing which channels each net's wire passes through) and **track assignment** (assigning non-overlapping tracks within each channel). See [Concepts: Channel Autorouter](./../concepts/channel_autorouter.md) for a conceptual overview.
+
+The full code for these examples can be found [in `examples/ChannelAutorouter/ChannelAutorouter.jl` in the DeviceLayout.jl repository](https://github.com/aws-cqc/DeviceLayout.jl/blob/main/examples/ChannelAutorouter/ChannelAutorouter.jl).
+
+```@example autorouter
+using DeviceLayout, FileIO
+include("../../../examples/ChannelAutorouter/ChannelAutorouter.jl")
+using .ChannelAutorouter
+nothing # hide
+```
+
+## Simple
+
+One horizontal channel, two pins, one net. The simplest possible autorouting scenario.
+
+```@example autorouter
+c, ar = ChannelAutorouter.example_simple()
+save("autoroute_simple.png", c); nothing # hide
+```
+
+```@raw html
+
+```
+
+## Parallel
+
+Three nets routed left-to-right at matching heights through a grid of two vertical and three horizontal channels. No crossings needed.
+
+```@example autorouter
+c, ar = ChannelAutorouter.example_parallel()
+save("autoroute_parallel.png", c); nothing # hide
+```
+
+```@raw html
+
+```
+
+## Crossing
+
+Two nets that must cross in a shared vertical channel, forcing the router to assign multiple tracks.
+
+```@example autorouter
+c, ar = ChannelAutorouter.example_crossing()
+save("autoroute_crossing.png", c); nothing # hide
+```
+
+```@raw html
+
+```
+
+There is also a variant of this example using the schematic routing interface.
+
+## Fan-in / fan-out
+
+Clustered pins on one side, spread-out pins on the other, routed through a single shared horizontal channel. The router assigns multiple tracks to accommodate the asymmetric spacing.
+
+```@example autorouter
+c, ar = ChannelAutorouter.example_fanin_fanout()
+save("autoroute_fanin_fanout.png", c); nothing # hide
+```
+
+```@raw html
+
+```
+
+## Multichannel fan-out
+
+Same topology as fan-in/fan-out, but with a dedicated horizontal channel per net. Each net uses exactly one track.
+
+```@example autorouter
+c, ar = ChannelAutorouter.example_multichannel_fanout()
+save("autoroute_multichannel_fanout.png", c); nothing # hide
+```
+
+```@raw html
+
+```
+
+## Grid
+
+A 4×4 grid of horizontal and vertical channels with pins on different edges. Nets take multi-hop paths through the grid.
+
+```@example autorouter
+c, ar = ChannelAutorouter.example_grid()
+save("autoroute_grid.png", c); nothing # hide
+```
+
+```@raw html
+
+```
+
+## Angled channels
+
+Non-Manhattan channels: two 45° diagonals crossing a horizontal channel. The router handles arbitrary channel geometry.
+
+```@example autorouter
+c, ar = ChannelAutorouter.example_angled()
+save("autoroute_angled.png", c); nothing # hide
+```
+
+```@raw html
+
+```
+
+## Dense
+
+Six nets sharing just two horizontal and two vertical channels. Forces three tracks per channel.
+
+```@example autorouter
+c, ar = ChannelAutorouter.example_dense()
+save("autoroute_dense.png", c); nothing # hide
+```
+
+```@raw html
+
+```
+
+## B-spline channels
+
+Curved channels using B-spline geometry with `BSplineRouting` transitions. Same fan-in/fan-out topology as above but with non-straight channels and a tapered channel.
+
+```@example autorouter
+c, ar = ChannelAutorouter.example_bspline()
+save("autoroute_bspline.png", c); nothing # hide
+```
+
+```@raw html
+
+```
+
+## 100-net fan-out
+
+100 nets fan out through a single wide channel from an inner row of pins to an outer row with twice the spacing.
+
+```@example autorouter
+c, ar = ChannelAutorouter.example_fanout100()
+save("autoroute_fanout100.svg", c; width=8DeviceLayout.Unitful.inch); nothing # hide
+```
+
+```@raw html
+
+```
diff --git a/docs/src/reference/path_api.md b/docs/src/reference/path_api.md
index b7b50aa18..5c4f7d858 100644
--- a/docs/src/reference/path_api.md
+++ b/docs/src/reference/path_api.md
@@ -114,6 +114,8 @@
Paths.CompoundRouteRule
Paths.SingleChannelRouting
Paths.RouteChannel
+ Paths.ChannelRouter
+ Paths.AutoChannelRouting
```
### Route drawing
diff --git a/examples/ChannelAutorouter/ChannelAutorouter.jl b/examples/ChannelAutorouter/ChannelAutorouter.jl
new file mode 100644
index 000000000..cf429d9bc
--- /dev/null
+++ b/examples/ChannelAutorouter/ChannelAutorouter.jl
@@ -0,0 +1,550 @@
+"""
+Channel autorouter examples demonstrating various routing scenarios.
+
+Each `example_*` function returns `(cell::Cell, router::ChannelRouter)`.
+`run_all_examples()` runs all examples and optionally saves GDS/PNG output.
+"""
+module ChannelAutorouter
+
+using DeviceLayout, .PreferredUnits, .SchematicDrivenLayout
+using FileIO
+
+import .Paths:
+ ChannelRouter, RouteChannel, AutoChannelRouting, autoroute!, visualize_router_state
+
+# ── Helpers ──────────────────────────────────────────────────────────────────
+
+"""
+Horizontal channel at height `y`, from `x0` to `x1`.
+"""
+function hchannel(x0, x1, y; width=2.0)
+ pa = Path(x0, y)
+ straight!(pa, x1 - x0, Paths.Trace(width))
+ return pa
+end
+
+"""
+Vertical channel at `x`, from `y0` to `y1`.
+"""
+function vchannel(x, y0, y1; width=2.0)
+ pa = Path(x, y0, α0=90°)
+ straight!(pa, y1 - y0, Paths.Trace(width))
+ return pa
+end
+
+"""
+Diagonal channel from `(x0,y0)` at angle `α` for length `len`.
+"""
+function dchannel(x0, y0, α, len; width=2.0)
+ pa = Path(x0, y0, α0=α)
+ straight!(pa, len, Paths.Trace(width))
+ return pa
+end
+
+"""
+B-spline channel from `(x0,y0)` to `(x1, y1)` at angle `α` at endpoints.
+"""
+function bchannel(x0, y0, α, x1, y1; width=2.0)
+ pa = Path(x0, y0, α0=α)
+ bspline!(
+ pa,
+ [Point(x1, y1)],
+ α,
+ Paths.Trace(width),
+ auto_speed=true,
+ auto_curvature=true,
+ endpoints_speed=1,
+ endpoints_curvature=0
+ )
+ return pa
+end
+
+# Pin convenience: PointHook with in_direction pointing INWARD (away from routing)
+# Left pin (route goes right): lpin(x, y) → in_direction = 180°
+# Right pin (route goes left): rpin(x, y) → in_direction = 0°
+# Bottom pin (route goes up): bpin(x, y) → in_direction = 270°
+# Top pin (route goes down): tpin(x, y) → in_direction = 90°
+lpin(x, y) = PointHook(Point(float(x), float(y)), 180°)
+rpin(x, y) = PointHook(Point(float(x), float(y)), 0°)
+bpin(x, y) = PointHook(Point(float(x), float(y)), 270°)
+tpin(x, y) = PointHook(Point(float(x), float(y)), 90°)
+
+const R = Paths.StraightAnd90(0.1)
+const WW = 0.05
+const MARGIN = 0.1
+# Autoroute can produce epsilon overlap between end of one segment and start of another
+# Filter prepared_intersections to only inter-path crossings (different path indices)
+function inter_path_intersections(paths)
+ return filter(Intersect.prepared_intersections(paths)) do ixn
+ ixn[1][1] != ixn[2][1]
+ end
+end
+
+# ── Example 1: Simple ────────────────────────────────────────────────────────
+# 1 horizontal channel, 2 pins, 1 net.
+# Pins offset vertically so their rays cross the channel perpendicularly.
+#
+# p1 (below) p2 (above)
+# ↑ ↓
+# ═══════════════════════ h1 (y=0)
+
+function example_simple()
+ channels = [hchannel(0, 10, 0)]
+ hooks = [
+ bpin(2, -0.5), # pin 1: below channel, ray goes up
+ tpin(8, 0.5) # pin 2: above channel, ray goes down
+ ]
+ nets = [(1, 2)]
+
+ ar = ChannelRouter(nets, hooks, RouteChannel.(channels))
+ autoroute!(ar, R, MARGIN)
+ c = visualize_router_state(ar; wire_width=WW)
+
+ @assert length(ar.net_wires) == 1
+ @assert length(ar.net_wires[1]) > 0 "Net should be routed"
+ @assert length(ar.channel_tracks[1]) == 1 "Should use exactly 1 track"
+ return c, ar
+end
+
+# ── Example 2: Parallel ──────────────────────────────────────────────────────
+# H/V grid, 3 nets routed left→right at matching heights. No crossings.
+#
+# p1 → ║══════════════║ ← p4 h_bot (y=0)
+# ║ ║
+# p2 → ║══════════════║ ← p5 h_mid (y=4)
+# ║ ║
+# p3 → ║══════════════║ ← p6 h_top (y=8)
+# v_left v_right
+
+function example_parallel()
+ channels = [
+ vchannel(0, -1, 9), # v_left
+ vchannel(10, -1, 9), # v_right
+ hchannel(-1, 11, 0), # h_bot
+ hchannel(-1, 11, 4), # h_mid
+ hchannel(-1, 11, 8) # h_top
+ ]
+ hooks = [
+ lpin(-0.5, 0), # p1: left, at h_bot level
+ lpin(-0.5, 4), # p2: left, at h_mid level
+ lpin(-0.5, 8), # p3: left, at h_top level
+ rpin(10.5, 0), # p4: right, at h_bot level
+ rpin(10.5, 4), # p5: right, at h_mid level
+ rpin(10.5, 8) # p6: right, at h_top level
+ ]
+ nets = [(1, 4), (2, 5), (3, 6)]
+
+ ar = ChannelRouter(nets, hooks, RouteChannel.(channels))
+ routes = autoroute!(ar, R, MARGIN)
+ paths = Path.(routes, Ref(Paths.Trace(WW)))
+ c = visualize_router_state(ar; wire_width=WW)
+ @assert isempty(inter_path_intersections(paths)) "No crossings"
+ @assert all(length.(ar.net_wires) .> 0) "All nets should be routed"
+ return c, ar
+end
+
+# ── Example 3: Crossing ──────────────────────────────────────────────────────
+# 2 horizontal channels + 1 vertical channel. 2 nets cross in the shared
+# vertical channel, forcing multiple tracks.
+#
+# p3 ← ═══════╪═══ → p4 h_top (y=6)
+# ║
+# p1 ← ═══════╪═══ → p2 h_bot (y=0)
+# v_mid (x=5)
+
+function example_crossing()
+ channels = [
+ vchannel(5, -1, 7), # v_mid
+ hchannel(-1, 9, 0), # h_bot
+ hchannel(-1, 9, 6) # h_top
+ ]
+ hooks = [
+ bpin(2, -0.5), # p1: below h_bot, left side
+ bpin(8, -0.5), # p2: below h_bot, right side
+ tpin(2, 6.5), # p3: above h_top, left side
+ tpin(8, 6.5) # p4: above h_top, right side
+ ]
+ # Crossed: bottom-left↔top-right, bottom-right↔top-left
+ nets = [(1, 4), (2, 3)]
+
+ ar = ChannelRouter(nets, hooks, RouteChannel.(channels))
+ routes = autoroute!(ar, R, MARGIN)
+ paths = Path.(routes, Ref(Paths.Trace(WW)))
+ c = visualize_router_state(ar; wire_width=WW)
+
+ @assert all(length.(ar.net_wires) .> 0) "All nets should be routed"
+ # Both nets traverse v_mid, so it needs ≥2 tracks
+ @assert length(ar.channel_tracks[1]) >= 2 "Crossing nets need multiple tracks in shared channel"
+ # Due to assignment order, only one horizontal channel has two tracks
+ @assert length(ar.channel_tracks[2]) + length(ar.channel_tracks[3]) == 3 "Crossing nets need multiple horizontal tracks only when they overlap due to vertical assignment"
+ @assert length(inter_path_intersections(paths)) == 1 "Exactly one crossing"
+ return c, ar
+end
+
+# ── Example 4: Fan-in/fan-out ─────────────────────────────────────────────────
+# Left and right pins spread out, must fan in/out asymmetrically to horizontal channel
+# 2 vertical + 1 horizontal channels.
+#
+# v_left v_right
+# p4 → (-0.5, 6) ║ ║ (10.5, 9) ← p8
+# p3 → (-0.5, 5) ║════════════════════║ (10.5, 6) ← p7 h_mid (y=7)
+# p2 → (-0.5, 4) ║ ║ (10.5, 3) ← p6
+# p1 → (-0.5, 3) ║ ║ (10.5, 0) ← p5
+function example_fanin_fanout()
+ channels = [
+ vchannel(0, -2, 11), # v_left
+ hchannel(-2, 12, 7), # h_mid
+ vchannel(10, -2, 11) # v_right
+ ]
+ # Left pins clustered at y=3,4,5,6 — all cross v_left
+ # Right pins spread at y=0,3,6,9 — all cross v_right
+ hooks = [
+ lpin(-1, 3), # p1
+ lpin(-1, 4), # p2
+ lpin(-1, 5), # p3
+ lpin(-1, 6), # p4
+ rpin(11, 0), # p5
+ rpin(11, 3), # p6
+ rpin(11, 6), # p7
+ rpin(11, 9) # p8
+ ]
+ nets = [(1, 5), (2, 6), (3, 7), (4, 8)]
+
+ ar = ChannelRouter(nets, hooks, RouteChannel.(channels))
+ routes = autoroute!(ar, R, MARGIN)
+ c = visualize_router_state(ar; wire_width=WW)
+
+ @assert all(length.(ar.net_wires) .> 0) "All nets should be routed"
+ paths = Path.(routes, Ref(Paths.Trace(WW)))
+ @assert isempty(inter_path_intersections(paths)) "No crossings"
+ @assert length(ar.channel_tracks[3]) == 3 "Last vertical channel only needs 3 tracks"
+ return c, ar
+end
+
+# ── Example 5: Multichannel fan-out ──────────────────────────────────────────
+# Left pins clustered (simulating component outputs), right pins spread out.
+# 2 vertical + 4 horizontal channels.
+#
+# v_left v_right
+# p4 → (-0.5, 6) ║════════════════════║ (10.5, 9) ← p8 h4 (y=9)
+# p3 → (-0.5, 5) ║════════════════════║ (10.5, 6) ← p7 h3 (y=6)
+# p2 → (-0.5, 4) ║════════════════════║ (10.5, 3) ← p6 h2 (y=3)
+# p1 → (-0.5, 3) ║════════════════════║ (10.5, 0) ← p5 h1 (y=0)
+function example_multichannel_fanout()
+ channels = [
+ vchannel(0, -2, 11), # v_left
+ hchannel(-2, 12, 1.5), # h_1
+ hchannel(-2, 12, 3.5), # h_2
+ hchannel(-2, 12, 5.5), # h_3
+ hchannel(-2, 12, 7.5), # h_4
+ vchannel(10, -2, 11) # v_right
+ ]
+ # Left pins clustered at y=3,4,5,6 — all cross v_left
+ # Right pins spread at y=0,3,6,9 — all cross v_right
+ hooks = [
+ lpin(-1, 3), # p1
+ lpin(-1, 4), # p2
+ lpin(-1, 5), # p3
+ lpin(-1, 6), # p4
+ rpin(11, 0), # p5
+ rpin(11, 3), # p6
+ rpin(11, 6), # p7
+ rpin(11, 9) # p8
+ ]
+ nets = [(1, 5), (2, 6), (3, 7), (4, 8)]
+
+ ar = ChannelRouter(nets, hooks, RouteChannel.(channels))
+ routes = autoroute!(ar, R, MARGIN)
+ paths = Path.(routes, Ref(Paths.Trace(WW)))
+ c = visualize_router_state(ar; wire_width=WW)
+
+ @assert all(length.(ar.net_wires) .> 0) "All nets should be routed"
+ @assert all(length.(ar.channel_tracks[2:5]) .== 1) "Each net should use its nearest horizontal channel"
+ @assert isempty(inter_path_intersections(paths)) "No crossings"
+ return c, ar
+end
+
+# ── Example 6: Grid ──────────────────────────────────────────────────────────
+# 4×4 H/V grid. 3 nets connecting pins on different edges, requiring
+# multi-channel paths through the grid.
+#
+# v1 v2 v3 v4
+# | | | |
+# p5 → ════╪════╪════╪════╪════ ← p6 h4 (y=9)
+# | | | |
+# ════╪════╪════╪════╪════ h3 (y=6)
+# | | | |
+# p1 → ════╪════╪════╪════╪════ h2 (y=3)
+# | | | |
+# ════╪════╪════╪════╪════ h1 (y=0)
+# | | | |
+# p3 p4
+# (bottom) (bottom)
+
+function example_grid()
+ channels = [
+ # Vertical channels (indices 1-4)
+ vchannel(0, -2, 11),
+ vchannel(3, -2, 11),
+ vchannel(6, -2, 11),
+ vchannel(9, -2, 11),
+ # Horizontal channels (indices 5-8)
+ hchannel(-2, 11, 0),
+ hchannel(-2, 11, 3),
+ hchannel(-2, 11, 6),
+ hchannel(-2, 11, 9)
+ ]
+ hooks = [
+ lpin(-0.5, 3), # p1: left edge, at h2 level → adj to v1
+ rpin(9.5, 6), # p2: right edge, at h3 level → adj to v4
+ bpin(1.5, -0.5), # p3: bottom edge → adj to h1
+ bpin(7.5, -0.5), # p4: bottom edge → adj to h1
+ lpin(-0.5, 9), # p5: left edge, at h4 level → adj to v1
+ rpin(9.5, 0) # p6: right edge, at h1 level → adj to v4
+ ]
+ nets = [
+ (1, 2), # left(y=3) → right(y=6): diagonal traverse
+ (3, 5), # bottom(x=1.5) → left(y=9): corner path
+ (4, 6) # bottom(x=7.5) → right(y=0): short path
+ ]
+
+ ar = ChannelRouter(nets, hooks, RouteChannel.(channels))
+ autoroute!(ar, R, MARGIN)
+ c = visualize_router_state(ar; wire_width=WW)
+
+ @assert all(length.(ar.net_wires) .> 0) "All nets should be routed"
+ # At least one net should traverse 3+ wire segments (multi-hop path)
+ max_segs = maximum(length.(ar.net_wires))
+ @assert max_segs >= 2 "Grid routing should produce multi-segment paths"
+ return c, ar
+end
+
+# ── Example 7: Angled ────────────────────────────────────────────────────────
+# Non-Manhattan channels: two 45° diagonals crossing a horizontal channel.
+# Demonstrates that the router handles arbitrary path geometry.
+#
+# ╲ ╱
+# ╲ ╱
+# ╲ ╱
+# ╳
+# ═══════════╱═╲═══════════ h1 (y=3)
+# ╱ ╲
+# d1 d2
+
+function example_angled()
+ channels = [
+ dchannel(-1, 0, 45°, 10 * sqrt(2); width=2.0), # d1: NE from (-1,0)
+ hchannel(-2, 12, 3; width=2.0), # h1 (y=3)
+ dchannel(-1, 10, -45°, 10 * sqrt(2); width=2.0) # d2: SE from (-1,10)
+ ]
+ # Pins offset so rays cross diagonal
+ hooks = [
+ bpin(0, -2), # p1: below d1, left side
+ bpin(8, -2), # p2: below d2, right side
+ rpin(12, 1), # p4: above d2, right side
+ lpin(-2, 1) # p3: below d1, left side
+ ]
+ # Net 1 goes left→right via h1 and diagonals
+ # Net 2 goes right→left via h1 and diagonals
+ nets = [(1, 2), (3, 4)]
+
+ ar = ChannelRouter(nets, hooks, RouteChannel.(channels))
+ routes = autoroute!(ar, Paths.StraightAnd45(0.1), MARGIN)
+ c = visualize_router_state(ar; wire_width=WW)
+
+ @assert all(length.(ar.net_wires) .> 0) "All nets should be routed"
+ @assert all(length.(ar.channel_tracks) .== 2) "Each channel needs two tracks"
+ paths = Path.(routes, Ref(Paths.Trace(WW)))
+ @assert isempty(inter_path_intersections(paths)) "No crossings"
+ return c, ar
+end
+
+# ── Example 8: Dense ─────────────────────────────────────────────────────────
+# 6 nets sharing just 2 horizontal + 2 vertical channels.
+# Forces multiple tracks per channel.
+#
+# p1-p6 on left p7-p12 on right
+# → ║════════════════║ ←
+# ║ h1 (y=0) ║
+# ║════════════════║
+# ║ h2 (y=5) ║
+# → ║════════════════║ ←
+# v_left v_right
+
+function example_dense()
+ channels = [
+ vchannel(0, -2, 7), # v_left (idx 1)
+ hchannel(-2, 10, -1), # h1 (idx 3)
+ hchannel(-2, 10, 6), # h2 (idx 4)
+ vchannel(8, -2, 7) # v_right (idx 2)
+ ]
+ # 6 left pins, tightly spaced, all crossing v_left
+ # 6 right pins, same y-positions, all crossing v_right
+ ys = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0]
+ hooks = [
+ [lpin(-1, y) for y in ys]..., # p1-p6
+ [rpin(9, y) for y in ys]... # p7-p12
+ ]
+ nets = [(i, i + 6) for i = 1:6]
+
+ ar = ChannelRouter(nets, hooks, RouteChannel.(channels))
+ routes = autoroute!(ar, R, MARGIN)
+ paths = Path.(routes, Ref(Paths.Trace(WW)))
+ c = visualize_router_state(ar; wire_width=WW)
+
+ @assert all(length.(ar.net_wires) .> 0) "All nets should be routed"
+ @assert all(length.(ar.channel_tracks) .== 3) "Requires 3 tracks on each channel"
+ @assert isempty(inter_path_intersections(paths)) "No crossings"
+ return c, ar
+end
+
+# ── Example 9: B-spline channels ─────────────────────────────────────────────
+# Curved channels using B-spline geometry. Same fan-in/fan-out topology as
+# example 4 but with non-straight channels and BSplineRouting transitions.
+
+function example_bspline()
+ channels = [
+ bchannel(0, -2, 30°, 1, 12), # v_left
+ bchannel(-1, 2, -30°, 12, 9; width=s -> 2.0 + s/10), # h_mid
+ bchannel(10, -2, 30°, 11, 12) # v_right
+ ]
+ # Left pins clustered at y=3,4,5,6 — all cross v_left
+ # Right pins spread at y=0,3,6,9 — all cross v_right
+ hooks = [
+ lpin(-3, 3), # p1
+ lpin(-3, 4), # p2
+ lpin(-3, 5), # p3
+ lpin(-3, 6), # p4
+ rpin(14, 0), # p5
+ rpin(14, 3), # p6
+ rpin(14, 6), # p7
+ rpin(14, 9) # p8
+ ]
+ nets = [(1, 5), (2, 6), (3, 7), (4, 8)]
+
+ ar = Paths.ChannelRouter(nets, hooks, RouteChannel.(channels))
+ transition_rule = Paths.BSplineRouting(
+ auto_speed=true,
+ auto_curvature=true,
+ endpoints_speed=1,
+ endpoints_curvature=0
+ )
+
+ routes = autoroute!(ar, transition_rule, 1.0)
+ c = visualize_router_state(ar, wire_width=WW)
+ @assert all(length.(ar.net_wires) .> 0) "All nets should be routed"
+ paths = Path.(routes, Ref(Paths.Trace(WW)))
+ @assert isempty(inter_path_intersections(paths)) "No crossings"
+ @assert length(ar.channel_tracks[3]) == 3 "Last vertical channel only needs 3 tracks"
+ return c, ar
+end
+
+# ── Example 10: 40-net fan-out ───────────────────────────────────────────────
+# 40 nets fan out through a single wide channel from inner to outer pin rows.
+
+function example_fanout100()
+ lx_outer = ly_outer = 10e6nm
+ lx_inner = ly_inner = 5e6nm
+
+ fanout_space_bottom =
+ Path(Point(-lx_outer / 2, -(ly_inner / 2 + (ly_outer - ly_inner) / 4)))
+ straight!(fanout_space_bottom, lx_outer, Paths.Trace(0.8 * (ly_outer - ly_inner) / 4))
+
+ n_nets = 100
+ x0s = range(-lx_outer / 2, stop=lx_outer / 2, length=n_nets + 2)[2:(end - 1)]
+ x1s = range(-lx_inner / 2, stop=lx_inner / 2, length=n_nets + 2)[2:(end - 1)]
+ p0s = [PointHook(x, -ly_outer / 2, -90°) for x in x0s]
+ p1s = [PointHook(x, -ly_inner / 2, 90°) for x in x1s]
+ mynets = [(i, i + n_nets) for i = 1:n_nets]
+ ar = ChannelRouter(mynets, vcat(p0s, p1s), [RouteChannel(fanout_space_bottom)])
+ routes = Paths.autoroute!(ar, Paths.StraightAnd90(10μm), 10μm)
+ # c = Paths.visualize_router_state(ar, wire_width=1μm)
+ @assert all(length.(ar.net_wires) .> 0) "All nets should be routed"
+ paths = Path.(routes, Ref(Paths.Trace(10μm)))
+ c = Cell("fanout100")
+ render!.(c, paths, GDSMeta())
+ @assert isempty(inter_path_intersections(paths)) "No crossings"
+ @assert length(ar.channel_tracks[1]) <= n_nets/2 "Left and right halves share tracks"
+ return c, ar
+end
+
+# ── Example 11: Schematic interface ───────────────────────────────────────────
+# Same as `example_crossing` but using the schematic interface to set up the routing problem.
+
+function example_crossing_schematic()
+ channels = RouteChannel.([
+ vchannel(5mm, -1mm, 7mm, width=2.0mm), # v_mid
+ hchannel(-1mm, 9mm, 0mm, width=2.0mm), # h_bot
+ hchannel(-1mm, 9mm, 6mm, width=2.0mm) # h_top
+ ])
+
+ hooks = [
+ bpin(2mm, -0.5mm), # p1: below h_bot, left side
+ bpin(8mm, -0.5mm), # p2: below h_bot, right side
+ tpin(2mm, 6.5mm), # p3: above h_top, left side
+ tpin(8mm, 6.5mm) # p4: above h_top, right side
+ ]
+ # Crossed: bottom-left↔top-right, bottom-right↔top-left
+ nets = [(1, 4), (2, 3)]
+ # Set up schematic
+ g = SchematicGraph("example")
+ # Start/end components
+ comps = [Spacer(; p1=h.p) for h in hooks]
+ comp_nodes = add_node!.(g, comps)
+ ar = ChannelRouter(channels)
+ rule = AutoChannelRouting(ar, Paths.StraightAnd90(MARGIN*1mm), MARGIN*1mm)
+ r1 = route!(g, rule, comp_nodes[1]=>:p1_south, comp_nodes[4]=>:p1_north, Paths.Trace(WW*1mm), GDSMeta())
+ r2 = route!(g, rule, comp_nodes[2]=>:p1_south, comp_nodes[3]=>:p1_north, Paths.Trace(WW*1mm), GDSMeta())
+
+ sch = plan(g)
+ paths = [SchematicDrivenLayout.path(r1.component), SchematicDrivenLayout.path(r2.component)]
+ c = Cell(sch.coordinate_system)
+ # c = visualize_router_state(ar; wire_width=WW)
+
+ @assert all(length.(ar.net_wires) .> 0) "All nets should be routed"
+ # Both nets traverse v_mid, so it needs ≥2 tracks
+ @assert length(ar.channel_tracks[1]) >= 2 "Crossing nets need multiple tracks in shared channel"
+ # Due to assignment order, only one horizontal channel has two tracks
+ @assert length(ar.channel_tracks[2]) + length(ar.channel_tracks[3]) == 3 "Crossing nets need multiple horizontal tracks only when they overlap due to vertical assignment"
+ @assert length(inter_path_intersections(paths)) == 1 "Exactly one crossing"
+ return c, ar
+end
+
+# ── Assembly ─────────────────────────────────────────────────────────────────
+
+const ALL_EXAMPLES = [
+ "simple" => example_simple,
+ "parallel" => example_parallel,
+ "crossing" => example_crossing,
+ "fanin_fanout" => example_fanin_fanout,
+ "multichannel_fanout" => example_multichannel_fanout,
+ "grid" => example_grid,
+ "angled" => example_angled,
+ "dense" => example_dense,
+ "bspline" => example_bspline,
+ "fanout100" => example_fanout100,
+ "crossing_schematic" => example_crossing_schematic,
+]
+
+function run_all_examples(; save_gds=true, save_png=true, dir=@__DIR__)
+ results = Pair{String, Tuple{Cell, ChannelRouter}}[]
+ for (name, fn) in ALL_EXAMPLES
+ @info "Running $name..."
+ push!(results, name => fn())
+ end
+ if save_gds
+ for (name, (c, _)) in results
+ save(joinpath(dir, "autoroute_$(name).gds"), c; spec_warnings=false)
+ end
+ @info "Saved $(length(results)) GDS files"
+ end
+ if save_png
+ for (name, (c, _)) in results
+ save(joinpath(dir, "autoroute_$(name).png"), c; spec_warnings=false)
+ end
+ @info "Saved $(length(results)) PNG files"
+ end
+ return results
+end
+
+end # module
diff --git a/src/hooks.jl b/src/hooks.jl
index 1ad51b78c..454d29bd8 100644
--- a/src/hooks.jl
+++ b/src/hooks.jl
@@ -20,6 +20,9 @@ struct PointHook{T} <: Hook{T}
end
PointHook(p, in_direction) = PointHook{eltype(p)}(p, in_direction) # Otherwise can't infer if in_direction is in degrees
PointHook(x, y, in_direction) = PointHook(Point(promote(x, y)...), in_direction)
+Base.convert(::Type{PointHook{T}}, h::PointHook{T}) where {T} = h
+Base.convert(::Type{PointHook{T}}, h::PointHook{S}) where {T, S} =
+ PointHook{T}(h.p, h.in_direction)
"""
in_direction(h::Hook)
@@ -35,6 +38,13 @@ The outward-pointing angle opposite to the direction stored by the PointHook
"""
out_direction(h::Hook) = in_direction(h) + 180°
+"""
+ getp(h::Hook)
+
+The point defining the position of `h`.
+"""
+getp(h::Hook) = h.p
+
"""
compass(prefix=""; p0=Point(0μm, 0μm))
@@ -104,6 +114,10 @@ HandedPointHook(p0::Point, in_direction, rh::Bool) =
HandedPointHook(x0::Coordinate, y0::Coordinate, in_direction, right_handed=true) =
HandedPointHook(PointHook(x0, y0, in_direction), right_handed)
+Base.convert(::Type{HandedPointHook{T}}, h::HandedPointHook{T}) where {T} = h
+Base.convert(::Type{HandedPointHook{T}}, h::HandedPointHook{S}) where {T, S} =
+ HandedPointHook{T}(PointHook{T}(h.h.p, h.h.in_direction), h.right_handed)
+
function transformation(h1::HandedPointHook, h2::HandedPointHook)
f = transformation(h1.h, h2.h)
h1.right_handed == h2.right_handed && return f
@@ -112,6 +126,8 @@ end
transformation(h1::PointHook, h2::HandedPointHook) = transformation(h1, h2.h)
transformation(h1::HandedPointHook, h2::PointHook) = transformation(h1.h, h2)
+getp(h::HandedPointHook) = h.h.p
+
function Base.getproperty(h::HandedPointHook, s::Symbol)
if s in (:p, :in_direction)
return getfield(h.h, s)
diff --git a/src/paths/channel_autorouter.jl b/src/paths/channel_autorouter.jl
new file mode 100644
index 000000000..43a0ea094
--- /dev/null
+++ b/src/paths/channel_autorouter.jl
@@ -0,0 +1,533 @@
+# DeviceLayout-specific ChannelRouter implementation.
+# Pure algorithmic core is in channel_routing_core.jl.
+
+# pathlength 1, pathlength 2, dir1, dir2, intersection point
+const IntersectionInfo{T} = Tuple{T, T, typeof(1.0°), typeof(1.0°), Point{T}}
+
+"""
+ ChannelRouter{T <: Coordinate}
+
+A simple autorouter where wires can run on horizontal and vertical channels.
+
+The router will attempt to connect pairs of pins, each with coordinates and a direction.
+The indices of connected pins are specified by `nets`.
+
+The router is initialized with a number of vertical and horizontal "channels", characterized
+by a width and the coordinate of their center line. Each channel will be divided into some
+number of tracks as necessary to route all nets.
+
+Routing proceeds in two steps. The first is "channel assignment", which proceeds net by net,
+choosing which channels the net's wire passes through on its way from one pin to the other. The second step
+is "track assigment", which proceeds channel by channel. Wire segments are assigned to
+tracks within the channel such that wire segments in the same track do not overlap.
+"""
+struct ChannelRouter{T <: Coordinate} <: AbstractChannelProblem{T}
+ channel_graph::SimpleGraph{Int} # Channels/pins are vertices, intersections are edges
+ net_pins::Vector{Tuple{Int, Int}} # Pairs of pins (indices in `pins`)
+ net_wires::Vector{NetWire} # Wire segments connecting pins for each net
+ pins::Vector{PointHook{T}} # Position and orientation of pins
+ channels::Vector{RouteChannel{T}} # List of channels
+ # channel_capacities::Vector{Int} # Maximum number of tracks per channel, not used
+ # For each edge, information to find the intersection point
+ channel_intersections::Dict{Tuple{Int, Int}, IntersectionInfo{T}}
+ # Vector of vectors of all segments in each channel
+ channel_segments::Vector{Vector{TrackWireSegment}}
+ # Vector of vectors of all tracks in each channel [where each track is a vector of wire segments]
+ channel_tracks::Vector{Vector{Track}}
+ net_routes::Vector{Route{T}}
+end
+
+"""
+ ChannelRouter(
+ nets::Vector{Tuple{Int, Int}},
+ pin_hooks::Vector{<:Hook},
+ channels::Vector{<:RouteChannel}
+ )
+
+Construct a `ChannelRouter` from `nets`, `pin_hooks`, and `channels`.
+
+`nets` is a list of `(i, j)` pairs of indices into `pin_hooks`; each pair specifies
+a wire to be routed from pin `i` to pin `j`. The pin `Hook`s supply the entry/exit
+positions and directions; each pin's outward ray (opposite its `in_direction`) must
+strike one of `channels` for graph construction to succeed.
+
+Channel intersections are computed eagerly during construction. Pass the result to
+[`autoroute!`](@ref) to actually assign channels and tracks.
+"""
+function ChannelRouter(nets, pin_hooks::Vector{<:Hook}, channels::Vector{<:RouteChannel})
+ T = promote_type(coordinatetype(pin_hooks), coordinatetype(channels))
+ net_wires = [NetWire() for i in eachindex(nets)]
+ channel_segments = [TrackWireSegment[] for i in eachindex(channels)]
+ channel_tracks = [Track[] for i in eachindex(channels)]
+ pins = [PointHook{T}(pin.p, pin.in_direction + 180°) for pin in pin_hooks]
+ # Build channel graphs with full paths to avoid compound operations
+ channel_paths = [ch.path for ch in channels]
+ channel_graph, ixns = build_channel_graph(pins, channel_paths, T)
+ return ChannelRouter{T}(
+ channel_graph,
+ nets,
+ net_wires,
+ pins,
+ channels,
+ ixns,
+ channel_segments,
+ channel_tracks,
+ Route{T}[]
+ )
+end
+
+"""
+ ChannelRouter(channels::Vector{RouteChannel{T}}) where {T}
+
+Construct an empty `ChannelRouter` containing `channels` but no pins or nets.
+
+Nets and pins are added later — typically by constructing an [`AutoChannelRouting`](@ref)
+rule around this router and calling `route!` on a schematic graph, which discovers pins
+from the schematic's hooks at `plan` time.
+"""
+function ChannelRouter(channels::Vector{RouteChannel{T}}) where {T}
+ channel_segments = [TrackWireSegment[] for i in eachindex(channels)]
+ channel_tracks = [Track[] for i in eachindex(channels)]
+ return ChannelRouter{T}(
+ SimpleGraph(),
+ Tuple{Int, Int}[],
+ NetWire[],
+ PointHook{T}[],
+ channels,
+ Dict{Tuple{Int, Int}, IntersectionInfo{T}}(),
+ channel_segments,
+ channel_tracks,
+ Route{T}[]
+ )
+end
+
+# ──── AbstractChannelProblem interface implementation ────────────────────────
+
+Base.broadcastable(x::ChannelRouter) = Ref(x)
+num_channels(ar::ChannelRouter) = length(ar.channels)
+num_nets(ar::ChannelRouter) = length(ar.net_pins)
+num_pins(ar::ChannelRouter) = length(ar.pins)
+
+channel_graph(ar::ChannelRouter) = ar.channel_graph
+net_pins(ar::ChannelRouter, net) = ar.net_pins[net]
+net_wire(ar::ChannelRouter, net) = ar.net_wires[net]
+pin_coordinates(ar::ChannelRouter, pin) = ar.pins[pin].p
+pin_direction(ar::ChannelRouter, pin) = ar.pins[pin].in_direction
+function channel_width(ar::ChannelRouter{T}, channel, s...) where {T}
+ is_pin(ar, channel) && return zero(T)
+ return Paths.width(ar.channels[channel].node.sty, s...)
+end
+channel_segments(ar::ChannelRouter, channel) = ar.channel_segments[channel]
+channel_tracks(ar::ChannelRouter, channel) = ar.channel_tracks[channel]
+num_tracks(ar::ChannelRouter, channel) = length(ar.channel_tracks[channel])
+
+channel_intersection(ar, s1, s2) = ar.channel_intersections[_swap(s1, s2)]
+
+function pathlength_at_intersection(
+ ar::ChannelRouter{T},
+ running_channel,
+ intersecting_channel
+) where {T}
+ # Intersecting channel is zero where a wire segment hits a pin
+ if iszero(running_channel) || iszero(intersecting_channel)
+ return zero(T)
+ end
+ ixn_info = channel_intersection(ar, running_channel, intersecting_channel)
+ running_channel < intersecting_channel && return ixn_info[1]
+ return ixn_info[2]
+end
+
+function direction_at_intersection(ar::ChannelRouter, running_channel, intersecting_channel)
+ # Intersecting channel is zero where a wire segment hits a pin
+ if iszero(running_channel) || iszero(intersecting_channel)
+ return 0.0
+ end
+ ixn_info = channel_intersection(ar, running_channel, intersecting_channel)
+ angle_unitful = running_channel < intersecting_channel ? ixn_info[3] : ixn_info[4]
+ return rem2pi(uconvert(NoUnits, angle_unitful), RoundNearest)
+end
+
+function segment_offset(
+ ar::ChannelRouter{T},
+ ws::TrackWireSegment,
+ s...;
+ use_wire_direction=true
+) where {T}
+ channel_idx = running_channel(ws)
+ is_pin(ar, channel_idx) && return zero(T)
+ track_idx = segment_track(ar, ws)
+ isnothing(track_idx) && return zero(T)
+ reversed = use_wire_direction && against_channel(ar, ws)
+ return track_section_offset(
+ length(ar.channel_tracks[channel_idx]),
+ channel_width(ar, channel_idx, s...),
+ track_idx;
+ reversed
+ )
+end
+
+# ──── DL-dependent geometry functions ────────────────────────────────────────
+
+pathlength_from_start(channel, node, s) = pathlength(channel[1:(node - 1)]) + s
+
+# Build graph with pins/channels as vertices and intersections as edges
+function build_channel_graph(pins, channels, T)
+ g = SimpleGraph(length(channels) + length(pins))
+ intersection_dict = Dict{Tuple{Int, Int}, IntersectionInfo{T}}()
+
+ # Create segments extending from pins
+ bbox = bounds(bounds(channels), bounds(Polygon(DeviceLayout.getp.(pins))))
+ ray_length = max(DeviceLayout.width(bbox), DeviceLayout.height(bbox)) * sqrt(2)
+ pin_rays = Path{T}[]
+ for pin in pins
+ path = Path{T}(pin.p, pin.in_direction)
+ straight!(path, ray_length, Paths.NoRender())
+ push!(pin_rays, path)
+ end
+
+ # Add edges for intersections between channels
+ intersections =
+ DeviceLayout.Intersect.prepared_intersections([channels..., pin_rays...])
+ pin_ixns = Dict{Int, Tuple{Int, IntersectionInfo{T}}}()
+ for ixn in intersections
+ location_1, location_2, p = ixn
+ v1_idx, node1_idx, s1 = location_1
+ v2_idx, node2_idx, s2 = location_2
+ if v1_idx >= v2_idx
+ # `prepared_intersections` guarantees v2 >= v1
+ @assert v1_idx == v2_idx
+ # We will also ignore self-intersecting channels v2 == v1
+ @info "Ignoring self-intersection of channel $v1_idx"
+ continue
+ elseif v1_idx > length(channels) && v2_idx > length(channels)
+ # Both are pins, ignore intersection
+ continue
+ elseif v2_idx > length(channels) # v2 is a pin
+ dir1 = direction(channels[v1_idx][node1_idx].seg, s1)
+ dir2 = pins[v2_idx - length(channels)].in_direction
+ s1 = pathlength_from_start(channels[v1_idx], node1_idx, s1)
+ # Record intersection if it's the closest so far
+ ixn_info = (s1, s2, dir1, dir2, p)
+ _, old_ixn_info = get(pin_ixns, v2_idx, (v1_idx, ixn_info))
+ old_distance = old_ixn_info[2]
+ if s2 <= old_distance
+ pin_ixns[v2_idx] = (v1_idx, ixn_info)
+ end
+ else # record intersection as edge in channel graph, with info in dict
+ haskey(intersection_dict, (v1_idx, v2_idx)) &&
+ error("Channels $v1_idx and $v2_idx have multiple intersections")
+ dir1 = direction(channels[v1_idx][node1_idx].seg, s1)
+ dir2 = direction(channels[v2_idx][node2_idx].seg, s2)
+ s1 = pathlength_from_start(channels[v1_idx], node1_idx, s1)
+ s2 = pathlength_from_start(channels[v2_idx], node2_idx, s2)
+
+ add_edge!(g, v1_idx, v2_idx)
+ intersection_dict[(v1_idx, v2_idx)] = # All records have v1 < v2
+ (s1, s2, dir1, dir2, p)
+ end
+ end
+ # Add min distance edge for each pin
+ for pin_idx = (length(channels) + 1):(length(channels) + length(pins))
+ orig_idx = pin_idx - length(channels)
+ !haskey(pin_ixns, pin_idx) && error(
+ "The ray from pin $(orig_idx) ($(pins[orig_idx])) does not intersect any channel"
+ )
+ channel_idx, ixn_info = pin_ixns[pin_idx]
+ add_edge!(g, (channel_idx, pin_idx))
+ intersection_dict[(channel_idx, pin_idx)] = ixn_info
+ end
+
+ return g, intersection_dict
+end
+
+"""
+ print_segments(ar::ChannelRouter, net)
+
+Print the information for the wire segments in `net` in a human-readable format.
+"""
+function print_segments(ar::ChannelRouter, net)
+ for (i, ws) in pairs(net_wire(ar, net))
+ s1, s2 = bounding_channels(ws)
+ channel_names = [
+ is_pin(ar, s1) ? "Pin $(graphidx_to_pin(ar, s1))" : "Channel $s1",
+ is_pin(ar, s2) ? "Pin $(graphidx_to_pin(ar, s2))" : "Channel $s2"
+ ]
+ println("""
+ Segment $i:
+ Runs along Channel $(running_channel(ws)), Track $(segment_track(ar, ws))
+ From $(channel_names[1]) to $(channel_names[2])
+ """)
+ end
+end
+
+"""
+ autoroute!(ar::ChannelRouter, transition_rule, margin; net_indices, fixed_channel_paths, verbose)
+
+Perform channel and track assigment, then make routes.
+
+Routes only the nets in `net_indices`. If the net is already routed, it is reset. A route
+for a net can be specified in `fixed_channel_paths` by the indices of the channels the route
+takes. For example, `fixed_channel_paths=Dict(1 => [2, 4, 1, 5])` will force net 1 to be
+routed from its source pin, through channels 2, 4, 1, 5 in order, then to its destination pin.
+
+If `verbose=true`, prints a summary of routing results including net count and track usage.
+"""
+function autoroute!(
+ ar::ChannelRouter,
+ transition_rule,
+ margin;
+ net_indices=eachindex(ar.net_pins),
+ fixed_channel_paths::Dict{Int, Vector{Int}}=Dict{Int, Vector{Int}}(),
+ verbose=false
+)
+ affected_nets = reroute_nets!(ar, net_indices; fixed_paths=fixed_channel_paths)
+ rule = AutoChannelRouting(ar, transition_rule, margin)
+ routes = make_routes!(ar, rule)
+
+ if verbose
+ n_routed = count(!isempty, ar.net_wires[collect(net_indices)])
+ n_total = length(net_indices)
+ max_tracks = maximum(num_tracks(ar, ch) for ch = 1:num_channels(ar))
+ @info "Autorouting complete" nets_routed = n_routed nets_total = n_total max_tracks_per_channel =
+ max_tracks
+ for idx in net_indices
+ for (seg_i, ws) in enumerate(net_wire(ar, idx))
+ if isnothing(segment_track(ar, ws))
+ @warn "Net $idx segment $seg_i: no track assigned" channel =
+ running_channel(ws)
+ end
+ end
+ end
+ end
+
+ return routes
+end
+
+######## Route construction
+
+"""
+ make_routes!(ar::ChannelRouter, rule; net_indices=eachindex(ar.net_pins))
+
+Using channel and track assignments, create `Route`s for the nets in `net_indices`.
+"""
+function make_routes!(ar::ChannelRouter{T}, rule) where {T}
+ empty!(ar.net_routes)
+ for idx_net in eachindex(ar.net_pins)
+ p0, p1 = pin_coordinates.(ar, net_pins(ar, idx_net))
+ α0, α1 = pin_direction.(ar, net_pins(ar, idx_net))
+ rt = Route(rule, p0, p1, α0, α1 + pi)
+ push!(ar.net_routes, rt)
+ end
+ return ar.net_routes
+end
+
+"""
+ validate_routes(ar::ChannelRouter{T}, sty; atol=onenanometer(T)) where {T}
+
+Construct `Path` objects from the router's `Route`s and check whether each one
+reaches its destination point and angle.
+
+Returns a `BitVector` of length `num_nets(ar)` where `true` means the route
+produced valid geometry. When a route fails, the corresponding `@error` from
+`route!()` is still emitted to the logging system.
+"""
+function validate_routes(
+ ar::ChannelRouter{T},
+ sty;
+ atol=DeviceLayout.onenanometer(T)
+) where {T}
+ success = trues(num_nets(ar))
+ for (idx, rt) in enumerate(ar.net_routes)
+ pa = Path(rt.p0, α0=rt.α0)
+ ok = route!(
+ pa,
+ rt.p1,
+ rt.α1,
+ rt.rule,
+ sty;
+ waypoints=rt.waypoints,
+ waydirs=rt.waydirs,
+ atol=atol
+ )
+ success[idx] = something(ok, false) # route! currently returns nothing on failure
+ end
+ return success
+end
+
+######## Visualization
+
+"""
+ visualize_router_state(ar::ChannelRouter{T}; wire_width=0.1*oneunit(T))
+
+Return a `Cell` with rectangles and labels illustrating channels, tracks, and routes.
+
+Tracks are labeled as `S:C` where `S` is the channel index and `C` is the track index.
+
+Pins are labeled as `P/N` where `P` is the pin index and `N` is the net index.
+
+Segment waypoints are marked with circles and numbered sequentially within each net.
+"""
+function visualize_router_state(ar::ChannelRouter{T}; wire_width=0.1 * oneunit(T)) where {T}
+ c = DeviceLayout.Cell{T}("track_viz")
+
+ paths = Path.(ar.net_routes, Ref(Paths.Trace(wire_width)))
+ DeviceLayout.render!.(c, paths, GDSMeta(7))
+ channels = channel_paths(ar)
+ DeviceLayout.render!.(c, channels, GDSMeta(3))
+ tracks = track_paths(ar)
+ DeviceLayout.render!.(c, tracks, GDSMeta(1))
+ trlab = track_labels(ar, tracks)
+ DeviceLayout.text!.(c, trlab, GDSMeta(0))
+ for pa in paths
+ for node in pa[1:(end - 1)]
+ DeviceLayout.render!(
+ c,
+ DeviceLayout.Circle(1.5wire_width) + p1(node.seg),
+ GDSMeta(5)
+ )
+ end
+ end
+ plab = pin_labels(ar)
+ for l in plab
+ DeviceLayout.text!(c, l..., GDSMeta(6))
+ end
+ return c
+end
+
+function track_paths(ar::ChannelRouter)
+ return [track_path(ar, s, c) for s = 1:num_channels(ar) for c = 1:num_tracks(ar, s)]
+end
+
+channel_paths(ar::ChannelRouter) = [channel_path(ar, s) for s = 1:num_channels(ar)]
+
+function track_labels(ar::ChannelRouter{T}, tracks) where {T}
+ return [
+ DeviceLayout.Texts.Text(
+ track.name,
+ p0(track),
+ width=width(track[1].sty, zero(T)),
+ rot=α0(track),
+ xalign=DeviceLayout.Align.LeftEdge(),
+ yalign=DeviceLayout.Align.YCenter()
+ ) for track in tracks
+ ]
+end
+
+function pin_labels(ar::ChannelRouter)
+ return [
+ ("$p/$n", pin_coordinates(ar, p)) for n = 1:num_nets(ar) for p in net_pins(ar, n)
+ ]
+end
+
+function channel_path(ar::ChannelRouter, channel_idx)
+ return ar.channels[channel_idx].path
+end
+
+function track_path(ar::ChannelRouter{T}, channel_idx, track_idx) where {T}
+ n_tracks = length(channel_tracks(ar, channel_idx))
+ seg = track_path_segment(n_tracks, ar.channels[channel_idx].node, track_idx)
+ w = channel_width(ar, channel_idx, zero(T)) # Just illustrative, assume constant width
+ pa = Path(
+ [Paths.Node(seg, Paths.Trace(0.9 * w / (n_tracks + 1)))];
+ name="$channel_idx.$track_idx"
+ )
+ return pa
+end
+
+######## Actually doing the path construction
+"""
+ struct AutoChannelRouting{T <: Coordinate} <: AbstractChannelRouting
+ AutoChannelRouting(router::ChannelRouter, transition_rule::RouteRule, margin)
+ AutoChannelRouting(channels::Vector{<:RouteChannel}, transition_rule::RouteRule, margin)
+
+A `RouteRule` that delegates routing decisions to a shared [`ChannelRouter`](@ref).
+
+At the schematic level, every `route!` call that should share channel and track
+assignments must use the **same** `AutoChannelRouting` instance, so that the
+underlying `router` sees all nets before channel and track assignment run during
+`plan`. The `channels`/`transition_rule`/`margin` form constructs a fresh empty
+`ChannelRouter` internally and is convenient when nets are populated from the schematic.
+
+`transition_rule` routes the short legs between a pin and its first channel, and
+between adjacent channels. It is typically [`Paths.StraightAnd90`](@ref),
+[`Paths.StraightAnd45`](@ref), or [`Paths.BSplineRouting`](@ref). `margin`
+reserves arclength at each end of a channel segment for the transition bends.
+"""
+struct AutoChannelRouting{T <: Coordinate} <: AbstractChannelRouting
+ router::ChannelRouter{T}
+ transition_rule::RouteRule
+ transition_margin::T
+end
+
+function AutoChannelRouting(router::ChannelRouter{T}, transition_rule, margin) where {T}
+ return AutoChannelRouting{T}(router, transition_rule, convert(T, margin))
+end
+function AutoChannelRouting(
+ channels::Vector{RouteChannel{T}},
+ transition_rule,
+ margin
+) where {T}
+ return AutoChannelRouting{T}(
+ ChannelRouter(channels),
+ transition_rule,
+ convert(T, margin)
+ )
+end
+entry_rules(r::AutoChannelRouting) = Iterators.repeated(r.transition_rule)
+exit_rule(r::AutoChannelRouting) = r.transition_rule
+
+function track_path_segments(rule::AutoChannelRouting, pa::Path, _)
+ matching_idx = findall(
+ pin -> pin.p ≈ p0(pa) && isapprox_angle(in_direction(pin), α0(pa)),
+ rule.router.pins[first.(rule.router.net_pins)]
+ )
+ isempty(matching_idx) && error(
+ "Could not find start pin corresponding to path $(pa.name) starting at $(p0(pa))."
+ )
+ length(matching_idx) > 1 && error(
+ "Multiple nets $(matching_idx) including $(pa.name) start at $(p0(pa)). AutoChannelRouting requires distinct starting pins."
+ )
+ net_idx = only(matching_idx)
+ return [
+ track_path_segment(rule.router, channel, net_idx; margin=rule.transition_margin) for
+ channel in channels_taken(rule.router, net_idx)
+ ]
+end
+
+function track_path_segment(
+ ar::ChannelRouter{T},
+ channel_idx::Int,
+ net_idx::Int;
+ margin=zero(T)
+) where {T}
+ ch = ar.channels[channel_idx]
+ # Get the track wire segment from the router
+ # Assume there is exactly one wire segment belonging to this path in the channel
+ wireseg_idx = findfirst(ws -> running_channel(ws) == channel_idx, net_wire(ar, net_idx))
+ wireseg = net_wire(ar, net_idx)[wireseg_idx]
+ track_idx = segment_track(ar, wireseg)
+ # Get the starting and ending pathlengths
+ # Accounting for track offsets
+ wireseg_start, wireseg_stop = unsorted_interval(ar, wireseg)
+ prev_width = zero(T) # Autorouter just uses margin
+ next_width = zero(T) # Interval already takes into account actual neighbor track offsets so we don't need this precaution
+ channel_section = segment_channel_section(
+ ch,
+ wireseg_start,
+ wireseg_stop,
+ prev_width,
+ next_width;
+ margin
+ )
+ # Return channel section segment offset by width according to track
+ return track_path_segment(
+ length(channel_tracks(ar, channel_idx)),
+ channel_section,
+ track_idx;
+ reversed=against_channel(ar, wireseg)
+ )
+end
+
+function channels_taken(ar::ChannelRouter, net_idx::Int)
+ return [running_channel(wireseg) for wireseg in net_wire(ar, net_idx)]
+end
diff --git a/src/paths/channel_routing_core.jl b/src/paths/channel_routing_core.jl
new file mode 100644
index 000000000..28761a43d
--- /dev/null
+++ b/src/paths/channel_routing_core.jl
@@ -0,0 +1,853 @@
+# Pure algorithmic core for channel routing.
+# No DeviceLayout geometry types — communicates through AbstractChannelProblem interface.
+#
+# References:
+# Original: Hashimoto and Stevens https://cs.baylor.edu/~maurer/CSI5346/originalCR.pdf
+# VCG, HCG/Zone representation, doglegs, merging: Yoshimura and Kuh https://my.ece.utah.edu/~kalla/phy_des/yk.pdf
+# Crossing-aware: Condrat, Kalla, Blair https://my.ece.utah.edu/~kalla/papers/condrat_crossing-aware_channel_routing_for_photonic_waveguides.pdf
+
+import Graphs:
+ SimpleGraph,
+ SimpleDiGraph,
+ nv,
+ ne,
+ add_edge!,
+ add_vertex!,
+ rem_edge!,
+ has_edge,
+ edges,
+ inneighbors,
+ neighbors,
+ outneighbors,
+ maximal_cliques,
+ dijkstra_shortest_paths,
+ enumerate_paths,
+ topological_sort_by_dfs
+import SparseArrays: sparse
+import BipartiteMatching
+
+# ──── Pure types ─────────────────────────────────────────────────────────────
+
+# TrackWireSegment = (net, lengthwise channel, start vertex, end vertex)
+# vertex is the channel index or, if the start/end is a pin, the graph index for that pin
+struct TrackWireSegment
+ net_index::Int
+ running_channel::Int
+ start_vertex::Int
+ stop_vertex::Int
+end
+
+bounding_channels(ws::TrackWireSegment) = (ws.start_vertex, ws.stop_vertex)
+net_index(ws::TrackWireSegment) = ws.net_index
+running_channel(ws::TrackWireSegment) = ws.running_channel
+
+const Track = Vector{TrackWireSegment}
+const NetWire = Vector{TrackWireSegment}
+
+struct AuxiliaryGraph{T, M <: AbstractMatrix{T}}
+ graph::SimpleGraph{Int}
+ distmx::M
+ aux_to_edge::Vector{Tuple{Int, Int}} # aux vertex → original (u, v) edge
+ edge_to_aux::Dict{Tuple{Int, Int}, Int} # original (u, v) edge → aux vertex
+end
+
+# ──── Abstract interface ─────────────────────────────────────────────────────
+
+"""
+ AbstractChannelProblem{T}
+
+Abstract supertype for channel routing problems.
+
+Subtypes must implement the following interface functions so that the core
+channel-routing algorithms can operate without knowledge of the underlying
+geometry representation:
+
+ - `channel_graph(prob)::SimpleGraph{Int}`
+ - `num_channels(prob)::Int`
+ - `num_nets(prob)::Int`
+ - `num_pins(prob)::Int`
+ - `net_pins(prob, net)::Tuple{Int,Int}`
+ - `net_wire(prob, net)::NetWire`
+ - `channel_segments(prob, ch)::Vector{TrackWireSegment}`
+ - `channel_tracks(prob, ch)::Vector{Track}`
+ - `num_tracks(prob, ch)::Int`
+ - `pathlength_at_intersection(prob, ch1, ch2)::T`
+ - `direction_at_intersection(prob, ch1, ch2)::Float64` (radians)
+ - `channel_width(prob, ch, s...)::T`
+ - `is_pin(prob, idx)::Bool`
+ - `segment_offset(prob, ws, s...; use_wire_direction)::T`
+"""
+abstract type AbstractChannelProblem{T} end
+
+function channel_graph end
+function num_channels end
+function num_nets end
+function num_pins end
+function net_pins end
+function net_wire end
+function channel_segments end
+function channel_tracks end
+function num_tracks end
+function pathlength_at_intersection end
+function direction_at_intersection end
+function channel_width end
+function is_pin end
+function segment_offset end
+
+# ──── Pure utility functions ─────────────────────────────────────────────────
+
+_swap(x, y) = (y > x ? (x, y) : (y, x))
+
+function _shared_vertex(edge_a::Tuple{Int, Int}, edge_b::Tuple{Int, Int})
+ a1, a2 = edge_a
+ b1, b2 = edge_b
+ a1 in (b1, b2) && return a1
+ a2 in (b1, b2) && return a2
+ return error("Edges $edge_a and $edge_b share no vertex")
+end
+
+pin_to_graphidx(ar::AbstractChannelProblem, p::Int) = p + num_channels(ar)
+graphidx_to_pin(ar::AbstractChannelProblem, graphidx::Int) = graphidx - num_channels(ar)
+is_pin(ar::AbstractChannelProblem, graphidx) = graphidx > num_channels(ar)
+adjoining_channel(ar::AbstractChannelProblem, pin) =
+ neighbors(channel_graph(ar), pin_to_graphidx(ar, pin))[1]
+
+# Offset coordinate or function for the section of track with given width
+function track_section_offset(n_tracks, section_width::Number, track_idx; reversed=false)
+ # (spacing) * number of tracks away from middle track
+ sgn = reversed ? -1 : 1
+ spacing = section_width / (n_tracks + 1)
+ return sgn * spacing * ((1 + n_tracks) / 2 - track_idx)
+end
+
+function track_section_offset(n_tracks, section_width::Function, track_idx; reversed=false)
+ # (spacing) * number of tracks away from middle track
+ return t ->
+ (reversed ? -1 : 1) *
+ (section_width(t) / (n_tracks + 1)) *
+ ((1 + n_tracks) / 2 - track_idx)
+end
+
+# ──── Track/segment queries ──────────────────────────────────────────────────
+
+"""
+ segment_track(ar::AbstractChannelProblem, ws::TrackWireSegment)
+
+The track index of `ws`, or `nothing` if no track has been assigned.
+"""
+function segment_track(ar::AbstractChannelProblem, ws::TrackWireSegment)
+ channel_idx = running_channel(ws)
+ tracks = channel_tracks(ar, channel_idx)
+ track_idx = findfirst((c) -> ws in c, tracks)
+ return track_idx
+end
+
+"""
+ next(ar::AbstractChannelProblem, ws::TrackWireSegment)
+
+The wire segment after `ws`, with the wire directed from the source to the destination pin.
+"""
+function next(ar::AbstractChannelProblem, ws::TrackWireSegment)
+ net_idx = net_index(ws)
+ segs = net_wire(ar, net_idx)
+ idx = findfirst(isequal(ws), segs)
+ if idx == length(segs)
+ final_pin_idx = pin_to_graphidx(ar, last(net_pins(ar, net_idx)))
+ return TrackWireSegment(net_idx, final_pin_idx, running_channel(ws), 0)
+ end
+ return segs[idx + 1]
+end
+
+"""
+ prev(ar::AbstractChannelProblem, ws::TrackWireSegment)
+
+The wire segment before `ws`, with the wire directed from the source to the destination pin.
+"""
+function prev(ar::AbstractChannelProblem, ws::TrackWireSegment)
+ net_idx = net_index(ws)
+ segs = net_wire(ar, net_idx)
+ idx = findfirst(isequal(ws), segs)
+ if idx == 1
+ first_pin_idx = pin_to_graphidx(ar, first(net_pins(ar, net_idx)))
+ return TrackWireSegment(net_idx, first_pin_idx, 0, running_channel(ws))
+ end
+ return segs[idx - 1]
+end
+
+function against_channel(ar, wireseg)
+ s1, s2 = unsorted_interval(ar, wireseg)
+ return s1 > s2
+end
+
+# ──── Interval computation ───────────────────────────────────────────────────
+
+"""
+ interval(ar::AbstractChannelProblem, ws::TrackWireSegment)
+
+A tuple `(start, stop)` of approximate channel pathlengths at which `ws` starts and stops.
+
+If tracks have been assigned to the previous or next segments, then the track offset is
+taken into account. Otherwise, the start and stop are at the centre line of the intersecting channel.
+
+The interval is always a tuple with the lower bound as the first element.
+"""
+function interval(ar::AbstractChannelProblem, ws::TrackWireSegment; use_track=true)
+ return _swap(unsorted_interval(ar, ws; use_track)...)
+end
+
+function unsorted_interval(ar::AbstractChannelProblem, ws::TrackWireSegment; use_track=true)
+ start_channel, stop_channel = bounding_channels(ws)
+ channel_idx = running_channel(ws)
+
+ start_channel, stop_channel = bounding_channels(ws)
+ s1 = pathlength_at_intersection(ar, channel_idx, start_channel)
+ s2 = pathlength_at_intersection(ar, channel_idx, stop_channel)
+ (!use_track || is_pin(ar, channel_idx)) && return _swap(s1, s2)
+ # Could just do that for all cases
+ # But if we want to break ties we use offsets from previous/next segments
+ # Offset sign needs to take into account relative directions of segments in channels
+ s_start = pathlength_at_intersection(ar, start_channel, channel_idx)
+ s_stop = pathlength_at_intersection(ar, stop_channel, channel_idx)
+ pt, nt = prev_next_tendency(ar, ws)
+ start_dir = direction_at_intersection(ar, start_channel, channel_idx)
+ dir1 = direction_at_intersection(ar, channel_idx, start_channel)
+ dir2 = direction_at_intersection(ar, channel_idx, stop_channel)
+ stop_dir = direction_at_intersection(ar, stop_channel, channel_idx)
+ # Offset also depends neighbor track offsets
+ α_ixn_start = (dir1 - start_dir)
+ α_ixn_stop = (stop_dir - dir2)
+ prev_offset_proj =
+ segment_offset(ar, prev(ar, ws), s_start; use_wire_direction=false) /
+ sin(α_ixn_start)
+ next_offset_proj =
+ segment_offset(ar, next(ar, ws), s_stop; use_wire_direction=false) / sin(α_ixn_stop)
+ # Offset *also* depends on this wire segment's offset at a non-90° intersection
+ start_offset_proj =
+ -segment_offset(ar, ws, s_start; use_wire_direction=false) / tan(α_ixn_start)
+ stop_offset_proj =
+ -segment_offset(ar, ws, s_stop; use_wire_direction=false) / tan(α_ixn_stop)
+ # This is approximate on bending or tapered tracks
+ return s1 + (prev_offset_proj + start_offset_proj),
+ s2 - (next_offset_proj + stop_offset_proj)
+end
+
+# ──── Tendency computation ───────────────────────────────────────────────────
+
+# +1 if segment crosses over low track index in ws's channel
+function prev_next_tendency(ar, ws; use_wire_direction=true)
+ channel_idx = running_channel(ws)
+ start_channel, stop_channel = bounding_channels(ws)
+ # Distances along bounding channels
+ s_along_start = pathlength_at_intersection(ar, start_channel, channel_idx)
+ s_along_stop = pathlength_at_intersection(ar, stop_channel, channel_idx)
+ # Directions of bounding and running channels (Float64 radians from interface)
+ start_dir = direction_at_intersection(ar, start_channel, channel_idx)
+ dir1 = direction_at_intersection(ar, channel_idx, start_channel)
+ dir2 = direction_at_intersection(ar, channel_idx, stop_channel)
+ stop_dir = direction_at_intersection(ar, stop_channel, channel_idx)
+ # Tendencies
+ ## +ve = wire makes CCW turns
+ ## But actual bends depend on direction of wires vs channels
+ ### Signs of angles made by channel intersections
+ sgn_bend1 = sign(rem2pi(dir1 - start_dir, RoundNearest))
+ sgn_bend2 = sign(rem2pi(stop_dir - dir2, RoundNearest))
+ !use_wire_direction && return (sgn_bend1, sgn_bend2)
+ ### Need to multiply according to direction in channel
+ ### Is prev upper-bounded by ws? Then it goes along with channel
+ sgn_start = s_along_start >= last(interval(ar, prev(ar, ws), use_track=false)) ? 1 : -1
+ ### Is next upper-bounded by ws? Then it goes against channel
+ sgn_stop = s_along_stop >= last(interval(ar, next(ar, ws), use_track=false)) ? -1 : 1
+ ### Bend signs get another -1 if ws runs opposite to its channel direction
+ ### But then tendency definition is reversed also
+ return (sgn_start * sgn_bend1, sgn_stop * sgn_bend2)
+end
+
+# ──── Overlap and avoidability ───────────────────────────────────────────────
+
+function segments_overlap(ar, seg1, seg2)
+ low1, high1 = interval(ar, seg1)
+ low2, high2 = interval(ar, seg2)
+ if low1 <= low2 # segments are in ascending order
+ low2 < high1 && return true
+ low2 == high1 || return false
+ # Boundary case: check if knock-knee is actually possible
+ return _same_tendency_at_boundary(ar, seg1, seg2)
+ else # descending order, just reverse the roles
+ low1 < high2 && return true
+ low1 == high2 || return false
+ return _same_tendency_at_boundary(ar, seg2, seg1)
+ end
+end
+
+function _same_tendency_at_boundary(ar, seg1, seg2)
+ p1, n1 = bounding_channels(seg1)
+ p2, n2 = bounding_channels(seg2)
+ shared_boundary = [2 - 1 * (p1 == p2 || p1 == n2), 2 - 1 * (p2 == p1 || p2 == n1)]
+ t1 = prev_next_tendency(ar, seg1)
+ t2 = prev_next_tendency(ar, seg2)
+ return t1[shared_boundary[1]] == t2[shared_boundary[2]]
+end
+
+function is_avoidable(
+ low1,
+ high1,
+ low2,
+ high2,
+ low1_tend,
+ high1_tend,
+ low2_tend,
+ high2_tend
+)
+ if high1 < high2
+ # 1 ____
+ # 2 ____
+ order = [1, 2, 1, 2] # low1 <= low2 < high1 < high2
+ ordered_tendency = [low1_tend, low2_tend, high1_tend, high2_tend]
+ else
+ # 1 _____
+ # 2 __
+ order = [1, 2, 2, 1] # low1 <= low2 < high2 <= high1
+ ordered_tendency = [low1_tend, low2_tend, high2_tend, high1_tend]
+ end
+ up = (ordered_tendency .== 1)
+ down = (!).(up)
+ ccw_order = [reverse(order[up]); order[down]]
+ # Crossing is avoidable if same net has both endpoints adjacent
+ # on the clock
+ avoidable = (ccw_order[1] == ccw_order[2] || ccw_order[2] == ccw_order[3])
+ # Also, if seg1 and seg2 have an endpoint at the same place and don't make an X,
+ # then crossing in this channel may be avoidable but depends on
+ # other channel; assume other channel will agree
+ depends =
+ ((low1 == low2) || high1 == high2) &&
+ ((low1_tend == low2_tend) || (high1_tend == high2_tend))
+ avoidable = (avoidable || depends)
+ return avoidable
+end
+
+# ──── Graph algorithms ───────────────────────────────────────────────────────
+
+"""
+ build_auxiliary_graph(ar::AbstractChannelProblem)
+
+Build an auxiliary graph where each vertex represents an intersection point (edge in the
+channel graph) and edges connect consecutive intersections along the same channel, weighted
+by the physical distance between them.
+"""
+function build_auxiliary_graph(ar::AbstractChannelProblem{T}) where {T}
+ g = channel_graph(ar)
+ n_aux = ne(g)
+
+ # Map channel-graph edges to auxiliary vertices
+ aux_to_edge = Vector{Tuple{Int, Int}}(undef, n_aux)
+ edge_to_aux = Dict{Tuple{Int, Int}, Int}()
+ for (i, e) in enumerate(edges(g))
+ key = _swap(e.src, e.dst)
+ aux_to_edge[i] = key
+ edge_to_aux[key] = i
+ end
+
+ # Build auxiliary graph: chain consecutive intersections per channel
+ aux_g = SimpleGraph(n_aux)
+ I = Int[]
+ J = Int[]
+ V = T[]
+ for ch = 1:num_channels(ar)
+ nbs = neighbors(g, ch)
+ length(nbs) < 2 && continue
+
+ # Collect (pathlength_along_ch, aux_vertex) for each intersection on this channel
+ ch_ixns = Tuple{T, Int}[
+ (pathlength_at_intersection(ar, ch, nb), edge_to_aux[_swap(ch, nb)]) for
+ nb in nbs
+ ]
+ sort!(ch_ixns, by=first)
+
+ # Connect consecutive intersection points
+ for k = 1:(length(ch_ixns) - 1)
+ s1, aux1 = ch_ixns[k]
+ s2, aux2 = ch_ixns[k + 1]
+ add_edge!(aux_g, aux1, aux2)
+ w = abs(s2 - s1)
+ push!(I, aux1)
+ push!(J, aux2)
+ push!(V, w)
+ push!(I, aux2)
+ push!(J, aux1)
+ push!(V, w)
+ end
+ end
+
+ distmx = sparse(I, J, V, n_aux, n_aux)
+ return AuxiliaryGraph(aux_g, distmx, aux_to_edge, edge_to_aux)
+end
+
+"""
+ shortest_path_between_pins(ar::AbstractChannelProblem, pin_1::Int, pin_2::Int, aux::AuxiliaryGraph)
+
+A shortest path minimizing physical distance (sum of arclengths along channels between
+intersection points), using a precomputed [`AuxiliaryGraph`](@ref).
+
+In both cases, the returned path is a list of vertex indices
+`[pin_gidx, ch1, ..., chN, pin_gidx]`.
+"""
+function shortest_path_between_pins(
+ ar::AbstractChannelProblem,
+ p0::Int,
+ p1::Int,
+ aux::AuxiliaryGraph
+)
+ pin0_gidx = pin_to_graphidx(ar, p0)
+ pin1_gidx = pin_to_graphidx(ar, p1)
+ src_aux = aux.edge_to_aux[_swap(pin0_gidx, adjoining_channel(ar, p0))]
+ dst_aux = aux.edge_to_aux[_swap(pin1_gidx, adjoining_channel(ar, p1))]
+
+ ds = dijkstra_shortest_paths(aux.graph, src_aux, aux.distmx)
+ aux_path = enumerate_paths(ds, dst_aux)
+ isempty(aux_path) && error("No path between pins $p0 and $p1")
+
+ # Convert aux vertices back to channel-graph vertex sequence.
+ # Skip consecutive duplicates: the aux path may traverse multiple intersections on
+ # the same channel, which just means a longer segment on that channel.
+ path = Int[pin0_gidx]
+ for i = 1:(length(aux_path) - 1)
+ ch = _shared_vertex(aux.aux_to_edge[aux_path[i]], aux.aux_to_edge[aux_path[i + 1]])
+ if ch != last(path)
+ push!(path, ch)
+ end
+ end
+ push!(path, pin1_gidx)
+ return path
+end
+
+# ──── Channel assignment ─────────────────────────────────────────────────────
+
+"""
+ assign_channels!(ar::AbstractChannelProblem)
+
+Performs channel assignment for `ar`.
+
+Finds a shortest path between pins minimizing physical distance along channels
+(sum of arclengths between intersection points), using an auxiliary intersection-point
+graph with Dijkstra's algorithm.
+Does not currently take congestion, crossings, or channel capacity into account.
+"""
+function assign_channels!(
+ ar::AbstractChannelProblem;
+ net_indices=eachindex(ar.net_pins),
+ fixed_paths::Dict{Int, Vector{Int}}=Dict{Int, Vector{Int}}()
+)
+ aux = build_auxiliary_graph(ar)
+ for (idx_net, net) in zip(net_indices, ar.net_pins[net_indices])
+ p0, p1 = net
+ path = if idx_net in keys(fixed_paths)
+ [
+ pin_to_graphidx(ar, p0)
+ fixed_paths[idx_net]
+ pin_to_graphidx(ar, p1)
+ ]
+ else
+ shortest_path_between_pins(ar, p0, p1, aux)
+ end
+ ixns = [(path[i], path[i + 1]) for i = 1:(length(path) - 1)]
+ segs = [(ixns[i], ixns[i + 1]) for i = 1:(length(ixns) - 1)]
+ for (channel, seg) in zip(path[2:(end - 1)], segs)
+ ws = TrackWireSegment(idx_net, channel, first(seg[1]), last(seg[2]))
+ push!(net_wire(ar, idx_net), ws)
+ push!(channel_segments(ar, channel), ws)
+ end
+ end
+end
+
+# ──── Track assignment ───────────────────────────────────────────────────────
+
+"""
+ assign_tracks!(ar::AbstractChannelProblem)
+
+Performs track assignment for all channels in `ar`.
+
+Track assignment operates on **all** channels, not a subset. Re-running after modifying
+a single net will reassign tracks globally in every channel that contains wire segments.
+"""
+function assign_tracks!(ar::AbstractChannelProblem)
+ # Order of channels will change results
+ # Generally you want early channels to be those with more pins adjoining them
+ # Or fewer non-pinned segments
+ # Because those channels will inform constraints on later channels
+ # For now the user can worry about that
+ for channel = 1:num_channels(ar)
+ assign_tracks_matching!(ar, channel)
+ end
+end
+
+function merge_in_vcg!(vcg, v1, v2)
+ # Children of one become children of the other
+ for child in outneighbors(vcg, v1) # Directed neighbor v1 -> child
+ add_edge!(vcg, v2, child)
+ end
+ for child in outneighbors(vcg, v2) # Directed neighbor v2 -> child
+ add_edge!(vcg, v1, child)
+ end
+ # Parents of one become parents of the other
+ for parent in inneighbors(vcg, v1) # parent -> v1
+ add_edge!(vcg, parent, v2)
+ end
+ for parent in inneighbors(vcg, v2)
+ add_edge!(vcg, parent, v1)
+ end
+end
+
+function assign_tracks_matching!(ar, channel)
+ # Yoshimura and Kuh Algorithm #2
+ # We will not check whether there are cycles in the vertical constraint graph, just assume
+ vcg, zone_ig = channel_problem_graphs(ar, channel)
+ zones = maximal_cliques(zone_ig)
+ isempty(zones) && return
+ # Need to sort zones left to right (by their minimal element)
+ sort!(zones, by=minimum)
+ # Same with wire segments, to follow same indexing as graphs
+ wiresegs_ascending = sort(channel_segments(ar, channel), by=(ws) -> interval(ar, ws))
+ L = Set{Int}()
+ active = Set(zones[1])
+ merged_groups = Dict{Int, Vector{Int}}()
+ merged_into = Dict{Int, Int}() # merged_into[x] = y means y was merged into x with y > x
+ merging_graph = SimpleGraph(length(wiresegs_ascending))
+ matching = Dict{Int, Int}()
+ R = Set{Int}()
+
+ for (zone, nextzone) in zip(zones, [zones[2:end]..., Int[]]) # Extra empty zone at end
+ # Merge nets terminating in current zone to left side
+ if length(zones) > 1
+ for v in collect(active) # v was in nextzone last round
+ if !(v in nextzone) # v terminates in current zone
+ pop!(active, v) # no longer active
+ push!(L, v) # add to left side
+ if haskey(matching, v)
+ # Matched in previous round, so update VCG
+ merge_in_vcg!(vcg, matching[v], v)
+ # Record merge
+ merged_into[matching[v]] = v
+ group = get!(merged_groups, matching[v], Int[matching[v]])
+ push!(group, v)
+ merged_groups[v] = group
+ # Replace match with v as representative of merged group in L
+ pop!(L, matching[v]) # match must have been in L
+ end
+ v in R && pop!(R, v)
+ end
+ end
+ end
+ # Add nets starting in next zone to right side
+ for v in nextzone
+ if !(v in active)
+ push!(R, v)
+ push!(active, v)
+ end
+ end
+ # Remove all edges in merging graph, start fresh this iteration
+ merging_graph = SimpleGraph(length(wiresegs_ascending))
+ # Add edges between left and right when they can be merged
+ high_to_low = topological_sort_by_dfs(vcg)
+ dists_from_r = Dict(r => dag_shortest_paths(vcg, high_to_low, r) for r in R)
+ for l in L
+ # Only use rightmost in any merged group
+ haskey(merged_into, l) && continue
+ dists_from_l = dag_shortest_paths(vcg, high_to_low, l)
+ for r in R
+ mergeable = dists_from_l[r] >= nv(vcg) && dists_from_r[r][l] >= nv(vcg)
+ !mergeable && continue
+ if !segments_overlap(ar, wiresegs_ascending[l], wiresegs_ascending[r])
+ add_edge!(merging_graph, l, r)
+ end
+ end
+ end
+ # Find max cardinality valid matching, removing edges as necessary
+ matching = best_matching!(merging_graph, vcg, L, R)
+ end
+ # Assign merged groups to tracks according to VCG
+ tracks = channel_tracks(ar, channel)
+ # At the end of this process, segments are merged into layers in the VCG
+ # The longest directed path gives a representative of each merged group where track height is max
+ # But VCG may be a partial order so use topological sort
+ high_to_low = topological_sort_by_dfs(vcg) # If vcg was acyclic to begin with, it is still acyclic
+ for v = 1:nv(vcg)
+ if !haskey(merged_groups, v)
+ merged_groups[v] = [v]
+ end
+ end
+ assigned = Int[]
+ for v in high_to_low # high in vcg => low track index
+ # Create a track with `v` and all others merged with it
+ v in assigned && continue
+ push!(tracks, wiresegs_ascending[merged_groups[v]])
+ append!(assigned, merged_groups[v])
+ end
+end
+
+function best_matching!(merging_graph, vcg, L, R)
+ # Collect set of edges to remove
+ to_remove = Set{Tuple{Int, Int}}()
+ # Create a temporary copy to help find problematic edges
+ edge_selection_graph = copy(merging_graph)
+ working_vcg = copy(vcg)
+ # Collect "deleted" vertices in edge_selection_graph
+ # Vertices aren't labelled, just indexed, so we don't actually delete them
+ ignored = Set{Int}()
+ # While working graphs have vertices, keep collecting edges to remove from merging graph
+ while length(ignored) < nv(edge_selection_graph)
+ # 1. Find nodes with no VCG ancestors, remove edges between them
+ # 2. If there are nodes with no edges in edge_selection_graph, remove them from working graphs and go back to 1
+ # 3. Now the node with the fewest edges has at least one edge
+ # That edge corresponds to a merging that would cause a problem in the VCG
+ # so then we mark it for removal from the merging graph
+ # and remove it from our working graphs
+ min_neighbors = nv(edge_selection_graph)
+ v_min_neighbors = 0
+ orphans = true
+ while orphans
+ min_neighbors = nv(edge_selection_graph)
+ v_min_neighbors = 0
+ orphans = false
+ # Remove edges between vertices with no ancestors
+ # Then they will not be selected for removal from `merging_graph`
+ no_ancestors = Int[]
+ for v = 1:nv(edge_selection_graph)
+ v in ignored && continue
+ if isempty(inneighbors(working_vcg, v)) ||
+ all([w in ignored for w in inneighbors(working_vcg, v)])
+ # v has no surviving ancestors, remove edges to other such vertices
+ for w in no_ancestors
+ rem_edge!(edge_selection_graph, v, w)
+ end
+ push!(no_ancestors, v)
+ end
+ end
+ # Find nodes with minimum number of edges
+ for v = 1:nv(edge_selection_graph)
+ v in ignored && continue
+ nbs = neighbors(edge_selection_graph, v)
+ if length(nbs) < min_neighbors
+ min_neighbors = length(nbs)
+ v_min_neighbors = v
+ end
+ # If any nodes have no edges, remove them and go back
+ if isempty(nbs)
+ min_neighbors = 0
+ orphans = true
+ push!(ignored, v)
+ # No need to remove edges from edge_selection_graph because it doesn't have any
+ # "Removing" v from working VCG means merging edges betwen its parents and children
+ for parent in inneighbors(working_vcg, v)
+ for child in outneighbors(working_vcg, v)
+ add_edge!(working_vcg, parent, child)
+ end
+ end
+ end
+ end
+ end
+ # Remove a node with fewest edges, remove and collect those edges
+ if !iszero(v_min_neighbors) && min_neighbors > 0
+ for nb in copy(neighbors(edge_selection_graph, v_min_neighbors)) # copy bc neighbors is changing in the loop
+ rem_edge!(edge_selection_graph, v_min_neighbors, nb)
+ push!(to_remove, (v_min_neighbors, nb))
+ end
+ push!(ignored, v_min_neighbors)
+ # "Removing" v from working VCG means merging edges betwen its parents and children
+ for parent in inneighbors(working_vcg, v_min_neighbors)
+ for child in outneighbors(working_vcg, v_min_neighbors)
+ add_edge!(working_vcg, parent, child)
+ end
+ end
+ end
+ end
+ # Remove marked edges
+ for edge in to_remove
+ rem_edge!(merging_graph, edge...)
+ end
+ # Any matching is feasible now that we've removed marked edges.
+ # Rows are R so the returned row→col dict is keyed by R, matching how the
+ # caller looks up `matching[v]` when v crosses from R into L next iteration.
+ R_vec = collect(R)
+ L_vec = collect(L)
+ sort!(R_vec) # For determinism
+ sort!(L_vec)
+ bip = falses(length(R_vec), length(L_vec))
+ for (i, r) in pairs(R_vec), (j, l) in pairs(L_vec)
+ bip[i, j] = has_edge(merging_graph, r, l)
+ end
+ row_to_col, _ = BipartiteMatching.findmaxcardinalitybipartitematching(BitMatrix(bip))
+ return Dict{Int, Int}(R_vec[i] => L_vec[j] for (i, j) in row_to_col)
+end
+
+function dag_shortest_paths(dag, v_sorted, s)
+ d = fill(nv(dag), nv(dag))
+ d[s] = 0
+ for i = findfirst(v -> v == s, v_sorted):nv(dag)
+ u = v_sorted[i]
+ for v in outneighbors(dag, u)
+ if d[v] > d[u] + 1
+ d[v] = d[u] + 1
+ end
+ end
+ end
+ return d
+end
+
+function channel_problem_graphs(ar::AbstractChannelProblem, channel)
+ wiresegs_ascending = sort(channel_segments(ar, channel), by=(ws) -> interval(ar, ws))
+ # Y&K zone representation as interval graph
+ # Edge between each pair of segments that overlap
+ zone_ig = SimpleGraph(length(wiresegs_ascending))
+ # Condrat et al. VCG with avoidable crossings as constraints
+ # Not handled: constraints from vertically aligned pin positions
+ vcg = SimpleDiGraph(length(wiresegs_ascending)) # just a fresh graph
+ for (idx1, seg1) in pairs(wiresegs_ascending)
+ low1, high1 = interval(ar, seg1)
+ for (idx2, seg2) in collect(pairs(wiresegs_ascending))[(idx1 + 1):end]
+ low2, high2 = interval(ar, seg2)
+
+ # If there is no overlap, break and move on to next seg1
+ # Use >= instead of > to potentially allow knock-knees
+ low2 >= high1 && break # All subsequent seg2 have low2 >= high1
+ # There is overlap, so add an edge to the interval graph
+ add_edge!(zone_ig, idx1, idx2)
+ # Now check if crossing is unavoidable
+ pt1, nt1 = prev_next_tendency(ar, seg1)
+ pt2, nt2 = prev_next_tendency(ar, seg2)
+ # If seg1 and seg2 enter and exit at the same place with same tendency, then crossing
+ # may be avoidable, but this channel can't say which goes on top yet
+ (low1 == high1 && low2 == high2 && pt1 == pt2 && nt1 == nt2) && break
+ low1_tend, high1_tend = against_channel(ar, seg1) ? (nt1, pt1) : (pt1, nt1)
+ low2_tend, high2_tend = against_channel(ar, seg2) ? (nt2, pt2) : (pt2, nt2)
+ avoidable = is_avoidable(
+ low1,
+ high1,
+ low2,
+ high2,
+ low1_tend,
+ high1_tend,
+ low2_tend,
+ high2_tend
+ )
+ !avoidable && continue
+
+ # Crossing is avoidable, so add a constraint
+ # Determine which goes on top based on the lower bound tendency of seg2
+ # Is prev or next the lower bound?
+ # top is rightmost segment iff its lower bound tends towards higher (lower index) tracks
+ top = (idx1, idx2)[1 + (low2_tend == 1)]
+ bottom = (idx1, idx2)[2 - (low2_tend == 1)]
+ add_edge!(vcg, top, bottom) # VCG has edge from higher to lower tracks
+ end
+ end
+ return vcg, zone_ig
+end
+
+# ──── Segment deletion / reset ───────────────────────────────────────────────
+
+function _delete_segment!(ar, ws; reset_tracks=true, from_net=true)
+ # Deletes the wire segment `ws` from `ar`.
+
+ # Delete the wire segment in channel_segments
+ s = running_channel(ws)
+ deleteat!(channel_segments(ar, s), findfirst(isequal(ws), channel_segments(ar, s)))
+
+ # Delete the segment from its track
+ c_idx = segment_track(ar, ws)
+ if !isnothing(c_idx)
+ if reset_tracks # by default, reset all track assignments in this channel
+ empty!(channel_tracks(ar, s))
+ else
+ deleteat!(
+ channel_tracks(ar, s)[c_idx],
+ findfirst(isequal(ws), channel_tracks(ar, s)[c_idx])
+ )
+ end
+ end
+
+ # if from_net, delete ws from net_wires
+ # We set from_net to false when looping over net segments so it's not changing under us
+ return from_net && deleteat!(
+ net_wire(ar, net_index(ws)),
+ findfirst(isequal(ws), net_wire(ar, net_index(ws)))
+ )
+end
+
+"""
+ reset_nets!(ar; net_indices=eachindex(ar.net_pins), reset_tracks=true)
+
+Resets the nets with `net_indices` to their unrouted state.
+
+If `reset_tracks` is `true` (the default), then **all** track assignments in every channel
+that contained a deleted segment are cleared — not just the tracks for the deleted nets.
+This affects other nets sharing those channels. This is intentional: track assignment must be
+globally consistent within a channel.
+"""
+function reset_nets!(ar; net_indices=eachindex(ar.net_pins), reset_tracks=true)
+ for segs in net_wire.(ar, net_indices)
+ for ws in segs
+ # delete segment from the router
+ # don't delete it from net yet, we'll do that after this loop
+ _delete_segment!(ar, ws, reset_tracks=reset_tracks, from_net=false)
+ end
+ empty!(segs)
+ end
+end
+
+"""
+ reroute_nets!(ar::AbstractChannelProblem, net_indices; fixed_paths=Dict{Int,Vector{Int}}())
+
+Reset and re-route specific nets, then reassign tracks globally.
+
+Returns the set of all net indices affected by the track reassignment (i.e., nets that
+shared a channel with any re-routed net). This set always includes `net_indices` and may
+include additional nets whose track assignments changed as a side effect.
+
+See also [`reset_nets!`](@ref), [`assign_channels!`](@ref), [`assign_tracks!`](@ref).
+"""
+function reroute_nets!(
+ ar::AbstractChannelProblem,
+ net_indices;
+ fixed_paths::Dict{Int, Vector{Int}}=Dict{Int, Vector{Int}}()
+)
+ # Identify all nets sharing channels with the target nets (for caller awareness)
+ affected_nets = Set{Int}(net_indices)
+ for idx in net_indices
+ for ws in net_wire(ar, idx)
+ for other_ws in channel_segments(ar, running_channel(ws))
+ push!(affected_nets, net_index(other_ws))
+ end
+ end
+ end
+
+ reset_nets!(ar; net_indices=net_indices, reset_tracks=true)
+ assign_channels!(ar; net_indices=net_indices, fixed_paths)
+ assign_tracks!(ar)
+ return affected_nets
+end
+
+# ──── Diagnostics ────────────────────────────────────────────────────────────
+
+"""
+ routing_summary([io::IO,] ar::AbstractChannelProblem)
+
+Print a per-net summary of routing results: pin pair, channel path, and track assignments.
+
+Segments with unassigned tracks are flagged.
+"""
+function routing_summary(io::IO, ar::AbstractChannelProblem)
+ for idx = 1:num_nets(ar)
+ wire = net_wire(ar, idx)
+ pins = net_pins(ar, idx)
+ channels_used = [running_channel(ws) for ws in wire]
+ tracks = [segment_track(ar, ws) for ws in wire]
+ has_unassigned = any(isnothing, tracks)
+ println(
+ io,
+ "Net $idx: pins $(pins[1])→$(pins[2]), $(length(wire)) segments, " *
+ "channels $channels_used, tracks $tracks" *
+ (has_unassigned ? " [UNASSIGNED TRACKS]" : "")
+ )
+ end
+end
+routing_summary(ar::AbstractChannelProblem) = routing_summary(stdout, ar)
diff --git a/src/paths/channels.jl b/src/paths/channels.jl
index 4d2494133..f52e01543 100644
--- a/src/paths/channels.jl
+++ b/src/paths/channels.jl
@@ -48,8 +48,8 @@ function segment_channel_section(
channel_section = split(
ch.node,
[
- wireseg_start + margin + prev_width / 2,
- wireseg_stop - margin - next_width / 2
+ max(zero(T), wireseg_start + margin + prev_width / 2),
+ min(pathlength(ch.node.seg), wireseg_stop - margin - next_width / 2)
]
)[2]
elseif d < zero(d) # segment is counter to channel direction
@@ -57,8 +57,8 @@ function segment_channel_section(
split(
ch.node,
[
- wireseg_stop + margin + next_width / 2,
- wireseg_start - margin - prev_width / 2
+ max(zero(T), wireseg_stop + margin + next_width / 2)
+ min(pathlength(ch.node.seg), wireseg_start - margin - prev_width / 2)
]
)[2]
)
@@ -74,27 +74,6 @@ function track_path_segment(n_tracks, channel_section, track_idx; reversed=false
)
end
-# Offset coordinate or function for the section of track with given width
-function track_section_offset(
- n_tracks,
- section_width::Coordinate,
- track_idx;
- reversed=false
-)
- # (spacing) * number of tracks away from middle track
- sgn = reversed ? -1 : 1
- spacing = section_width / (n_tracks + 1)
- return sgn * spacing * ((1 + n_tracks) / 2 - track_idx)
-end
-
-function track_section_offset(n_tracks, section_width::Function, track_idx; reversed=false)
- # (spacing) * number of tracks away from middle track
- return t ->
- (reversed ? -1 : 1) *
- (section_width(t) / (n_tracks + 1)) *
- ((1 + n_tracks) / 2 - track_idx)
-end
-
reverse(n::Node) = Paths.Node(reverse(n.seg), reverse(n.sty, pathlength(n.seg)))
######## Methods required to use segments and styles as RouteChannels
function reverse(b::BSpline{T}) where {T}
@@ -177,7 +156,7 @@ function _route!(
sty;
waypoints
)
- push!(p, Node(resolve_offset(track_path_seg), sty), reconcile=false) # p0, α0 reconciled by construction
+ push!(p, Node(resolve_offset(track_path_seg), sty), reconcile=false)
p[end - 1].next = p[end]
p[end].prev = p[end - 1]
# Note `auto_curvature` BSpline uses curvature from end of previous segment
diff --git a/src/paths/paths.jl b/src/paths/paths.jl
index df8b6a91a..07a5ce214 100644
--- a/src/paths/paths.jl
+++ b/src/paths/paths.jl
@@ -709,8 +709,11 @@ end
Generic fallback, approximating a [`Paths.Segment`](@ref) using many
[`Polygons.LineSegment`](@ref) objects. Returns a vector of `LineSegment`s.
"""
-function line_segments(seg::Paths.Segment)
- return Polygons.segmentize(seg.(discretization(seg)), false)
+function line_segments(seg::Paths.Segment{T}) where {T}
+ return Polygons.segmentize(
+ DeviceLayout.discretize_curve(seg, DeviceLayout.onenanometer(T)),
+ false
+ )
end
"""
@@ -761,7 +764,9 @@ include("segments/bspline_optimization.jl")
include("routes.jl")
+include("channel_routing_core.jl")
include("channels.jl")
+include("channel_autorouter.jl")
function change_handedness!(seg::Union{Turn, Corner})
return seg.α = -seg.α
diff --git a/src/render/discretization.jl b/src/render/discretization.jl
index d2bbb68a9..4d4ead3ea 100644
--- a/src/render/discretization.jl
+++ b/src/render/discretization.jl
@@ -216,6 +216,7 @@ function discretization_grid(
bnds::Tuple{Float64, Float64}=(0.0, 1.0);
t_scale=1.0
)
+ bnds[1] == bnds[2] && return [bnds[1]]
dt = 0.01 * bnds[2]
ts = zeros(100)
ts[1] = bnds[1]
@@ -245,7 +246,7 @@ function discretization_grid(
t = ts[i]
end
# Make sure last two points aren't unnecessarily close together
- if ts[i - 1] > (ts[i] + ts[i - 2]) / 2
+ if i > 2 && ts[i - 1] > (ts[i] + ts[i - 2]) / 2
ts[i - 1] = (ts[i] + ts[i - 2]) / 2
end
return ts[1:i]
diff --git a/src/schematics/routes.jl b/src/schematics/routes.jl
index 481151d44..2d594e7ad 100644
--- a/src/schematics/routes.jl
+++ b/src/schematics/routes.jl
@@ -38,7 +38,8 @@ RouteComponent(
sty::Paths.Style,
meta::Meta
) where {T} = RouteComponent{T}(name, r, gw, [sty], meta)
-hooks(rc::RouteComponent) = (p0=PointHook(rc.r.p0, rc.r.α0), p1=PointHook(rc.r.p1, rc.r.α1))
+hooks(rc::RouteComponent) =
+ (p0=PointHook(rc.r.p0, rc.r.α0), p1=PointHook(rc.r.p1, rc.r.α1 + 180°))
function redecorate!(path::Path, sty::Paths.Style) end
function redecorate!(path::Path, sty::Paths.DecoratedStyle)
@@ -268,3 +269,36 @@ function _update_with_plan!(rule::Paths.SingleChannelRouting, route_node, sch)
global_node = trans(rule.channel.node)
return rule.global_channel = Paths.RouteChannel(Path([global_node]))
end
+
+# AutoChannelRouting
+function _update_with_graph!(rule::Paths.AutoChannelRouting, route_node, graph; kwargs...)
+ push!(rule.router.net_wires, Paths.NetWire()) # So we know how many routes to expect in planning
+ return
+end
+# Only when planning
+function _update_with_plan!(rule::Paths.AutoChannelRouting{T}, route_node, sch) where {T}
+ pin_idx = length(rule.router.pins) + 1
+ push!(rule.router.pins, hooks(route_node.component).p0)
+ push!(rule.router.pins, hooks(route_node.component).p1)
+ push!(rule.router.net_pins, (pin_idx, pin_idx + 1))
+ # If all paths have been added, go ahead and run autorouting
+ if length(rule.router.net_pins) == length(rule.router.net_wires)
+ g, ixns = Paths.build_channel_graph(
+ rule.router.pins,
+ getproperty.(rule.router.channels, :path),
+ T
+ )
+ # Populate the router's graph and intersection dict in-place
+ # (ChannelRouter is immutable, but its mutable fields can be mutated)
+ ar_g = rule.router.channel_graph
+ for _ = 1:(Paths.nv(g) - Paths.nv(ar_g))
+ Paths.add_vertex!(ar_g)
+ end
+ for e in edges(g)
+ Paths.add_edge!(ar_g, e.src, e.dst)
+ end
+ merge!(rule.router.channel_intersections, ixns)
+ Paths.assign_channels!(rule.router)
+ Paths.assign_tracks!(rule.router)
+ end
+end
diff --git a/test/test_aqua.jl b/test/test_aqua.jl
index 186c7c7ee..543893796 100644
--- a/test/test_aqua.jl
+++ b/test/test_aqua.jl
@@ -3,7 +3,7 @@
# Everything but stdlib should have compat versions
Aqua.test_deps_compat(
DeviceLayout,
- ignore=[:Dates, :LinearAlgebra, :Logging, :Random, :UUIDs],
+ ignore=[:Dates, :LinearAlgebra, :Logging, :Random, :UUIDs, :SparseArrays],
check_extras=(; ignore=[:Test])
)
# We define ForwardDiff.extract_derivative with Unitful.Quantity; ignore that one
diff --git a/test/test_autorouter_internals.jl b/test/test_autorouter_internals.jl
new file mode 100644
index 000000000..bbf35e9f0
--- /dev/null
+++ b/test/test_autorouter_internals.jl
@@ -0,0 +1,246 @@
+@testitem "Autorouter internals" setup = [CommonTestSetup] begin
+ import DeviceLayout.Paths: ChannelRouter, RouteChannel, autoroute!
+ # ── Helpers (shared with autoroute_examples.jl) ──────────────────────────────
+
+ hchannel(x0, x1, y; width=2.0) =
+ let pa = Path(x0, y)
+ straight!(pa, x1 - x0, Paths.Trace(width))
+ pa
+ end
+ vchannel(x, y0, y1; width=2.0) =
+ let pa = Path(x, y0, α0=90°)
+ straight!(pa, y1 - y0, Paths.Trace(width))
+ pa
+ end
+
+ lpin(x, y) = PointHook(Point(float(x), float(y)), 180°)
+ rpin(x, y) = PointHook(Point(float(x), float(y)), 0°)
+ bpin(x, y) = PointHook(Point(float(x), float(y)), 270°)
+ tpin(x, y) = PointHook(Point(float(x), float(y)), 90°)
+
+ """
+ Build a ChannelRouter and run channel assignment only (no track assignment).
+ """
+ function _route_channels(channels, hooks, nets)
+ ar = ChannelRouter(nets, hooks, RouteChannel.(channels))
+ Paths.assign_channels!(ar)
+ return ar
+ end
+
+ # ── is_avoidable ─────────────────────────────────────────────────────────────
+ # Pure function: is_avoidable(low1, high1, low2, high2, lt1, ht1, lt2, ht2)
+ # Determines if a crossing between two overlapping segments can be resolved
+ # by choosing track order, based on the tendency (±1) at each endpoint.
+
+ @testset "is_avoidable" begin
+ # Segments: [0,6] and [3,9], high1 < high2 → order = [1,2,1,2]
+ # All same tendency (all +1): not avoidable
+ @test Paths.is_avoidable(0, 6, 3, 9, 1, 1, 1, 1) == false
+
+ # Alternating: seg1 enters up, exits down; seg2 enters down, exits up
+ # Tendencies: low1=+1, high1=-1, low2=-1, high2=+1
+ @test Paths.is_avoidable(0, 6, 3, 9, 1, -1, -1, 1) == false
+
+ # Seg1 both up, seg2 both down
+ @test Paths.is_avoidable(0, 6, 3, 9, 1, 1, -1, -1) == true
+
+ # Contained case: high2 <= high1 → order = [1,2,2,1]
+ # All same tendency: avoidable
+ @test Paths.is_avoidable(0, 9, 3, 6, 1, 1, 1, 1) == true
+ # Contained, opposite
+ @test Paths.is_avoidable(0, 9, 3, 6, -1, -1, -1, -1) == true
+
+ # Shared endpoint (low1 == low2): assumed avoidable depending on neighbor
+ @test Paths.is_avoidable(0, 6, 0, 9, 1, -1, -1, -1) == true
+ @test Paths.is_avoidable(0, 6, 0, 9, -1, 1, -1, 1) == true
+ # Crossing is still not avoidable if the entry/exit points make an X
+ @test Paths.is_avoidable(0, 6, 0, 9, 1, -1, -1, 1) == false
+ @test Paths.is_avoidable(0, 6, 0, 9, -1, 1, 1, -1) == false
+
+ # Shared endpoint (high1 == high2): assumed avoidable
+ @test Paths.is_avoidable(0, 9, 3, 9, 1, -1, 1, 1) == true
+ end
+
+ # ── segments_overlap: boundary case with tendency ────────────────────────────
+ # The knock-knee relaxation (strict < instead of <=) should NOT apply when
+ # segments at a shared endpoint face the same direction.
+
+ @testset "segments_overlap boundary" begin
+ # Crossing setup: 2H + 1V, two nets that cross in the vertical channel.
+ # v_mid assigned first so its track offsets propagate.
+ channels = [
+ vchannel(5, -1, 7), # idx 1: v_mid
+ hchannel(-1, 9, 0), # idx 2: h_bot
+ hchannel(-1, 9, 6) # idx 3: h_top
+ ]
+ hooks = [
+ bpin(2, -0.5), # p1: below h_bot, left
+ bpin(8, -0.5), # p2: below h_bot, right
+ tpin(2, 6.5), # p3: above h_top, left
+ tpin(8, 6.5) # p4: above h_top, right
+ ]
+ # Crossing: bottom-left↔top-right, bottom-right↔top-left
+ ar = _route_channels(channels, hooks, [(1, 4), (2, 3)])
+
+ # In h_bot (channel 2): two segments touching at the v_mid intersection (x=5)
+ segs_hbot = ar.channel_segments[2]
+ @test length(segs_hbot) == 2
+ # These segments touch at x=5 with same tendency (both enter v_mid going up)
+ # → should be treated as overlapping (can't knock-knee)
+ @test Paths.segments_overlap(ar, segs_hbot[1], segs_hbot[2]) == true
+
+ # In v_mid (channel 1): two segments fully overlapping (both span h_bot→h_top)
+ segs_vmid = ar.channel_segments[1]
+ @test length(segs_vmid) == 2
+ @test Paths.segments_overlap(ar, segs_vmid[1], segs_vmid[2]) == true
+ end
+
+ @testset "segments_overlap knock-knee" begin
+ # Parallel setup: two nets through a shared channel, NOT crossing.
+ # They should NOT overlap since knock-knee is possible
+ # (touching at boundary with opposite tendencies)
+ channels = [
+ vchannel(5, -1, 10), # idx 1: v_mid
+ hchannel(-1, 9, 0), # idx 2: h_bot
+ hchannel(-1, 9, 6), # idx 3: h_mid
+ hchannel(-1, 9, 9) # idx 3: h_top
+ ]
+ hooks = [
+ bpin(2, -0.5), # p1
+ tpin(8, 6.5), # p2
+ bpin(2, 4), # p3
+ tpin(8, 9.5) # p4
+ ]
+ # Parallel: bottom-left↔top-left, bottom-right↔top-right
+ ar = _route_channels(channels, hooks, [(1, 2), (3, 4)])
+
+ # In v_mid: both nets traverse it but one starts where the other ends
+ # And they come from opposite sides at that point
+ # Knock-knee relaxation says they don't overlap
+ segs_vmid = ar.channel_segments[1]
+ @test length(segs_vmid) == 2
+ @test Paths.segments_overlap(ar, segs_vmid[1], segs_vmid[2]) == false
+ end
+
+ # ── Track assignment results ─────────────────────────────────────────────────
+ # Verify that track counts match expectations after full routing.
+
+ @testset "track assignment: crossing" begin
+ channels = [vchannel(5, -1, 7), hchannel(-1, 9, 0), hchannel(-1, 9, 6)]
+ hooks = [bpin(2, -0.5), bpin(8, -0.5), tpin(2, 6.5), tpin(8, 6.5)]
+ ar = ChannelRouter([(1, 4), (2, 3)], hooks, RouteChannel.(channels))
+ autoroute!(ar, Paths.StraightAnd90(0.1), 0.1)
+
+ # v_mid gets 2 tracks (full overlap)
+ @test length(ar.channel_tracks[1]) == 2
+ # One horizontal channel gets 2 tracks (post-crossing overlap detected),
+ # the other gets 1 (pre-crossing, touching but not overlapping)
+ h_tracks = length(ar.channel_tracks[2]) + length(ar.channel_tracks[3])
+ @test h_tracks == 3
+ end
+
+ @testset "track assignment: parallel" begin
+ channels = [
+ vchannel(0, -1, 9),
+ vchannel(10, -1, 9),
+ hchannel(-1, 11, 0),
+ hchannel(-1, 11, 4),
+ hchannel(-1, 11, 8)
+ ]
+ hooks = [
+ lpin(-0.5, 0),
+ lpin(-0.5, 4),
+ lpin(-0.5, 8),
+ rpin(10.5, 0),
+ rpin(10.5, 4),
+ rpin(10.5, 8)
+ ]
+ ar = ChannelRouter([(1, 4), (2, 5), (3, 6)], hooks, RouteChannel.(channels))
+ autoroute!(ar, Paths.StraightAnd90(0.1), 0.1)
+
+ @test all(length.(ar.net_wires) .> 0)
+ # No channel should need more than 1 track (non-crossing parallel routes)
+ @test all(length.(ar.channel_tracks) .<= 1)
+ end
+
+ # ── routing_summary ──────────────────────────────────────────────────────
+ @testset "routing_summary" begin
+ channels = [vchannel(5, -1, 7), hchannel(-1, 9, 0), hchannel(-1, 9, 6)]
+ hooks = [bpin(2, -0.5), bpin(8, -0.5), tpin(2, 6.5), tpin(8, 6.5)]
+ ar = ChannelRouter([(1, 4), (2, 3)], hooks, RouteChannel.(channels))
+ autoroute!(ar, Paths.StraightAnd90(0.1), 0.1)
+
+ output = sprint(Paths.routing_summary, ar)
+ @test occursin("Net 1:", output)
+ @test occursin("Net 2:", output)
+ @test !occursin("UNASSIGNED", output)
+ end
+
+ # ── validate_routes ──────────────────────────────────────────────────────
+ @testset "validate_routes" begin
+ channels = [vchannel(5, -1, 7), hchannel(-1, 9, 0), hchannel(-1, 9, 6)]
+ hooks = [bpin(2, -0.5), bpin(8, -0.5), tpin(2, 6.5), tpin(8, 6.5)]
+ ar = ChannelRouter([(1, 4), (2, 3)], hooks, RouteChannel.(channels))
+ autoroute!(ar, Paths.StraightAnd90(0.1), 0.1)
+
+ ok = Paths.validate_routes(ar, Paths.Trace(0.05))
+ @test ok isa BitVector
+ @test length(ok) == 2
+ @test all(ok)
+ end
+
+ # ── verbose autoroute! ───────────────────────────────────────────────────
+ @testset "verbose autoroute!" begin
+ channels = [hchannel(0, 10, 0)]
+ hooks = [bpin(2, -0.5), tpin(8, 0.5)]
+ ar = ChannelRouter([(1, 2)], hooks, RouteChannel.(channels))
+
+ # verbose=true should not error and should produce log output
+ routes = autoroute!(ar, Paths.StraightAnd90(0.1), 0.1; verbose=true)
+ @test length(routes) == 1
+ @test all(length.(ar.net_wires) .> 0)
+ end
+
+ # ── reroute_nets! ────────────────────────────────────────────────────────
+ @testset "reroute_nets!" begin
+ channels = [vchannel(5, -1, 7), hchannel(-1, 9, 0), hchannel(-1, 9, 6)]
+ hooks = [bpin(2, -0.5), bpin(8, -0.5), tpin(2, 6.5), tpin(8, 6.5)]
+ ar = ChannelRouter([(1, 4), (2, 3)], hooks, RouteChannel.(channels))
+ autoroute!(ar, Paths.StraightAnd90(0.1), 0.1)
+
+ # Record original track assignments
+ orig_tracks_net1 = [Paths.segment_track(ar, ws) for ws in Paths.net_wire(ar, 1)]
+ @test all(!isnothing, orig_tracks_net1)
+
+ # Reroute net 1 with a fixed channel path
+ affected = Paths.reroute_nets!(ar, [1]; fixed_paths=Dict(1 => [2, 1, 3]))
+ @test 1 in affected
+ @test 2 in affected # net 2 shares all channels with net 1
+
+ # Net 1 should still be routed
+ @test length(Paths.net_wire(ar, 1)) > 0
+ # Track assignments should still be valid
+ new_tracks_net1 = [Paths.segment_track(ar, ws) for ws in Paths.net_wire(ar, 1)]
+ @test all(!isnothing, new_tracks_net1)
+ end
+
+ # ── best_matching! bipartite shape ───────────────────────────────────────
+ @testset "best_matching! bipartite shape" begin
+ import Graphs: SimpleGraph, SimpleDiGraph, add_edge!
+
+ # 4 vertices, L={1,2}, R={3,4}, edges (1,3), (1,4), (2,3).
+ # True max bipartite matching has 2 R→L pairs (e.g. 3→2, 4→1).
+ g = SimpleGraph(4)
+ add_edge!(g, 1, 3)
+ add_edge!(g, 1, 4)
+ add_edge!(g, 2, 3)
+ # Empty VCG preserves all merging edges (no ancestor constraints).
+ vcg = SimpleDiGraph(4)
+
+ m = Paths.best_matching!(g, vcg, Set([1, 2]), Set([3, 4]))
+ @test length(m) == 2
+ @test all(k in (3, 4) for k in keys(m))
+ @test all(v in (1, 2) for v in values(m))
+ @test length(unique(values(m))) == 2 # no duplicate L targets
+ end
+end
diff --git a/test/test_channels.jl b/test/test_channels.jl
index 531a09943..712f9bd24 100644
--- a/test/test_channels.jl
+++ b/test/test_channels.jl
@@ -287,3 +287,48 @@
## Schematic-level routing
test_schematic_single_channel()
end
+
+@testitem "Channel Autorouter" setup = [CommonTestSetup] begin
+ import DeviceLayout.Paths: RouteChannel, ChannelRouter
+ using .SchematicDrivenLayout
+
+ function test_simple()
+ mypins = [
+ Point(4.0, 3.0),
+ Point(6.0, 3.0),
+ Point(4.0, 7.0),
+ Point(6.0, 7.0),
+ Point(3.0, 4.0),
+ Point(3.0, 6.0),
+ Point(7.0, 4.0),
+ Point(7.0, 6.0)
+ ]
+ dirs = [0, pi, 0, pi, pi / 2, -pi / 2, pi / 2, -pi / 2] .+ pi
+ pins = PointHook.(mypins, dirs)
+
+ space_paths = Path[]
+ for x0 in [1.0, 5.0, 9.0]
+ pa = Path(x0, 0.0, α0=90°)
+ straight!(pa, 10.0, Paths.Trace(2.0))
+ push!(space_paths, pa)
+ end
+ for y0 in [1.0, 5.0, 9.0]
+ pa = Path(0.0, y0)
+ straight!(pa, 10.0, Paths.Trace(2.0))
+ push!(space_paths, pa)
+ end
+
+ n_wires = 4
+ mynets = [(i, i + 4) for i = 1:n_wires]
+ ar = ChannelRouter(mynets, pins, RouteChannel.(space_paths))
+
+ Paths.assign_channels!(ar)
+ Paths.assign_tracks!(ar)
+
+ c = Paths.visualize_router_state(ar)
+ return c
+ end
+
+ # Runs without error
+ test_simple()
+end
diff --git a/test/test_examples.jl b/test/test_examples.jl
index 0d04eb313..a84306afd 100644
--- a/test/test_examples.jl
+++ b/test/test_examples.jl
@@ -1,3 +1,14 @@
+@testitem "Channel Autorouter Examples" setup = [CommonTestSetup] begin
+ include("../examples/ChannelAutorouter/ChannelAutorouter.jl")
+ for (name, fn) in ChannelAutorouter.ALL_EXAMPLES
+ @testset "$name" begin
+ c, ar = fn()
+ @test c isa DeviceLayout.Cell
+ @test all(length.(ar.net_wires) .> 0)
+ end
+ end
+end
+
@testitem "ExamplePDK" setup = [CommonTestSetup] begin
include("../examples/DemoQPU17/DemoQPU17.jl")
@time "Total" schematic, artwork = DemoQPU17.qpu17_demo(dir=tdir)