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:
@@ -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++];
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user