Skip to content

Routing Engine (RouterCore)

src/msd/routing/RouterCore.js is the pathfinding engine behind every MSD line: one instance per MSD card, shared by all line overlays on that card. It decides each line's viewBox-space geometry — where it bends, how it avoids obstacles, and how multiple lines coordinate into bundles ("cable raceway" behavior).

User-facing behavior and configuration: Line Routing & Channels. This page covers the internals.

RouterCore has zero DOM dependencies — it's pure geometry and graph search, which is what makes it unit-testable: tests/routing/*.test.js (npm run test:routing, Node's built-in test runner) exercises it directly through a small harness (tests/routing/helpers/router-harness.js).


Request Lifecycle

  1. buildRouteRequest resolves the line's mode: explicit route: wins, then a card-wide default_mode (if set to something other than auto), otherwise auto resolves straight to smart — unconditionally, not gated on obstacles/channels being present. manhattan and grid are the explicit, always-honored opt-outs for the cheap/non-participating and no-refinement-pass alternatives respectively.
  2. AdvancedRenderer.render() positions control overlays, waits for their DOM to settle, then resolves every overlay's anchor/attachment points.
  3. Discovery loop (AdvancedRenderer._discoverLineRoutes): before anything renders, every line is routed — iterated in a fixed order sorted by overlay id, not YAML order — repeatedly, until a full sweep causes zero registry mutations (capped by trunk_discovery_max_passes, default 4), which is what makes bundling outcomes independent of declaration order. The loop must use the same complete anchor set (static + attachment-manager virtual anchors) the render pass uses, or control-anchored lines silently fail to resolve and the loop no-ops.
  4. Render pass (declaration order, for SVG z-ordering): each LineOverlay.render() calls RouterCore.buildRouteRequest() + computePath() — all cache hits once the loop has converged.
  5. computePath dispatches on mode: manual / direct / corridor-chained (_computeCorridorRouted) / plain A* (_computeGrid, plus _refineSmart for smart).
  6. After geometry is final (post corner-rounding, pre smoothing), the line's straight runs are registered into the shared registries, making its geometry visible to other lines' future routing decisions.

Core Data Structures

StructureContentsPurpose
_trunksTrunk rows: bounds, direction, origin (channel/discovered), sourceLineId (creator), crossCenter, members: Map<lineId, [...]> (per-member flow span + derived lane/side metadata, see Lane Assignment)Bundling. Config channels are pre-seeded rows; discovered rows are created from lines' own straight runs (≥ trunk_min_length, default 60)
_crossingsPer-line straight-run records (≥ crossing_min_length, default 12)Crossing avoidance occupancy
_registryVersionCounter bumped only on real registry mutationsFolded into the route cache key — the mechanism that makes the discovery loop converge and steady state free
_cacheLRU route cache keyed on every request field + _registryVersion + viewBox + obstacle versionA line recomputes exactly when something it depends on changed

A* and Cost Biases

_computeGrid runs 4-direction A* over a coarse grid (grid_resolution) with a turn penalty, plus three per-cell bias layers looked up during the search. When grid_resolution isn't set in config, _defaultGridResolution() derives it from the viewBox's own shorter dimension (~1/12th of it, clamped to [16, 64]), since a flat default is only ever right by coincidence for how large the author's view_box happens to be; it's recomputed on every call rather than cached, so it stays correct across setViewBox(). Explicit config always wins; values ≤ 4 are silently coerced to 32.

  • Channel bias (_buildChannelCostGrid): discount for moving along a prefer channel's flow direction; penalty inside avoid channels. A prefer discount can break the Manhattan heuristic's admissibility, so those searches fall back to h=0 (Dijkstra).
  • Crossing penalty (_buildCrossingCostGrid): penalizes moving orthogonally through another line's registered segment, always non-negative (no admissibility concerns). Bundle-mates are exempt — lines sharing a trunk, and the occupants of a corridor being joined — because reaching an outer lane legitimately crosses inner lanes, and penalizing that made A* sneak around segment endpoints instead.
  • Cardinal anchor_side/attach_side guarantees are enforced structurally with fixed stub segments spliced around the search (_applyCardinalStubs), not as costs — see below for how long that stub is allowed to be.

Corner-Radius-Driven Stub Length

