# The regional router

Branch `profile-the-solve-rider`. This note is the design; the measurements in
it are real and the phases record what has landed.

**There is one mechanism: a router bounded by a rectangle.** No global router is
ever built — not for a commit, not for a drag, not for drawing a wire. A router
takes a rectangular boundary as an input, and an op says *"this part of the
diagram changed, please heal it"* by naming the region it disturbed.

That replaces every earlier plan in this note: the background/foreground cache,
its invalidation rule, the incremental `update()`, and `ClosedRouter::extended`.
The history of why is kept at the end, because the reasons are worth not
rediscovering.

## Why a bounded router is enough

A local edit has local effects. Measured on the settled 50×50
(`routing::region_cost`, release):

| | build | nodes |
|---|---|---|
| whole sheet | **44.6 ms** | 110,173 |
| a 3×3 block neighbourhood | **2.1 ms** | 1,355 |

And the gestures that looked like they needed a global lattice do not:

- **A block move or nudge.** The disturbed region is the block's footprint, old
  ∪ new, grown by its moat.
- **A drag.** The same region, per frame. A frame routes five to seven legs and
  they are all beside the block.
- **Drawing a wire.** A region around the cursor, rebuilt when the cursor comes
  near its boundary. The part already drawn is fixed geometry; only the live leg
  needs a lattice, and it is always next to the cursor.

So the case for a cached global router was never about needing one — it was
about not wanting to pay for one repeatedly. A bounded router costs ~2 ms, which
is cheaper than reasoning about when a cached one is stale.

## What a bounded router is

The boundary is an input to the router, not a filter the caller applies:

- **Blocks that intersect the boundary are kept whole.** One straddling the edge
  obstructs inside it too, and dropping it would let a wire route through the
  half that is out of view. Blocks entirely outside are ignored — they cannot
  clip anything inside.
- **Channels are clipped to the boundary**, as they already are to blocks.
- **Wires crossing the boundary are pinned** where they cross. That crossing is
  a fixed terminal, preserved by construction; the portion outside is kept
  verbatim, and a wire passing clean through has two such terminals.
- **Occupancy** comes from the wires inside, including those through-spans.

Locality falls out: a long wire is re-solved only where it passes through the
region, so nudging a block cannot reshape its far end.

## The rider takes a region

`solve_rider` currently derives a `Foreground` — the set of wires a gesture may
write — from the ops it is riding. Under this design the primary input is the
**region those ops disturbed**, and the wires to heal are the ones inside it.
An op says what it changed; the rider heals that rectangle.

The region for a set of ops is the union of the rects they touched, old and new,
grown by `MOAT_REACH` — a block shapes the channels that far out, so that is how
far its movement can change geometry.

`Foreground` survives as the answer to *which wires does this write*, which is
what the superset rule is stated over and what the diagnostic overlay draws. It
is derived from the region rather than assembled beside it.

## When the region is too small

A wire that cannot be routed inside its region falls back to what happens today:
`path_find_with_fallback` draws an L. That is tolerable while this is being
built and is **not** tolerable to ship, because a region being too small is
routine and recoverable where a genuinely unroutable wire is neither — and
nothing records that the fallback fired. The unresolved list (below) is the fix,
and growing the region and retrying is the cheaper one to try first.

## Where the time goes now

Measured on the settled 50×50, after the lattice was made lazy on commit
(`a_nudge_on_the_settled_grid_is_timed`, `a_drag_on_the_settled_grid_is_timed`):

| | now | what is left in it |
|---|---|---|
| **a commit (nudge)** | **68 ms** | `present_document` 27, `write_rev` 8, draw 5 — **no lattice at all** |
| **a drag frame** | **131 ms** | the whole-sheet lattice ~42, the preview loop over all 7,550, ~17 ms of endpoint gathering, draw 5 |
| a selection change | 3.5 ms | deriving what it raises, 9 wires of 7,550 |
| the drop | 289 ms | |

A commit routes **zero** legs and now builds nothing. A drag frame routes five
to seven and builds a 110,173-node lattice to serve them: that is the regional
router's case, entire.

**The arrangement, not the graph, is the expensive part** of a build:

| phase | time |
|---|---|
| `normalize` | 1.8 ms |
| `intersections` | 5.3 ms |
| `resegment` | 7.6 ms |
| `endpoints` | 9.5 ms |
| `rebuild_graph` | 6.0 ms |

The arrangement is also **dense** — with the ports placed it reports `h=605,
v=266` lines and 110,173 nodes, and a line is crossed by most of the transverse
ones. So a line is expensive (~266 crossings) but there are only ~871 of them,
which is what makes per-line re-derivation worth anything.

## What this is not

Not an incrementally maintained lattice. That was considered and rejected: it
needs provenance on every derived segment, refcounted crossing nodes, cost
subtracted rather than added, and `StableGraph` (petgraph's `remove_node`
swap-removes, silently invalidating `node_to_index` for whichever node was
last). All of that to save the ~24 ms arrangement, and every piece of it is
bookkeeping that fails by leaking a dense lattice — the exact failure it
exists to prevent.

## The design: foreground and background

Mutation reaches the document through a **selection**, or by adding something
new. So split the router in two:

- **background** — everything *outside* the foreground, built once and cached with
  the document index.
- **foreground** — what the current gesture may write, replayed on top of a clone
  of the background router on every mutation.

The rebuild is paid when the **selection changes**, which is user-paced, not
when the selected objects are **mutated**, which is frame-paced.

Removal never has to be inverted: the background router is built from the
complement of the foreground. That is what deletes the whole provenance
design above.

`ClosedRouter` is already `Clone`, and cloning the settled 50×50 router measures
**1.8–2.3 ms** (taken at 101,761 nodes and 186,684 edges, before the ports were
placed; the lattice is ~110k nodes now). That is the per-mutation floor, against
32.5 ms to rebuild.

### The foreground

Derived from the selection, not declared:

- the selected shapes;
- every route terminating on a pin or port of a selected shape;
- every route the selection's footprint — **old ∪ new** — reaches, which means
  within its **moat**, not merely touching it.

The moat rather than the route gutter because a block does not only obstruct
wires: it *shapes the channels* around itself, trimming the ones whose rows it
spans and seeding five lanes of its own out to `MOAT_REACH` (6 cells — the
lanes sit at `distance + 2`). A wire running in one of those channels is drawn
where it is *because* of this block, so moving the block can change it although
the wire never touches it. Raising by gutter alone would leave such a wire in
the background, where a gesture would then write to it.

The third clause is not about the solve's correctness. It is about the cache:

> **The foreground must be a superset of everything the gesture can write.**
> If it is, the background survives the whole gesture. If it isn't, the gesture
> invalidates its own background partway through.

A block moved onto a bystander wire makes the rider promote that wire's
corners. If the wire is in the background, that promotion is a write outside the
foreground and the background dies mid-drag.

### Blocks clip; routes only add

A route's contribution to the lattice is **additive** — segments through its
waypoints, seed points at its ends, cost along its edges — so raising one into
the foreground and replaying it on top is exactly what it sounds like.

