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
+50 -5
View File
@@ -2,7 +2,8 @@ import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { applyEditedPathToNode, editablePathForNode, isEditablePathNode, normalizedPathDataToSvg } from "../src/ui/pages/CanvasWorkspace/canvas/editablePath.ts";
import { applyEditedPathToNode, applyEditedPathToNodes, editablePathForNode, isEditablePathNode, normalizedPathDataToSvg } from "../src/ui/pages/CanvasWorkspace/canvas/editablePath.ts";
import { createBooleanShapeGroupNode } from "../src/ui/pages/CanvasWorkspace/canvas/shapeNode.ts";
const shapeNode = {
id: "shape-1",
@@ -41,6 +42,20 @@ test("persists an edited shape path and expands its canvas bounds without moving
assert.match(editablePathForNode(edited), /^M 3 10/);
});
test("discards stale per-boundary stroke geometry after editing a boolean result path", () => {
const booleanResult = {
...shapeNode,
content: JSON.stringify({
kind: "path",
pathData: [1, 0, 0, 2, 1, 0, 2, 1, 1, 2, 0, 1, 11],
strokeSegments: [{ pathData: [1, 0, 0, 2, 1, 0], strokeColor: "#7C3AED", strokeWidth: 4, strokeStyle: "solid", opacity: 1 }]
})
};
const edited = applyEditedPathToNode(booleanResult, [1, 0, 0, 2, 100, 0, 2, 50, 60, 11]);
assert.equal(JSON.parse(edited.content).strokeSegments, undefined);
});
test("keeps unmarked Brush strokes outside the vertex editor", () => {
const brushSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50" viewBox="0 0 100 50"><path d="M 5 5 L 90 45" fill="none" stroke="#000000" stroke-width="3"/></svg>';
const brushNode = {
@@ -65,6 +80,26 @@ test("scales normalized cubic data back into SVG coordinates", () => {
assert.equal(normalizedPathDataToSvg([1, 0, 0.5, 5, 0.25, 0, 0.75, 1, 1, 0.5, 11], 200, 100), "M 0 50 C 50 0 150 100 200 50 Z");
});
test("opens a boolean result as an editable path and flattens it on commit", () => {
const secondShape = { ...shapeNode, id: "shape-2", x: 60 };
const booleanNode = createBooleanShapeGroupNode({ id: "boolean-1", title: "UNION", operation: "union", nodes: [shapeNode, secondShape] });
const nodes = [
{ ...shapeNode, parentId: booleanNode.id },
{ ...secondShape, parentId: booleanNode.id },
booleanNode
];
assert.equal(isEditablePathNode(booleanNode), true);
assert.match(editablePathForNode(booleanNode), /^M /);
const editedNodes = applyEditedPathToNodes(nodes, booleanNode.id, [1, 0, 0, 2, booleanNode.width, 0, 2, booleanNode.width, booleanNode.height, 2, 0, booleanNode.height, 11]);
const edited = editedNodes.find((node) => node.id === booleanNode.id);
assert.equal(editedNodes.length, 1);
assert.equal(edited.layerRole, "shape");
assert.equal(JSON.parse(edited.content).kind, "path");
});
test("registers SVGPathEditor on the project's Leafer 2.2 Path runtime", async () => {
await import("@leafer-in/editor");
const core = await import("@leafer-ui/core");
@@ -77,6 +112,7 @@ test("registers SVGPathEditor on the project's Leafer 2.2 Path runtime", async (
test("keeps the Pen tool immediately after Shape in the workspace toolbar", async () => {
const source = await readFile(new URL("../src/ui/pages/CanvasWorkspace/canvas/WorkspaceToolbar.tsx", import.meta.url), "utf8");
const workspaceSource = await readFile(new URL("../src/ui/pages/CanvasWorkspace/index.tsx", import.meta.url), "utf8");
const pathEditorSource = await readFile(new URL("../src/ui/pages/CanvasWorkspace/canvas/CanvasPathEditor.tsx", import.meta.url), "utf8");
const styles = await readFile(new URL("../src/ui/styles.css", import.meta.url), "utf8");
const nodePointerSource = workspaceSource.slice(workspaceSource.indexOf("const handleNodePointerDown"), workspaceSource.indexOf("const startPathEditing"));
@@ -85,19 +121,28 @@ test("keeps the Pen tool immediately after Shape in the workspace toolbar", asyn
assert.match(source, /title=\{`\$\{t\("pathPen"\)\} P`\}/);
assert.match(source, /title=\{`\$\{t\("pen"\)\} B`\}/);
assert.match(workspaceSource, /if \(activeTool === "pen"\) \{\s+startPenDrawing\(event\);/);
assert.match(workspaceSource, /setActiveTool\(editAfterCommit \? "path" : "select"\);/);
assert.match(workspaceSource, /if \(editAfterCommit\) \{\s+pathEditorExitToolRef\.current = null;\s+setPathEditingNodeId\(node\.id\);\s+\}/);
const pathCommitSource = workspaceSource.slice(workspaceSource.indexOf("const commitPathPenDrawing"), workspaceSource.indexOf("const startPathPenDrawing"));
assert.match(pathCommitSource, /setActiveTool\("path"\);/);
assert.match(pathCommitSource, /const finishPathPenDrawing = useCallback\(\s+\(closed = false\) =>/);
assert.doesNotMatch(pathCommitSource, /setPathEditingNodeId/);
assert.match(workspaceSource, /const startPathEditing[\s\S]*?setActiveTool\("path"\);[\s\S]*?setPathEditingNodeId\(node\.id\);/);
assert.match(workspaceSource, /onClose=\{handlePathEditorClose\}/);
assert.match(nodePointerSource, /if \(activeTool === "path"\) \{\s+if \(isEditablePathNode\(node\) && editablePathForNode\(node\)\) \{/);
assert.match(nodePointerSource, /commitPathPenDrawing\(currentPathPenDraft, false, false\);/);
assert.match(nodePointerSource, /commitPathPenDrawing\(currentPathPenDraft\);/);
assert.match(nodePointerSource, /event\.preventDefault\(\);\s+startPathEditing\(node\);\s+return;/);
assert.doesNotMatch(nodePointerSource, /!pathPenDraftRef\.current && isEditablePathNode/);
assert.match(workspaceSource, /if \(tool !== "path" && pathPenDraftRef\.current\) finishPathPenDrawing\(false, false\);/);
assert.match(workspaceSource, /if \(tool !== "path" && pathPenDraftRef\.current\) finishPathPenDrawing\(\);/);
assert.match(workspaceSource, /settings=\{activeTool === "path" \? pathPenControlSettings : penSettings\}/);
assert.match(workspaceSource, /updateShapeStyle\(pathPenControlNode\.id, pathPenSettingsToShapeStyle\(settings, viewportRef\.current\.k\)\);/);
assert.match(pathEditorSource, /const pathRef = useRef<import\("leafer-ui"\)\.Path \| null>\(null\);/);
assert.match(pathEditorSource, /pathRef\.current\?\.set\(pathNodeStyle\(node\)\);/);
assert.doesNotMatch(workspaceSource, /startPenDrawing\(event, true\)/);
assert.match(workspaceSource, /key === "p"\) return void run\(\(\) => chooseCanvasTool\("path"\)\)/);
assert.match(workspaceSource, /key === "b"\) return void run\(\(\) => chooseCanvasTool\("pen"\)\)/);
assert.match(styles, /\.canvas-stage\.is-path-pen-tool \.canvas-world \*/);
assert.match(styles, /\.canvas-stage\.is-path-pen-tool \.canvas-path-editor-overlay \*/);
assert.match(styles, /cursor:\s*url\("\/cursors\/pen-tool\.png\?v=4"\) 4 4, default !important;/);
assert.match(styles, /\.canvas-stage\.is-path-pen-tool \.pen-tool-controls[\s\S]*?cursor:\s*default !important;/);
const controlsRule = styles.match(/\.pen-tool-controls \{[\s\S]*?\n\}/g)?.at(-1) ?? "";
assert.match(controlsRule, /z-index:\s*42;/);
});