diff options
Diffstat (limited to 'apps/web/src/lib/preview')
| -rw-r--r-- | apps/web/src/lib/preview/element-bounds.ts | 654 | ||||
| -rw-r--r-- | apps/web/src/lib/preview/preview-snap.ts | 1078 |
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 };
+}
|
