# How Arrow Routing Works in tldraw

Arrows are one of those features that seem simple until you try to build them. A user drags from shape A to shape B, and a nice-looking connector appears. Behind the scenes, the system has to figure out which edges to exit and enter, how to route around overlapping shapes, where to put the midpoint handle, and how to land precisely on a star or an ellipse instead of just a bounding box. Here's how we do it.

## Three Kinds of Arrows

Every arrow in tldraw has a `kind` — either `arc` or `elbow`. Combined with the `bend` property, this gives us three visual styles:

- **Straight** — an arc arrow with near-zero bend (< 8px). A simple line segment from A to B, intersected against the bound shapes' actual geometry.
- **Curved** — an arc arrow with meaningful bend. We compute a circle through three points (start, middle, end) and draw an SVG arc.
- **Elbow** — the interesting one. Axis-aligned segments with right-angle turns, like connectors in a diagram tool.

The entry point is `getArrowInfo`, which dispatches based on kind and bend. The result is cached per shape using a computed cache that skips recomputation when only floating-point noise has changed.

```
getArrowInfo(editor, shape)
  ├─ elbow  →  getElbowArrowInfo()
  ├─ arc + low bend  →  getStraightArrowInfo()
  └─ arc + high bend →  getCurvedArrowInfo()
```

The rest of this post focuses on elbow routing, since that's where the real complexity lives.

## Resolving Terminals

Before routing, we need to know *what* we're connecting. Each end of an arrow is a "terminal" that might be:

- A free point in space (no binding)
- A binding to a shape, with a target point, bounding box, and actual geometry

The function `getElbowArrowTerminalInfo` resolves bindings into concrete geometry in arrow-local space. It also computes arrowhead offsets — the distance we need to pull back from the shape edge so the arrowhead doesn't overlap the shape's stroke.

There's a special case for unclosed paths (like a freehand line). Since they don't have a clear "inside," we compute the surface normal at the binding point and use it to determine which side the arrow should approach from.

## Computing Usable Edges

Each terminal has four potential edges (top, right, bottom, left) that an arrow could exit or enter from. But not all edges are usable — if shape B is sitting right on top of shape A's right edge, we can't route an arrow out that way.

The `getUsableEdge` function models each edge as a value (its position) and a cross-axis range (the span along the perpendicular axis where the arrow can actually connect). When shapes overlap, we use range subtraction to carve out the blocked portion:

```
Original edge range:  |============|
Overlapping shape:         |====|
After subtraction:    |====|    |==|
                      ↑ pick the larger remaining segment
```

If no usable range remains, the edge is marked `null`. If *no* edges are usable, the terminal gets downgraded to a "point" — a zero-size bounding box. Point terminals have looser routing rules (the arrow might pass through the shape), but they prevent the system from getting stuck.

## The Gap and the Midpoint

Two numbers drive most routing decisions: `gapX` and `gapY` — the distances between the bounding boxes of the two terminals along each axis. Positive gap means clear space between them; negative means overlap.

When there's enough gap, we compute a midpoint line — a position between the two shapes where the arrow's middle segment can run. The user can drag this midpoint via the `elbowMidPoint` property (0 to 1), and we lerp within the valid range. The `ElbowArrowRouteBuilder` records which segment is the midpoint handle and on which axis, so the UI knows what to show.

## Edge Picking: Three Strategies

Once we know the usable edges, we need to decide which side of A to exit from and which side of B to enter. There are three modes:

**Fully automatic** — neither terminal has a user-specified side. We use heuristics: if the horizontal gap is larger, try right→left (or left→right). If shapes are stacked vertically, try bottom→top. For diagonal arrangements, we check whether an L-shaped route is possible (e.g., right→top when A is above-left of B).

**Partially manual** — the user has pinned one side (by dragging the arrow to a specific edge). We respect that choice and auto-pick the other side.

**Fully manual** — both sides are pinned. We try the exact combination first. If it fails (e.g., the geometry makes it impossible), we fall back to auto with a `'fallback'` tag.

If heuristics don't produce a route, `pickBest` tries all valid combinations and picks the one with the fewest corners, breaking ties by shortest Manhattan distance.

## Four Functions, Sixteen Combinations

Here's the key architectural insight: there are 4 sides × 4 sides = 16 possible (exit, entry) combinations, but we only write 4 routing functions:

- `routeRightToLeft` — the classic S-bend or U-bend
- `routeRightToTop` — L-bends and corner routes
- `routeRightToBottom` — delegates to `routeRightToTop` with a Y-flip
- `routeRightToRight` — U-bends exiting and entering the same side

