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:
@@ -2,7 +2,8 @@ import type { NextConfig } from "next";
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
compress: true
|
compress: true,
|
||||||
|
serverExternalPackages: ["paper"]
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
"leafer-x-path-editor": "^1.1.3",
|
"leafer-x-path-editor": "^1.1.3",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"next": "^16.2.10",
|
"next": "^16.2.10",
|
||||||
|
"paper": "0.12.18",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7"
|
"react-dom": "^19.2.7"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+8
@@ -58,6 +58,9 @@ dependencies:
|
|||||||
next:
|
next:
|
||||||
specifier: ^16.2.10
|
specifier: ^16.2.10
|
||||||
version: 16.2.10(react-dom@19.2.7)(react@19.2.7)
|
version: 16.2.10(react-dom@19.2.7)(react@19.2.7)
|
||||||
|
paper:
|
||||||
|
specifier: 0.12.18
|
||||||
|
version: 0.12.18
|
||||||
react:
|
react:
|
||||||
specifier: ^19.2.7
|
specifier: ^19.2.7
|
||||||
version: 19.2.7
|
version: 19.2.7
|
||||||
@@ -1988,6 +1991,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
|
resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/paper@0.12.18:
|
||||||
|
resolution: {integrity: sha512-ZSLIEejQTJZuYHhSSqAf4jXOnii0kPhCJGAnYAANtdS72aNwXJ9cP95tZHgq1tnNpvEwgQhggy+4OarviqTCGw==}
|
||||||
|
engines: {node: '>=8.0.0'}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/picocolors@1.1.1:
|
/picocolors@1.1.1:
|
||||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||||
|
|
||||||
|
|||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
declare module "paper/dist/paper-core.js" {
|
||||||
|
import paper = require("paper");
|
||||||
|
export = paper;
|
||||||
|
}
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import type { CanvasNode } from "@/domain/design";
|
import type { CanvasNode } from "@/domain/design";
|
||||||
import { normalizedPathDataToSvg } from "@/ui/pages/CanvasWorkspace/canvas/editablePath";
|
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 { normalizeStrokeStyle } from "@/ui/pages/CanvasWorkspace/canvas/frameStyle";
|
||||||
import "./index.css";
|
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, "")}`;
|
const markerId = `shape-arrow-${node.id.replace(/[^a-zA-Z0-9_-]/g, "")}`;
|
||||||
|
|
||||||
if (options.kind === "path") {
|
if (options.kind === "path") {
|
||||||
|
const path = normalizedPathDataToSvg(options.pathData, width, height);
|
||||||
|
const hasSegmentStrokes = Boolean(options.strokeSegments?.length);
|
||||||
return (
|
return (
|
||||||
<svg className="shape-node-preview" viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="none">
|
<svg className="shape-node-preview" viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="none">
|
||||||
<path d={normalizedPathDataToSvg(options.pathData, width, height)} fill={fill} fillOpacity={opacity} stroke={stroke} strokeWidth={strokeWidth} strokeDasharray={dashArray} strokeLinecap="round" strokeLinejoin="round" vectorEffect="non-scaling-stroke" />
|
<path d={path} fill={fill} fillOpacity={opacity} fillRule="evenodd" stroke={hasSegmentStrokes ? "none" : stroke} strokeWidth={strokeWidth} strokeDasharray={dashArray} strokeLinecap="round" strokeLinejoin="round" vectorEffect="non-scaling-stroke" />
|
||||||
|
{hasSegmentStrokes && <ShapeStrokeSegments segments={options.strokeSegments || []} width={width} height={height} />}
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -82,78 +86,47 @@ export function ShapeNodePreview({ node }: { node: CanvasNode }) {
|
|||||||
|
|
||||||
function BooleanShapePreview({ node }: { node: CanvasNode }) {
|
function BooleanShapePreview({ node }: { node: CanvasNode }) {
|
||||||
const options = parseBooleanShapeOptions(node.content);
|
const options = parseBooleanShapeOptions(node.content);
|
||||||
const operands = options.operands;
|
|
||||||
const base = operands[0];
|
|
||||||
const width = Math.max(node.width, 1);
|
const width = Math.max(node.width, 1);
|
||||||
const height = Math.max(node.height, 1);
|
const height = Math.max(node.height, 1);
|
||||||
const maskId = `shape-boolean-mask-${node.id.replace(/[^a-zA-Z0-9_-]/g, "")}`;
|
const computed = options.pathData?.length && options.strokeSegments?.length ? null : booleanShapePathResult(options.operands, options.operation, options.width, options.height);
|
||||||
const fill = base?.fillColor && base.fillColor !== "transparent" ? base.fillColor : node.fillColor && node.fillColor !== "transparent" ? node.fillColor : "#9b9b99";
|
const pathData = options.pathData?.length ? options.pathData : computed?.pathData;
|
||||||
const opacity = typeof base?.opacity === "number" ? base.opacity : typeof node.opacity === "number" ? node.opacity : 1;
|
const strokeSegments = options.strokeSegments?.length ? options.strokeSegments : computed?.strokeSegments || [];
|
||||||
const basePath = base ? operandPath(base) : "";
|
const path = normalizedPathDataToSvg(pathData, width, height);
|
||||||
const cutterPaths = operands.slice(1).map(operandPath);
|
const fill = node.fillColor || "transparent";
|
||||||
const allPaths = operands.map(operandPath).join(" ");
|
const stroke = node.strokeColor || "#111827";
|
||||||
|
const strokeWidth = Math.max(node.strokeWidth ?? 2, 0);
|
||||||
if (!base || operands.length === 0) {
|
const opacity = typeof node.opacity === "number" ? node.opacity : 1;
|
||||||
return <svg className="shape-node-preview" viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="none" />;
|
const dashArray = strokeDashArray(normalizeStrokeStyle(node.strokeStyle), strokeWidth);
|
||||||
}
|
|
||||||
|
|
||||||
if (options.operation === "exclude") {
|
|
||||||
return (
|
|
||||||
<svg className="shape-node-preview" viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="none">
|
|
||||||
<path d={allPaths} fill={fill} fillOpacity={opacity} fillRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.operation === "union" || options.operation === "flatten") {
|
|
||||||
return (
|
|
||||||
<svg className="shape-node-preview" viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="none">
|
|
||||||
<defs>
|
|
||||||
<mask id={maskId} maskUnits="userSpaceOnUse" x="0" y="0" width={width} height={height}>
|
|
||||||
<rect width={width} height={height} fill="black" />
|
|
||||||
{operands.map((operand) => (
|
|
||||||
<path key={operand.id} d={operandPath(operand)} fill="white" />
|
|
||||||
))}
|
|
||||||
</mask>
|
|
||||||
</defs>
|
|
||||||
<rect width={width} height={height} fill={fill} fillOpacity={opacity} mask={`url(#${maskId})`} />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.operation === "intersect") {
|
|
||||||
return (
|
|
||||||
<svg className="shape-node-preview" viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="none">
|
|
||||||
<defs>
|
|
||||||
<mask id={maskId} maskUnits="userSpaceOnUse" x="0" y="0" width={width} height={height}>
|
|
||||||
<rect width={width} height={height} fill="black" />
|
|
||||||
<path d={basePath} fill="white" />
|
|
||||||
{cutterPaths.map((path, index) => (
|
|
||||||
<path key={index} d={path} fill="white" fillOpacity={0.56} />
|
|
||||||
))}
|
|
||||||
</mask>
|
|
||||||
</defs>
|
|
||||||
<rect width={width} height={height} fill={fill} fillOpacity={opacity} mask={`url(#${maskId})`} />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg className="shape-node-preview" viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="none">
|
<svg className="shape-node-preview" viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="none">
|
||||||
<defs>
|
{path && <path d={path} fill={fill} fillOpacity={opacity} fillRule="evenodd" stroke={strokeSegments.length ? "none" : stroke} strokeWidth={strokeWidth} strokeDasharray={dashArray} strokeLinecap="round" strokeLinejoin="round" vectorEffect="non-scaling-stroke" />}
|
||||||
<mask id={maskId} maskUnits="userSpaceOnUse" x="0" y="0" width={width} height={height}>
|
{strokeSegments.length > 0 && <ShapeStrokeSegments segments={strokeSegments} width={width} height={height} />}
|
||||||
<rect width={width} height={height} fill="black" />
|
|
||||||
<path d={basePath} fill="white" />
|
|
||||||
{cutterPaths.map((path, index) => (
|
|
||||||
<path key={index} d={path} fill="black" />
|
|
||||||
))}
|
|
||||||
</mask>
|
|
||||||
</defs>
|
|
||||||
<rect width={width} height={height} fill={fill} fillOpacity={opacity} mask={`url(#${maskId})`} />
|
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<path
|
||||||
|
key={`${segment.strokeColor}-${index}`}
|
||||||
|
d={normalizedPathDataToSvg(segment.pathData, width, height)}
|
||||||
|
fill="none"
|
||||||
|
stroke={segment.strokeColor}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeOpacity={segment.opacity}
|
||||||
|
strokeDasharray={strokeDashArray(normalizeStrokeStyle(segment.strokeStyle), strokeWidth)}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function shapeLinePathFromNode(node: CanvasNode) {
|
export function shapeLinePathFromNode(node: CanvasNode) {
|
||||||
const options = parseShapeOptions(node.content);
|
const options = parseShapeOptions(node.content);
|
||||||
const startWorld = shapeEndpointToWorldPoint(node, options.start);
|
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)}`;
|
if (style === "dotted") return `1 ${Math.max(width * 2, 6)}`;
|
||||||
return undefined;
|
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`;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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<string, any> {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content);
|
||||||
|
return parsed && typeof parsed === "object" ? parsed : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { CanvasNode } from "@/domain/design";
|
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[];
|
export type EditablePathData = number[];
|
||||||
|
|
||||||
@@ -14,11 +15,16 @@ const command = {
|
|||||||
const ellipseControlRatio = 0.5522847498;
|
const ellipseControlRatio = 0.5522847498;
|
||||||
|
|
||||||
export function isEditablePathNode(node: CanvasNode) {
|
export function isEditablePathNode(node: CanvasNode) {
|
||||||
return isShapeNode(node);
|
return isShapeNode(node) || isBooleanShapeGroupNode(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function editablePathForNode(node: CanvasNode) {
|
export function editablePathForNode(node: CanvasNode) {
|
||||||
if (isShapeNode(node)) return shapePathForEditing(node);
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,13 +40,37 @@ export function applyEditedPathToNode(node: CanvasNode, pathData: EditablePathDa
|
|||||||
y: normalized.y,
|
y: normalized.y,
|
||||||
width: normalized.width,
|
width: normalized.width,
|
||||||
height: normalized.height,
|
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;
|
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) {
|
export function normalizedPathDataToSvg(pathData: EditablePathData | undefined, width: number, height: number, offsetX = 0, offsetY = 0) {
|
||||||
if (!pathData || !isEditablePathData(pathData)) return "";
|
if (!pathData || !isEditablePathData(pathData)) return "";
|
||||||
return pathDataToSvg(transformPathData(pathData, (value, axis) => value * (axis === "x" ? width : height) + (axis === "x" ? offsetX : offsetY)));
|
return pathDataToSvg(transformPathData(pathData, (value, axis) => value * (axis === "x" ? width : height) + (axis === "x" ? offsetX : offsetY)));
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { CanvasNode } from "@/domain/design";
|
import type { CanvasNode } from "@/domain/design";
|
||||||
import type { Justification, Layer as PsdLayer, LayerTextData, Psd, RGB } from "ag-psd";
|
import type { Justification, Layer as PsdLayer, LayerTextData, Psd, RGB } from "ag-psd";
|
||||||
import { imageAdjustmentImageStyle, isDefaultImageAdjustments, parseImageAdjustments } from "@/ui/lib/imageAdjustments";
|
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 { normalizedPathDataToSvg } from "@/ui/pages/CanvasWorkspace/canvas/editablePath";
|
||||||
import { defaultFrameFillColor, defaultFrameStrokeColor, defaultFrameStrokeStyle, defaultFrameStrokeWidth, normalizeHexColor, normalizeStrokeStyle } from "./frameStyle";
|
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 = "") {
|
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);
|
if (isShapeNode(node)) return shapeSvgFragment(node, x, y, width, height, transform);
|
||||||
const strokeWidth = Math.max(0, node.strokeWidth ?? defaultFrameStrokeWidth);
|
const strokeWidth = Math.max(0, node.strokeWidth ?? defaultFrameStrokeWidth);
|
||||||
const fillColor = node.fillColor || defaultFrameFillColor;
|
const fillColor = node.fillColor || defaultFrameFillColor;
|
||||||
@@ -497,6 +499,22 @@ function frameSvgRect(node: CanvasNode, x: number, y: number, width: number, hei
|
|||||||
return `<rect x="${x + inset}" y="${y + inset}" width="${Math.max(0, width - strokeWidth)}" height="${Math.max(0, height - strokeWidth)}" ${fill} ${stroke}${transform}/>`;
|
return `<rect x="${x + inset}" y="${y + inset}" width="${Math.max(0, width - strokeWidth)}" height="${Math.max(0, height - strokeWidth)}" ${fill} ${stroke}${transform}/>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 `<path d="${path}" ${fill} ${strokeSegments.length ? `stroke="none"` : stroke} fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round"${transform}/>${shapeStrokeSvgFragments(strokeSegments, x, y, width, height, transform)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function shapeSvgFragment(node: CanvasNode, x: number, y: number, width: number, height: number, transform = "") {
|
function shapeSvgFragment(node: CanvasNode, x: number, y: number, width: number, height: number, transform = "") {
|
||||||
const options = parseShapeOptions(node.content);
|
const options = parseShapeOptions(node.content);
|
||||||
const strokeWidth = Math.max(0, node.strokeWidth ?? defaultFrameStrokeWidth);
|
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;
|
const inset = strokeWidth / 2;
|
||||||
|
|
||||||
if (options.kind === "path") {
|
if (options.kind === "path") {
|
||||||
return `<path d="${normalizedPathDataToSvg(options.pathData, width, height, x, y)}" ${fill} ${stroke} stroke-linecap="round" stroke-linejoin="round"${transform}/>`;
|
const strokeSegments = options.strokeSegments || [];
|
||||||
|
return `<path d="${normalizedPathDataToSvg(options.pathData, width, height, x, y)}" ${fill} ${strokeSegments.length ? `stroke="none"` : stroke} fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round"${transform}/>${shapeStrokeSvgFragments(strokeSegments, x, y, width, height, transform)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.kind === "line" || options.kind === "arrow") {
|
if (options.kind === "line" || options.kind === "arrow") {
|
||||||
@@ -539,6 +558,20 @@ function shapeSvgFragment(node: CanvasNode, x: number, y: number, width: number,
|
|||||||
return `<rect x="${x + inset}" y="${y + inset}" width="${Math.max(0, width - strokeWidth)}" height="${Math.max(0, height - strokeWidth)}" rx="${Math.min(options.cornerRadius, width / 2, height / 2)}" ${fill} ${stroke}${transform}/>`;
|
return `<rect x="${x + inset}" y="${y + inset}" width="${Math.max(0, width - strokeWidth)}" height="${Math.max(0, height - strokeWidth)}" rx="${Math.min(options.cornerRadius, width / 2, height / 2)}" ${fill} ${stroke}${transform}/>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 `<path d="${path}" fill="none" stroke="${color}" stroke-width="${strokeWidth}" stroke-opacity="${opacity}"${strokeDashArray(segment.strokeStyle, strokeWidth)} stroke-linecap="round" stroke-linejoin="round"${transform}/>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
function shapeSvgFill(node: CanvasNode) {
|
function shapeSvgFill(node: CanvasNode) {
|
||||||
const fillColor = node.fillColor || "transparent";
|
const fillColor = node.fillColor || "transparent";
|
||||||
if (fillColor === "transparent") return `fill="none"`;
|
if (fillColor === "transparent") return `fill="none"`;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { CanvasNode } from "@/domain/design";
|
import type { CanvasNode } from "@/domain/design";
|
||||||
import type { FrameStylePatch } from "@/ui/pages/CanvasWorkspace/canvas/types";
|
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 ShapeKind = "rectangle" | "line" | "arrow" | "ellipse" | "polygon" | "star";
|
||||||
export type ShapeNodeKind = ShapeKind | "path";
|
export type ShapeNodeKind = ShapeKind | "path";
|
||||||
@@ -20,6 +21,15 @@ export type ShapeOptions = {
|
|||||||
start: ShapeEndpoint;
|
start: ShapeEndpoint;
|
||||||
end: ShapeEndpoint;
|
end: ShapeEndpoint;
|
||||||
pathData?: number[];
|
pathData?: number[];
|
||||||
|
strokeSegments?: ShapeStrokeSegment[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ShapeStrokeSegment = {
|
||||||
|
pathData: number[];
|
||||||
|
strokeColor: string;
|
||||||
|
strokeWidth: number;
|
||||||
|
strokeStyle: string;
|
||||||
|
opacity: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ShapeWorldDraft = {
|
export type ShapeWorldDraft = {
|
||||||
@@ -54,6 +64,10 @@ export type BooleanShapeOptions = {
|
|||||||
kind: "boolean";
|
kind: "boolean";
|
||||||
operation: BooleanShapeOperation;
|
operation: BooleanShapeOperation;
|
||||||
operands: BooleanShapeOperand[];
|
operands: BooleanShapeOperand[];
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
pathData?: number[];
|
||||||
|
strokeSegments?: ShapeStrokeSegment[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const shapeKinds: ShapeKind[] = ["rectangle", "line", "arrow", "ellipse", "polygon", "star"];
|
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),
|
curve: clampNumber(parsed.curve, -260, 260, 0),
|
||||||
start: normalizeEndpoint(parsed.start, { x: 0, y: 0 }),
|
start: normalizeEndpoint(parsed.start, { x: 0, y: 0 }),
|
||||||
end: normalizeEndpoint(parsed.end, { x: 1, y: 1 }),
|
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,
|
curve: Math.round(options.curve * 10) / 10,
|
||||||
start: normalizeEndpoint(options.start, { x: 0, y: 0 }),
|
start: normalizeEndpoint(options.start, { x: 0, y: 0 }),
|
||||||
end: normalizeEndpoint(options.end, { x: 1, y: 1 }),
|
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,
|
strokeStyle: node.strokeStyle,
|
||||||
opacity: node.opacity
|
opacity: node.opacity
|
||||||
}));
|
}));
|
||||||
|
const result = booleanShapePathResult(operands, operation, bounds.width, bounds.height);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -203,7 +220,11 @@ export function createBooleanShapeGroupNode({
|
|||||||
content: JSON.stringify({
|
content: JSON.stringify({
|
||||||
kind: "boolean",
|
kind: "boolean",
|
||||||
operation,
|
operation,
|
||||||
operands
|
operands,
|
||||||
|
width: bounds.width,
|
||||||
|
height: bounds.height,
|
||||||
|
pathData: result.pathData,
|
||||||
|
strokeSegments: result.strokeSegments
|
||||||
} satisfies BooleanShapeOptions),
|
} satisfies BooleanShapeOptions),
|
||||||
tone: "shape",
|
tone: "shape",
|
||||||
status: "draft",
|
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 {
|
export function parseBooleanShapeOptions(content: string | undefined): BooleanShapeOptions {
|
||||||
const parsed = safeJson(content);
|
const parsed = safeJson(content);
|
||||||
const operation = isBooleanShapeOperation(parsed.operation) ? parsed.operation : "subtract";
|
const operation = isBooleanShapeOperation(parsed.operation) ? parsed.operation : "subtract";
|
||||||
const rawOperands = Array.isArray(parsed.operands) ? parsed.operands : [];
|
const rawOperands = Array.isArray(parsed.operands) ? parsed.operands : [];
|
||||||
|
const operands = rawOperands.map(normalizeBooleanOperand).filter(Boolean) as BooleanShapeOperand[];
|
||||||
|
const bounds = booleanOperandBounds(operands);
|
||||||
return {
|
return {
|
||||||
kind: "boolean",
|
kind: "boolean",
|
||||||
operation,
|
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 {
|
export function updateBooleanShapeOperation(node: CanvasNode, operation: BooleanShapeOperation): CanvasNode {
|
||||||
if (!isBooleanShapeGroupNode(node)) return node;
|
if (!isBooleanShapeGroupNode(node)) return node;
|
||||||
const options = parseBooleanShapeOptions(node.content);
|
const options = parseBooleanShapeOptions(node.content);
|
||||||
|
const result = booleanShapePathResult(options.operands, operation, options.width, options.height);
|
||||||
return {
|
return {
|
||||||
...node,
|
...node,
|
||||||
title: operation.toUpperCase(),
|
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 width = patch.width ?? node.width;
|
||||||
const height = patch.height ?? node.height;
|
const height = patch.height ?? node.height;
|
||||||
const currentOptions = parseShapeOptions(node.content);
|
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 {
|
return {
|
||||||
...node,
|
...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) {
|
function rectFromShapeDraft(draft: ShapeWorldDraft) {
|
||||||
return {
|
return {
|
||||||
x: draft.x,
|
x: draft.x,
|
||||||
@@ -361,6 +478,40 @@ function normalizePathData(value: unknown) {
|
|||||||
return data.length >= 4 && data.every(Number.isFinite) ? data : undefined;
|
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<ShapeStrokeSegment>;
|
||||||
|
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<string, any> {
|
function safeJson(content: string | undefined): Record<string, any> {
|
||||||
if (!content) return {};
|
if (!content) return {};
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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 { readFile } from "node:fs/promises";
|
||||||
import test from "node:test";
|
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 = {
|
const shapeNode = {
|
||||||
id: "shape-1",
|
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/);
|
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", () => {
|
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 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 = {
|
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");
|
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 () => {
|
test("registers SVGPathEditor on the project's Leafer 2.2 Path runtime", async () => {
|
||||||
await import("@leafer-in/editor");
|
await import("@leafer-in/editor");
|
||||||
const core = await import("@leafer-ui/core");
|
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 () => {
|
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 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 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 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"));
|
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\("pathPen"\)\} P`\}/);
|
||||||
assert.match(source, /title=\{`\$\{t\("pen"\)\} B`\}/);
|
assert.match(source, /title=\{`\$\{t\("pen"\)\} B`\}/);
|
||||||
assert.match(workspaceSource, /if \(activeTool === "pen"\) \{\s+startPenDrawing\(event\);/);
|
assert.match(workspaceSource, /if \(activeTool === "pen"\) \{\s+startPenDrawing\(event\);/);
|
||||||
assert.match(workspaceSource, /setActiveTool\(editAfterCommit \? "path" : "select"\);/);
|
const pathCommitSource = workspaceSource.slice(workspaceSource.indexOf("const commitPathPenDrawing"), workspaceSource.indexOf("const startPathPenDrawing"));
|
||||||
assert.match(workspaceSource, /if \(editAfterCommit\) \{\s+pathEditorExitToolRef\.current = null;\s+setPathEditingNodeId\(node\.id\);\s+\}/);
|
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, /const startPathEditing[\s\S]*?setActiveTool\("path"\);[\s\S]*?setPathEditingNodeId\(node\.id\);/);
|
||||||
assert.match(workspaceSource, /onClose=\{handlePathEditorClose\}/);
|
assert.match(workspaceSource, /onClose=\{handlePathEditorClose\}/);
|
||||||
assert.match(nodePointerSource, /if \(activeTool === "path"\) \{\s+if \(isEditablePathNode\(node\) && editablePathForNode\(node\)\) \{/);
|
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.match(nodePointerSource, /event\.preventDefault\(\);\s+startPathEditing\(node\);\s+return;/);
|
||||||
assert.doesNotMatch(nodePointerSource, /!pathPenDraftRef\.current && isEditablePathNode/);
|
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.doesNotMatch(workspaceSource, /startPenDrawing\(event, true\)/);
|
||||||
assert.match(workspaceSource, /key === "p"\) return void run\(\(\) => chooseCanvasTool\("path"\)\)/);
|
assert.match(workspaceSource, /key === "p"\) return void run\(\(\) => chooseCanvasTool\("path"\)\)/);
|
||||||
assert.match(workspaceSource, /key === "b"\) return void run\(\(\) => chooseCanvasTool\("pen"\)\)/);
|
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-world \*/);
|
||||||
assert.match(styles, /\.canvas-stage\.is-path-pen-tool \.canvas-path-editor-overlay \*/);
|
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, /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