_applyCardinalStubs and _computeManhattan's own fallback stub logic both splice a fixed, unsearched lead-out/lead-in segment onto the true endpoint before any A*/crossing-cost evaluation runs — the pathfinder only searches between the stub endpoints. That reserved length can never be routed around anything, including another line's already-registered crossing segment — corner geometry takes structural precedence over crossing avoidance, not a cost tradeoff a bias knob can influence.

Two functions compute it:

  • stubLengthFor(req)max(MIN_STUB_LENGTH, cornerRadius*2), gated on cornerStyle actually rendering an arc/chamfer (round/bevel; a miter line has nothing to make room for). Used unconditionally by _pushBundledApproachLegs's bundled-corridor lane-nudge distance, since that leg is independently pathfound through the crossing-cost grid, so it isn't a blind splice.
  • cardinalStubLengthFor(req, minAutoLength) — used only by the two genuinely blind call sites (_applyCardinalStubs, _computeManhattan's fallback). An explicit per-line stub_length override (req.stubLength, see below) wins unconditionally if set. Otherwise it returns stubLengthFor(req) when cornerRadiusMode === 'forced' (opt-in); by default ('auto') it returns minAutoLength (the router's own resolved grid_resolution), leaving the full route shape to the crossing-aware search. Rendered corner radius still targets the configured value wherever the chosen path's leg lengths allow it.

cornerRadiusMode is a per-request field resolved in buildRouteRequest and folded into _cacheKey (CRM:) alongside cornerRadius/cornerStyle. A per-line stub_length (viewBox units) bypasses both auto/forced computations entirely, for a line that needs a specifically shorter or longer lead-out without touching the card-wide grid_resolution.

Stroke-width-aware corner clearance: buildRouteRequest resolves req.width from the line's style (style.width/style.stroke_width, default 2) and threads it into each registered _crossings entry. _distanceToNearestOtherLineSegment nets (askingWidth + otherWidth) / 2 off the raw centerline-to-centerline distance before _applyCornerRounding clamps a corner's radius to it — two thick parallel lines get a smaller effective radius at the same lane spacing than two thin ones, correctly accounting for rendered edges rather than centerlines. width participates in the registration change-diff, so a width-only edit still bumps _registryVersion.

Corner-Room Refinement (corner_room_weight)

On by default (DEFAULT_CORNER_ROOM_WEIGHT = 4) — a different tool from the stub reservation above, which is a blind, structural reservation; corner_room_weight does a cost-compared local refinement for interior corners the stub mechanism never reaches, reusing _refineSmart's elbow-nudge search (gated on smart_proximity > 0 OR cornerRoomWeight > 0 && cornerStyle === 'round' && cornerRadius > 0). _estimateCornerRadii (shared with _applyCornerRounding's pre-calc) gives refinement scoring and final rendering one source of truth for "how would this corner round." Each elbow-shift candidate's cost gets + shortfall * cornerRoomWeight added, shortfall being the sum of max(0, cornerRadius - achievedRadius) across the candidate's corners, compared against the same computation on the unmodified baseline.

Candidate generation produces only the 2 geometrically-meaningful shift directions per elbow (the other 2 always degenerate to a no-op once compacted), each a 2-point replacement so both adjacent legs stay orthogonal by construction — legitimately adding one bend, capped by smart_max_extra_bends (default 3). A candidate is rejected outright (never repaired) if its path crosses an obstacle, reverses direction along an axis, or would land outside the card's viewBox (tests/routing/refine-smart-viewbox-bounds.test.js).

Per-Waypoint Corner Radius Override

manual-routed lines can override an individual corner's rounding radius (round) or chamfer size (bevel) via a 3rd number on a coordinate waypoint ([x, y, radius]), instead of every corner sharing the line's single req.cornerRadius. This is a target substitution: _estimateCornerRadii already computes a per-corner radius internally (room clamp, neighbor-clearance shrink, consecutive-corner scaling) from one global scalar; a per-waypoint override replaces which value each corner's clamp chain starts from, via a perCornerRadius option (null = inherit radiusGlobal) — everything downstream still applies as a ceiling over whichever target is in effect. _applyCornerBeveling does not share _estimateCornerRadii and carries the identical substitution independently.

A waypoint's radius travels with its point through _computeManual's pipeline (as {pt, radius} entries, deduped on pt) rather than being re-derived by index, since _computeManual can drop or dedup a waypoint and misalign a naive index mapping. Named-anchor waypoints always resolve to radius: null. Scoped to manual mode only — every other mode passes null into _applyCornerRounding/_applyCornerBeveling.

