From 4d127a038bf768eded68aae3cfa5c1b12241bdac Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Fri, 27 Mar 2026 00:55:15 +0100 Subject: chore: normalize line endings --- apps/web/src/lib/preview/element-bounds.ts | 654 ++++++++--------- apps/web/src/lib/preview/preview-snap.ts | 1078 ++++++++++++++-------------- 2 files changed, 866 insertions(+), 866 deletions(-) (limited to 'apps/web/src/lib/preview') 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({ - 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(); - - 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({ + 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(); + + 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 }; +} -- cgit v1.3.1