mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
398 lines
8.9 KiB
TypeScript
398 lines
8.9 KiB
TypeScript
import { computeFeatherUpdate } from "../param-update";
|
|
import type {
|
|
MaskDefinition,
|
|
MaskParamUpdateArgs,
|
|
SplitMaskParams,
|
|
} from "@/masks/types";
|
|
import { halfPlaneSign, lineEdgeIntersection } from "../utils";
|
|
import {
|
|
getLineMaskHandlePositions,
|
|
getLineMaskOverlay,
|
|
} from "@/masks/handle-positions";
|
|
import { snapSplitMaskInteraction } from "@/masks/snap";
|
|
|
|
// cos(π/2) returns ~6e-17 in JS, not 0. Values below this threshold are snapped
|
|
// to exactly 0 to prevent opposite-sign float noise on canvas corners that lie
|
|
// exactly on the split line, which produces spurious midpoint vertices.
|
|
const NORMAL_SNAP_EPSILON = 1e-10;
|
|
|
|
// Guards against collinear vertices from float noise at canvas edges.
|
|
const MIN_POLYGON_AREA_PX = 0.5;
|
|
const INTERSECTION_EPSILON = 1e-6;
|
|
|
|
function polygonArea({ vertices }: { vertices: [number, number][] }): number {
|
|
let area = 0;
|
|
for (let i = 0; i < vertices.length; i++) {
|
|
const [x1, y1] = vertices[i];
|
|
const [x2, y2] = vertices[(i + 1) % vertices.length];
|
|
area += x1 * y2 - x2 * y1;
|
|
}
|
|
return Math.abs(area) * 0.5;
|
|
}
|
|
|
|
function splitLineGeometry({
|
|
centerX,
|
|
centerY,
|
|
rotation,
|
|
width,
|
|
height,
|
|
}: {
|
|
centerX: number;
|
|
centerY: number;
|
|
rotation: number;
|
|
width: number;
|
|
height: number;
|
|
}): { normalX: number; normalY: number; lineX: number; lineY: number } {
|
|
const angleRad = (rotation * Math.PI) / 180;
|
|
const normalX =
|
|
Math.abs(Math.cos(angleRad)) < NORMAL_SNAP_EPSILON ? 0 : Math.cos(angleRad);
|
|
const normalY =
|
|
Math.abs(Math.sin(angleRad)) < NORMAL_SNAP_EPSILON ? 0 : Math.sin(angleRad);
|
|
const lineX = width / 2 + centerX * width;
|
|
const lineY = height / 2 + centerY * height;
|
|
return { normalX, normalY, lineX, lineY };
|
|
}
|
|
|
|
function pointsEqual({
|
|
a,
|
|
b,
|
|
}: {
|
|
a: { x: number; y: number };
|
|
b: { x: number; y: number };
|
|
}): boolean {
|
|
return (
|
|
Math.abs(a.x - b.x) <= INTERSECTION_EPSILON &&
|
|
Math.abs(a.y - b.y) <= INTERSECTION_EPSILON
|
|
);
|
|
}
|
|
|
|
export function getSplitMaskStrokeSegment({
|
|
resolvedParams,
|
|
width,
|
|
height,
|
|
}: {
|
|
resolvedParams: SplitMaskParams;
|
|
width: number;
|
|
height: number;
|
|
}): [{ x: number; y: number }, { x: number; y: number }] | null {
|
|
const { centerX, centerY, rotation } = resolvedParams;
|
|
const { normalX, normalY, lineX, lineY } = splitLineGeometry({
|
|
centerX,
|
|
centerY,
|
|
rotation,
|
|
width,
|
|
height,
|
|
});
|
|
|
|
const edges: [number, number, number, number][] = [
|
|
[0, 0, width, 0],
|
|
[width, 0, width, height],
|
|
[width, height, 0, height],
|
|
[0, height, 0, 0],
|
|
];
|
|
const intersections: { x: number; y: number }[] = [];
|
|
|
|
for (const [x1, y1, x2, y2] of edges) {
|
|
const hit = lineEdgeIntersection({
|
|
lineX,
|
|
lineY,
|
|
normalX,
|
|
normalY,
|
|
x1,
|
|
y1,
|
|
x2,
|
|
y2,
|
|
});
|
|
|
|
if (!hit || intersections.some((point) => pointsEqual({ a: point, b: hit }))) {
|
|
continue;
|
|
}
|
|
|
|
intersections.push(hit);
|
|
}
|
|
|
|
if (intersections.length !== 2) {
|
|
return null;
|
|
}
|
|
|
|
return [intersections[0], intersections[1]];
|
|
}
|
|
|
|
function computeSplitMaskParamUpdate({
|
|
handleId,
|
|
startParams,
|
|
deltaX,
|
|
deltaY,
|
|
startCanvasX,
|
|
startCanvasY,
|
|
bounds,
|
|
canvasSize,
|
|
}: MaskParamUpdateArgs<SplitMaskParams>): Partial<SplitMaskParams> {
|
|
if (handleId.kind === "position") {
|
|
const rawX = startParams.centerX + deltaX / bounds.width;
|
|
const rawY = startParams.centerY + deltaY / bounds.height;
|
|
|
|
const minX = -bounds.cx / bounds.width;
|
|
const maxX = (canvasSize.width - bounds.cx) / bounds.width;
|
|
const minY = -bounds.cy / bounds.height;
|
|
const maxY = (canvasSize.height - bounds.cy) / bounds.height;
|
|
|
|
return {
|
|
centerX: Math.max(minX, Math.min(maxX, rawX)),
|
|
centerY: Math.max(minY, Math.min(maxY, rawY)),
|
|
};
|
|
}
|
|
|
|
if (handleId.kind === "feather") {
|
|
const angleRad = (startParams.rotation * Math.PI) / 180;
|
|
return computeFeatherUpdate({
|
|
startFeather: startParams.feather,
|
|
deltaX,
|
|
deltaY,
|
|
directionX: -Math.cos(angleRad),
|
|
directionY: -Math.sin(angleRad),
|
|
});
|
|
}
|
|
|
|
if (handleId.kind === "rotation") {
|
|
const pivotX = bounds.cx + startParams.centerX * bounds.width;
|
|
const pivotY = bounds.cy + startParams.centerY * bounds.height;
|
|
const startAngle =
|
|
(Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) /
|
|
Math.PI;
|
|
const currentAngle =
|
|
(Math.atan2(
|
|
startCanvasY + deltaY - pivotY,
|
|
startCanvasX + deltaX - pivotX,
|
|
) *
|
|
180) /
|
|
Math.PI;
|
|
|
|
let deltaAngle = currentAngle - startAngle;
|
|
if (deltaAngle > 180) deltaAngle -= 360;
|
|
if (deltaAngle < -180) deltaAngle += 360;
|
|
|
|
return {
|
|
rotation: (((startParams.rotation + deltaAngle) % 360) + 360) % 360,
|
|
};
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
export const splitMaskDefinition: MaskDefinition<"split"> = {
|
|
type: "split",
|
|
name: "Split",
|
|
features: {
|
|
hasPosition: true,
|
|
hasRotation: true,
|
|
sizeMode: "none",
|
|
},
|
|
interaction: {
|
|
getInteraction({
|
|
params,
|
|
bounds,
|
|
displayScale,
|
|
scaleX: _scaleX,
|
|
scaleY: _scaleY,
|
|
}) {
|
|
return {
|
|
handles: getLineMaskHandlePositions({
|
|
centerX: params.centerX,
|
|
centerY: params.centerY,
|
|
rotation: params.rotation,
|
|
feather: params.feather,
|
|
bounds,
|
|
displayScale,
|
|
}),
|
|
overlays: [
|
|
getLineMaskOverlay({
|
|
centerX: params.centerX,
|
|
centerY: params.centerY,
|
|
rotation: params.rotation,
|
|
bounds,
|
|
}),
|
|
],
|
|
};
|
|
},
|
|
snap(args) {
|
|
return snapSplitMaskInteraction(args);
|
|
},
|
|
},
|
|
buildDefault() {
|
|
return {
|
|
type: "split",
|
|
params: {
|
|
feather: 0,
|
|
inverted: false,
|
|
strokeColor: "#ffffff",
|
|
strokeWidth: 0,
|
|
strokeAlign: "center",
|
|
centerX: 0,
|
|
centerY: 0,
|
|
rotation: 0,
|
|
},
|
|
};
|
|
},
|
|
computeParamUpdate: computeSplitMaskParamUpdate,
|
|
params: [
|
|
{
|
|
key: "centerX",
|
|
label: "X",
|
|
type: "number",
|
|
default: 0,
|
|
min: -100,
|
|
max: 100,
|
|
step: 1,
|
|
displayMultiplier: 100,
|
|
},
|
|
{
|
|
key: "centerY",
|
|
label: "Y",
|
|
type: "number",
|
|
default: 0,
|
|
min: -100,
|
|
max: 100,
|
|
step: 1,
|
|
displayMultiplier: 100,
|
|
},
|
|
{
|
|
key: "rotation",
|
|
label: "Rotation",
|
|
type: "number",
|
|
default: 0,
|
|
min: 0,
|
|
max: 360,
|
|
step: 1,
|
|
},
|
|
],
|
|
renderer: {
|
|
body: {
|
|
kind: "drawWithFeather",
|
|
drawWithFeather({ resolvedParams, ctx, width, height, feather }) {
|
|
const { centerX, centerY, rotation } = resolvedParams;
|
|
const { normalX, normalY, lineX, lineY } = splitLineGeometry({
|
|
centerX,
|
|
centerY,
|
|
rotation,
|
|
width,
|
|
height,
|
|
});
|
|
|
|
// Analytical gradient avoids JFA's two-sided distance artifact near canvas edges.
|
|
const featherHalf = feather / 2;
|
|
const gradient = ctx.createLinearGradient(
|
|
lineX - normalX * featherHalf,
|
|
lineY - normalY * featherHalf,
|
|
lineX + normalX * featherHalf,
|
|
lineY + normalY * featherHalf,
|
|
);
|
|
gradient.addColorStop(0, "rgba(255,255,255,0)");
|
|
gradient.addColorStop(1, "white");
|
|
|
|
ctx.fillStyle = gradient;
|
|
ctx.fillRect(0, 0, width, height);
|
|
},
|
|
|
|
opaqueFastPath: {
|
|
buildPath({ resolvedParams, width, height }) {
|
|
const { centerX, centerY, rotation } = resolvedParams;
|
|
const { normalX, normalY, lineX, lineY } = splitLineGeometry({
|
|
centerX,
|
|
centerY,
|
|
rotation,
|
|
width,
|
|
height,
|
|
});
|
|
|
|
const edges: [number, number, number, number][] = [
|
|
[0, 0, width, 0],
|
|
[width, 0, width, height],
|
|
[width, height, 0, height],
|
|
[0, height, 0, 0],
|
|
];
|
|
|
|
const isInsideHalfPlane = ({
|
|
x,
|
|
y,
|
|
}: {
|
|
x: number;
|
|
y: number;
|
|
}) => halfPlaneSign({ lineX, lineY, normalX, normalY, x, y }) >= 0;
|
|
|
|
const vertices: [number, number][] = [];
|
|
for (const [x1, y1, x2, y2] of edges) {
|
|
const isVertex1Inside = isInsideHalfPlane({ x: x1, y: y1 });
|
|
const isVertex2Inside = isInsideHalfPlane({ x: x2, y: y2 });
|
|
|
|
if (isVertex1Inside && isVertex2Inside) {
|
|
vertices.push([x2, y2]);
|
|
} else if (isVertex1Inside && !isVertex2Inside) {
|
|
const hit = lineEdgeIntersection({
|
|
lineX,
|
|
lineY,
|
|
normalX,
|
|
normalY,
|
|
x1,
|
|
y1,
|
|
x2,
|
|
y2,
|
|
});
|
|
if (hit) vertices.push([hit.x, hit.y]);
|
|
} else if (!isVertex1Inside && isVertex2Inside) {
|
|
const hit = lineEdgeIntersection({
|
|
lineX,
|
|
lineY,
|
|
normalX,
|
|
normalY,
|
|
x1,
|
|
y1,
|
|
x2,
|
|
y2,
|
|
});
|
|
if (hit) {
|
|
vertices.push([hit.x, hit.y]);
|
|
vertices.push([x2, y2]);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (
|
|
vertices.length < 3 ||
|
|
polygonArea({ vertices }) < MIN_POLYGON_AREA_PX
|
|
) {
|
|
return new Path2D();
|
|
}
|
|
|
|
const path = new Path2D();
|
|
path.moveTo(vertices[0][0], vertices[0][1]);
|
|
for (let i = 1; i < vertices.length; i++) {
|
|
path.lineTo(vertices[i][0], vertices[i][1]);
|
|
}
|
|
path.closePath();
|
|
return path;
|
|
},
|
|
},
|
|
},
|
|
stroke: {
|
|
kind: "strokeFromPath",
|
|
buildStrokePath({ resolvedParams, width, height }) {
|
|
const segment = getSplitMaskStrokeSegment({
|
|
resolvedParams,
|
|
width,
|
|
height,
|
|
});
|
|
const path = new Path2D();
|
|
|
|
if (!segment) {
|
|
return path;
|
|
}
|
|
|
|
path.moveTo(segment[0].x, segment[0].y);
|
|
path.lineTo(segment[1].x, segment[1].y);
|
|
return path;
|
|
},
|
|
},
|
|
},
|
|
};
|