A block's contribution is **subtractive**: `seed_horiz_channel` shortens each
channel against the blocks spanning its row. Build the background without a
selected block and its lattice has channels running through where the block
sits; adding it back
means *truncating segments that already exist*, not adding any.

This is the one piece of "modify what is there" the design still needs, and it
is bounded: the background lattice without the block is a strict superset of the
legal one, so replaying the selection re-clips only the rows and columns the
block spans, at both positions. ~20 lines of 611, which by the density above is
~2 ms.

### Invalidation is pushed, not pulled

The background is valid iff the foreground is unchanged **and** nothing outside it
has moved. The second half cannot be a comparison: a stamp over
everything-but-the-selection is O(document) to compute, which is the cost being
avoided. So the commit path drops the background when a commit carries an op on an
entity outside the foreground — O(ops), and fail-safe, since a dropped background
only means a rebuild.

**This gives up a property the code currently advertises.** `Presentation`'s doc
comment: *"gated on the document's own stamp, so a commit that landed anywhere
— local, foreign, or an undo — re-derives it on the next borrow and nothing has
to remember to invalidate it."* With a background router, something has to remember.
Stated here so it is a decision rather than a discovery.

### The rule did not hold until the solve was scoped (P2's finding, closed by P3a)

Measured through the real gesture path: moving one block one cell on the 3×3
seals ops on **20 of its 30 wires**. Not a few disturbed neighbours — two
thirds of the sheet.

The cause is that the rider re-solves the *whole scope* and promotes every wire
whose corners came out different (`commit_route_edit` filters on
`edit.waypoints != route.waypoints`). Re-routing everything means everything may
move, so the set of wires a gesture writes is not bounded by the selection at
all — and no derivation of the foreground, however careful, can be a superset of
it.

So the foreground is not merely a *description* of what the gesture will touch.
**It has to drive the solve.** Once the rider solves only the foreground, it can
only write the foreground, and the rule holds by construction rather than by
analysis. That is P3's real content.

Worth noting the churn is a defect in its own right, not only a cache problem:
20 wires' stored corners rewritten because one block moved one cell is undo
granularity, diff noise, and wires visibly jumping on screen. The same move on
the 50×50 promotes nothing (`fold{ops=1}`), so it is dense small sheets that
suffer.

That is what P3a did, and the characterization test —
`todays_unscoped_rider_writes_far_outside_the_foreground`, asserting the
escapees were *not* empty — duly failed and was inverted into
`a_move_writes_nothing_outside_the_foreground`.

## The region router supersedes the cache (user, 2026-09-19)

**There is no reason the router has to contain the whole diagram.** A local
edit has local effects, so a lattice built to move one block can cover the
region that edit touches. Measured on the settled 50×50
(`routing::region_cost`, release):

| | build | nodes |
|---|---|---|
| whole sheet | **44.6 ms** | 110,173 |
| a 3×3 block neighbourhood | **2.1 ms** | 1,355 |

21× on the build, 81× on the lattice — and with a naive filter that still walks
every shape to test intersection.

That is better than caching a background, and *simpler*: a region router has no
cache, so it has no invalidation rule — the part both of us named as the whole
risk of P3b. Every frame builds a small lattice from scratch and nothing can go
stale.

### The boundary conditions

A rectangular cut of the routing plane needs two things, and they are the
design:

- **Boundary blocks.** Every block *intersecting* the region is kept, exactly
  as an ordinary block. One straddling the edge obstructs inside it too, and
  dropping it would let a wire route through the half that is out of view.
- **Entry and exit points.** Where a wire crosses the region's periphery is a
  fixed terminal, preserved by construction. The portion outside is kept
  verbatim; only the span inside is re-solved. A wire passing clean through has
  two such terminals.

Occupancy comes from the wires inside the region, including those through-spans.

This also buys locality for free: a long wire running off across the sheet is
re-solved only in its region-portion, so nudging a block cannot reshape its
distant end.

### What it retires

**P3b's cache and the incremental `update()` both go**, and
`ClosedRouter::extended` with them — insertion into a closed lattice exists to
make a cached background cheap to replay, and there is no cached background.
What survives is `Foreground` (it decides what is re-solved, and so what the
region must contain) and `fingerprint` with its property test, which becomes the
sharper question: *does a route solved in the region match the whole-sheet solve
where nothing forces a detour beyond it?*

### The prerequisite: an unresolved list

When no path is found, `path_find_with_fallback` draws an L — `start →
(end.x, start.y) → end` — which may run straight through blocks. **Nothing
records that it fired.** There is no unresolved list and no retry; the
fallback is silent.

On a whole-sheet router that is tolerable, because the fallback only fires when
a route is genuinely impossible. On a region router it fires whenever the region
was too small, which is a routine and recoverable condition — so the list has to
exist first: a wire that could not be solved is drawn straight, recorded, and
retried on each update until it resolves or the user fixes it by hand. That is
also a plain improvement on today, where an unroutable wire silently becomes an
L through a block.

### Why the replay re-derives, and what that costs (P3b, retired)

A channel is clipped by the blocks present **when it is seeded**:
`seed_horiz_channel` walks the blocks spanning its row and cuts the channel down
to the gap its seed point falls in. Two things follow, and they are the reason
`extended` re-seeds from the requests rather than patching the segments.

**A block cannot be applied to segments that already exist.** Splitting them
around it would leave a fragment on its far side — a channel a whole build would
never have produced, because the seeding would have stopped at the block. The
lattice would be a superset of the real one and would route through channels
that should not be there.

**And a segment does not remember where its channel was seeded**, so nothing in
the derived lattice can decide which fragment to keep. `RouterNG` therefore
keeps its `Requests` — the channels and seed points it was asked for — and
`reseed` runs them again against the new block set. Requests are the input;
segments are output, and output is not patched.

