Viewport 视口数学

`Viewport` 是画布的平移与缩放状态,定义逻辑坐标(文档坐标)与屏幕坐标(像素坐标)之间的双向变换。它是一个纯数学结构,零 GPUI 依赖,所有渲染层在绘制前用它把逻辑坐标转屏幕坐标。

数据结构

pub struct Viewport {
    pub offset: PointF, // 屏幕空间下逻辑原点的位置(平移)
    pub scale: f32,     // 缩放因子(1.0 = 100%)
}

impl Default for Viewport {
    fn default() -> Self { Self { offset: PointF::zero(), scale: 1.0 } }
}

impl Viewport {
    pub const MIN_SCALE: f32 = 0.2;
    pub const MAX_SCALE: f32 = 3.0;
}

| 字段 | 含义 | 默认 | |------|------|------| | `offset` | 逻辑原点 (0,0) 在屏幕上的位置 | (0,0) | | `scale` | 1 逻辑单位对应多少屏幕像素 | 1.0 |

`offset` 不是「相机偏移」而是「逻辑原点的屏幕坐标」——这两个视角等价但前者更易推导变换公式。

双向变换

/// Logical → screen: screen = logical * scale + offset
pub fn to_screen(self, logical: PointF) -> PointF {
    PointF::new(
        logical.x * self.scale + self.offset.x,
        logical.y * self.scale + self.offset.y,
    )
}

/// Screen → logical: logical = (screen - offset) / scale
pub fn to_logical(self, screen: PointF) -> PointF {
    PointF::new(
        (screen.x - self.offset.x) / self.scale,
        (screen.y - self.offset.y) / self.scale,
    )
}

两者互逆:`to_logical(to_screen(p)) == p`。

逻辑空间                屏幕空间
 (0,0) ──────→  offset
                │
逻辑 (Lx,Ly) ──→ (Lx*scale+off.x, Ly*scale+off.y)

| 变换 | 公式 | 用途 | |------|------|------| | `to_screen` | `logical * scale + offset` | 绘制:逻辑坐标 → 屏幕坐标 | | `to_logical` | `(screen - offset) / scale` | 命中测试:鼠标屏幕坐标 → 逻辑坐标 |

渲染时所有节点/边坐标先 `to_screen` 转屏幕坐标再绘制;鼠标事件先把屏幕坐标 `to_logical` 转逻辑坐标再做命中测试——这使命中测试代码无需感知视口状态。

缩放限制

pub fn clamp_scale(scale: f32) -> f32 {
    scale.clamp(Self::MIN_SCALE, Self::MAX_SCALE)
}

| 常量 | 值 | 含义 | |------|-----|------| | `MIN_SCALE` | 0.2 | 最小缩放(缩到 20%) | | `MAX_SCALE` | 3.0 | 最大缩放(放到 300%) |

限制缩放范围避免:过度缩小导致节点小到无法点击、过度放大导致渲染开销爆炸或精度问题。`zoom_around` 与外部缩放调用都应通过 `clamp_scale` 收敛。

zoom_around:锚点缩放

直接改 `scale` 会让画面「跳」——当前鼠标下的点会跑掉。`zoom_around` 保持锚点(通常是鼠标位置)在屏幕上不动:

pub fn zoom_around(self, anchor_screen: PointF, new_scale: f32) -> Self {
    let new_scale = Self::clamp_scale(new_scale);
    // anchor_logical 在新旧视口下都应映射到 anchor_screen
    let anchor_logical = self.to_logical(anchor_screen);
    // anchor_screen = anchor_logical * new_scale + new_offset
    // => new_offset = anchor_screen - anchor_logical * new_scale
    let new_offset = PointF::new(
        anchor_screen.x - anchor_logical.x * new_scale,
        anchor_screen.y - anchor_logical.y * new_scale,
    );
    Self { offset: new_offset, scale: new_scale }
}

推导

锚点保持的约束是:锚点的逻辑坐标不变,且在新视口下仍映射到同一屏幕点。

已知:anchor_screen = old.to_logical⁻¹(anchor_logical)  (即 anchor_logical = old.to_logical(anchor_screen))
求:new_offset 使 new.to_screen(anchor_logical) == anchor_screen

new.to_screen(anchor_logical) = anchor_logical * new_scale + new_offset
                              = anchor_screen
=> new_offset = anchor_screen - anchor_logical * new_scale
flowchart LR
  M[鼠标屏幕坐标 anchor_screen] --> TL[old.to_logical]
  TL --> AL[anchor_logical 逻辑坐标]
  AL --> NO[new_offset = anchor_screen - anchor_logical*new_scale]
  NO --> NV[新 Viewport offset+scale]

效果

缩放前(鼠标在节点中心):     缩放后(放大 2x,锚点保持):
  ┌────────┐                  ┌────────────┐
  │ node   │ ● 鼠标           │            │
  │        │                  │   node     │ ● 鼠标(同位置)
  └────────┘                  │            │
                              └────────────┘

节点中心在屏幕上的位置不变,节点整体放大——这就是用户期望的「以鼠标为中心缩放」。

与渲染层的协作

gpui 层 `FlowEditorView` 持有 `viewport: Viewport`,渲染时逐元素变换:

节点逻辑坐标 (node.position)
  → viewport.to_screen(node.position)  → 屏幕坐标绘制
节点尺寸 (node.size)
  → node.size * viewport.scale         → 屏幕尺寸绘制

边的折线点序列同理:每个 `PointF` 先 `to_screen` 再连线路径。命中测试反向:鼠标 `screen` → `to_logical` → 用逻辑坐标查节点 `bounds().contains`。

平移的实现

平移只改 `offset`,不改 `scale`:

// 拖动 delta_screen 像素
viewport.offset = viewport.offset + delta_screen;

因为 `offset` 是「逻辑原点的屏幕位置」,屏幕空间拖多少像素,offset 就加多少——无需除以 scale。这与「相机平移」直觉一致。

何时持久化

`Viewport` 状态通常**不**写入 `FlowDocument`——它是视图状态而非流程数据。同一份流程文档在不同窗口/会话下可以有不同的视口。应用若需记忆视口(如恢复上次浏览位置),可单独持久化,与流程数据解耦。

小结

`Viewport = offset + scale`,`to_screen = logical*scale + offset`,`to_logical = (screen-offset)/scale`。缩放范围 clamp 在 `[0.2, 3.0]`。`zoom_around` 通过「锚点逻辑坐标在新视口下仍映射到同一屏幕点」推导 `new_offset`,实现以鼠标为中心的缩放。平移只改 `offset`,与 scale 无关。视口是视图状态,不入 `FlowDocument`。

上一节:[Dagre 布局引擎](dagre-layout.md) · 返回 [本章目录](INDEX.md)