packages feed

hgg-core-0.1.0.0: src/Graphics/Hgg/Render/EdgeRoute.hs

-- |
-- Module      : Graphics.Hgg.Render.EdgeRoute
-- Description : DAG edge の pt 空間 routing 幾何 (障害物回避・port・制御点列)
-- Copyright   : (c) 2026 Aelysce Project (Toshiaki Honda)
-- License     : BSD-3-Clause
--
-- Phase 39 B2: routing を描画 (Render.Special) から分離した純幾何 module。
-- pt 空間で toScreen・radius・plate bbox (障害物) を受け、 edge の制御点列と
-- 描画 style ('EdgeRoute') を返す。 Primitive 生成や ThemePalette には依存しない
-- (= 描画は呼出側 'renderEdge' の責務)。 B1 の段階型と対になる「routing 入力契約」。
--
-- node 形状幾何 ('nodeExtent' / 'edgePortPoint') も routing が依存するため本 module に
-- 置き、 描画側 (renderNode 等) は本 module から import する (= 下位 = 幾何、
-- 上位 = 描画 の層分け)。
{-# LANGUAGE OverloadedStrings #-}
module Graphics.Hgg.Render.EdgeRoute
  ( -- * routing 結果
    EdgeRoute (..)
  , routeEdge
  , spreadPorts
    -- * 障害物モデル (A-1)
  , Box (..)
  , Obstacles (..)
  , dagObstacles
  , plateBoxPt
  , plateChildrenOf
    -- * channel + funnel (A-2 / A-3)
  , buildChannel
  , funnel
    -- * box 拘束 Bézier fit (R3 = graphviz Proutespline)
  , proutespline
  , solve3
    -- * node 形状幾何 (port 交点)
  , edgePortPoint
  , nodeExtent
  , nodeShowsDist
  , dagLabelFs
  ) where

import           Graphics.Hgg.Layout (dagLabelFs, dagNodeBaseHalfWidth)
import           Graphics.Hgg.Primitive     (Point (..))
import           Graphics.Hgg.Spec   (DAGEdge (..), DAGNode (..),
                                      DAGNodeKind (..), DAGPlate (..))
import           Data.Maybe          (mapMaybe)
import           Data.Text           (Text)

-- | edge routing の結果 = 制御点列 + 描画 style。 ThemePalette/Primitive 非依存。
--
--   * 'StraightArrow' = 単独 short edge (直線 + 矢印)
--   * 'SplinePath'    = 並列 short / 長 edge 非迂回 (Catmull-Rom・呼出側で平滑化)
--   * 'BezierPath'    = 長 edge の plate box 迂回 (箱角 waypoint を平滑化せず通す)
--   * 'CubicPath'     = R3 (Step6 P7a): graphviz Proutespline の box 拘束 cubic Bézier
--                       fit。 先頭 = 始点、 以後 3 点ずつ (制御点1, 制御点2, 終点) の
--                       cubic segment 列。
data EdgeRoute
  = StraightArrow Point Point
  | SplinePath [Point]
  | BezierPath [Point]
  | CubicPath [Point]
  deriving (Show, Eq)

-- | edge の制御点列と style を pt 空間で決定する純関数 (= 'renderEdge' から routing 部を抽出)。
-- 並列 edge は perpendicular に offset、 長 edge は graphviz routesplines:
-- 障害物 ('Obstacles') から box-channel を作り (A-2)、 funnel 最短折れ線 (A-3) を通す。
routeEdge
  :: (Double -> Double -> Point)
  -> Obstacles                            -- ^ A-1: node + plate 障害物 (pt 空間)
  -> DAGNode -> DAGNode -> Maybe [(Double, Double)]
  -> Double
  -> Int -> Int  -- ^ parIx, parCount
  -> EdgeRoute
routeEdge toScreen obs from to mPath radius parIx parCount =
  let fromCenter = toScreen (dnX from) (dnY from)
      toCenter   = toScreen (dnX to)   (dnY to)
      Point fx fy = fromCenter
      Point tx ty = toCenter
      edx = tx - fx; edy = ty - fy
      elen = sqrt (edx * edx + edy * edy)
      (perpx, perpy) = if elen > 1e-12 then (-edy / elen, edx / elen) else (0, 0)
      offsetMag =
        if parCount > 1
          then (fromIntegral parIx - fromIntegral (parCount - 1) / 2)
               * (radius * 1.4)
          else 0
      offsetP (Point px py) =
        Point (px + perpx * offsetMag) (py + perpy * offsetMag)
  in case mPath of
       Just [_, wp, _] | abs (fy - ty) < 1e-9 ->
         -- P7b 最小 (Phase 53 A3-4): flat edge (= 同 rank edge) の迂回 arc。
         -- chain は [from, gap-waypoint, to] の 3 点 (DAG.routeStage 産)。
         -- 'buildChannel' / 'funnel' は y 単調な rank 下り chain 前提で、 flat の
         -- V 字 guide を潰して中間 node を貫通する (実測: flat5 a→b が m 中心を
         -- 通過) ため通さず、 graphviz make_flat_edge と同じく rank 間 gap を
         -- 通る arc を直接作る。 並列 flat edge は waypoint ごと perpendicular
         -- offset で分離。
         let wpP = offsetP (let (wx, wy) = wp in toScreen wx wy)
             fromPortS = edgePortPoint from fromCenter wpP radius
             toPortS   = edgePortPoint to   toCenter   wpP radius
         in SplinePath [fromPortS, wpP, toPortS]
       Just chain | length chain >= 2 ->
         -- A-2/A-3: dummy chain を guide に box-channel を作り funnel で最短折れ線へ。
         -- 内部制御点は並列 spread 用に perpendicular bend を掛けてから guide にする。
         let inner    = [ toScreen x y | (x, y) <- take (length chain - 2) (drop 1 chain) ]
             innerOff = map offsetP inner
             guide0   = fromCenter : innerOff ++ [toCenter]
             -- A-1: この edge が避ける障害物 (= 端点を含む箱は除外)。
             eboxes   = edgeBoxes obs from to fromCenter toCenter
             -- ★ Phase 52 A7: guide (dummy lane) が box を貫くと 'buildChannel' の
             -- portal が行ごとに反対側へ飛び (pushOut の側 flip)、 taut が box 内部を
             -- 対角に横切って node に重なって描画される (実測: dense15 x4→x15 が
             -- x13 box を貫通)。 guide 段階で貫通線分に迂回 corner を挿入して
             -- channel を一貫させる (= graphviz P4a が dummy を box 外へ置くのと同層)。
             -- taut 段階の補正だと taut が barrier 上を沿走して fit が直角に縮退する。
             -- dedup 6pt は corner と旧 waypoint の近接 backtrack 掃除。
             guide    = dedupTaut 6.0 (avoidBoxTaut eboxes guide0)
             portals  = buildChannel eboxes guide
             taut     = funnel portals          -- src .. snk (端点含む taut 折れ線)
             interior = drop 1 (initSafe taut)  -- 端点を除く taut waypoint
             -- A-5 (暫定): port は taut 経路の端 segment 方向へ取る (自然入射)。
             firstDir = case interior of (p:_) -> p; [] -> toCenter
             lastDir  = case reverse interior of (p:_) -> p; [] -> fromCenter
             fromPortS = edgePortPoint from fromCenter firstDir radius
             toPortS   = edgePortPoint to   toCenter   lastDir  radius
             -- R3 (Step6 P7a): taut 折れ線 (port→port) に box 拘束 cubic Bézier を
             -- graphviz Proutespline で fit。 channel 境界 = portal の左鎖 + 右鎖。
             tautPorts = fromPortS : interior ++ [toPortS]
             barriers  = channelBarriers portals
             -- ★ A3.3 (2026-06-24): endpoint slope は taut の端 segment 方向 (= 自然な
             -- port 入射方向) を渡す。graphviz の `±π/2 constrained` は **内部の
             -- box-segment 境界** に適用される拘束であり、**実端点 (src/snk port) の接線は
             -- port 方向 (斜め)**。一次実測: dot 14.1.5 の `edge src snk` gold は始点
             -- `(1.9076,2.3025)→(1.9989,2.2455)` = 接線 (+0.091,-0.057) で水平寄りの斜め
             -- (垂直でない)。A3.1 は端点も垂直と誤って拘束し、A3.2 の y-sweep で taut が
             -- 4 点クリーン化した後は **斜め近接 + 強制垂直の衝突で overshoot (内側 S)** を
             -- 生んでいた。自然方向に戻すと S が消え単一 bow に (A3.1 の cusp も y-sweep で
             -- taut が V でなくなったため再発しない)。
             ev0 = case tautPorts of
                     (a:b:_) -> vnorm (vsub b a)
                     _       -> Point 0 1
             ev1 = case reverse tautPorts of
                     (a:b:_) -> vnorm (vsub a b)
                     _       -> Point 0 1
             ctrl   = proutespline barriers tautPorts ev0 ev1
         in CubicPath ctrl
       _ | parCount > 1 ->
         -- 並列 short edge: 中点 bend 1 点を挟む 3 点 spline で「並ぶ曲線」 を出す
         let midRaw  = Point ((fx + tx) / 2) ((fy + ty) / 2)
             midBent = offsetP midRaw
             fromPortS = edgePortPoint from fromCenter midBent radius
             toPortS   = edgePortPoint to   toCenter   midBent radius
             pts       = [fromPortS, midBent, toPortS]
         in SplinePath pts
       _ ->
         -- 単独 short edge: 従来の直線 + 矢印
         let fromPort = edgePortPoint from fromCenter toCenter radius
             toPort   = edgePortPoint to   toCenter   fromCenter radius
         in StraightArrow fromPort toPort

initSafe :: [a] -> [a]
initSafe [] = []
initSafe xs = init xs

-- ===========================================================================
-- A-1: 障害物モデル (pt 空間の軸並行矩形)
-- ===========================================================================

-- | pt 空間の軸並行矩形 (xlo ≤ xhi, ylo ≤ yhi)。 routing の障害物 / channel box 共用。
data Box = Box !Double !Double !Double !Double  -- ^ xlo ylo xhi yhi
  deriving (Show, Eq)

-- | routing 用障害物集合。 node glyph box (= id 付き・端点除外用) と plate box を分けて保持。
--
-- Phase 53 A4: 'obLanes' = 各 edge の dummy lane box 列 ((from, to) key 付き)。
-- graphviz `make_regular_edge` の per-edge boxes は「rank order 上の左右隣接
-- オブジェクト (**virtual node 含む**) で clip した回廊」 ('maximal_bbox')。
-- 'buildChannel' の free 区間 clip は既に「最寄り crossing box = 隣接オブジェクト」
-- なので、 他 edge の dummy lane を障害物に足せば channel がそのまま
-- 「自レーンの box 回廊」 になる (= 他 edge の dummy レーンに侵入不能)。
data Obstacles = Obstacles
  { obNodes  :: [(Text, Box)]   -- ^ node id → glyph box (clearance margin 込み)
  , obPlates :: [Box]           -- ^ plate 枠 box (clearance margin 込み)
  , obLanes  :: [((Text, Text), [Box])]
    -- ^ A4: edge (from, to) → dummy lane box 列 (chain 内部 waypoint の virtual node box)
  } deriving (Show, Eq)

-- | clearance margin (= spline が箱に接しないための余白)。 graphviz: cluster 8pt。
-- ★ Phase 52 A7 実測メモ (2026-07-08): node 4→8pt を試したが channel が狭まり
-- 分割接合の junction kink (157°/54°) が再発したため 4pt に据え置き
-- (routes CSV + analyze-kinks.py で確認)。 かすり対策は box 辺 barrier
-- ('boxBarriers') 側で行う。
obNodeMargin, obPlateMargin :: Double
obNodeMargin  = 4
obPlateMargin = 8

-- | 全 node glyph box (+margin) と plate box (+margin) を pt 空間で構築する (A-1)。
--
-- Phase 53 A4: @edges@ から dummy lane box ('obLanes') も構築する。 chain 内部
-- waypoint (= long-edge dummy) ごとに幅 'laneHalfW'、 高さ = その rank の band
-- (同 y の real node の最大 ry) の virtual node box を置く。 flat edge
-- (端点同 rank) の gap waypoint は rank line 上のオブジェクトではないため対象外。
dagObstacles :: (Double -> Double -> Point) -> Double
             -> [DAGNode] -> [(Text, DAGNode)] -> [DAGPlate] -> [DAGEdge]
             -> Obstacles
dagObstacles toScreen radius nodes nodeMap plates edges =
  Obstacles
    { obNodes =
        [ (dnId n, Box (cx - rx - obNodeMargin) (cy - ry - obNodeMargin)
                       (cx + rx + obNodeMargin) (cy + ry + obNodeMargin))
        | n <- nodes
        , let Point cx cy = toScreen (dnX n) (dnY n)
        , let (rx, ry) = nodeExtent n radius ]
    , obPlates =
        [ Box (xlo - obPlateMargin) (ylo - obPlateMargin)
              (xhi + obPlateMargin) (yhi + obPlateMargin)
        | p <- plates
        , Just (xlo, ylo, xhi, yhi) <- [plateBoxPt toScreen radius nodeMap plates p] ]
    , obLanes =
        [ ((deFrom e, deTo e), laneBoxes chain)
        | e <- edges
        , Just chain <- [dePath e]
        , length chain > 2
        , not (isFlatChain chain) ]
    }
  where
    -- rank band の半高: 同 y (= 同 rank line) の real node の最大 ry。 real node の
    -- 無い all-dummy rank は 1 行 node 相当へ fallback。 graphviz の virtual node box
    -- が rank の band を占める (= 交差は rank 間 gap で起きる) のと同層。
    rankHalfH py =
      case [ ry | n <- nodes
                , let Point _ cy = toScreen (dnX n) (dnY n)
                , abs (cy - py) < 1e-6
                , let (_, ry) = nodeExtent n radius ] of
        [] -> max (radius * 0.7) ((dagLabelFs + 3) / 2 + 4)
        rs -> maximum rs
    isFlatChain chain = case (chain, reverse chain) of
      ((_, y0) : _, (_, yn) : _) -> abs (y0 - yn) < 1e-9
      _                          -> False
    laneBoxes chain =
      [ Box (px - laneHalfW) (py - hh) (px + laneHalfW) (py + hh)
      | (x, y) <- take (length chain - 2) (drop 1 chain)
      , let Point px py = toScreen x y
      , let hh = rankHalfH py ]

-- | dummy lane box の半幅 (pt) = nodesep/2 (auxNodeSep 18 の半分)。
-- graphviz 'maximal_bbox' は隣接 virtual node との中点 (= 自 box 右端 + nodesep/2)
-- まで回廊を開くため、 隣接 lane の回廊同士はちょうど tile して重ならない。
-- 半幅 9pt の lane 障害物で clip すると同じ境界になる。
laneHalfW :: Double
laneHalfW = 9

-- | この edge が避けるべき障害物 box 群。 端点 (from/to) の node box と、 端点中心を
-- 内側に含む box (= 端点が属する plate 等) は除外する (= edge は正規にそこへ接続する)。
--
-- Phase 53 A4: 他 edge の dummy lane box ('obLanes') も避ける = per-edge box 回廊。
-- 自 lane と、 同一端点対の並列 edge (chain 共有・perpendicular offset で分離済) の
-- lane は除外する。
edgeBoxes :: Obstacles -> DAGNode -> DAGNode -> Point -> Point -> [Box]
edgeBoxes obs from to srcC snkC =
  let nodeB = [ b | (i, b) <- obNodes obs, i /= dnId from, i /= dnId to ]
      laneB = [ b | ((f, t), bs) <- obLanes obs
                  , not (f == dnId from && t == dnId to)
                  , not (f == dnId to && t == dnId from)
                  , b <- bs ]
      allB  = nodeB ++ obPlates obs ++ laneB
  in [ b | b <- allB, not (boxContains b srcC), not (boxContains b snkC) ]

-- | 点が box の interior にあるか (境界は外側扱い)。
boxContains :: Box -> Point -> Bool
boxContains (Box xlo ylo xhi yhi) (Point x y) =
  x > xlo && x < xhi && y > ylo && y < yhi

-- ===========================================================================
-- A-2: box-channel 構築 (guide 折れ線 + 障害物 → portal 列)
-- ===========================================================================

-- | guide 折れ線 (端点含む・y 単調を想定) と障害物から funnel 用 portal 列を作る。
-- 各内部 guide 点の y 水平線上で、 guide の x を含む free 区間 (左右最寄り障害物に
-- clip) を求め、 (左点, 右点) の portal に。 端点 (src/snk) は退化 portal として両端に置く。
-- free 区間が退化/逆転したら guide 点をそのまま通す退化 portal にフォールバック
-- (= その点は funnel の強制通過点になる。 'funnel' の退化 portal 扱いを参照)。
--
-- ★ R1 (Step6 P7a・2026-06-24): 片側に障害物が無いときの壁を **graph bbox 端 (有限値)**
-- に clip する (旧: ±Infinity)。graphviz `maximal_bbox` (dotsplines.c) は隣 node が無ければ
-- cluster/graph 境界へ clip するため壁は常に有限。旧 ±Inf は funnel の 'tri' 外積を
-- Infinity 化して符号崩壊 → 直線 collapse を招いていた (correspondence doc §4-B)。
--
-- ★ R2-fix (2026-06-24): portal を free 区間**全幅**でなく **dummy x まわりの狭い窓**
-- ([gx-w, gx+w] を free 区間で clip) にする。graphviz `maximal_bbox` は virtual node 自身の
-- 細い幅 (lw≈1pt) 基準で box を作るため box は dummy に密着する。旧実装は free 区間全幅を
-- portal にしていたため、端点が片寄ると funnel が dummy lane を無視して chain 寄りへ
-- shortcut し L 字 (角 1 個) になり、R3 の cubic fit が暴走 (bulge) していた。狭い窓に
-- すると funnel が collinear な dummy lane に沿い、graphviz と同じ滑らかな bow になる。
buildChannel :: [Box] -> [Point] -> [(Point, Point)]
buildChannel boxes guide = case guide of
  []  -> []
  [p] -> [(p, p)]
  (p0@(Point _ y0) : rest) ->
    let pn@(Point _ yn) = last rest
        interiorGys     = [ gy | Point _ gy <- initSafe rest ]   -- dummy lane の y
        -- R1: 有限フォールバック壁。 全 box edge + guide x の外側へ channelMargin だけ
        -- 余白を取った graph bbox 端。 隣 box の無い側はここまで開く (= 拘束なし相当)。
        xsAll = [ x | Box xlo _ xhi _ <- boxes, x <- [xlo, xhi] ]
                ++ [ gx | Point gx _ <- guide ]
        gloX  = minimum xsAll - channelMargin
        ghiX  = maximum xsAll + channelMargin
        -- ★ A3.2 (2026-06-24): graphviz box-stack の rank 境界に相当する portal を
        -- **障害物の上下エッジ y** で張る。矩形障害物の周りでは funnel がその上角・下角を
        -- 2 つの waypoint として丸める (= graphviz Pshortestpath が cluster bbox 角を経由
        -- する忠実構造)。旧実装は dummy の y 1 点でしか portal を張らず waypoint が 1 個 →
        -- taut が単一屈曲 V → Proutespline が splinefits 第一段 (polyLen 短縮) 棄却で dummy
        -- 分割 → 各 2 点 forceflag 直線 → テント角になっていた。box の内側 epsY だけ寄せた
        -- 高さで sample し (strict cross 判定に乗せる)、上下角を確実に waypoint 化する。
        yLo = min y0 yn; yHi = max y0 yn
        boxEdgeYs = concat [ [ylo + epsY, yhi - epsY] | Box _ ylo _ yhi <- boxes ]
        eventYs0  = filter (\y -> y > yLo + epsY && y < yHi - epsY)
                           (boxEdgeYs ++ interiorGys)
        -- sweep 方向に整列・近接重複除去 (src→snk の y 単調順)。
        ascending = yn >= y0
        eventYs   = dedupNear (if ascending then sortAsc eventYs0
                                            else reverse (sortAsc eventYs0))
        mkPortal gy =
          let gx0      = guideXAt guide gy   -- dummy lane の x (側の選択に使う)
              crossing = [ b | b@(Box _ ylo _ yhi) <- boxes, ylo < gy, gy < yhi ]
              -- ★ Phase 44.4: guide x が crossing box の **内部**にあると (= layout が
              -- radius floor のズレで dummy を箱境界の内側に置いた場合)、box の左右どちらの
              -- エッジも「正しい側」の clip 条件 (xhi ≤ gx / xlo ≥ gx) に乗らず free 区間が
              -- 全開 (gloX, ghiX) に退化し、funnel が箱内を通って spline が箱を貫通する
              -- (Phase 39 P8 A2 stopgap 撤去で露呈した回帰)。graphviz `routesplines` /
              -- `maximal_bbox` は channel box を cluster と重ねず必ず外側へ clip するため、
              -- guide を内包する box の **近い辺** へ push し、その外側 free 区間で portal を
              -- 張る (= 箱をハード障害物にして spline 全体を箱外に保つ)。
              pushOut x (Box xlo _ xhi _)
                | xlo < x && x < xhi = if x - xlo <= xhi - x then xlo else xhi
                | otherwise          = x
              gx       = foldl pushOut gx0 crossing
              -- 隣接 box (or graph 端) までの free 区間 (= maximal_bbox の隣接クリップ)。
              freeL = maximum (gloX : [ xhi | Box _ _ xhi _ <- crossing, xhi <= gx ])
              freeR = minimum (ghiX : [ xlo | Box xlo _ _ _ <- crossing, xlo >= gx ])
          in if freeL < freeR
               then (Point freeL gy, Point freeR gy)        -- (左点, 右点)
               else (Point gx gy, Point gx gy)              -- 退化: lane x を強制通過
    in (p0, p0) : map mkPortal eventYs ++ [(pn, pn)]

-- | guide 折れ線 (y 単調を想定) の高さ @y@ における x を線形補間する。
-- box-stack portal の「側」 (どの障害物が左/右か) を決めるのに使う。
guideXAt :: [Point] -> Double -> Double
guideXAt pts y = go pts
  where
    go (Point x1 y1 : more@(Point x2 y2 : _))
      | inSeg y y1 y2 =
          if abs (y2 - y1) < 1e-9 then x1
          else x1 + (x2 - x1) * (y - y1) / (y2 - y1)
      | otherwise = go more
    go [Point x _] = x
    go _           = 0
    inSeg t a b = (t >= min a b - 1e-9) && (t <= max a b + 1e-9)

-- | 昇順ソート (挿入ソート・小規模 event 列向け)。
sortAsc :: [Double] -> [Double]
sortAsc = foldr ins []
  where
    ins x [] = [x]
    ins x (z : zs) | x <= z    = x : z : zs
                   | otherwise = z : ins x zs

-- | 近接した y を 1 つに畳む (portal の零高さセグメント除け)。
dedupNear :: [Double] -> [Double]
dedupNear [] = []
dedupNear (x : xs) = x : go x xs
  where
    go _ [] = []
    go prev (z : zs) | abs (z - prev) < epsY = go prev zs
                     | otherwise             = z : go z zs

-- | box 内側へ寄せて portal を sample する高さオフセット (pt)。 strict cross 判定に
-- 乗せ、box 上端・下端の角を確実に waypoint 化する。 近接 y の畳み込み閾値も兼ねる。
epsY :: Double
epsY = 0.75

-- | R1 フォールバック壁の余白 (pt)。 graph bbox 端からさらに外へ取る隙間。
channelMargin :: Double
channelMargin = 16

-- ===========================================================================
-- A-3: funnel (stringpulling) 最短折れ線
-- ===========================================================================

-- | portal 列 ((左点, 右点) の列・先頭=src 末尾=snk の退化 portal) を通る最短折れ線を
-- funnel アルゴリズムで求める。 戻り = src .. snk の折れ線 (端点含む)。
--
-- ★ R2 (Step6 P7a・2026-06-24): graphviz `Pshortestpath` (shortest.c の三角形分割 +
-- deque funnel + `ccw`) と **数学的に同一**な教科書的 Lee funnel
-- (Mononen "Simple Stupid Funnel Algorithm") に置換。box-stack polygon では三角形分割の
-- 対角線 = box 重なり portal なので portal-funnel = 三角形分割 funnel (= 新規アルゴでなく
-- Pshortestpath そのもの)。旧自前 apex-jump funnel は cone 不変条件違反で左右壁を交互
-- 往復する zigzag を生んでいた (correspondence doc §4-C)。
--
-- 規約: portal.left = 小 x 側 / portal.right = 大 x 側、path は下方向 (y 増加)。
-- 'triarea2' は canonical 定義 (bx*ay - ax*by)。right 壁が左へ寄ると triarea2 ≤ 0 で funnel が
-- 締まる (手計算検証済)。退化 portal (left==right) は 'vequal' 分岐で素通り。
funnel :: [(Point, Point)] -> [Point]
funnel [] = []
funnel ps
  | n <= 1    = [apex0]
  | otherwise = dedupConsec (go cap 1 apex0 0 apex0 0 apex0 0 [apex0])
  where
    n        = length ps
    leftAt i  = fst (ps !! i)
    rightAt i = snd (ps !! i)
    apex0    = fst (head ps)
    goal     = fst (ps !! (n - 1))
    -- funnel は線形 (Mononen で証明済) だが、不正 portal 列での無限 restart を防ぐ保険。
    cap      = 8 * n + 64 :: Int
    -- 状態: fuel / 走査 i / apex (idx ai) / 左壁 lp (idx li) / 右壁 rp (idx ri) / 逆順 acc
    go fuel i apex ai lp li rp ri acc
      | fuel <= 0 = reverse (goal : acc)   -- 保険発火 (理論上到達せず)
      | i >= n    = reverse (goal : acc)
      | otherwise =
          let r = rightAt i
          -- 右壁の更新
          in if triarea2 apex rp r <= 0
               then if vequal apex rp || triarea2 apex lp r > 0
                      then stepLeft fuel i apex ai lp li r i acc   -- 締める (rp:=r, ri:=i)
                      else go (fuel - 1) (li + 1) lp li lp li lp li (lp : acc)  -- right が left 越え → left を確定
               else stepLeft fuel i apex ai lp li rp ri acc        -- 右更新スキップ
    -- 左壁の更新 (右更新を経た後・同 i)
    stepLeft fuel i apex ai lp li rp ri acc =
      let l = leftAt i
      in if triarea2 apex lp l >= 0
           then if vequal apex lp || triarea2 apex rp l < 0
                  then go (fuel - 1) (i + 1) apex ai l i rp ri acc   -- 締める (lp:=l, li:=i) → i 前進
                  else go (fuel - 1) (ri + 1) rp ri rp ri rp ri (rp : acc)  -- left が right 越え → right を確定
           else go (fuel - 1) (i + 1) apex ai lp li rp ri acc        -- 左更新スキップ → i 前進

-- | 連続する同一点 (vequal) を 1 つに畳む。 Mononen funnel は goal を末尾に必ず append
-- するため、 funnel が goal で collapse すると末尾が重複しうる。 R3 spline fit の零長
-- セグメント除けも兼ねる。
dedupConsec :: [Point] -> [Point]
dedupConsec [] = []
dedupConsec (x : xs) = x : go x xs
  where
    go _ [] = []
    go prev (y : ys)
      | vequal prev y = go prev ys
      | otherwise     = y : go y ys

-- | 三角形 (a,b,c) の符号付き面積 ×2 (Mononen canonical: bx*ay - ax*by)。
triarea2 :: Point -> Point -> Point -> Double
triarea2 (Point ax' ay') (Point bx' by') (Point cx' cy') =
  let ax = bx' - ax'; ay = by' - ay'
      bx = cx' - ax'; by = cy' - ay'
  in bx * ay - ax * by

vequal :: Point -> Point -> Bool
vequal (Point ax ay) (Point bx by) =
  abs (ax - bx) < 1e-9 && abs (ay - by) < 1e-9

-- ===========================================================================
-- R3: box 拘束 cubic Bézier fit (graphviz Proutespline・route.c の忠実移植)
-- ===========================================================================
-- 一次根拠 = lib/pathplan/route.c (mkspline / splinefits / reallyroutespline /
-- splineisinside / splineintersectsline) + lib/pathplan/solvers.c (solve3)。
-- taut 折れ線に cubic Bézier を 1 本 fit → channel 境界 (barrier 線分) を逸脱しなければ
-- 採用、 逸脱すれば最大偏差点で分割して再帰。 これで graphviz と同じ滑らかな迂回弧になる。

-- 簡易ベクトル演算 (Point を 2D ベクトルとして扱う)。
vsub, vadd :: Point -> Point -> Point
vsub (Point ax ay) (Point bx by) = Point (ax - bx) (ay - by)
vadd (Point ax ay) (Point bx by) = Point (ax + bx) (ay + by)
vscale :: Double -> Point -> Point
vscale k (Point x y) = Point (k * x) (k * y)
vdot :: Point -> Point -> Double
vdot (Point ax ay) (Point bx by) = ax * bx + ay * by
vlen :: Point -> Double
vlen p = sqrt (vdot p p)
vdist :: Point -> Point -> Double
vdist a b = vlen (vsub a b)
vnorm :: Point -> Point
vnorm p = let l = vlen p in if l > 1e-12 then vscale (1 / l) p else p

-- | Phase 52 A7: funnel 後の taut 補正。 'buildChannel' の portal は guide が box を
-- 貫く行で 'pushOut' により反対側へ飛ぶことがあり (側 flip)、 連続 portal 間の
-- channel 多角形が box をまたぐ → taut 線分が box 内部を対角に横切る
-- (実測: dense15 x4→x15 の taut (32.4,176)→(75.6,190.2) が x13 box を貫通)。
-- box 内部を実質的に横切る線分 (貫通長 > 'boxCrossEps') に、 貫通側の box 角
-- waypoint を挿入して外周へ迂回させる。 端点が box 境界上に乗るだけの接触は対象外。
avoidBoxTaut :: [Box] -> [Point] -> [Point]
avoidBoxTaut boxes = go (8 :: Int)
  where
    go 0 pts = pts
    go n pts =
      let pts' = pass pts
      in if length pts' == length pts then pts else go (n - 1) pts'
    pass (a : b : rest) = case firstCross a b of
      Just corners -> a : corners ++ pass (b : rest)
      Nothing      -> a : pass (b : rest)
    pass xs = xs
    firstCross a b =
      case [ cs | bx <- boxes, Just cs <- [crossCorners bx a b] ] of
        (cs : _) -> Just cs
        []       -> Nothing

-- | 線分 (a,b) が box 内部を横切るとき、 迂回に挿入する box 角列 (a→b 順)。
-- Liang-Barsky で貫通区間を求め、 貫通長が 'boxCrossEps' 以下 (角の接触等) は無視。
-- 迂回側 (上辺経由 / 下辺経由 / 左右) は総距離が短い方を選ぶ。
crossCorners :: Box -> Point -> Point -> Maybe [Point]
crossCorners (Box xlo ylo xhi yhi) a@(Point ax ay) b@(Point bx by) =
  let dx = bx - ax; dy = by - ay
      -- Liang-Barsky clip
      clip p q (t0, t1)
        | abs p < 1e-12 = if q < 0 then Nothing else Just (t0, t1)
        | otherwise =
            let r = q / p
            in if p < 0
                 then if r > t1 then Nothing else Just (max t0 r, t1)
                 else if r < t0 then Nothing else Just (t0, min t1 r)
      mtt = pure (0, 1)
        >>= clip (-dx) (ax - xlo) >>= clip dx (xhi - ax)
        >>= clip (-dy) (ay - ylo) >>= clip dy (yhi - ay)
  in case mtt of
       Just (t0, t1)
         | (t1 - t0) * sqrt (dx * dx + dy * dy) > boxCrossEps ->
             let entry = Point (ax + dx * t0) (ay + dy * t0)
                 exit_ = Point (ax + dx * t1) (ay + dy * t1)
                 -- 周回 corner 列 (時計回り): TL → TR → BR → BL。
                 -- ★ corner は box 外側へ 'cornerClear' だけ斜めに逃がす: box 辺
                 -- barrier 上に waypoint が正確に乗ると丸め fit が全て barrier 判定で
                 -- 棄却され forceflag 直角に縮退する (実測: junction 89.9°/45°)。
                 c = cornerClear
                 tl = Point (xlo - c) (ylo - c); tr = Point (xhi + c) (ylo - c)
                 br = Point (xhi + c) (yhi + c); bl = Point (xlo - c) (yhi + c)
                 ring = [tl, tr, br, bl]
                 -- entry/exit が乗る辺 index (TL-TR=0, TR-BR=1, BR-BL=2, BL-TL=3)
                 edgeOf (Point x y) =
                   snd $ minimum
                     [ (abs (y - ylo), 0 :: Int), (abs (x - xhi), 1)
                     , (abs (y - yhi), 2), (abs (x - xlo), 3) ]
                 e0 = edgeOf entry; e1 = edgeOf exit_
                 -- 辺 e0 から e1 へ時計回り / 反時計回りに渡る corner 列
                 cw  = [ ring !! ((i + 1) `mod` 4) | i <- takeWhile (/= e1) (iterate ((`mod` 4) . (+ 1)) e0) ]
                 ccw = [ ring !! (i `mod` 4) | i <- takeWhile (/= e1) (iterate ((`mod` 4) . (+ 3)) e0) ]
                 plen ps = polyLen (entry : ps ++ [exit_])
             in if e0 == e1
                  then Nothing   -- 同一辺内の接触 (貫通ではない)
                  else Just (if plen cw <= plen ccw then cw else ccw)
       _ -> Nothing

-- | box 貫通とみなす最小貫通長 (pt)。 角の接触・境界沿いを除外する。
boxCrossEps :: Double
boxCrossEps = 2.0

-- | 迂回 corner waypoint を box から斜め外側へ逃がす量 (pt)。 spline の丸めが
-- box 辺 barrier に触れない余地を作る。
cornerClear :: Double
cornerClear = 2.0

-- | 近接 taut 点の畳み込み (端点は保持)。 corner 挿入 ('avoidBoxTaut') で旧 waypoint と
-- 角が 1pt 未満で並ぶ backtrack を掃除し、 spline fit の零長セグメント荒れを防ぐ。
dedupTaut :: Double -> [Point] -> [Point]
dedupTaut eps pts = case pts of
  []       -> []
  [_]      -> pts
  (p0 : rest) ->
    let lastP = last rest
        mids  = initSafe rest
        step acc q = if vdist (head acc) q >= eps then q : acc else acc
        kept  = reverse (foldl step [p0] mids)
        kept' = if length kept > 1 && vdist (last kept) lastP < eps
                  then initSafe kept else kept
    in kept' ++ [lastP]

-- (★ A7 試行メモ: box 4 辺を barrier に足す案は、 channel 壁 = box 辺ゆえ taut が
--  box 縁を沿走する正常区間まで「barrier 上のライド」 として fit 全棄却 →
--  forceflag 直角に縮退したため撤回。 貫通対策は 'avoidBoxTaut' の corner 挿入のみ。)

-- | portal 列から channel 境界の barrier 線分群を作る。 左鎖 (portal.left を上→下に連結)
-- と右鎖 (portal.right を連結) の各隣接ペア。 spline はこの内側に留まる。
channelBarriers :: [(Point, Point)] -> [(Point, Point)]
channelBarriers portals =
  let lefts  = map fst portals
      rights = map snd portals
      segs xs = filter (\(a, b) -> not (vequal a b)) (zip xs (drop 1 xs))
  in segs lefts ++ segs rights

-- | graphviz Proutespline 入口。 barriers (channel 境界線分) + taut 折れ線 (端点含む) +
-- 端点接線方向 (ev0=始点, ev1=終点・**単位ベクトル**) から cubic Bézier 制御点列を返す。
-- 戻り = [始点, c1, c2, 終点, c1, c2, 終点, ...] (= 先頭始点 + 3 点ずつの cubic segment)。
--
-- graphviz は endpoint slope を**呼出側 (dotsplines.c) が渡す**設計なので本 port も
-- ev0/ev1 を引数で受ける。 graphviz の @P->start.theta=-π/2 / P->end.theta=π/2 /
-- constrained@ は **内部の box-segment 境界** に適用される拘束で、 **実端点 (src/snk
-- port) の接線は port 方向 (斜め)** (一次実測: dot 14.1.5 gold は端点で斜め接線)。
-- 呼出側 (routeEdge A3.3) は taut の端 segment 方向 = 自然 port 方向を渡す。
-- (A3.1 で一時 rank 方向の垂直を渡したが、 これは narrow-portal 時代の symmetric V
--  taut への対症で、 A3.2 の y-sweep で taut が 4 点クリーン化した後は斜め近接 +
--  強制垂直の衝突で内側 S を生むため A3.3 で自然方向へ戻した。)
proutespline :: [(Point, Point)] -> [Point] -> Point -> Point -> [Point]
proutespline _ []  _   _   = []
proutespline _ [p] _   _   = [p]
proutespline barriers inps ev0 ev1 =
  head inps : reallyroutespline barriers inps (vnorm ev0) (vnorm ev1)

-- | route.c reallyroutespline。 1 本 fit を試み、 失敗なら最大偏差点で分割し再帰。
-- 戻り = 3 点ずつの cubic segment 列 (始点は含まない)。
reallyroutespline :: [(Point, Point)] -> [Point] -> Point -> Point -> [Point]
reallyroutespline barriers inps ev0 ev1 =
  let (pa, va, pb, vb) = mkspline inps ev0 ev1
  in case splinefits barriers pa va pb vb inps of
       Just cps -> cps
       Nothing  ->
         let spliti = maxDevIndex inps
             cip    = inps !! spliti
             v1     = vnorm (vsub cip (inps !! (spliti - 1)))
             v2     = vnorm (vsub (inps !! (spliti + 1)) cip)
             splitv = vnorm (vadd v1 v2)
         in reallyroutespline barriers (take (spliti + 1) inps) ev0 splitv
            ++ reallyroutespline barriers (drop spliti inps) splitv ev1

-- | route.c mkspline。 input 折れ線 + 端点単位方向 ev0/ev1 から、 端点接線の scale を
-- 最小二乗で解く。 戻り = (始点, 始点接線ベクトル, 終点, 終点接線ベクトル)。
mkspline :: [Point] -> Point -> Point -> (Point, Point, Point, Point)
mkspline inps ev0 ev1 =
  let p0  = head inps
      p3  = last inps
      -- 弦長パラメタ化 t ∈ [0,1]
      cum = scanl (+) 0 (zipWith vdist inps (drop 1 inps))
      tot = last cum
      ts  = if tot > 1e-12 then map (/ tot) cum else map (const 0) cum
      terms =
        [ (a0, a1, tmp)
        | (pt, t) <- zip inps ts
        , let a0  = vscale (b1 t) ev0
              a1  = vscale (negate (b2 t)) ev1
              tmp = vsub pt (vadd (vscale (b01 t) p0) (vscale (b23 t) p3)) ]
      c00 = sum [ vdot a0 a0  | (a0, _ , _ ) <- terms ]
      c01 = sum [ vdot a0 a1  | (a0, a1, _ ) <- terms ]
      c11 = sum [ vdot a1 a1  | (_ , a1, _ ) <- terms ]
      x0  = sum [ vdot a0 tmp | (a0, _ , tmp) <- terms ]
      x1  = sum [ vdot a1 tmp | (_ , a1, tmp) <- terms ]
      det01 = c00 * c11 - c01 * c01
      s0d = (x0 * c11 - x1 * c01) / det01     -- detX1/det01
      s3d = (c00 * x1 - c01 * x0) / det01     -- det0X/det01
      d01 = vdist p0 p3 / 3
      (s0, s3)
        | abs det01 < 1e-6 || s0d <= 0 || s3d <= 0 = (d01, d01)
        | otherwise                                 = (s0d, s3d)
  in (p0, vscale s0 ev0, p3, vscale s3 ev1)

-- | route.c splinefits。 mkspline の接線を a/3 倍 (a=4 から半減) しつつ control 点を作り、
-- channel 内に収まる最大 (= 滑らかな) ものを採用。 inpn==2 は強制採用 (forceflag)。
--
-- ★ Phase 52 A2 (2026-07-08): channel 内でも control polygon が taut 比
-- 'hairpinCap' 倍を超える候補は hairpin (接線暴走) として棄却する。
-- 真因 (A1 実測 = design/phase52-kink/): taut が 3 点 + 屈曲が終端寄りだと
-- 'mkspline' の最小二乗が厳密解に退化し接線 scale が爆発 (kink 辺 = taut 比
-- 2.18 倍超、 健全辺 ≤ ~1.3 倍)。 graphviz は box 列が taut を密に拘束するため
-- 顕在化しないが、 我々の channel は片側が graph bbox 端 (R1) まで開くことが
-- あり、 暴走 S 字が「channel 内」 と誤判定されていた。 棄却後は a 半減で
-- 平坦化 → それでも合わなければ従来どおり分割 (= graphviz と同じ収束先)。
splinefits :: [(Point, Point)] -> Point -> Point -> Point -> Point -> [Point] -> Maybe [Point]
splinefits barriers pa va pb vb inps = goA 4 True
  where
    forceflag = length inps == 2
    inLen     = polyLen inps
    goA a first =
      let s1  = vadd pa (vscale (a / 3) va)
          s2  = vsub pb (vscale (a / 3) vb)
          sps = [pa, s1, s2, pb]
      in if first && polyLen sps < inLen - 1e-3
           then Nothing                                  -- control polygon が短すぎ → 分割へ
           else if splineisinside barriers sps && polyLen sps <= hairpinCap * inLen
             then Just [s1, s2, pb]
             else if a < 0.005
               then if forceflag then Just [s1, s2, pb] else Nothing
               else goA (if a > 0.01 then a / 2 else 0) False

-- | Phase 52 A2: hairpin 判定の control polygon 長 / taut 長 の上限比。
-- A1 実測 (routes-before.csv): kink 5 辺 = 2.18〜2.6 倍 / 健全辺 ≤ ~1.3 倍。
hairpinCap :: Double
hairpinCap = 1.5

-- | inps[0]..inps[n-1] の chord (始点-終点) から最も離れた内部点の index。
maxDevIndex :: [Point] -> Int
maxDevIndex inps =
  let p0 = head inps
      pn = last inps
      n  = length inps
      ds = [ (distToSeg (inps !! i) p0 pn, i) | i <- [1 .. n - 2] ]
  in if null ds then 1 else snd (maximum ds)

-- | 点 p から線分 (a,b) への距離。
distToSeg :: Point -> Point -> Point -> Double
distToSeg p a b =
  let ab = vsub b a
      l2 = vdot ab ab
  in if l2 < 1e-12 then vdist p a
     else let t = max 0 (min 1 (vdot (vsub p a) ab / l2))
          in vdist p (vadd a (vscale t ab))

polyLen :: [Point] -> Double
polyLen ps = sum (zipWith vdist ps (drop 1 ps))

-- | route.c splineisinside。 cubic (sps=[P0,c1,c2,P3]) が barrier 線分のいずれかを
-- 内部交差すれば外 (False)。
splineisinside :: [(Point, Point)] -> [Point] -> Bool
splineisinside barriers sps = not (any crosses barriers)
  where
    crosses bar = case splineIntersectsLine sps bar of
      Left ()    -> False                                 -- 退化 (4) は continue (= 非交差扱い)
      Right roots -> any (\t -> t > 1e-3 && t < 1 - 1e-3) roots

-- | route.c splineintersectsline。 cubic (sps) と線分 lps の交差 t (spline 側) を返す。
-- Left () = 退化 (graphviz の rootn==4 = 直線が spline 上に乗る/解無限)。
splineIntersectsLine :: [Point] -> (Point, Point) -> Either () [Double]
splineIntersectsLine sps (lp0@(Point l0x l0y), lp1@(Point l1x l1y))
  | vequal lp0 lp1 = Right []                             -- 退化 barrier (点) は無視
  | xc1 == 0 && yc1 == 0 = Right []                       -- (到達しない・上で除外済)
  | xc1 == 0  =                                           -- 垂直線
      case solve3 (sub0 cx xc0) of
        Left ()    -> Left ()
        Right rs   -> Right [ t | t <- rs, t >= 0, t <= 1
                                , let sv = (evalCubic cy t - yc0) / yc1
                                , sv >= 0, sv <= 1 ]
  | otherwise =                                            -- 一般線
      let rat   = yc1 / xc1
          combo = ( c0y - rat * c0x + rat * xc0 - yc0
                  , c1y - rat * c1x
                  , c2y - rat * c2x
                  , c3y - rat * c3x )
      in case solve3 combo of
           Left ()  -> Left ()
           Right rs -> Right [ t | t <- rs, t >= 0, t <= 1
                                 , let sv = (evalCubic cx t - xc0) / xc1
                                 , sv >= 0, sv <= 1 ]
  where
    [Point p0x p0y, Point p1x p1y, Point p2x p2y, Point p3x p3y] = sps
    cx@(c0x, c1x, c2x, c3x) = points2coeff p0x p1x p2x p3x
    cy@(c0y, c1y, c2y, c3y) = points2coeff p0y p1y p2y p3y
    xc0 = l0x; xc1 = l1x - l0x
    yc0 = l0y; yc1 = l1y - l0y
    sub0 (a, b, c, d) k = (a - k, b, c, d)

-- | Bézier control 値 (1D) → power-basis 係数 (c0 + c1 t + c2 t² + c3 t³)。
points2coeff :: Double -> Double -> Double -> Double -> (Double, Double, Double, Double)
points2coeff p0 p1 p2 p3 =
  ( p0
  , 3 * (p1 - p0)
  , 3 * (p0 - 2 * p1 + p2)
  , p3 - 3 * p2 + 3 * p1 - p0 )

-- | power-basis cubic を t で評価。
evalCubic :: (Double, Double, Double, Double) -> Double -> Double
evalCubic (a, b, c, d) t = a + t * (b + t * (c + t * d))

-- | 実 cubic 求解 (solvers.c solve3 相当)。 戻り Right = 実根列、 Left () = 退化 (恒等0)。
-- 係数は power basis (c0 + c1 x + c2 x² + c3 x³)。
solve3 :: (Double, Double, Double, Double) -> Either () [Double]
solve3 (c0, c1, c2, c3)
  | abs c3 < tiny = solve2 (c0, c1, c2)
  | otherwise =
      let a = c2 / c3; b = c1 / c3; c = c0 / c3
          p = b - a * a / 3
          q = 2 * a * a * a / 27 - a * b / 3 + c
          shift = - a / 3
          disc = q * q / 4 + p * p * p / 27
      in Right $ map (+ shift) $
         if disc > tiny
           then [ cbrt (- q / 2 + sqrt disc) + cbrt (- q / 2 - sqrt disc) ]
           else if disc < - tiny
             then let m = 2 * sqrt (- p / 3)
                      th = acos (clampU ((3 * q) / (p * m))) / 3
                  in [ m * cos (th - 2 * pi * fromIntegral k / 3) | k <- [0, 1, 2 :: Int] ]
             else let u = cbrt (- q / 2) in [2 * u, - u]
  where tiny = 1e-12

solve2 :: (Double, Double, Double) -> Either () [Double]
solve2 (c0, c1, c2)
  | abs c2 < tiny = solve1 (c0, c1)
  | otherwise =
      let disc = c1 * c1 - 4 * c2 * c0
      in if disc < - tiny then Right []
         else if disc < tiny then Right [ - c1 / (2 * c2) ]
         else let s = sqrt disc in Right [ (- c1 + s) / (2 * c2), (- c1 - s) / (2 * c2) ]
  where tiny = 1e-12

solve1 :: (Double, Double) -> Either () [Double]
solve1 (c0, c1)
  | abs c1 < tiny = if abs c0 < tiny then Left () else Right []
  | otherwise     = Right [ - c0 / c1 ]
  where tiny = 1e-12

cbrt :: Double -> Double
cbrt x = signum x * (abs x ** (1 / 3))

clampU :: Double -> Double
clampU = max (-1) . min 1

-- | Bernstein 基底 (b01 = B0+B1, b23 = B2+B3)。 mkspline 用。
b1, b2, b01, b23 :: Double -> Double
b1 t  = 3 * t * (1 - t) * (1 - t)
b2 t  = 3 * t * t * (1 - t)
b01 t = (1 - t) ** 3 + b1 t
b23 t = b2 t + t ** 3

-- ===========================================================================
-- Phase 52 A6: port 分散 (同一 node の近接重複 port を境界に沿って扇状に)
-- ===========================================================================

-- | route 端点の種別 (発 = 始点 / 着 = 終点)。
data PortEnd = SrcEnd | SnkEnd deriving (Eq, Show)

-- | Phase 52 A6: 同一 node を共有する複数 edge の port が近接重複 ('portClusterEps'
-- 以内) するとき、 node 境界に沿って 'portSep' 間隔の扇状に分散する post-pass。
-- graphviz P6 sameports 段 (correspondence doc Step 5、 未実装) の実用版。
-- route 全体は動かさず**端点 + 隣接制御点を同 delta 平行移動**するだけなので
-- 曲線形状は保たれる (delta は数 pt)。 bake ('dagBakeRoutes') と live
-- ('renderDAGStandalone') の両 pipeline が同順で呼ぶ (= HS/PS parity 維持)。
spreadPorts
  :: (Double -> Double -> Point) -> Double
  -> [(DAGNode, DAGNode)]   -- ^ 各 route の (from, to)。 routes と同順
  -> [EdgeRoute] -> [EdgeRoute]
spreadPorts toScreen radius ends routes =
  let idx = zip [0 :: Int ..] (zip ends routes)
      occs = concat
        [ [ (dnId f, SrcEnd, i, f), (dnId t, SnkEnd, i, t) ]
        | (i, ((f, t), _)) <- idx ]
      nids = dedupTexts [ nid | (nid, _, _, _) <- occs ]
      deltas = concatMap nodeDeltas nids
      applyAll r i =
        foldl (\acc (j, e, d) -> if j == i then adjustEnd e d acc else acc) r deltas
  in [ applyAll r i | (i, (_, r)) <- idx ]
  where
    routeOf i = routes !! i
    -- node ごとの port cluster を検出し、 各 member の (routeIx, End, delta) を返す
    nodeDeltas nid =
      let members =
            [ (i, end, n)
            | (i, (f, t)) <- zip [0 :: Int ..] ends
            , (end, n) <- [(SrcEnd, f), (SnkEnd, t)]
            , dnId n == nid ]
      in case members of
           ((_, _, n0) : _) | length members >= 2 ->
             let center = toScreen (dnX n0) (dnY n0)
                 withGeo =
                   [ (i, end, n, port, ang (vsub adj center))
                   | (i, end, n) <- members
                   , let r = routeOf i
                   , let port = endPoint end r
                   , let adj  = adjPoint end r ]
                 -- port 角で整列 → 近接 (境界弧距離 < portClusterEps) を cluster 化
                 sorted = sortOnD (\(_, _, _, p, _) -> ang (vsub p center)) withGeo
                 clusters = clusterBy
                   (\(_, _, _, p1, _) (_, _, _, p2, _) -> vdist p1 p2 < portClusterEps)
                   sorted
             in concatMap (fanCluster center) clusters
           _ -> []
    fanCluster center cl
      | length cl < 2 = []
      | otherwise =
          let k = length cl
              -- 隣接点方向の角度順に並べ、 扇の並びと route の向きを一致させる
              ordered = sortOnD (\(_, _, _, _, aAdj) -> aAdj) cl
              rEff = maximum (1 : [ vdist p center | (_, _, _, p, _) <- cl ])
              dTh  = portSep / rEff
              phi0 = ang (vsub (avgP [ p | (_, _, _, p, _) <- cl ]) center)
          in [ (i, end, vsub newPort port)
             | (j, (i, end, n, port, _)) <- zip [0 :: Int ..] ordered
             , let phi = phi0 + (fromIntegral j - fromIntegral (k - 1) / 2) * dTh
             , let target = vadd center (Point (100 * cos phi) (100 * sin phi))
             , let newPort = edgePortPoint n center target radius ]
    endPoint SrcEnd r = case samplePts r of (p : _) -> p; [] -> Point 0 0
    endPoint SnkEnd r = case reverse (samplePts r) of (p : _) -> p; [] -> Point 0 0
    -- 扇の並び順に使う「進入回廊」 点 = 端から弧長 'portBackDist' 手前の経路点。
    -- port 直近の制御点 (局所接線) だと近接方向の edge 対で順序が逆転し、 分散後に
    -- 経路がクロスする (実測: corr6 chd 着の genetics/exercise が接線角 0.03rad 差で
    -- 逆順 → 交差)。 回廊は経路サンプル折れ線を端から遡って取る。
    adjPoint end r =
      let poly = case end of
            SrcEnd -> samplePts r
            SnkEnd -> reverse (samplePts r)
      in walkBack portBackDist poly
    walkBack _ []  = Point 0 0
    walkBack _ [p] = p
    walkBack budget (p : q : rest)
      | d >= budget = q
      | otherwise   = walkBack (budget - d) (q : rest)
      where d = vdist p q
    -- route を折れ線近似 (cubic は各セグメント 8 分割)
    samplePts r = case r of
      StraightArrow a b -> [a, b]
      SplinePath ps     -> ps
      BezierPath ps     -> ps
      CubicPath (p0 : rest) -> p0 : concat
        [ [ bezPt a c1 c2 b (fromIntegral k / 8) | k <- [1 .. 8 :: Int] ]
        | (a, c1, c2, b) <- segsOf p0 rest ]
      CubicPath []      -> []
    segsOf cur (c1 : c2 : p3 : more) = (cur, c1, c2, p3) : segsOf p3 more
    segsOf _ _ = []
    bezPt (Point x0 y0) (Point x1 y1) (Point x2 y2) (Point x3 y3) t =
      let mt = 1 - t
          f a b c d' = mt*mt*mt*a + 3*mt*mt*t*b + 3*mt*t*t*c + t*t*t*d'
      in Point (f x0 x1 x2 x3) (f y0 y1 y2 y3)
    avgP ps = let n = fromIntegral (length ps)
              in Point (sum [ x | Point x _ <- ps ] / n) (sum [ y | Point _ y <- ps ] / n)
    ang (Point x y) = atan2 y x

-- | route の端点 (+cubic は隣接制御点も) を delta 平行移動する。
adjustEnd :: PortEnd -> Point -> EdgeRoute -> EdgeRoute
adjustEnd end d r = case (end, r) of
  (SrcEnd, StraightArrow a b)      -> StraightArrow (vadd a d) b
  (SnkEnd, StraightArrow a b)      -> StraightArrow a (vadd b d)
  (SrcEnd, SplinePath (p : rest))  -> SplinePath (vadd p d : rest)
  (SnkEnd, SplinePath ps)          -> SplinePath (mapLast (`vadd` d) ps)
  (SrcEnd, BezierPath (p : rest))  -> BezierPath (vadd p d : rest)
  (SnkEnd, BezierPath ps)          -> BezierPath (mapLast (`vadd` d) ps)
  (SrcEnd, CubicPath (p0 : c1 : rest)) -> CubicPath (vadd p0 d : vadd c1 d : rest)
  (SnkEnd, CubicPath ps)           -> CubicPath (mapLast2 (`vadd` d) ps)
  _                                -> r
  where
    mapLast f xs = case reverse xs of
      (z : zs) -> reverse (f z : zs)
      []       -> xs
    mapLast2 f xs = case reverse xs of
      (z : y : zs) -> reverse (f z : f y : zs)
      [z]          -> [f z]
      []           -> xs

-- | port cluster 判定の近接閾値 (pt)。 これ未満の port 対は「重なって見える」。
portClusterEps :: Double
portClusterEps = 4.0

-- | 分散後の port 間隔 (境界弧距離、 pt)。 矢印幅 (~8pt) が重ならない程度。
portSep :: Double
portSep = 7.0

-- | 扇順序の「進入回廊」 を測る端からの弧長 (pt)。 局所接線より遠くで測ることで
-- 近接方向 edge 対の順序逆転 (= 分散後クロス) を防ぐ。
portBackDist :: Double
portBackDist = 20.0

-- | 整列済みリストを隣接述語で連結 cluster に分割する。
clusterBy :: (a -> a -> Bool) -> [a] -> [[a]]
clusterBy _ [] = []
clusterBy eq (x : xs) = go [x] xs
  where
    go acc [] = [reverse acc]
    go acc@(prev : _) (y : ys)
      | eq prev y = go (y : acc) ys
      | otherwise = reverse acc : go [y] ys
    go [] _ = []

-- | 挿入ソート (射影キー・小規模用)。
sortOnD :: Ord b => (a -> b) -> [a] -> [a]
sortOnD f = foldr ins []
  where
    ins x [] = [x]
    ins x (z : zs) | f x <= f z = x : z : zs
                   | otherwise  = z : ins x zs

-- | Text の重複除去 (順序保持・小規模用)。
dedupTexts :: [Text] -> [Text]
dedupTexts = go []
  where
    go _ [] = []
    go seen (x : xs) | x `elem` seen = go seen xs
                     | otherwise     = x : go (x : seen) xs

-- | Phase 1 A7: edge と node 形状の正確な交点を返す (= 矢印 port)。
-- 'nodeAt' = node 中心 (screen 座標)、 'target' = edge 反対側 (= 方向決定用)、
-- 'baseR' = node の size scale。 楕円 / 矩形いずれも中心から target 方向へ伸ばし、
-- 形状境界との交点を解析的に計算。
edgePortPoint :: DAGNode -> Point -> Point -> Double -> Point
edgePortPoint n (Point cx cy) (Point tx ty) baseR =
  let (rx, ry) = nodeExtent n baseR   -- ★A15-1: renderNode と同じ可変サイズを共有
      dx = tx - cx
      dy = ty - cy
      len = sqrt (dx * dx + dy * dy)
      (ux, uy) = if len > 1e-12 then (dx / len, dy / len) else (1, 0)
      t = case dnKind n of
        NodeLatent        -> ellipseT
        NodeObserved      -> ellipseT
        NodeDeterministic -> rectT
        NodeData          -> rectT
        NodeOther         -> rectT
      -- 楕円 (x/rx)^2 + (y/ry)^2 = 1 と方向 (ux, uy) の交点パラメタ
      ellipseT =
        let a = (ux / rx) ^ (2 :: Int) + (uy / ry) ^ (2 :: Int)
        in if a > 0 then 1 / sqrt a else baseR
      -- 矩形 |x| ≤ rx, |y| ≤ ry と方向の交点 (= 軸方向最小値)
      rectT =
        let txT = if abs ux > 1e-12 then rx / abs ux else 1 / 0
            tyT = if abs uy > 1e-12 then ry / abs uy else 1 / 0
        in min txT tyT
  in Point (cx + ux * t) (cy + uy * t)

-- | DAG ノードの半径 (rx, ry) を label 文字幅に合わせて算出 (Phase 52.A15-1)。
-- 'renderNode' と 'edgePortPoint' が共有し、 形状端と edge port を一致させる。
-- deterministic は dist sublabel を出さない (= 1 行)。 @baseR@ は最小サイズの下限。
--
-- Phase 39 P8 A4-2: 横半幅 rx の本体 (radius 非依存部) は layout と共有する
-- 'dagNodeBaseHalfWidth' に一本化した。 ここでは render-time に既知の baseR
-- (= radius) を floor として被せるだけ。
nodeExtent :: DAGNode -> Double -> (Double, Double)
nodeExtent n baseR =
  let showDist = nodeShowsDist n
      rx       = max baseR (dagNodeBaseHalfWidth n)
      nLines   = if showDist then 3 else 1 :: Int
      lineH    = dagLabelFs + 3
      ry       = max (baseR * 0.7) (fromIntegral nLines * lineH / 2 + 4)
  in (rx, ry)

-- | dist sublabel (@~ Dist@) を描くか。 deterministic は派生量ゆえ分布を持たず name のみ (PyMC 慣例)。
nodeShowsDist :: DAGNode -> Bool
nodeShowsDist n = case dnKind n of
  NodeDeterministic -> False
  _                 -> case dnDist n of Just _ -> True; Nothing -> False

-- | Phase 39 A2-8 / A4 (nested): plate 枠の **実 bbox (pt 空間)** = (xlo, boxTop, xhi, yhi)。
-- label 帯を含む描画矩形そのもの。 'renderPlate' (描画) と pt 空間 edge router
-- (障害物判定) が共有する。 member が 1 つも無ければ Nothing。
--
-- A4 (nested plate): graphviz の cluster bbox 計算 (= 子 cluster box ∪ 直接 member
-- glyph box を union し、 自身の margin を 1 段ぶん足す) を **再帰** で忠実再現する。
-- これにより nested plate の親枠が子枠の外側 margin (graphviz @CL_OFFSET@ 相当) に出る
-- (= 旧実装は親も子も member 極値から flat margin で再計算し境界が一致していた)。
-- leaf plate (子無し) は @directIds = 全 member@ ・ @childBoxes = []@ で従来と完全同一
-- (= 図ビット不変)。 自身の直接子は 'plateChildrenOf' で 'allPlates' の包含関係から復元。
plateBoxPt :: (Double -> Double -> Point) -> Double
           -> [(Text, DAGNode)] -> [DAGPlate] -> DAGPlate
           -> Maybe (Double, Double, Double, Double)
plateBoxPt toScreen radius nodeMap allPlates plate =
  let children   = plateChildrenOf allPlates plate
      childIds   = concatMap dpNodeIds children
      directIds  = [ i | i <- dpNodeIds plate, i `notElem` childIds ]
      included   = mapMaybe (\i -> lookup i nodeMap) directIds
      -- 直接 member の glyph box (= left, right, top, bottom)
      memberBoxes = [ (x - gx, x + gx, y - gy, y + gy)
                    | n <- included
                    , let Point x y = toScreen (dnX n) (dnY n)
                    , let (gx, gy) = nodeExtent n radius ]
      -- 子 plate の box (= 既に各自の margin + label 帯込み)。 (xlo, boxTop, xhi, yhi)
      -- を本関数 tuple 規約 (left, right, top, bottom) に並べ替えて union 対象に混ぜる。
      childBoxes  = [ (xlo, xhi, boxTop, yhi)
                    | c <- children
                    , Just (xlo, boxTop, xhi, yhi)
                        <- [plateBoxPt toScreen radius nodeMap allPlates c] ]
      boxes = memberBoxes ++ childBoxes
  in case boxes of
       [] -> Nothing
       _  ->
         let margin = radius * 0.5
             labelH = 14
             xlo = minimum [a | (a, _, _, _) <- boxes] - margin
             ylo = minimum [b | (_, _, b, _) <- boxes] - margin
             xhi = maximum [a | (_, a, _, _) <- boxes] + margin
             yhi = maximum [b | (_, _, _, b) <- boxes] + margin
         -- label 帯は枠の **下** に確保する (graphviz labelloc=b 同型)。 box 下端を
         -- labelH ぶん広げ、 box 上端は member の margin のみ。
         in Just (xlo, ylo, xhi, yhi + labelH)

-- | A4: plate 'parent' の **直接の子** plate (= graphviz subcluster) 群。
-- 子 = nodeIds が parent の真部分集合で、 間に別の plate を挟まない (= immediate) もの。
-- graphviz は cluster をネスト木として保持するが、 我々は plate list の包含関係から
-- 復元する (= 内側 plate の member ⊊ 外側 plate の member、 という運用前提)。
plateChildrenOf :: [DAGPlate] -> DAGPlate -> [DAGPlate]
plateChildrenOf allPlates parent =
  let strictSub q p =
        let qs = dpNodeIds q; ps = dpNodeIds p
        in all (`elem` ps) qs && not (all (`elem` qs) ps)
      cands = [ q | q <- allPlates, strictSub q parent ]
      immediate q = not (any (\r -> strictSub q r && strictSub r parent) cands)
  in filter immediate cands

-- (routeAroundBoxes は Phase 52 A5 で削除: Phase 39 A2-8 の遺物で caller ゼロ、
--  channel/funnel + Proutespline (A-2/A-3/R3) に置換済。 実装は git 履歴参照。)