The honest cost of that choice: `extended` re-runs `update()`, so it is as
expensive as a fresh build (~32 ms on the 50×50) and a cached background would
save only the seeding (~10 ms of `closed_build`'s 42 ms). **The cache does not
pay until `update()` is incremental.**

What makes that tractable is the same insertion-only property. Adding channels
dirties the rows and columns they cross; the arrangement is dense but there are
only ~871 lines, so a handful of new channels touches a bounded slice of it. And
because nothing is ever removed, the incremental path needs no refcounting —
only "these lines changed, re-derive them", with the property test above as the
guard that re-deriving a slice equals re-deriving the whole.

### It forces the second pass to go

The rider has a selection; `present_document` does not. Under
selection-dependent ordering the two passes would disagree **by construction**,
not merely duplicate work. So the second pass must adopt the rider's solved
geometry rather than re-derive it.

That is a better argument for removing the re-derivation than "it is wasteful",
and it settles which pass is authoritative: the one that knows what the user
selected.

### The ordering is now meaningful

The selected object routes **last**, against everything else's occupancy. That
is explainable — selecting something raises it — and it matches what users
already expect, which is not true today.

## Cost model

| | when | cost |
|---|---|---|
| rebuild the background | the selection changes | ~32 ms, user-paced, defer-able to idle |
| mutate | per commit or frame | ~2 ms clone + ~2 ms re-clip + O(foreground) solve |

The per-wire loops that remain O(sheet) — `closed_route_loop` 32 ms,
`straighten` 13.7, `crossings` 7.9, `anchors` 6.1 — are *not* addressed by the
background rebuild. They are addressed by the foreground being the only
thing re-solved,
which is the same idea applied one layer up. Both are needed:

| | attacks | worth |
|---|---|---|
| ~~foreground-only solving~~ (P3a, done) | `closed_route_loop` 30.3 → 4.2 | **26 ms, taken** |
| background rebuild (P3b) | `router_rebuild` 31.7 ms, once per mutation | ~30 ms |
| the reconstruction's own per-wire passes (P4) | `straighten` 14.6 + `anchors` 5.8 + `crossings` 3.9 | ~24 ms |

Under both sits a floor of ~13 ms: `write_rev` 8.3 (a whole-document snapshot
per commit) and `tool_widget` 4.8 (the draw). So ~140 ms → plausibly ~25–30 ms,
and no further without changing the format or the draw.

## The standing deferrals (resolved — kept for the record)

Measured per pass on the settled 50×50: the initial settle deferred all 5,100
wires (`skew` — no corners stored yet), then **every later pass deferred exactly
the same 100 `in` wires**, as `blocked horizontal` before the nudge and `hugs
horizontal` after it. One failing leg with the rect that refused it:
`(1,1)→(-1,1)`, culprit `(0,0)..(8,8)` — `block_0_0`. The leg *started inside a
block*, and so did the other 99: all 100 resolved their start anchor to the same
`(1,1)`, from 50 distinct ports.

**The cause was the fixture.** A scope draws its own boundary ports as
free-standing shapes placed by `Pin::rect` — *"the port body's placement inside
the block's own interior view — a different scope from `slot`"* — and
`fixtures/scale.rs` set only `slot`, leaving all 100 port bodies at
`GridRect::default()`, stacked at the origin under `block_0_0`. Fixed by placing
them in the sheet's margins, level with the row each serves.

What survives is a latent difference in the code, now unexercised.
`reconstruct_route` drops inaccessible corners **only when routing**:

```rust
if router.is_some() && !obstacles.accessible(pos) {
    continue;
}
```

Straightening (`router: None`) would keep a corner sitting inside a block, so the
leg to it crosses that block and defers forever, while the router never sees a
problem because it deletes the corner before it looks. No wire in the tree has
such a corner now, so this is a trap rather than a live bug — worth closing when
something else touches that walk.

Also unconfirmed, and a disagreement of the same kind: `seed_horiz_channel`
expands each blocking rect by one before clipping (`expand_x(1).expand_y(1)`)
while `seed_vert_channel` does not — so the router may open a channel where
`hugs_wire` refuses a leg.

## Phases

- [x] **P0** This note.
- [x] **P1** The standing deferrals — a fixture bug, fixed.
- [x] **P2** `Foreground`: what a gesture may write, derived from the selection
      or from the ops it staged.
- [x] **P3a** The rider solves the foreground rather than the scope.
- [x] **P3c** The lattice is built only when a leg needs one. A commit on
      settled geometry needs none: a nudge routes **zero** legs.
- [x] **P6** **A router takes a rectangular boundary** (2026-09-20). `Bounds` is
      an input to the lattice, not a filter the caller applies: channels clip to
      it as they already do to blocks, a block beyond it is ignored since it can
      clip nothing inside, and a seed point outside it seeds nothing.
      `Bounds::UNBOUNDED` is the whole plane, so bounded and whole-sheet are one
      code path with different numbers in it. Two tests in `router::bounded`:
      nothing in a bounded lattice escapes its bounds, and a bounded lattice
      holds everything an unbounded one has inside those bounds.
      - **Not wired in until P10** (found 2026-09-21). The editor never passed
        `Bounds` to the builder: `build_router_within` filtered blocks and seed
        points by the region itself and left the channels unbounded. A region
        lattice therefore held lanes running out past its edge into space where
        it had dropped every block, and a wire could route along one straight
        through a block and come back *routed* — invisible even to P10's
        record, since nothing fell back. `Region` now only names a `Bounds`, and
        the router owns what bounds mean;
        `a_region_lattice_holds_nothing_outside_its_region` fails without it.
        The P7/P8 timings were taken with the leak and are not disturbed by the
        fix on legal geometry (nudge 37 ms, drag frame ~29 ms).
- [x] **P7** **The rider takes a region** (2026-09-20/21). Both heal paths — the
      interactive one and the load one — name the rectangle the wires that could
      not be straightened span, grown by twice the moat, through one
      `healing_region`. A first settle defers everything, so its region is the
      sheet and costs what it always did.
- [x] **P4a** **The reconstruction takes the changed region** (2026-09-21).
      `seal` returns a `Sealed` carrying the commit and the rectangle the
      foreground occupied — taken before *and* after the solve, since the wires
      it rewrites move — and the session reconstructs that rectangle and claims
      the stamp, so the gate has nothing left to do. A gesture that cannot say
      what it reached returns no rectangle and the whole document is re-derived,
      as for an open, an undo or a foreign commit.
      - The rider resolves anchors only for wires it may re-solve; a wire it
        will not touch contributes geometry as occupancy, which the presentation
        already holds.
      - Seed points are gathered per lattice and within its bounds, so a commit
        that builds no lattice gathers nothing for one.
      - **The trap**: `reconstruct_within` must claim the stamp *before* it
        runs, because the pass builds a `Drawing` whose constructor calls back
        into the gate. Claiming it afterwards ran the scoped pass after a full
        one — strictly worse, and it measured as no improvement at all, which is
        how it was found.
- [x] **P4b** **Crossings leave the presentation** (2026-09-21). They are
      display-only — their sole consumer is `splice_hops` in `render/path.rs`,
      splicing hop arcs into a drawn polyline; nothing hit-tests or solves
      against them — and were recomputed over all 7,550 wires twice a commit.
      `RouteGeometry::crossings` and `recompute_route_crossings` are gone;
      `Drawing::hops` computes them over the wires a pass draws, in draw order,
      and `render_route` takes them as an argument. The scene's route pass hands
      it what it culled to, a hover redraw what the index finds near the wire
      (`hops_of`), the pin-drag pass what it draws.
      - Why a culled set is enough: a crossing inside a rectangle lies on every
        wire through it, and each of those wires' bounds holds the point, so the
        index reports them all. `hops_over_a_viewport_agree_with_the_whole_scope_inside_it`
        proves it on the 12×12.
      - It closes a bug P4a opened: a scoped reconstruction recomputed hops
        among its subset only, so a wire lost its hops against wires outside it
        and the others kept hops against stale geometry. Nothing stored, nothing
        stale.
      - The cost moves from the commit to the draw: ~20–40 µs for the tens of
        wires a zoomed-in view shows, the whole sheet's ~4 ms at fit.
      - The pin-drag pass now hops only against wires it draws — a wire hidden
        because the pin has no valid slot no longer leaves hops on the wires it
        crosses.
      - nudge 45 → **37 ms**, nine further 61 → **49 ms**, drag frame 35 →
        **29 ms**, the drop 115 → **100 ms**.
- [x] **P8** **The preview arm** (2026-09-21): a drag frame was ~133 ms and
      none of the work above touched it. Three parts, all closed:
      - [x] it built a **whole-sheet lattice every frame**, ~42 ms. It now
        builds one over the region the wires it reroutes span — the same
        `healing_region` and the same `healing_lattice` the commit's deferred
        pass uses, everything not rerouted laid in as occupancy first — ~5 ms;
      - [x] it walked all 7,550 wires rather than the foreground. A preview now
        derives a `Foreground` from the shapes it moves and the footprints they
        are heading for, and solves `Solving::Only` that, as the commit does;
      - [x] and it ended with `Presentation::routes_previewed()`, which blanked
        the stamp for the *whole* presentation — so previewing nine wires
        re-reconstructed 7,550 on the next borrow, ~30 ms a frame. It now names
        the wires it previewed, and the next borrow takes back exactly those
        (`Reconstructing::These`), ~3 ms.
      - A drag frame 133 → 110 ms from the take-back, then → **~35 ms** from the
        other two; the drop 200 → **115 ms**. Of a frame's preview solve
        (~22 ms): lattice 5 ms, routing seven wires 8 ms, crossings 4 ms (since gone, P4b).
        Its region is tall — one wire runs to the sheet's edge and drags the
        box to 990 × 12,300 px — which is P11's per-wire region.
      - The preview's ordering changed with it: everything not rerouted is now
        occupancy before anything routes, where the old loop interleaved them
        in draw order. That is the commit's order already, so preview and drop
        agree by construction rather than by coincidence.
- [ ] **P9** **Drawing a wire** takes a region around the cursor, rebuilt when
      the cursor nears its boundary.
- [x] **P10** **Illegal wires settle on their L** (2026-09-21; reworked the
      same day). The first cut kept an unresolved record in the presentation,
      never wrote the L, drew the wire amber and retried it on every commit. The
      user's model is simpler and cheaper: a wire no lattice routes settles on
      its fallback L like any other result, and "illegal" is a fact of the
      document — a straight leg running through a block — marked where the
      overlaps already are.
      - **The router says when it fell back.** `route_leg` returns a `Leg` with a
        `Resolution` (`Routed` / `Fallback`); the waypoint router and the preview
        reroutes carry it up. It now only decides whether to grow the region.
      - **Grow and retry, once.** `heal` is the one step the commit, the preview
        and the reconstruction take: route in a lattice over the healing region,
        then route what fell back again over the whole scope. A wire settles on
        its L only when the sheet itself holds no path — never worse than before
        regions. The commit keeps the last attempt's corners.
      - **A blocked leg means different things to different passes**
        (`LegRules`): the rider reroutes it (`reroute_blocked` — something moved
        onto the wire, and it is the one pass that writes); a reconstruction
        keeps it as the document has it (`keep_blocked`), with no lattice, and
        keeps a corner sitting inside a block too. So an illegal wire costs
        nothing until a gesture raises it — moving the block it crosses, or
        either end — and then pays the bounded attempt and one whole-sheet one.
        A path opening far away does not resolve it; that is the trade for not
        retrying on every commit.
      - **Marked where it crosses, not by color**: an accent can be amber too.
        `Block::edge_crossing` is the stretch of a straight wire inside a block —
        `intersects_edge` is now "that stretch exists", so the router's refusal
        and the mark cannot disagree — and `Drawing::wire_conflicts` grows each
        by half a cell for the standing-conflict hatch to fill, in the pass that
        already hatches overlaps. Exports draw it too, as they draw overlaps.
      - **Not wired in until now: the P6 bounds.** The editor never passed
        `Bounds` to the builder (see P6); fixed here, since a leaking lattice
        "routed" through blocks and would have hidden every illegal wire.
      - The scoped reconstruction takes back the last preview frame in its own
        pass (`Reconstructing::Within { disturbed, previewed }`).
      - **Timings are single samples** from the ignored kernel test, on a
        machine whose load moved them by up to 40%: legal drag frames ~29–31 ms,
        nudge ~37 ms. The drag benchmark walks block(0,0) into block(0,1), so
        its frames 6–8 (~78 ms) and drop (~230 ms) re-solve three wires that
        have no path anywhere, each paying a whole-sheet lattice (~45 ms). The
        standing-conflict pass is ~1 ms a frame.
- [ ] **P11** Tighten the disturbed rectangle. One union over the whole
      foreground reaches **600 of 7,550 wires**, because a wire anchored to the
      moved block can run clear across the sheet and drags the bounding box with
      it. Per-wire regions rather than one union would cut that, and it is the
      same move a doc-crossing heal wants.
- [ ] **P5** Re-measure, including the drag frame and the selection change.

### Where it stands

Criterion, `settled_50x50` baseline `p10` (2026-09-21; TUNING Finding 10). The
"at the start" column is the single samples the ignored timing tests printed,
kept for the order of magnitude.

| | at the start | now |
|---|---|---|
| a nudge, and the frame after | 468 ms | **49.7 ms** |
| a drag frame | 150 ms | **21.4 ms** |
| the drop, and the frame after | 307 ms | **92.6 ms** |
| a selection change | — | 9.9 ms |
| opening | — | 37.7 ms |

What is left under a commit is mostly not routing: untraced time in the tool
and dispatch (~12–28 ms), the spatial index rebuilt from scratch (~6 ms), and
the whole-document rev snapshot (~8 ms).

### Retired, and why

- **P3b, the background cache**, with its invalidation rule, the incremental
  `update()` it needed, and `ClosedRouter::extended`. A cache keyed on the
  document is invalidated by almost every commit — its inputs are block rects
  and route waypoints — so it would be rebuilt constantly or reasoned about
  constantly. A bounded router costs ~2 ms and has no key. The property test and
  `fingerprint` survive; `extended` and `Requests` stay only until P6 lands,
  then go.
- **The decision that the rebuild is eager**, which was about the cache.
- **Adopting the rider's geometry in the second pass** as a *performance* item:
  `present_document` no longer builds a lattice, so what is left there is
  per-wire work (P4), not a duplicated build. It may still be wanted for
  correctness once ordering is selection-dependent.

## Risks

- ~~**A region that is too small routes an L through a block, silently.**~~
  Closed by P10: a region that fails grows to the scope, and what fails there
  settles on its L, hatched where it crosses a block. Closing it found the worse version of the
  same risk — a region lattice that routed *past* its edge, through blocks it
  had dropped, and called it routed (see P6).
- **Geometry is selection- and region-dependent.** Accepted: the selected object
  routes last, and a bounded solve cannot detour beyond its bound. Goldens must
  fix the selection, and `docs/json-format.md` should say wire geometry is not a
  function of the document alone.
- **Regions are rebuilt per frame rather than cached.** If per-frame rebuilds of
  a *stable* region turn out to matter, the cache to add is one keyed on the
  region — a far smaller and more honest thing than one keyed on the document.

## Next: rendering at far zoom — a retained GPU scene (scoping, 2026-09-21)

The kernel work above is merged (PR #60). What is left at far zoom is not the
kernel. On the 50×50 at 10% in Chrome, with DevTools closed: kernel ~24 ms,
canvas replay ~230 ms, ~2 fps while the pointer moves. This section scopes the
change that would address it; nothing here is built yet.

### What the frame costs now, and why

- **Every frame re-records the whole scene in screen space.** `Recording` maps
  each command through the `Vantage` as it is recorded — positions, and stroke
  widths via `remap_len` — so a pan or a zoom changes every command, and even a
  pointer move with nothing under it re-records all of them.
- **At 10% the display list is ~19,500 commands**: 8,840 text runs (~28,000
  glyphs, 0.9–1.5 px tall — none of it legible), 3,536 polygons, 3,536
  segments, 2,668 polylines, 884 rects.
- **Every command crosses into the browser, and every glyph is a vector
  fill**: five Canvas2D calls a glyph (`save`, `translate`, `transform`,
  `fill`, `restore`), ~140,000 wasm → JS → Blink calls for the text alone, each
  fill its own raster job.
- **egui held ~30 fps at the same zoom** because it tessellated in wasm and then
  drew a handful of GPU batches, with text as atlas quads.

### Why not level of detail and caches

Dropping sub-legible text removes about half the commands; call it a factor of
two at best. The cost that remains is the one Canvas2D imposes — a boundary
crossing and a raster job per primitive — and working around it means
level-of-detail thresholds, per-object image caches and their invalidation, all
complexity in the app to absorb a property of the API underneath it. Fix the
API instead (CLAUDE.md, *Fix the API, don't work around it*).

### The target

- **Tessellate per object, in world space, and keep it.** Each block, pin, wire
  (with its hops), label, text box and image becomes a triangle mesh in world
  coordinates, cached by entity and rebuilt only when that entity — or
  something its drawing depends on — changes.
- **Compose on the GPU.** The meshes live in GPU buffers in draw order; a frame
  is a few draw calls under one view transform. A pan or a zoom changes a
  uniform, not the scene.
- **The fallback if no crate retains for us**: tessellate only what changed, and
  upload the whole (cached) triangle list each frame. No tessellation on an
  unchanged frame is already the win; the upload of a few MB is not the
  bottleneck the 140,000 calls are.
- **The shell gets simpler.** It owns a GL context and uploads buffers and
  textures; the `Images` cache over `Blob` URLs moves to the kernel as decoded
  textures, and asset import no longer round-trips through the browser's
  decoder.
- **Code we already have.** The `Renderer` seam was shaped on egui's painter;
  epaint (its `Shape` → `Mesh` tessellator) or lyon would do the tessellation.

### Decisions this forces

- **Where tessellation lives, and the `headless` gate.** The gate forbids
  `epaint` in the ten core crates. Tessellating in the kernel means lyon (host-
  free, no gate change), or relaxing the gate for epaint alone — it has no
  window or GPU dependency, it is listed as egui's. A GPU backend crate (GL
  context, buffer upload) sits beside `blockworx-canvas2d`, outside the gate,
  as the one backend or as a second.
- **Anti-aliasing must be world-space-safe.** epaint's feathering is computed
  in screen pixels (`pixels_per_point`), which a zoom invalidates — a cached
  world-space mesh cannot carry it. MSAA on the WebGL2 surface, or analytic AA
  in the shader, keeps the cache valid across zooms.
- **Text.** Three candidates: glyph outlines tessellated into world-space
  meshes (exact at every zoom, most triangles), a glyph atlas at a few sizes
  (egui's way; blurs between sizes), or a signed-distance-field atlas (one
  texture, sharp at every zoom, the usual answer for zoomable text). The glyphs
  must stay the `Shaper`'s — epaint's own text system would measure
  differently from the kernel's layout, the drift CLAUDE.md warns about.
- **What is screen-space.** Handles, selection frames, the overlay's marks, hit
  targets and hairlines keep their size on screen at every zoom; they are a
  small immediate layer drawn over the retained scene each frame.
- **Invalidation.** An entity's mesh depends on more than the entity: a wire's
  hops depend on the wires it crosses, a block's pins on its pins, an accent on
  the palette. The commit path already knows what it disturbed (`Sealed`'s
  rectangle, the foreground); the retained scene should key off the same facts
  rather than grow a second change-tracking scheme.
- **The seam.** `View` is serializable and carries a screen-space `DrawList`.
  It would carry scene changes (meshes added, replaced, removed; textures) plus
  the view transform and the immediate layer. Export (SVG, PDF) stays vector:
  it must draw from the same per-object shapes the tessellator consumes, so the
  screen and the export cannot diverge.

### Text: replace the engine, not only the renderer (user, 2026-09-21)

`blockworx-text` is our own layout engine: harfrust shaping, line-breaking
rules ported from epaint, a layout cache, and glyph outlines. It is the single
source of truth for the kernel's hit tests, the canvas and the SVG/PDF export,
and that must stay true — but it need not be *ours*. Replacing it with a
mainstream crate that shapes, breaks lines and hands back glyph ids and
positions would delete the custom engine and its maintenance, and the new
renderer would take its glyphs from the same layout the kernel measured with.

Candidates, and what is known of each:

- **femtovg's `TextContext`** (the nanovg port): measures with no canvas —
  `measure_text` returns `TextMetrics { x, y, glyphs: Vec<ShapedGlyph> }`, each
  glyph carrying `glyph_id`, `font_id`, `byte_index`, position and advances;
  rustybuzz shaping, `break_text`/`break_text_vec`, bidi, fallback across up to
  eight fonts. Against it: `glow` is a hard dependency of femtovg (0.27), so the
  kernel would carry an OpenGL binding — the `headless` gate forbids that; its
  line breaking is by word width rather than UAX #14; rustybuzz is harfrust's
  predecessor; and its own text *drawing* rasterizes glyphs to a screen-size
  atlas, immediate mode, which a world-space retained scene does not want. It
  ties the text engine to the renderer: changing renderers would change text.
- **cosmic-text** and **parley** are text-layout crates with no renderer: both
  shape, break lines by UAX #14, handle bidi and font fallback, and expose laid
  glyph runs (ids and positions) for any renderer to draw — cosmic-text is what
  iced, Bevy and glyphon lay out with; parley is the Linebender stack's (Xilem,
  Vello). Their exact shaping backends, glyph-outline access and wasm weight are
  to be confirmed in R0.

So: the text engine and the renderer are two choices, not one. Pick the layout
crate on its own merits (headless, glyph runs exposed, outlines available for
world-space meshes or an SDF atlas), and the renderer takes glyphs from it —
femtovg included, drawing our glyphs rather than its own text.

### Text: epaint as the one layouter, exports as text (user, 2026-09-21)

Supersedes the candidate list above. Two facts change the question:

- **The vector exports draw text as outlines**, so a PDF or SVG of a diagram
  carries no text: nothing to select, copy, search or grep. That is a
  usability loss worth more than glyph-exact parity with the screen.
- **The exports can carry text as text without new machinery.** usvg lays
  `<text>` out itself (fontdb, rustybuzz, bidi; no wrapping — no
  `inline-size`), honours `textLength`/`lengthAdjust`, and `krilla-svg` draws
  a usvg text node with krilla's `draw_glyphs`, source text attached — real,
  selectable text with an embedded subset. The PNG is resvg over the same SVG.

So the direction:

- **epaint is the screen renderer and the one layouter.** The kernel's
  `TextLayout` is implemented over epaint's `Fonts`: measuring, wrapping, hit
  tests. Rows are decided once, in world units at a fixed scale, so a zoom can
  never move a line break; the renderer re-lays each *row* at the zoom it draws
  at, unwrapped. The seam already takes an engine that reports characters and
  no glyph ids (`Glyph.id` is `Option`), so no private epaint API is needed.
  The `headless` gate admits epaint alone (not egui).
- **The exports write rows, not glyphs**: one `<text>` a row at the row's
  position, `textLength` pinned to the row's measured width, the face embedded
  as `@font-face` for viewers and loaded into usvg's fontdb for the PNG and
  PDF. The viewer shapes inside the row — ligatures included.
- **`blockworx-text` goes**: the `Shaper`, the ported line breaking, the
  layout cache, `Outlines`.
- **What is given up**: glyph positions *within* a row may differ between the
  screen and a viewer. Same face; harfrust and rustybuzz are the same
  HarfBuzz port a version apart; `textLength` pins each row's width.

Checks, before building (they replace test 8):

- **T1 Zoom.** Rows laid out at world scale are the rows at every zoom; and
  how far a row re-laid at a screen scale drifts in width from the world row.
- **T2 Drift.** Glyph by glyph, epaint's positions against usvg's for the same
  row, across the fixture's labels and a corpus of kerning pairs, ligatures
  and wrapped paragraphs.
- **T3 PDF text.** A diagram's labels come back out of the PDF through
  `pdftotext`.
- **T4 Viewers.** The text SVG renders alike in Chrome, Firefox, Safari and
  resvg.

### R0t results (2026-09-21)

`tools/text-export-spike`: 2,841 distinct labels — the fixture's plus a corpus
of kerning pairs, ligatures, symbols and two paragraphs swept across 114 wrap
widths (228 wrapped labels).

- **T1 Zoom — rows must be decided in world units.** Re-wrapping at the screen
  scale moves line breaks in 119 of 228 wrapped labels at 10%, 51 at 25%, and
  13 even at 100% on Retina: epaint's layout is not scale-invariant. With the
  rows fixed in world units and each row re-laid unwrapped at the drawn scale,
  a row's width drifts at most 0.06 screen px from 10% to 400%. Hinting makes
  no difference to layout.
- **epaint snaps each glyph to the pixel grid of the density it lays out at.**
  At 1 px per point, world glyph positions are whole world px — visibly uneven
  at 400%. The world layout wants density ≥ 4 (the deepest zoom). And epaint
  rasterizes every glyph into its atlas *while laying out*: at density 64 a
  48 px label asks for a 2,569 px glyph and panics. The kernel's `Fonts` is
  its own, so its atlas is thrown away, but it is paid for.
- **T2 Drift — epaint and usvg agree.** Laid out at density 8, over 58,671
  glyphs: mean 0.05 px, max 0.37 px (on a row of symbols); ligatures (121
  clusters) and kerning match. Pinning rows with `textLength` does not help —
  epaint's row width and its advances disagree by up to a pixel, and the pin
  adds that error — so the export writes plain `<text>`.
- **T3 PDF text — works.** A sheet of 125 rows through krilla-svg: every row
  comes back out of `pdftotext` exactly, ligatures and symbols included; 26 KB
  with the embedded subset.
- **T4 Viewers — alike.** resvg, Chrome and Firefox land every glyph on
  epaint's tick. The one difference is fallback: Roboto has no `→`, and resvg
  (fontdb holding only Roboto) draws tofu where the browsers borrow a system
  face. Every consumer — epaint, usvg, the SVG's `@font-face` — must be given
  the same fallback faces.

**Newer crates (user asked, 2026-09-21).** The Linebender stack is the
movement: **parley 0.11** lays out with harfrust + skrifa + ICU segmentation,
exposes glyph ids, positions and clusters, and runs headless with fonts
registered in memory; **vello_hybrid 0.2** is a CPU/GPU renderer with a WebGL2
backend of its own (the `webgl` feature, no wgpu) that draws parley's glyph
runs directly (`Scene::glyph_run`) — one layouter, exact glyphs on screen.
epaint 0.36 itself now rasterizes glyphs with vello_cpu. Against it today:
its own README calls it less mature than vello_cpu, some features panic, glyph
caching is experimental, and it processes every path on the CPU every frame
(sparse strips), so the 10% frame's 80 k glyphs are its hard case —
unmeasured. So: epaint first in R1 (measured, 9.6 ms at 10%), with
parley + vello_hybrid measured beside it in the browser spike; the export
design (rows as `<text>`) is the same under either layouter.

**Decided (user, 2026-09-21): epaint.** Safari renders the text SVG alike too,
so T4 holds in all four viewers. epaint is the renderer and the one layouter;
parley + vello_hybrid are not pursued, and R1 measures epaint alone.

### Tests that decide it

Measure before building; each of these is cheap next to the change.

1. **Is the boundary the cost?** Replay the 10% display list into a no-op
   context (the wasm → JS calls, no raster) and into the real canvas; and
   replay it with text removed. Splits the 230 ms into crossing, raster and
   text, and puts a number on "LOD is at most ×2".
2. **World-space tessellation of the whole 50×50**, natively, with lyon and
   with epaint: time, triangle and vertex counts, memory — including glyphs as
   meshes, to size that option. A criterion scenario, per CLAUDE.md.
3. **A browser spike**: upload that mesh to WebGL2 and draw it under a view
   transform; frame time while panning and zooming at 10% and at 100%, on this
   machine and on the iPad. Pass: 60 fps panning at 10% here, 30 on the iPad.
4. **The fallback**: the same spike re-uploading the full buffer every frame,
   to price "immediate mode over cached triangles" against true retention.
5. **Text quality**: screenshots of labels at 10%, 50%, 100%, 400% for each text
   option against today's Canvas2D rendering.
6. **Invalidation is exact** (once built): a property test that the incrementally
   maintained scene equals a fresh tessellation after random edit sequences —
   the `fingerprint`-style check the router already uses.
7. **Export parity**: the SVG export and the retained scene draw from the same
   shapes; golden tests guard it.
8. **The text engine swap**: for femtovg's `TextContext`, cosmic-text and
   parley — the dependency tree against the `headless` gate; whether glyph ids,
   positions and outlines are public; laying out the 50×50's 8,840 labels
   (criterion); the wasm size each adds; and parity with today's wrapping and
   measurement tests, whose differences are enumerated and accepted or not
   rather than discovered.

### R0, test 1: where the canvas time goes (2026-09-21)

`tools/canvas-replay-bench` replays the real 50×50 display list on a 1600×1000
canvas in headless Chrome 149 — **software raster, no WebGL**, so a GPU
browser will differ — timing the calls issued and then the raster a one-pixel
`getImageData` forces. Means over 8 runs, ms, issue + raster:

| variant | 10% (46,764 ops) | 24% (11,478) | 48% (3,285) |
|---|---|---|---|
| `full` — today's replay | 176 | 52 | 17.6 |
| `no_text` | 36 | 8.5 | 2.7 |
| `lod3` — text under 3 px dropped | 36 | 37 | 15.7 |
| `styles` — a style set only when it changes | 160 | 45 | 14.2 |
| `batched` — + strokes by style, one `Path2D` per run | 201 | 56 | 18.3 |
| `batched_lod3` | **22** | 41 | 18.8 |
| `native_text` — the browser's `fillText` in our face | 84 | **21** | **6.2** |

- **Text is the cost**, ~80% at 10%, and at 10% all of it is under 3 px: level
  of detail there is ×5, not ×2. Raster is cheap far out — issuing the calls
  is nearly all of it — and a real share nearer in, where glyphs are drawn as
  vector fills.
- **Drawing glyphs ourselves is what costs.** One `Path2D` per run was *slower*
  (a JS matrix object per glyph); the browser's own `fillText`, in our font
  loaded as a `FontFace`, is 2.5–3× faster at mid zoom — its glyph rasters are
  cached.
- **Strokes batched by style and styles set once** take the non-text 36 →
  22 ms at 10%.
- **On the kernel side, text is not the cost**: skipping sub-3 px text in the
  recorder saved 3–5% (criterion, `frame_at_fit`/`hover_at_fit`), and a frame at
  a new zoom (`zoom_far_out`, layouts re-shaped) costs what a steady one does.
  The layout cache holds 4,096 runs against ~2,600 distinct labels, so a zoom
  gesture clears it every step or two — worth fixing, not worth much.

**On a GPU browser** — Safari on the user's MacBook Pro, same page, ms:

| variant | 10% | 24% | 48% |
|---|---|---|---|
| `full` | 91.3 | 25.9 | 8.3 |
| `no_text` | 19.8 | 5.2 | 1.5 |
| `batched_lod3` | **11.8** | 19.7 | 8.5 |
| `native_text` (also batches strokes, caches styles) | 44.0 | **11.2** | **3.2** |

Chrome on the same MacBook Pro at 10%: `full` 91.8, `no_text` 20.1,
`batched_lod3` 12.0, `native_text` 44.8 — Safari's numbers to within a few
percent, so it is the display list and not one engine. Both ran at a
device-pixel ratio of 1; the app on a Retina screen draws at 2, four times the
pixels (`?dpr=2` on the page) — the likely gap between these and the ~230 ms the
app's badge showed. Headless at `dpr=2`, 10%: `full` 204, `native_lod3` 31
(against 176 and 21.6 at 1). Chrome on the MacBook Pro at `dpr=2`, 10%:
`full` 105.6, `native_lod3` **15.3** ms.

**Mid zoom settles it (2026-09-21).** Chrome on the MacBook Pro at `dpr=2`,
24%: `full` 31.1, `lod3` 22.3, `batched_lod3` 26.5, `text_only` 24.2 (8.0 of it
issue, 16 raster) — against `native_text` 12.5 and `native_lod3` 8.9. With our
own glyphs, text alone is a 60 fps frame's whole budget: Canvas2D re-rasterizes
every glyph as a vector fill every frame, and the browser's `fillText` is fast
only because it caches glyph rasters — which cannot be had without taking the
browser's layout too, the second layouter the `Shaper` exists to prevent. The
only way to keep one layouter *and* cache glyph rasters on Canvas2D would be an
atlas of our glyphs drawn with `drawImage` — the cache-over-the-API complexity
this section set out to avoid. So the GPU scene (R1) is the route after all;
the far-zoom Canvas2D numbers below stand as the record.

**The decision this first seemed to support (superseded above).** On the user's machine the
combined replay draws the far-zoom frame in ~15 ms at Retina resolution; with
the kernel's ~24 ms while hovering, a frame is ~39 ms (~25 fps). 30 fps and up
needs the kernel under ~18 ms there — its known costs: the hover hit test
(~5 ms native), the pairwise fault pass (~3.3 ms), sub-pixel pins and hops
still recorded (~3.7 ms). The Canvas2D route meets the bar without the GPU
rewrite, so it goes first; the retained GPU scene above stays the documented
fallback.

Half the headless numbers or better, and the same shape: text is the cost,
dropping it far out and letting the browser draw it nearer in are the two
wins. `native_lod3` — the three combined — was added after this run; in
headless Chrome it is the best at every zoom, 21.6 / 13.2 / 5.5 ms (10 / 24 /
48%), which on the Safari ratio is roughly 11 / 7 / 3.

**What it says about Canvas2D.** Three changes, all inside the canvas2d replay
— the browser's text in our face, text under ~3 px dropped, strokes batched
and styles cached — keep the canvas at or under ~22 ms at every zoom measured.
With the kernel's ~12–20 ms (native; wasm near it), a frame lands at roughly
25–30 fps far out and better nearer in; 60 needs the kernel's hover path
trimmed too (the hit test, the pairwise fault pass, the wire-list re-sorts).
Before deciding:

- **Measure on a GPU browser** — the page is the instrument; headless Chrome
  rasterized in software.
- **Native text draws with the browser's shaper**, not the `Shaper` the kernel
  measured with: for one font the advances should agree, but kerning,
  fallback and wrap points can drift from the hit tests and the export.
  Measure the drift (a pixel diff against today's rendering) — and test 8
  bears on it: a layout engine whose glyph positions the browser can be told
  exactly would remove the question.

### R0, test 2: the world-space scene, built and held (2026-09-21)

`tools/tessellation-bench` tessellates the settled 50×50 recorded at zoom 1,
where screen space is world space: the whole sheet, 30,350 shapes and 25,200
labels (79,830 placed glyphs, 23 distinct). Every mesh is built for the
deepest zoom, 4×: curves flattened to 0.1 screen px there (0.025 world px),
nothing feathered in screen pixels. Criterion, native, a cold build of the
whole scene; vertices are a position and a colour (12 bytes, epaint's 20).

| mesh | build | vertices | triangles | MB |
|---|---|---|---|---|
| lyon, shapes, round joins and caps (the canvas's) | 46.7 ms | 822,480 | 756,580 | 18.1 |
| lyon, shapes, mitred joins, butt caps | 18.5 ms | 438,240 | 372,340 | 9.3 |
| epaint, shapes, unfeathered at 4× (world-safe) | 3.7 ms | 373,040 | 312,340 | 10.7 |
| epaint, shapes, feathered at 1× (egui's frame; valid at one zoom) | 25.2 ms | 641,080 | 953,420 | 23.1 |
| lyon, the 23 distinct glyphs, once each | 0.13 ms | 1,160 | 1,121 | 0.03 |
| lyon, every placed glyph | 463 ms | 3,596,340 | 3,410,720 | 80.2 |

lyon refused none of them.

- **Shapes are cheap to build and to hold.** The whole sheet cold is 4–47 ms
  and 9–18 MB; one block or wire is microseconds, so rebuilding what a commit
  disturbed fits in any frame and an open costs a frame or three. Uploaded
  once, a pan or zoom is a uniform.
- **Round joins double lyon's triangles** and cost 2.5× the time. The canvas
  draws them today; whether mitred joins on 1–3 px strokes are visibly
  different is test 5's question.
- **epaint is ~5× faster than lyon for the same mitred geometry**, but its
  anti-aliasing is feathering in screen pixels, so the world-safe mesh has
  none and leans on MSAA; and it fills only convex paths, so it cannot
  tessellate a glyph. It is host-free — with default features off its tree is
  emath, ecolor and a CPU text stack (harfrust, kurbo, peniko, vello_cpu), no
  GPU, window or async crate — so the `headless` gate names it by policy (the
  toolkit we left, kept from creeping back), not because it needs a host;
  allowing epaint alone is a one-line change to that gate. Its laid-out
  `Glyph` still carries a character and an atlas rect but no glyph id, so its
  text is no replacement for the `Shaper`. lyon fills anything.
- **Glyphs as meshes, one per placement, are out**: 3.4 M triangles, 80 MB,
  463 ms. Instanced — one mesh per distinct glyph, each placement a transform
  and a colour (~2.2 MB) — is cheap to hold, but the GPU still rasterizes
  3.5 M triangles a frame, nearly all sub-pixel at 10%, the case GPUs handle
  worst. **Text on the GPU is an atlas**: one quad per glyph (160 k
  triangles), rasterized from the `Shaper`'s outlines — a few sizes, or an
  SDF. Test 5 picks. (The fixture flatters the glyph count; a real
  document's Latin text is ~100 glyphs, still small.)

So R1 draws shapes as per-object world meshes and text as atlas quads from
our own outlines. The tessellator is not the risk either way: epaint for
shapes (5× faster, the gate relaxed for it alone), or lyon for everything.

### R0, test 2b: epaint doing all of it (user, 2026-09-21)

epaint is host-free and draws text itself — its own layout (harfrust
shaping), a glyph atlas it fills as it goes, a textured quad a glyph — so the
whole diagram can be one epaint mesh, as egui drew it. `whole::Whole` in the
tessellation bench keeps one `Fonts` across frames (the galley cache and the
atlas persist, as in egui) and tessellates every op, text included, into one
mesh. Criterion, native:

| frame | time | vertices | triangles | MB |
|---|---|---|---|---|
| world scene, cold (new `Fonts`: layout, atlas, tessellation) | 38.6 ms | 692,360 | 472,000 | 18.6 |
| world scene, warm (galleys and atlas cached) | 11.3 ms | | | |
| screen, 10%, dpr 1 (egui's frame: feathered, snapped) | 9.6 ms | 588,912 | 499,212 | 16.9 |
| screen, 10%, dpr 2 | 9.6 ms | 588,912 | 499,212 | 16.9 |
| screen, 24%, dpr 2 | 1.9 ms | 143,544 | 120,076 | 4.1 |
| screen, 48%, dpr 2 | 0.6 ms | 51,432 | 56,178 | 1.6 |

The atlas stays small: 2048×128 for the world scene, 2048×32 at 10%.

- **Immediate mode is already affordable.** Re-tessellating everything every
  frame, text included, is 9.6 ms at the far floor and under 2 ms from 24% in
  — before any retention. Half a million triangles is nothing to a GPU. This
  is why egui held ~30 fps far out: epaint's frame was never the problem, the
  per-call raster of Canvas2D is.
- **Why lyon took 47 ms**: its fill tessellator is a general sweep line
  (self-intersection, holes, any winding) run for every rect and polygon, and
  its round joins at 0.025 px flatten finely. epaint special-cases what the
  diagram is made of — rects, convex polygons, polylines — and draws text as
  quads rather than outlines.
- **The catch is the layouter.** epaint's text is laid out by epaint, not the
  `Shaper`, and its `Glyph` carries a character and an atlas rect but no glyph
  id. Using it for the screen alone is the second layouter again. Using it as
  *the* layouter — the kernel's `TextLayout` implemented over epaint's
  `Fonts`, the export drawing outlines by each glyph's character — is a test 8
  candidate alongside cosmic-text and parley, and the only one that brings a
  renderer with it. It rasterizes at screen size, so a zoom lays out and
  rasterizes afresh, the churn the `Shaper`'s cache already shows.

### Phases

- [ ] **R0** Tests 1–2: where the 230 ms goes, and what the world-space scene
      costs to build and hold; test 8, the text engine candidates.
      - [x] Test 1 (above).
      - [x] Test 2 (above), and 2b: epaint doing all of it.
- [ ] ~~**C1**~~ *(superseded: at mid zoom our own glyphs cost a 60 fps
      frame's budget on Canvas2D; browser text is a second layouter)* The
      Canvas2D route: in the canvas2d
      replay, the browser's text in our face, text under ~3 px dropped,
      strokes batched and styles set once — after a pixel diff of the
      browser's text against today's, since it shapes with the browser's
      engine rather than the `Shaper`.
- [ ] **C2** The kernel's far-zoom frame under ~18 ms (wasm): the hover hit
      test, the pairwise fault pass, sub-pixel pins and hops.
- [ ] **R1** Test 3–5: the browser spike, the fallback, the text options. Go or
      no-go on a GPU backend, and which tessellator and text strategy — next,
      since C1 falls short at mid zoom with one layouter. The glyphs stay the
      `Shaper`'s (or test 8's replacement's); what the GPU adds is the raster
      cache Canvas2D withholds.
- [ ] **R2** The retained scene in the kernel: per-object meshes, invalidation
      from what commits already know, the screen-space layer. Export draws from
      the same shapes.
- [x] **R0t** T1–T4 above: epaint as the one layouter, exports as text.
      Rows fixed in world units at density ≥ 4; plain `<text>`; one fallback
      set for every consumer.
- [ ] **R2t** Replace `blockworx-text` with the chosen layout crate, behind the
      existing `TextLayout` seam, before the renderer depends on it — so the
      swap is measured and its layout differences reviewed on their own.
- [ ] **R3** The GPU backend crate and the shell on it; images as textures from
      the kernel; Canvas2D kept until parity, then retired.
- [ ] **R4** Re-measure, on the iPad too.
