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
+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", () => {