feat(canvas): tag boolean stroke segments with stable edge indexes

Split result-path curves at operand boundary points so the emitted
pathData edges line up one-to-one with stroke segment geometry, and
record each segment's edge indexes so later edits can re-associate
styles with the boundaries they came from.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 00:59:01 +08:00
parent 874bf9b215
commit 2f5607f58e
2 changed files with 35 additions and 46 deletions
@@ -28,9 +28,10 @@ export function booleanShapePathResult(operands: BooleanShapeOperand[], operatio
} }
const normalizedWidth = Math.max(width, 1); const normalizedWidth = Math.max(width, 1);
const normalizedHeight = Math.max(height, 1); const normalizedHeight = Math.max(height, 1);
const boundaryPoints = sourceBoundaryPoints(sources.map((source) => source.path));
return { return {
pathData: paperPathItemToNormalizedPathData(result, normalizedWidth, normalizedHeight), pathData: paperPathItemToNormalizedPathData(result, boundaryPoints, normalizedWidth, normalizedHeight),
strokeSegments: paperPathItemToStrokeSegments(result, sources, normalizedWidth, normalizedHeight) strokeSegments: paperPathItemToStrokeSegments(result, sources, boundaryPoints, normalizedWidth, normalizedHeight)
}; };
} }
@@ -60,19 +61,14 @@ function applyBooleanOperation(first: paper.PathItem, second: paper.PathItem, op
return first.unite(second, options); return first.unite(second, options);
} }
function paperPathItemToNormalizedPathData(item: paper.PathItem, width: number, height: number) { function paperPathItemToNormalizedPathData(item: paper.PathItem, boundaryPoints: paper.Point[], width: number, height: number) {
const output: number[] = []; const output: number[] = [];
paperPaths(item).forEach((path) => { paperPaths(item).forEach((path) => {
const segments = path.segments; const curves = path.curves.flatMap((curve) => splitCurveAtBoundary(curve, boundaryPoints));
if (segments.length === 0) return; if (curves.length === 0) return;
appendPointCommand(output, command.move, segments[0].point, width, height); appendPointCommand(output, command.move, curves[0].point1, width, height);
for (let index = 1; index < segments.length; index += 1) appendPaperSegment(output, segments[index - 1], segments[index], width, height); curves.forEach((curve) => appendPaperCurve(output, curve, width, height));
if (path.closed) { if (path.closed) output.push(command.close);
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; return output;
} }
@@ -80,21 +76,18 @@ function paperPathItemToNormalizedPathData(item: paper.PathItem, width: number,
function paperPathItemToStrokeSegments( function paperPathItemToStrokeSegments(
item: paper.PathItem, item: paper.PathItem,
sources: Array<{ operand: BooleanShapeOperand; path: paper.PathItem }>, sources: Array<{ operand: BooleanShapeOperand; path: paper.PathItem }>,
boundaryPoints: paper.Point[],
width: number, width: number,
height: number height: number
) { ) {
const boundaryPoints = sourceBoundaryPoints(sources.map((source) => source.path));
const output: ShapeStrokeSegment[] = []; const output: ShapeStrokeSegment[] = [];
let edgeIndex = 0;
paperPaths(item).forEach((path) => { paperPaths(item).forEach((path) => {
let parts = path.curves.flatMap((curve) => styledCurveParts(curve, sources, boundaryPoints)); const parts = path.curves.flatMap((curve) => styledCurveParts(curve, sources, boundaryPoints)).map((part) => ({ ...part, edgeIndex: edgeIndex++ }));
if (parts.length === 0) return; if (parts.length === 0) return;
const singleSource = parts.every((part) => part.sourceIndex === parts[0].sourceIndex); 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; let runStart = 0;
while (runStart < parts.length) { while (runStart < parts.length) {
@@ -105,7 +98,7 @@ function paperPathItemToStrokeSegments(
appendPointCommand(pathData, command.move, run[0].curve.point1, width, height); appendPointCommand(pathData, command.move, run[0].curve.point1, width, height);
run.forEach((part) => appendPaperCurve(pathData, part.curve, width, height)); run.forEach((part) => appendPaperCurve(pathData, part.curve, width, height));
if (singleSource && path.closed) pathData.push(command.close); if (singleSource && path.closed) pathData.push(command.close);
output.push(strokeSegmentForOperand(pathData, sources[run[0].sourceIndex].operand)); output.push(strokeSegmentForOperand(pathData, run.map((part) => part.edgeIndex), sources[run[0].sourceIndex].operand));
runStart = runEnd; runStart = runEnd;
} }
}); });
@@ -118,6 +111,16 @@ function styledCurveParts(
sources: Array<{ operand: BooleanShapeOperand; path: paper.PathItem }>, sources: Array<{ operand: BooleanShapeOperand; path: paper.PathItem }>,
boundaryPoints: paper.Point[] boundaryPoints: paper.Point[]
) { ) {
return splitCurveAtBoundary(curve, boundaryPoints).map((part) => {
const midpoint = part.getPointAtTime(0.5);
return {
curve: part,
sourceIndex: nearestSourceIndex(midpoint, sources)
};
});
}
function splitCurveAtBoundary(curve: paper.Curve, boundaryPoints: paper.Point[]) {
const times = [0, 1]; const times = [0, 1];
boundaryPoints.forEach((point) => { boundaryPoints.forEach((point) => {
const time = curve.getTimeOf(point); const time = curve.getTimeOf(point);
@@ -125,15 +128,7 @@ function styledCurveParts(
}); });
times.sort((first, second) => first - second); times.sort((first, second) => first - second);
const uniqueTimes = times.filter((time, index) => index === 0 || Math.abs(time - times[index - 1]) > 0.000001); const uniqueTimes = times.filter((time, index) => index === 0 || Math.abs(time - times[index - 1]) > 0.000001);
return uniqueTimes.slice(0, -1).map((from, index) => curve.getPart(from, uniqueTimes[index + 1]));
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[]) { function sourceBoundaryPoints(paths: paper.PathItem[]) {
@@ -159,9 +154,10 @@ function nearestSourceIndex(point: paper.Point, sources: Array<{ path: paper.Pat
return nearestIndex; return nearestIndex;
} }
function strokeSegmentForOperand(pathData: number[], operand: BooleanShapeOperand): ShapeStrokeSegment { function strokeSegmentForOperand(pathData: number[], edgeIndexes: number[], operand: BooleanShapeOperand): ShapeStrokeSegment {
return { return {
pathData, pathData,
edgeIndexes,
strokeColor: operand.strokeColor || "#111827", strokeColor: operand.strokeColor || "#111827",
strokeWidth: Math.max(operand.strokeWidth ?? 2, 0), strokeWidth: Math.max(operand.strokeWidth ?? 2, 0),
strokeStyle: operand.strokeStyle || "solid", strokeStyle: operand.strokeStyle || "solid",
@@ -175,22 +171,6 @@ function paperPaths(item: paper.PathItem): paper.Path[] {
return []; 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) { function appendPaperCurve(output: number[], curve: paper.Curve, width: number, height: number) {
if (curve.isStraight()) { if (curve.isStraight()) {
appendPointCommand(output, command.line, curve.point2, width, height); appendPointCommand(output, command.line, curve.point2, width, height);
@@ -26,6 +26,7 @@ export type ShapeOptions = {
export type ShapeStrokeSegment = { export type ShapeStrokeSegment = {
pathData: number[]; pathData: number[];
edgeIndexes?: number[];
strokeColor: string; strokeColor: string;
strokeWidth: number; strokeWidth: number;
strokeStyle: string; strokeStyle: string;
@@ -488,6 +489,7 @@ function normalizeStrokeSegments(value: unknown) {
if (!pathData) return null; if (!pathData) return null;
return { return {
pathData, pathData,
edgeIndexes: normalizeEdgeIndexes(segment.edgeIndexes),
strokeColor: typeof segment.strokeColor === "string" && segment.strokeColor ? segment.strokeColor : defaultShapeStrokeColor, strokeColor: typeof segment.strokeColor === "string" && segment.strokeColor ? segment.strokeColor : defaultShapeStrokeColor,
strokeWidth: Math.max(0, Number.isFinite(Number(segment.strokeWidth)) ? Number(segment.strokeWidth) : defaultShapeStrokeWidth), strokeWidth: Math.max(0, Number.isFinite(Number(segment.strokeWidth)) ? Number(segment.strokeWidth) : defaultShapeStrokeWidth),
strokeStyle: typeof segment.strokeStyle === "string" ? segment.strokeStyle : "solid", strokeStyle: typeof segment.strokeStyle === "string" ? segment.strokeStyle : "solid",
@@ -498,6 +500,12 @@ function normalizeStrokeSegments(value: unknown) {
return segments.length ? segments : undefined; return segments.length ? segments : undefined;
} }
function normalizeEdgeIndexes(value: unknown) {
if (!Array.isArray(value)) return undefined;
const indexes = [...new Set(value.map(Number).filter((index) => Number.isInteger(index) && index >= 0))];
return indexes.length ? indexes : undefined;
}
function roundedPathData(pathData: number[]) { function roundedPathData(pathData: number[]) {
return pathData.map((value) => Math.round(value * 100_000) / 100_000); return pathData.map((value) => Math.round(value * 100_000) / 100_000);
} }
@@ -505,6 +513,7 @@ function roundedPathData(pathData: number[]) {
function serializeStrokeSegment(segment: ShapeStrokeSegment): ShapeStrokeSegment { function serializeStrokeSegment(segment: ShapeStrokeSegment): ShapeStrokeSegment {
return { return {
pathData: roundedPathData(segment.pathData), pathData: roundedPathData(segment.pathData),
...(segment.edgeIndexes?.length ? { edgeIndexes: segment.edgeIndexes } : {}),
strokeColor: segment.strokeColor, strokeColor: segment.strokeColor,
strokeWidth: Math.round(segment.strokeWidth * 1000) / 1000, strokeWidth: Math.round(segment.strokeWidth * 1000) / 1000,
strokeStyle: segment.strokeStyle, strokeStyle: segment.strokeStyle,