From dd8c67af3ea192103c6658fbf9b410d845a1890a Mon Sep 17 00:00:00 2001 From: liangxu Date: Sat, 18 Jul 2026 00:23:19 +0800 Subject: [PATCH] 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 --- frontend/next.config.ts | 3 +- frontend/package.json | 1 + frontend/pnpm-lock.yaml | 8 + frontend/src/types/paper-core.d.ts | 4 + .../canvas/ShapeNodePreview/index.tsx | 147 +++----- .../canvas/booleanShapePath.ts | 317 ++++++++++++++++++ .../CanvasWorkspace/canvas/editablePath.ts | 36 +- .../CanvasWorkspace/canvas/nodeExport.ts | 37 +- .../pages/CanvasWorkspace/canvas/shapeNode.ts | 163 ++++++++- frontend/tests/booleanShapePath.test.mjs | 217 ++++++++++++ frontend/tests/editablePath.test.mjs | 55 ++- 11 files changed, 862 insertions(+), 126 deletions(-) create mode 100644 frontend/src/types/paper-core.d.ts create mode 100644 frontend/src/ui/pages/CanvasWorkspace/canvas/booleanShapePath.ts create mode 100644 frontend/tests/booleanShapePath.test.mjs diff --git a/frontend/next.config.ts b/frontend/next.config.ts index f9a1988..6df1db9 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -2,7 +2,8 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { output: "standalone", - compress: true + compress: true, + serverExternalPackages: ["paper"] }; export default nextConfig; diff --git a/frontend/package.json b/frontend/package.json index bca6230..1c5afb3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,6 +27,7 @@ "leafer-x-path-editor": "^1.1.3", "lucide-react": "^1.23.0", "next": "^16.2.10", + "paper": "0.12.18", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 1d7024f..ec8d24c 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -58,6 +58,9 @@ dependencies: next: specifier: ^16.2.10 version: 16.2.10(react-dom@19.2.7)(react@19.2.7) + paper: + specifier: 0.12.18 + version: 0.12.18 react: specifier: ^19.2.7 version: 19.2.7 @@ -1988,6 +1991,11 @@ packages: resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} dev: false + /paper@0.12.18: + resolution: {integrity: sha512-ZSLIEejQTJZuYHhSSqAf4jXOnii0kPhCJGAnYAANtdS72aNwXJ9cP95tZHgq1tnNpvEwgQhggy+4OarviqTCGw==} + engines: {node: '>=8.0.0'} + dev: false + /picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} diff --git a/frontend/src/types/paper-core.d.ts b/frontend/src/types/paper-core.d.ts new file mode 100644 index 0000000..f104b25 --- /dev/null +++ b/frontend/src/types/paper-core.d.ts @@ -0,0 +1,4 @@ +declare module "paper/dist/paper-core.js" { + import paper = require("paper"); + export = paper; +} diff --git a/frontend/src/ui/pages/CanvasWorkspace/canvas/ShapeNodePreview/index.tsx b/frontend/src/ui/pages/CanvasWorkspace/canvas/ShapeNodePreview/index.tsx index 8a4b553..6252134 100644 --- a/frontend/src/ui/pages/CanvasWorkspace/canvas/ShapeNodePreview/index.tsx +++ b/frontend/src/ui/pages/CanvasWorkspace/canvas/ShapeNodePreview/index.tsx @@ -2,7 +2,8 @@ import type { CanvasNode } from "@/domain/design"; import { normalizedPathDataToSvg } from "@/ui/pages/CanvasWorkspace/canvas/editablePath"; -import { isBooleanShapeGroupNode, parseBooleanShapeOptions, parseShapeOptions, shapeEndpointToWorldPoint, type BooleanShapeOperand } from "@/ui/pages/CanvasWorkspace/canvas/shapeNode"; +import { booleanShapePathResult } from "@/ui/pages/CanvasWorkspace/canvas/booleanShapePath"; +import { isBooleanShapeGroupNode, parseBooleanShapeOptions, parseShapeOptions, shapeEndpointToWorldPoint, type ShapeStrokeSegment } from "@/ui/pages/CanvasWorkspace/canvas/shapeNode"; import { normalizeStrokeStyle } from "@/ui/pages/CanvasWorkspace/canvas/frameStyle"; import "./index.css"; @@ -25,9 +26,12 @@ export function ShapeNodePreview({ node }: { node: CanvasNode }) { const markerId = `shape-arrow-${node.id.replace(/[^a-zA-Z0-9_-]/g, "")}`; if (options.kind === "path") { + const path = normalizedPathDataToSvg(options.pathData, width, height); + const hasSegmentStrokes = Boolean(options.strokeSegments?.length); return ( - + + {hasSegmentStrokes && } ); } @@ -82,78 +86,47 @@ export function ShapeNodePreview({ node }: { node: CanvasNode }) { function BooleanShapePreview({ node }: { node: CanvasNode }) { const options = parseBooleanShapeOptions(node.content); - const operands = options.operands; - const base = operands[0]; const width = Math.max(node.width, 1); const height = Math.max(node.height, 1); - const maskId = `shape-boolean-mask-${node.id.replace(/[^a-zA-Z0-9_-]/g, "")}`; - const fill = base?.fillColor && base.fillColor !== "transparent" ? base.fillColor : node.fillColor && node.fillColor !== "transparent" ? node.fillColor : "#9b9b99"; - const opacity = typeof base?.opacity === "number" ? base.opacity : typeof node.opacity === "number" ? node.opacity : 1; - const basePath = base ? operandPath(base) : ""; - const cutterPaths = operands.slice(1).map(operandPath); - const allPaths = operands.map(operandPath).join(" "); - - if (!base || operands.length === 0) { - return ; - } - - if (options.operation === "exclude") { - return ( - - - - ); - } - - if (options.operation === "union" || options.operation === "flatten") { - return ( - - - - - {operands.map((operand) => ( - - ))} - - - - - ); - } - - if (options.operation === "intersect") { - return ( - - - - - - {cutterPaths.map((path, index) => ( - - ))} - - - - - ); - } + const computed = options.pathData?.length && options.strokeSegments?.length ? null : booleanShapePathResult(options.operands, options.operation, options.width, options.height); + const pathData = options.pathData?.length ? options.pathData : computed?.pathData; + const strokeSegments = options.strokeSegments?.length ? options.strokeSegments : computed?.strokeSegments || []; + const path = normalizedPathDataToSvg(pathData, width, height); + const fill = node.fillColor || "transparent"; + const stroke = node.strokeColor || "#111827"; + const strokeWidth = Math.max(node.strokeWidth ?? 2, 0); + const opacity = typeof node.opacity === "number" ? node.opacity : 1; + const dashArray = strokeDashArray(normalizeStrokeStyle(node.strokeStyle), strokeWidth); return ( - - - - - {cutterPaths.map((path, index) => ( - - ))} - - - + {path && } + {strokeSegments.length > 0 && } ); } +function ShapeStrokeSegments({ segments, width, height }: { segments: ShapeStrokeSegment[]; width: number; height: number }) { + return segments.map((segment, index) => { + const strokeWidth = Math.max(segment.strokeWidth, 0); + if (strokeWidth === 0) return null; + return ( + + ); + }); +} + export function shapeLinePathFromNode(node: CanvasNode) { const options = parseShapeOptions(node.content); const startWorld = shapeEndpointToWorldPoint(node, options.start); @@ -238,47 +211,3 @@ function strokeDashArray(style: string, width: number) { if (style === "dotted") return `1 ${Math.max(width * 2, 6)}`; return undefined; } - -function operandPath(operand: BooleanShapeOperand) { - const options = parseShapeOptions(operand.content); - const strokeWidth = Math.max(operand.strokeWidth ?? 0, 0); - const inset = strokeWidth / 2; - const x = operand.x; - const y = operand.y; - const width = Math.max(operand.width, 1); - const height = Math.max(operand.height, 1); - - if (options.kind === "path") { - return normalizedPathDataToSvg(options.pathData, width, height, x, y); - } - - if (options.kind === "ellipse") { - return ellipsePath(x + width / 2, y + height / 2, Math.max(width / 2 - inset, 0.5), Math.max(height / 2 - inset, 0.5)); - } - if (options.kind === "polygon") { - return roundedPath(regularPolygonPointsAt(x, y, width, height, options.vertices, inset + 2), options.cornerRadius); - } - if (options.kind === "star") { - return roundedPath(regularStarPointsAt(x, y, width, height, options.vertices, options.innerRadius, inset + 2), options.cornerRadius); - } - const radius = Math.min(options.cornerRadius, width / 2, height / 2); - return roundedRectPath(x + inset, y + inset, Math.max(width - strokeWidth, 0.5), Math.max(height - strokeWidth, 0.5), radius); -} - -function regularPolygonPointsAt(x: number, y: number, width: number, height: number, vertices: number, inset: number) { - return regularPolygonPoints(width, height, vertices, inset).map((point) => ({ x: point.x + x, y: point.y + y })); -} - -function regularStarPointsAt(x: number, y: number, width: number, height: number, vertices: number, innerRatio: number, inset: number) { - return regularStarPoints(width, height, vertices, innerRatio, inset).map((point) => ({ x: point.x + x, y: point.y + y })); -} - -function roundedRectPath(x: number, y: number, width: number, height: number, radius: number) { - const r = Math.max(0, Math.min(radius, width / 2, height / 2)); - if (r < 0.5) return `M ${x} ${y} H ${x + width} V ${y + height} H ${x} Z`; - return `M ${x + r} ${y} H ${x + width - r} Q ${x + width} ${y} ${x + width} ${y + r} V ${y + height - r} Q ${x + width} ${y + height} ${x + width - r} ${y + height} H ${x + r} Q ${x} ${y + height} ${x} ${y + height - r} V ${y + r} Q ${x} ${y} ${x + r} ${y} Z`; -} - -function ellipsePath(cx: number, cy: number, rx: number, ry: number) { - return `M ${cx - rx} ${cy} A ${rx} ${ry} 0 1 0 ${cx + rx} ${cy} A ${rx} ${ry} 0 1 0 ${cx - rx} ${cy} Z`; -} diff --git a/frontend/src/ui/pages/CanvasWorkspace/canvas/booleanShapePath.ts b/frontend/src/ui/pages/CanvasWorkspace/canvas/booleanShapePath.ts new file mode 100644 index 0000000..00cedc1 --- /dev/null +++ b/frontend/src/ui/pages/CanvasWorkspace/canvas/booleanShapePath.ts @@ -0,0 +1,317 @@ +import paper from "paper/dist/paper-core.js"; +import type { BooleanShapeOperand, BooleanShapeOperation, ShapeStrokeSegment } from "./shapeNode.ts"; + +const command = { + move: 1, + line: 2, + cubic: 5, + close: 11 +} as const; + +const scope = new paper.PaperScope(); +scope.setup(new scope.Size(1, 1)); +scope.settings.insertItems = false; + +export function booleanShapePathData(operands: BooleanShapeOperand[], operation: BooleanShapeOperation, width: number, height: number) { + return booleanShapePathResult(operands, operation, width, height).pathData; +} + +export function booleanShapePathResult(operands: BooleanShapeOperand[], operation: BooleanShapeOperation, width: number, height: number) { + const sources = operands + .map((operand) => ({ operand, path: new scope.CompoundPath({ pathData: booleanShapeOperandPath(operand), insert: false }) })) + .filter((source) => source.path.children.length > 0); + if (sources.length === 0) return { pathData: [], strokeSegments: [] }; + + let result: paper.PathItem = sources[0].path; + for (const source of sources.slice(1)) { + result = applyBooleanOperation(result, source.path, operation); + } + const normalizedWidth = Math.max(width, 1); + const normalizedHeight = Math.max(height, 1); + return { + pathData: paperPathItemToNormalizedPathData(result, normalizedWidth, normalizedHeight), + strokeSegments: paperPathItemToStrokeSegments(result, sources, normalizedWidth, normalizedHeight) + }; +} + +export function booleanShapeOperandPath(operand: BooleanShapeOperand) { + const options = shapeOptions(operand.content); + const strokeWidth = Math.max(operand.strokeWidth ?? 0, 0); + const inset = strokeWidth / 2; + const x = operand.x; + const y = operand.y; + const width = Math.max(operand.width, 1); + const height = Math.max(operand.height, 1); + + if (options.kind === "path") return normalizedPathDataToSvg(options.pathData, width, height, x, y); + if (options.kind === "ellipse") return ellipsePath(x + width / 2, y + height / 2, Math.max(width / 2 - inset, 0.5), Math.max(height / 2 - inset, 0.5)); + if (options.kind === "polygon") return roundedPath(regularPolygonPoints(x, y, width, height, options.vertices, inset + 2), options.cornerRadius); + if (options.kind === "star") return roundedPath(regularStarPoints(x, y, width, height, options.vertices, options.innerRadius, inset + 2), options.cornerRadius); + + const radius = Math.min(options.cornerRadius, width / 2, height / 2); + return roundedRectPath(x + inset, y + inset, Math.max(width - strokeWidth, 0.5), Math.max(height - strokeWidth, 0.5), radius); +} + +function applyBooleanOperation(first: paper.PathItem, second: paper.PathItem, operation: BooleanShapeOperation) { + const options = { insert: false }; + if (operation === "subtract") return first.subtract(second, options); + if (operation === "intersect") return first.intersect(second, options); + if (operation === "exclude") return first.exclude(second, options); + return first.unite(second, options); +} + +function paperPathItemToNormalizedPathData(item: paper.PathItem, width: number, height: number) { + const output: number[] = []; + paperPaths(item).forEach((path) => { + const segments = path.segments; + if (segments.length === 0) return; + appendPointCommand(output, command.move, segments[0].point, width, height); + for (let index = 1; index < segments.length; index += 1) appendPaperSegment(output, segments[index - 1], segments[index], width, height); + if (path.closed) { + const last = segments[segments.length - 1]; + const first = segments[0]; + if (!last.handleOut.isZero() || !first.handleIn.isZero()) appendPaperSegment(output, last, first, width, height); + output.push(command.close); + } + }); + return output; +} + +function paperPathItemToStrokeSegments( + item: paper.PathItem, + sources: Array<{ operand: BooleanShapeOperand; path: paper.PathItem }>, + width: number, + height: number +) { + const boundaryPoints = sourceBoundaryPoints(sources.map((source) => source.path)); + const output: ShapeStrokeSegment[] = []; + + paperPaths(item).forEach((path) => { + let parts = path.curves.flatMap((curve) => styledCurveParts(curve, sources, boundaryPoints)); + if (parts.length === 0) return; + + const singleSource = parts.every((part) => part.sourceIndex === parts[0].sourceIndex); + if (path.closed && !singleSource) { + const boundaryIndex = parts.findIndex((part, index) => part.sourceIndex !== parts[(index - 1 + parts.length) % parts.length].sourceIndex); + if (boundaryIndex > 0) parts = [...parts.slice(boundaryIndex), ...parts.slice(0, boundaryIndex)]; + } + + let runStart = 0; + while (runStart < parts.length) { + let runEnd = runStart + 1; + while (runEnd < parts.length && parts[runEnd].sourceIndex === parts[runStart].sourceIndex) runEnd += 1; + const run = parts.slice(runStart, runEnd); + const pathData: number[] = []; + appendPointCommand(pathData, command.move, run[0].curve.point1, width, height); + run.forEach((part) => appendPaperCurve(pathData, part.curve, width, height)); + if (singleSource && path.closed) pathData.push(command.close); + output.push(strokeSegmentForOperand(pathData, sources[run[0].sourceIndex].operand)); + runStart = runEnd; + } + }); + + return output; +} + +function styledCurveParts( + curve: paper.Curve, + sources: Array<{ operand: BooleanShapeOperand; path: paper.PathItem }>, + boundaryPoints: paper.Point[] +) { + const times = [0, 1]; + boundaryPoints.forEach((point) => { + const time = curve.getTimeOf(point); + if (Number.isFinite(time) && time > 0.000001 && time < 0.999999) times.push(time); + }); + times.sort((first, second) => first - second); + const uniqueTimes = times.filter((time, index) => index === 0 || Math.abs(time - times[index - 1]) > 0.000001); + + return uniqueTimes.slice(0, -1).map((from, index) => { + const to = uniqueTimes[index + 1]; + const midpoint = curve.getPointAtTime((from + to) / 2); + return { + curve: curve.getPart(from, to), + sourceIndex: nearestSourceIndex(midpoint, sources) + }; + }); +} + +function sourceBoundaryPoints(paths: paper.PathItem[]) { + const points = paths.flatMap((path) => paperPaths(path).flatMap((child) => child.segments.map((segment) => segment.point))); + for (let first = 0; first < paths.length; first += 1) { + for (let second = first + 1; second < paths.length; second += 1) { + paths[first].getIntersections(paths[second]).forEach((location) => points.push(location.point)); + } + } + return points; +} + +function nearestSourceIndex(point: paper.Point, sources: Array<{ path: paper.PathItem }>) { + let nearestIndex = 0; + let nearestDistance = Number.POSITIVE_INFINITY; + sources.forEach((source, index) => { + const distance = source.path.getNearestLocation(point)?.distance ?? Number.POSITIVE_INFINITY; + if (distance < nearestDistance - 0.000001) { + nearestDistance = distance; + nearestIndex = index; + } + }); + return nearestIndex; +} + +function strokeSegmentForOperand(pathData: number[], operand: BooleanShapeOperand): ShapeStrokeSegment { + return { + pathData, + strokeColor: operand.strokeColor || "#111827", + strokeWidth: Math.max(operand.strokeWidth ?? 2, 0), + strokeStyle: operand.strokeStyle || "solid", + opacity: clampNumber(operand.opacity, 0, 1, 1) + }; +} + +function paperPaths(item: paper.PathItem): paper.Path[] { + if (item instanceof scope.Path) return [item]; + if (item instanceof scope.CompoundPath) return item.children.flatMap((child) => paperPaths(child as paper.PathItem)); + return []; +} + +function appendPaperSegment(output: number[], from: paper.Segment, to: paper.Segment, width: number, height: number) { + if (from.handleOut.isZero() && to.handleIn.isZero()) { + appendPointCommand(output, command.line, to.point, width, height); + return; + } + output.push( + command.cubic, + normalized(from.point.x + from.handleOut.x, width), + normalized(from.point.y + from.handleOut.y, height), + normalized(to.point.x + to.handleIn.x, width), + normalized(to.point.y + to.handleIn.y, height), + normalized(to.point.x, width), + normalized(to.point.y, height) + ); +} + +function appendPaperCurve(output: number[], curve: paper.Curve, width: number, height: number) { + if (curve.isStraight()) { + appendPointCommand(output, command.line, curve.point2, width, height); + return; + } + const values = curve.values; + output.push( + command.cubic, + normalized(values[2], width), + normalized(values[3], height), + normalized(values[4], width), + normalized(values[5], height), + normalized(values[6], width), + normalized(values[7], height) + ); +} + +function appendPointCommand(output: number[], name: number, point: paper.Point, width: number, height: number) { + output.push(name, normalized(point.x, width), normalized(point.y, height)); +} + +function normalized(value: number, size: number) { + return Math.round((value / size) * 100_000) / 100_000; +} + +function shapeOptions(content: string) { + const parsed = safeJson(content); + return { + kind: parsed.kind === "path" || parsed.kind === "ellipse" || parsed.kind === "polygon" || parsed.kind === "star" ? parsed.kind : "rectangle", + cornerRadius: clampNumber(parsed.cornerRadius, 0, 300, parsed.kind === "rectangle" ? 0 : 12), + vertices: Math.round(clampNumber(parsed.vertices, 3, 12, 5)), + innerRadius: clampNumber(parsed.innerRadius, 0.12, 0.86, 0.46), + pathData: normalizePathData(parsed.pathData) + }; +} + +function normalizedPathDataToSvg(pathData: number[] | undefined, width: number, height: number, offsetX: number, offsetY: number) { + if (!pathData) return ""; + const output: string[] = []; + for (let index = 0; index < pathData.length; ) { + const name = pathData[index++]; + if (name === 1 || name === 2) { + output.push(`${name === 1 ? "M" : "L"} ${pathData[index++] * width + offsetX} ${pathData[index++] * height + offsetY}`); + continue; + } + if (name === 5) { + output.push(`C ${pathData[index++] * width + offsetX} ${pathData[index++] * height + offsetY} ${pathData[index++] * width + offsetX} ${pathData[index++] * height + offsetY} ${pathData[index++] * width + offsetX} ${pathData[index++] * height + offsetY}`); + continue; + } + if (name === 7) { + output.push(`Q ${pathData[index++] * width + offsetX} ${pathData[index++] * height + offsetY} ${pathData[index++] * width + offsetX} ${pathData[index++] * height + offsetY}`); + continue; + } + if (name === 11) { + output.push("Z"); + continue; + } + return ""; + } + return output.join(" "); +} + +function regularPolygonPoints(x: number, y: number, width: number, height: number, vertices: number, inset: number) { + const count = Math.max(3, Math.round(vertices)); + const radius = Math.max(Math.min(width, height) / 2 - inset, 2); + return Array.from({ length: count }, (_, index) => pointOnCircle(x, y, width, height, radius, -Math.PI / 2 + (Math.PI * 2 * index) / count)); +} + +function regularStarPoints(x: number, y: number, width: number, height: number, vertices: number, innerRatio: number, inset: number) { + const count = Math.max(3, Math.round(vertices)); + const outerRadius = Math.max(Math.min(width, height) / 2 - inset, 2); + return Array.from({ length: count * 2 }, (_, index) => pointOnCircle(x, y, width, height, index % 2 === 0 ? outerRadius : outerRadius * innerRatio, -Math.PI / 2 + (Math.PI * index) / count)); +} + +function pointOnCircle(x: number, y: number, width: number, height: number, radius: number, angle: number) { + return { x: x + width / 2 + Math.cos(angle) * radius, y: y + height / 2 + Math.sin(angle) * radius }; +} + +function roundedPath(points: Array<{ x: number; y: number }>, radius: number) { + if (points.length < 3) return ""; + if (radius < 0.5) return `M ${points.map((point) => `${point.x} ${point.y}`).join(" L ")} Z`; + const segments = points.map((point, index) => ({ + point, + before: pointAlong(point, points[(index - 1 + points.length) % points.length], radius), + after: pointAlong(point, points[(index + 1) % points.length], radius) + })); + return [`M ${segments[0].after.x} ${segments[0].after.y}`, ...segments.slice(1).map((segment) => `L ${segment.before.x} ${segment.before.y} Q ${segment.point.x} ${segment.point.y} ${segment.after.x} ${segment.after.y}`), `L ${segments[0].before.x} ${segments[0].before.y} Q ${segments[0].point.x} ${segments[0].point.y} ${segments[0].after.x} ${segments[0].after.y}`, "Z"].join(" "); +} + +function pointAlong(from: { x: number; y: number }, to: { x: number; y: number }, radius: number) { + const length = Math.max(Math.hypot(to.x - from.x, to.y - from.y), 1); + const distance = Math.min(radius, length * 0.42); + return { x: from.x + ((to.x - from.x) / length) * distance, y: from.y + ((to.y - from.y) / length) * distance }; +} + +function roundedRectPath(x: number, y: number, width: number, height: number, radius: number) { + const r = Math.max(0, Math.min(radius, width / 2, height / 2)); + if (r < 0.5) return `M ${x} ${y} H ${x + width} V ${y + height} H ${x} Z`; + return `M ${x + r} ${y} H ${x + width - r} Q ${x + width} ${y} ${x + width} ${y + r} V ${y + height - r} Q ${x + width} ${y + height} ${x + width - r} ${y + height} H ${x + r} Q ${x} ${y + height} ${x} ${y + height - r} V ${y + r} Q ${x} ${y} ${x + r} ${y} Z`; +} + +function ellipsePath(cx: number, cy: number, rx: number, ry: number) { + return `M ${cx - rx} ${cy} A ${rx} ${ry} 0 1 0 ${cx + rx} ${cy} A ${rx} ${ry} 0 1 0 ${cx - rx} ${cy} Z`; +} + +function normalizePathData(value: unknown) { + if (!Array.isArray(value)) return undefined; + const data = value.map(Number); + return data.length >= 4 && data.every(Number.isFinite) ? data : undefined; +} + +function clampNumber(value: unknown, min: number, max: number, fallback: number) { + const number = Number(value); + return Number.isFinite(number) ? Math.min(Math.max(number, min), max) : fallback; +} + +function safeJson(content: string): Record { + try { + const parsed = JSON.parse(content); + return parsed && typeof parsed === "object" ? parsed : {}; + } catch { + return {}; + } +} diff --git a/frontend/src/ui/pages/CanvasWorkspace/canvas/editablePath.ts b/frontend/src/ui/pages/CanvasWorkspace/canvas/editablePath.ts index cc83833..cbe354e 100644 --- a/frontend/src/ui/pages/CanvasWorkspace/canvas/editablePath.ts +++ b/frontend/src/ui/pages/CanvasWorkspace/canvas/editablePath.ts @@ -1,5 +1,6 @@ import type { CanvasNode } from "@/domain/design"; -import { isShapeNode, parseShapeOptions, shapeEndpointToWorldPoint, shapeOptionsToContent } from "./shapeNode.ts"; +import { booleanShapePathData } from "./booleanShapePath.ts"; +import { isBooleanShapeGroupNode, isShapeNode, parseBooleanShapeOptions, parseShapeOptions, shapeEndpointToWorldPoint, shapeOptionsToContent } from "./shapeNode.ts"; export type EditablePathData = number[]; @@ -14,11 +15,16 @@ const command = { const ellipseControlRatio = 0.5522847498; export function isEditablePathNode(node: CanvasNode) { - return isShapeNode(node); + return isShapeNode(node) || isBooleanShapeGroupNode(node); } export function editablePathForNode(node: CanvasNode) { if (isShapeNode(node)) return shapePathForEditing(node); + if (isBooleanShapeGroupNode(node)) { + const options = parseBooleanShapeOptions(node.content); + const pathData = options.pathData?.length ? options.pathData : booleanShapePathData(options.operands, options.operation, options.width, options.height); + return normalizedPathDataToSvg(pathData, node.width, node.height); + } return null; } @@ -34,13 +40,37 @@ export function applyEditedPathToNode(node: CanvasNode, pathData: EditablePathDa y: normalized.y, width: normalized.width, height: normalized.height, - content: shapeOptionsToContent({ ...options, kind: "path", pathData: normalized.pathData }) + content: shapeOptionsToContent({ ...options, kind: "path", pathData: normalized.pathData, strokeSegments: undefined }) + }; + } + + if (isBooleanShapeGroupNode(node)) { + const options = parseShapeOptions(JSON.stringify({ kind: "path", pathData: normalized.pathData })); + return { + ...node, + x: normalized.x, + y: normalized.y, + width: normalized.width, + height: normalized.height, + content: shapeOptionsToContent(options), + layerRole: "shape" }; } return node; } +export function applyEditedPathToNodes(nodes: CanvasNode[], nodeId: string, pathData: EditablePathData) { + const target = nodes.find((node) => node.id === nodeId); + if (!target) return nodes; + const edited = applyEditedPathToNode(target, pathData); + return nodes.flatMap((node) => { + if (node.id === nodeId) return [edited]; + if (isBooleanShapeGroupNode(target) && node.parentId === nodeId) return []; + return [node]; + }); +} + export function normalizedPathDataToSvg(pathData: EditablePathData | undefined, width: number, height: number, offsetX = 0, offsetY = 0) { if (!pathData || !isEditablePathData(pathData)) return ""; return pathDataToSvg(transformPathData(pathData, (value, axis) => value * (axis === "x" ? width : height) + (axis === "x" ? offsetX : offsetY))); diff --git a/frontend/src/ui/pages/CanvasWorkspace/canvas/nodeExport.ts b/frontend/src/ui/pages/CanvasWorkspace/canvas/nodeExport.ts index ac864dd..bdc4378 100644 --- a/frontend/src/ui/pages/CanvasWorkspace/canvas/nodeExport.ts +++ b/frontend/src/ui/pages/CanvasWorkspace/canvas/nodeExport.ts @@ -1,7 +1,8 @@ import type { CanvasNode } from "@/domain/design"; import type { Justification, Layer as PsdLayer, LayerTextData, Psd, RGB } from "ag-psd"; import { imageAdjustmentImageStyle, isDefaultImageAdjustments, parseImageAdjustments } from "@/ui/lib/imageAdjustments"; -import { isShapeNode, parseShapeOptions, shapeEndpointToWorldPoint } from "@/ui/pages/CanvasWorkspace/canvas/shapeNode"; +import { booleanShapePathResult } from "@/ui/pages/CanvasWorkspace/canvas/booleanShapePath"; +import { isBooleanShapeGroupNode, isShapeNode, parseBooleanShapeOptions, parseShapeOptions, shapeEndpointToWorldPoint, type ShapeStrokeSegment } from "@/ui/pages/CanvasWorkspace/canvas/shapeNode"; import { normalizedPathDataToSvg } from "@/ui/pages/CanvasWorkspace/canvas/editablePath"; import { defaultFrameFillColor, defaultFrameStrokeColor, defaultFrameStrokeStyle, defaultFrameStrokeWidth, normalizeHexColor, normalizeStrokeStyle } from "./frameStyle"; @@ -484,6 +485,7 @@ function canvasNodeSvgFragment(node: CanvasNode, bounds: { x: number; y: number; } function frameSvgRect(node: CanvasNode, x: number, y: number, width: number, height: number, transform = "") { + if (isBooleanShapeGroupNode(node)) return booleanShapeSvgFragment(node, x, y, width, height, transform); if (isShapeNode(node)) return shapeSvgFragment(node, x, y, width, height, transform); const strokeWidth = Math.max(0, node.strokeWidth ?? defaultFrameStrokeWidth); const fillColor = node.fillColor || defaultFrameFillColor; @@ -497,6 +499,22 @@ function frameSvgRect(node: CanvasNode, x: number, y: number, width: number, hei return ``; } +function booleanShapeSvgFragment(node: CanvasNode, x: number, y: number, width: number, height: number, transform = "") { + const options = parseBooleanShapeOptions(node.content); + const computed = options.pathData?.length && options.strokeSegments?.length ? null : booleanShapePathResult(options.operands, options.operation, options.width, options.height); + const pathData = options.pathData?.length ? options.pathData : computed?.pathData; + const strokeSegments = options.strokeSegments?.length ? options.strokeSegments : computed?.strokeSegments || []; + const path = normalizedPathDataToSvg(pathData, width, height, x, y); + if (!path) return ""; + const strokeWidth = Math.max(0, node.strokeWidth ?? defaultFrameStrokeWidth); + const fill = shapeSvgFill(node); + const stroke = + strokeWidth > 0 + ? `stroke="${escapeXml(normalizeHexColor(node.strokeColor || defaultFrameStrokeColor, defaultFrameStrokeColor))}" stroke-width="${strokeWidth}"${strokeDashArray(node.strokeStyle, strokeWidth)}` + : `stroke="none"`; + return `${shapeStrokeSvgFragments(strokeSegments, x, y, width, height, transform)}`; +} + function shapeSvgFragment(node: CanvasNode, x: number, y: number, width: number, height: number, transform = "") { const options = parseShapeOptions(node.content); const strokeWidth = Math.max(0, node.strokeWidth ?? defaultFrameStrokeWidth); @@ -508,7 +526,8 @@ function shapeSvgFragment(node: CanvasNode, x: number, y: number, width: number, const inset = strokeWidth / 2; if (options.kind === "path") { - return ``; + const strokeSegments = options.strokeSegments || []; + return `${shapeStrokeSvgFragments(strokeSegments, x, y, width, height, transform)}`; } if (options.kind === "line" || options.kind === "arrow") { @@ -539,6 +558,20 @@ function shapeSvgFragment(node: CanvasNode, x: number, y: number, width: number, return ``; } +function shapeStrokeSvgFragments(segments: ShapeStrokeSegment[], x: number, y: number, width: number, height: number, transform: string) { + return segments + .map((segment) => { + const strokeWidth = Math.max(0, segment.strokeWidth); + if (strokeWidth === 0) return ""; + const path = normalizedPathDataToSvg(segment.pathData, width, height, x, y); + if (!path) return ""; + const color = escapeXml(normalizeHexColor(segment.strokeColor, defaultFrameStrokeColor)); + const opacity = Math.min(Math.max(segment.opacity, 0), 1); + return ``; + }) + .join(""); +} + function shapeSvgFill(node: CanvasNode) { const fillColor = node.fillColor || "transparent"; if (fillColor === "transparent") return `fill="none"`; diff --git a/frontend/src/ui/pages/CanvasWorkspace/canvas/shapeNode.ts b/frontend/src/ui/pages/CanvasWorkspace/canvas/shapeNode.ts index b6b5688..a3d5e77 100644 --- a/frontend/src/ui/pages/CanvasWorkspace/canvas/shapeNode.ts +++ b/frontend/src/ui/pages/CanvasWorkspace/canvas/shapeNode.ts @@ -1,5 +1,6 @@ import type { CanvasNode } from "@/domain/design"; import type { FrameStylePatch } from "@/ui/pages/CanvasWorkspace/canvas/types"; +import { booleanShapePathResult } from "./booleanShapePath.ts"; export type ShapeKind = "rectangle" | "line" | "arrow" | "ellipse" | "polygon" | "star"; export type ShapeNodeKind = ShapeKind | "path"; @@ -20,6 +21,15 @@ export type ShapeOptions = { start: ShapeEndpoint; end: ShapeEndpoint; pathData?: number[]; + strokeSegments?: ShapeStrokeSegment[]; +}; + +export type ShapeStrokeSegment = { + pathData: number[]; + strokeColor: string; + strokeWidth: number; + strokeStyle: string; + opacity: number; }; export type ShapeWorldDraft = { @@ -54,6 +64,10 @@ export type BooleanShapeOptions = { kind: "boolean"; operation: BooleanShapeOperation; operands: BooleanShapeOperand[]; + width: number; + height: number; + pathData?: number[]; + strokeSegments?: ShapeStrokeSegment[]; }; export const shapeKinds: ShapeKind[] = ["rectangle", "line", "arrow", "ellipse", "polygon", "star"]; @@ -109,7 +123,8 @@ export function parseShapeOptions(content: string | undefined): ShapeOptions { curve: clampNumber(parsed.curve, -260, 260, 0), start: normalizeEndpoint(parsed.start, { x: 0, y: 0 }), end: normalizeEndpoint(parsed.end, { x: 1, y: 1 }), - pathData: kind === "path" ? normalizePathData(parsed.pathData) : undefined + pathData: kind === "path" ? normalizePathData(parsed.pathData) : undefined, + strokeSegments: kind === "path" ? normalizeStrokeSegments(parsed.strokeSegments) : undefined }; } @@ -122,7 +137,8 @@ export function shapeOptionsToContent(options: ShapeOptions) { curve: Math.round(options.curve * 10) / 10, start: normalizeEndpoint(options.start, { x: 0, y: 0 }), end: normalizeEndpoint(options.end, { x: 1, y: 1 }), - ...(options.kind === "path" && options.pathData ? { pathData: options.pathData.map((value) => Math.round(value * 100_000) / 100_000) } : {}) + ...(options.kind === "path" && options.pathData ? { pathData: roundedPathData(options.pathData) } : {}), + ...(options.kind === "path" && options.strokeSegments?.length ? { strokeSegments: options.strokeSegments.map(serializeStrokeSegment) } : {}) }); } @@ -191,6 +207,7 @@ export function createBooleanShapeGroupNode({ strokeStyle: node.strokeStyle, opacity: node.opacity })); + const result = booleanShapePathResult(operands, operation, bounds.width, bounds.height); return { id, @@ -203,7 +220,11 @@ export function createBooleanShapeGroupNode({ content: JSON.stringify({ kind: "boolean", operation, - operands + operands, + width: bounds.width, + height: bounds.height, + pathData: result.pathData, + strokeSegments: result.strokeSegments } satisfies BooleanShapeOptions), tone: "shape", status: "draft", @@ -216,24 +237,82 @@ export function createBooleanShapeGroupNode({ }; } +export function createBooleanShapeResultNode({ + id, + title, + operation, + nodes +}: { + id: string; + title: string; + operation: BooleanShapeOperation; + nodes: CanvasNode[]; +}): CanvasNode { + const bounds = shapeNodesBounds(nodes); + const baseNode = nodes[0]; + const operands = booleanShapeOperands(nodes, bounds); + const result = booleanShapePathResult(operands, operation, bounds.width, bounds.height); + + return { + id, + type: "frame", + title, + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + content: shapeOptionsToContent({ ...defaultShapeOptions("rectangle"), kind: "path", pathData: result.pathData, strokeSegments: result.strokeSegments }), + tone: "shape", + status: "draft", + layerRole: "shape", + opacity: baseNode?.opacity ?? 1, + fillColor: baseNode?.fillColor || defaultShapeFillColor, + strokeColor: baseNode?.strokeColor || defaultShapeStrokeColor, + strokeWidth: baseNode?.strokeWidth ?? defaultShapeStrokeWidth, + strokeStyle: baseNode?.strokeStyle || "solid" + }; +} + +export function booleanShapeGroupToPathNode(node: CanvasNode, operation?: BooleanShapeOperation): CanvasNode { + if (!isBooleanShapeGroupNode(node)) return node; + const options = parseBooleanShapeOptions(node.content); + const nextOperation = operation ?? options.operation; + const computed = nextOperation === options.operation && options.pathData?.length && options.strokeSegments?.length ? null : booleanShapePathResult(options.operands, nextOperation, options.width, options.height); + const pathData = nextOperation === options.operation && options.pathData?.length ? options.pathData : computed?.pathData ?? []; + const strokeSegments = nextOperation === options.operation && options.strokeSegments?.length ? options.strokeSegments : computed?.strokeSegments; + return { + ...node, + title: nextOperation.toUpperCase(), + content: shapeOptionsToContent({ ...defaultShapeOptions("rectangle"), kind: "path", pathData, strokeSegments }), + layerRole: "shape" + }; +} + export function parseBooleanShapeOptions(content: string | undefined): BooleanShapeOptions { const parsed = safeJson(content); const operation = isBooleanShapeOperation(parsed.operation) ? parsed.operation : "subtract"; const rawOperands = Array.isArray(parsed.operands) ? parsed.operands : []; + const operands = rawOperands.map(normalizeBooleanOperand).filter(Boolean) as BooleanShapeOperand[]; + const bounds = booleanOperandBounds(operands); return { kind: "boolean", operation, - operands: rawOperands.map(normalizeBooleanOperand).filter(Boolean) as BooleanShapeOperand[] + operands, + width: Math.max(1, Number.isFinite(Number(parsed.width)) ? Number(parsed.width) : bounds.width), + height: Math.max(1, Number.isFinite(Number(parsed.height)) ? Number(parsed.height) : bounds.height), + pathData: normalizePathData(parsed.pathData), + strokeSegments: normalizeStrokeSegments(parsed.strokeSegments) }; } export function updateBooleanShapeOperation(node: CanvasNode, operation: BooleanShapeOperation): CanvasNode { if (!isBooleanShapeGroupNode(node)) return node; const options = parseBooleanShapeOptions(node.content); + const result = booleanShapePathResult(options.operands, operation, options.width, options.height); return { ...node, title: operation.toUpperCase(), - content: JSON.stringify({ ...options, operation }) + content: JSON.stringify({ ...options, operation, pathData: result.pathData, strokeSegments: result.strokeSegments }) }; } @@ -242,7 +321,19 @@ export function patchShapeStyleNode(node: CanvasNode, patch: ShapeStylePatch): C const width = patch.width ?? node.width; const height = patch.height ?? node.height; const currentOptions = parseShapeOptions(node.content); - const nextOptions = patch.shape ? { ...currentOptions, ...patch.shape } : currentOptions; + let nextOptions = patch.shape ? { ...currentOptions, ...patch.shape } : currentOptions; + if (currentOptions.strokeSegments?.length) { + nextOptions = { + ...nextOptions, + strokeSegments: currentOptions.strokeSegments.map((segment) => ({ + ...segment, + strokeColor: patch.strokeColor ?? segment.strokeColor, + strokeWidth: patch.strokeWidth ?? segment.strokeWidth, + strokeStyle: patch.strokeStyle ?? segment.strokeStyle, + opacity: patch.opacity === undefined ? segment.opacity : clampNumber(patch.opacity / 100, 0, 1, segment.opacity) + })) + }; + } return { ...node, @@ -330,6 +421,32 @@ function shapeNodesBounds(nodes: CanvasNode[]) { }; } +function booleanShapeOperands(nodes: CanvasNode[], bounds: { x: number; y: number }) { + return nodes.map((node) => ({ + id: node.id, + title: node.title, + x: node.x - bounds.x, + y: node.y - bounds.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 + } satisfies BooleanShapeOperand)); +} + +function booleanOperandBounds(operands: BooleanShapeOperand[]) { + if (operands.length === 0) return { width: 1, height: 1 }; + const left = Math.min(...operands.map((operand) => operand.x)); + const top = Math.min(...operands.map((operand) => operand.y)); + const right = Math.max(...operands.map((operand) => operand.x + operand.width)); + const bottom = Math.max(...operands.map((operand) => operand.y + operand.height)); + return { width: Math.max(right - Math.min(left, 0), 1), height: Math.max(bottom - Math.min(top, 0), 1) }; +} + function rectFromShapeDraft(draft: ShapeWorldDraft) { return { x: draft.x, @@ -361,6 +478,40 @@ function normalizePathData(value: unknown) { return data.length >= 4 && data.every(Number.isFinite) ? data : undefined; } +function normalizeStrokeSegments(value: unknown) { + if (!Array.isArray(value)) return undefined; + const segments = value + .map((item): ShapeStrokeSegment | null => { + if (!item || typeof item !== "object") return null; + const segment = item as Partial; + const pathData = normalizePathData(segment.pathData); + if (!pathData) return null; + return { + pathData, + strokeColor: typeof segment.strokeColor === "string" && segment.strokeColor ? segment.strokeColor : defaultShapeStrokeColor, + strokeWidth: Math.max(0, Number.isFinite(Number(segment.strokeWidth)) ? Number(segment.strokeWidth) : defaultShapeStrokeWidth), + strokeStyle: typeof segment.strokeStyle === "string" ? segment.strokeStyle : "solid", + opacity: clampNumber(segment.opacity, 0, 1, 1) + }; + }) + .filter((segment): segment is ShapeStrokeSegment => Boolean(segment)); + return segments.length ? segments : undefined; +} + +function roundedPathData(pathData: number[]) { + return pathData.map((value) => Math.round(value * 100_000) / 100_000); +} + +function serializeStrokeSegment(segment: ShapeStrokeSegment): ShapeStrokeSegment { + return { + pathData: roundedPathData(segment.pathData), + strokeColor: segment.strokeColor, + strokeWidth: Math.round(segment.strokeWidth * 1000) / 1000, + strokeStyle: segment.strokeStyle, + opacity: Math.round(segment.opacity * 1000) / 1000 + }; +} + function safeJson(content: string | undefined): Record { if (!content) return {}; try { diff --git a/frontend/tests/booleanShapePath.test.mjs b/frontend/tests/booleanShapePath.test.mjs new file mode 100644 index 0000000..fe5866a --- /dev/null +++ b/frontend/tests/booleanShapePath.test.mjs @@ -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, / { + 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]*? { + 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; + } +} diff --git a/frontend/tests/editablePath.test.mjs b/frontend/tests/editablePath.test.mjs index 1a771b3..3f67641 100644 --- a/frontend/tests/editablePath.test.mjs +++ b/frontend/tests/editablePath.test.mjs @@ -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 = ''; 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\(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;/); });