_cacheKey folds JSON.stringify(req.waypoints) into the key (empty string for non-manual lines), so a per-corner radius edit with unchanged endpoints can't hit a stale cache entry. invalidate(id)'s scan matches keys via k.startsWith(${id}@).

Channel Entry Is Always At An End

_channelCrossingPoints always clamps entryFlow = Math.max(lo, Math.min(hi, approachFlow)) to the channel's own flow-axis bounds [lo, hi]; whenever the approach point sits outside that range, it snaps to the nearest of the channel's two ends. There is no code path that lets a line join a channel's centerline mid-span from the side — every crossing is an end-to-end (or end-to-partway-through) traversal by construction. This is an architectural constraint, not a gap; genuine mid-span side-entry would be a substantial design change.

Post-Search Reshape — Orthogonality Invariant

After _computeGrid's A* search reconstructs a path, a final reshape pass snaps the first and last segments onto the exact requested endpoint coordinates (the search itself only ever lands on grid-quantized points). Each side has a degenerate-axis relief: when the required axis for that end has zero real distance to cover (e.g. a channel_axis hint wants vertical, but start/end already share the same y), forcing the segment onto that axis anyway would insert a spurious zero-length elbow, so the relief accepts the reconstructed segment as-is — but only once it has confirmed the segment is actually orthogonal (actuallyHorizontal || actuallyVertical), since under some grid-snap geometries the raw reconstructed segment can be diagonal, which this Manhattan-only router can't render or corner-round.

This invariant — every segment _computeGrid produces must be axis-aligned — is asserted in tests/routing/diagonal-segment.test.js. Separately, meta.bends/meta.segments (and _costComposite's own bend count) are derived from _compactPolyline(pts).length, never raw pts.length — a collinear "phantom" point (typically a stub's landing point continuing the same direction) otherwise over-counts by one; _compactPolyline still preserves genuine same-axis reversal points (see Convergence Discipline below).

Same-grid-cell collapse: a leg whose raw req.a/req.b round to the same grid cell (e.g. a corridor hop shorter than one grid_resolution) makes the A* search "arrive" on its first pop, producing one degenerate point that an existing guard duplicates before the reshape snaps both copies back onto the two real endpoint coordinates. The reshape's hard-hint branches test the current, post-snap segment directly for this case, since the grid's pre-snap point is just the collapsed cell's own center. The generic branch (no hard hint) uses a narrower test gated on wasSameCellCollapse, captured at the same point the guard duplicates the collapsed point — so only the collapsed case gets this treatment, and the normal case still uses the original pre-snap comparison, which would false-positive if applied unconditionally. Covered by tests/routing/same-cell-collapse-diagonal.test.js.

Corridor-Crossing Direction Enforcement

A channel_axis-sourced hint's hard block in _computeGrid's neighbor-expansion loop guarantees a leg's departure from — or arrival at — a corridor crossing lands on the required axis. This is extended, on both sides, to also reject the exact reverse of continuationDir when known (req._continuationDirFirst/Last, from _computeCorridorRouted's entryHint/departHint) — an axis-only check permits arrival from either direction along that axis, including a same-axis reversal against the corridor's own established flow, which the direction check closes. Covered by tests/routing/channel-entry-direction.test.js.

_computeCorridorRouted's chain loop tracks prevChannelDir — the last known real direction of travel — so a leg departing a channel whose own entry→exit crossing is degenerate (zero real distance, entry === exit) still gets a "don't reverse" guarantee carried from an earlier channel in the chain. When the chain's very first channel is itself the degenerate one, there's no earlier channel to carry a direction from — the fallback derives one from the approach leg's own real displacement (cursor before this channel's reassignment, to entry) instead of leaving prevChannelDir at 0. Covered by tests/routing/channel-chain-first-hop-degenerate.test.js.

Note: _buildChannelCostGrid's per-cell 'prefer' discount applies to any leg carrying the channel in req.channels, not just the leg designated as the official crossing — an approach leg still technically outside the channel earns the same reward as the real crossing leg. Known architectural characteristic, not narrowed by the direction fix above — see below for the case where it required its own fix.

Plain-Candidate Channel-Bias Leak

