summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/preview/preview-snap.ts
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
commit3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch)
tree57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/lib/preview/preview-snap.ts
parent729d10592f4e802f91c555c309ff3ba4b119f843 (diff)
downloadopencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/lib/preview/preview-snap.ts')
-rw-r--r--apps/web/src/lib/preview/preview-snap.ts539
1 files changed, 0 insertions, 539 deletions
diff --git a/apps/web/src/lib/preview/preview-snap.ts b/apps/web/src/lib/preview/preview-snap.ts
deleted file mode 100644
index 86d35390..00000000
--- a/apps/web/src/lib/preview/preview-snap.ts
+++ /dev/null
@@ -1,539 +0,0 @@
-export interface SnapLine {
- type: "horizontal" | "vertical";
- position: number;
-}
-
-const ROTATION_SNAP_STEP_DEGREES = 90;
-const ROTATION_SNAP_THRESHOLD_DEGREES = 5;
-export const MIN_SCALE = 0.01;
-export const SNAP_THRESHOLD_SCREEN_PIXELS = 8;
-
-export interface SnapResult {
- snappedPosition: { x: number; y: number };
- activeLines: SnapLine[];
-}
-
-type ScaleEdge = "left" | "right" | "top" | "bottom";
-
-export interface ScaleEdgePreference {
- left?: boolean;
- right?: boolean;
- top?: boolean;
- bottom?: boolean;
-}
-
-function hasPreferredEdge({
- preferredEdges,
- edge,
-}: {
- preferredEdges?: ScaleEdgePreference;
- edge: ScaleEdge;
-}): boolean {
- return preferredEdges?.[edge] === true;
-}
-
-function pickClosestScaleCandidate<T extends { distance: number; edge: ScaleEdge }>({
- candidates,
- preferredEdges,
-}: {
- candidates: T[];
- preferredEdges?: ScaleEdgePreference;
-}): T | null {
- if (candidates.length === 0) {
- return null;
- }
-
- return candidates.reduce((bestCandidate, candidate) => {
- if (candidate.distance < bestCandidate.distance) {
- return candidate;
- }
- if (candidate.distance > bestCandidate.distance) {
- return bestCandidate;
- }
-
- const shouldPreferCandidate = hasPreferredEdge({
- preferredEdges,
- edge: candidate.edge,
- });
- const shouldPreferBestCandidate = hasPreferredEdge({
- preferredEdges,
- edge: bestCandidate.edge,
- });
-
- return shouldPreferCandidate && !shouldPreferBestCandidate
- ? candidate
- : bestCandidate;
- });
-}
-
-export function snapPosition({
- proposedPosition,
- canvasSize,
- elementSize,
- rotation = 0,
- snapThreshold,
-}: {
- proposedPosition: { x: number; y: number };
- canvasSize: { width: number; height: number };
- elementSize: { width: number; height: number };
- rotation?: number;
- snapThreshold: { x: number; y: number };
-}): SnapResult {
- const centerX = 0;
- const centerY = 0;
- const left = -canvasSize.width / 2;
- const right = canvasSize.width / 2;
- const top = -canvasSize.height / 2;
- const bottom = canvasSize.height / 2;
-
- const rotRad = (rotation * Math.PI) / 180;
- const cosR = Math.abs(Math.cos(rotRad));
- const sinR = Math.abs(Math.sin(rotRad));
- const halfWidth = (elementSize.width * cosR + elementSize.height * sinR) / 2;
- const halfHeight = (elementSize.width * sinR + elementSize.height * cosR) / 2;
- const activeLines: SnapLine[] = [];
-
- type AxisSnapCandidate = {
- snappedPosition: number;
- line: SnapLine;
- distance: number;
- };
-
- function getClosestAxisSnap({
- candidates,
- threshold,
- }: {
- candidates: AxisSnapCandidate[];
- threshold: number;
- }): AxisSnapCandidate | null {
- const snapCandidatesWithinThreshold = candidates.filter(
- (candidate) => candidate.distance <= threshold,
- );
- if (snapCandidatesWithinThreshold.length === 0) {
- return null;
- }
- return snapCandidatesWithinThreshold.reduce((closest, current) =>
- current.distance < closest.distance ? current : closest,
- );
- }
-
- const verticalTargets = [centerX, left, right];
- const horizontalTargets = [centerY, top, bottom];
-
- const xCandidates: AxisSnapCandidate[] = [];
- for (const targetX of verticalTargets) {
- xCandidates.push({
- snappedPosition: targetX,
- line: { type: "vertical", position: targetX },
- distance: Math.abs(proposedPosition.x - targetX),
- });
- xCandidates.push({
- snappedPosition: targetX + halfWidth,
- line: { type: "vertical", position: targetX },
- distance: Math.abs(proposedPosition.x - halfWidth - targetX),
- });
- xCandidates.push({
- snappedPosition: targetX - halfWidth,
- line: { type: "vertical", position: targetX },
- distance: Math.abs(proposedPosition.x + halfWidth - targetX),
- });
- }
- const yCandidates: AxisSnapCandidate[] = [];
- for (const targetY of horizontalTargets) {
- yCandidates.push({
- snappedPosition: targetY,
- line: { type: "horizontal", position: targetY },
- distance: Math.abs(proposedPosition.y - targetY),
- });
- yCandidates.push({
- snappedPosition: targetY + halfHeight,
- line: { type: "horizontal", position: targetY },
- distance: Math.abs(proposedPosition.y - halfHeight - targetY),
- });
- yCandidates.push({
- snappedPosition: targetY - halfHeight,
- line: { type: "horizontal", position: targetY },
- distance: Math.abs(proposedPosition.y + halfHeight - targetY),
- });
- }
-
- const closestX = getClosestAxisSnap({
- candidates: xCandidates,
- threshold: snapThreshold.x,
- });
- const closestY = getClosestAxisSnap({
- candidates: yCandidates,
- threshold: snapThreshold.y,
- });
-
- const x = closestX?.snappedPosition ?? proposedPosition.x;
- const y = closestY?.snappedPosition ?? proposedPosition.y;
- if (closestX) {
- activeLines.push(closestX.line);
- }
- if (closestY) {
- activeLines.push(closestY.line);
- }
-
- return {
- snappedPosition: { x, y },
- activeLines,
- };
-}
-
-export interface ScaleSnapResult {
- snappedScale: number;
- activeLines: SnapLine[];
-}
-
-export function snapScale({
- proposedScale,
- position,
- baseWidth,
- baseHeight,
- rotation = 0,
- canvasSize,
- snapThreshold,
- preferredEdges,
-}: {
- proposedScale: number;
- position: { x: number; y: number };
- baseWidth: number;
- baseHeight: number;
- rotation?: number;
- canvasSize: { width: number; height: number };
- snapThreshold: { x: number; y: number };
- preferredEdges?: ScaleEdgePreference;
-}): ScaleSnapResult {
- const centerX = 0;
- const centerY = 0;
- const left = -canvasSize.width / 2;
- const right = canvasSize.width / 2;
- const top = -canvasSize.height / 2;
- const bottom = canvasSize.height / 2;
-
- const rotRad = (rotation * Math.PI) / 180;
- const cosR = Math.abs(Math.cos(rotRad));
- const sinR = Math.abs(Math.sin(rotRad));
- const aabbBaseHalfW = (baseWidth * cosR + baseHeight * sinR) / 2;
- const aabbBaseHalfH = (baseWidth * sinR + baseHeight * cosR) / 2;
-
- const leftEdge = position.x - aabbBaseHalfW * proposedScale;
- const rightEdge = position.x + aabbBaseHalfW * proposedScale;
- const topEdge = position.y - aabbBaseHalfH * proposedScale;
- const bottomEdge = position.y + aabbBaseHalfH * proposedScale;
-
- interface SnapCandidate {
- scale: number;
- distance: number;
- lines: SnapLine[];
- edge: ScaleEdge;
- }
-
- const candidates: SnapCandidate[] = [];
-
- const verticalTargets = [
- { position: left, line: { type: "vertical" as const, position: left } },
- {
- position: centerX,
- line: { type: "vertical" as const, position: centerX },
- },
- { position: right, line: { type: "vertical" as const, position: right } },
- ];
-
- for (const target of verticalTargets) {
- const distanceLeft = Math.abs(leftEdge - target.position);
- if (distanceLeft <= snapThreshold.x) {
- const scale = (position.x - target.position) / aabbBaseHalfW;
- if (Math.abs(scale) > MIN_SCALE) {
- candidates.push({
- scale,
- distance: distanceLeft,
- lines: [target.line],
- edge: "left",
- });
- }
- }
- const distanceRight = Math.abs(rightEdge - target.position);
- if (distanceRight <= snapThreshold.x) {
- const scale = (target.position - position.x) / aabbBaseHalfW;
- if (Math.abs(scale) > MIN_SCALE) {
- candidates.push({
- scale,
- distance: distanceRight,
- lines: [target.line],
- edge: "right",
- });
- }
- }
- }
-
- const horizontalTargets = [
- { position: top, line: { type: "horizontal" as const, position: top } },
- {
- position: centerY,
- line: { type: "horizontal" as const, position: centerY },
- },
- {
- position: bottom,
- line: { type: "horizontal" as const, position: bottom },
- },
- ];
-
- for (const target of horizontalTargets) {
- const distanceTop = Math.abs(topEdge - target.position);
- if (distanceTop <= snapThreshold.y) {
- const scale = (position.y - target.position) / aabbBaseHalfH;
- if (Math.abs(scale) > MIN_SCALE) {
- candidates.push({
- scale,
- distance: distanceTop,
- lines: [target.line],
- edge: "top",
- });
- }
- }
- const distanceBottom = Math.abs(bottomEdge - target.position);
- if (distanceBottom <= snapThreshold.y) {
- const scale = (target.position - position.y) / aabbBaseHalfH;
- if (Math.abs(scale) > MIN_SCALE) {
- candidates.push({
- scale,
- distance: distanceBottom,
- lines: [target.line],
- edge: "bottom",
- });
- }
- }
- }
-
- const best = pickClosestScaleCandidate({
- candidates,
- preferredEdges,
- });
- if (!best) {
- return { snappedScale: proposedScale, activeLines: [] };
- }
-
- const snappedLeft = position.x - aabbBaseHalfW * best.scale;
- const snappedRight = position.x + aabbBaseHalfW * best.scale;
- const snappedTop = position.y - aabbBaseHalfH * best.scale;
- const snappedBottom = position.y + aabbBaseHalfH * best.scale;
-
- const activeLines: SnapLine[] = [];
- const seenKeys = new Set<string>();
-
- function addLine({ line }: { line: SnapLine }) {
- const key = `${line.type}-${line.position}`;
- if (!seenKeys.has(key)) {
- seenKeys.add(key);
- activeLines.push(line);
- }
- }
-
- for (const target of verticalTargets) {
- if (
- (hasPreferredEdge({ preferredEdges, edge: "left" }) &&
- Math.abs(snappedLeft - target.position) <= 1) ||
- (hasPreferredEdge({ preferredEdges, edge: "right" }) &&
- Math.abs(snappedRight - target.position) <= 1) ||
- (!preferredEdges &&
- (Math.abs(snappedLeft - target.position) <= 1 ||
- Math.abs(snappedRight - target.position) <= 1))
- ) {
- addLine({ line: target.line });
- }
- }
- for (const target of horizontalTargets) {
- if (
- (hasPreferredEdge({ preferredEdges, edge: "top" }) &&
- Math.abs(snappedTop - target.position) <= 1) ||
- (hasPreferredEdge({ preferredEdges, edge: "bottom" }) &&
- Math.abs(snappedBottom - target.position) <= 1) ||
- (!preferredEdges &&
- (Math.abs(snappedTop - target.position) <= 1 ||
- Math.abs(snappedBottom - target.position) <= 1))
- ) {
- addLine({ line: target.line });
- }
- }
-
- return {
- snappedScale: best.scale,
- activeLines,
- };
-}
-
-export interface AxisSnapResult {
- snappedScale: number;
- /** Infinity when no snap candidate was within threshold */
- snapDistance: number;
- activeLines: SnapLine[];
-}
-
-export function snapScaleAxes({
- proposedScaleX,
- proposedScaleY,
- position,
- baseWidth,
- baseHeight,
- rotation = 0,
- canvasSize,
- snapThreshold,
- preferredEdges,
-}: {
- proposedScaleX: number;
- proposedScaleY: number;
- position: { x: number; y: number };
- baseWidth: number;
- baseHeight: number;
- rotation?: number;
- canvasSize: { width: number; height: number };
- snapThreshold: { x: number; y: number };
- preferredEdges?: ScaleEdgePreference;
-}): { x: AxisSnapResult; y: AxisSnapResult } {
- const canvasLeft = -canvasSize.width / 2;
- const canvasRight = canvasSize.width / 2;
- const canvasTop = -canvasSize.height / 2;
- const canvasBottom = canvasSize.height / 2;
-
- const rotRad = (rotation * Math.PI) / 180;
- const cosR = Math.abs(Math.cos(rotRad));
- const sinR = Math.abs(Math.sin(rotRad));
- const EPSILON = 1e-6;
-
- // Current AABB edges at proposed scales
- const currentAabbHalfW = (baseWidth * proposedScaleX * cosR + baseHeight * proposedScaleY * sinR) / 2;
- const currentAabbHalfH = (baseWidth * proposedScaleX * sinR + baseHeight * proposedScaleY * cosR) / 2;
- const currentLeftEdge = position.x - currentAabbHalfW;
- const currentRightEdge = position.x + currentAabbHalfW;
- const currentTopEdge = position.y - currentAabbHalfH;
- const currentBottomEdge = position.y + currentAabbHalfH;
-
- interface Candidate {
- scale: number;
- distance: number;
- line: SnapLine;
- edge: ScaleEdge;
- }
-
- function bestCandidate({
- candidates,
- proposedScale,
- }: {
- candidates: Candidate[];
- proposedScale: number;
- }): AxisSnapResult {
- const best = pickClosestScaleCandidate({
- candidates,
- preferredEdges,
- });
- if (!best) {
- return { snappedScale: proposedScale, snapDistance: Infinity, activeLines: [] };
- }
- return { snappedScale: best.scale, snapDistance: best.distance, activeLines: [best.line] };
- }
-
- // sX candidates: snap via vertical targets (left/right AABB edges) — only valid when cosR ≠ 0
- // snap via horizontal targets (top/bottom AABB edges) — only valid when sinR ≠ 0
- const xCandidates: Candidate[] = [];
- const yContribW = baseHeight * proposedScaleY * sinR;
- const yContribH = baseHeight * proposedScaleY * cosR;
-
- if (cosR > EPSILON) {
- for (const T of [canvasLeft, 0, canvasRight]) {
- const line: SnapLine = { type: "vertical", position: T };
- const distLeft = Math.abs(currentLeftEdge - T);
- if (distLeft <= snapThreshold.x) {
- const scale = (2 * (position.x - T) - yContribW) / (baseWidth * cosR);
- if (Math.abs(scale) > MIN_SCALE) xCandidates.push({ scale, distance: distLeft, line, edge: "left" });
- }
- const distRight = Math.abs(currentRightEdge - T);
- if (distRight <= snapThreshold.x) {
- const scale = (2 * (T - position.x) - yContribW) / (baseWidth * cosR);
- if (Math.abs(scale) > MIN_SCALE) xCandidates.push({ scale, distance: distRight, line, edge: "right" });
- }
- }
- }
-
- if (sinR > EPSILON) {
- for (const T of [canvasTop, 0, canvasBottom]) {
- const line: SnapLine = { type: "horizontal", position: T };
- const distTop = Math.abs(currentTopEdge - T);
- if (distTop <= snapThreshold.y) {
- const scale = (2 * (position.y - T) - yContribH) / (baseWidth * sinR);
- if (Math.abs(scale) > MIN_SCALE) xCandidates.push({ scale, distance: distTop, line, edge: "top" });
- }
- const distBottom = Math.abs(currentBottomEdge - T);
- if (distBottom <= snapThreshold.y) {
- const scale = (2 * (T - position.y) - yContribH) / (baseWidth * sinR);
- if (Math.abs(scale) > MIN_SCALE) xCandidates.push({ scale, distance: distBottom, line, edge: "bottom" });
- }
- }
- }
-
- // sY candidates: snap via vertical targets — only valid when sinR ≠ 0
- // snap via horizontal targets — only valid when cosR ≠ 0
- const yCandidates: Candidate[] = [];
- const xContribW = baseWidth * proposedScaleX * cosR;
- const xContribH = baseWidth * proposedScaleX * sinR;
-
- if (sinR > EPSILON) {
- for (const T of [canvasLeft, 0, canvasRight]) {
- const line: SnapLine = { type: "vertical", position: T };
- const distLeft = Math.abs(currentLeftEdge - T);
- if (distLeft <= snapThreshold.x) {
- const scale = (2 * (position.x - T) - xContribW) / (baseHeight * sinR);
- if (Math.abs(scale) > MIN_SCALE) yCandidates.push({ scale, distance: distLeft, line, edge: "left" });
- }
- const distRight = Math.abs(currentRightEdge - T);
- if (distRight <= snapThreshold.x) {
- const scale = (2 * (T - position.x) - xContribW) / (baseHeight * sinR);
- if (Math.abs(scale) > MIN_SCALE) yCandidates.push({ scale, distance: distRight, line, edge: "right" });
- }
- }
- }
-
- if (cosR > EPSILON) {
- for (const T of [canvasTop, 0, canvasBottom]) {
- const line: SnapLine = { type: "horizontal", position: T };
- const distTop = Math.abs(currentTopEdge - T);
- if (distTop <= snapThreshold.y) {
- const scale = (2 * (position.y - T) - xContribH) / (baseHeight * cosR);
- if (Math.abs(scale) > MIN_SCALE) yCandidates.push({ scale, distance: distTop, line, edge: "top" });
- }
- const distBottom = Math.abs(currentBottomEdge - T);
- if (distBottom <= snapThreshold.y) {
- const scale = (2 * (T - position.y) - xContribH) / (baseHeight * cosR);
- if (Math.abs(scale) > MIN_SCALE) yCandidates.push({ scale, distance: distBottom, line, edge: "bottom" });
- }
- }
- }
-
- return {
- x: bestCandidate({ candidates: xCandidates, proposedScale: proposedScaleX }),
- y: bestCandidate({ candidates: yCandidates, proposedScale: proposedScaleY }),
- };
-}
-
-export interface RotationSnapResult {
- snappedRotation: number;
- isSnapped: boolean;
-}
-
-export function snapRotation({
- proposedRotation,
-}: {
- proposedRotation: number;
-}): RotationSnapResult {
- const nearestRotationSnap =
- Math.round(proposedRotation / ROTATION_SNAP_STEP_DEGREES) *
- ROTATION_SNAP_STEP_DEGREES;
- const distanceToNearestSnap = Math.abs(
- proposedRotation - nearestRotationSnap,
- );
- if (distanceToNearestSnap <= ROTATION_SNAP_THRESHOLD_DEGREES) {
- return { snappedRotation: nearestRotationSnap, isSnapped: true };
- }
- return { snappedRotation: proposedRotation, isSnapped: false };
-}