课程 22 - VectorNetwork
在这节课中你将学习到以下内容:
- SVG Path 的局限性
- 什么是 VectorNetwork?
- 使用 Pen 工具修改 Path
- 双击进入 Vector 编辑态与 Move / Bend / Cut 工具
- 拓扑算子:分裂边、删除顶点、Cut 断开闭合环
SVG Path 的局限性
在 课程 13 - 绘制 Path & 手绘风格 中我们学习了 Path 的绘制方式。Figma 也提供了 VectorPath API,它支持 SVG Path 的路径命令子集(详见:VectorPath-data)和 fillRule(Figma 中称作 windingRule)。
node.vectorPaths = [
{
windingRule: 'EVENODD',
data: 'M 0 100 L 100 100 L 50 0 Z',
},
];那为什么还要引入 VectorNetwork API 呢?原因在于 SVG Path 存在一些天然的局限性。The Engineering behind Figma's Vector Networks 一文很直观地展示了这一点。下面的图形是无法仅仅使用一个 Path 描述的:
只能通过拆分成多个 Path 描述,虽然可行,但在编辑场景下无法实现某些很符合直觉的操作。例如拖动左下的中心顶点时,只有一个顶点会跟随,因为它由两个独立的 Path 组成:
除了顶点无法拥有超过 2 条边,边也无法共享。Vector Graphics Complexes 的原始论文和 PPT 中对比了 SVG 和 Planar maps,两者都无法支持重叠、共享顶点和边这些特性,这才引出了一种新的几何表达(下文简称为 VGC):

vpaint 就是基于 VGC 实现的,可以看到完成合并点和边的操作之后,编辑中的联动效果是多么自然:
或者使用 The Engineering behind Figma's Vector Networks 一文中拖拽立方体一条边的例子:
鼠标双击进入编辑,可以拖拽立方体的任意一条边:
值得一提的是,Discussion in HN 中提到了 VGC 和 Figma 的 VectorNetwork 之间奇妙的相似程度,考虑到两者几乎处于同一时期开始探索,在某种程度上算殊途同归,因此下文就使用 VectorNetwork 这一名词了。
CEO of Figma here. Most of the original insights around vector networks were in 2013, though we continued to polish the implementation over time. We didn't exit stealth and ship the closed beta of Figma until December 2015 which is why there isn't blog content before then. At first glance, this thesis looks super neat! I'm excited to check it out! I don't believe I've seen it before which is surprising given the overlap.
下面我们来看 VectorNetwork 是如何定义的。
VectorNetwork 的拓扑定义
VectorNetwork / VGC 的定义相比 Path 路径要复杂得多,其数据结构是一个图,由顶点、边和面(填充区域)组成,下图来自 Vector Graphics Complexes 原始论文。它不需要特定的方向或闭合于起始点,允许多个路径在同一对象内向任何方向分支,这使得创建复杂形状更加迅速高效。

这里仅讨论拓扑定义,其他绘图属性和 Path 可以保持一致:
On top of this core structure, more drawing attributes can be added for fine control on rendering. For instance, we added vertex radius, variable edge width, cell color (possibly transparent), and edge junctions style (mitre join or bevel join).
顶点很好理解,在 VGC 中的边由一组 start 和 end 的顶点索引组成,两者重合时为自环。

而填充区域由一组顶点组成的闭合环路定义。在 VGC 中使用一组 halfedge 定义:

下面三角形的例子来自 VectorNetwork API,可以看到和 VGC 基本一致,只是填充区域由顶点索引和 fillRule 定义。其他非几何定义属性例如 strokeCap 和 Path 保持一致:
node.vectorNetwork = {
// The vertices of the triangle
vertices: [
{ x: 0, y: 100 },
{ x: 100, y: 100 },
{ x: 50, y: 0 },
],
// The edges of the triangle. 'start' and 'end' refer to indices in the vertices array.
segments: [
{
start: 0,
tangentStart: { x: 0, y: 0 }, // optional
end: 1,
tangentEnd: { x: 0, y: 0 }, // optional
},
{
start: 1,
end: 2,
},
{
start: 2,
end: 0,
},
],
// The loop that forms the triangle. Each loop is a
// sequence of indices into the segments array.
regions: [{ windingRule: 'NONZERO', loops: [[0, 1, 2]] }],
};按 Figma 约定用三次贝塞尔 —— (P_0=) 起点,(P_3=) 终点,(P_1=P_0+) tangentStart,(P_2=P_3+) tangentEnd;直线(两端控制点与锚点重合)用 2 点;否则用 CubicBezierCurve.getPoints,分段数由弦长与控制多边形长度估算(8 ~ 64)
在编辑场景下,顶点和边由用户定义,而填充区域需要系统自动计算。那如何找到这些填充区域呢?
Filling
在 click to fill 这样的操作中,需要找到顶点组成的最小环路。

