feat(canvas): remap per-boundary stroke styles when a path is edited

Instead of discarding stroke segments after a vertex edit,
strokeSegmentsForEditedPath projects each stored edge index onto the
edited path's edges and rebuilds every segment's geometry from the
edges it now owns, so boolean results keep their per-operand colors,
widths, and dash styles through the path editor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 00:59:23 +08:00
parent 2f5607f58e
commit ca101fd916
2 changed files with 121 additions and 7 deletions
@@ -1,6 +1,6 @@
import type { CanvasNode } from "@/domain/design";
import { booleanShapePathData } from "./booleanShapePath.ts";
import { isBooleanShapeGroupNode, isShapeNode, parseBooleanShapeOptions, parseShapeOptions, shapeEndpointToWorldPoint, shapeOptionsToContent } from "./shapeNode.ts";
import { isBooleanShapeGroupNode, isShapeNode, parseBooleanShapeOptions, parseShapeOptions, shapeEndpointToWorldPoint, shapeOptionsToContent, type ShapeStrokeSegment } from "./shapeNode.ts";
export type EditablePathData = number[];
@@ -34,13 +34,15 @@ export function applyEditedPathToNode(node: CanvasNode, pathData: EditablePathDa
if (isShapeNode(node)) {
const options = parseShapeOptions(node.content);
const remappedStrokeSegments = strokeSegmentsForEditedPath(normalized.pathData, options.strokeSegments);
const strokeSegments = remappedStrokeSegments || (pathDataApproximatelyEqual(options.pathData, normalized.pathData) ? options.strokeSegments : undefined);
return {
...node,
x: normalized.x,
y: normalized.y,
width: normalized.width,
height: normalized.height,
content: shapeOptionsToContent({ ...options, kind: "path", pathData: normalized.pathData, strokeSegments: undefined })
content: shapeOptionsToContent({ ...options, kind: "path", pathData: normalized.pathData, strokeSegments })
};
}
@@ -76,6 +78,36 @@ export function normalizedPathDataToSvg(pathData: EditablePathData | undefined,
return pathDataToSvg(transformPathData(pathData, (value, axis) => value * (axis === "x" ? width : height) + (axis === "x" ? offsetX : offsetY)));
}
export function strokeSegmentsForEditedPath(pathData: EditablePathData, segments: ShapeStrokeSegment[] | undefined) {
if (!segments?.length) return undefined;
const edges = editablePathEdges(pathData);
const indexedSegments = segments.filter((segment) => segment.edgeIndexes?.length);
if (edges.length === 0 || indexedSegments.length === 0) return undefined;
const sourceEdgeCount = Math.max(...indexedSegments.flatMap((segment) => segment.edgeIndexes || [])) + 1;
const assignments = edges.map((_edge, edgeIndex) => {
const projectedSourceIndex = edges.length === 1 ? 0 : Math.round((edgeIndex / (edges.length - 1)) * Math.max(sourceEdgeCount - 1, 0));
let nearestSegmentIndex = 0;
let nearestDistance = Number.POSITIVE_INFINITY;
indexedSegments.forEach((segment, segmentIndex) => {
segment.edgeIndexes?.forEach((sourceEdgeIndex) => {
const distance = Math.abs(sourceEdgeIndex - projectedSourceIndex);
if (distance < nearestDistance) {
nearestDistance = distance;
nearestSegmentIndex = segmentIndex;
}
});
});
return nearestSegmentIndex;
});
return indexedSegments.reduce<ShapeStrokeSegment[]>((output, segment, segmentIndex) => {
const edgeIndexes = assignments.flatMap((assignment, edgeIndex) => (assignment === segmentIndex ? [edgeIndex] : []));
if (edgeIndexes.length > 0) output.push({ ...segment, edgeIndexes, pathData: pathDataForEdges(edges, edgeIndexes) });
return output;
}, []);
}
export function pathDataToSvg(pathData: EditablePathData) {
const output: string[] = [];
for (let index = 0; index < pathData.length; ) {
@@ -187,6 +219,62 @@ function transformPathData(pathData: EditablePathData, transform: (value: number
return output;
}
type EditablePathEdge = {
start: { x: number; y: number };
end: { x: number; y: number };
commandData: number[];
};
function editablePathEdges(pathData: EditablePathData) {
const edges: EditablePathEdge[] = [];
let current: { x: number; y: number } | null = null;
let subpathStart: { x: number; y: number } | null = null;
for (let index = 0; index < pathData.length; ) {
const name = pathData[index++];
if (name === command.move) {
current = { x: pathData[index++], y: pathData[index++] };
subpathStart = current;
continue;
}
const coordinateCount = name === command.line ? 2 : name === command.cubic ? 6 : name === command.quadratic ? 4 : name === command.close ? 0 : -1;
if (coordinateCount < 0) return [];
if (name === command.close) {
if (current && subpathStart && !samePoint(current, subpathStart)) {
edges.push({ start: current, end: subpathStart, commandData: [command.line, subpathStart.x, subpathStart.y] });
}
current = subpathStart;
continue;
}
const commandData = [name, ...pathData.slice(index, index + coordinateCount)];
const end = { x: pathData[index + coordinateCount - 2], y: pathData[index + coordinateCount - 1] };
if (current) edges.push({ start: current, end, commandData });
current = end;
index += coordinateCount;
}
return edges;
}
function pathDataForEdges(edges: EditablePathEdge[], edgeIndexes: number[]) {
const output: number[] = [];
let previousEnd: { x: number; y: number } | null = null;
edgeIndexes.forEach((edgeIndex) => {
const edge = edges[edgeIndex];
if (!edge) return;
if (!previousEnd || !samePoint(previousEnd, edge.start)) output.push(command.move, edge.start.x, edge.start.y);
output.push(...edge.commandData);
previousEnd = edge.end;
});
return output;
}
function samePoint(first: { x: number; y: number }, second: { x: number; y: number }) {
return Math.abs(first.x - second.x) < 0.000001 && Math.abs(first.y - second.y) < 0.000001;
}
function pathDataApproximatelyEqual(first: EditablePathData | undefined, second: EditablePathData) {
return Boolean(first && first.length === second.length && first.every((value, index) => Math.abs(value - second[index]) < 0.00001));
}
function walkPathCoordinates(pathData: EditablePathData, visit: (x: number, y: number) => void) {
for (let index = 0; index < pathData.length; ) {
const name = pathData[index++];
+31 -5
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { applyEditedPathToNode, applyEditedPathToNodes, editablePathForNode, isEditablePathNode, normalizedPathDataToSvg } from "../src/ui/pages/CanvasWorkspace/canvas/editablePath.ts";
import { applyEditedPathToNode, applyEditedPathToNodes, editablePathForNode, isEditablePathNode, normalizedPathDataToSvg, strokeSegmentsForEditedPath } from "../src/ui/pages/CanvasWorkspace/canvas/editablePath.ts";
import { createBooleanShapeGroupNode } from "../src/ui/pages/CanvasWorkspace/canvas/shapeNode.ts";
const shapeNode = {
@@ -42,18 +42,44 @@ 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", () => {
test("preserves per-boundary colors and widths while 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 }]
strokeSegments: [
{ pathData: [1, 0, 0, 2, 1, 0, 2, 1, 1], edgeIndexes: [0, 1], strokeColor: "#111111", strokeWidth: 3, strokeStyle: "solid", opacity: 1 },
{ pathData: [1, 1, 1, 2, 0, 1, 2, 0, 0], edgeIndexes: [2, 3], strokeColor: "#7C3AED", strokeWidth: 5, strokeStyle: "solid", opacity: 1 }
]
})
};
const edited = applyEditedPathToNode(booleanResult, [1, 0, 0, 2, 100, 0, 2, 50, 60, 11]);
const edited = applyEditedPathToNode(booleanResult, [1, 0, 0, 2, 100, 5, 2, 95, 60, 2, 0, 55, 2, 0, 0, 11]);
const options = JSON.parse(edited.content);
assert.equal(JSON.parse(edited.content).strokeSegments, undefined);
assert.deepEqual(new Set(options.strokeSegments.map((segment) => segment.strokeColor)), new Set(["#111111", "#7C3AED"]));
assert.deepEqual(new Set(options.strokeSegments.map((segment) => segment.strokeWidth)), new Set([3, 5]));
assert.ok(options.strokeSegments.every((segment) => segment.edgeIndexes.length > 0));
assert.notDeepEqual(options.strokeSegments[0].pathData, JSON.parse(booleanResult.content).strokeSegments[0].pathData);
});
test("keeps the closing stroke group when the path editor normalizes the edge count", () => {
const editedPath = [1, 0, 0];
for (let index = 1; index <= 13; index += 1) editedPath.push(2, index, index % 2);
const styles = [
{ edgeIndexes: [0, 1, 2, 3], strokeColor: "#111111" },
{ edgeIndexes: [4, 5, 6, 7], strokeColor: "#7C3AED" },
{ edgeIndexes: [8, 9, 10, 11], strokeColor: "#0EA5E9" },
{ edgeIndexes: [12, 13, 14, 15], strokeColor: "#EF4444" }
].map((style) => ({ ...style, pathData: [1, 0, 0, 2, 1, 0], strokeWidth: 3, strokeStyle: "solid", opacity: 1 }));
const remapped = strokeSegmentsForEditedPath(editedPath, styles);
assert.equal(remapped.length, 4);
assert.deepEqual(new Set(remapped.map((segment) => segment.strokeColor)), new Set(styles.map((segment) => segment.strokeColor)));
assert.deepEqual(
new Set(remapped.flatMap((segment) => segment.edgeIndexes)),
new Set(Array.from({ length: 13 }, (_, index) => index))
);
});
test("keeps unmarked Brush strokes outside the vertex editor", () => {