Dagre 布局引擎

dagre 是 rust-agent-flow 的自动布局核心,基于 Sugiyama 分层布局算法。框架包装了 `dagre` crate(dagre.js 的完整 Rust 移植,与 ReactFlow 官方示例同源),并在其结果上叠加 9 步后处理管线以适配控制流节点的语义。

LayoutEngine 抽象

布局能力用 trait 抽象,便于未来替换实现:

pub enum LayoutDirection { Vertical, Horizontal }  // TB / LR

pub struct LayoutResult {
    pub positions: HashMap<NodeId, PointF>,
}

pub trait LayoutEngine: Send + Sync {
    fn layout(&self, graph: &FlowGraph, direction: LayoutDirection) -> LayoutResult;
}

`Send + Sync` 约束保证布局引擎可跨线程使用(虽然当前都是同步调用)。`LayoutResult` 只含位置,尺寸仍由 `node.size` 携带——布局引擎读尺寸但不修改它。

DagreLayout 配置

pub struct DagreLayout {
    nodesep: f64,  // 同层节点间距,默认 40
    ranksep: f64,  // 层间距,默认 80
}
let layout = DagreLayout::new()
    .with_nodesep(40.0)
    .with_ranksep(80.0);

layout 主流程

impl LayoutEngine for DagreLayout {
    fn layout(&self, graph, direction) -> LayoutResult {
        let rankdir = match direction { Vertical => TB, Horizontal => LR };
        // 1. 构建 dagre 图:节点用稳定索引字符串作 id
        // 2. 给边赋 weight/minlen 引导布局
        // 3. 调用 dagre layout
        // 4. 中心坐标转左上角坐标
        // 5. 9 步后处理
    }
}

节点映射

slotmap 键不能直接给 dagre(它要字符串 id),因此用枚举索引作稳定 id:

for (i, node) in graph.nodes().enumerate() {
    let key = i.to_string();
    id_map.insert(node.id, key.clone());
    g.set_node(key, NodeLabel { width, height, .. });
}

边的 weight/minlen 策略

这是适配控制流语义的关键。不同端口语义的边赋不同权重,引导 dagre 的破环与分层:

let (weight, minlen) = match edge.source_port.as_deref() {
    Some("loop_body") => (100, 1),  // 高权重,避免反转
    Some("done")      => (100, 2),  // 高权重 + minlen=2,强制 done 目标到第 2 层
    Some("loop_in")   => (1, 1),    // 低权重,dagre 倾向反转它破环
    _                 => (1, 1),
};

| 端口 | weight | minlen | 目的 | |------|--------|--------|------| | `loop_body` | 100 | 1 | 保持前向,循环体在 Loop 下一层 | | `done` | 100 | 2 | done 目标强制到第 2 层(循环体之下),防止挤入回环 U 形区域 | | `loop_in` | 1 | 1 | 低权重让 dagre 反转它破环,不干扰主流 | | 普通边 | 1 | 1 | 默认 |

dagre 用 NetworkSimplex ranker 求解分层,高权重边倾向不被反转,低权重边倾向被反转——这套权重让循环图被「正确」地破环:回环边反转,主流保持前向。

坐标转换

dagre 返回节点**中心**坐标,框架转成左上角(`Node.position` 的语义):

positions.insert(node_id, PointF::new(
    (x - label.width * 0.5) as f32,
    (y - label.height * 0.5) as f32,
));

9 步后处理管线

dagre 的原始结果对通用 DAG 友好,但对控制流语义(分支顺序、循环体收起、回环空间)不够精确。框架在 dagre 结果上叠加 9 步后处理,**顺序敏感**:

flowchart TB
  D[dagre layout] --> B1[1. reorder_branch_targets<br/>分支出口按 if_N/else 顺序对齐]
  B1 --> B2[2. align_linear_chain<br/>Kahn 拓扑排序拉直主线]
  B2 --> B3[3. align_main_flow<br/>跨合并点拉齐主流中位数]
  B3 --> B4[4. reserve_loop_back_edge_space<br/>回环走廊下方预留空间]
  B4 --> B5[5. align_loop_in_sources<br/>Loop 移到入源中位数]
  B5 --> B6[6. align_loop_done_target<br/>拉直 done 边]
  B6 --> B7[7. align_loop_body_target<br/>循环体摆 Loop 右侧纵向堆叠]
  B7 --> B8[8. avoid_body_group_collision<br/>碰撞节点推出 body 组]
  B8 --> B9[9. align_post_done_chain<br/>拉直 done 后续链]
  B9 --> R[LayoutResult]

| 步骤 | 模块 | 作用 | |------|------|------| | 1 | `branch` | Condition 的 `if_N`/`else` 出口按声明顺序重排,分支标签与目标位置对应 | | 2 | `linear` | 用 Kahn 拓扑排序拉直无分支主线,避免 dagre 偶发的交错 | | 3 | `main_flow` | 跨合并点拉齐主流节点到中位数中心线(补 `align_linear_chain` 在合并点断链的不足) | | 4 | `loop_layout` | Loop 底部以下的非 body/非 done 节点下移 `BACK_EDGE_RESERVE`,给 U 形回环留空间 | | 5 | `loop_layout` | Loop 节点移到 `loop_in` 入源的中位数 Y,回环边视觉对称 | | 6 | `loop_layout` | done 目标对齐到 Loop 的 done 端口高度,拉直 done 边 | | 7 | `loop_layout` | 循环体节点摆到 Loop 右侧,纵向堆叠(与下方回环路由配套) | | 8 | `loop_layout` | 检测非 body 节点与 body 组包围盒的重叠,右移碰撞节点到 body 组右侧 | | 9 | `loop_layout` | done 目标之后的直线链路对齐,避免后续节点错位 |

