feat(canvas): compute real boolean shape geometry with paper.js

Add a booleanShapePath engine that runs union/subtract/intersect/
exclude/flatten through paper.js and returns normalized result path
data plus per-operand stroke segments, so boolean results are real
vector paths instead of SVG mask approximations.

- Store the computed pathData on boolean groups and recompute it when
  the operation changes; flatten legacy groups into ordinary path nodes
- Render previews and SVG/PSD exports from the computed result path,
  keeping each operand's stroke style on the boundary it contributes
- Open boolean results in the path editor and flatten them on commit
- Add paper 0.12.18 with a paper-core type shim and mark it as a
  server-external package for the standalone Next build

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 00:23:19 +08:00
parent 7e9f8194d9
commit dd8c67af3e
11 changed files with 862 additions and 126 deletions
+217
View File
@@ -0,0 +1,217 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { booleanShapePathData, booleanShapePathResult } from "../src/ui/pages/CanvasWorkspace/canvas/booleanShapePath.ts";
import { booleanShapeGroupToPathNode, createBooleanShapeGroupNode, createBooleanShapeResultNode, parseBooleanShapeOptions, patchShapeStyleNode, updateBooleanShapeOperation } from "../src/ui/pages/CanvasWorkspace/canvas/shapeNode.ts";
const first = shapeNode("first", 0, 0, 100, 100);
const second = shapeNode("second", 50, 0, 100, 100);
const operands = [first, second].map((node) => ({
id: node.id,
title: node.title,
x: node.x,
y: node.y,
width: node.width,
height: node.height,
content: node.content,
fillColor: node.fillColor,
strokeColor: node.strokeColor,
strokeWidth: node.strokeWidth,
strokeStyle: node.strokeStyle,
opacity: node.opacity
}));
test("creates real result paths for every boolean operation", () => {
const union = booleanShapePathData(operands, "union", 150, 100);
const subtract = booleanShapePathData(operands, "subtract", 150, 100);
const intersect = booleanShapePathData(operands, "intersect", 150, 100);
const exclude = booleanShapePathData(operands, "exclude", 150, 100);
assert.deepEqual(pathBounds(union), { left: 0, top: 0, right: 1, bottom: 1 });
assertBounds(pathBounds(subtract), { left: 0, top: 0, right: 1 / 3, bottom: 1 });
assertBounds(pathBounds(intersect), { left: 1 / 3, top: 0, right: 2 / 3, bottom: 1 });
assert.deepEqual(pathBounds(exclude), { left: 0, top: 0, right: 1, bottom: 1 });
assert.equal(commandCount(union, 1), 1);
assert.equal(commandCount(subtract, 1), 1);
assert.equal(commandCount(intersect, 1), 1);
assert.equal(commandCount(exclude, 1), 2);
});
test("stores the computed result path on a boolean group", () => {
const group = createBooleanShapeGroupNode({
id: "boolean-1",
title: "UNION",
operation: "union",
nodes: [first, second]
});
const options = parseBooleanShapeOptions(group.content);
assert.ok(Array.isArray(options.pathData));
assert.equal(options.pathData[0], 1);
assert.equal(options.width, 150);
assert.equal(options.height, 100);
assert.equal(group.fillColor, first.fillColor);
assert.equal(group.strokeColor, first.strokeColor);
});
test("creates boolean operations as ordinary editable path nodes", () => {
const result = createBooleanShapeResultNode({ id: "result-1", title: "UNION", operation: "union", nodes: [first, second] });
const options = JSON.parse(result.content);
assert.equal(result.layerRole, "shape");
assert.equal(options.kind, "path");
assert.ok(Array.isArray(options.pathData));
assert.equal(result.fillColor, first.fillColor);
assert.equal(result.strokeColor, first.strokeColor);
});
test("keeps each operand stroke style on the boundary it contributes", () => {
const black = { ...first, strokeColor: "#111111", strokeWidth: 3, strokeStyle: "solid" };
const purple = { ...second, strokeColor: "#7C3AED", strokeWidth: 5, strokeStyle: "dashed" };
const styledOperands = [black, purple].map((node) => ({
id: node.id,
title: node.title,
x: node.x,
y: node.y,
width: node.width,
height: node.height,
content: node.content,
fillColor: node.fillColor,
strokeColor: node.strokeColor,
strokeWidth: node.strokeWidth,
strokeStyle: node.strokeStyle,
opacity: node.opacity
}));
const computed = booleanShapePathResult(styledOperands, "union", 150, 100);
const result = createBooleanShapeResultNode({ id: "result-styled", title: "UNION", operation: "union", nodes: [black, purple] });
const options = JSON.parse(result.content);
assert.deepEqual(new Set(computed.strokeSegments.map((segment) => segment.strokeColor)), new Set(["#111111", "#7C3AED"]));
assert.deepEqual(new Set(options.strokeSegments.map((segment) => segment.strokeColor)), new Set(["#111111", "#7C3AED"]));
assert.ok(options.strokeSegments.some((segment) => segment.strokeColor === "#111111" && segment.strokeWidth === 3 && segment.strokeStyle === "solid"));
assert.ok(options.strokeSegments.some((segment) => segment.strokeColor === "#7C3AED" && segment.strokeWidth === 5 && segment.strokeStyle === "dashed"));
});
test("lets the single-shape toolbar intentionally update all boolean result strokes", () => {
const black = { ...first, strokeColor: "#111111", strokeWidth: 3 };
const purple = { ...second, strokeColor: "#7C3AED", strokeWidth: 5 };
const result = createBooleanShapeResultNode({ id: "result-patched", title: "UNION", operation: "union", nodes: [black, purple] });
const patched = patchShapeStyleNode(result, { strokeColor: "#22C55E", strokeWidth: 8 });
const options = JSON.parse(patched.content);
assert.deepEqual(new Set(options.strokeSegments.map((segment) => segment.strokeColor)), new Set(["#22C55E"]));
assert.deepEqual(new Set(options.strokeSegments.map((segment) => segment.strokeWidth)), new Set([8]));
});
test("keeps curved operands as curved result paths", () => {
const ellipse = {
...operands[0],
content: JSON.stringify({ kind: "ellipse" })
};
const rectangle = {
...operands[1],
x: 40,
y: 20,
width: 40,
height: 60
};
const result = booleanShapePathData([ellipse, rectangle], "subtract", 100, 100);
assert.ok(result.includes(5));
assert.equal(result.at(-1), 11);
});
test("recomputes the result path when the boolean operation changes", () => {
const union = createBooleanShapeGroupNode({ id: "boolean-2", title: "UNION", operation: "union", nodes: [first, second] });
const subtract = parseBooleanShapeOptions(updateBooleanShapeOperation(union, "subtract").content);
assertBounds(pathBounds(subtract.pathData), { left: 0, top: 0, right: 1 / 3, bottom: 1 });
});
test("flattens legacy boolean groups into ordinary path nodes", () => {
const group = createBooleanShapeGroupNode({ id: "boolean-3", title: "UNION", operation: "union", nodes: [first, second] });
const result = booleanShapeGroupToPathNode(group);
assert.equal(result.layerRole, "shape");
assert.equal(JSON.parse(result.content).kind, "path");
});
test("renders and exports only the computed boolean result path", async () => {
const previewSource = await readFile(new URL("../src/ui/pages/CanvasWorkspace/canvas/ShapeNodePreview/index.tsx", import.meta.url), "utf8");
const exportSource = await readFile(new URL("../src/ui/pages/CanvasWorkspace/canvas/nodeExport.ts", import.meta.url), "utf8");
const booleanPreview = previewSource.slice(previewSource.indexOf("function BooleanShapePreview"), previewSource.indexOf("export function shapeLinePathFromNode"));
assert.match(booleanPreview, /normalizedPathDataToSvg\(pathData, width, height\)/);
assert.match(booleanPreview, /fillRule="evenodd"/);
assert.match(previewSource, /options\.strokeSegments/);
assert.doesNotMatch(booleanPreview, /<mask|<rect/);
assert.match(exportSource, /function booleanShapeSvgFragment/);
assert.match(exportSource, /fill-rule="evenodd"/);
assert.match(exportSource, /strokeSegments/);
});
test("selects a boolean result as one shape with the single-shape toolbar", async () => {
const workspaceSource = await readFile(new URL("../src/ui/pages/CanvasWorkspace/index.tsx", import.meta.url), "utf8");
assert.match(workspaceSource, /createBooleanShapeResultNode\(/);
assert.match(workspaceSource, /selectOnlyNode\(resultNode\.id\)/);
assert.match(workspaceSource, /selectedShapeNode && selectedShapeBounds[\s\S]*?<ShapeStyleToolbar/);
});
function shapeNode(id, x, y, width, height) {
return {
id,
type: "frame",
title: id,
x,
y,
width,
height,
content: JSON.stringify({ kind: "rectangle", cornerRadius: 0 }),
tone: "shape",
status: "draft",
layerRole: "shape",
fillColor: "#ff0000",
strokeColor: "#111827",
strokeWidth: 0,
strokeStyle: "solid",
opacity: 1
};
}
function commandCount(pathData, command) {
let count = 0;
walkPathData(pathData, (name) => {
if (name === command) count += 1;
});
return count;
}
function assertBounds(actual, expected) {
for (const key of ["left", "top", "right", "bottom"]) assert.ok(Math.abs(actual[key] - expected[key]) < 0.00001, `${key}: expected ${expected[key]}, received ${actual[key]}`);
}
function pathBounds(pathData) {
const points = [];
walkPathData(pathData, (_name, coordinates) => {
for (let index = 0; index < coordinates.length; index += 2) points.push({ x: coordinates[index], y: coordinates[index + 1] });
});
return {
left: Math.min(...points.map((point) => point.x)),
top: Math.min(...points.map((point) => point.y)),
right: Math.max(...points.map((point) => point.x)),
bottom: Math.max(...points.map((point) => point.y))
};
}
function walkPathData(pathData, visit) {
for (let index = 0; index < pathData.length; ) {
const name = pathData[index++];
const coordinateCount = name === 1 || name === 2 ? 2 : name === 5 ? 6 : name === 7 ? 4 : name === 11 ? 0 : -1;
assert.notEqual(coordinateCount, -1);
const coordinates = pathData.slice(index, index + coordinateCount);
visit(name, coordinates);
index += coordinateCount;
}
}