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 { snapMaskInteraction } 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: { 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: unknown; width: number; height: number; }): [{ x: number; y: number }, { x: number; y: number }] | null { const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams; 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(point, 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): Partial { if (handleId === "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 === "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 === "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 = { 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 snapMaskInteraction(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: { renderMaskHandlesFeather: true, renderMask({ resolvedParams, ctx, width, height, feather }) { const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams; 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); }, buildPath({ resolvedParams, width, height }) { const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams; 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: 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(x1, y1); const isVertex2Inside = isInsideHalfPlane(x2, 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; }, 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; }, }, };