变换下的命中测试
isPointInPath 有两套重载:
isPointInPath(x, y, fillRule?):测 当前默认路径。isPointInPath(path, x, y, fillRule?):测给定Path2D。
(x, y) 是 当前用户空间 里的点。path 会再乘 当前 CTM。
如果你已经 setTransform(world),应当传入 同一用户空间 下的点(通常是 CSS 像素业务坐标),path 用局部坐标。
1. 推荐:点变到局部,测未变换 path
这样测试时不必改 CTM,也不会和绘制时的 setTransform 打架。
interface Pickable {
id: string;
path: Path2D;
world: DOMMatrix;
fillRule?: CanvasFillRule;
}
export function eventToBackingStore(
canvas: HTMLCanvasElement,
event: PointerEvent,
): { x: number; y: number } {
const rect = canvas.getBoundingClientRect();
return {
x: ((event.clientX - rect.left) / rect.width) * canvas.width,
y: ((event.clientY - rect.top) / rect.height) * canvas.height,
};
}
/**
* 业务坐标 = CSS 像素(假设绘制前 ctx.scale(dpr, dpr))。
* backing store 坐标先除以 dpr。
*/
export function backingToUser(
x: number,
y: number,
dpr: number,
): { x: number; y: number } {
return { x: x / dpr, y: y / dpr };
}
export function hitTest(
ctx: CanvasRenderingContext2D,
nodes: readonly Pickable[],
userX: number,
userY: number,
): string | undefined {
for (let i = nodes.length - 1; i >= 0; i -= 1) {
const node = nodes[i];
if (!node) continue;
const local = node.world.inverse().transformPoint(new DOMPoint(userX, userY));
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
const inside = ctx.isPointInPath(
node.path,
local.x,
local.y,
node.fillRule ?? "nonzero",
);
ctx.restore();
if (inside) {
return node.id;
}
}
return undefined;
}
从上到下(数组末尾 = 最上层)拾取。透明填充但 fill 过的 path,isPointInPath 仍为 true;只描边的环要用 isPointInStroke,并考虑 lineWidth 与 CTM 缩放对线宽的影响(线宽在用户空间,CTM 缩放会让命中环变厚)。
2. 另一种:不 inverse,靠当前 CTM
ctx.setTransform(node.world);
const hit = ctx.isPointInPath(node.path, userX, userY);
此时 (userX, userY) 必须与绘制时传入 fill 的用户空间一致。两种写法不要混。
3. 大量对象
isPointInPath 对复杂 path 不便宜。场景大于几百可拾取节点时:
- 先用世界空间 AABB 粗测(把 path 包围盒通过 world 变换)。
- 再精确
isPointInPath。 - 再大就上网格 / R 树。不要先上四叉树——AABB + 顺序扫描在 2D 编辑器规模通常够。
不要用全画布 getImageData 做拾取:taint、读回、CPU 光栅三连。
4. 拖拽
命中成功后,记录:
pointerId(多指)- 按下时的局部坐标
- 节点当时的
world
移动时用同一套 eventToBackingStore → backingToUser → inverse(world) 得到新的局部点,写回 local 平移。不要用 movementX 累加——它在浏览器缩放、指针锁定下会漂。
5. 失败形态
| 症状 | 原因 |
|---|---|
| 只有左上角能点中 | 忘了 DPR,用 client 坐标去比 backing path |
| 旋转后差一个角 | path 已是世界坐标,又乘了 world |
| 描边点不中 | 用了 isPointInPath 而不是 isPointInStroke |
| 下层挡住上层 | 没倒序遍历 |
权威资料
核对日期:2026-08-26