Every other combination is handled by applying a geometric transform (flip X, flip Y, rotate 90°/180°/270°) to the working info, running one of these four functions, then inverting the transform on the output points. A static 4×4 lookup table maps each (aSide, bSide) pair to the right transform + function:

```typescript
const routes = {
  top:    { top: [Rotate270, routeRightToRight],  left: [Rotate270, routeRightToTop], ... },
  right:  { top: [Identity,  routeRightToTop],     right: [Identity, routeRightToRight], ... },
  bottom: { top: [Rotate90,  routeRightToLeft],    left: [Rotate90, routeRightToBottom], ... },
  left:   { top: [Rotate180, routeRightToBottom],  left: [Rotate180, routeRightToRight], ... },
}
```

This keeps the routing code roughly 4× smaller than writing each combination by hand, and it means bug fixes in one canonical function automatically apply to all rotated variants.

The `ElbowArrowWorkingInfo` class manages the mutable state. `apply(transform)` flips/rotates all the boxes, edges, gaps, and midpoints in place. `reset()` inverts back. The `vec(x, y)` method on the working info applies the inverse transform, so route functions can think in canonical (right-to-X) coordinates while the builder emits points in the original coordinate space.

## Route Variants

Each canonical function handles multiple geometric arrangements. For example, `routeRightToLeft` has 5 variants:

```
1: S-bend          2: U-bend         3: Around corner    4: Around opposite   5: Complex overlap
┌───┐              ┌───┐             ┌───┐               ┌───────┐            ┌───────┐ ┌───┐
│ A ├─┐            │ A ├─┐           │ A ├───┐           │ ┌───┐ │            │ ┌───┐ │ │ A ├─┐
└───┘ │ ┌───┐      └───┘ │           └───┘   │           │ │ A ├─┘            └─► B │ │ └───┘ │
      └─► B │       ┌────┘             ┌───┐ │           │ └───┘               └───┘ └───────┘
        └───┘       │ ┌───┐          ┌►│ B │ │           │   ┌───┐
                    └─► B │          │ └───┘ │           └───► B │
                      └───┘          └───────┘               └───┘
```

The function picks the right variant based on gap signs, midpoint availability, and Manhattan distance comparisons. When multiple variants are geometrically valid, it picks the shortest one.

## Landing on Real Geometry

Routes are computed against bounding boxes for speed, but shapes aren't rectangles. `castPathSegmentIntoGeometry` takes the first and last segments of the route and ray-casts them into the actual shape outline. It finds the nearest intersection point, applies the arrowhead offset (pulling back so the arrowhead tip lands right at the edge), and updates the route point in place.

There's a subtle detail here: if the arrowhead offset would push the endpoint *inside* the shape (which can happen with very short final segments), the code detects this and either reduces the offset or adds an extra midpoint to keep the path clean.

After geometry casting, `fixTinyEndNubs` cleans up degenerate cases where the first or last segment is shorter than the minimum end-segment length. It removes the tiny stub and adjusts the adjacent point to maintain axis alignment.

## Snap Lines

When you drag an elbow arrow, its segments can snap to segments of other elbow arrows on the canvas. `elbowArrowSnapLines.tsx` precomputes a `Map<angle, Set<snapLine>>` from all unselected arrows. Each horizontal or vertical segment becomes a snap line characterized by its angle (0 for horizontal, π/2 for vertical) and perpendicular distance from the origin. This makes snap queries efficient — just look up the angle and scan the set.

## Caching and Performance

Arrow routing runs on every frame during a drag, so performance matters:

- **Structural sharing** — the computed cache checks `a.props === b.props` (reference equality on the props object). The store uses structural sharing, so unchanged shapes keep the same props reference.
- **Floating-point tolerance** — `isEqualAllowingForFloatingPointErrors` prevents cache invalidation from sub-pixel jitter.
- **Mutation + reset** — `ElbowArrowWorkingInfo` mutates in place during route search and resets after, avoiding allocations per candidate route.
- **Static dispatch** — the 4×4 transform lookup table is O(1), no iteration needed.

## Wrapping Up

The arrow routing system handles a surprising amount of complexity — overlapping shapes, user-pinned edges, arrowhead offsets, non-rectangular geometry, draggable midpoints, snap alignment — while keeping the core routing logic compact through transform-based symmetry reduction. The four canonical route functions, combined with flip/rotate transforms, cover all 16 side combinations without duplicating logic.

If you're working on the arrow code, the best starting points are `getElbowArrowInfo.tsx` (the orchestrator) and `elbowArrowRoutes.tsx` (the actual path-finding). The ASCII art in the source comments is genuinely the best documentation for understanding which variant handles which arrangement.
