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 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,
|
||||||
|
|||||||
Reference in New Issue
Block a user