Files
odysseus/static/js/editor/multi-transform.js
T

160 lines
6.1 KiB
JavaScript

/** Shared-bounds geometry for transforming one or many layers. */
export function selectionBounds(editorState, layers) {
if (!layers?.length) return null;
let left = Infinity;
let top = Infinity;
let right = -Infinity;
let bottom = -Infinity;
for (const layer of layers) {
const offset = editorState.layerOffsets.get(layer.id) || { x: 0, y: 0 };
left = Math.min(left, offset.x);
top = Math.min(top, offset.y);
right = Math.max(right, offset.x + Math.max(1, layer.canvas?.width || 1));
bottom = Math.max(bottom, offset.y + Math.max(1, layer.canvas?.height || 1));
}
return {
x: left,
y: top,
width: Math.max(1, right - left),
height: Math.max(1, bottom - top),
centerX: (left + right) / 2,
centerY: (top + bottom) / 2,
};
}
export function transformedLayerGeometry(snapshot, sourceBounds, target) {
const scaleX = Math.max(1, target.width) / Math.max(1, sourceBounds.width);
const scaleY = Math.max(1, target.height) / Math.max(1, sourceBounds.height);
const signedScaleX = target.flipH ? -scaleX : scaleX;
const signedScaleY = target.flipV ? -scaleY : scaleY;
const radians = (target.rotation * Math.PI) / 180;
const cos = Math.cos(radians);
const sin = Math.sin(radians);
const absCos = Math.abs(cos);
const absSin = Math.abs(sin);
const sourceCenterX = snapshot.offset.x + snapshot.width / 2;
const sourceCenterY = snapshot.offset.y + snapshot.height / 2;
const relativeX = (sourceCenterX - sourceBounds.centerX) * signedScaleX;
const relativeY = (sourceCenterY - sourceBounds.centerY) * signedScaleY;
const centerX = target.centerX + relativeX * cos - relativeY * sin;
const centerY = target.centerY + relativeX * sin + relativeY * cos;
const scaledWidth = snapshot.width * scaleX;
const scaledHeight = snapshot.height * scaleY;
const width = Math.max(1, Math.round(scaledWidth * absCos + scaledHeight * absSin));
const height = Math.max(1, Math.round(scaledWidth * absSin + scaledHeight * absCos));
return {
scaleX,
scaleY,
signedScaleX,
signedScaleY,
rotation: target.rotation,
radians,
width,
height,
centerX,
centerY,
offset: {
x: Math.round(centerX - width / 2),
y: Math.round(centerY - height / 2),
},
};
}
export function transformedSelectionBounds(sourceBounds, target) {
const radians = (target.rotation * Math.PI) / 180;
const cos = Math.abs(Math.cos(radians));
const sin = Math.abs(Math.sin(radians));
const width = Math.max(1, target.width * cos + target.height * sin);
const height = Math.max(1, target.width * sin + target.height * cos);
return {
x: target.centerX - width / 2,
y: target.centerY - height / 2,
width,
height,
centerX: target.centerX,
centerY: target.centerY,
};
}
function canvasPixels(canvas) {
const width = Number(canvas?.width) || 0;
const height = Number(canvas?.height) || 0;
return Math.max(0, width) * Math.max(0, height);
}
/**
* Build a transform plan without allocating output canvases. The project
* decoder and the live editor share the same limits so a transform cannot
* create a document that the editor would later refuse to reopen.
*/
export function planLayerTransform(editorState, snapshots, sourceBounds, target, limits) {
const maxDimension = Number(limits?.maxDimension) || 32768;
const maxSurfacePixels = Number(limits?.maxSurfacePixels) || 300_000_000;
const numericTarget = [
target?.width, target?.height, target?.rotation,
target?.centerX, target?.centerY,
].every(Number.isFinite);
if (!numericTarget || target.width < 1 || target.height < 1) {
return { ok: false, reason: 'Transform values must be finite positive numbers.' };
}
const planned = [];
const byLayerId = new Map();
for (const snapshot of snapshots || []) {
const geometry = transformedLayerGeometry(snapshot, sourceBounds, target);
if (![geometry.width, geometry.height, geometry.offset.x, geometry.offset.y].every(Number.isFinite)) {
return { ok: false, reason: 'Transform geometry is outside the supported numeric range.' };
}
if (geometry.width > maxDimension || geometry.height > maxDimension) {
return {
ok: false,
reason: `A transformed layer would exceed the ${maxDimension.toLocaleString()} px dimension limit.`,
};
}
const item = { snapshot, geometry };
planned.push(item);
byLayerId.set(snapshot.layer.id, item);
}
let surfacePixels = 0;
const addPixels = pixels => {
surfacePixels += pixels;
return surfacePixels <= maxSurfacePixels;
};
for (const layer of editorState.layers || []) {
const item = byLayerId.get(layer.id);
if (!addPixels(item ? item.geometry.width * item.geometry.height : canvasPixels(layer.canvas))) {
return { ok: false, reason: 'Transform would exceed the 300 megapixel surface budget.' };
}
if (layer.kind === 'placed' && layer.placed?.sourceCanvas && !addPixels(canvasPixels(layer.placed.sourceCanvas))) {
return { ok: false, reason: 'Transform would exceed the 300 megapixel surface budget.' };
}
for (const mask of layer.masks || []) {
const maskSnapshot = item?.snapshot.masks?.find(candidate => candidate.mask === mask);
const pixels = maskSnapshot?.linked
? item.geometry.width * item.geometry.height
: canvasPixels(mask.canvas);
if (!addPixels(pixels)) {
return { ok: false, reason: 'Transform would exceed the 300 megapixel surface budget.' };
}
}
}
for (const group of editorState.layerGroups || []) {
for (const mask of group.masks || []) {
if (!addPixels(canvasPixels(mask.canvas))) {
return { ok: false, reason: 'Transform would exceed the 300 megapixel surface budget.' };
}
}
}
for (const selection of editorState.savedSelections || []) {
if (!addPixels(canvasPixels(selection.canvas))) {
return { ok: false, reason: 'Transform would exceed the 300 megapixel surface budget.' };
}
}
if (editorState.wandMask && !addPixels(canvasPixels(editorState.wandMask))) {
return { ok: false, reason: 'Transform would exceed the 300 megapixel surface budget.' };
}
return { ok: true, surfacePixels, items: planned };
}