From 3e89d2998588f8e35c524e523ba2ee853f0f6475 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 20 Apr 2026 11:31:17 +0200 Subject: refactor: restructure files to their domains, new preview overlay system, and dep graph --- apps/web/src/preview/preview-snap.ts | 539 +++++++++++++++++++++++++++++++++++ 1 file changed, 539 insertions(+) create mode 100644 apps/web/src/preview/preview-snap.ts (limited to 'apps/web/src/preview/preview-snap.ts') diff --git a/apps/web/src/preview/preview-snap.ts b/apps/web/src/preview/preview-snap.ts new file mode 100644 index 00000000..86d35390 --- /dev/null +++ b/apps/web/src/preview/preview-snap.ts @@ -0,0 +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 }; +} -- cgit v1.3.1