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:
@@ -28,9 +28,10 @@ export function booleanShapePathResult(operands: BooleanShapeOperand[], operatio
|
||||
}
|
||||
const normalizedWidth = Math.max(width, 1);
|
||||
const normalizedHeight = Math.max(height, 1);
|
||||
const boundaryPoints = sourceBoundaryPoints(sources.map((source) => source.path));
|
||||
return {
|
||||
pathData: paperPathItemToNormalizedPathData(result, normalizedWidth, normalizedHeight),
|
||||
strokeSegments: paperPathItemToStrokeSegments(result, sources, normalizedWidth, normalizedHeight)
|
||||
pathData: paperPathItemToNormalizedPathData(result, boundaryPoints, 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);
|
||||
}
|
||||
|
||||
function paperPathItemToNormalizedPathData(item: paper.PathItem, width: number, height: number) {
|
||||
function paperPathItemToNormalizedPathData(item: paper.PathItem, boundaryPoints: paper.Point[], 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);
|
||||
}
|
||||
const curves = path.curves.flatMap((curve) => splitCurveAtBoundary(curve, boundaryPoints));
|
||||
if (curves.length === 0) return;
|
||||
appendPointCommand(output, command.move, curves[0].point1, width, height);
|
||||
curves.forEach((curve) => appendPaperCurve(output, curve, width, height));
|
||||
if (path.closed) output.push(command.close);
|
||||
});
|
||||
return output;
|
||||
}
|
||||
@@ -80,21 +76,18 @@ function paperPathItemToNormalizedPathData(item: paper.PathItem, width: number,
|
||||
function paperPathItemToStrokeSegments(
|
||||
item: paper.PathItem,
|
||||
sources: Array<{ operand: BooleanShapeOperand; path: paper.PathItem }>,
|
||||
boundaryPoints: paper.Point[],
|
||||
width: number,
|
||||
height: number
|
||||
) {
|
||||
const boundaryPoints = sourceBoundaryPoints(sources.map((source) => source.path));
|
||||
const output: ShapeStrokeSegment[] = [];
|
||||
let edgeIndex = 0;
|
||||
|
||||
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;
|
||||
|
||||
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) {
|
||||
@@ -105,7 +98,7 @@ function paperPathItemToStrokeSegments(
|
||||
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));
|
||||
output.push(strokeSegmentForOperand(pathData, run.map((part) => part.edgeIndex), sources[run[0].sourceIndex].operand));
|
||||
runStart = runEnd;
|
||||
}
|
||||
});
|
||||
@@ -118,6 +111,16 @@ function styledCurveParts(
|
||||
sources: Array<{ operand: BooleanShapeOperand; path: paper.PathItem }>,
|
||||
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];
|
||||
boundaryPoints.forEach((point) => {
|
||||
const time = curve.getTimeOf(point);
|
||||
@@ -125,15 +128,7 @@ function styledCurveParts(
|
||||
});
|
||||
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)
|
||||
};
|
||||
});
|
||||
return uniqueTimes.slice(0, -1).map((from, index) => curve.getPart(from, uniqueTimes[index + 1]));
|
||||
}
|
||||
|
||||
function sourceBoundaryPoints(paths: paper.PathItem[]) {
|
||||
@@ -159,9 +154,10 @@ function nearestSourceIndex(point: paper.Point, sources: Array<{ path: paper.Pat
|
||||
return nearestIndex;
|
||||
}
|
||||
|
||||
function strokeSegmentForOperand(pathData: number[], operand: BooleanShapeOperand): ShapeStrokeSegment {
|
||||
function strokeSegmentForOperand(pathData: number[], edgeIndexes: number[], operand: BooleanShapeOperand): ShapeStrokeSegment {
|
||||
return {
|
||||
pathData,
|
||||
edgeIndexes,
|
||||
strokeColor: operand.strokeColor || "#111827",
|
||||
strokeWidth: Math.max(operand.strokeWidth ?? 2, 0),
|
||||
strokeStyle: operand.strokeStyle || "solid",
|
||||
@@ -175,22 +171,6 @@ function paperPaths(item: paper.PathItem): paper.Path[] {
|
||||
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);
|
||||
|
||||
@@ -26,6 +26,7 @@ export type ShapeOptions = {
|
||||
|
||||
export type ShapeStrokeSegment = {
|
||||
pathData: number[];
|
||||
edgeIndexes?: number[];
|
||||
strokeColor: string;
|
||||
strokeWidth: number;
|
||||
strokeStyle: string;
|
||||
@@ -488,6 +489,7 @@ function normalizeStrokeSegments(value: unknown) {
|
||||
if (!pathData) return null;
|
||||
return {
|
||||
pathData,
|
||||
edgeIndexes: normalizeEdgeIndexes(segment.edgeIndexes),
|
||||
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",
|
||||
@@ -498,6 +500,12 @@ function normalizeStrokeSegments(value: unknown) {
|
||||
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[]) {
|
||||
return pathData.map((value) => Math.round(value * 100_000) / 100_000);
|
||||
}
|
||||
@@ -505,6 +513,7 @@ function roundedPathData(pathData: number[]) {
|
||||
function serializeStrokeSegment(segment: ShapeStrokeSegment): ShapeStrokeSegment {
|
||||
return {
|
||||
pathData: roundedPathData(segment.pathData),
|
||||
...(segment.edgeIndexes?.length ? { edgeIndexes: segment.edgeIndexes } : {}),
|
||||
strokeColor: segment.strokeColor,
|
||||
strokeWidth: Math.round(segment.strokeWidth * 1000) / 1000,
|
||||
strokeStyle: segment.strokeStyle,
|
||||
|
||||
Reference in New Issue
Block a user