我们把 VectorNetwork 看作平面图,每条 segment 拆成两条有向半边(half-edge)。在每个顶点处把出边按极角排序,沿着「下一条半边」(相对于反向边最靠近顺时针方向的那条出边)遍历就能枚举出所有最小面(face)。包含点击位置且面积最小的那个面即为目标填充区域,把它的有序 segment 下标序列写入 VectorRegion.loops 即可复用上文的填充三角化。
export function findRegionLoopAtPoint(
vertices: VectorVertexLike[],
segments: VectorSegmentLike[],
point: [number, number],
): number[] | null;数值稳健性:共线、重合顶点与自环都需要 EPS 容差与退化处理;外侧无界面(outer face)在该遍历下有符号面积为正,需要跳过。
转换方法
参考 figma-fill-rule-editor,我们给出如下类型定义:
export class VectorNetwork {
@field.object declare vertices: VectorVertex[];
@field.object declare segments: VectorSegment[];
@field.object declare regions?: VectorRegion[];
}
interface VectorVertex {
x: number;
y: number;
strokeLinecap?: Stroke['linecap'];
strokeLinejoin?: Stroke['linejoin'];
cornerRadius?: number;
handleMirroring?: HandleMirroring;
}
interface VectorSegment {
start: number;
end: number;
tangentStart?: VectorVertex;
tangentEnd?: VectorVertex;
}
interface VectorRegion {
fillRule: CanvasFillRule;
loops: ReadonlyArray<ReadonlyArray<number>>;
}Polyline 是最容易转换成 VectorNetwork 的图形:
class VectorNetwork {
static fromEntity(entity: Entity): VectorNetwork {
if (entity.has(Polyline)) {
const { points } = entity.read(Polyline);
const vertices: VectorVertex[] = points.map(([x, y]) => ({ x, y }));
const segments: VectorSegment[] = points.slice(1).map((_, i) => ({
start: i,
end: i + 1,
}));
return { vertices, segments };
}
}
}[Path] 的转换更复杂一些,需要把 SVG path 命令规范化(path2Absolute)后逐段解析:M/L/H/V 生成直线 segment;C/S/Q/T 生成 cubic(Q/T 先升阶为三次),并按 Figma 约定把绝对控制点换算成相对切线 tangentStart = P1 - P0、tangentEnd = P2 - P3;S/T 需要维护上一段控制点做反射;Z 闭合时若末点与起点重合则复用起点顶点,避免重复,并为闭合子路径产出 region loop。该逻辑实现在纯函数 pathToVectorNetwork(d, fillRule) 中,fromEntity 在 entity.has(Path) 时调用它。
三角化
Stroke
我们需要将邻接边转换成折线后,使用 课程 12 - 绘制折线 中介绍的方法渲染。
- 为每个顶点维护邻接边
- 在未使用的边上迭代,从一条边出发先向前、再向后延伸,仅在「当前顶点只剩一条未使用边」时继续,从而在 degree 为 2 的顶点合并为一条折线(用 join 代替 cap)
- 分叉处 (degree ≥ 3) 停止,子路径之间用 NaN 分隔
对于每一条邻接边:
- 按 Figma 约定用三次贝塞尔,它的
P_0就是起点,P_3就是终点,P_1 = P_0 + tangentStart,P_2 = P_3 + tangentEnd - 直线(两端控制点与锚点重合)用 2 点
- 否则用 CubicBezierCurve.getPoints,分段数由弦长与控制多边形长度估算
function tessellateVectorSegment(
vertices: VectorVertexLike[],
seg: VectorSegmentLike,
): number[] {
const a = vertices[seg.start];
const b = vertices[seg.end];
const p0 = vec2.fromValues(a.x, a.y);
const p3 = vec2.fromValues(b.x, b.y);
const ts = seg.tangentStart;
const te = seg.tangentEnd;
const p1 = vec2.create();
const p2 = vec2.create();
vec2.add(p1, p0, vec2.fromValues(ts?.x ?? 0, ts?.y ?? 0));
vec2.add(p2, p3, vec2.fromValues(te?.x ?? 0, te?.y ?? 0));
}Fill
按 Figma 的 loops(有序 segment 下标)走一圈,用与描边相同的 tessellateVectorSegment 把每条边(含 cubic)细分,按拓扑方向拼接,去掉重复点并闭合。
- 对每个 region 的每个 loop 生成一条闭合轮廓
- nonzero(或 Figma 的 windingRule: 'NONZERO'):沿用 Mesh 里 Path 的 earcut + 孔洞 逻辑(isClockWise 区分外环/洞)
- evenodd(或 EVENODD):用 triangulate(libtess)
- 多个 region 依次三角化后,把顶点与索引拼到同一张 mesh 上(vOffset 累加)
Bending
下文来自 Introducing Vector Networks - Bending,对于贝塞尔曲线的编辑,在 Path 和 VectorNetwork 中都是通用的:
Vector graphics today are based on cubic bezier splines, which are curves with two extra points called control handles that are positioned away from the curve itself and that control how much it bends, sort of like how a magnet might bend a wire towards it. Changing the shape of a curve involves dragging a control handle off in space instead of dragging the curve directly.
在 VectorNetwork 的边定义中,使用 tangentStart 和 tangentEnd 可以定义三阶贝塞尔曲线的两个控制点,当两者为 [0, 0] 时退化为直线。
也可以在 Konva 的 How to modify line points with anchors? 在线例子或者 bezierjs 中体验。
双击进入编辑态、Move / Bend / Cut 工具条与 midpoint 插入等交互见下文 进入编辑态与工具条。

