路径布尔运算
Canvas 2D 没有 Path2D.union/intersect/subtract。Path2D 在规范里基本是只写几何:你塞贝塞尔进去,光栅器和 isPointInPath 能用,但 读不回 verb 列表(没有稳定的 getPathData)。所谓布尔,要么是像素假布尔,要么在 JS/WASM 里对多边形做真布尔,再生成新 path。
1. 三种完全不同的「挖洞」
| 手段 | 结果类型 | 分辨率无关 | 可再命中为矢量 | 适用 |
|---|---|---|---|---|
同一 Path2D + fill(..., "evenodd") | 一次填充的视觉洞 | 是 | 是(仍是那条 path) | 环、甜甜圈、字假挖洞 |
clip / destination-out | 当前表面像素 | 否(已烘焙) | 否 | 遮罩、橡皮、一次性效果 |
多边形布尔 → 新 Path2D | 新矢量 | 是 | 是 | 合并形状、CAD 式编辑、导出 SVG |
前两个在 clip 与合成 已经够用。编辑器要「两个矩形 subtract 之后还是一个可拖对象」,必须走第三条。
evenodd 不是布尔:自交、重叠绕组会让洞「莫名填实」。要稳定环,外轮廓 CW、内孔 CCW(或反过来)+ nonzero,并保证孔是真正的子路径 closePath。
2. 像素假布尔(分辨率相关)
export function pixelSubtract(
ctx: OffscreenCanvasRenderingContext2D,
subject: () => void,
cutter: () => void,
): void {
subject();
ctx.save();
ctx.globalCompositeOperation = "destination-out";
cutter();
ctx.restore();
}
得到的是位图。放大相机后边缘阶梯,撤销只能存 bitmap。印章、笔刷、刮刮卡可以;设计工具的「图形布尔」不行。
clip(subject); fill(cutter) 也不是 subtract:clip 限制的是后续绘制区域,不会从 subject 几何里剪掉 cutter。
3. 真布尔:多边形 → 运算 → 回 Path2D
生产上把 Path2D 当终点,不当源。源用自己的几何:
export interface PolyPath {
/** 每条环:世界坐标,已闭合;[0] 外环,其后为孔 */
rings: { x: number; y: number }[][];
}
export type BooleanOp = "union" | "intersection" | "difference" | "xor";
export function polyToPath(poly: PolyPath): Path2D {
const path = new Path2D();
for (const ring of poly.rings) {
if (ring.length < 3) continue;
path.moveTo(ring[0]!.x, ring[0]!.y);
for (let i = 1; i < ring.length; i += 1) {
path.lineTo(ring[i]!.x, ring[i]!.y);
}
path.closePath();
}
return path;
}
运算不要自己写 Greiner–Hormann 第一版( degenerate 边会翻车)。用成熟实现:
- 多边形:
polygon-clipping(Martinez)、Clipper 的 WASM 端口 - 需要贝塞尔保持曲线:Paper.js
Path.unite/subtract,或 Skia PathKit(WASM)。输出再转Path2D/ SVGd
export function pathFromSvgD(d: string): Path2D {
return new Path2D(d);
}
new Path2D(svgPathString) 是把 已算完的 d 送进光栅器。布尔发生在库里,不在 ctx。
贝塞尔 → 多边形要 平坦化(按屏幕误差自适应切段)。切太粗,布尔缺口;切太细,点爆炸。按 pixelError / camera.scale 在 当前缩放 下切,存盘仍存原始曲线,不要存平坦化结果当文档。
4. 圆角矩形 minus 圆:不要先画进 canvas 再取像素
export function flattenEllipse(
cx: number,
cy: number,
rx: number,
ry: number,
segments: number,
): { x: number; y: number }[] {
const ring: { x: number; y: number }[] = [];
for (let i = 0; i < segments; i += 1) {
const t = (Math.PI * 2 * i) / segments;
ring.push({ x: cx + rx * Math.cos(t), y: cy + ry * Math.sin(t) });
}
return ring;
}
圆角矩形先在自己的 scene 里用直线+四分弧表示,布尔前一起 flatten。从 Path2D 反向提取在浏览器里没有可依赖 API。
5. 命中与填充规则
布尔结果常带孔。填充用 evenodd 或按库约定的绕数。命中:isPointInPath(path, x, y, "evenodd") 必须和 fill 同一 fillRule,否则点在洞里仍算命中。
描边布尔更脏(offset curve)。产品若只要「描边看起来像减过」,对 fill 结果 stroke,不要对原始两轮廓做 stroke 再布尔。
6. 适用 / 不适用
适用:图标合并、楼层平面墙体挖门、标注框合并。
不适用:每帧粒子之间布尔、笔刷实时布尔(用像素层)、指望 Path2D 自带 CSG。
7. 失败形态
| 症状 | 原因 |
|---|---|
| 挖洞失败、变成实心 | nonzero + 环方向相同,或没用 close |
| 放大后缺口 | 像素假布尔当矢量用 |
| 布尔后面包屑几千点 | flatten 按世界固定步长,没跟相机 |
| 命中点到洞里 | fill evenodd、hit 默认 nonzero |
| 无法导出 SVG | 只留下了 canvas 像素 |
权威资料
- Path2D(构造与 SVG
d,无布尔方法) - fill rule
- clip()
- Martinez / polygon-clipping、Skia PathKit、Paper.js boolean 为业界实现,不是 WHATWG 的一部分
核对日期:2026-08-26