步骤 3(`align_main_flow`)必须在 `align_linear_chain`(步骤 2)之后——前者拉直简单链后,后者用已对齐节点的中位数把跨合并点的主流节点拉回同一中心线;必须在 `reserve_loop_back_edge_space`(步骤 4)之前——确保 Loop 及其后续节点在预留空间前已在主流线上。步骤 4 必须在 5 之前——回环空间预留位移源节点后,步骤 5 才能将 Loop 对齐到最终位置。步骤 5 必须在 6/7 之前——因为它会移动 Loop 节点位置,6/7 据新位置调整。步骤 8 在 body 摆位(步骤7)之后运行,用最终 body 位置计算碰撞检测。`loop_body_groups` 在管线开始前计算一次,供步骤 4/7/8 复用,避免重复 BFS。

Loop 区域三层空间模型

Loop 节点在画布上占用三层空间,由后处理管线的步骤 4-8 分别负责:

| 层 | 空间 | 负责步骤 | 说明 | |---|------|---------|------| | L1 | Loop 节点自身 | dagre 布局 + 步骤 5 | dagre 按 rank 放置,步骤 5 调 cross-axis 对齐入源 | | L2 | Body Group 区域 | 步骤 7 + 步骤 8 | 步骤 7 将 body 节点纵向堆叠在 Loop 右侧;步骤 8 检测并推开与 body 组包围盒重叠的其他节点 | | L3 | Back-edge 走廊 | 步骤 4 | Loop 底部以下区域,供 `loop_back_path` 的 U 形回环路由使用 |

横向布局示意图:

  ┌─────────┐ L1
  │  Loop   │═══════════┐ L2 (body group)
  └────┬────┘           │     ┌─────────┐
       │                ├════>│ Process │ (纵向堆叠)
       │                │     └────┬────┘
       │                │          │
  ═════╧════════════════╧══════════╧════ L3 (back-edge corridor)
  ←───────── loop_back_path U 形路由 ─────────→

**设计要点**: - L3 的走廊预留(步骤4)在 Loop 对齐(步骤5)之前运行,确保 Loop 对齐到位移后的源节点位置 - L2 的碰撞检测(步骤8)在 body 摆位(步骤7)之后运行,用最终 body 位置计算碰撞 - `done_target` 在步骤 4 中被排除,保护步骤 6 的 done 对齐不被破坏

模块稳定性分级

| 模块 | 稳定性 | 原因 | |------|--------|------| | `branch` | 稳定 | 纯算法,与端口语义弱耦合 | | `linear` | 稳定 | Kahn 拓扑排序,通用 | | `main_flow` | 稳定 | 中位数对齐,补 `linear` 在合并点断链的不足 | | `loop_layout` | 易变 | 紧耦合 Loop 端口语义与渲染层端口位置 |

`loop_layout` 单独隔离,使 Loop 布局策略调整不干扰稳定算法。常量如 `LOOP_NODE_MID_Y=40`(必须匹配 gpui 层 `node.size.h/2`,其中 `node.size.h = TITLE_H + BODY_H = 80`)在此模块内声明,因为 core 不能依赖 gpui。

relayout 集成

gpui 层 `FlowEditorView::relayout` 是布局入口:

fn relayout(&mut self) {
    self.sync_node_sizes();                    // 同步渲染尺寸到 node.size
    let dir = /* Horizontal => LR, Vertical => TB */;
    let result = DagreLayout::new().layout(&self.graph, dir);
    for (node_id, pos) in &result.positions {
        if let Some(node) = self.graph.node_mut(*node_id) {
            node.position = *pos;
        }
    }
    self.cached_body_groups = self.graph.loop_body_groups();  // 缓存循环体
    // ... 派生 cached_all_body_nodes / cached_hidden_nodes
}

**为什么 sync_node_sizes 必须先跑**:结构化节点(Condition)高度随条件项数量变化,但 `node.size.h` 可能在创建后未更新。dagre 用 `node.size` 算分层与间距,尺寸错误会导致层间距错乱、回环边边界计算错误。`sync_node_sizes` 在布局前同步真实渲染尺寸。

后处理的性能取舍

9 步后处理都是 O(V+E) 级别的局部调整,相对 dagre 本身的 NetworkSimplex 求解开销可忽略。它们只在 `relayout` 时跑一次,结果写入 `node.position` 后被渲染层缓存——拖拽、平移等交互不触发重排。

属性面板编辑结构化字段时,`update_node_size_if_changed` 单节点检查:只有尺寸真正变化(如增删条件项导致高度变)才触发 `relayout`,普通文本编辑不触发——这是性能优化的关键路径。

小结

`DagreLayout` 包装 dagre crate(Sugiyama 算法),用 weight/minlen 策略引导破环:`loop_body`/`done` 高权重保持前向,`loop_in` 低权重被反转。dagre 返回中心坐标需转左上角。9 步后处理管线(顺序敏感)适配控制流语义:分支对齐、主线拉直、主流跨合并点对齐、循环体右侧堆叠、body 组碰撞避让、回环走廊预留。`loop_layout` 模块单独隔离因其紧耦合 Loop 语义。`relayout` 前 `sync_node_sizes` 保证 dagre 用准确尺寸。

下一节:[Viewport 视口数学](viewport.md)