feat: masks, properties refactor, shaders, storage migrations, and more

This commit is contained in:
Maze Winther
2026-03-29 15:48:22 +02:00
parent 39ea298a9c
commit 8db3bead13
690 changed files with 35618 additions and 7337 deletions
+148 -111
View File
@@ -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,
+48 -6
View File
@@ -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
);
}
+55 -44
View File
@@ -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,
};
}
+264 -29
View File
@@ -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;