summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/preview
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/lib/preview')
-rw-r--r--apps/web/src/lib/preview/element-bounds.ts654
-rw-r--r--apps/web/src/lib/preview/preview-snap.ts1078
2 files changed, 866 insertions, 866 deletions
diff --git a/apps/web/src/lib/preview/element-bounds.ts b/apps/web/src/lib/preview/element-bounds.ts
index 5c416afc..c8420467 100644
--- a/apps/web/src/lib/preview/element-bounds.ts
+++ b/apps/web/src/lib/preview/element-bounds.ts
@@ -1,327 +1,327 @@
-import type { TimelineTrack, TimelineElement } from "@/lib/timeline";
-import type { MediaAsset } from "@/lib/media/types";
-import { isMainTrack } from "@/lib/timeline";
-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,
-} from "@/lib/animation";
-
-export interface ElementBounds {
- cx: number;
- cy: number;
- width: number;
- height: number;
- rotation: number;
-}
-
-export interface ElementWithBounds {
- trackId: string;
- elementId: string;
- element: TimelineElement;
- bounds: ElementBounds;
-}
-
-function getVisualElementBounds({
- canvasWidth,
- canvasHeight,
- sourceWidth,
- sourceHeight,
- transform,
-}: {
- canvasWidth: number;
- canvasHeight: number;
- sourceWidth: number;
- sourceHeight: number;
- transform: {
- scaleX: number;
- scaleY: number;
- position: { x: number; y: number };
- rotate: number;
- };
-}): ElementBounds {
- const containScale = Math.min(
- canvasWidth / sourceWidth,
- canvasHeight / sourceHeight,
- );
- 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;
-
- return {
- cx,
- cy,
- width: scaledWidth,
- height: scaledHeight,
- rotation: transform.rotate,
- };
-}
-
-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,
- localTime,
-}: {
- element: TimelineElement;
- canvasSize: { width: number; height: number };
- mediaAsset?: MediaAsset | null;
- localTime: number;
-}): ElementBounds | null {
- if (element.type === "audio" || element.type === "effect") return null;
- if ("hidden" in element && element.hidden) return null;
-
- const { width: canvasWidth, height: canvasHeight } = canvasSize;
-
- if (element.type === "video" || element.type === "image") {
- const transform = resolveTransformAtTime({
- baseTransform: element.transform,
- animations: element.animations,
- localTime,
- });
- const sourceWidth = mediaAsset?.width ?? canvasWidth;
- const sourceHeight = mediaAsset?.height ?? canvasHeight;
- return getVisualElementBounds({
- canvasWidth,
- canvasHeight,
- sourceWidth,
- sourceHeight,
- transform,
- });
- }
-
- if (element.type === "sticker") {
- const transform = resolveTransformAtTime({
- baseTransform: element.transform,
- animations: element.animations,
- localTime,
- });
- return getVisualElementBounds({
- canvasWidth,
- canvasHeight,
- 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,
- });
- }
-
- if (element.type === "text") {
- const transform = resolveTransformAtTime({
- baseTransform: element.transform,
- animations: element.animations,
- localTime,
- });
-
- const canvas = document.createElement("canvas");
- const ctx = canvas.getContext("2d");
- if (!ctx) return null;
-
- const measured = measureTextElement({
- element,
- canvasHeight,
- localTime,
- ctx,
- });
-
- 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,
- canvasSize,
- mediaAssets,
-}: {
- tracks: TimelineTrack[];
- currentTime: number;
- canvasSize: { width: number; height: number };
- mediaAssets: MediaAsset[];
-}): ElementWithBounds[] {
- const mediaMap = new Map(mediaAssets.map((m) => [m.id, m]));
- const visibleTracks = tracks.filter(
- (track) => !("hidden" in track && track.hidden),
- );
- const orderedTracks = [
- ...visibleTracks.filter((track) => !isMainTrack(track)),
- ...visibleTracks.filter((track) => isMainTrack(track)),
- ].reverse();
-
- const result: ElementWithBounds[] = [];
-
- for (const track of orderedTracks) {
- const elements = track.elements
- .filter((element) => !("hidden" in element && element.hidden))
- .filter(
- (element) =>
- currentTime >= element.startTime &&
- currentTime < element.startTime + element.duration,
- )
- .slice()
- .sort((a, b) => {
- if (a.startTime !== b.startTime) return a.startTime - b.startTime;
- return a.id.localeCompare(b.id);
- });
-
- for (const element of elements) {
- const localTime = getElementLocalTime({
- timelineTime: currentTime,
- elementStartTime: element.startTime,
- elementDuration: element.duration,
- });
- const mediaAsset =
- element.type === "video" || element.type === "image"
- ? mediaMap.get(element.mediaId)
- : undefined;
- const bounds = getElementBounds({
- element,
- canvasSize,
- mediaAsset,
- localTime,
- });
- if (bounds) {
- result.push({
- trackId: track.id,
- elementId: element.id,
- element,
- bounds,
- });
- }
- }
- }
-
- return result;
-}
+import type { TimelineTrack, TimelineElement } from "@/lib/timeline";
+import type { MediaAsset } from "@/lib/media/types";
+import { isMainTrack } from "@/lib/timeline";
+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,
+} from "@/lib/animation";
+
+export interface ElementBounds {
+ cx: number;
+ cy: number;
+ width: number;
+ height: number;
+ rotation: number;
+}
+
+export interface ElementWithBounds {
+ trackId: string;
+ elementId: string;
+ element: TimelineElement;
+ bounds: ElementBounds;
+}
+
+function getVisualElementBounds({
+ canvasWidth,
+ canvasHeight,
+ sourceWidth,
+ sourceHeight,
+ transform,
+}: {
+ canvasWidth: number;
+ canvasHeight: number;
+ sourceWidth: number;
+ sourceHeight: number;
+ transform: {
+ scaleX: number;
+ scaleY: number;
+ position: { x: number; y: number };
+ rotate: number;
+ };
+}): ElementBounds {
+ const containScale = Math.min(
+ canvasWidth / sourceWidth,
+ canvasHeight / sourceHeight,
+ );
+ 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;
+
+ return {
+ cx,
+ cy,
+ width: scaledWidth,
+ height: scaledHeight,
+ rotation: transform.rotate,
+ };
+}
+
+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,
+ localTime,
+}: {
+ element: TimelineElement;
+ canvasSize: { width: number; height: number };
+ mediaAsset?: MediaAsset | null;
+ localTime: number;
+}): ElementBounds | null {
+ if (element.type === "audio" || element.type === "effect") return null;
+ if ("hidden" in element && element.hidden) return null;
+
+ const { width: canvasWidth, height: canvasHeight } = canvasSize;
+
+ if (element.type === "video" || element.type === "image") {
+ const transform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
+ const sourceWidth = mediaAsset?.width ?? canvasWidth;
+ const sourceHeight = mediaAsset?.height ?? canvasHeight;
+ return getVisualElementBounds({
+ canvasWidth,
+ canvasHeight,
+ sourceWidth,
+ sourceHeight,
+ transform,
+ });
+ }
+
+ if (element.type === "sticker") {
+ const transform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
+ return getVisualElementBounds({
+ canvasWidth,
+ canvasHeight,
+ 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,
+ });
+ }
+
+ if (element.type === "text") {
+ const transform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
+
+ const canvas = document.createElement("canvas");
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return null;
+
+ const measured = measureTextElement({
+ element,
+ canvasHeight,
+ localTime,
+ ctx,
+ });
+
+ 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,
+ canvasSize,
+ mediaAssets,
+}: {
+ tracks: TimelineTrack[];
+ currentTime: number;
+ canvasSize: { width: number; height: number };
+ mediaAssets: MediaAsset[];
+}): ElementWithBounds[] {
+ const mediaMap = new Map(mediaAssets.map((m) => [m.id, m]));
+ const visibleTracks = tracks.filter(
+ (track) => !("hidden" in track && track.hidden),
+ );
+ const orderedTracks = [
+ ...visibleTracks.filter((track) => !isMainTrack(track)),
+ ...visibleTracks.filter((track) => isMainTrack(track)),
+ ].reverse();
+
+ const result: ElementWithBounds[] = [];
+
+ for (const track of orderedTracks) {
+ const elements = track.elements
+ .filter((element) => !("hidden" in element && element.hidden))
+ .filter(
+ (element) =>
+ currentTime >= element.startTime &&
+ currentTime < element.startTime + element.duration,
+ )
+ .slice()
+ .sort((a, b) => {
+ if (a.startTime !== b.startTime) return a.startTime - b.startTime;
+ return a.id.localeCompare(b.id);
+ });
+
+ for (const element of elements) {
+ const localTime = getElementLocalTime({
+ timelineTime: currentTime,
+ elementStartTime: element.startTime,
+ elementDuration: element.duration,
+ });
+ const mediaAsset =
+ element.type === "video" || element.type === "image"
+ ? mediaMap.get(element.mediaId)
+ : undefined;
+ const bounds = getElementBounds({
+ element,
+ canvasSize,
+ mediaAsset,
+ localTime,
+ });
+ if (bounds) {
+ result.push({
+ trackId: track.id,
+ elementId: element.id,
+ element,
+ bounds,
+ });
+ }
+ }
+ }
+
+ return result;
+}
diff --git a/apps/web/src/lib/preview/preview-snap.ts b/apps/web/src/lib/preview/preview-snap.ts
index 64220264..86d35390 100644
--- a/apps/web/src/lib/preview/preview-snap.ts
+++ b/apps/web/src/lib/preview/preview-snap.ts
@@ -1,539 +1,539 @@
-export interface SnapLine {
- type: "horizontal" | "vertical";
- position: number;
-}
-
-const ROTATION_SNAP_STEP_DEGREES = 90;
-const ROTATION_SNAP_THRESHOLD_DEGREES = 5;
-export const MIN_SCALE = 0.01;
-export const SNAP_THRESHOLD_SCREEN_PIXELS = 8;
-
-export interface SnapResult {
- snappedPosition: { x: number; y: number };
- 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;
- const centerY = 0;
- const left = -canvasSize.width / 2;
- const right = canvasSize.width / 2;
- const top = -canvasSize.height / 2;
- const bottom = canvasSize.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 = {
- snappedPosition: number;
- line: SnapLine;
- distance: number;
- };
-
- function getClosestAxisSnap({
- candidates,
- threshold,
- }: {
- candidates: AxisSnapCandidate[];
- threshold: number;
- }): AxisSnapCandidate | null {
- const snapCandidatesWithinThreshold = candidates.filter(
- (candidate) => candidate.distance <= threshold,
- );
- if (snapCandidatesWithinThreshold.length === 0) {
- return null;
- }
- return snapCandidatesWithinThreshold.reduce((closest, current) =>
- current.distance < closest.distance ? current : closest,
- );
- }
-
- const verticalTargets = [centerX, left, right];
- const horizontalTargets = [centerY, top, bottom];
-
- const xCandidates: AxisSnapCandidate[] = [];
- for (const targetX of verticalTargets) {
- xCandidates.push({
- snappedPosition: targetX,
- line: { type: "vertical", position: targetX },
- distance: Math.abs(proposedPosition.x - targetX),
- });
- xCandidates.push({
- snappedPosition: targetX + halfWidth,
- line: { type: "vertical", position: targetX },
- distance: Math.abs(proposedPosition.x - halfWidth - targetX),
- });
- xCandidates.push({
- snappedPosition: targetX - halfWidth,
- line: { type: "vertical", position: targetX },
- distance: Math.abs(proposedPosition.x + halfWidth - targetX),
- });
- }
- const yCandidates: AxisSnapCandidate[] = [];
- for (const targetY of horizontalTargets) {
- yCandidates.push({
- snappedPosition: targetY,
- line: { type: "horizontal", position: targetY },
- distance: Math.abs(proposedPosition.y - targetY),
- });
- yCandidates.push({
- snappedPosition: targetY + halfHeight,
- line: { type: "horizontal", position: targetY },
- distance: Math.abs(proposedPosition.y - halfHeight - targetY),
- });
- yCandidates.push({
- snappedPosition: targetY - halfHeight,
- line: { type: "horizontal", position: targetY },
- distance: Math.abs(proposedPosition.y + halfHeight - targetY),
- });
- }
-
- const closestX = getClosestAxisSnap({
- candidates: xCandidates,
- threshold: snapThreshold.x,
- });
- const closestY = getClosestAxisSnap({
- candidates: yCandidates,
- threshold: snapThreshold.y,
- });
-
- const x = closestX?.snappedPosition ?? proposedPosition.x;
- const y = closestY?.snappedPosition ?? proposedPosition.y;
- if (closestX) {
- activeLines.push(closestX.line);
- }
- if (closestY) {
- activeLines.push(closestY.line);
- }
-
- return {
- snappedPosition: { x, y },
- activeLines,
- };
-}
-
-export interface ScaleSnapResult {
- snappedScale: number;
- activeLines: SnapLine[];
-}
-
-export function snapScale({
- proposedScale,
- 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;
- const left = -canvasSize.width / 2;
- const right = canvasSize.width / 2;
- const top = -canvasSize.height / 2;
- const bottom = canvasSize.height / 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[] = [];
-
- const verticalTargets = [
- { position: left, line: { type: "vertical" as const, position: left } },
- {
- position: centerX,
- line: { type: "vertical" as const, position: centerX },
- },
- { position: right, line: { type: "vertical" as const, position: right } },
- ];
-
- for (const target of verticalTargets) {
- const distanceLeft = Math.abs(leftEdge - target.position);
- if (distanceLeft <= snapThreshold.x) {
- 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 = (target.position - position.x) / aabbBaseHalfW;
- if (Math.abs(scale) > MIN_SCALE) {
- candidates.push({
- scale,
- distance: distanceRight,
- lines: [target.line],
- edge: "right",
- });
- }
- }
- }
-
- const horizontalTargets = [
- { position: top, line: { type: "horizontal" as const, position: top } },
- {
- position: centerY,
- line: { type: "horizontal" as const, position: centerY },
- },
- {
- position: bottom,
- line: { type: "horizontal" as const, position: bottom },
- },
- ];
-
- for (const target of horizontalTargets) {
- const distanceTop = Math.abs(topEdge - target.position);
- if (distanceTop <= snapThreshold.y) {
- 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 = (target.position - position.y) / aabbBaseHalfH;
- if (Math.abs(scale) > MIN_SCALE) {
- candidates.push({
- scale,
- distance: distanceBottom,
- lines: [target.line],
- edge: "bottom",
- });
- }
- }
- }
-
- const best = pickClosestScaleCandidate({
- candidates,
- preferredEdges,
- });
- if (!best) {
- return { snappedScale: proposedScale, activeLines: [] };
- }
-
- 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>();
-
- function addLine({ line }: { line: SnapLine }) {
- const key = `${line.type}-${line.position}`;
- if (!seenKeys.has(key)) {
- seenKeys.add(key);
- activeLines.push(line);
- }
- }
-
- for (const target of verticalTargets) {
- if (
- (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 (
- (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 });
- }
- }
-
- return {
- snappedScale: best.scale,
- activeLines,
- };
-}
-
-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;
-}
-
-export function snapRotation({
- proposedRotation,
-}: {
- proposedRotation: number;
-}): RotationSnapResult {
- const nearestRotationSnap =
- Math.round(proposedRotation / ROTATION_SNAP_STEP_DEGREES) *
- ROTATION_SNAP_STEP_DEGREES;
- const distanceToNearestSnap = Math.abs(
- proposedRotation - nearestRotationSnap,
- );
- if (distanceToNearestSnap <= ROTATION_SNAP_THRESHOLD_DEGREES) {
- return { snappedRotation: nearestRotationSnap, isSnapped: true };
- }
- return { snappedRotation: proposedRotation, isSnapped: false };
-}
+export interface SnapLine {
+ type: "horizontal" | "vertical";
+ position: number;
+}
+
+const ROTATION_SNAP_STEP_DEGREES = 90;
+const ROTATION_SNAP_THRESHOLD_DEGREES = 5;
+export const MIN_SCALE = 0.01;
+export const SNAP_THRESHOLD_SCREEN_PIXELS = 8;
+
+export interface SnapResult {
+ snappedPosition: { x: number; y: number };
+ 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;
+ const centerY = 0;
+ const left = -canvasSize.width / 2;
+ const right = canvasSize.width / 2;
+ const top = -canvasSize.height / 2;
+ const bottom = canvasSize.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 = {
+ snappedPosition: number;
+ line: SnapLine;
+ distance: number;
+ };
+
+ function getClosestAxisSnap({
+ candidates,
+ threshold,
+ }: {
+ candidates: AxisSnapCandidate[];
+ threshold: number;
+ }): AxisSnapCandidate | null {
+ const snapCandidatesWithinThreshold = candidates.filter(
+ (candidate) => candidate.distance <= threshold,
+ );
+ if (snapCandidatesWithinThreshold.length === 0) {
+ return null;
+ }
+ return snapCandidatesWithinThreshold.reduce((closest, current) =>
+ current.distance < closest.distance ? current : closest,
+ );
+ }
+
+ const verticalTargets = [centerX, left, right];
+ const horizontalTargets = [centerY, top, bottom];
+
+ const xCandidates: AxisSnapCandidate[] = [];
+ for (const targetX of verticalTargets) {
+ xCandidates.push({
+ snappedPosition: targetX,
+ line: { type: "vertical", position: targetX },
+ distance: Math.abs(proposedPosition.x - targetX),
+ });
+ xCandidates.push({
+ snappedPosition: targetX + halfWidth,
+ line: { type: "vertical", position: targetX },
+ distance: Math.abs(proposedPosition.x - halfWidth - targetX),
+ });
+ xCandidates.push({
+ snappedPosition: targetX - halfWidth,
+ line: { type: "vertical", position: targetX },
+ distance: Math.abs(proposedPosition.x + halfWidth - targetX),
+ });
+ }
+ const yCandidates: AxisSnapCandidate[] = [];
+ for (const targetY of horizontalTargets) {
+ yCandidates.push({
+ snappedPosition: targetY,
+ line: { type: "horizontal", position: targetY },
+ distance: Math.abs(proposedPosition.y - targetY),
+ });
+ yCandidates.push({
+ snappedPosition: targetY + halfHeight,
+ line: { type: "horizontal", position: targetY },
+ distance: Math.abs(proposedPosition.y - halfHeight - targetY),
+ });
+ yCandidates.push({
+ snappedPosition: targetY - halfHeight,
+ line: { type: "horizontal", position: targetY },
+ distance: Math.abs(proposedPosition.y + halfHeight - targetY),
+ });
+ }
+
+ const closestX = getClosestAxisSnap({
+ candidates: xCandidates,
+ threshold: snapThreshold.x,
+ });
+ const closestY = getClosestAxisSnap({
+ candidates: yCandidates,
+ threshold: snapThreshold.y,
+ });
+
+ const x = closestX?.snappedPosition ?? proposedPosition.x;
+ const y = closestY?.snappedPosition ?? proposedPosition.y;
+ if (closestX) {
+ activeLines.push(closestX.line);
+ }
+ if (closestY) {
+ activeLines.push(closestY.line);
+ }
+
+ return {
+ snappedPosition: { x, y },
+ activeLines,
+ };
+}
+
+export interface ScaleSnapResult {
+ snappedScale: number;
+ activeLines: SnapLine[];
+}
+
+export function snapScale({
+ proposedScale,
+ 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;
+ const left = -canvasSize.width / 2;
+ const right = canvasSize.width / 2;
+ const top = -canvasSize.height / 2;
+ const bottom = canvasSize.height / 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[] = [];
+
+ const verticalTargets = [
+ { position: left, line: { type: "vertical" as const, position: left } },
+ {
+ position: centerX,
+ line: { type: "vertical" as const, position: centerX },
+ },
+ { position: right, line: { type: "vertical" as const, position: right } },
+ ];
+
+ for (const target of verticalTargets) {
+ const distanceLeft = Math.abs(leftEdge - target.position);
+ if (distanceLeft <= snapThreshold.x) {
+ 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 = (target.position - position.x) / aabbBaseHalfW;
+ if (Math.abs(scale) > MIN_SCALE) {
+ candidates.push({
+ scale,
+ distance: distanceRight,
+ lines: [target.line],
+ edge: "right",
+ });
+ }
+ }
+ }
+
+ const horizontalTargets = [
+ { position: top, line: { type: "horizontal" as const, position: top } },
+ {
+ position: centerY,
+ line: { type: "horizontal" as const, position: centerY },
+ },
+ {
+ position: bottom,
+ line: { type: "horizontal" as const, position: bottom },
+ },
+ ];
+
+ for (const target of horizontalTargets) {
+ const distanceTop = Math.abs(topEdge - target.position);
+ if (distanceTop <= snapThreshold.y) {
+ 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 = (target.position - position.y) / aabbBaseHalfH;
+ if (Math.abs(scale) > MIN_SCALE) {
+ candidates.push({
+ scale,
+ distance: distanceBottom,
+ lines: [target.line],
+ edge: "bottom",
+ });
+ }
+ }
+ }
+
+ const best = pickClosestScaleCandidate({
+ candidates,
+ preferredEdges,
+ });
+ if (!best) {
+ return { snappedScale: proposedScale, activeLines: [] };
+ }
+
+ 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>();
+
+ function addLine({ line }: { line: SnapLine }) {
+ const key = `${line.type}-${line.position}`;
+ if (!seenKeys.has(key)) {
+ seenKeys.add(key);
+ activeLines.push(line);
+ }
+ }
+
+ for (const target of verticalTargets) {
+ if (
+ (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 (
+ (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 });
+ }
+ }
+
+ return {
+ snappedScale: best.scale,
+ activeLines,
+ };
+}
+
+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;
+}
+
+export function snapRotation({
+ proposedRotation,
+}: {
+ proposedRotation: number;
+}): RotationSnapResult {
+ const nearestRotationSnap =
+ Math.round(proposedRotation / ROTATION_SNAP_STEP_DEGREES) *
+ ROTATION_SNAP_STEP_DEGREES;
+ const distanceToNearestSnap = Math.abs(
+ proposedRotation - nearestRotationSnap,
+ );
+ if (distanceToNearestSnap <= ROTATION_SNAP_THRESHOLD_DEGREES) {
+ return { snappedRotation: nearestRotationSnap, isSnapped: true };
+ }
+ return { snappedRotation: proposedRotation, isSnapped: false };
+}