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 }; }