import { Skia, type SkPath } from '@shopify/react-native-skia'; import { getMaskSegmentRuntimeConfig } from './maskSegmentRuntime'; import type { SegmentRegion, RegionMaskData } from './maskSegmentation'; /** partition dashed highlight: extract outer contour from the same pixel grid as the fill mask */ type GridPoint = { x: number; y: number }; type GridEdge = { x0: number; y0: number; x1: number; y1: number; }; function isMaskPixelOn( binary: Uint8Array, cols: number, rows: number, x: number, y: number, ): boolean { return ( x >= 0 && x < cols && y >= 0 && y < rows && binary[y * cols + x] > 0 ); } function collectBoundaryEdges( binary: Uint8Array, cols: number, rows: number, ): GridEdge[] { const edges: GridEdge[] = []; 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: GridEdge[]): GridPoint[][] { const outgoing = new Map(); const edgeKey = (edge: GridEdge) => `${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: GridPoint[][] = []; for (const edge of edges) { const startEdgeKey = edgeKey(edge); if (used.has(startEdgeKey)) { continue; } const loop: GridPoint[] = [{ 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: GridPoint[]): GridPoint[] { if (points.length <= 3) { return points; } const out: GridPoint[] = [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: GridPoint, lineStart: GridPoint, lineEnd: GridPoint, ): number { 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: GridPoint[], epsilon: number): GridPoint[] { 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: GridPoint[][], cols: number, rows: number, rect: { x: number; y: number; w: number; h: number }, ): SkPath { 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: number, py: number, loop: GridPoint[]): boolean { 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: GridPoint[]): number { 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: GridPoint[][], cols: number, rows: number, seedPx?: { x: number; y: number }, ): GridPoint[][] { 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: Uint8Array, cols: number, rows: number, seedX: number, seedY: number, ): Uint8Array | null { 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: Uint8Array, cols: number, rows: number, ): { x: number; y: number } | null { const visited = new Uint8Array(cols * rows); let bestArea = 0; let bestSeed: { x: number; y: number } | null = 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: Uint8Array, cols: number, rows: number, rect: { x: number; y: number; w: number; h: number }, seedPx?: { x: number; y: number }, ): SkPath { 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: Uint8Array, cols: number, rows: number, normSeed?: { x: number; y: number }, ): { x: number; y: number } | undefined { 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: number, regions: SegmentRegion[], maskData: RegionMaskData, rect: { x: number; y: number; w: number; h: number }, normSeed?: { x: number; y: number }, ): SkPath { 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: SegmentRegion[], maskData: RegionMaskData, ): Map { 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: number | null = 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: SegmentRegion[], maskData: RegionMaskData, rect: { x: number; y: number; w: number; h: number }, ): Map { 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; }