172 lines
6.1 KiB
JavaScript
172 lines
6.1 KiB
JavaScript
/**
|
||
* Lasso-tool pixel & path helpers.
|
||
*
|
||
* All functions take the lasso polygon `points` as an explicit
|
||
* argument so they can be tested in isolation. The legacy gallery
|
||
* editor calls them with its module-level `_lassoPoints` array.
|
||
*/
|
||
|
||
/**
|
||
* Shift each polygon vertex along the outward normal by `grow` pixels.
|
||
* Used by the lasso overlay (to draw the "feather" halo) and by
|
||
* `buildLassoMask` (to bake the grown polygon into the mask).
|
||
*
|
||
* @param {{x: number, y: number}[]} points Polygon vertices in draw order.
|
||
* @param {number} grow Positive = expand outward, negative = contract.
|
||
* @returns {{x: number, y: number}[]} New array (same length, original is not mutated).
|
||
*/
|
||
export function lassoOffsetPoints(points, grow) {
|
||
const n = points.length;
|
||
if (n < 3 || !grow) return points;
|
||
// Polygon winding (positive = CCW) — flip the normal so it points
|
||
// away from the interior regardless of draw direction.
|
||
let area = 0;
|
||
for (let i = 0; i < n; i++) {
|
||
const p = points[i], q = points[(i + 1) % n];
|
||
area += (q.x - p.x) * (q.y + p.y);
|
||
}
|
||
const sign = area > 0 ? 1 : -1;
|
||
const out = new Array(n);
|
||
for (let i = 0; i < n; i++) {
|
||
const a = points[(i - 1 + n) % n], b = points[i], c = points[(i + 1) % n];
|
||
const e1x = b.x - a.x, e1y = b.y - a.y;
|
||
const e2x = c.x - b.x, e2y = c.y - b.y;
|
||
const l1 = Math.hypot(e1x, e1y) || 1;
|
||
const l2 = Math.hypot(e2x, e2y) || 1;
|
||
// Perpendicular (dy, -dx); flip via `sign` for outward direction.
|
||
const n1x = (e1y / l1) * sign, n1y = (-e1x / l1) * sign;
|
||
const n2x = (e2y / l2) * sign, n2y = (-e2x / l2) * sign;
|
||
const nx = (n1x + n2x) / 2;
|
||
const ny = (n1y + n2y) / 2;
|
||
const nl = Math.hypot(nx, ny) || 1;
|
||
out[i] = { x: b.x + (nx / nl) * grow, y: b.y + (ny / nl) * grow };
|
||
}
|
||
return out;
|
||
}
|
||
|
||
|
||
/**
|
||
* Trace the lasso polygon on the given context (move-to + line-to,
|
||
* closed). Caller is responsible for `stroke()` / `fill()` choice.
|
||
*/
|
||
export function getLassoPath(ctx, points) {
|
||
if (!points || points.length < 1) return;
|
||
ctx.beginPath();
|
||
ctx.moveTo(points[0].x, points[0].y);
|
||
for (let i = 1; i < points.length; i++) {
|
||
ctx.lineTo(points[i].x, points[i].y);
|
||
}
|
||
ctx.closePath();
|
||
}
|
||
|
||
|
||
/**
|
||
* Build a (optionally feathered, optionally grown) selection mask
|
||
* from a lasso polygon.
|
||
*
|
||
* @param {{x: number, y: number}[]} points Polygon vertices.
|
||
* @param {number} w / h Output canvas dimensions.
|
||
* @param {number} offX / offY Translate the polygon by (offX, offY) before rasterising.
|
||
* @param {number} feather Feather width in pixels. 0 = hard edge.
|
||
* @param {number} grow Positive = dilate the polygon, negative = erode.
|
||
* @returns {HTMLCanvasElement} A `w × h` canvas with alpha = selection strength.
|
||
*/
|
||
export function buildLassoMask(points, w, h, offX, offY, feather, grow) {
|
||
// Step 1: draw hard mask
|
||
const hard = document.createElement('canvas');
|
||
hard.width = w; hard.height = h;
|
||
const hCtx = hard.getContext('2d');
|
||
hCtx.beginPath();
|
||
hCtx.moveTo(points[0].x - offX, points[0].y - offY);
|
||
for (let i = 1; i < points.length; i++) {
|
||
hCtx.lineTo(points[i].x - offX, points[i].y - offY);
|
||
}
|
||
hCtx.closePath();
|
||
hCtx.fillStyle = '#fff';
|
||
hCtx.fill();
|
||
|
||
// Step 1b: grow / shrink — blur the hard mask, threshold low for
|
||
// grow and high for shrink. Same technique as the bg-remove edge
|
||
// tuner. RGB is left alone, alpha is replaced.
|
||
if (grow && grow !== 0) {
|
||
const blurC = document.createElement('canvas');
|
||
blurC.width = w; blurC.height = h;
|
||
const bctx = blurC.getContext('2d');
|
||
bctx.filter = `blur(${Math.abs(grow)}px)`;
|
||
bctx.drawImage(hard, 0, 0);
|
||
bctx.filter = 'none';
|
||
const blurred = bctx.getImageData(0, 0, w, h).data;
|
||
const hd = hCtx.getImageData(0, 0, w, h);
|
||
const out = hd.data;
|
||
const thr = grow > 0 ? 32 : 200;
|
||
for (let i = 0; i < out.length; i += 4) {
|
||
const a = blurred[i + 3] >= thr ? 255 : 0;
|
||
out[i] = a; out[i + 1] = a; out[i + 2] = a; out[i + 3] = a;
|
||
}
|
||
hCtx.putImageData(hd, 0, 0);
|
||
}
|
||
|
||
if (feather <= 0) return hard;
|
||
|
||
// Step 2: pixel data and distance-based feather.
|
||
const hardData = hCtx.getImageData(0, 0, w, h);
|
||
const d = hardData.data;
|
||
|
||
// Build inside/outside map.
|
||
const inside = new Uint8Array(w * h);
|
||
for (let i = 0; i < w * h; i++) {
|
||
inside[i] = d[i * 4] > 128 ? 1 : 0;
|
||
}
|
||
|
||
// Distance from edge (for pixels inside the selection, distance to nearest outside pixel).
|
||
const dist = new Float32Array(w * h);
|
||
dist.fill(feather + 1);
|
||
|
||
// Seed: edge pixels (inside pixels adjacent to outside pixels).
|
||
for (let y = 0; y < h; y++) {
|
||
for (let x = 0; x < w; x++) {
|
||
const i = y * w + x;
|
||
if (!inside[i]) { dist[i] = 0; continue; }
|
||
const hasOutside = (x > 0 && !inside[i-1]) || (x < w-1 && !inside[i+1]) ||
|
||
(y > 0 && !inside[(y-1)*w+x]) || (y < h-1 && !inside[(y+1)*w+x]);
|
||
if (hasOutside) dist[i] = 1;
|
||
}
|
||
}
|
||
|
||
// Two-pass chamfer distance transform.
|
||
for (let y = 0; y < h; y++) {
|
||
for (let x = 0; x < w; x++) {
|
||
const i = y * w + x;
|
||
if (dist[i] === 0) continue;
|
||
if (x > 0) dist[i] = Math.min(dist[i], dist[i-1] + 1);
|
||
if (y > 0) dist[i] = Math.min(dist[i], dist[(y-1)*w+x] + 1);
|
||
}
|
||
}
|
||
for (let y = h-1; y >= 0; y--) {
|
||
for (let x = w-1; x >= 0; x--) {
|
||
const i = y * w + x;
|
||
if (dist[i] === 0) continue;
|
||
if (x < w-1) dist[i] = Math.min(dist[i], dist[i+1] + 1);
|
||
if (y < h-1) dist[i] = Math.min(dist[i], dist[(y+1)*w+x] + 1);
|
||
}
|
||
}
|
||
|
||
// Pixels near the edge get reduced alpha.
|
||
const result = document.createElement('canvas');
|
||
result.width = w; result.height = h;
|
||
const rCtx = result.getContext('2d');
|
||
const rData = rCtx.createImageData(w, h);
|
||
|
||
for (let i = 0; i < w * h; i++) {
|
||
if (!inside[i]) continue;
|
||
const edgeDist = dist[i];
|
||
const alpha = edgeDist >= feather ? 255 : Math.round((edgeDist / feather) * 255);
|
||
rData.data[i*4] = alpha;
|
||
rData.data[i*4+1] = alpha;
|
||
rData.data[i*4+2] = alpha;
|
||
rData.data[i*4+3] = 255;
|
||
}
|
||
rCtx.putImageData(rData, 0, 0);
|
||
return result;
|
||
}
|