Room around a point
64-bitFlood-fills the room and returns its outline in pixel corners.
The 32-bit ink values are only compared with 0.25 to make a 0 or 1 wall mask; everything after is whole-number pixel work. The pixel count px that is returned is the region before the closing step and is not the area that is reported.
export function roomAt(ink, seed, { gapPx = 20, on = 0.25, maxShare = 0.6 } = {}) {
const { data, w, h } = ink;
const sx = Math.floor(seed.x), sy = Math.floor(seed.y);
if (sx < 0 || sy < 0 || sx >= w || sy >= h) return { ok: false, why: 'open' };
const wall = new Uint8Array(w * h);
for (let i = 0; i < wall.length; i++) wall[i] = data[i] >= on ? 1 : 0;
if (wall[sy * w + sx]) return { ok: false, why: 'wall' };
const r = Math.max(1, Math.round(gapPx / 2));
const closed = dilateBin(wall, w, h, r);
// the seed may sit within r of a line (a narrow closet, a click near a wall): start from the nearest open pixel
let start = sy * w + sx;
if (closed[start]) {
let best = -1, bd = Infinity;
for (let y = Math.max(0, sy - r); y <= Math.min(h - 1, sy + r); y++) for (let x = Math.max(0, sx - r); x <= Math.min(w - 1, sx + r); x++) {
const d = (x - sx) ** 2 + (y - sy) ** 2;
if (!closed[y * w + x] && d < bd) { bd = d; best = y * w + x; }
}
if (best < 0) return { ok: false, why: 'small' };
start = best;
}
// flood fill (4-connected) among the open pixels
const region = new Uint8Array(w * h);
const stack = new Int32Array(w * h);
let sp = 0, n = 0, edge = false;
region[start] = 1; stack[sp++] = start;
while (sp) {
const i = stack[--sp];
n++;
const x = i % w, y = (i - x) / w;
if (x === 0 || y === 0 || x === w - 1 || y === h - 1) edge = true;
if (x > 0 && !region[i - 1] && !closed[i - 1]) { region[i - 1] = 1; stack[sp++] = i - 1; }
if (x < w - 1 && !region[i + 1] && !closed[i + 1]) { region[i + 1] = 1; stack[sp++] = i + 1; }
if (y > 0 && !region[i - w] && !closed[i - w]) { region[i - w] = 1; stack[sp++] = i - w; }
if (y < h - 1 && !region[i + w] && !closed[i + w]) { region[i + w] = 1; stack[sp++] = i + w; }
}
if (edge || n > maxShare * w * h) return { ok: false, why: 'open' };
// grow back to the lines' faces (not into them, not through a door: only pixels near the filled part)
const grown = dilateBin(region, w, h, r);
for (let i = 0; i < grown.length; i++) if (wall[i]) grown[i] = 0;
// keep the part connected to the seed (what grew past a line's end into another space is dropped)
const keep = new Uint8Array(w * h);
sp = 0; keep[start] = 1; stack[sp++] = start;
let px = 0;
while (sp) {
const i = stack[--sp];
px++;
const x = i % w, y = (i - x) / w;
for (const j of [x > 0 ? i - 1 : -1, x < w - 1 ? i + 1 : -1, y > 0 ? i - w : -1, y < h - 1 ? i + w : -1]) {
if (j >= 0 && grown[j] && !keep[j]) { keep[j] = 1; stack[sp++] = j; }
}
}
if (px < 4 * r * r) return { ok: false, why: 'small' };
// close slits the grow-back leaves where text or a fixture stands near a wall (dilate, then erode, by half the radius;
// never into the lines)
const c = Math.max(1, Math.round(r / 2));
const dil = dilateBin(keep, w, h, c);
for (let i = 0; i < dil.length; i++) dil[i] = dil[i] ? 0 : 1;
const outside = dilateBin(dil, w, h, c);
for (let i = 0; i < keep.length; i++) keep[i] = !outside[i] && !wall[i] ? 1 : 0;
const ring = simplify(trace(keep, w, h), 1.0);
if (ring.length < 3) return { ok: false, why: 'small' };
return { ok: true, ring, px };
}