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