react-native-mask-segment-c.../dist/utils/maskOutlinePaths.js

419 lines
15 KiB
JavaScript
Raw Normal View History

import { Skia } from '@shopify/react-native-skia';
import { getMaskSegmentRuntimeConfig } from './maskSegmentRuntime';
function isMaskPixelOn(binary, cols, rows, x, y) {
return (x >= 0 && x < cols && y >= 0 && y < rows && binary[y * cols + x] > 0);
}
function collectBoundaryEdges(binary, cols, rows) {
const edges = [];
for (let y = 0; y < rows; y++) {
const row = y * cols;
for (let x = 0; x < cols; x++) {
if (!binary[row + x]) {
continue;
}
if (!isMaskPixelOn(binary, cols, rows, x, y - 1)) {
edges.push({ x0: x, y0: y, x1: x + 1, y1: y });
}
if (!isMaskPixelOn(binary, cols, rows, x, y + 1)) {
edges.push({ x0: x + 1, y0: y + 1, x1: x, y1: y + 1 });
}
if (!isMaskPixelOn(binary, cols, rows, x - 1, y)) {
edges.push({ x0: x, y0: y + 1, x1: x, y1: y });
}
if (!isMaskPixelOn(binary, cols, rows, x + 1, y)) {
edges.push({ x0: x + 1, y0: y, x1: x + 1, y1: y + 1 });
}
}
}
return edges;
}
function chainBoundaryLoops(edges) {
const outgoing = new Map();
const edgeKey = (edge) => `${edge.x0},${edge.y0}->${edge.x1},${edge.y1}`;
for (const edge of edges) {
const key = `${edge.x0},${edge.y0}`;
const list = outgoing.get(key);
if (list) {
list.push(edge);
}
else {
outgoing.set(key, [edge]);
}
}
const used = new Set();
const loops = [];
for (const edge of edges) {
const startEdgeKey = edgeKey(edge);
if (used.has(startEdgeKey)) {
continue;
}
const loop = [{ x: edge.x0, y: edge.y0 }];
let current = edge;
used.add(startEdgeKey);
loop.push({ x: current.x1, y: current.y1 });
while (true) {
const endKey = `${current.x1},${current.y1}`;
const startKey = `${loop[0].x},${loop[0].y}`;
if (endKey === startKey && loop.length > 2) {
break;
}
const candidates = outgoing.get(endKey);
const next = candidates?.find(candidate => !used.has(edgeKey(candidate)));
if (!next) {
break;
}
current = next;
used.add(edgeKey(current));
loop.push({ x: current.x1, y: current.y1 });
if (loop.length > edges.length + 1) {
break;
}
}
if (loop.length >= 4) {
loops.push(loop);
}
}
return loops;
}
function simplifyOrthogonalLoop(points) {
if (points.length <= 3) {
return points;
}
const out = [points[0]];
for (let i = 1; i < points.length - 1; i++) {
const prev = out[out.length - 1];
const curr = points[i];
const next = points[i + 1];
const collinearX = prev.x === curr.x && curr.x === next.x;
const collinearY = prev.y === curr.y && curr.y === next.y;
if (!collinearX && !collinearY) {
out.push(curr);
}
}
out.push(points[points.length - 1]);
return out;
}
function perpendicularDistance2(point, lineStart, lineEnd) {
const dx = lineEnd.x - lineStart.x;
const dy = lineEnd.y - lineStart.y;
if (dx === 0 && dy === 0) {
return Math.hypot(point.x - lineStart.x, point.y - lineStart.y);
}
const t = ((point.x - lineStart.x) * dx + (point.y - lineStart.y) * dy) /
(dx * dx + dy * dy);
const projX = lineStart.x + t * dx;
const projY = lineStart.y + t * dy;
return Math.hypot(point.x - projX, point.y - projY);
}
/**
* Ramer-Douglas-Peucker simplification for outline loops.
* Reduces stair-step jagginess from grid boundary tracing so that dashed
* outlines render smoothly instead of zigzagging across every pixel edge.
* Epsilon is in grid-pixel units (1.0 = one mask pixel).
*/
function simplifyLoopRdp(points, epsilon) {
if (points.length <= 2) {
return points;
}
let maxDist = 0;
let index = 0;
const end = points.length - 1;
const lineStart = points[0];
const lineEnd = points[end];
for (let i = 1; i < end; i++) {
const p = points[i];
const dist = perpendicularDistance2(p, lineStart, lineEnd);
if (dist > maxDist) {
maxDist = dist;
index = i;
}
}
if (maxDist > epsilon) {
const left = simplifyLoopRdp(points.slice(0, index + 1), epsilon);
const right = simplifyLoopRdp(points.slice(index), epsilon);
return [...left.slice(0, -1), ...right];
}
return [lineStart, lineEnd];
}
function loopsToSkPath(loops, cols, rows, rect) {
const path = Skia.Path.Make();
for (const rawLoop of loops) {
// Two-pass simplification: first remove collinear points, then RDP to
// smooth stair-step artifacts from grid boundary tracing.
const orthogonal = simplifyOrthogonalLoop(rawLoop);
const loop = simplifyLoopRdp(orthogonal, 1.0);
if (loop.length < 2) {
continue;
}
const [first, ...rest] = loop;
path.moveTo(rect.x + (first.x / cols) * rect.w, rect.y + (first.y / rows) * rect.h);
for (const point of rest) {
path.lineTo(rect.x + (point.x / cols) * rect.w, rect.y + (point.y / rows) * rect.h);
}
path.close();
}
return path;
}
function pointInIntegerLoop(px, py, loop) {
let inside = false;
for (let i = 0, j = loop.length - 1; i < loop.length; j = i++) {
const xi = loop[i].x;
const yi = loop[i].y;
const xj = loop[j].x;
const yj = loop[j].y;
const intersect = yi > py !== yj > py &&
px < ((xj - xi) * (py - yi)) / (yj - yi + Number.EPSILON) + xi;
if (intersect) {
inside = !inside;
}
}
return inside;
}
function loopBoundingArea(loop) {
if (loop.length === 0) {
return 0;
}
let minX = loop[0].x;
let maxX = loop[0].x;
let minY = loop[0].y;
let maxY = loop[0].y;
for (const point of loop) {
minX = Math.min(minX, point.x);
maxX = Math.max(maxX, point.x);
minY = Math.min(minY, point.y);
maxY = Math.max(maxY, point.y);
}
return (maxX - minX) * (maxY - minY);
}
function filterOutlineLoops(loops, cols, rows, seedPx) {
if (loops.length === 0) {
return loops;
}
const minLoopArea = Math.max(16, Math.floor(cols * rows * 0.00005));
const significant = loops.filter(loop => loopBoundingArea(loop) >= minLoopArea);
const candidates = significant.length > 0 ? significant : loops;
if (seedPx) {
const sampleX = seedPx.x + 0.5;
const sampleY = seedPx.y + 0.5;
const containing = candidates.filter(loop => pointInIntegerLoop(sampleX, sampleY, loop));
if (containing.length > 0) {
containing.sort((a, b) => loopBoundingArea(b) - loopBoundingArea(a));
return containing;
}
}
candidates.sort((a, b) => loopBoundingArea(b) - loopBoundingArea(a));
// Keep loops ≥ 5% of the largest area to filter isolated noise speckles
// while retaining genuine disconnected fragments of the same region.
const minKeepArea = loopBoundingArea(candidates[0]) * 0.05;
return candidates.filter(loop => loopBoundingArea(loop) >= minKeepArea);
}
function floodFillComponent(binary, cols, rows, seedX, seedY) {
if (seedX < 0 ||
seedY < 0 ||
seedX >= cols ||
seedY >= rows ||
!binary[seedY * cols + seedX]) {
return null;
}
const out = new Uint8Array(cols * rows);
const stack = [seedY * cols + seedX];
out[stack[0]] = 255;
while (stack.length > 0) {
const index = stack.pop();
const x = index % cols;
const y = (index - x) / cols;
if (x > 0) {
const left = index - 1;
if (binary[left] && !out[left]) {
out[left] = 255;
stack.push(left);
}
}
if (x + 1 < cols) {
const right = index + 1;
if (binary[right] && !out[right]) {
out[right] = 255;
stack.push(right);
}
}
if (y > 0) {
const up = index - cols;
if (binary[up] && !out[up]) {
out[up] = 255;
stack.push(up);
}
}
if (y + 1 < rows) {
const down = index + cols;
if (binary[down] && !out[down]) {
out[down] = 255;
stack.push(down);
}
}
}
return out;
}
function findLargestComponentSeed(binary, cols, rows) {
const visited = new Uint8Array(cols * rows);
let bestArea = 0;
let bestSeed = null;
for (let y = 0; y < rows; y++) {
const row = y * cols;
for (let x = 0; x < cols; x++) {
const start = row + x;
if (!binary[start] || visited[start]) {
continue;
}
let area = 0;
let sumX = 0;
let sumY = 0;
const stack = [start];
visited[start] = 1;
while (stack.length > 0) {
const index = stack.pop();
area += 1;
const px = index % cols;
const py = (index - px) / cols;
sumX += px;
sumY += py;
if (px > 0) {
const left = index - 1;
if (binary[left] && !visited[left]) {
visited[left] = 1;
stack.push(left);
}
}
if (px + 1 < cols) {
const right = index + 1;
if (binary[right] && !visited[right]) {
visited[right] = 1;
stack.push(right);
}
}
if (py > 0) {
const up = index - cols;
if (binary[up] && !visited[up]) {
visited[up] = 1;
stack.push(up);
}
}
if (py + 1 < rows) {
const down = index + cols;
if (binary[down] && !visited[down]) {
visited[down] = 1;
stack.push(down);
}
}
}
if (area > bestArea) {
bestArea = area;
bestSeed = {
x: Math.floor(sumX / area),
y: Math.floor(sumY / area),
};
}
}
}
return bestSeed;
}
function buildRegionOutlinePathFromBinary(binary, cols, rows, rect, seedPx) {
let working = binary;
if (seedPx) {
const component = floodFillComponent(binary, cols, rows, seedPx.x, seedPx.y);
if (!component) {
return Skia.Path.Make();
}
working = component;
}
const edges = collectBoundaryEdges(working, cols, rows);
const loops = chainBoundaryLoops(edges);
const filtered = filterOutlineLoops(loops, cols, rows, seedPx);
return loopsToSkPath(filtered, cols, rows, rect);
}
function resolveRegionOutlineSeedPx(binary, cols, rows, normSeed) {
if (normSeed) {
return {
x: Math.min(cols - 1, Math.max(0, Math.floor(normSeed.x * cols))),
y: Math.min(rows - 1, Math.max(0, Math.floor(normSeed.y * rows))),
};
}
return findLargestComponentSeed(binary, cols, rows) ?? undefined;
}
export function buildRegionOutlinePathForRegion(regionId, regions, maskData, rect, normSeed) {
const binaries = extractRegionBinaries(regions, maskData);
const binary = binaries.get(regionId);
if (!binary) {
return Skia.Path.Make();
}
const { cols, rows } = maskData;
const seedPx = resolveRegionOutlineSeedPx(binary, cols, rows, normSeed);
return buildRegionOutlinePathFromBinary(binary, cols, rows, rect, seedPx);
}
function extractRegionBinaries(regions, maskData) {
const { labels, baseboardBinary, cols, rows, wallSubLabels } = maskData;
const size = cols * rows;
const binaries = new Map();
const semanticColors = getMaskSegmentRuntimeConfig().mask.semanticColors;
const regionIdBySemantic = new Int32Array(semanticColors.length);
regionIdBySemantic.fill(-1);
const wallSubRegionIds = new Map();
let baseboardRegionId = null;
for (const reg of regions) {
binaries.set(reg.id, new Uint8Array(size));
if (reg.thinStrip) {
baseboardRegionId = reg.id;
continue;
}
const wallMatch = /^wall-(\d+)$/.exec(reg.name);
if (wallMatch && wallSubLabels) {
wallSubRegionIds.set(Number(wallMatch[1]) - 1, reg.id);
continue;
}
const semanticIndex = semanticColors.findIndex(entry => entry.name === reg.name);
if (semanticIndex >= 0) {
regionIdBySemantic[semanticIndex] = reg.id;
}
}
const semanticCount = semanticColors.length;
const wallIdx = semanticColors.findIndex(entry => entry.name === 'wall');
for (let i = 0; i < size; i++) {
if (baseboardRegionId != null && baseboardBinary[i] > 0) {
binaries.get(baseboardRegionId)[i] = 255;
continue;
}
if (wallSubLabels && wallIdx >= 0 && labels[i] === wallIdx) {
const subIdx = wallSubLabels[i];
if (subIdx !== 255) {
const regionId = wallSubRegionIds.get(subIdx);
if (regionId !== undefined) {
binaries.get(regionId)[i] = 255;
}
}
continue;
}
const semanticIndex = labels[i];
if (semanticIndex < semanticCount && regionIdBySemantic[semanticIndex] >= 0) {
binaries.get(regionIdBySemantic[semanticIndex])[i] = 255;
}
}
return binaries;
}
export function buildAllRegionOutlinePaths(regions, maskData, rect) {
const { cols, rows } = maskData;
const binaries = extractRegionBinaries(regions, maskData);
const map = new Map();
for (const reg of regions) {
const binary = binaries.get(reg.id);
if (!binary) {
map.set(reg.id, Skia.Path.Make());
continue;
}
// No seed — build outlines from the full binary so all disconnected
// fragments (common after mask downsampling) get dashed outlines during
// the init flash loop. Per-region hold highlights still use a touch-seed
// via buildRegionOutlinePathForRegion for precise fragment isolation.
map.set(reg.id, buildRegionOutlinePathFromBinary(binary, cols, rows, rect));
}
return map;
}
//# sourceMappingURL=maskOutlinePaths.js.map