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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user