diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-03-25 16:47:22 +0100 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-03-29 15:48:22 +0200 |
| commit | 8db3bead13441a5882d8689dfc6a9f09c82375d9 (patch) | |
| tree | e9d0cafa93904ffcbec3188c37961bcc16c6422d /apps/web/src/lib/preview | |
| parent | 39ea298a9ccba3104c549c0ca079c231981811f5 (diff) | |
| download | opencut-8db3bead13441a5882d8689dfc6a9f09c82375d9.tar.gz | |
feat: masks, properties refactor, shaders, storage migrations, and more
Diffstat (limited to 'apps/web/src/lib/preview')
| -rw-r--r-- | apps/web/src/lib/preview/element-bounds.ts | 259 | ||||
| -rw-r--r-- | apps/web/src/lib/preview/hit-test.ts | 54 | ||||
| -rw-r--r-- | apps/web/src/lib/preview/preview-coords.ts | 99 | ||||
| -rw-r--r-- | apps/web/src/lib/preview/preview-snap.ts | 293 |
4 files changed, 515 insertions, 190 deletions
diff --git a/apps/web/src/lib/preview/element-bounds.ts b/apps/web/src/lib/preview/element-bounds.ts index 08ffbfa8..5c416afc 100644 --- a/apps/web/src/lib/preview/element-bounds.ts +++ b/apps/web/src/lib/preview/element-bounds.ts @@ -1,17 +1,12 @@ -import type { TimelineTrack, TimelineElement } from "@/types/timeline"; -import type { MediaAsset } from "@/types/assets"; +import type { TimelineTrack, TimelineElement } from "@/lib/timeline"; +import type { MediaAsset } from "@/lib/media/types"; import { isMainTrack } from "@/lib/timeline"; -import { - DEFAULT_TEXT_ELEMENT, - DEFAULT_LINE_HEIGHT, - DEFAULT_TEXT_BACKGROUND, - FONT_SIZE_SCALE_REFERENCE, -} from "@/constants/text-constants"; -import { getTextVisualRect, measureTextBlock } from "@/lib/text/layout"; +import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/constants/sticker-constants"; +import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/lib/graphics"; +import { measureTextElement } from "@/lib/text/measure-element"; import { getElementLocalTime, resolveTransformAtTime, - resolveNumberAtTime, } from "@/lib/animation"; export interface ElementBounds { @@ -41,7 +36,8 @@ function getVisualElementBounds({ sourceWidth: number; sourceHeight: number; transform: { - scale: number; + scaleX: number; + scaleY: number; position: { x: number; y: number }; rotate: number; }; @@ -50,8 +46,8 @@ function getVisualElementBounds({ canvasWidth / sourceWidth, canvasHeight / sourceHeight, ); - const scaledWidth = sourceWidth * containScale * transform.scale; - const scaledHeight = sourceHeight * containScale * transform.scale; + const scaledWidth = sourceWidth * containScale * transform.scaleX; + const scaledHeight = sourceHeight * containScale * transform.scaleY; const cx = canvasWidth / 2 + transform.position.x; const cy = canvasHeight / 2 + transform.position.y; @@ -64,7 +60,53 @@ function getVisualElementBounds({ }; } -export function getElementBounds({ +function getTransformedRectBounds({ + canvasWidth, + canvasHeight, + rect, + transform, +}: { + canvasWidth: number; + canvasHeight: number; + rect: { left: number; top: number; width: number; height: number }; + transform: { + scaleX: number; + scaleY: number; + position: { x: number; y: number }; + rotate: number; + }; +}): ElementBounds { + const localCenterX = rect.left + rect.width / 2; + const localCenterY = rect.top + rect.height / 2; + const scaledCenterX = localCenterX * transform.scaleX; + const scaledCenterY = localCenterY * transform.scaleY; + const rotationRad = (transform.rotate * Math.PI) / 180; + const cos = Math.cos(rotationRad); + const sin = Math.sin(rotationRad); + return { + cx: + canvasWidth / 2 + + transform.position.x + + scaledCenterX * cos - + scaledCenterY * sin, + cy: + canvasHeight / 2 + + transform.position.y + + scaledCenterX * sin + + scaledCenterY * cos, + width: rect.width * transform.scaleX, + height: rect.height * transform.scaleY, + rotation: transform.rotate, + }; +} + +/** + * Bounds policy: bounds reflect base content geometry (text glyphs + background, + * sticker/image/video content area) and base transform. Post-effect spill (blur, + * glow) and mask-clipped regions are intentionally excluded — handles manipulate + * the canonical element geometry, not visual effect output. + */ +function getElementBounds({ element, canvasSize, mediaAsset, @@ -106,123 +148,118 @@ export function getElementBounds({ return getVisualElementBounds({ canvasWidth, canvasHeight, - sourceWidth: 200, - sourceHeight: 200, + sourceWidth: element.intrinsicWidth ?? STICKER_INTRINSIC_SIZE_FALLBACK, + sourceHeight: element.intrinsicHeight ?? STICKER_INTRINSIC_SIZE_FALLBACK, transform, }); } - if (element.type === "text") { + if (element.type === "graphic") { const transform = resolveTransformAtTime({ baseTransform: element.transform, animations: element.animations, localTime, }); - const scaledFontSize = - element.fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE); - const letterSpacing = element.letterSpacing ?? 0; - const lineHeight = element.lineHeight ?? DEFAULT_LINE_HEIGHT; - const lineHeightPx = scaledFontSize * lineHeight; + return getVisualElementBounds({ + canvasWidth, + canvasHeight, + sourceWidth: DEFAULT_GRAPHIC_SOURCE_SIZE, + sourceHeight: DEFAULT_GRAPHIC_SOURCE_SIZE, + transform, + }); + } - let measuredWidth = 100; - let measuredHeight = scaledFontSize; + if (element.type === "text") { + const transform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); const canvas = document.createElement("canvas"); - canvas.width = 4096; - canvas.height = 4096; const ctx = canvas.getContext("2d"); + if (!ctx) return null; - if (ctx) { - const fontWeight = element.fontWeight === "bold" ? "bold" : "normal"; - const fontStyle = element.fontStyle === "italic" ? "italic" : "normal"; - const fontFamily = `"${element.fontFamily.replace(/"/g, '\\"')}"`; - ctx.font = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`; - ctx.textAlign = element.textAlign as CanvasTextAlign; - if ("letterSpacing" in ctx) { - ( - ctx as CanvasRenderingContext2D & { letterSpacing: string } - ).letterSpacing = `${letterSpacing}px`; - } - - const lines = element.content.split("\n"); - const lineMetrics = lines.map((line) => ctx.measureText(line)); - const block = measureTextBlock({ - lineMetrics, - lineHeightPx, - fallbackFontSize: scaledFontSize, - }); - const fontSizeRatio = element.fontSize / DEFAULT_TEXT_ELEMENT.fontSize; - const resolvedBackground = { - ...element.background, - paddingX: resolveNumberAtTime({ - baseValue: - element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX, - animations: element.animations, - propertyPath: "background.paddingX", - localTime, - }), - paddingY: resolveNumberAtTime({ - baseValue: - element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY, - animations: element.animations, - propertyPath: "background.paddingY", - localTime, - }), - offsetX: resolveNumberAtTime({ - baseValue: - element.background.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX, - animations: element.animations, - propertyPath: "background.offsetX", - localTime, - }), - offsetY: resolveNumberAtTime({ - baseValue: - element.background.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY, - animations: element.animations, - propertyPath: "background.offsetY", - localTime, - }), - }; - const visualRect = getTextVisualRect({ - textAlign: element.textAlign, - block, - background: resolvedBackground, - fontSizeRatio, - }); - measuredWidth = visualRect.width; - measuredHeight = visualRect.height; - const localCenterX = visualRect.left + visualRect.width / 2; - const localCenterY = visualRect.top + visualRect.height / 2; - const scaledCenterX = localCenterX * transform.scale; - const scaledCenterY = localCenterY * transform.scale; - const rotationRad = (transform.rotate * Math.PI) / 180; - const cos = Math.cos(rotationRad); - const sin = Math.sin(rotationRad); - const rotatedCenterX = scaledCenterX * cos - scaledCenterY * sin; - const rotatedCenterY = scaledCenterX * sin + scaledCenterY * cos; - return { - cx: canvasWidth / 2 + transform.position.x + rotatedCenterX, - cy: canvasHeight / 2 + transform.position.y + rotatedCenterY, - width: measuredWidth * transform.scale, - height: measuredHeight * transform.scale, - rotation: transform.rotate, - }; - } + const measured = measureTextElement({ + element, + canvasHeight, + localTime, + ctx, + }); - const width = measuredWidth * transform.scale; - const height = measuredHeight * transform.scale; - return { - cx: canvasWidth / 2 + transform.position.x, - cy: canvasHeight / 2 + transform.position.y, - width, - height, - rotation: transform.rotate, - }; + return getTransformedRectBounds({ + canvasWidth, + canvasHeight, + rect: measured.visualRect, + transform, + }); } return null; } +export const ROTATION_HANDLE_OFFSET = 24; + +export type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; +export type Edge = "right" | "left" | "bottom"; + +export function getCornerPosition({ + bounds, + corner, +}: { + bounds: ElementBounds; + corner: Corner; +}): { x: number; y: number } { + const halfW = bounds.width / 2; + const halfH = bounds.height / 2; + const angleRad = (bounds.rotation * Math.PI) / 180; + const cos = Math.cos(angleRad); + const sin = Math.sin(angleRad); + const localX = + corner === "top-left" || corner === "bottom-left" ? -halfW : halfW; + const localY = + corner === "top-left" || corner === "top-right" ? -halfH : halfH; + return { + x: bounds.cx + (localX * cos - localY * sin), + y: bounds.cy + (localX * sin + localY * cos), + }; +} + +export function getEdgeHandlePosition({ + bounds, + edge, +}: { + bounds: ElementBounds; + edge: Edge; +}): { x: number; y: number } { + const halfWidth = bounds.width / 2; + const halfHeight = bounds.height / 2; + const angleRad = (bounds.rotation * Math.PI) / 180; + const cos = Math.cos(angleRad); + const sin = Math.sin(angleRad); + const localX = edge === "right" ? halfWidth : edge === "left" ? -halfWidth : 0; + const localY = edge === "bottom" ? halfHeight : 0; + return { + x: bounds.cx + (localX * cos - localY * sin), + y: bounds.cy + (localX * sin + localY * cos), + }; +} + +export function getRotationHandlePosition({ + bounds, +}: { + bounds: ElementBounds; +}): { x: number; y: number } { + const angleRad = (bounds.rotation * Math.PI) / 180; + const cos = Math.cos(angleRad); + const sin = Math.sin(angleRad); + const localY = -bounds.height / 2 - ROTATION_HANDLE_OFFSET; + return { + x: bounds.cx - localY * sin, + y: bounds.cy + localY * cos, + }; +} + export function getVisibleElementsWithBounds({ tracks, currentTime, diff --git a/apps/web/src/lib/preview/hit-test.ts b/apps/web/src/lib/preview/hit-test.ts index c2fde87b..2552922b 100644 --- a/apps/web/src/lib/preview/hit-test.ts +++ b/apps/web/src/lib/preview/hit-test.ts @@ -1,4 +1,5 @@ import type { ElementWithBounds } from "./element-bounds"; +import type { ElementRef } from "@/lib/timeline/types"; function pointInRotatedRect({ px, @@ -24,14 +25,14 @@ function pointInRotatedRect({ const dy = py - cy; const localX = dx * cos - dy * sin; const localY = dx * sin + dy * cos; - const halfW = width / 2; - const halfH = height / 2; + const halfW = Math.abs(width) / 2; + const halfH = Math.abs(height) / 2; return ( localX >= -halfW && localX <= halfW && localY >= -halfH && localY <= halfH ); } -export function hitTest({ +export function getHitElements({ canvasX, canvasY, elementsWithBounds, @@ -39,7 +40,9 @@ export function hitTest({ canvasX: number; canvasY: number; elementsWithBounds: ElementWithBounds[]; -}): ElementWithBounds | null { +}): ElementWithBounds[] { + const hits: ElementWithBounds[] = []; + for (let i = elementsWithBounds.length - 1; i >= 0; i--) { const { bounds } = elementsWithBounds[i]; if ( @@ -53,8 +56,47 @@ export function hitTest({ rotation: bounds.rotation, }) ) { - return elementsWithBounds[i]; + hits.push(elementsWithBounds[i]); } } - return null; + + return hits; +} + +export function hitTest({ + canvasX, + canvasY, + elementsWithBounds, +}: { + canvasX: number; + canvasY: number; + elementsWithBounds: ElementWithBounds[]; +}): ElementWithBounds | null { + return ( + getHitElements({ + canvasX, + canvasY, + elementsWithBounds, + })[0] ?? null + ); +} + +export function resolvePreferredHit({ + hits, + preferredElements, +}: { + hits: ElementWithBounds[]; + preferredElements: ElementRef[]; +}): ElementWithBounds | null { + if (preferredElements.length === 0) return null; + + return ( + hits.find((hit) => + preferredElements.some( + (preferredElement) => + preferredElement.trackId === hit.trackId && + preferredElement.elementId === hit.elementId, + ), + ) ?? null + ); } diff --git a/apps/web/src/lib/preview/preview-coords.ts b/apps/web/src/lib/preview/preview-coords.ts index dcc87282..64184990 100644 --- a/apps/web/src/lib/preview/preview-coords.ts +++ b/apps/web/src/lib/preview/preview-coords.ts @@ -1,90 +1,101 @@ +export interface PreviewViewportGeometry { + canvasHeight: number; + canvasWidth: number; + centerX: number; + centerY: number; + scale: number; + viewportHeight: number; + viewportWidth: number; +} + +function getCanvasOrigin({ + geometry, +}: { + geometry: PreviewViewportGeometry; +}): { x: number; y: number } { + return { + x: geometry.viewportWidth / 2 - geometry.centerX * geometry.scale, + y: geometry.viewportHeight / 2 - geometry.centerY * geometry.scale, + }; +} + export function screenToCanvas({ clientX, clientY, - canvas, + geometry, + viewportRect, }: { clientX: number; clientY: number; - canvas: HTMLCanvasElement; + geometry: PreviewViewportGeometry; + viewportRect: DOMRect; }): { x: number; y: number } { - const rect = canvas.getBoundingClientRect(); - const scaleX = canvas.width / rect.width; - const scaleY = canvas.height / rect.height; + const overlayX = clientX - viewportRect.left; + const overlayY = clientY - viewportRect.top; + return { - x: (clientX - rect.left) * scaleX, - y: (clientY - rect.top) * scaleY, + x: + geometry.centerX + + (overlayX - geometry.viewportWidth / 2) / geometry.scale, + y: + geometry.centerY + + (overlayY - geometry.viewportHeight / 2) / geometry.scale, }; } export function canvasToOverlay({ canvasX, canvasY, - canvasRect, - containerRect, - canvasSize, + geometry, }: { canvasX: number; canvasY: number; - canvasRect: DOMRect; - containerRect: DOMRect; - canvasSize: { width: number; height: number }; + geometry: PreviewViewportGeometry; }): { x: number; y: number } { - const scaleX = canvasRect.width / canvasSize.width; - const scaleY = canvasRect.height / canvasSize.height; + const canvasOrigin = getCanvasOrigin({ geometry }); + return { - x: canvasRect.left - containerRect.left + canvasX * scaleX, - y: canvasRect.top - containerRect.top + canvasY * scaleY, + x: canvasOrigin.x + canvasX * geometry.scale, + y: canvasOrigin.y + canvasY * geometry.scale, }; } export function positionToOverlay({ positionX, positionY, - canvasRect, - containerRect, - canvasSize, + geometry, }: { positionX: number; positionY: number; - canvasRect: DOMRect; - containerRect: DOMRect; - canvasSize: { width: number; height: number }; + geometry: PreviewViewportGeometry; }): { x: number; y: number } { - const scaleX = canvasRect.width / canvasSize.width; - const scaleY = canvasRect.height / canvasSize.height; - const centerScreenX = - canvasRect.left - containerRect.left + (canvasSize.width / 2) * scaleX; - const centerScreenY = - canvasRect.top - containerRect.top + (canvasSize.height / 2) * scaleY; - return { - x: centerScreenX + positionX * scaleX, - y: centerScreenY + positionY * scaleY, - }; + return canvasToOverlay({ + canvasX: geometry.canvasWidth / 2 + positionX, + canvasY: geometry.canvasHeight / 2 + positionY, + geometry, + }); } export function getDisplayScale({ - canvasRect, - canvasSize, + geometry, }: { - canvasRect: DOMRect; - canvasSize: { width: number; height: number }; + geometry: PreviewViewportGeometry; }): { x: number; y: number } { return { - x: canvasRect.width / canvasSize.width, - y: canvasRect.height / canvasSize.height, + x: geometry.scale, + y: geometry.scale, }; } export function screenPixelsToLogicalThreshold({ - canvas, + geometry, screenPixels, }: { - canvas: HTMLCanvasElement; + geometry: PreviewViewportGeometry; screenPixels: number; }): { x: number; y: number } { - const canvasRect = canvas.getBoundingClientRect(); return { - x: screenPixels * (canvas.width / canvasRect.width), - y: screenPixels * (canvas.height / canvasRect.height), + x: screenPixels / geometry.scale, + y: screenPixels / geometry.scale, }; } diff --git a/apps/web/src/lib/preview/preview-snap.ts b/apps/web/src/lib/preview/preview-snap.ts index 36efc3ce..64220264 100644 --- a/apps/web/src/lib/preview/preview-snap.ts +++ b/apps/web/src/lib/preview/preview-snap.ts @@ -13,15 +13,70 @@ export interface SnapResult { activeLines: SnapLine[]; } +type ScaleEdge = "left" | "right" | "top" | "bottom"; + +export interface ScaleEdgePreference { + left?: boolean; + right?: boolean; + top?: boolean; + bottom?: boolean; +} + +function hasPreferredEdge({ + preferredEdges, + edge, +}: { + preferredEdges?: ScaleEdgePreference; + edge: ScaleEdge; +}): boolean { + return preferredEdges?.[edge] === true; +} + +function pickClosestScaleCandidate<T extends { distance: number; edge: ScaleEdge }>({ + candidates, + preferredEdges, +}: { + candidates: T[]; + preferredEdges?: ScaleEdgePreference; +}): T | null { + if (candidates.length === 0) { + return null; + } + + return candidates.reduce((bestCandidate, candidate) => { + if (candidate.distance < bestCandidate.distance) { + return candidate; + } + if (candidate.distance > bestCandidate.distance) { + return bestCandidate; + } + + const shouldPreferCandidate = hasPreferredEdge({ + preferredEdges, + edge: candidate.edge, + }); + const shouldPreferBestCandidate = hasPreferredEdge({ + preferredEdges, + edge: bestCandidate.edge, + }); + + return shouldPreferCandidate && !shouldPreferBestCandidate + ? candidate + : bestCandidate; + }); +} + export function snapPosition({ proposedPosition, canvasSize, elementSize, + rotation = 0, snapThreshold, }: { proposedPosition: { x: number; y: number }; canvasSize: { width: number; height: number }; elementSize: { width: number; height: number }; + rotation?: number; snapThreshold: { x: number; y: number }; }): SnapResult { const centerX = 0; @@ -31,8 +86,11 @@ export function snapPosition({ const top = -canvasSize.height / 2; const bottom = canvasSize.height / 2; - const halfWidth = elementSize.width / 2; - const halfHeight = elementSize.height / 2; + const rotRad = (rotation * Math.PI) / 180; + const cosR = Math.abs(Math.cos(rotRad)); + const sinR = Math.abs(Math.sin(rotRad)); + const halfWidth = (elementSize.width * cosR + elementSize.height * sinR) / 2; + const halfHeight = (elementSize.width * sinR + elementSize.height * cosR) / 2; const activeLines: SnapLine[] = []; type AxisSnapCandidate = { @@ -59,8 +117,8 @@ export function snapPosition({ ); } - const verticalTargets = [left, centerX, right]; - const horizontalTargets = [top, centerY, bottom]; + const verticalTargets = [centerX, left, right]; + const horizontalTargets = [centerY, top, bottom]; const xCandidates: AxisSnapCandidate[] = []; for (const targetX of verticalTargets) { @@ -133,15 +191,19 @@ export function snapScale({ position, baseWidth, baseHeight, + rotation = 0, canvasSize, snapThreshold, + preferredEdges, }: { proposedScale: number; position: { x: number; y: number }; baseWidth: number; baseHeight: number; + rotation?: number; canvasSize: { width: number; height: number }; snapThreshold: { x: number; y: number }; + preferredEdges?: ScaleEdgePreference; }): ScaleSnapResult { const centerX = 0; const centerY = 0; @@ -150,15 +212,22 @@ export function snapScale({ const top = -canvasSize.height / 2; const bottom = canvasSize.height / 2; - const leftEdge = position.x - (baseWidth * proposedScale) / 2; - const rightEdge = position.x + (baseWidth * proposedScale) / 2; - const topEdge = position.y - (baseHeight * proposedScale) / 2; - const bottomEdge = position.y + (baseHeight * proposedScale) / 2; + const rotRad = (rotation * Math.PI) / 180; + const cosR = Math.abs(Math.cos(rotRad)); + const sinR = Math.abs(Math.sin(rotRad)); + const aabbBaseHalfW = (baseWidth * cosR + baseHeight * sinR) / 2; + const aabbBaseHalfH = (baseWidth * sinR + baseHeight * cosR) / 2; + + const leftEdge = position.x - aabbBaseHalfW * proposedScale; + const rightEdge = position.x + aabbBaseHalfW * proposedScale; + const topEdge = position.y - aabbBaseHalfH * proposedScale; + const bottomEdge = position.y + aabbBaseHalfH * proposedScale; interface SnapCandidate { scale: number; distance: number; lines: SnapLine[]; + edge: ScaleEdge; } const candidates: SnapCandidate[] = []; @@ -175,23 +244,25 @@ export function snapScale({ for (const target of verticalTargets) { const distanceLeft = Math.abs(leftEdge - target.position); if (distanceLeft <= snapThreshold.x) { - const scale = (2 * (position.x - target.position)) / baseWidth; - if (scale > MIN_SCALE) { + const scale = (position.x - target.position) / aabbBaseHalfW; + if (Math.abs(scale) > MIN_SCALE) { candidates.push({ scale, distance: distanceLeft, lines: [target.line], + edge: "left", }); } } const distanceRight = Math.abs(rightEdge - target.position); if (distanceRight <= snapThreshold.x) { - const scale = (2 * (target.position - position.x)) / baseWidth; - if (scale > MIN_SCALE) { + const scale = (target.position - position.x) / aabbBaseHalfW; + if (Math.abs(scale) > MIN_SCALE) { candidates.push({ scale, distance: distanceRight, lines: [target.line], + edge: "right", }); } } @@ -212,40 +283,42 @@ export function snapScale({ for (const target of horizontalTargets) { const distanceTop = Math.abs(topEdge - target.position); if (distanceTop <= snapThreshold.y) { - const scale = (2 * (position.y - target.position)) / baseHeight; - if (scale > MIN_SCALE) { + const scale = (position.y - target.position) / aabbBaseHalfH; + if (Math.abs(scale) > MIN_SCALE) { candidates.push({ scale, distance: distanceTop, lines: [target.line], + edge: "top", }); } } const distanceBottom = Math.abs(bottomEdge - target.position); if (distanceBottom <= snapThreshold.y) { - const scale = (2 * (target.position - position.y)) / baseHeight; - if (scale > MIN_SCALE) { + const scale = (target.position - position.y) / aabbBaseHalfH; + if (Math.abs(scale) > MIN_SCALE) { candidates.push({ scale, distance: distanceBottom, lines: [target.line], + edge: "bottom", }); } } } - if (candidates.length === 0) { + const best = pickClosestScaleCandidate({ + candidates, + preferredEdges, + }); + if (!best) { return { snappedScale: proposedScale, activeLines: [] }; } - const best = candidates.reduce((acc, candidate) => - candidate.distance < acc.distance ? candidate : acc, - ); - - const snappedLeft = position.x - (baseWidth * best.scale) / 2; - const snappedRight = position.x + (baseWidth * best.scale) / 2; - const snappedTop = position.y - (baseHeight * best.scale) / 2; - const snappedBottom = position.y + (baseHeight * best.scale) / 2; + const snappedLeft = position.x - aabbBaseHalfW * best.scale; + const snappedRight = position.x + aabbBaseHalfW * best.scale; + const snappedTop = position.y - aabbBaseHalfH * best.scale; + const snappedBottom = position.y + aabbBaseHalfH * best.scale; const activeLines: SnapLine[] = []; const seenKeys = new Set<string>(); @@ -260,16 +333,26 @@ export function snapScale({ for (const target of verticalTargets) { if ( - Math.abs(snappedLeft - target.position) <= 1 || - Math.abs(snappedRight - target.position) <= 1 + (hasPreferredEdge({ preferredEdges, edge: "left" }) && + Math.abs(snappedLeft - target.position) <= 1) || + (hasPreferredEdge({ preferredEdges, edge: "right" }) && + Math.abs(snappedRight - target.position) <= 1) || + (!preferredEdges && + (Math.abs(snappedLeft - target.position) <= 1 || + Math.abs(snappedRight - target.position) <= 1)) ) { addLine({ line: target.line }); } } for (const target of horizontalTargets) { if ( - Math.abs(snappedTop - target.position) <= 1 || - Math.abs(snappedBottom - target.position) <= 1 + (hasPreferredEdge({ preferredEdges, edge: "top" }) && + Math.abs(snappedTop - target.position) <= 1) || + (hasPreferredEdge({ preferredEdges, edge: "bottom" }) && + Math.abs(snappedBottom - target.position) <= 1) || + (!preferredEdges && + (Math.abs(snappedTop - target.position) <= 1 || + Math.abs(snappedBottom - target.position) <= 1)) ) { addLine({ line: target.line }); } @@ -281,6 +364,158 @@ export function snapScale({ }; } +export interface AxisSnapResult { + snappedScale: number; + /** Infinity when no snap candidate was within threshold */ + snapDistance: number; + activeLines: SnapLine[]; +} + +export function snapScaleAxes({ + proposedScaleX, + proposedScaleY, + position, + baseWidth, + baseHeight, + rotation = 0, + canvasSize, + snapThreshold, + preferredEdges, +}: { + proposedScaleX: number; + proposedScaleY: number; + position: { x: number; y: number }; + baseWidth: number; + baseHeight: number; + rotation?: number; + canvasSize: { width: number; height: number }; + snapThreshold: { x: number; y: number }; + preferredEdges?: ScaleEdgePreference; +}): { x: AxisSnapResult; y: AxisSnapResult } { + const canvasLeft = -canvasSize.width / 2; + const canvasRight = canvasSize.width / 2; + const canvasTop = -canvasSize.height / 2; + const canvasBottom = canvasSize.height / 2; + + const rotRad = (rotation * Math.PI) / 180; + const cosR = Math.abs(Math.cos(rotRad)); + const sinR = Math.abs(Math.sin(rotRad)); + const EPSILON = 1e-6; + + // Current AABB edges at proposed scales + const currentAabbHalfW = (baseWidth * proposedScaleX * cosR + baseHeight * proposedScaleY * sinR) / 2; + const currentAabbHalfH = (baseWidth * proposedScaleX * sinR + baseHeight * proposedScaleY * cosR) / 2; + const currentLeftEdge = position.x - currentAabbHalfW; + const currentRightEdge = position.x + currentAabbHalfW; + const currentTopEdge = position.y - currentAabbHalfH; + const currentBottomEdge = position.y + currentAabbHalfH; + + interface Candidate { + scale: number; + distance: number; + line: SnapLine; + edge: ScaleEdge; + } + + function bestCandidate({ + candidates, + proposedScale, + }: { + candidates: Candidate[]; + proposedScale: number; + }): AxisSnapResult { + const best = pickClosestScaleCandidate({ + candidates, + preferredEdges, + }); + if (!best) { + return { snappedScale: proposedScale, snapDistance: Infinity, activeLines: [] }; + } + return { snappedScale: best.scale, snapDistance: best.distance, activeLines: [best.line] }; + } + + // sX candidates: snap via vertical targets (left/right AABB edges) — only valid when cosR ≠ 0 + // snap via horizontal targets (top/bottom AABB edges) — only valid when sinR ≠ 0 + const xCandidates: Candidate[] = []; + const yContribW = baseHeight * proposedScaleY * sinR; + const yContribH = baseHeight * proposedScaleY * cosR; + + if (cosR > EPSILON) { + for (const T of [canvasLeft, 0, canvasRight]) { + const line: SnapLine = { type: "vertical", position: T }; + const distLeft = Math.abs(currentLeftEdge - T); + if (distLeft <= snapThreshold.x) { + const scale = (2 * (position.x - T) - yContribW) / (baseWidth * cosR); + if (Math.abs(scale) > MIN_SCALE) xCandidates.push({ scale, distance: distLeft, line, edge: "left" }); + } + const distRight = Math.abs(currentRightEdge - T); + if (distRight <= snapThreshold.x) { + const scale = (2 * (T - position.x) - yContribW) / (baseWidth * cosR); + if (Math.abs(scale) > MIN_SCALE) xCandidates.push({ scale, distance: distRight, line, edge: "right" }); + } + } + } + + if (sinR > EPSILON) { + for (const T of [canvasTop, 0, canvasBottom]) { + const line: SnapLine = { type: "horizontal", position: T }; + const distTop = Math.abs(currentTopEdge - T); + if (distTop <= snapThreshold.y) { + const scale = (2 * (position.y - T) - yContribH) / (baseWidth * sinR); + if (Math.abs(scale) > MIN_SCALE) xCandidates.push({ scale, distance: distTop, line, edge: "top" }); + } + const distBottom = Math.abs(currentBottomEdge - T); + if (distBottom <= snapThreshold.y) { + const scale = (2 * (T - position.y) - yContribH) / (baseWidth * sinR); + if (Math.abs(scale) > MIN_SCALE) xCandidates.push({ scale, distance: distBottom, line, edge: "bottom" }); + } + } + } + + // sY candidates: snap via vertical targets — only valid when sinR ≠ 0 + // snap via horizontal targets — only valid when cosR ≠ 0 + const yCandidates: Candidate[] = []; + const xContribW = baseWidth * proposedScaleX * cosR; + const xContribH = baseWidth * proposedScaleX * sinR; + + if (sinR > EPSILON) { + for (const T of [canvasLeft, 0, canvasRight]) { + const line: SnapLine = { type: "vertical", position: T }; + const distLeft = Math.abs(currentLeftEdge - T); + if (distLeft <= snapThreshold.x) { + const scale = (2 * (position.x - T) - xContribW) / (baseHeight * sinR); + if (Math.abs(scale) > MIN_SCALE) yCandidates.push({ scale, distance: distLeft, line, edge: "left" }); + } + const distRight = Math.abs(currentRightEdge - T); + if (distRight <= snapThreshold.x) { + const scale = (2 * (T - position.x) - xContribW) / (baseHeight * sinR); + if (Math.abs(scale) > MIN_SCALE) yCandidates.push({ scale, distance: distRight, line, edge: "right" }); + } + } + } + + if (cosR > EPSILON) { + for (const T of [canvasTop, 0, canvasBottom]) { + const line: SnapLine = { type: "horizontal", position: T }; + const distTop = Math.abs(currentTopEdge - T); + if (distTop <= snapThreshold.y) { + const scale = (2 * (position.y - T) - xContribH) / (baseHeight * cosR); + if (Math.abs(scale) > MIN_SCALE) yCandidates.push({ scale, distance: distTop, line, edge: "top" }); + } + const distBottom = Math.abs(currentBottomEdge - T); + if (distBottom <= snapThreshold.y) { + const scale = (2 * (T - position.y) - xContribH) / (baseHeight * cosR); + if (Math.abs(scale) > MIN_SCALE) yCandidates.push({ scale, distance: distBottom, line, edge: "bottom" }); + } + } + } + + return { + x: bestCandidate({ candidates: xCandidates, proposedScale: proposedScaleX }), + y: bestCandidate({ candidates: yCandidates, proposedScale: proposedScaleY }), + }; +} + export interface RotationSnapResult { snappedRotation: number; isSnapped: boolean; |
