Files
OpenCut/apps/web/src/masks/freeform/path.ts
T
2026-05-02 03:27:23 +02:00

836 lines
16 KiB
TypeScript

import type { ElementBounds } from "@/preview/element-bounds";
export interface FreeformPathPoint {
id: string;
x: number;
y: number;
inX: number;
inY: number;
outX: number;
outY: number;
}
function isFreeformPathPoint(value: unknown): value is FreeformPathPoint {
if (!value || typeof value !== "object") {
return false;
}
return (
"id" in value &&
typeof value.id === "string" &&
"x" in value &&
typeof value.x === "number" &&
"y" in value &&
typeof value.y === "number" &&
"inX" in value &&
typeof value.inX === "number" &&
"inY" in value &&
typeof value.inY === "number" &&
"outX" in value &&
typeof value.outX === "number" &&
"outY" in value &&
typeof value.outY === "number"
);
}
export function parseFreeformPath({
path,
}: {
path: string;
}): FreeformPathPoint[] {
if (!path) {
return [];
}
try {
const parsed = JSON.parse(path);
return Array.isArray(parsed) ? parsed.filter(isFreeformPathPoint) : [];
} catch {
return [];
}
}
export function serializeFreeformPath({
points,
}: {
points: FreeformPathPoint[];
}): string {
return JSON.stringify(points);
}
export function removeFreeformPathPoints({
points,
pointIds,
}: {
points: FreeformPathPoint[];
pointIds: string[];
}): FreeformPathPoint[] {
if (pointIds.length === 0) {
return points;
}
const pointIdsToRemove = new Set(pointIds);
return points.filter((point) => !pointIdsToRemove.has(point.id));
}
export function getFreeformPathClosedStateAfterPointRemoval({
wasClosed,
remainingPointCount,
}: {
wasClosed: boolean;
remainingPointCount: number;
}): boolean {
return wasClosed && remainingPointCount >= 3;
}
function rotatePoint({
x,
y,
rotationDegrees,
}: {
x: number;
y: number;
rotationDegrees: number;
}): { x: number; y: number } {
const angleRad = (rotationDegrees * Math.PI) / 180;
const cos = Math.cos(angleRad);
const sin = Math.sin(angleRad);
return {
x: x * cos - y * sin,
y: x * sin + y * cos,
};
}
export function getFreeformCenterCanvasPoint({
centerX,
centerY,
bounds,
}: {
centerX: number;
centerY: number;
bounds: ElementBounds;
}): { x: number; y: number } {
return {
x: bounds.cx + centerX * bounds.width,
y: bounds.cy + centerY * bounds.height,
};
}
export function freeformLocalPointToCanvas({
point,
centerX,
centerY,
rotation,
scale,
bounds,
}: {
point: { x: number; y: number };
centerX: number;
centerY: number;
rotation: number;
scale: number;
bounds: ElementBounds;
}): { x: number; y: number } {
const center = getFreeformCenterCanvasPoint({ centerX, centerY, bounds });
const scaledLocal = {
x: point.x * bounds.width * scale,
y: point.y * bounds.height * scale,
};
const rotated = rotatePoint({
x: scaledLocal.x,
y: scaledLocal.y,
rotationDegrees: rotation,
});
return {
x: center.x + rotated.x,
y: center.y + rotated.y,
};
}
export function freeformCanvasPointToLocal({
point,
centerX,
centerY,
rotation,
scale,
bounds,
}: {
point: { x: number; y: number };
centerX: number;
centerY: number;
rotation: number;
scale: number;
bounds: ElementBounds;
}): { x: number; y: number } {
const center = getFreeformCenterCanvasPoint({ centerX, centerY, bounds });
const translated = {
x: point.x - center.x,
y: point.y - center.y,
};
const rotated = rotatePoint({
x: translated.x,
y: translated.y,
rotationDegrees: -rotation,
});
return {
x: bounds.width === 0 ? 0 : rotated.x / (bounds.width * scale),
y: bounds.height === 0 ? 0 : rotated.y / (bounds.height * scale),
};
}
export function getFreeformCanvasGeometry({
points,
centerX,
centerY,
rotation,
scale,
bounds,
}: {
points: FreeformPathPoint[];
centerX: number;
centerY: number;
rotation: number;
scale: number;
bounds: ElementBounds;
}) {
const anchors = points.map((point) => ({
id: point.id,
anchor: freeformLocalPointToCanvas({
point: { x: point.x, y: point.y },
centerX,
centerY,
rotation,
scale,
bounds,
}),
inHandle: freeformLocalPointToCanvas({
point: { x: point.x + point.inX, y: point.y + point.inY },
centerX,
centerY,
rotation,
scale,
bounds,
}),
outHandle: freeformLocalPointToCanvas({
point: { x: point.x + point.outX, y: point.y + point.outY },
centerX,
centerY,
rotation,
scale,
bounds,
}),
}));
const geometryPoints = anchors.flatMap((point) => [
point.anchor,
point.inHandle,
point.outHandle,
]);
if (geometryPoints.length === 0) {
return {
anchors,
bounds: null,
};
}
const geometryBounds = getCanvasPointBounds({
points: geometryPoints,
});
return {
anchors,
bounds: geometryBounds,
};
}
export interface CanvasPoint {
x: number;
y: number;
}
function getCanvasPointBounds({ points }: { points: CanvasPoint[] }): {
minX: number;
maxX: number;
minY: number;
maxY: number;
width: number;
height: number;
centerX: number;
centerY: number;
} | null {
if (points.length === 0) {
return null;
}
const [firstPoint, ...restPoints] = points;
let minX = firstPoint.x;
let maxX = firstPoint.x;
let minY = firstPoint.y;
let maxY = firstPoint.y;
for (const point of restPoints) {
minX = Math.min(minX, point.x);
maxX = Math.max(maxX, point.x);
minY = Math.min(minY, point.y);
maxY = Math.max(maxY, point.y);
}
return {
minX,
maxX,
minY,
maxY,
width: Math.max(1, maxX - minX),
height: Math.max(1, maxY - minY),
centerX: (minX + maxX) / 2,
centerY: (minY + maxY) / 2,
};
}
export interface FreeformCanvasSegment {
index: number;
startPointId: string;
endPointId: string;
start: CanvasPoint;
startOut: CanvasPoint;
endIn: CanvasPoint;
end: CanvasPoint;
pathData: string;
}
function clampUnit(value: number): number {
return Math.min(1, Math.max(0, value));
}
function getDistanceSquared({
a,
b,
}: {
a: CanvasPoint;
b: CanvasPoint;
}): number {
const dx = a.x - b.x;
const dy = a.y - b.y;
return dx * dx + dy * dy;
}
function lerpPoint({
a,
b,
t,
}: {
a: CanvasPoint;
b: CanvasPoint;
t: number;
}): CanvasPoint {
return {
x: a.x + (b.x - a.x) * t,
y: a.y + (b.y - a.y) * t,
};
}
function evaluateCubicBezier({
p0,
p1,
p2,
p3,
t,
}: {
p0: CanvasPoint;
p1: CanvasPoint;
p2: CanvasPoint;
p3: CanvasPoint;
t: number;
}): CanvasPoint {
const oneMinusT = 1 - t;
return {
x:
oneMinusT ** 3 * p0.x +
3 * oneMinusT ** 2 * t * p1.x +
3 * oneMinusT * t ** 2 * p2.x +
t ** 3 * p3.x,
y:
oneMinusT ** 3 * p0.y +
3 * oneMinusT ** 2 * t * p1.y +
3 * oneMinusT * t ** 2 * p2.y +
t ** 3 * p3.y,
};
}
function getFreeformSegmentIndices({
points,
segmentIndex,
closed,
}: {
points: FreeformPathPoint[];
segmentIndex: number;
closed: boolean;
}): { startIndex: number; endIndex: number } | null {
const segmentCount = getFreeformSegmentCount({ points, closed });
if (segmentIndex < 0 || segmentIndex >= segmentCount) {
return null;
}
return {
startIndex: segmentIndex,
endIndex: (segmentIndex + 1) % points.length,
};
}
export function getFreeformSegmentCount({
points,
closed,
}: {
points: FreeformPathPoint[];
closed: boolean;
}): number {
if (points.length < 2) {
return 0;
}
return closed ? points.length : points.length - 1;
}
export function getFreeformCanvasSegments({
points,
centerX,
centerY,
rotation,
scale,
bounds,
closed,
}: {
points: FreeformPathPoint[];
centerX: number;
centerY: number;
rotation: number;
scale: number;
bounds: ElementBounds;
closed: boolean;
}): FreeformCanvasSegment[] {
const geometry = getFreeformCanvasGeometry({
points,
centerX,
centerY,
rotation,
scale,
bounds,
});
const segmentCount = getFreeformSegmentCount({ points, closed });
return Array.from({ length: segmentCount }, (_, segmentIndex) => {
const start = geometry.anchors[segmentIndex];
const end = geometry.anchors[(segmentIndex + 1) % geometry.anchors.length];
return {
index: segmentIndex,
startPointId: start.id,
endPointId: end.id,
start: start.anchor,
startOut: start.outHandle,
endIn: end.inHandle,
end: end.anchor,
pathData: `M ${start.anchor.x},${start.anchor.y} C ${start.outHandle.x},${start.outHandle.y} ${end.inHandle.x},${end.inHandle.y} ${end.anchor.x},${end.anchor.y}`,
};
});
}
export function findClosestPointOnFreeformSegment({
points,
segmentIndex,
canvasPoint,
centerX,
centerY,
rotation,
scale,
bounds,
closed,
}: {
points: FreeformPathPoint[];
segmentIndex: number;
canvasPoint: CanvasPoint;
centerX: number;
centerY: number;
rotation: number;
scale: number;
bounds: ElementBounds;
closed: boolean;
}): { t: number; point: CanvasPoint } | null {
const segment = getFreeformCanvasSegments({
points,
centerX,
centerY,
rotation,
scale,
bounds,
closed,
}).find((candidate) => candidate.index === segmentIndex);
if (!segment) {
return null;
}
const sampleCount = 24;
let bestT = 0;
let bestDistanceSquared = getDistanceSquared({
a: canvasPoint,
b: segment.start,
});
for (let step = 0; step <= sampleCount; step++) {
const t = step / sampleCount;
const point = evaluateCubicBezier({
p0: segment.start,
p1: segment.startOut,
p2: segment.endIn,
p3: segment.end,
t,
});
const distanceSquared = getDistanceSquared({ a: canvasPoint, b: point });
if (distanceSquared < bestDistanceSquared) {
bestDistanceSquared = distanceSquared;
bestT = t;
}
}
let searchStep = 1 / sampleCount;
for (let iteration = 0; iteration < 8; iteration++) {
const candidates = [bestT - searchStep, bestT, bestT + searchStep]
.map(clampUnit)
.map((t) => ({
t,
point: evaluateCubicBezier({
p0: segment.start,
p1: segment.startOut,
p2: segment.endIn,
p3: segment.end,
t,
}),
}));
for (const candidate of candidates) {
const distanceSquared = getDistanceSquared({
a: canvasPoint,
b: candidate.point,
});
if (distanceSquared < bestDistanceSquared) {
bestDistanceSquared = distanceSquared;
bestT = candidate.t;
}
}
searchStep /= 2;
}
const clampedT = Math.min(0.999, Math.max(0.001, bestT));
return {
t: clampedT,
point: evaluateCubicBezier({
p0: segment.start,
p1: segment.startOut,
p2: segment.endIn,
p3: segment.end,
t: clampedT,
}),
};
}
export function insertPointIntoFreeformSegment({
points,
segmentIndex,
pointId,
t,
closed,
}: {
points: FreeformPathPoint[];
segmentIndex: number;
pointId: string;
t: number;
closed: boolean;
}): FreeformPathPoint[] {
const indices = getFreeformSegmentIndices({
points,
segmentIndex,
closed,
});
if (!indices) {
return points;
}
const startPoint = points[indices.startIndex];
const endPoint = points[indices.endIndex];
const clampedT = Math.min(0.999, Math.max(0.001, t));
const p0 = { x: startPoint.x, y: startPoint.y };
const p1 = {
x: startPoint.x + startPoint.outX,
y: startPoint.y + startPoint.outY,
};
const p2 = {
x: endPoint.x + endPoint.inX,
y: endPoint.y + endPoint.inY,
};
const p3 = { x: endPoint.x, y: endPoint.y };
const p01 = lerpPoint({ a: p0, b: p1, t: clampedT });
const p12 = lerpPoint({ a: p1, b: p2, t: clampedT });
const p23 = lerpPoint({ a: p2, b: p3, t: clampedT });
const p012 = lerpPoint({ a: p01, b: p12, t: clampedT });
const p123 = lerpPoint({ a: p12, b: p23, t: clampedT });
const splitPoint = lerpPoint({ a: p012, b: p123, t: clampedT });
const nextPoints = [...points];
nextPoints[indices.startIndex] = {
...startPoint,
outX: p01.x - startPoint.x,
outY: p01.y - startPoint.y,
};
nextPoints[indices.endIndex] = {
...endPoint,
inX: p23.x - endPoint.x,
inY: p23.y - endPoint.y,
};
nextPoints.splice(indices.endIndex, 0, {
id: pointId,
x: splitPoint.x,
y: splitPoint.y,
inX: p012.x - splitPoint.x,
inY: p012.y - splitPoint.y,
outX: p123.x - splitPoint.x,
outY: p123.y - splitPoint.y,
});
return nextPoints;
}
export function getFreeformLocalBounds({
points,
bounds,
}: {
points: FreeformPathPoint[];
bounds: ElementBounds;
}) {
if (points.length === 0) {
return null;
}
const values = points.flatMap((point) => [
{ x: point.x * bounds.width, y: point.y * bounds.height },
{
x: (point.x + point.inX) * bounds.width,
y: (point.y + point.inY) * bounds.height,
},
{
x: (point.x + point.outX) * bounds.width,
y: (point.y + point.outY) * bounds.height,
},
]);
const localBounds = getCanvasPointBounds({
points: values,
});
if (!localBounds) {
return null;
}
return {
width: localBounds.width,
height: localBounds.height,
};
}
export function recenterFreeformPath({
points,
centerX,
centerY,
rotation,
scale,
bounds,
}: {
points: FreeformPathPoint[];
centerX: number;
centerY: number;
rotation: number;
scale: number;
bounds: ElementBounds;
}) {
if (points.length === 0) {
return { centerX, centerY, points };
}
const geometry = getFreeformCanvasGeometry({
points,
centerX,
centerY,
rotation,
scale,
bounds,
});
if (!geometry.bounds) {
return { centerX, centerY, points };
}
const nextCenterCanvas = {
x: geometry.bounds.centerX,
y: geometry.bounds.centerY,
};
const nextCenterLocal = {
x: bounds.width === 0 ? 0 : (nextCenterCanvas.x - bounds.cx) / bounds.width,
y:
bounds.height === 0
? 0
: (nextCenterCanvas.y - bounds.cy) / bounds.height,
};
const nextPoints = geometry.anchors.map((point) => {
const anchor = freeformCanvasPointToLocal({
point: point.anchor,
centerX: nextCenterLocal.x,
centerY: nextCenterLocal.y,
rotation,
scale,
bounds,
});
const inHandle = freeformCanvasPointToLocal({
point: point.inHandle,
centerX: nextCenterLocal.x,
centerY: nextCenterLocal.y,
rotation,
scale,
bounds,
});
const outHandle = freeformCanvasPointToLocal({
point: point.outHandle,
centerX: nextCenterLocal.x,
centerY: nextCenterLocal.y,
rotation,
scale,
bounds,
});
return {
id: point.id,
x: anchor.x,
y: anchor.y,
inX: inHandle.x - anchor.x,
inY: inHandle.y - anchor.y,
outX: outHandle.x - anchor.x,
outY: outHandle.y - anchor.y,
};
});
return {
centerX: nextCenterLocal.x,
centerY: nextCenterLocal.y,
points: nextPoints,
};
}
export function buildFreeformPath2D({
points,
centerX,
centerY,
rotation,
scale,
bounds,
closed,
}: {
points: FreeformPathPoint[];
centerX: number;
centerY: number;
rotation: number;
scale: number;
bounds: ElementBounds;
closed: boolean;
}): Path2D {
const path = new Path2D();
if (points.length === 0) {
return path;
}
const geometry = getFreeformCanvasGeometry({
points,
centerX,
centerY,
rotation,
scale,
bounds,
});
const anchors = geometry.anchors;
path.moveTo(anchors[0].anchor.x, anchors[0].anchor.y);
for (let index = 1; index < anchors.length; index++) {
const previous = anchors[index - 1];
const current = anchors[index];
path.bezierCurveTo(
previous.outHandle.x,
previous.outHandle.y,
current.inHandle.x,
current.inHandle.y,
current.anchor.x,
current.anchor.y,
);
}
if (closed && anchors.length > 1) {
const last = anchors[anchors.length - 1];
const first = anchors[0];
path.bezierCurveTo(
last.outHandle.x,
last.outHandle.y,
first.inHandle.x,
first.inHandle.y,
first.anchor.x,
first.anchor.y,
);
path.closePath();
}
return path;
}
export function buildFreeformSvgPath({
points,
centerX,
centerY,
rotation,
scale,
bounds,
closed,
}: {
points: FreeformPathPoint[];
centerX: number;
centerY: number;
rotation: number;
scale: number;
bounds: ElementBounds;
closed: boolean;
}): string {
if (points.length === 0) {
return "";
}
const geometry = getFreeformCanvasGeometry({
points,
centerX,
centerY,
rotation,
scale,
bounds,
});
const anchors = geometry.anchors;
const segments = [`M ${anchors[0].anchor.x},${anchors[0].anchor.y}`];
for (let index = 1; index < anchors.length; index++) {
const previous = anchors[index - 1];
const current = anchors[index];
segments.push(
`C ${previous.outHandle.x},${previous.outHandle.y} ${current.inHandle.x},${current.inHandle.y} ${current.anchor.x},${current.anchor.y}`,
);
}
if (closed && anchors.length > 1) {
const last = anchors[anchors.length - 1];
const first = anchors[0];
segments.push(
`C ${last.outHandle.x},${last.outHandle.y} ${first.inHandle.x},${first.inHandle.y} ${first.anchor.x},${first.anchor.y}`,
);
segments.push("Z");
}
return segments.join(" ");
}