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

396 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 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)));
// Snap the anchor to whole document pixels so a fixed-size marquee
// never straddles a pixel column/row (which antialiases into a
// 1px-taller selection on the trailing edge).
const ax = Math.round(sx);
const ay = Math.round(sy);
return {
x: Math.max(0, Math.min(width - w, ax)),
y: Math.max(0, Math.min(height - h, ay)),
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;
// Snap the anchor origin to whole document pixels, but keep w/h derived
// exactly from the ratio so the aspect stays exact (rounding w and h
// independently would drift the ratio).
return {
x: Math.round(signX < 0 ? sx - w : sx),
y: Math.round(signY < 0 ? sy - h : sy),
w: Math.max(1, w),
h: Math.max(1, 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;
}
/**
* Boundary pixels AND the selected-flag grid for a mask, computed in one
* pass. The flags let the ants painter find each outline pixel's exposed
* edges without re-reading the mask.
*/
export function selectionBoundary(mask, threshold = 128) {
const fallback = { width: mask?.width || 0, height: mask?.height || 0, pixels: [], flags: null };
if (!mask?.width || !mask?.height) return fallback;
let data;
try { data = mask.getContext('2d').getImageData(0, 0, mask.width, mask.height).data; }
catch { return fallback; }
const width = mask.width;
const height = mask.height;
const flags = new Uint8Array(width * height);
// First/last selected pixel of each row, so the outline pass can skip the
// empty margins above, below and around the selection instead of testing
// every pixel of a large canvas.
const firsts = new Int32Array(height).fill(-1);
const lasts = new Int32Array(height).fill(-1);
for (let y = 0, i = 0, alpha = 3; y < height; y += 1) {
let first = -1, last = -1;
for (let x = 0; x < width; x += 1, i += 1, alpha += 4) {
if (data[alpha] < threshold) continue;
flags[i] = 1;
if (first < 0) first = x;
last = x;
}
firsts[y] = first;
lasts[y] = last;
}
// Direct index arithmetic instead of a closure call per neighbour: this runs
// over every pixel of the image on each new selection, and the five function
// calls per pixel were a measurable share of the pen-close cost on big docs.
const pixels = [];
for (let y = 0; y < height; y += 1) {
const first = firsts[y];
if (first < 0) continue;
const last = lasts[y];
const row = y * width;
const above = row - width;
const below = row + width;
const top = y === 0;
const bottom = y === height - 1;
for (let x = first; x <= last; x += 1) {
const index = row + x;
if (flags[index] !== 1) continue;
if ((x === 0 || flags[index - 1] !== 1)
|| (x === width - 1 || flags[index + 1] !== 1)
|| (top || flags[above + x] !== 1)
|| (bottom || flags[below + x] !== 1)) {
pixels.push(index);
}
}
}
return { width, height, pixels, flags };
}
export function paintSelectionBoundary(ctx, pixels, width, height, phase = 0, zoom = 1, flags = null) {
if (!ctx || !pixels?.length || !width || !height) return;
if (!flags || typeof Path2D === 'undefined') {
// Fallback (no mask context / non-browser): per-pixel fill in image space.
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);
return;
}
// Screen-constant marching ants. `width`/`height` are the MASK (image)
// dimensions; the caller's overlay bitmap is rendered at SCREEN resolution
// (image size × zoom), so we multiply the image-space outline into screen
// pixels and stroke a flat 1px line — no bitmap resampling, so the outline
// stays the same thickness and opacity at every zoom level.
const scale = zoom > 0 ? zoom : 1;
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
ctx.lineWidth = 1;
ctx.lineCap = 'butt';
ctx.setLineDash([]);
const white = new Path2D();
const black = new Path2D();
const selected = (x, y) => x >= 0 && y >= 0 && x < width && y < height && flags[y * width + x] === 1;
for (const index of pixels) {
const x = index % width;
const y = Math.floor(index / width);
const x0 = x * scale;
const y0 = y * scale;
const x1 = (x + 1) * scale;
const y1 = (y + 1) * scale;
const sx = Math.floor((x + 0.5) * scale);
const sy = Math.floor((y + 0.5) * scale);
const path = ((sx + sy + phase) % 8) < 4 ? white : black;
if (!selected(x, y - 1)) { path.moveTo(x0, y0); path.lineTo(x1, y0); }
if (!selected(x, y + 1)) { path.moveTo(x0, y1); path.lineTo(x1, y1); }
if (!selected(x - 1, y)) { path.moveTo(x0, y0); path.lineTo(x0, y1); }
if (!selected(x + 1, y)) { path.moveTo(x1, y0); path.lineTo(x1, y1); }
}
ctx.strokeStyle = '#ffffff';
ctx.stroke(white);
ctx.strokeStyle = '#000000';
ctx.stroke(black);
}