export enum Pen {
SELECT = 'select',
HAND = 'hand',
VECTOR_NETWORK = 'vector-network',
}有别于 课程 21 - Transformer 中基于 OBB 的实现:
- 拖拽 VectorSegment 和 OBB 一样,移动整个图形
- 拖拽 VectorVertex 只移动该顶点本身,所有共享它的 segment 自然联动——这是 Vector Network 相较 Path 的核心价值。拖拽产生的新坐标通过统一写回入口
API.updateNodeVectorNetwork(node, vectorNetwork)落到实体的VectorNetwork组件,并触发重新三角化与历史记录(undo/redo)。
// packages/ecs/src/systems/Select.ts
// 在 handleControlPointMoving 中,针对 vector-network 节点:
// 1. 读取 VectorNetwork 组件,用 GlobalTransform 的逆变换把指针坐标转回局部坐标
// 2. 更新 vertices[activeIndex].x/y
// 3. 调用 api.updateNodeVectorNetwork 写回写回时会复用 VectorNetwork.getGeometryBounds 重算几何包围盒,并把左上角归一化到局部 (0, 0)(顶点整体平移 -minX/-minY,平移量加到 node.x/y),从而保持 node.x == 几何左边 这一 Transformer resize 所依赖的不变量。
进入编辑态与工具条
参考 Figma 的 Edit vector layers,双击 vector-network 节点进入顶点编辑态:为实体添加 Editable.isEditing = true,并显示底部居中的 Move / Bend / Cut 工具条(VectorNetworkEditMode,见 context-vector-network-edit-bar.ts)。退出编辑(工具条关闭按钮、Esc 或点击画布空白)时写回 isEditing: false,RenderTransformer 会隐藏所有编辑锚点(顶点、线段 midpoint、切线手柄)。
| 模式 | 交互 |
|---|---|
| Move | 拖拽顶点;hover 线段显示 midpoint,点击插入新顶点 |
| Bend | 选中顶点后显示切线手柄,拖拽调整 tangentStart / tangentEnd |
| Cut | 与 Move 相同可在线段 midpoint 插入顶点;点击顶点在 cut 点断开拓扑并自动切回 Move,便于拖拽分离 |
锚点的 hover 高亮与选中分离:Transformable.hoveredControlPointIndex 随指针移开消失,selectedControlPointIndex 在点击后保持,直到点击空白或图形内部取消。
Move:线段 midpoint 插入顶点
hover 某条 segment 时在曲线中点(t = 0.5,cubic 边取曲线上的点)渲染 midpoint 锚点。点击后调用 splitSegmentAt(见 Creation & delete)分裂该边并写回 network。相关逻辑在 Select.insertControlPointFromMidpoint 与 RenderTransformer.findHoveredVectorNetworkSegmentIndex(viewport 空间到局部曲线的距离检测)。
Topological operators
Figma 支持 Boolean operations,例如 union
也许可以参考 Paper.js 的实现。
Creation & delete
Delete and Heal for Vector Networks
新增顶点:在某条 segment 的参数 t 处把它分裂成两段并插入新顶点(cubic 边用 de Casteljau 细分以保持曲线形状),而不是简单地往 points 数组里 splice:
export function splitSegmentAt(
network: VectorNetworkData,
segIdx: number,
t: number,
): VectorNetworkData;删除顶点:移除该顶点及其关联边后,对 degree==2 的相邻顶点执行「heal」——把它的两条边合并为一条,从而保持路径连通(对齐 Figma 的 Delete and Heal)。编辑态下按 Delete / Backspace 触发:
export function deleteVertex(
network: VectorNetworkData,
vertexIdx: number,
): VectorNetworkData;上述算子均为纯函数(位于
packages/ecs/src/utils/vector-network-topology.ts),输入输出都是{ vertices, segments, regions },方便单测且与渲染解耦;编辑系统拿到结果后再通过API.updateNodeVectorNetwork统一写回。
Glue & unglue

Cut & uncut

Cut 在 cut 顶点处断开拓扑(不是删掉对边)。闭合环上保留 cut 点上的两条 incident 边,复制闭合端点并改写闭合 segment,使路径在该点打开。以三角形 0—1—2—0 在顶点 1 处 Cut 为例:
Cut 前: 0 — 1 — 2 — 0(闭合)
Cut 后: 0 — 1 — 2 — 3(3 与 0 同位置,开口折线)
segments: [0,1], [1,2], [2,3]开口折线上则在 cut 点复制顶点,把除第一条外的 incident 边改连到副本,两条链可在 Move 模式下拖开。实现见 breakVertex:
export function breakVertex(
network: VectorNetworkData,
vertexIndex: number,
): VectorNetworkData | null;Cut 模式点击顶点后调用 breakVectorNetworkAtVertex(Select.ts),写回 network、记录历史,并 setAppState({ vectorNetworkEditMode: MOVE }) 以便立刻拖拽。regions 在断开后丢弃,需重新 click-to-fill 或由后续 region 检测重建。