Files
odysseus/static/js/editor/selection-mask.js
T

295 lines
10 KiB
JavaScript

/** Shared selection-mask geometry and compositing helpers. */
import { dilateMask } from './mask-utils.js';
function makeCanvas(width, height) {
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(width));
canvas.height = Math.max(1, Math.round(height));
return canvas;
}
export function normalizeSelectionRect(start, end, width, height, square = false) {
let dx = end.x - start.x;
let dy = end.y - start.y;
if (square) {
const size = Math.min(Math.abs(dx), Math.abs(dy));
dx = Math.sign(dx || 1) * size;
dy = Math.sign(dy || 1) * size;
}
const x1 = Math.max(0, Math.min(width, start.x));
const y1 = Math.max(0, Math.min(height, start.y));
const x2 = Math.max(0, Math.min(width, start.x + dx));
const y2 = Math.max(0, Math.min(height, start.y + dy));
return {
x: Math.min(x1, x2),
y: Math.min(y1, y2),
w: Math.abs(x2 - x1),
h: Math.abs(y2 - y1),
};
}
export function normalizeConstrainedSelectionRect(start, end, width, height, options = {}) {
const mode = ['ratio', 'size'].includes(options.mode) ? options.mode : 'free';
if (mode === 'free') return normalizeSelectionRect(start, end, width, height, !!options.square);
const sx = Math.max(0, Math.min(width, Number(start.x) || 0));
const sy = Math.max(0, Math.min(height, Number(start.y) || 0));
if (mode === 'size') {
const w = Math.max(1, Math.min(width, Math.round(Number(options.fixedWidth) || 1)));
const h = Math.max(1, Math.min(height, Math.round(Number(options.fixedHeight) || 1)));
return {
x: Math.max(0, Math.min(width - w, sx)),
y: Math.max(0, Math.min(height - h, sy)),
w,
h,
};
}
const ratioWidth = Math.max(0.01, Number(options.ratioWidth) || 1);
const ratioHeight = Math.max(0.01, Number(options.ratioHeight) || 1);
const ratio = ratioWidth / ratioHeight;
const dx = (Number(end.x) || 0) - sx;
const dy = (Number(end.y) || 0) - sy;
const signX = dx < 0 ? -1 : 1;
const signY = dy < 0 ? -1 : 1;
let w;
let h;
if (Math.abs(dx) / Math.max(Math.abs(dy), 0.0001) >= ratio) {
w = Math.abs(dx);
h = w / ratio;
} else {
h = Math.abs(dy);
w = h * ratio;
}
const availableW = signX < 0 ? sx : width - sx;
const availableH = signY < 0 ? sy : height - sy;
const scale = Math.min(1, availableW / Math.max(w, 0.0001), availableH / Math.max(h, 0.0001));
w *= scale;
h *= scale;
return {
x: signX < 0 ? sx - w : sx,
y: signY < 0 ? sy - h : sy,
w,
h,
};
}
export function createMarqueeMask(width, height, rect, shape = 'rectangle') {
const canvas = makeCanvas(width, height);
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#fff';
if (shape === 'ellipse') {
ctx.beginPath();
ctx.ellipse(
rect.x + rect.w / 2,
rect.y + rect.h / 2,
rect.w / 2,
rect.h / 2,
0,
0,
Math.PI * 2,
);
ctx.fill();
} else {
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
}
return canvas;
}
export function mergeSelectionMasks(current, incoming, mode = 'replace') {
if (!current || mode === 'replace') return incoming;
const merged = makeCanvas(incoming.width, incoming.height);
const ctx = merged.getContext('2d');
ctx.drawImage(current, 0, 0);
if (mode === 'subtract') ctx.globalCompositeOperation = 'destination-out';
else if (mode === 'intersect') ctx.globalCompositeOperation = 'destination-in';
else ctx.globalCompositeOperation = 'source-over';
ctx.drawImage(incoming, 0, 0);
ctx.globalCompositeOperation = 'source-over';
return merged;
}
function copyMask(mask) {
if (!mask) return null;
const canvas = makeCanvas(mask.width, mask.height);
canvas.getContext('2d').drawImage(mask, 0, 0);
return canvas;
}
function blurMask(mask, radius) {
if (!mask || radius <= 0) return copyMask(mask);
const canvas = makeCanvas(mask.width, mask.height);
const ctx = canvas.getContext('2d');
ctx.filter = `blur(${Math.max(0, radius)}px)`;
ctx.drawImage(mask, 0, 0);
ctx.filter = 'none';
return canvas;
}
function thresholdMask(mask, threshold = 128) {
const canvas = copyMask(mask);
const ctx = canvas.getContext('2d');
const image = ctx.getImageData(0, 0, canvas.width, canvas.height);
for (let i = 0; i < image.data.length; i += 4) {
const alpha = image.data[i + 3] >= threshold ? 255 : 0;
image.data[i] = alpha;
image.data[i + 1] = alpha;
image.data[i + 2] = alpha;
image.data[i + 3] = alpha;
}
ctx.putImageData(image, 0, 0);
return canvas;
}
/** Build a non-destructive preview for the Select > Refine workflow. */
export function refineSelectionMask(mask, options = {}) {
if (!mask) return null;
const expand = Math.max(-100, Math.min(100, Math.round(Number(options.expand) || 0)));
const smooth = Math.max(0, Math.min(100, Math.round(Number(options.smooth) || 0)));
const border = Math.max(0, Math.min(200, Math.round(Number(options.border) || 0)));
const feather = Math.max(0, Math.min(200, Number(options.feather) || 0));
let result = expand ? dilateMask(mask, expand) : copyMask(mask);
if (smooth) result = thresholdMask(blurMask(result, smooth), 128);
if (border) {
const outer = dilateMask(result, Math.ceil(border / 2));
const inner = dilateMask(result, -Math.floor(border / 2));
const ctx = outer.getContext('2d');
ctx.globalCompositeOperation = 'destination-out';
ctx.drawImage(inner, 0, 0);
ctx.globalCompositeOperation = 'source-over';
result = outer;
}
return feather ? blurMask(result, feather) : result;
}
export function selectionMaskToDocument(mask, space, layerOffset, width, height) {
if (!mask) return null;
if (space === 'document' && mask.width === width && mask.height === height) return mask;
const canvas = makeCanvas(width, height);
canvas.getContext('2d').drawImage(
mask,
space === 'document' ? 0 : (layerOffset?.x || 0),
space === 'document' ? 0 : (layerOffset?.y || 0),
);
return canvas;
}
export function selectionMaskForLayer(mask, space, layerOffset, width, height) {
if (!mask) return null;
if (space !== 'document' && mask.width === width && mask.height === height) return mask;
const canvas = makeCanvas(width, height);
canvas.getContext('2d').drawImage(
mask,
space === 'document' ? -(layerOffset?.x || 0) : 0,
space === 'document' ? -(layerOffset?.y || 0) : 0,
);
return canvas;
}
export function selectionMaskContains(mask, x, y, threshold = 1) {
if (!mask || x < 0 || y < 0 || x >= mask.width || y >= mask.height) return false;
try {
return mask.getContext('2d').getImageData(Math.floor(x), Math.floor(y), 1, 1).data[3] >= threshold;
} catch {
return false;
}
}
export function selectionMaskBounds(mask, threshold = 1) {
if (!mask?.width || !mask?.height) return null;
let data;
try { data = mask.getContext('2d').getImageData(0, 0, mask.width, mask.height).data; }
catch { return null; }
let left = mask.width;
let top = mask.height;
let right = -1;
let bottom = -1;
for (let y = 0; y < mask.height; y += 1) {
for (let x = 0; x < mask.width; x += 1) {
if (data[(y * mask.width + x) * 4 + 3] < threshold) continue;
left = Math.min(left, x);
top = Math.min(top, y);
right = Math.max(right, x);
bottom = Math.max(bottom, y);
}
}
return right < left ? null : { x: left, y: top, width: right - left + 1, height: bottom - top + 1 };
}
export function translateSelectionMask(mask, dx, dy, width = mask?.width, height = mask?.height) {
if (!mask) return null;
const canvas = makeCanvas(width, height);
canvas.getContext('2d').drawImage(mask, Math.round(dx), Math.round(dy));
return canvas;
}
/**
* Render a document-space selection through one affine transform. The source
* mask is never mutated, so callers can rebuild every live preview from the
* same pixels instead of accumulating interpolation damage.
*/
export function transformSelectionMask(mask, sourceBounds, target, width = mask?.width, height = mask?.height) {
if (!mask || !sourceBounds || !target) return null;
const sourceWidth = Math.max(1, Number(sourceBounds.width) || 1);
const sourceHeight = Math.max(1, Number(sourceBounds.height) || 1);
const targetWidth = Math.max(1, Number(target.width) || 1);
const targetHeight = Math.max(1, Number(target.height) || 1);
const canvas = makeCanvas(width, height);
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.save();
ctx.translate(Number(target.centerX) || 0, Number(target.centerY) || 0);
ctx.rotate(((Number(target.rotation) || 0) * Math.PI) / 180);
ctx.scale(target.flipH ? -1 : 1, target.flipV ? -1 : 1);
ctx.drawImage(
mask,
sourceBounds.x,
sourceBounds.y,
sourceWidth,
sourceHeight,
-targetWidth / 2,
-targetHeight / 2,
targetWidth,
targetHeight,
);
ctx.restore();
return canvas;
}
export function selectionBoundaryPixels(mask, threshold = 128) {
if (!mask?.width || !mask?.height) return [];
let data;
try { data = mask.getContext('2d').getImageData(0, 0, mask.width, mask.height).data; }
catch { return []; }
const selected = (x, y) => x >= 0 && y >= 0 && x < mask.width && y < mask.height
&& data[(y * mask.width + x) * 4 + 3] >= threshold;
const pixels = [];
for (let y = 0; y < mask.height; y += 1) {
for (let x = 0; x < mask.width; x += 1) {
if (!selected(x, y)) continue;
if (!selected(x - 1, y) || !selected(x + 1, y) || !selected(x, y - 1) || !selected(x, y + 1)) {
pixels.push(y * mask.width + x);
}
}
}
return pixels;
}
export function paintSelectionBoundary(ctx, pixels, width, height, phase = 0) {
if (!ctx || !pixels?.length || !width || !height) return;
const image = ctx.createImageData(width, height);
for (const index of pixels) {
const x = index % width;
const y = Math.floor(index / width);
const light = ((x + y + phase) % 8) < 4;
const offset = index * 4;
image.data[offset] = light ? 255 : 0;
image.data[offset + 1] = light ? 255 : 0;
image.data[offset + 2] = light ? 255 : 0;
image.data[offset + 3] = 255;
}
ctx.putImageData(image, 0, 0);
}