-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathgeometry.ts
More file actions
217 lines (195 loc) · 7.15 KB
/
Copy pathgeometry.ts
File metadata and controls
217 lines (195 loc) · 7.15 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
import type { CropRect, PhotoConfig, SplitHalf } from '../types';
import { CANVAS_PRESETS } from './presets';
export interface DestRect {
dx: number;
dy: number;
dw: number;
dh: number;
}
export interface SourceRect {
sx: number;
sy: number;
sw: number;
sh: number;
}
export const FULL_CROP: CropRect = { x: 0, y: 0, w: 1, h: 1 };
const MIN_CROP_SIZE = 0.05;
export function normalizeCrop(crop: CropRect | undefined): CropRect {
const x = crop?.x ?? 0;
const y = crop?.y ?? 0;
const w = crop?.w ?? 1;
const h = crop?.h ?? 1;
const clampedW = Math.min(1, Math.max(MIN_CROP_SIZE, w));
const clampedH = Math.min(1, Math.max(MIN_CROP_SIZE, h));
const clampedX = Math.min(1 - clampedW, Math.max(0, x));
const clampedY = Math.min(1 - clampedH, Math.max(0, y));
return { x: clampedX, y: clampedY, w: clampedW, h: clampedH };
}
export function fitContain(srcW: number, srcH: number, canvasW: number, canvasH: number): DestRect {
const scale = Math.min(canvasW / srcW, canvasH / srcH);
const dw = srcW * scale;
const dh = srcH * scale;
return {
dx: (canvasW - dw) / 2,
dy: (canvasH - dh) / 2,
dw,
dh,
};
}
export function sliceForHalf(srcW: number, srcH: number, half: SplitHalf): SourceRect {
switch (half) {
case 'left':
return { sx: 0, sy: 0, sw: srcW / 2, sh: srcH };
case 'right':
return { sx: srcW / 2, sy: 0, sw: srcW / 2, sh: srcH };
default:
// Defensive fallback: never return an invalid source rect.
return { sx: 0, sy: 0, sw: srcW, sh: srcH };
}
}
/** Effective source rect before crop (accounts for split, otherwise full image). */
export function effectiveBaseSource(config: PhotoConfig): SourceRect {
if (config.splitOf) return sliceForHalf(config.naturalW, config.naturalH, config.splitOf.half);
return { sx: 0, sy: 0, sw: config.naturalW, sh: config.naturalH };
}
/** Effective source rect after split + crop. */
export function effectiveSource(config: PhotoConfig): SourceRect {
const base = effectiveBaseSource(config);
const crop = normalizeCrop(config.crop);
return {
sx: base.sx + base.sw * crop.x,
sy: base.sy + base.sh * crop.y,
sw: base.sw * crop.w,
sh: base.sh * crop.h,
};
}
/** Compute final destination rect on canvas given config + source slice. */
export function computeDestRect(config: PhotoConfig): DestRect {
const { w: cw, h: ch } = CANVAS_PRESETS[config.preset];
// Keep transform geometry stable against the pre-crop frame so crop box
// interactions map predictably to the on-canvas image.
const src = effectiveBaseSource(config);
const fit = fitContain(src.sw, src.sh, cw, ch);
const dw = fit.dw * config.scale;
const dh = fit.dh * config.scale;
const anchoredDx = config.splitOf
? config.splitOf.half === 'left'
? cw - dw
: 0
: (cw - dw) / 2;
return {
// Split halves should hug the seam side, never re-center on the canvas x-axis.
dx: anchoredDx + config.offsetX,
dy: (ch - dh) / 2 + config.offsetY,
dw,
dh,
};
}
/** Destination rect if only split is applied (used by crop overlay interactions). */
export function computeBaseDestRect(config: PhotoConfig): DestRect {
const { w: cw, h: ch } = CANVAS_PRESETS[config.preset];
const src = effectiveBaseSource(config);
const fit = fitContain(src.sw, src.sh, cw, ch);
const dw = fit.dw * config.scale;
const dh = fit.dh * config.scale;
const anchoredDx = config.splitOf
? config.splitOf.half === 'left'
? cw - dw
: 0
: (cw - dw) / 2;
return {
dx: anchoredDx + config.offsetX,
dy: (ch - dh) / 2 + config.offsetY,
dw,
dh,
};
}
export const SNAP_THRESHOLD = 60;
export function snapToCenter(value: number): number {
return Math.abs(value) < SNAP_THRESHOLD ? 0 : value;
}
export function isCentered(offsetX: number, offsetY: number): boolean {
return offsetX === 0 && offsetY === 0;
}
function centerOffsetTarget(photo: PhotoConfig): { offsetX: number; offsetY: number } {
const { w: cw, h: ch } = CANVAS_PRESETS[photo.preset];
const src = effectiveBaseSource(photo);
const fit = fitContain(src.sw, src.sh, cw, ch);
const dw = fit.dw * photo.scale;
const dh = fit.dh * photo.scale;
const anchoredDx = photo.splitOf
? photo.splitOf.half === 'left'
? cw - dw
: 0
: (cw - dw) / 2;
const crop = normalizeCrop(photo.crop);
const cropCenterX = crop.x + crop.w / 2;
const cropCenterY = crop.y + crop.h / 2;
return {
offsetX: cw / 2 - anchoredDx - dw * cropCenterX,
offsetY: ch / 2 - (ch - dh) / 2 - dh * cropCenterY,
};
}
export function snapTransformToVisibleCenter(
photo: PhotoConfig,
offsetX: number,
offsetY: number,
): { offsetX: number; offsetY: number; snappedX: boolean; snappedY: boolean } {
const target = centerOffsetTarget(photo);
const snappedX = Math.abs(offsetX - target.offsetX) < SNAP_THRESHOLD;
const snappedY = Math.abs(offsetY - target.offsetY) < SNAP_THRESHOLD;
return {
offsetX: snappedX ? target.offsetX : offsetX,
offsetY: snappedY ? target.offsetY : offsetY,
snappedX,
snappedY,
};
}
export function isVisiblyCentered(photo: PhotoConfig): boolean {
const target = centerOffsetTarget(photo);
return Math.abs(photo.offsetX - target.offsetX) < 0.5 && Math.abs(photo.offsetY - target.offsetY) < 0.5;
}
/**
* Clamp scale/offset bounds for editor interactions.
* Non-split images are kept fully within canvas bounds.
* Split images stay seam-anchored (no horizontal panning) and can scale larger.
*/
export function clampTransform(
photo: PhotoConfig,
offsetX: number,
offsetY: number,
scale: number,
): { offsetX: number; offsetY: number; scale: number } {
const { w: cw, h: ch } = CANVAS_PRESETS[photo.preset];
// Crop should not alter pan/zoom bounds; bounds are based on the base frame.
const src = effectiveBaseSource(photo);
const fit = fitContain(src.sw, src.sh, cw, ch);
const crop = normalizeCrop(photo.crop);
// Allow zooming cropped photos until the visible crop can fill the canvas.
// Without this, post-crop zoom is capped at the original full-image fit.
const cropMaxScale = Math.min(cw / (fit.dw * crop.w), ch / (fit.dh * crop.h));
// Split halves stay seam-anchored and can still scale beyond canvas fill.
const maxScale = photo.splitOf ? 5 : cropMaxScale;
const clampedScale = Math.max(0.05, Math.min(maxScale, scale));
const dw = fit.dw * clampedScale;
const dh = fit.dh * clampedScale;
const anchoredDx = photo.splitOf
? photo.splitOf.half === 'left'
? cw - dw
: 0
: (cw - dw) / 2;
const baseTop = (ch - dh) / 2;
const minOffsetX = -anchoredDx - dw * crop.x;
const maxOffsetX = cw - anchoredDx - dw * (crop.x + crop.w);
const minOffsetY = -baseTop - dh * crop.y;
const maxOffsetY = ch - baseTop - dh * (crop.y + crop.h);
const finalMinOffsetX = photo.splitOf ? 0 : Math.min(minOffsetX, maxOffsetX);
const finalMaxOffsetX = photo.splitOf ? 0 : Math.max(minOffsetX, maxOffsetX);
const finalMinOffsetY = Math.min(minOffsetY, maxOffsetY);
const finalMaxOffsetY = Math.max(minOffsetY, maxOffsetY);
return {
scale: clampedScale,
offsetX: Math.max(finalMinOffsetX, Math.min(finalMaxOffsetX, offsetX)),
offsetY: Math.max(finalMinOffsetY, Math.min(finalMaxOffsetY, offsetY)),
};
}