computePath's prefer-channel/discovered-trunk branch compares a plain candidate (the honest, no-corridor route) against every corridor option on effectiveCost (meta.cost + a crossing-avoidance tiebreaker). computePlain() builds its own request: any channel id present in explicitCorridors (a force/prefer channel this line could chain through) is stripped from req.channels before it reaches _computeGrid/_refineSmart, so the comparison baseline is honestly "what if this line never used the channel at all" — otherwise plain would receive the same channel-bias discount (per the leak noted above) while never using the corridor mechanism, unreliable once bend cost is cheap relative to the discount (e.g. a low turn_penalty). avoid-mode channel ids stay in place, since their repulsion bias is exactly what an honest plain route should still respect. The corridor can still legitimately win the comparison outright when no competing line creates a crossing risk — a deliberate, cost-vetted use, not a leak. Covered by tests/routing/channel-plain-candidate-bias.test.js.

Trunk-and-Branch (Bundling)

A channel is just a pre-seeded trunk; a discovered trunk is a corridor learned from another line's routed path. Both live in _trunks and flow through the same corridor-composition machinery:

  • Discovery (_discoverTrunkCandidates): a line finds joinable trunks by flow-axis overlap (≥ trunk_min_overlap, default 60) and cross-axis proximity (≤ trunk_proximity, default 32, a hard cutoff — see Known Limitations). Exclusions: corridors already referenced explicitly, trunks the line itself created, ghost shells with zero members, and rows authored with discoverable: false. Membership is deliberately not an exclusion — a joined line must be able to re-discover its own trunk on recompute, or joining would silently revert ("join ratchet"). Discovery runs regardless of a line's route_channels, which only controls whether a channel is mandatory/cost-biased for lines listing it; discoverable: false is the only way to scope a channel to just those users.
  • Composition (_computeCorridorRouted): approach leg → through leg → depart leg per corridor, each independently pathfound. A discovered trunk (or prefer channel) chain is an optional candidate compared against the plain route by real cost; only force channels are mandatory and skip trunk discovery entirely. _polylineSelfIntersects hard-rejects (never repairs) a chain whose own non-adjacent legs cross, gated !hasForceChannel — a large enough channel discount can otherwise make a self-crossing candidate look cheapest, since the crossing-cost term only ever checks a candidate against other lines' segments, never its own earlier legs. Covered by tests/routing/corridor-self-crossing.test.js.
  • Discount cap (_trunkBundleDiscountCap, config trunk_bundle_discount_cap, default 150): _corridorDelta's prefer-mode discount credits at most this much distance toward a discovered trunk's cost reduction (a force/prefer channel authored via route_channels is never capped). A real, binding tuning value — it stops a line whose own plain route is already short and clean from detouring into an unrelated discovered trunk purely to farm the discount.
  • Registration (_registerLineSegments_mergeOrRegisterTrunk): a finished route's straight runs merge into geometrically-matching trunk rows as member spans, or create new rows. Registration is diff-in-place: matching entries update with no reported change on identical geometry, and only entries the scan didn't touch are dropped. Rows are never deleted — emptied "shell" rows reactivate on rejoin.

Lane Assignment — Derived, Never Stored

_trunkLaneAssignment(corridor, lineId, chainSideHint) computes {laneIndex, laneCount, offset} as a pure function of the trunk's current member set on every call — there is deliberately no stored lane state. The predecessor (_channelLineIndex, a permanent insertion-order map) caused the engine's recurring failure class: stateful per-corridor bookkeeping the discovery loop couldn't converge over. Membership changes bump _registryVersion; identical re-registration is a strict no-op.

  • Discovered trunk: the creator (sourceLineId) implicitly holds lane 0 at offset 0 — its path is the centerline (crossCenter). Joiners (members minus creator, plus the asking line, sorted lexicographically within each side) alternate sides: +s, -s, +2s, -2s, …. The row's cross-axis band width is derived from joiner count (_trunkBandHalfWidth), grown symmetrically so the band center never drifts. The creator's own natural side is recorded per-member and, when strictly positive, flips which side-group gets the lower laneIndex — a lane numbering preference only, since lane bookkeeping runs after routing, not before.
  • Config channel: all users get centered offsets (i − (n−1)/2) × line_spacing, clamped to the authored band. Ordering among members uses the same naturalSide-first, lineId-tie-break sort the discovered branch uses — a pure-lexicographic sort by id alone can order lanes exactly backwards from each line's own geometry, creating unnecessary crossovers. Covered by tests/routing/channel-lane-natural-side.test.js.
  • Chain-aware ordering: both branches route the same-side tie-break through shared _sameSideOrder/_bothGroundedAt helpers rather than maintaining independent copies — see Chain-Aware Corridor Lane Consistency below.

