mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: masks, properties refactor, shaders, storage migrations, and more
This commit is contained in:
@@ -1,17 +1,12 @@
|
||||
import type { TimelineTrack, TimelineElement } from "@/types/timeline";
|
||||
import type { MediaAsset } from "@/types/assets";
|
||||
import type { TimelineTrack, TimelineElement } from "@/lib/timeline";
|
||||
import type { MediaAsset } from "@/lib/media/types";
|
||||
import { isMainTrack } from "@/lib/timeline";
|
||||
import {
|
||||
DEFAULT_TEXT_ELEMENT,
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
DEFAULT_TEXT_BACKGROUND,
|
||||
FONT_SIZE_SCALE_REFERENCE,
|
||||
} from "@/constants/text-constants";
|
||||
import { getTextVisualRect, measureTextBlock } from "@/lib/text/layout";
|
||||
import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/constants/sticker-constants";
|
||||
import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/lib/graphics";
|
||||
import { measureTextElement } from "@/lib/text/measure-element";
|
||||
import {
|
||||
getElementLocalTime,
|
||||
resolveTransformAtTime,
|
||||
resolveNumberAtTime,
|
||||
} from "@/lib/animation";
|
||||
|
||||
export interface ElementBounds {
|
||||
@@ -41,7 +36,8 @@ function getVisualElementBounds({
|
||||
sourceWidth: number;
|
||||
sourceHeight: number;
|
||||
transform: {
|
||||
scale: number;
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
position: { x: number; y: number };
|
||||
rotate: number;
|
||||
};
|
||||
@@ -50,8 +46,8 @@ function getVisualElementBounds({
|
||||
canvasWidth / sourceWidth,
|
||||
canvasHeight / sourceHeight,
|
||||
);
|
||||
const scaledWidth = sourceWidth * containScale * transform.scale;
|
||||
const scaledHeight = sourceHeight * containScale * transform.scale;
|
||||
const scaledWidth = sourceWidth * containScale * transform.scaleX;
|
||||
const scaledHeight = sourceHeight * containScale * transform.scaleY;
|
||||
const cx = canvasWidth / 2 + transform.position.x;
|
||||
const cy = canvasHeight / 2 + transform.position.y;
|
||||
|
||||
@@ -64,7 +60,53 @@ function getVisualElementBounds({
|
||||
};
|
||||
}
|
||||
|
||||
export function getElementBounds({
|
||||
function getTransformedRectBounds({
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
rect,
|
||||
transform,
|
||||
}: {
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
rect: { left: number; top: number; width: number; height: number };
|
||||
transform: {
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
position: { x: number; y: number };
|
||||
rotate: number;
|
||||
};
|
||||
}): ElementBounds {
|
||||
const localCenterX = rect.left + rect.width / 2;
|
||||
const localCenterY = rect.top + rect.height / 2;
|
||||
const scaledCenterX = localCenterX * transform.scaleX;
|
||||
const scaledCenterY = localCenterY * transform.scaleY;
|
||||
const rotationRad = (transform.rotate * Math.PI) / 180;
|
||||
const cos = Math.cos(rotationRad);
|
||||
const sin = Math.sin(rotationRad);
|
||||
return {
|
||||
cx:
|
||||
canvasWidth / 2 +
|
||||
transform.position.x +
|
||||
scaledCenterX * cos -
|
||||
scaledCenterY * sin,
|
||||
cy:
|
||||
canvasHeight / 2 +
|
||||
transform.position.y +
|
||||
scaledCenterX * sin +
|
||||
scaledCenterY * cos,
|
||||
width: rect.width * transform.scaleX,
|
||||
height: rect.height * transform.scaleY,
|
||||
rotation: transform.rotate,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds policy: bounds reflect base content geometry (text glyphs + background,
|
||||
* sticker/image/video content area) and base transform. Post-effect spill (blur,
|
||||
* glow) and mask-clipped regions are intentionally excluded — handles manipulate
|
||||
* the canonical element geometry, not visual effect output.
|
||||
*/
|
||||
function getElementBounds({
|
||||
element,
|
||||
canvasSize,
|
||||
mediaAsset,
|
||||
@@ -106,8 +148,23 @@ export function getElementBounds({
|
||||
return getVisualElementBounds({
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
sourceWidth: 200,
|
||||
sourceHeight: 200,
|
||||
sourceWidth: element.intrinsicWidth ?? STICKER_INTRINSIC_SIZE_FALLBACK,
|
||||
sourceHeight: element.intrinsicHeight ?? STICKER_INTRINSIC_SIZE_FALLBACK,
|
||||
transform,
|
||||
});
|
||||
}
|
||||
|
||||
if (element.type === "graphic") {
|
||||
const transform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
return getVisualElementBounds({
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
sourceWidth: DEFAULT_GRAPHIC_SOURCE_SIZE,
|
||||
sourceHeight: DEFAULT_GRAPHIC_SOURCE_SIZE,
|
||||
transform,
|
||||
});
|
||||
}
|
||||
@@ -118,111 +175,91 @@ export function getElementBounds({
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
const scaledFontSize =
|
||||
element.fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE);
|
||||
const letterSpacing = element.letterSpacing ?? 0;
|
||||
const lineHeight = element.lineHeight ?? DEFAULT_LINE_HEIGHT;
|
||||
const lineHeightPx = scaledFontSize * lineHeight;
|
||||
|
||||
let measuredWidth = 100;
|
||||
let measuredHeight = scaledFontSize;
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 4096;
|
||||
canvas.height = 4096;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
|
||||
if (ctx) {
|
||||
const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
|
||||
const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
|
||||
const fontFamily = `"${element.fontFamily.replace(/"/g, '\\"')}"`;
|
||||
ctx.font = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`;
|
||||
ctx.textAlign = element.textAlign as CanvasTextAlign;
|
||||
if ("letterSpacing" in ctx) {
|
||||
(
|
||||
ctx as CanvasRenderingContext2D & { letterSpacing: string }
|
||||
).letterSpacing = `${letterSpacing}px`;
|
||||
}
|
||||
const measured = measureTextElement({
|
||||
element,
|
||||
canvasHeight,
|
||||
localTime,
|
||||
ctx,
|
||||
});
|
||||
|
||||
const lines = element.content.split("\n");
|
||||
const lineMetrics = lines.map((line) => ctx.measureText(line));
|
||||
const block = measureTextBlock({
|
||||
lineMetrics,
|
||||
lineHeightPx,
|
||||
fallbackFontSize: scaledFontSize,
|
||||
});
|
||||
const fontSizeRatio = element.fontSize / DEFAULT_TEXT_ELEMENT.fontSize;
|
||||
const resolvedBackground = {
|
||||
...element.background,
|
||||
paddingX: resolveNumberAtTime({
|
||||
baseValue:
|
||||
element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
|
||||
animations: element.animations,
|
||||
propertyPath: "background.paddingX",
|
||||
localTime,
|
||||
}),
|
||||
paddingY: resolveNumberAtTime({
|
||||
baseValue:
|
||||
element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY,
|
||||
animations: element.animations,
|
||||
propertyPath: "background.paddingY",
|
||||
localTime,
|
||||
}),
|
||||
offsetX: resolveNumberAtTime({
|
||||
baseValue:
|
||||
element.background.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX,
|
||||
animations: element.animations,
|
||||
propertyPath: "background.offsetX",
|
||||
localTime,
|
||||
}),
|
||||
offsetY: resolveNumberAtTime({
|
||||
baseValue:
|
||||
element.background.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY,
|
||||
animations: element.animations,
|
||||
propertyPath: "background.offsetY",
|
||||
localTime,
|
||||
}),
|
||||
};
|
||||
const visualRect = getTextVisualRect({
|
||||
textAlign: element.textAlign,
|
||||
block,
|
||||
background: resolvedBackground,
|
||||
fontSizeRatio,
|
||||
});
|
||||
measuredWidth = visualRect.width;
|
||||
measuredHeight = visualRect.height;
|
||||
const localCenterX = visualRect.left + visualRect.width / 2;
|
||||
const localCenterY = visualRect.top + visualRect.height / 2;
|
||||
const scaledCenterX = localCenterX * transform.scale;
|
||||
const scaledCenterY = localCenterY * transform.scale;
|
||||
const rotationRad = (transform.rotate * Math.PI) / 180;
|
||||
const cos = Math.cos(rotationRad);
|
||||
const sin = Math.sin(rotationRad);
|
||||
const rotatedCenterX = scaledCenterX * cos - scaledCenterY * sin;
|
||||
const rotatedCenterY = scaledCenterX * sin + scaledCenterY * cos;
|
||||
return {
|
||||
cx: canvasWidth / 2 + transform.position.x + rotatedCenterX,
|
||||
cy: canvasHeight / 2 + transform.position.y + rotatedCenterY,
|
||||
width: measuredWidth * transform.scale,
|
||||
height: measuredHeight * transform.scale,
|
||||
rotation: transform.rotate,
|
||||
};
|
||||
}
|
||||
|
||||
const width = measuredWidth * transform.scale;
|
||||
const height = measuredHeight * transform.scale;
|
||||
return {
|
||||
cx: canvasWidth / 2 + transform.position.x,
|
||||
cy: canvasHeight / 2 + transform.position.y,
|
||||
width,
|
||||
height,
|
||||
rotation: transform.rotate,
|
||||
};
|
||||
return getTransformedRectBounds({
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
rect: measured.visualRect,
|
||||
transform,
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const ROTATION_HANDLE_OFFSET = 24;
|
||||
|
||||
export type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
||||
export type Edge = "right" | "left" | "bottom";
|
||||
|
||||
export function getCornerPosition({
|
||||
bounds,
|
||||
corner,
|
||||
}: {
|
||||
bounds: ElementBounds;
|
||||
corner: Corner;
|
||||
}): { x: number; y: number } {
|
||||
const halfW = bounds.width / 2;
|
||||
const halfH = bounds.height / 2;
|
||||
const angleRad = (bounds.rotation * Math.PI) / 180;
|
||||
const cos = Math.cos(angleRad);
|
||||
const sin = Math.sin(angleRad);
|
||||
const localX =
|
||||
corner === "top-left" || corner === "bottom-left" ? -halfW : halfW;
|
||||
const localY =
|
||||
corner === "top-left" || corner === "top-right" ? -halfH : halfH;
|
||||
return {
|
||||
x: bounds.cx + (localX * cos - localY * sin),
|
||||
y: bounds.cy + (localX * sin + localY * cos),
|
||||
};
|
||||
}
|
||||
|
||||
export function getEdgeHandlePosition({
|
||||
bounds,
|
||||
edge,
|
||||
}: {
|
||||
bounds: ElementBounds;
|
||||
edge: Edge;
|
||||
}): { x: number; y: number } {
|
||||
const halfWidth = bounds.width / 2;
|
||||
const halfHeight = bounds.height / 2;
|
||||
const angleRad = (bounds.rotation * Math.PI) / 180;
|
||||
const cos = Math.cos(angleRad);
|
||||
const sin = Math.sin(angleRad);
|
||||
const localX = edge === "right" ? halfWidth : edge === "left" ? -halfWidth : 0;
|
||||
const localY = edge === "bottom" ? halfHeight : 0;
|
||||
return {
|
||||
x: bounds.cx + (localX * cos - localY * sin),
|
||||
y: bounds.cy + (localX * sin + localY * cos),
|
||||
};
|
||||
}
|
||||
|
||||
export function getRotationHandlePosition({
|
||||
bounds,
|
||||
}: {
|
||||
bounds: ElementBounds;
|
||||
}): { x: number; y: number } {
|
||||
const angleRad = (bounds.rotation * Math.PI) / 180;
|
||||
const cos = Math.cos(angleRad);
|
||||
const sin = Math.sin(angleRad);
|
||||
const localY = -bounds.height / 2 - ROTATION_HANDLE_OFFSET;
|
||||
return {
|
||||
x: bounds.cx - localY * sin,
|
||||
y: bounds.cy + localY * cos,
|
||||
};
|
||||
}
|
||||
|
||||
export function getVisibleElementsWithBounds({
|
||||
tracks,
|
||||
currentTime,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ElementWithBounds } from "./element-bounds";
|
||||
import type { ElementRef } from "@/lib/timeline/types";
|
||||
|
||||
function pointInRotatedRect({
|
||||
px,
|
||||
@@ -24,14 +25,14 @@ function pointInRotatedRect({
|
||||
const dy = py - cy;
|
||||
const localX = dx * cos - dy * sin;
|
||||
const localY = dx * sin + dy * cos;
|
||||
const halfW = width / 2;
|
||||
const halfH = height / 2;
|
||||
const halfW = Math.abs(width) / 2;
|
||||
const halfH = Math.abs(height) / 2;
|
||||
return (
|
||||
localX >= -halfW && localX <= halfW && localY >= -halfH && localY <= halfH
|
||||
);
|
||||
}
|
||||
|
||||
export function hitTest({
|
||||
export function getHitElements({
|
||||
canvasX,
|
||||
canvasY,
|
||||
elementsWithBounds,
|
||||
@@ -39,7 +40,9 @@ export function hitTest({
|
||||
canvasX: number;
|
||||
canvasY: number;
|
||||
elementsWithBounds: ElementWithBounds[];
|
||||
}): ElementWithBounds | null {
|
||||
}): ElementWithBounds[] {
|
||||
const hits: ElementWithBounds[] = [];
|
||||
|
||||
for (let i = elementsWithBounds.length - 1; i >= 0; i--) {
|
||||
const { bounds } = elementsWithBounds[i];
|
||||
if (
|
||||
@@ -53,8 +56,47 @@ export function hitTest({
|
||||
rotation: bounds.rotation,
|
||||
})
|
||||
) {
|
||||
return elementsWithBounds[i];
|
||||
hits.push(elementsWithBounds[i]);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
return hits;
|
||||
}
|
||||
|
||||
export function hitTest({
|
||||
canvasX,
|
||||
canvasY,
|
||||
elementsWithBounds,
|
||||
}: {
|
||||
canvasX: number;
|
||||
canvasY: number;
|
||||
elementsWithBounds: ElementWithBounds[];
|
||||
}): ElementWithBounds | null {
|
||||
return (
|
||||
getHitElements({
|
||||
canvasX,
|
||||
canvasY,
|
||||
elementsWithBounds,
|
||||
})[0] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function resolvePreferredHit({
|
||||
hits,
|
||||
preferredElements,
|
||||
}: {
|
||||
hits: ElementWithBounds[];
|
||||
preferredElements: ElementRef[];
|
||||
}): ElementWithBounds | null {
|
||||
if (preferredElements.length === 0) return null;
|
||||
|
||||
return (
|
||||
hits.find((hit) =>
|
||||
preferredElements.some(
|
||||
(preferredElement) =>
|
||||
preferredElement.trackId === hit.trackId &&
|
||||
preferredElement.elementId === hit.elementId,
|
||||
),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,90 +1,101 @@
|
||||
export interface PreviewViewportGeometry {
|
||||
canvasHeight: number;
|
||||
canvasWidth: number;
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
scale: number;
|
||||
viewportHeight: number;
|
||||
viewportWidth: number;
|
||||
}
|
||||
|
||||
function getCanvasOrigin({
|
||||
geometry,
|
||||
}: {
|
||||
geometry: PreviewViewportGeometry;
|
||||
}): { x: number; y: number } {
|
||||
return {
|
||||
x: geometry.viewportWidth / 2 - geometry.centerX * geometry.scale,
|
||||
y: geometry.viewportHeight / 2 - geometry.centerY * geometry.scale,
|
||||
};
|
||||
}
|
||||
|
||||
export function screenToCanvas({
|
||||
clientX,
|
||||
clientY,
|
||||
canvas,
|
||||
geometry,
|
||||
viewportRect,
|
||||
}: {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
canvas: HTMLCanvasElement;
|
||||
geometry: PreviewViewportGeometry;
|
||||
viewportRect: DOMRect;
|
||||
}): { x: number; y: number } {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / rect.width;
|
||||
const scaleY = canvas.height / rect.height;
|
||||
const overlayX = clientX - viewportRect.left;
|
||||
const overlayY = clientY - viewportRect.top;
|
||||
|
||||
return {
|
||||
x: (clientX - rect.left) * scaleX,
|
||||
y: (clientY - rect.top) * scaleY,
|
||||
x:
|
||||
geometry.centerX +
|
||||
(overlayX - geometry.viewportWidth / 2) / geometry.scale,
|
||||
y:
|
||||
geometry.centerY +
|
||||
(overlayY - geometry.viewportHeight / 2) / geometry.scale,
|
||||
};
|
||||
}
|
||||
|
||||
export function canvasToOverlay({
|
||||
canvasX,
|
||||
canvasY,
|
||||
canvasRect,
|
||||
containerRect,
|
||||
canvasSize,
|
||||
geometry,
|
||||
}: {
|
||||
canvasX: number;
|
||||
canvasY: number;
|
||||
canvasRect: DOMRect;
|
||||
containerRect: DOMRect;
|
||||
canvasSize: { width: number; height: number };
|
||||
geometry: PreviewViewportGeometry;
|
||||
}): { x: number; y: number } {
|
||||
const scaleX = canvasRect.width / canvasSize.width;
|
||||
const scaleY = canvasRect.height / canvasSize.height;
|
||||
const canvasOrigin = getCanvasOrigin({ geometry });
|
||||
|
||||
return {
|
||||
x: canvasRect.left - containerRect.left + canvasX * scaleX,
|
||||
y: canvasRect.top - containerRect.top + canvasY * scaleY,
|
||||
x: canvasOrigin.x + canvasX * geometry.scale,
|
||||
y: canvasOrigin.y + canvasY * geometry.scale,
|
||||
};
|
||||
}
|
||||
|
||||
export function positionToOverlay({
|
||||
positionX,
|
||||
positionY,
|
||||
canvasRect,
|
||||
containerRect,
|
||||
canvasSize,
|
||||
geometry,
|
||||
}: {
|
||||
positionX: number;
|
||||
positionY: number;
|
||||
canvasRect: DOMRect;
|
||||
containerRect: DOMRect;
|
||||
canvasSize: { width: number; height: number };
|
||||
geometry: PreviewViewportGeometry;
|
||||
}): { x: number; y: number } {
|
||||
const scaleX = canvasRect.width / canvasSize.width;
|
||||
const scaleY = canvasRect.height / canvasSize.height;
|
||||
const centerScreenX =
|
||||
canvasRect.left - containerRect.left + (canvasSize.width / 2) * scaleX;
|
||||
const centerScreenY =
|
||||
canvasRect.top - containerRect.top + (canvasSize.height / 2) * scaleY;
|
||||
return {
|
||||
x: centerScreenX + positionX * scaleX,
|
||||
y: centerScreenY + positionY * scaleY,
|
||||
};
|
||||
return canvasToOverlay({
|
||||
canvasX: geometry.canvasWidth / 2 + positionX,
|
||||
canvasY: geometry.canvasHeight / 2 + positionY,
|
||||
geometry,
|
||||
});
|
||||
}
|
||||
|
||||
export function getDisplayScale({
|
||||
canvasRect,
|
||||
canvasSize,
|
||||
geometry,
|
||||
}: {
|
||||
canvasRect: DOMRect;
|
||||
canvasSize: { width: number; height: number };
|
||||
geometry: PreviewViewportGeometry;
|
||||
}): { x: number; y: number } {
|
||||
return {
|
||||
x: canvasRect.width / canvasSize.width,
|
||||
y: canvasRect.height / canvasSize.height,
|
||||
x: geometry.scale,
|
||||
y: geometry.scale,
|
||||
};
|
||||
}
|
||||
|
||||
export function screenPixelsToLogicalThreshold({
|
||||
canvas,
|
||||
geometry,
|
||||
screenPixels,
|
||||
}: {
|
||||
canvas: HTMLCanvasElement;
|
||||
geometry: PreviewViewportGeometry;
|
||||
screenPixels: number;
|
||||
}): { x: number; y: number } {
|
||||
const canvasRect = canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: screenPixels * (canvas.width / canvasRect.width),
|
||||
y: screenPixels * (canvas.height / canvasRect.height),
|
||||
x: screenPixels / geometry.scale,
|
||||
y: screenPixels / geometry.scale,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,15 +13,70 @@ export interface SnapResult {
|
||||
activeLines: SnapLine[];
|
||||
}
|
||||
|
||||
type ScaleEdge = "left" | "right" | "top" | "bottom";
|
||||
|
||||
export interface ScaleEdgePreference {
|
||||
left?: boolean;
|
||||
right?: boolean;
|
||||
top?: boolean;
|
||||
bottom?: boolean;
|
||||
}
|
||||
|
||||
function hasPreferredEdge({
|
||||
preferredEdges,
|
||||
edge,
|
||||
}: {
|
||||
preferredEdges?: ScaleEdgePreference;
|
||||
edge: ScaleEdge;
|
||||
}): boolean {
|
||||
return preferredEdges?.[edge] === true;
|
||||
}
|
||||
|
||||
function pickClosestScaleCandidate<T extends { distance: number; edge: ScaleEdge }>({
|
||||
candidates,
|
||||
preferredEdges,
|
||||
}: {
|
||||
candidates: T[];
|
||||
preferredEdges?: ScaleEdgePreference;
|
||||
}): T | null {
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return candidates.reduce((bestCandidate, candidate) => {
|
||||
if (candidate.distance < bestCandidate.distance) {
|
||||
return candidate;
|
||||
}
|
||||
if (candidate.distance > bestCandidate.distance) {
|
||||
return bestCandidate;
|
||||
}
|
||||
|
||||
const shouldPreferCandidate = hasPreferredEdge({
|
||||
preferredEdges,
|
||||
edge: candidate.edge,
|
||||
});
|
||||
const shouldPreferBestCandidate = hasPreferredEdge({
|
||||
preferredEdges,
|
||||
edge: bestCandidate.edge,
|
||||
});
|
||||
|
||||
return shouldPreferCandidate && !shouldPreferBestCandidate
|
||||
? candidate
|
||||
: bestCandidate;
|
||||
});
|
||||
}
|
||||
|
||||
export function snapPosition({
|
||||
proposedPosition,
|
||||
canvasSize,
|
||||
elementSize,
|
||||
rotation = 0,
|
||||
snapThreshold,
|
||||
}: {
|
||||
proposedPosition: { x: number; y: number };
|
||||
canvasSize: { width: number; height: number };
|
||||
elementSize: { width: number; height: number };
|
||||
rotation?: number;
|
||||
snapThreshold: { x: number; y: number };
|
||||
}): SnapResult {
|
||||
const centerX = 0;
|
||||
@@ -31,8 +86,11 @@ export function snapPosition({
|
||||
const top = -canvasSize.height / 2;
|
||||
const bottom = canvasSize.height / 2;
|
||||
|
||||
const halfWidth = elementSize.width / 2;
|
||||
const halfHeight = elementSize.height / 2;
|
||||
const rotRad = (rotation * Math.PI) / 180;
|
||||
const cosR = Math.abs(Math.cos(rotRad));
|
||||
const sinR = Math.abs(Math.sin(rotRad));
|
||||
const halfWidth = (elementSize.width * cosR + elementSize.height * sinR) / 2;
|
||||
const halfHeight = (elementSize.width * sinR + elementSize.height * cosR) / 2;
|
||||
const activeLines: SnapLine[] = [];
|
||||
|
||||
type AxisSnapCandidate = {
|
||||
@@ -59,8 +117,8 @@ export function snapPosition({
|
||||
);
|
||||
}
|
||||
|
||||
const verticalTargets = [left, centerX, right];
|
||||
const horizontalTargets = [top, centerY, bottom];
|
||||
const verticalTargets = [centerX, left, right];
|
||||
const horizontalTargets = [centerY, top, bottom];
|
||||
|
||||
const xCandidates: AxisSnapCandidate[] = [];
|
||||
for (const targetX of verticalTargets) {
|
||||
@@ -133,15 +191,19 @@ export function snapScale({
|
||||
position,
|
||||
baseWidth,
|
||||
baseHeight,
|
||||
rotation = 0,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
preferredEdges,
|
||||
}: {
|
||||
proposedScale: number;
|
||||
position: { x: number; y: number };
|
||||
baseWidth: number;
|
||||
baseHeight: number;
|
||||
rotation?: number;
|
||||
canvasSize: { width: number; height: number };
|
||||
snapThreshold: { x: number; y: number };
|
||||
preferredEdges?: ScaleEdgePreference;
|
||||
}): ScaleSnapResult {
|
||||
const centerX = 0;
|
||||
const centerY = 0;
|
||||
@@ -150,15 +212,22 @@ export function snapScale({
|
||||
const top = -canvasSize.height / 2;
|
||||
const bottom = canvasSize.height / 2;
|
||||
|
||||
const leftEdge = position.x - (baseWidth * proposedScale) / 2;
|
||||
const rightEdge = position.x + (baseWidth * proposedScale) / 2;
|
||||
const topEdge = position.y - (baseHeight * proposedScale) / 2;
|
||||
const bottomEdge = position.y + (baseHeight * proposedScale) / 2;
|
||||
const rotRad = (rotation * Math.PI) / 180;
|
||||
const cosR = Math.abs(Math.cos(rotRad));
|
||||
const sinR = Math.abs(Math.sin(rotRad));
|
||||
const aabbBaseHalfW = (baseWidth * cosR + baseHeight * sinR) / 2;
|
||||
const aabbBaseHalfH = (baseWidth * sinR + baseHeight * cosR) / 2;
|
||||
|
||||
const leftEdge = position.x - aabbBaseHalfW * proposedScale;
|
||||
const rightEdge = position.x + aabbBaseHalfW * proposedScale;
|
||||
const topEdge = position.y - aabbBaseHalfH * proposedScale;
|
||||
const bottomEdge = position.y + aabbBaseHalfH * proposedScale;
|
||||
|
||||
interface SnapCandidate {
|
||||
scale: number;
|
||||
distance: number;
|
||||
lines: SnapLine[];
|
||||
edge: ScaleEdge;
|
||||
}
|
||||
|
||||
const candidates: SnapCandidate[] = [];
|
||||
@@ -175,23 +244,25 @@ export function snapScale({
|
||||
for (const target of verticalTargets) {
|
||||
const distanceLeft = Math.abs(leftEdge - target.position);
|
||||
if (distanceLeft <= snapThreshold.x) {
|
||||
const scale = (2 * (position.x - target.position)) / baseWidth;
|
||||
if (scale > MIN_SCALE) {
|
||||
const scale = (position.x - target.position) / aabbBaseHalfW;
|
||||
if (Math.abs(scale) > MIN_SCALE) {
|
||||
candidates.push({
|
||||
scale,
|
||||
distance: distanceLeft,
|
||||
lines: [target.line],
|
||||
edge: "left",
|
||||
});
|
||||
}
|
||||
}
|
||||
const distanceRight = Math.abs(rightEdge - target.position);
|
||||
if (distanceRight <= snapThreshold.x) {
|
||||
const scale = (2 * (target.position - position.x)) / baseWidth;
|
||||
if (scale > MIN_SCALE) {
|
||||
const scale = (target.position - position.x) / aabbBaseHalfW;
|
||||
if (Math.abs(scale) > MIN_SCALE) {
|
||||
candidates.push({
|
||||
scale,
|
||||
distance: distanceRight,
|
||||
lines: [target.line],
|
||||
edge: "right",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -212,40 +283,42 @@ export function snapScale({
|
||||
for (const target of horizontalTargets) {
|
||||
const distanceTop = Math.abs(topEdge - target.position);
|
||||
if (distanceTop <= snapThreshold.y) {
|
||||
const scale = (2 * (position.y - target.position)) / baseHeight;
|
||||
if (scale > MIN_SCALE) {
|
||||
const scale = (position.y - target.position) / aabbBaseHalfH;
|
||||
if (Math.abs(scale) > MIN_SCALE) {
|
||||
candidates.push({
|
||||
scale,
|
||||
distance: distanceTop,
|
||||
lines: [target.line],
|
||||
edge: "top",
|
||||
});
|
||||
}
|
||||
}
|
||||
const distanceBottom = Math.abs(bottomEdge - target.position);
|
||||
if (distanceBottom <= snapThreshold.y) {
|
||||
const scale = (2 * (target.position - position.y)) / baseHeight;
|
||||
if (scale > MIN_SCALE) {
|
||||
const scale = (target.position - position.y) / aabbBaseHalfH;
|
||||
if (Math.abs(scale) > MIN_SCALE) {
|
||||
candidates.push({
|
||||
scale,
|
||||
distance: distanceBottom,
|
||||
lines: [target.line],
|
||||
edge: "bottom",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length === 0) {
|
||||
const best = pickClosestScaleCandidate({
|
||||
candidates,
|
||||
preferredEdges,
|
||||
});
|
||||
if (!best) {
|
||||
return { snappedScale: proposedScale, activeLines: [] };
|
||||
}
|
||||
|
||||
const best = candidates.reduce((acc, candidate) =>
|
||||
candidate.distance < acc.distance ? candidate : acc,
|
||||
);
|
||||
|
||||
const snappedLeft = position.x - (baseWidth * best.scale) / 2;
|
||||
const snappedRight = position.x + (baseWidth * best.scale) / 2;
|
||||
const snappedTop = position.y - (baseHeight * best.scale) / 2;
|
||||
const snappedBottom = position.y + (baseHeight * best.scale) / 2;
|
||||
const snappedLeft = position.x - aabbBaseHalfW * best.scale;
|
||||
const snappedRight = position.x + aabbBaseHalfW * best.scale;
|
||||
const snappedTop = position.y - aabbBaseHalfH * best.scale;
|
||||
const snappedBottom = position.y + aabbBaseHalfH * best.scale;
|
||||
|
||||
const activeLines: SnapLine[] = [];
|
||||
const seenKeys = new Set<string>();
|
||||
@@ -260,16 +333,26 @@ export function snapScale({
|
||||
|
||||
for (const target of verticalTargets) {
|
||||
if (
|
||||
Math.abs(snappedLeft - target.position) <= 1 ||
|
||||
Math.abs(snappedRight - target.position) <= 1
|
||||
(hasPreferredEdge({ preferredEdges, edge: "left" }) &&
|
||||
Math.abs(snappedLeft - target.position) <= 1) ||
|
||||
(hasPreferredEdge({ preferredEdges, edge: "right" }) &&
|
||||
Math.abs(snappedRight - target.position) <= 1) ||
|
||||
(!preferredEdges &&
|
||||
(Math.abs(snappedLeft - target.position) <= 1 ||
|
||||
Math.abs(snappedRight - target.position) <= 1))
|
||||
) {
|
||||
addLine({ line: target.line });
|
||||
}
|
||||
}
|
||||
for (const target of horizontalTargets) {
|
||||
if (
|
||||
Math.abs(snappedTop - target.position) <= 1 ||
|
||||
Math.abs(snappedBottom - target.position) <= 1
|
||||
(hasPreferredEdge({ preferredEdges, edge: "top" }) &&
|
||||
Math.abs(snappedTop - target.position) <= 1) ||
|
||||
(hasPreferredEdge({ preferredEdges, edge: "bottom" }) &&
|
||||
Math.abs(snappedBottom - target.position) <= 1) ||
|
||||
(!preferredEdges &&
|
||||
(Math.abs(snappedTop - target.position) <= 1 ||
|
||||
Math.abs(snappedBottom - target.position) <= 1))
|
||||
) {
|
||||
addLine({ line: target.line });
|
||||
}
|
||||
@@ -281,6 +364,158 @@ export function snapScale({
|
||||
};
|
||||
}
|
||||
|
||||
export interface AxisSnapResult {
|
||||
snappedScale: number;
|
||||
/** Infinity when no snap candidate was within threshold */
|
||||
snapDistance: number;
|
||||
activeLines: SnapLine[];
|
||||
}
|
||||
|
||||
export function snapScaleAxes({
|
||||
proposedScaleX,
|
||||
proposedScaleY,
|
||||
position,
|
||||
baseWidth,
|
||||
baseHeight,
|
||||
rotation = 0,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
preferredEdges,
|
||||
}: {
|
||||
proposedScaleX: number;
|
||||
proposedScaleY: number;
|
||||
position: { x: number; y: number };
|
||||
baseWidth: number;
|
||||
baseHeight: number;
|
||||
rotation?: number;
|
||||
canvasSize: { width: number; height: number };
|
||||
snapThreshold: { x: number; y: number };
|
||||
preferredEdges?: ScaleEdgePreference;
|
||||
}): { x: AxisSnapResult; y: AxisSnapResult } {
|
||||
const canvasLeft = -canvasSize.width / 2;
|
||||
const canvasRight = canvasSize.width / 2;
|
||||
const canvasTop = -canvasSize.height / 2;
|
||||
const canvasBottom = canvasSize.height / 2;
|
||||
|
||||
const rotRad = (rotation * Math.PI) / 180;
|
||||
const cosR = Math.abs(Math.cos(rotRad));
|
||||
const sinR = Math.abs(Math.sin(rotRad));
|
||||
const EPSILON = 1e-6;
|
||||
|
||||
// Current AABB edges at proposed scales
|
||||
const currentAabbHalfW = (baseWidth * proposedScaleX * cosR + baseHeight * proposedScaleY * sinR) / 2;
|
||||
const currentAabbHalfH = (baseWidth * proposedScaleX * sinR + baseHeight * proposedScaleY * cosR) / 2;
|
||||
const currentLeftEdge = position.x - currentAabbHalfW;
|
||||
const currentRightEdge = position.x + currentAabbHalfW;
|
||||
const currentTopEdge = position.y - currentAabbHalfH;
|
||||
const currentBottomEdge = position.y + currentAabbHalfH;
|
||||
|
||||
interface Candidate {
|
||||
scale: number;
|
||||
distance: number;
|
||||
line: SnapLine;
|
||||
edge: ScaleEdge;
|
||||
}
|
||||
|
||||
function bestCandidate({
|
||||
candidates,
|
||||
proposedScale,
|
||||
}: {
|
||||
candidates: Candidate[];
|
||||
proposedScale: number;
|
||||
}): AxisSnapResult {
|
||||
const best = pickClosestScaleCandidate({
|
||||
candidates,
|
||||
preferredEdges,
|
||||
});
|
||||
if (!best) {
|
||||
return { snappedScale: proposedScale, snapDistance: Infinity, activeLines: [] };
|
||||
}
|
||||
return { snappedScale: best.scale, snapDistance: best.distance, activeLines: [best.line] };
|
||||
}
|
||||
|
||||
// sX candidates: snap via vertical targets (left/right AABB edges) — only valid when cosR ≠ 0
|
||||
// snap via horizontal targets (top/bottom AABB edges) — only valid when sinR ≠ 0
|
||||
const xCandidates: Candidate[] = [];
|
||||
const yContribW = baseHeight * proposedScaleY * sinR;
|
||||
const yContribH = baseHeight * proposedScaleY * cosR;
|
||||
|
||||
if (cosR > EPSILON) {
|
||||
for (const T of [canvasLeft, 0, canvasRight]) {
|
||||
const line: SnapLine = { type: "vertical", position: T };
|
||||
const distLeft = Math.abs(currentLeftEdge - T);
|
||||
if (distLeft <= snapThreshold.x) {
|
||||
const scale = (2 * (position.x - T) - yContribW) / (baseWidth * cosR);
|
||||
if (Math.abs(scale) > MIN_SCALE) xCandidates.push({ scale, distance: distLeft, line, edge: "left" });
|
||||
}
|
||||
const distRight = Math.abs(currentRightEdge - T);
|
||||
if (distRight <= snapThreshold.x) {
|
||||
const scale = (2 * (T - position.x) - yContribW) / (baseWidth * cosR);
|
||||
if (Math.abs(scale) > MIN_SCALE) xCandidates.push({ scale, distance: distRight, line, edge: "right" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sinR > EPSILON) {
|
||||
for (const T of [canvasTop, 0, canvasBottom]) {
|
||||
const line: SnapLine = { type: "horizontal", position: T };
|
||||
const distTop = Math.abs(currentTopEdge - T);
|
||||
if (distTop <= snapThreshold.y) {
|
||||
const scale = (2 * (position.y - T) - yContribH) / (baseWidth * sinR);
|
||||
if (Math.abs(scale) > MIN_SCALE) xCandidates.push({ scale, distance: distTop, line, edge: "top" });
|
||||
}
|
||||
const distBottom = Math.abs(currentBottomEdge - T);
|
||||
if (distBottom <= snapThreshold.y) {
|
||||
const scale = (2 * (T - position.y) - yContribH) / (baseWidth * sinR);
|
||||
if (Math.abs(scale) > MIN_SCALE) xCandidates.push({ scale, distance: distBottom, line, edge: "bottom" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sY candidates: snap via vertical targets — only valid when sinR ≠ 0
|
||||
// snap via horizontal targets — only valid when cosR ≠ 0
|
||||
const yCandidates: Candidate[] = [];
|
||||
const xContribW = baseWidth * proposedScaleX * cosR;
|
||||
const xContribH = baseWidth * proposedScaleX * sinR;
|
||||
|
||||
if (sinR > EPSILON) {
|
||||
for (const T of [canvasLeft, 0, canvasRight]) {
|
||||
const line: SnapLine = { type: "vertical", position: T };
|
||||
const distLeft = Math.abs(currentLeftEdge - T);
|
||||
if (distLeft <= snapThreshold.x) {
|
||||
const scale = (2 * (position.x - T) - xContribW) / (baseHeight * sinR);
|
||||
if (Math.abs(scale) > MIN_SCALE) yCandidates.push({ scale, distance: distLeft, line, edge: "left" });
|
||||
}
|
||||
const distRight = Math.abs(currentRightEdge - T);
|
||||
if (distRight <= snapThreshold.x) {
|
||||
const scale = (2 * (T - position.x) - xContribW) / (baseHeight * sinR);
|
||||
if (Math.abs(scale) > MIN_SCALE) yCandidates.push({ scale, distance: distRight, line, edge: "right" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (cosR > EPSILON) {
|
||||
for (const T of [canvasTop, 0, canvasBottom]) {
|
||||
const line: SnapLine = { type: "horizontal", position: T };
|
||||
const distTop = Math.abs(currentTopEdge - T);
|
||||
if (distTop <= snapThreshold.y) {
|
||||
const scale = (2 * (position.y - T) - xContribH) / (baseHeight * cosR);
|
||||
if (Math.abs(scale) > MIN_SCALE) yCandidates.push({ scale, distance: distTop, line, edge: "top" });
|
||||
}
|
||||
const distBottom = Math.abs(currentBottomEdge - T);
|
||||
if (distBottom <= snapThreshold.y) {
|
||||
const scale = (2 * (T - position.y) - xContribH) / (baseHeight * cosR);
|
||||
if (Math.abs(scale) > MIN_SCALE) yCandidates.push({ scale, distance: distBottom, line, edge: "bottom" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
x: bestCandidate({ candidates: xCandidates, proposedScale: proposedScaleX }),
|
||||
y: bestCandidate({ candidates: yCandidates, proposedScale: proposedScaleY }),
|
||||
};
|
||||
}
|
||||
|
||||
export interface RotationSnapResult {
|
||||
snappedRotation: number;
|
||||
isSnapped: boolean;
|
||||
|
||||
Reference in New Issue
Block a user