summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/preview
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-03-25 16:47:22 +0100
committerMaze Winther <mazewinther@gmail.com>2026-03-29 15:48:22 +0200
commit8db3bead13441a5882d8689dfc6a9f09c82375d9 (patch)
treee9d0cafa93904ffcbec3188c37961bcc16c6422d /apps/web/src/lib/preview
parent39ea298a9ccba3104c549c0ca079c231981811f5 (diff)
downloadopencut-8db3bead13441a5882d8689dfc6a9f09c82375d9.tar.gz
feat: masks, properties refactor, shaders, storage migrations, and more
Diffstat (limited to 'apps/web/src/lib/preview')
-rw-r--r--apps/web/src/lib/preview/element-bounds.ts259
-rw-r--r--apps/web/src/lib/preview/hit-test.ts54
-rw-r--r--apps/web/src/lib/preview/preview-coords.ts99
-rw-r--r--apps/web/src/lib/preview/preview-snap.ts293
4 files changed, 515 insertions, 190 deletions
diff --git a/apps/web/src/lib/preview/element-bounds.ts b/apps/web/src/lib/preview/element-bounds.ts
index 08ffbfa8..5c416afc 100644
--- a/apps/web/src/lib/preview/element-bounds.ts
+++ b/apps/web/src/lib/preview/element-bounds.ts
@@ -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,123 +148,118 @@ 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 === "text") {
+ if (element.type === "graphic") {
const transform = resolveTransformAtTime({
baseTransform: element.transform,
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;
+ return getVisualElementBounds({
+ canvasWidth,
+ canvasHeight,
+ sourceWidth: DEFAULT_GRAPHIC_SOURCE_SIZE,
+ sourceHeight: DEFAULT_GRAPHIC_SOURCE_SIZE,
+ transform,
+ });
+ }
- let measuredWidth = 100;
- let measuredHeight = scaledFontSize;
+ if (element.type === "text") {
+ const transform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
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 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 measured = measureTextElement({
+ element,
+ canvasHeight,
+ localTime,
+ ctx,
+ });
- 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,
diff --git a/apps/web/src/lib/preview/hit-test.ts b/apps/web/src/lib/preview/hit-test.ts
index c2fde87b..2552922b 100644
--- a/apps/web/src/lib/preview/hit-test.ts
+++ b/apps/web/src/lib/preview/hit-test.ts
@@ -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
+ );
}
diff --git a/apps/web/src/lib/preview/preview-coords.ts b/apps/web/src/lib/preview/preview-coords.ts
index dcc87282..64184990 100644
--- a/apps/web/src/lib/preview/preview-coords.ts
+++ b/apps/web/src/lib/preview/preview-coords.ts
@@ -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,
};
}
diff --git a/apps/web/src/lib/preview/preview-snap.ts b/apps/web/src/lib/preview/preview-snap.ts
index 36efc3ce..64220264 100644
--- a/apps/web/src/lib/preview/preview-snap.ts
+++ b/apps/web/src/lib/preview/preview-snap.ts
@@ -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;