Matched-Sibling Corner-Arc Clearance

When two lines sharing a trunk/channel row turn the identical way at corresponding ends of the shared corridor (a "bundle-mate" match), their rendered corner arcs are cleared against each other's exact geometry, not just their straight runs — trimLo/trimHi (inside _distanceToNearestOtherLineSegment) already handles a neighbor's straight, still-sharp span; this covers the neighbor's own curved region, which a linear trim can't represent.

  • Identification (_matchedTrunkCorner, _matchedSiblingArcs): a corner is a trunk-exit corner when its incoming run's flow span exactly matches this line's own registered member entry in some _trunks row. A sibling "matches" when it's a member of that row with a corner at the same relative end and an identical (vInUnit, vOutUnit) pair — not just the same offsetDir — since two geometrically distinct turns can share an offsetDir while sweeping in opposite rotational senses.
  • Exact clearance (_siblingArcClearance): once a sibling's corner is resolved, its full swept arc is sampled at K+1 angles (K=12) against the candidate's own arc, taking the true minimum pairwise distance (verified against tests/routing's flattenSvgPath oracle to within ~0.5%) — matched single-index sampling isn't sufficient, since the true closest-approach pair between two differently-centered arcs generally sits at different sweep fractions on each.
  • Combining with the existing proxy: the exact signal only ever floors up (Math.max) the existing _distanceToNearestOtherLineSegment proxy, which still runs unmodified against every line including matched siblings — never excludes or replaces it. Excluding a matched sibling from the proxy and relying solely on the exact check causes genuine oscillation (two mutually-matched siblings each reading the other's previous-pass radius with no monotonic anchor); flooring against the untouched, monotonic proxy guarantees a fixed point.
  • Convergence cost: can add up to one extra discovery-loop pass versus not having it — safely within the existing pass cap in every tested scenario.

Covered by tests/routing/matched-corner-arc-clearance.test.js.

Corridor-Entry Nudging

Bundle members approach a shared corridor via _pushBundledApproachLegs, which builds the from → nudge → mid → to leg split used both by _channelCrossingPoints's entry taper and by ordinary bundled-approach legs. Several details govern where nudge/mid land:

  • Grace-zone taper: _channelCrossingPoints reserves min(stubLengthFor(stubReq), halfSpan) units of flow-axis room ahead of a channel entry whenever laneCount > 1, so a real cross-axis lane correction has room to render instead of a zero-length kink. entryAlreadyInside (whether the boundary is fundamentally a cross-axis-only correction, so the leg's arrival hint stays a soft "don't reverse" guarantee rather than a hard axis-lock) is captured before the taper moves entryFlow, not re-derived afterward — re-deriving it post-move would always read false and wrongly escalate the hint to a geometrically unsatisfiable hard lock.
  • Grid-quantization alias guard: two quantization functions run over the same grid — Math.floor for a registered segment's cell vs. Math.round for the A* query side — which can disagree at a cell boundary and alias two genuinely-distinct rows into one cell, triggering a false parallel-overlap penalty against an unrelated line. _taperAliasesRegisteredSegment checks every registered segment for this alias before a candidate coordinate is committed (taper or corridorOffset alike); on a hit, the taper is skipped or corridorOffset falls back to 0, since both are purely cosmetic and a tighter fallback corner is always safe. A structurally cross-axis-only leg has its hint downgraded from a hard channel_axis lock to a softer hint, mirroring entryAlreadyInside's own downgrade. Covered by tests/routing/channel-taper-grid-alias.test.js.
  • Conflict-extension search: corridorOffset (stubLengthFor(stubReq) + laneIndex * lineSpacing) has no inherent awareness of other lines' still-active approach segments — the smallest-laneIndex member's nudge can land within another member's not-yet-turned approach run, producing a real crossing. If the resulting (nudge, mid) leg runs unsafely close to another registered segment (_pureCrossAxisLegTooCloseToOtherLine), the offset is extended in lineSpacing-sized steps (up to the available clamp), taking the smallest safe extension; when none exists, corridorOffset is left as computed and the pre-existing detour mechanism still applies. Covered by tests/routing/kelvin-bundle-crossing.test.js.
  • Cross-axis-aware sizing: the entry's two lane-separation-jog corners are constrained by the cross-axis lane-offset distance (line_spacing-driven, typically 8-12px), not corner_radius. The corner_radius used to size rawOffset is capped at min(corner_radius, crossDist * 0.325) (0.325 from the 0.65 consecutive-corner fill-fraction split), but only when it doesn't change whether the reservation saturates against available — a corridor exit leg relies on the larger, uncapped reservation overshooting available so mid coincides with to (midIsTo), collapsing a 3-leg structure into one clean corner. Covered by tests/routing/kelvin-bundle-crossing.test.js and tests/routing/channel-chain-transition-taper.test.js.

Bundle-Entry Corner Geometry

A bundle member's lane-separation jog, rendered as a plain 90°-quarter-circle fillet, is capped at roughly crossDist * 0.325 independent of corner_radius. Since crossDist differs per member, entry curves render at visibly different, undersized radii even with an identical configured corner_radius — unlike a later channel-to-channel transition, where every member has generous leg room and renders uniformly at full radius. No config knob touches crossDist itself, and clamping every member to the group's smallest achievable radius is uniform but wrong — the goal is each member as close to its requested radius as possible.

Reverse curve: reverseCurveGeometry (module-level pure function) computes a road/rail S-curve lane-change — two tangent circular arcs of shared radius R connecting a flow-axis-aligned line to a parallel one offset by d (crossDist) on the cross axis. Identities: d = 2R(1-cosθ), L = 2R sinθ, θ capped at 90°. A small crossDist sweeps a smaller portion (θ) of the same target radius rather than needing a smaller circle, so every member independently targets and achieves radiusGlobal given enough room; when d > 2R the excess becomes a plain straight segment, and when the target radius doesn't fit the room, R is re-derived from the room constraint (θ = 2·arctan(d / (2·Lavail)), R = Lavail / sinθ), degrading gracefully toward a hard corner. Bundle-entry legs skip the crossDist-based shrink above and keep the full 2 × corner_radius reservation, since the reverse curve can use the extra room; exit legs are unaffected. _pushBundledApproachLegs records each entry hint; _buildBundleEntryReverseCurves matches hints back onto the final point list by coordinate and stores a .reverseCurve descriptor consumed by a dedicated rendering branch. Shipped as the default, no config knob. Covered by tests/routing/bundle-entry-radius-coordination.test.js and pinned assertions in tests/routing/kelvin-bundle-crossing.test.js / tests/routing/entry-taper-grace-zone.test.js.

Concentric positioning: corridorOffset unconditionally adds laneIndex * lineSpacing to every member's nudge distance, staggering entries apart even when nothing requires it (the term exists for a real case elsewhere — vertically-stacked, same-anchor_side controls whose stubs would otherwise run coincident before diverging late). Since the conflict-extension search above already re-derives however much separation a line needs on demand, bundle-entry legs use laneStagger = bundleEntryHints ? 0 : laneIndex * lineSpacing, starting from a shared, unstaggered baseline; bundle-exit legs keep the original stagger (exit-leg saturation, midIsTo, is sensitive to this term's magnitude). Members whose jog never reaches a sibling's approach row land on the identical unstaggered position; members with a longer jog still get pushed out when a real conflict is detected. Covered by tests/routing/bundle-entry-radius-coordination.test.js and a pinned assertion in tests/routing/kelvin-bundle-crossing.test.js.

Chain-Aware Corridor Lane Consistency

Lane assignment (_trunkLaneAssignment) decides which side/offset a line gets at a corridor locally, one corridor at a time, with no inherent visibility into what the same line — or a sibling sharing that corridor — decides at any other corridor in a multi-corridor chain. Two distinct problems follow: one line's own side disagreeing with itself across its chain (Phase 1), and two different lines' relative order at one corridor failing to compose with their relative order at another they also share (Phase 2).

Phase 1 — Chain Self-Consistency

The full chain of corridors a line will traverse is known up front (chainChannels, built by _mergeCorridors). naturalSide/naturalLean's sign convention (negative = north/west, positive = south/east) is frame-fixed — it doesn't rotate with direction of travel — so two corridors' "positive" doesn't mean the same physical side of a bundle unless corrected for how travel direction differs between them. corridorFlowFactor(flowSign, horizontal) computes k = flowSign × (horizontal ? +1 : -1) per corridor per line. Given a line's real, locally-measured naturalLean at one canonical corridor in its chain (_naturalLeanAt), the chain-consistent target at any other corridor B is offsetRel = naturalLean(canonical) × k(canonical), targetLean(B) = offsetRel × k(B), targetSide(B) = Math.sign(targetLean(B)). Every k is ±1, so magnitude never propagates — only sign — and the transform is its own inverse. Only a chain's first-or-last corridor can be a valid canonical anchor; a discovered trunk's own creator is excluded from having a side at all.

_chainSideAssignment(chainChannels, lineId, rawA, rawB) returns Map<corridorId, {side, lean, offsetRel, k, grounded}> | null (null for a single-corridor chain), computed once per _computeCorridorRoutedAttempt call and threaded as chainSideHint into every _trunkLaneAssignment call the attempt makes; the same values are recorded on meta.chainSides so registration reuses what was just rendered. The member tuple carries [flowLo, flowHi, naturalSide, cornerInfo, naturalLean, offsetRel, k, grounded]naturalLean stays unmodified; only the discretized naturalSide is overridden when a chain hint applies. grounded (whether this member's chain canonical anchor is this corridor) feeds Phase 2. Covered by tests/routing/chain-side-propagation.test.js and tests/routing/chain-self-consistency-regression.test.js.

Phase 2 — Cross-Line Consistency

Phase 1 makes one line's own naturalSide chain-consistent; it says nothing about two different lines whose relative order at one shared corridor might not compose with their order at another. The same-side tie-break's magnitude comparison (Math.abs(leanOf(a)) − Math.abs(leanOf(b))) is where this bites, since neither naturalLean value carries any notion of whether it's measured in a trustworthy frame at that corridor. The mechanism reuses Phase 1's discipline — one grounded reference, sign/topology propagated via a closed-form ±1 factor, magnitude never propagated — applied to a pair of lines:

  1. Groundedness gate (_bothGroundedAt, _locallyGrounded): a corridor's recorded naturalLean magnitude is safe to compare between two members only when it's each member's own chain canonical anchor (grounded), plus a structural floor — the two lines must also co-occur as members in some other differently-oriented corridor (catches registrations that bypassed _chainSideAssignment, where grounded silently defaults to true). Applied uniformly to both _trunkLaneAssignment branches.
  2. Seed-and-propagate (_pairwiseCrossCorridorOrder), when the corridor itself isn't jointly grounded: among the other co-occurring corridors, a seed is valid only if both lines are locally grounded there (exactly one valid seed required; zero or 2+ falls through to lexicographic). The pair's order at the seed maps to the target via a binary handedness factor: pairOrderHandedness = sign((kASeed·kATarget) × (kBSeed·kBTarget)), orderAtTarget = orderAtSeed × handedness — composing across any chain length for free since every k is ±1.
  3. Group-size restriction: the shared comparator (_sameSideOrder) only attempts _pairwiseCrossCorridorOrder when exactly 2 lines are in the same-side group, since independent pairwise resolutions across 3+ lines have no guarantee of composing into the transitive order Array.prototype.sort requires. 3+-way groups fall back to lexicographic (see Known Limitations).

Covered by tests/routing/chain-side-propagation.test.js, tests/routing/pairwise-cross-corridor-order.test.js, and tests/routing/chain-self-consistency-regression.test.js.

Convergence Discipline

The discovery loop only terminates affordably because of three invariants:

  1. Idempotence: re-registering identical geometry must not report a change.
  2. Derived state: anything computed from the registries (lanes, band widths, lane counts) is recomputed from current state, never incrementally mutated.
  3. History independence: setOverlays with a changed overlay set resets the discovered registries. Bundle arrangements can be genuine cost ties with multiple stable outcomes — whichever line routes first picks the winner — so each config's outcome must be a function of the config plus the fixed loop order, never of edit history.

The regression suite encodes these directly: the fixed-point test (cache-cleared recompute reproduces the converged answer with zero version bumps) is the canary for the whole class.

Debug forensics: each cache entry's key embeds the _registryVersion it was stored at (…|RV:n), so [...router._cache.keys()] reconstructs the order lines were actually computed in. RouterCore.prototype.trunks() (wired to window.lcards.debug.msd.routing.trunks(cardId)) snapshots every trunk row — id, origin, direction, sourceLineId, bounds, crossCenter, member ids — for inspecting bundling state without reaching into private fields; the MSD Studio dialog's "Discovered Trunks" overlay reads the same method live. Every computePath result also carries meta.debug: { stubLength, gridResolution, cornerRadiusMode, cornerRadius } — the router's resolved values for that line.

Pitfalls

  • grid_resolution values ≤ 4 are silently coerced to 32.
  • channels config lives at msd.channels, not msd.routing.channels (MsdCardCoordinator assembles RouterCore's config as { ...mergedConfig.routing, channels: mergedConfig.channels }).
  • Never call computePath with endpoints other than the line's real resolved anchors "just to inspect" — registration is a side effect of routing, and synthetic requests pollute the registries under the real line's id. RouterCore.inspect(id) reads the cache without computing.
  • route: direct/manual results still register (walls/backbones participate in avoidance and bundling) but never react to other lines.
  • Never derive bend/segment counts from raw pts.length — always _compactPolyline(pts).length.
  • A channel without discoverable: false is joinable by any nearby auto/smart/grid line, not just ones listing it in route_channels — not a bug, just the automatic-bundling design (see Trunk-and-Branch above).

Known Limitations (deliberately not fixed)

Each was investigated with a concrete attempted fix, not just noticed and left:

  • trunk_proximity is a hard cutoff (_discoverTrunkCandidates), not a graduated cost — a line either qualifies to bundle with a nearby trunk or it doesn't, with a measured cliff right at the threshold. Widening the gate softens the cliff but regresses an already-correct scenario back to its exact pre-fix buggy shape. A real fix needs the proximity check to contribute a graduated cost rather than a boolean gate — a broad enough change to the A* cost landscape to warrant its own regression pass.
  • _mergeCorridors's chain-ordering sorts a line's candidate trunks by comparing each trunk's flow-axis coordinate against its own flow span — a fine proxy when every chained trunk shares the line's dominant travel axis, but incomparable across a mixed horizontal/vertical chain (confirmed via scale-stress.test.js's manyToOne scenario to occasionally order a farther crossing point before a closer one, forcing a small backtrack). Only reproduces in stress scenarios with several chained mixed-axis trunks. Tracked as a .todo(); a fix needs a genuinely different ordering metric (e.g. predicted crossing-point distance per candidate).
  • Arc-vs-arc clearance between two arbitrary (non-bundle-mate) lines whose corners happen to land near each other — unlike bundle-mates turning the same way, which Matched-Sibling Corner-Arc Clearance (above) solves exactly. A candidate-center-vs-neighbor's-registered-arc proxy (bounding box, then sample points) gets measurably wrong once the target is a discrete curve. The bundle-mate fix works because a matched sibling's exact geometry is already known via a shared registry key; two unrelated lines have no such key, so the technique doesn't generalize without a real "which lines are worth comparing" discovery mechanism of its own.
  • _buildCrossingCostGrid's overlapRegions/perpMates formulas are too pervasively load-bearing to edit directly. Several modification strategies for overlapRegions each regress the full test suite in different combinations; rescoping perpMates regresses both of convergence.test.js's own fixed-point tests, meaning it's load-bearing for the discovery loop's convergence guarantee itself, not just cosmetic shape. Prefer a narrow, additive, upstream-of-the-cost-grid fix (the Corridor-Entry Nudging section is the template) over editing these formulas directly.
  • _pairwiseCrossCorridorOrder's cross-line consistency mechanism is deliberately restricted to exactly 2 same-side lines at a corridor (see Chain-Aware Corridor Lane Consistency above). A same-side group of 3+ has no fix within a pairwise-comparator architecture — independent pairwise resolutions, each potentially via a different seed corridor, have no guarantee of composing into one transitive order. A genuine fix needs a real constraint-satisfaction pass over the whole group at once, not a comparator.
  • Chaining through a degenerate channel crossing back toward the line's own origin conflicts two of this router's own invariants: never render a same-axis reversal, and never force a detour when a direct path exists. When a mode: prefer channel's crossing is degenerate (entry === exit) and the chain's next channel sits back toward where the line came from, forcing the direct shape reintroduces the reversal those invariants exist to prevent, while blocking it forces an artificial detour instead — the geometry has no third option. Needs its own dedicated design pass, given the direct conflict with already-hardened invariants.

See Also