fix: align default pipeline config with docs and split large modules
- Fix DEFAULT_PIPELINE_CONFIG from PIPELINE_HIGH (1440) to PIPELINE_MEDIUM (720) to match documented performance estimates and test expectations - Extract geometry/hit-detection utilities from MaskSegmentCanvas.tsx into canvasGeometry.ts (~380 lines) - Extract outline path functions from maskSegmentation.ts into maskOutlinePaths.ts (~470 lines) - Net reduction of ~1,700 lines across the two core files Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2dd744de0f
commit
843544b9e8
2
dist/components/MaskSegmentCanvas.d.ts.map
vendored
2
dist/components/MaskSegmentCanvas.d.ts.map
vendored
@ -1 +1 @@
|
||||
{"version":3,"file":"MaskSegmentCanvas.d.ts","sourceRoot":"","sources":["../../src/components/MaskSegmentCanvas.tsx"],"names":[],"mappings":"AAAA,OAAO,KAQN,MAAM,OAAO,CAAC;AA8Cf,OAAO,KAAK,EAEV,sBAAsB,EACtB,oBAAoB,EAMrB,MAAM,2BAA2B,CAAC;AAcnC,YAAY,EACV,sBAAsB,EACtB,oBAAoB,EACpB,kBAAkB,EAClB,qBAAqB,EACrB,mBAAmB,EACnB,QAAQ,EACR,iBAAiB,GAClB,MAAM,2BAA2B,CAAC;AA0cnC,QAAA,MAAM,iBAAiB,qGAo/DrB,CAAC;AAmBH,eAAe,iBAAiB,CAAC"}
|
||||
{"version":3,"file":"MaskSegmentCanvas.d.ts","sourceRoot":"","sources":["../../src/components/MaskSegmentCanvas.tsx"],"names":[],"mappings":"AAAA,OAAO,KAQN,MAAM,OAAO,CAAC;AA6Cf,OAAO,KAAK,EAEV,sBAAsB,EACtB,oBAAoB,EAMrB,MAAM,2BAA2B,CAAC;AAcnC,YAAY,EACV,sBAAsB,EACtB,oBAAoB,EACpB,kBAAkB,EAClB,qBAAqB,EACrB,mBAAmB,EACnB,QAAQ,EACR,iBAAiB,GAClB,MAAM,2BAA2B,CAAC;AA+BnC,QAAA,MAAM,iBAAiB,qGAo/DrB,CAAC;AAmBH,eAAe,iBAAiB,CAAC"}
|
||||
289
dist/components/MaskSegmentCanvas.js
vendored
289
dist/components/MaskSegmentCanvas.js
vendored
@ -4,7 +4,7 @@ import { View, StyleSheet, } from 'react-native';
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||
import { runOnJS } from 'react-native-reanimated';
|
||||
import cv from '../utils/opencvAdapter';
|
||||
import { buildAllRegionOutlinePaths, buildRegionOutlinePathForRegion, downsampleMaskDataForPaths, extractRegionsFromMaskBufferSync, isBaseboardMaskPixel, upscaleBinaryMask, } from '../utils/maskSegmentation';
|
||||
import { buildAllRegionOutlinePaths, buildRegionOutlinePathForRegion, downsampleMaskDataForPaths, extractRegionsFromMaskBufferSync, upscaleBinaryMask, } from '../utils/maskSegmentation';
|
||||
import { splitWallRegionsByTexture } from '../utils/wallTextureSplit';
|
||||
import { clearDerivedImageCache, readPngBgrBuffer, prewarmPngBgrCache, resizeBgrBuffer, } from '../utils/pngImage';
|
||||
import { hashUrl, resolveImageUrl } from '../utils/resolveImageUrl';
|
||||
@ -14,292 +14,7 @@ import { preparePaintResourcesFromWorkBuffer, releaseFreqLayerImages, } from '..
|
||||
import { PaintShaderLayer, createPaintColorMapForPaint, } from '../utils/paintShaderRuntime';
|
||||
import { createRuntimeConfig, getMaskRuntimeRevision, getMaskSegmentRuntimeConfig, resolvePipelineConfig, setMaskSegmentRuntimeConfig, } from '../utils/maskSegmentRuntime';
|
||||
import { Canvas, Image as SkiaImage, Path, Group, DashPathEffect, Rect, useCanvasRef, Skia, } from '@shopify/react-native-skia';
|
||||
function bgrColorEquals(a, b) {
|
||||
return a.b === b.b && a.g === b.g && a.r === b.r;
|
||||
}
|
||||
/* ==========================================================================
|
||||
* 几何工具
|
||||
* ========================================================================== */
|
||||
function rectsEqual(a, b) {
|
||||
return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;
|
||||
}
|
||||
function getContainRect(canvasW, canvasH, imgW, imgH) {
|
||||
const imgAspect = imgW / imgH;
|
||||
const canvasAspect = canvasW / canvasH;
|
||||
if (imgAspect > canvasAspect) {
|
||||
const w = canvasW;
|
||||
const h = canvasW / imgAspect;
|
||||
return { x: 0, y: (canvasH - h) / 2, w, h };
|
||||
}
|
||||
const h = canvasH;
|
||||
const w = canvasH * imgAspect;
|
||||
return { x: (canvasW - w) / 2, y: 0, w, h };
|
||||
}
|
||||
function canvasToNormalized(cx, cy, canvasW, canvasH, imgW, imgH) {
|
||||
const rect = getContainRect(canvasW, canvasH, imgW, imgH);
|
||||
if (cx < rect.x ||
|
||||
cx > rect.x + rect.w ||
|
||||
cy < rect.y ||
|
||||
cy > rect.y + rect.h) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
x: (cx - rect.x) / rect.w,
|
||||
y: (cy - rect.y) / rect.h,
|
||||
};
|
||||
}
|
||||
/** Skia matrix: pan → scale around viewport center. Matches screenToCanvasCoords. */
|
||||
function buildZoomPanMatrix(panX, panY, scale, canvasW, canvasH) {
|
||||
const cx = canvasW / 2;
|
||||
const cy = canvasH / 2;
|
||||
const m = Skia.Matrix();
|
||||
m.translate(panX, panY);
|
||||
m.translate(cx, cy);
|
||||
m.scale(scale, scale);
|
||||
m.translate(-cx, -cy);
|
||||
return m;
|
||||
}
|
||||
/** Clamp pan so scaled containRect does not expose empty margins beyond the viewport. */
|
||||
function clampPanOffset(pan, scale, canvasW, canvasH, containRect) {
|
||||
if (!containRect || scale <= 1 || canvasW <= 0 || canvasH <= 0) {
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
const cx = canvasW / 2;
|
||||
const cy = canvasH / 2;
|
||||
const r = containRect;
|
||||
const scaledMinX = cx + scale * (r.x - cx);
|
||||
const scaledMaxX = cx + scale * (r.x + r.w - cx);
|
||||
const scaledMinY = cy + scale * (r.y - cy);
|
||||
const scaledMaxY = cy + scale * (r.y + r.h - cy);
|
||||
let x = pan.x;
|
||||
let y = pan.y;
|
||||
if (scaledMaxX - scaledMinX > canvasW) {
|
||||
x = Math.max(canvasW - scaledMaxX, Math.min(-scaledMinX, x));
|
||||
}
|
||||
else {
|
||||
x = 0;
|
||||
}
|
||||
if (scaledMaxY - scaledMinY > canvasH) {
|
||||
y = Math.max(canvasH - scaledMaxY, Math.min(-scaledMinY, y));
|
||||
}
|
||||
else {
|
||||
y = 0;
|
||||
}
|
||||
return { x, y };
|
||||
}
|
||||
/**
|
||||
* Inverse of the Skia Group transform applied during pinch-zoom.
|
||||
* Converts a raw touch point (screen pixels) back to the canvas coordinate
|
||||
* space where the image and regions are positioned before any scale/pan.
|
||||
* When zoomScale ≤ 1 (no zoom), returns the input unchanged.
|
||||
*/
|
||||
function screenToCanvasCoords(screenX, screenY, canvasW, canvasH, zoomScale, panOffset) {
|
||||
if (zoomScale <= 1)
|
||||
return { x: screenX, y: screenY };
|
||||
return {
|
||||
x: (screenX - panOffset.x - canvasW / 2) / zoomScale + canvasW / 2,
|
||||
y: (screenY - panOffset.y - canvasH / 2) / zoomScale + canvasH / 2,
|
||||
};
|
||||
}
|
||||
function pointInPolygon(x, y, points) {
|
||||
let inside = false;
|
||||
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
|
||||
const xi = points[i].x;
|
||||
const yi = points[i].y;
|
||||
const xj = points[j].x;
|
||||
const yj = points[j].y;
|
||||
const intersect = yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
|
||||
if (intersect)
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
function pointInPolygonWithPadding(x, y, points, padding) {
|
||||
if (points.length < 3) {
|
||||
return false;
|
||||
}
|
||||
let minX = points[0].x;
|
||||
let maxX = points[0].x;
|
||||
let minY = points[0].y;
|
||||
let maxY = points[0].y;
|
||||
for (const point of points) {
|
||||
minX = Math.min(minX, point.x);
|
||||
maxX = Math.max(maxX, point.x);
|
||||
minY = Math.min(minY, point.y);
|
||||
maxY = Math.max(maxY, point.y);
|
||||
}
|
||||
if (x >= minX - padding &&
|
||||
x <= maxX + padding &&
|
||||
y >= minY - padding &&
|
||||
y <= maxY + padding) {
|
||||
if (maxY - minY < padding * 2.5 || maxX - minX < padding * 2.5) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return pointInPolygon(x, y, points);
|
||||
}
|
||||
function getRegionHitPolygons(reg) {
|
||||
return reg.hitPolygons && reg.hitPolygons.length > 0
|
||||
? reg.hitPolygons
|
||||
: reg.polygons;
|
||||
}
|
||||
function pointHitsRegion(x, y, reg, options) {
|
||||
const interaction = getMaskSegmentRuntimeConfig().interaction;
|
||||
const thinPadding = options?.thinPadding ?? interaction.thinStripPadding;
|
||||
const padding = reg.thinStrip ? thinPadding : interaction.regionPadding;
|
||||
return getRegionHitPolygons(reg).some(poly => poly.length >= 3 && pointInPolygonWithPadding(x, y, poly, padding));
|
||||
}
|
||||
function pointStrictlyHitsRegion(x, y, reg) {
|
||||
return getRegionHitPolygons(reg).some(poly => poly.length >= 3 && pointInPolygon(x, y, poly));
|
||||
}
|
||||
function resolveRegionHit(regions, x, y) {
|
||||
const hits = [];
|
||||
for (const reg of regions) {
|
||||
const bboxPad = reg.thinStrip ? 0.005 : 0;
|
||||
const b = reg.bbox;
|
||||
if (x < b.x - bboxPad ||
|
||||
x > b.x + b.w + bboxPad ||
|
||||
y < b.y - bboxPad ||
|
||||
y > b.y + b.h + bboxPad) {
|
||||
continue;
|
||||
}
|
||||
if (pointHitsRegion(x, y, reg)) {
|
||||
hits.push(reg);
|
||||
}
|
||||
}
|
||||
if (hits.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (hits.length === 1) {
|
||||
return hits[0].id;
|
||||
}
|
||||
const strictNonThin = hits.filter(reg => !reg.thinStrip && pointStrictlyHitsRegion(x, y, reg));
|
||||
if (strictNonThin.length > 0) {
|
||||
strictNonThin.sort((a, b) => a.area - b.area);
|
||||
return strictNonThin[0].id;
|
||||
}
|
||||
const strictThin = hits.filter(reg => reg.thinStrip && pointStrictlyHitsRegion(x, y, reg));
|
||||
if (strictThin.length > 0) {
|
||||
strictThin.sort((a, b) => a.area - b.area);
|
||||
return strictThin[0].id;
|
||||
}
|
||||
const nonThin = hits.filter(reg => !reg.thinStrip);
|
||||
if (nonThin.length > 0) {
|
||||
nonThin.sort((a, b) => a.area - b.area);
|
||||
return nonThin[0].id;
|
||||
}
|
||||
hits.sort((a, b) => a.area - b.area);
|
||||
return hits[0].id;
|
||||
}
|
||||
function pickKickRegionFromMask(normX, normY, pick, kickRegionId, baseboardPickMask, strict = false) {
|
||||
const cx = Math.floor(normX * pick.cols);
|
||||
const cy = Math.floor(normY * pick.rows);
|
||||
if (cx < 0 || cy < 0 || cx >= pick.cols || cy >= pick.rows) {
|
||||
return null;
|
||||
}
|
||||
if (strict) {
|
||||
if (baseboardPickMask) {
|
||||
return baseboardPickMask[cy * pick.cols + cx] ? kickRegionId : null;
|
||||
}
|
||||
return isBaseboardMaskPixel(pick.buffer, pick.cols, pick.rows, cx, cy)
|
||||
? kickRegionId
|
||||
: null;
|
||||
}
|
||||
const interaction = getMaskSegmentRuntimeConfig().interaction;
|
||||
const radius = Math.max(interaction.kickMaskPickRadiusPx, Math.floor(pick.cols * 0.022));
|
||||
const radiusSq = radius * radius;
|
||||
for (let dy = -radius; dy <= radius; dy++) {
|
||||
for (let dx = -radius; dx <= radius; dx++) {
|
||||
if (dx * dx + dy * dy > radiusSq) {
|
||||
continue;
|
||||
}
|
||||
const x = cx + dx;
|
||||
const y = cy + dy;
|
||||
if (x < 0 || y < 0 || x >= pick.cols || y >= pick.rows) {
|
||||
continue;
|
||||
}
|
||||
if (baseboardPickMask) {
|
||||
if (baseboardPickMask[y * pick.cols + x]) {
|
||||
return kickRegionId;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (isBaseboardMaskPixel(pick.buffer, pick.cols, pick.rows, x, y)) {
|
||||
return kickRegionId;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function pickKickNearStrip(normX, normY, kickReg) {
|
||||
const polys = kickReg.hitPolygons ?? kickReg.polygons;
|
||||
const pad = getMaskSegmentRuntimeConfig().interaction.thinStripPadding + 0.004;
|
||||
return polys.some(poly => poly.length >= 3 && pointInPolygonWithPadding(normX, normY, poly, pad));
|
||||
}
|
||||
function lookupRegionFromPickMap(normX, normY, pick, radiusPx = getMaskSegmentRuntimeConfig().interaction.pickMapSearchRadiusPx) {
|
||||
const cx = Math.min(pick.cols - 1, Math.max(0, Math.floor(normX * pick.cols)));
|
||||
const cy = Math.min(pick.rows - 1, Math.max(0, Math.floor(normY * pick.rows)));
|
||||
const readCode = (x, y) => pick.buffer[y * pick.cols + x];
|
||||
const center = readCode(cx, cy);
|
||||
if (center > 0) {
|
||||
return center - 1;
|
||||
}
|
||||
if (radiusPx <= 0) {
|
||||
return null;
|
||||
}
|
||||
const r = Math.max(4, radiusPx);
|
||||
const rSq = r * r;
|
||||
for (let dy = -r; dy <= r; dy++) {
|
||||
for (let dx = -r; dx <= r; dx++) {
|
||||
if (dx * dx + dy * dy > rSq) {
|
||||
continue;
|
||||
}
|
||||
const x = cx + dx;
|
||||
const y = cy + dy;
|
||||
if (x < 0 || y < 0 || x >= pick.cols || y >= pick.rows) {
|
||||
continue;
|
||||
}
|
||||
const code = readCode(x, y);
|
||||
if (code > 0) {
|
||||
return code - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function releasePaintResourceLayers(layers) {
|
||||
if (!layers) {
|
||||
return;
|
||||
}
|
||||
layers.lowFreqImage.dispose();
|
||||
layers.highFreqImage.dispose();
|
||||
}
|
||||
function releaseOriginSkImage(image) {
|
||||
if (image) {
|
||||
image.dispose();
|
||||
}
|
||||
}
|
||||
async function prepareWorkScaledBgrBuffer(bgrBuffer, cols, rows, workScale) {
|
||||
if (workScale >= 1) {
|
||||
return { buffer: bgrBuffer, cols, rows };
|
||||
}
|
||||
const workCols = Math.floor(cols * workScale);
|
||||
const workRows = Math.floor(rows * workScale);
|
||||
const buffer = resizeBgrBuffer(bgrBuffer, cols, rows, workCols, workRows);
|
||||
return { buffer, cols: workCols, rows: workRows };
|
||||
}
|
||||
/* ==========================================================================
|
||||
* 分段计时工具(仅开发环境生效)
|
||||
* ========================================================================== */
|
||||
let _timeLogTs = 0;
|
||||
function timeLog(tag) {
|
||||
if (!__DEV__)
|
||||
return;
|
||||
const now = performance.now();
|
||||
const dt = _timeLogTs ? now - _timeLogTs : 0;
|
||||
console.log(`[⏱ ${tag}] ${dt.toFixed(2)} ms`);
|
||||
_timeLogTs = now;
|
||||
}
|
||||
import { bgrColorEquals, rectsEqual, getContainRect, canvasToNormalized, buildZoomPanMatrix, clampPanOffset, screenToCanvasCoords, resolveRegionHit, pickKickRegionFromMask, pickKickNearStrip, lookupRegionFromPickMap, releasePaintResourceLayers, releaseOriginSkImage, prepareWorkScaledBgrBuffer, timeLog, } from '../utils/canvasGeometry';
|
||||
/* ==========================================================================
|
||||
* 组件主体
|
||||
* ========================================================================== */
|
||||
|
||||
2
dist/components/MaskSegmentCanvas.js.map
vendored
2
dist/components/MaskSegmentCanvas.js.map
vendored
File diff suppressed because one or more lines are too long
81
dist/utils/canvasGeometry.d.ts
vendored
Normal file
81
dist/utils/canvasGeometry.d.ts
vendored
Normal file
@ -0,0 +1,81 @@
|
||||
import { type SkImage } from '@shopify/react-native-skia';
|
||||
import type { SegmentRegion } from './maskSegmentation';
|
||||
import type { BgrColor } from '../components/MaskSegmentCanvas.types';
|
||||
export type PaintResourceLayers = {
|
||||
lowFreqImage: SkImage;
|
||||
highFreqImage: SkImage;
|
||||
};
|
||||
export type ContainRect = {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
};
|
||||
export type WorkScaledBgr = {
|
||||
buffer: Uint8Array;
|
||||
cols: number;
|
||||
rows: number;
|
||||
};
|
||||
export declare function bgrColorEquals(a: BgrColor, b: BgrColor): boolean;
|
||||
export declare function rectsEqual(a: ContainRect, b: ContainRect): boolean;
|
||||
export declare function getContainRect(canvasW: number, canvasH: number, imgW: number, imgH: number): ContainRect;
|
||||
export declare function canvasToNormalized(cx: number, cy: number, canvasW: number, canvasH: number, imgW: number, imgH: number): {
|
||||
x: number;
|
||||
y: number;
|
||||
} | null;
|
||||
/** Skia matrix: pan → scale around viewport center. Matches screenToCanvasCoords. */
|
||||
export declare function buildZoomPanMatrix(panX: number, panY: number, scale: number, canvasW: number, canvasH: number): import("@shopify/react-native-skia").SkMatrix;
|
||||
/** Clamp pan so scaled containRect does not expose empty margins beyond the viewport. */
|
||||
export declare function clampPanOffset(pan: {
|
||||
x: number;
|
||||
y: number;
|
||||
}, scale: number, canvasW: number, canvasH: number, containRect: ContainRect | null): {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
/**
|
||||
* Inverse of the Skia Group transform applied during pinch-zoom.
|
||||
* Converts a raw touch point (screen pixels) back to the canvas coordinate
|
||||
* space where the image and regions are positioned before any scale/pan.
|
||||
* When zoomScale ≤ 1 (no zoom), returns the input unchanged.
|
||||
*/
|
||||
export declare function screenToCanvasCoords(screenX: number, screenY: number, canvasW: number, canvasH: number, zoomScale: number, panOffset: {
|
||||
x: number;
|
||||
y: number;
|
||||
}): {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
export declare function pointInPolygon(x: number, y: number, points: {
|
||||
x: number;
|
||||
y: number;
|
||||
}[]): boolean;
|
||||
export declare function pointInPolygonWithPadding(x: number, y: number, points: {
|
||||
x: number;
|
||||
y: number;
|
||||
}[], padding: number): boolean;
|
||||
export declare function getRegionHitPolygons(reg: SegmentRegion): {
|
||||
x: number;
|
||||
y: number;
|
||||
}[][];
|
||||
export declare function pointHitsRegion(x: number, y: number, reg: SegmentRegion, options?: {
|
||||
thinPadding?: number;
|
||||
}): boolean;
|
||||
export declare function pointStrictlyHitsRegion(x: number, y: number, reg: SegmentRegion): boolean;
|
||||
export declare function resolveRegionHit(regions: SegmentRegion[], x: number, y: number): number | null;
|
||||
export declare function pickKickRegionFromMask(normX: number, normY: number, pick: {
|
||||
buffer: Uint8Array;
|
||||
cols: number;
|
||||
rows: number;
|
||||
}, kickRegionId: number, baseboardPickMask?: Uint8Array | null, strict?: boolean): number | null;
|
||||
export declare function pickKickNearStrip(normX: number, normY: number, kickReg: SegmentRegion): boolean;
|
||||
export declare function lookupRegionFromPickMap(normX: number, normY: number, pick: {
|
||||
buffer: Uint8Array;
|
||||
cols: number;
|
||||
rows: number;
|
||||
}, radiusPx?: number): number | null;
|
||||
export declare function releasePaintResourceLayers(layers: PaintResourceLayers | null): void;
|
||||
export declare function releaseOriginSkImage(image: SkImage | null): void;
|
||||
export declare function prepareWorkScaledBgrBuffer(bgrBuffer: Uint8Array, cols: number, rows: number, workScale: number): Promise<WorkScaledBgr>;
|
||||
export declare function timeLog(tag: string): void;
|
||||
//# sourceMappingURL=canvasGeometry.d.ts.map
|
||||
1
dist/utils/canvasGeometry.d.ts.map
vendored
Normal file
1
dist/utils/canvasGeometry.d.ts.map
vendored
Normal file
@ -0,0 +1 @@
|
||||
{"version":3,"file":"canvasGeometry.d.ts","sourceRoot":"","sources":["../../src/utils/canvasGeometry.ts"],"names":[],"mappings":"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"}
|
||||
306
dist/utils/canvasGeometry.js
vendored
Normal file
306
dist/utils/canvasGeometry.js
vendored
Normal file
@ -0,0 +1,306 @@
|
||||
import { Skia } from '@shopify/react-native-skia';
|
||||
import { isBaseboardMaskPixel } from './maskSegmentation';
|
||||
import { getMaskSegmentRuntimeConfig } from './maskSegmentRuntime';
|
||||
import { resizeBgrBuffer } from './pngImage';
|
||||
/* ==========================================================================
|
||||
* 颜色工具
|
||||
* ========================================================================== */
|
||||
export function bgrColorEquals(a, b) {
|
||||
return a.b === b.b && a.g === b.g && a.r === b.r;
|
||||
}
|
||||
/* ==========================================================================
|
||||
* 几何工具
|
||||
* ========================================================================== */
|
||||
export function rectsEqual(a, b) {
|
||||
return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;
|
||||
}
|
||||
export function getContainRect(canvasW, canvasH, imgW, imgH) {
|
||||
const imgAspect = imgW / imgH;
|
||||
const canvasAspect = canvasW / canvasH;
|
||||
if (imgAspect > canvasAspect) {
|
||||
const w = canvasW;
|
||||
const h = canvasW / imgAspect;
|
||||
return { x: 0, y: (canvasH - h) / 2, w, h };
|
||||
}
|
||||
const h = canvasH;
|
||||
const w = canvasH * imgAspect;
|
||||
return { x: (canvasW - w) / 2, y: 0, w, h };
|
||||
}
|
||||
export function canvasToNormalized(cx, cy, canvasW, canvasH, imgW, imgH) {
|
||||
const rect = getContainRect(canvasW, canvasH, imgW, imgH);
|
||||
if (cx < rect.x ||
|
||||
cx > rect.x + rect.w ||
|
||||
cy < rect.y ||
|
||||
cy > rect.y + rect.h) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
x: (cx - rect.x) / rect.w,
|
||||
y: (cy - rect.y) / rect.h,
|
||||
};
|
||||
}
|
||||
/** Skia matrix: pan → scale around viewport center. Matches screenToCanvasCoords. */
|
||||
export function buildZoomPanMatrix(panX, panY, scale, canvasW, canvasH) {
|
||||
const cx = canvasW / 2;
|
||||
const cy = canvasH / 2;
|
||||
const m = Skia.Matrix();
|
||||
m.translate(panX, panY);
|
||||
m.translate(cx, cy);
|
||||
m.scale(scale, scale);
|
||||
m.translate(-cx, -cy);
|
||||
return m;
|
||||
}
|
||||
/** Clamp pan so scaled containRect does not expose empty margins beyond the viewport. */
|
||||
export function clampPanOffset(pan, scale, canvasW, canvasH, containRect) {
|
||||
if (!containRect || scale <= 1 || canvasW <= 0 || canvasH <= 0) {
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
const cx = canvasW / 2;
|
||||
const cy = canvasH / 2;
|
||||
const r = containRect;
|
||||
const scaledMinX = cx + scale * (r.x - cx);
|
||||
const scaledMaxX = cx + scale * (r.x + r.w - cx);
|
||||
const scaledMinY = cy + scale * (r.y - cy);
|
||||
const scaledMaxY = cy + scale * (r.y + r.h - cy);
|
||||
let x = pan.x;
|
||||
let y = pan.y;
|
||||
if (scaledMaxX - scaledMinX > canvasW) {
|
||||
x = Math.max(canvasW - scaledMaxX, Math.min(-scaledMinX, x));
|
||||
}
|
||||
else {
|
||||
x = 0;
|
||||
}
|
||||
if (scaledMaxY - scaledMinY > canvasH) {
|
||||
y = Math.max(canvasH - scaledMaxY, Math.min(-scaledMinY, y));
|
||||
}
|
||||
else {
|
||||
y = 0;
|
||||
}
|
||||
return { x, y };
|
||||
}
|
||||
/**
|
||||
* Inverse of the Skia Group transform applied during pinch-zoom.
|
||||
* Converts a raw touch point (screen pixels) back to the canvas coordinate
|
||||
* space where the image and regions are positioned before any scale/pan.
|
||||
* When zoomScale ≤ 1 (no zoom), returns the input unchanged.
|
||||
*/
|
||||
export function screenToCanvasCoords(screenX, screenY, canvasW, canvasH, zoomScale, panOffset) {
|
||||
if (zoomScale <= 1)
|
||||
return { x: screenX, y: screenY };
|
||||
return {
|
||||
x: (screenX - panOffset.x - canvasW / 2) / zoomScale + canvasW / 2,
|
||||
y: (screenY - panOffset.y - canvasH / 2) / zoomScale + canvasH / 2,
|
||||
};
|
||||
}
|
||||
/* ==========================================================================
|
||||
* 点击检测
|
||||
* ========================================================================== */
|
||||
export function pointInPolygon(x, y, points) {
|
||||
let inside = false;
|
||||
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
|
||||
const xi = points[i].x;
|
||||
const yi = points[i].y;
|
||||
const xj = points[j].x;
|
||||
const yj = points[j].y;
|
||||
const intersect = yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
|
||||
if (intersect)
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
export function pointInPolygonWithPadding(x, y, points, padding) {
|
||||
if (points.length < 3) {
|
||||
return false;
|
||||
}
|
||||
let minX = points[0].x;
|
||||
let maxX = points[0].x;
|
||||
let minY = points[0].y;
|
||||
let maxY = points[0].y;
|
||||
for (const point of points) {
|
||||
minX = Math.min(minX, point.x);
|
||||
maxX = Math.max(maxX, point.x);
|
||||
minY = Math.min(minY, point.y);
|
||||
maxY = Math.max(maxY, point.y);
|
||||
}
|
||||
if (x >= minX - padding &&
|
||||
x <= maxX + padding &&
|
||||
y >= minY - padding &&
|
||||
y <= maxY + padding) {
|
||||
if (maxY - minY < padding * 2.5 || maxX - minX < padding * 2.5) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return pointInPolygon(x, y, points);
|
||||
}
|
||||
export function getRegionHitPolygons(reg) {
|
||||
return reg.hitPolygons && reg.hitPolygons.length > 0
|
||||
? reg.hitPolygons
|
||||
: reg.polygons;
|
||||
}
|
||||
export function pointHitsRegion(x, y, reg, options) {
|
||||
const interaction = getMaskSegmentRuntimeConfig().interaction;
|
||||
const thinPadding = options?.thinPadding ?? interaction.thinStripPadding;
|
||||
const padding = reg.thinStrip ? thinPadding : interaction.regionPadding;
|
||||
return getRegionHitPolygons(reg).some(poly => poly.length >= 3 && pointInPolygonWithPadding(x, y, poly, padding));
|
||||
}
|
||||
export function pointStrictlyHitsRegion(x, y, reg) {
|
||||
return getRegionHitPolygons(reg).some(poly => poly.length >= 3 && pointInPolygon(x, y, poly));
|
||||
}
|
||||
export function resolveRegionHit(regions, x, y) {
|
||||
const hits = [];
|
||||
for (const reg of regions) {
|
||||
const bboxPad = reg.thinStrip ? 0.005 : 0;
|
||||
const b = reg.bbox;
|
||||
if (x < b.x - bboxPad ||
|
||||
x > b.x + b.w + bboxPad ||
|
||||
y < b.y - bboxPad ||
|
||||
y > b.y + b.h + bboxPad) {
|
||||
continue;
|
||||
}
|
||||
if (pointHitsRegion(x, y, reg)) {
|
||||
hits.push(reg);
|
||||
}
|
||||
}
|
||||
if (hits.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (hits.length === 1) {
|
||||
return hits[0].id;
|
||||
}
|
||||
const strictNonThin = hits.filter(reg => !reg.thinStrip && pointStrictlyHitsRegion(x, y, reg));
|
||||
if (strictNonThin.length > 0) {
|
||||
strictNonThin.sort((a, b) => a.area - b.area);
|
||||
return strictNonThin[0].id;
|
||||
}
|
||||
const strictThin = hits.filter(reg => reg.thinStrip && pointStrictlyHitsRegion(x, y, reg));
|
||||
if (strictThin.length > 0) {
|
||||
strictThin.sort((a, b) => a.area - b.area);
|
||||
return strictThin[0].id;
|
||||
}
|
||||
const nonThin = hits.filter(reg => !reg.thinStrip);
|
||||
if (nonThin.length > 0) {
|
||||
nonThin.sort((a, b) => a.area - b.area);
|
||||
return nonThin[0].id;
|
||||
}
|
||||
hits.sort((a, b) => a.area - b.area);
|
||||
return hits[0].id;
|
||||
}
|
||||
/* ==========================================================================
|
||||
* 踢脚线/掩码拾取
|
||||
* ========================================================================== */
|
||||
export function pickKickRegionFromMask(normX, normY, pick, kickRegionId, baseboardPickMask, strict = false) {
|
||||
const cx = Math.floor(normX * pick.cols);
|
||||
const cy = Math.floor(normY * pick.rows);
|
||||
if (cx < 0 || cy < 0 || cx >= pick.cols || cy >= pick.rows) {
|
||||
return null;
|
||||
}
|
||||
if (strict) {
|
||||
if (baseboardPickMask) {
|
||||
return baseboardPickMask[cy * pick.cols + cx] ? kickRegionId : null;
|
||||
}
|
||||
return isBaseboardMaskPixel(pick.buffer, pick.cols, pick.rows, cx, cy)
|
||||
? kickRegionId
|
||||
: null;
|
||||
}
|
||||
const interaction = getMaskSegmentRuntimeConfig().interaction;
|
||||
const radius = Math.max(interaction.kickMaskPickRadiusPx, Math.floor(pick.cols * 0.022));
|
||||
const radiusSq = radius * radius;
|
||||
for (let dy = -radius; dy <= radius; dy++) {
|
||||
for (let dx = -radius; dx <= radius; dx++) {
|
||||
if (dx * dx + dy * dy > radiusSq) {
|
||||
continue;
|
||||
}
|
||||
const x = cx + dx;
|
||||
const y = cy + dy;
|
||||
if (x < 0 || y < 0 || x >= pick.cols || y >= pick.rows) {
|
||||
continue;
|
||||
}
|
||||
if (baseboardPickMask) {
|
||||
if (baseboardPickMask[y * pick.cols + x]) {
|
||||
return kickRegionId;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (isBaseboardMaskPixel(pick.buffer, pick.cols, pick.rows, x, y)) {
|
||||
return kickRegionId;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
export function pickKickNearStrip(normX, normY, kickReg) {
|
||||
const polys = kickReg.hitPolygons ?? kickReg.polygons;
|
||||
const pad = getMaskSegmentRuntimeConfig().interaction.thinStripPadding + 0.004;
|
||||
return polys.some(poly => poly.length >= 3 && pointInPolygonWithPadding(normX, normY, poly, pad));
|
||||
}
|
||||
export function lookupRegionFromPickMap(normX, normY, pick, radiusPx = getMaskSegmentRuntimeConfig().interaction.pickMapSearchRadiusPx) {
|
||||
const cx = Math.min(pick.cols - 1, Math.max(0, Math.floor(normX * pick.cols)));
|
||||
const cy = Math.min(pick.rows - 1, Math.max(0, Math.floor(normY * pick.rows)));
|
||||
const readCode = (x, y) => pick.buffer[y * pick.cols + x];
|
||||
const center = readCode(cx, cy);
|
||||
if (center > 0) {
|
||||
return center - 1;
|
||||
}
|
||||
if (radiusPx <= 0) {
|
||||
return null;
|
||||
}
|
||||
const r = Math.max(4, radiusPx);
|
||||
const rSq = r * r;
|
||||
for (let dy = -r; dy <= r; dy++) {
|
||||
for (let dx = -r; dx <= r; dx++) {
|
||||
if (dx * dx + dy * dy > rSq) {
|
||||
continue;
|
||||
}
|
||||
const x = cx + dx;
|
||||
const y = cy + dy;
|
||||
if (x < 0 || y < 0 || x >= pick.cols || y >= pick.rows) {
|
||||
continue;
|
||||
}
|
||||
const code = readCode(x, y);
|
||||
if (code > 0) {
|
||||
return code - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
/* ==========================================================================
|
||||
* 资源释放
|
||||
* ========================================================================== */
|
||||
export function releasePaintResourceLayers(layers) {
|
||||
if (!layers) {
|
||||
return;
|
||||
}
|
||||
layers.lowFreqImage.dispose();
|
||||
layers.highFreqImage.dispose();
|
||||
}
|
||||
export function releaseOriginSkImage(image) {
|
||||
if (image) {
|
||||
image.dispose();
|
||||
}
|
||||
}
|
||||
/* ==========================================================================
|
||||
* 工作缓冲区缩放
|
||||
* ========================================================================== */
|
||||
export async function prepareWorkScaledBgrBuffer(bgrBuffer, cols, rows, workScale) {
|
||||
if (workScale >= 1) {
|
||||
return { buffer: bgrBuffer, cols, rows };
|
||||
}
|
||||
const workCols = Math.floor(cols * workScale);
|
||||
const workRows = Math.floor(rows * workScale);
|
||||
const buffer = resizeBgrBuffer(bgrBuffer, cols, rows, workCols, workRows);
|
||||
return { buffer, cols: workCols, rows: workRows };
|
||||
}
|
||||
/* ==========================================================================
|
||||
* 分段计时工具(仅开发环境生效)
|
||||
* ========================================================================== */
|
||||
let _timeLogTs = 0;
|
||||
export function timeLog(tag) {
|
||||
if (!__DEV__)
|
||||
return;
|
||||
const now = performance.now();
|
||||
const dt = _timeLogTs ? now - _timeLogTs : 0;
|
||||
console.log(`[⏱ ${tag}] ${dt.toFixed(2)} ms`);
|
||||
_timeLogTs = now;
|
||||
}
|
||||
//# sourceMappingURL=canvasGeometry.js.map
|
||||
1
dist/utils/canvasGeometry.js.map
vendored
Normal file
1
dist/utils/canvasGeometry.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
18
dist/utils/maskOutlinePaths.d.ts
vendored
Normal file
18
dist/utils/maskOutlinePaths.d.ts
vendored
Normal file
@ -0,0 +1,18 @@
|
||||
import { type SkPath } from '@shopify/react-native-skia';
|
||||
import type { SegmentRegion, RegionMaskData } from './maskSegmentation';
|
||||
export declare function buildRegionOutlinePathForRegion(regionId: number, regions: SegmentRegion[], maskData: RegionMaskData, rect: {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}, normSeed?: {
|
||||
x: number;
|
||||
y: number;
|
||||
}): SkPath;
|
||||
export declare function buildAllRegionOutlinePaths(regions: SegmentRegion[], maskData: RegionMaskData, rect: {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}): Map<number, SkPath>;
|
||||
//# sourceMappingURL=maskOutlinePaths.d.ts.map
|
||||
1
dist/utils/maskOutlinePaths.d.ts.map
vendored
Normal file
1
dist/utils/maskOutlinePaths.d.ts.map
vendored
Normal file
@ -0,0 +1 @@
|
||||
{"version":3,"file":"maskOutlinePaths.d.ts","sourceRoot":"","sources":["../../src/utils/maskOutlinePaths.ts"],"names":[],"mappings":"AAAA,OAAO,EAAQ,KAAK,MAAM,EAAE,MAAM,4BAA4B,CAAC;AAE/D,OAAO,KAAK,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAqcxE,wBAAgB,+BAA+B,CAC7C,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,aAAa,EAAE,EACxB,QAAQ,EAAE,cAAc,EACxB,IAAI,EAAE;IAAE,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAA;CAAE,EACpD,QAAQ,CAAC,EAAE;IAAE,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAA;CAAE,GAClC,MAAM,CAUR;AA4DD,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,aAAa,EAAE,EACxB,QAAQ,EAAE,cAAc,EACxB,IAAI,EAAE;IAAE,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAA;CAAE,GACnD,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAoBrB"}
|
||||
419
dist/utils/maskOutlinePaths.js
vendored
Normal file
419
dist/utils/maskOutlinePaths.js
vendored
Normal file
@ -0,0 +1,419 @@
|
||||
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
|
||||
1
dist/utils/maskOutlinePaths.js.map
vendored
Normal file
1
dist/utils/maskOutlinePaths.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
2
dist/utils/maskSegmentRuntime.d.ts.map
vendored
2
dist/utils/maskSegmentRuntime.d.ts.map
vendored
@ -1 +1 @@
|
||||
{"version":3,"file":"maskSegmentRuntime.d.ts","sourceRoot":"","sources":["../../src/utils/maskSegmentRuntime.ts"],"names":[],"mappings":"AAAA,OAAO,EAKL,KAAK,iBAAiB,EACvB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,KAAK,EACV,iBAAiB,EACjB,iBAAiB,EACjB,WAAW,EACX,cAAc,EACd,cAAc,EAEf,MAAM,uCAAuC,CAAC;AAC/C,WAAW;AACX,eAAO,MAAM,aAAa,EAAE,QAAQ,CAAC,cAAc,CAQlD,CAAC;AAEF,aAAa;AACb,eAAO,MAAM,eAAe,EAAE,QAAQ,CAAC,cAAc,CAQpD,CAAC;AAEF,UAAU;AACV,eAAO,MAAM,YAAY,EAAE,QAAQ,CAAC,cAAc,CAQjD,CAAC;AACF,eAAO,MAAM,gBAAgB,EAAE,MAAM,CAAC,cAAc,EAAE,QAAQ,CAAC,cAAc,CAAC,CAI7E,CAAC;AAEF,eAAO,MAAM,uBAAuB,EAAE,QAAQ,CAAC,cAAc,CAAiB,CAAC;AAE/E,wBAAgB,qBAAqB,CACnC,MAAM,CAAC,EAAE,cAAc,EACvB,SAAS,CAAC,EAAE,cAAc,GACzB,QAAQ,CAAC,cAAc,CAAC,CAI1B;AAWD,eAAO,MAAM,oBAAoB,EAAE,QAAQ,CAAC,WAAW,CAkBtD,CAAC;AAEF,eAAO,MAAM,0BAA0B,EAAE,QAAQ,CAAC,iBAAiB,CAOlE,CAAC;AAEF,eAAO,MAAM,mBAAmB,EAAE,QAAQ,CACxC,IAAI,CACF,iBAAiB,EACf,yBAAyB,GACzB,eAAe,GACf,kBAAkB,GAClB,wBAAwB,CAC3B,CACF,GAAG;IACF,uBAAuB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACrC,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC3B,gBAAgB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC9B,sBAAsB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACpC,cAAc,EAAE,iBAAiB,EAAE,CAAC;CAwBrC,CAAC;AAEF,MAAM,MAAM,0BAA0B,GAAG;IACvC,QAAQ,EAAE,QAAQ,CAAC,cAAc,CAAC,CAAC;IACnC,IAAI,EAAE,OAAO,mBAAmB,CAAC;IACjC,KAAK,EAAE,QAAQ,CAAC,WAAW,CAAC,CAAC;IAC7B,WAAW,EAAE,QAAQ,CAAC,iBAAiB,CAAC,CAAC;CAC1C,CAAC;AAMF,wBAAgB,eAAe,CAC7B,OAAO,CAAC,EAAE,iBAAiB,GAC1B,OAAO,mBAAmB,CA0D5B;AAED,wBAAgB,mBAAmB,CAAC,KAAK,CAAC,EAAE;IAC1C,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC,UAAU,CAAC,EAAE,iBAAiB,CAAC;IAC/B,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,iBAAiB,CAAC,EAAE,iBAAiB,CAAC;CACvC,GAAG,0BAA0B,CAiB7B;AAKD,wBAAgB,sBAAsB,IAAI,MAAM,CAE/C;AAED,wBAAgB,2BAA2B,CAAC,KAAK,CAAC,EAAE;IAClD,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC,UAAU,CAAC,EAAE,iBAAiB,CAAC;IAC/B,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,iBAAiB,CAAC,EAAE,iBAAiB,CAAC;CACvC,GAAG,0BAA0B,CAiB7B;AAED,wBAAgB,2BAA2B,IAAI,0BAA0B,CAExE;AAED,wBAAgB,6BAA6B,IAAI,0BAA0B,CAI1E"}
|
||||
{"version":3,"file":"maskSegmentRuntime.d.ts","sourceRoot":"","sources":["../../src/utils/maskSegmentRuntime.ts"],"names":[],"mappings":"AAAA,OAAO,EAKL,KAAK,iBAAiB,EACvB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,KAAK,EACV,iBAAiB,EACjB,iBAAiB,EACjB,WAAW,EACX,cAAc,EACd,cAAc,EAEf,MAAM,uCAAuC,CAAC;AAC/C,WAAW;AACX,eAAO,MAAM,aAAa,EAAE,QAAQ,CAAC,cAAc,CAQlD,CAAC;AAEF,aAAa;AACb,eAAO,MAAM,eAAe,EAAE,QAAQ,CAAC,cAAc,CAQpD,CAAC;AAEF,UAAU;AACV,eAAO,MAAM,YAAY,EAAE,QAAQ,CAAC,cAAc,CAQjD,CAAC;AACF,eAAO,MAAM,gBAAgB,EAAE,MAAM,CAAC,cAAc,EAAE,QAAQ,CAAC,cAAc,CAAC,CAI7E,CAAC;AAEF,eAAO,MAAM,uBAAuB,EAAE,QAAQ,CAAC,cAAc,CAAmB,CAAC;AAEjF,wBAAgB,qBAAqB,CACnC,MAAM,CAAC,EAAE,cAAc,EACvB,SAAS,CAAC,EAAE,cAAc,GACzB,QAAQ,CAAC,cAAc,CAAC,CAI1B;AAWD,eAAO,MAAM,oBAAoB,EAAE,QAAQ,CAAC,WAAW,CAkBtD,CAAC;AAEF,eAAO,MAAM,0BAA0B,EAAE,QAAQ,CAAC,iBAAiB,CAOlE,CAAC;AAEF,eAAO,MAAM,mBAAmB,EAAE,QAAQ,CACxC,IAAI,CACF,iBAAiB,EACf,yBAAyB,GACzB,eAAe,GACf,kBAAkB,GAClB,wBAAwB,CAC3B,CACF,GAAG;IACF,uBAAuB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACrC,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC3B,gBAAgB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC9B,sBAAsB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACpC,cAAc,EAAE,iBAAiB,EAAE,CAAC;CAwBrC,CAAC;AAEF,MAAM,MAAM,0BAA0B,GAAG;IACvC,QAAQ,EAAE,QAAQ,CAAC,cAAc,CAAC,CAAC;IACnC,IAAI,EAAE,OAAO,mBAAmB,CAAC;IACjC,KAAK,EAAE,QAAQ,CAAC,WAAW,CAAC,CAAC;IAC7B,WAAW,EAAE,QAAQ,CAAC,iBAAiB,CAAC,CAAC;CAC1C,CAAC;AAMF,wBAAgB,eAAe,CAC7B,OAAO,CAAC,EAAE,iBAAiB,GAC1B,OAAO,mBAAmB,CA0D5B;AAED,wBAAgB,mBAAmB,CAAC,KAAK,CAAC,EAAE;IAC1C,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC,UAAU,CAAC,EAAE,iBAAiB,CAAC;IAC/B,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,iBAAiB,CAAC,EAAE,iBAAiB,CAAC;CACvC,GAAG,0BAA0B,CAiB7B;AAKD,wBAAgB,sBAAsB,IAAI,MAAM,CAE/C;AAED,wBAAgB,2BAA2B,CAAC,KAAK,CAAC,EAAE;IAClD,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC,UAAU,CAAC,EAAE,iBAAiB,CAAC;IAC/B,WAAW,CAAC,EAAE,WAAW,CAAC;IAC1B,iBAAiB,CAAC,EAAE,iBAAiB,CAAC;CACvC,GAAG,0BAA0B,CAiB7B;AAED,wBAAgB,2BAA2B,IAAI,0BAA0B,CAExE;AAED,wBAAgB,6BAA6B,IAAI,0BAA0B,CAI1E"}
|
||||
2
dist/utils/maskSegmentRuntime.js
vendored
2
dist/utils/maskSegmentRuntime.js
vendored
@ -34,7 +34,7 @@ export const PIPELINE_PRESETS = {
|
||||
medium: PIPELINE_MEDIUM,
|
||||
low: PIPELINE_LOW,
|
||||
};
|
||||
export const DEFAULT_PIPELINE_CONFIG = PIPELINE_HIGH;
|
||||
export const DEFAULT_PIPELINE_CONFIG = PIPELINE_MEDIUM;
|
||||
export function resolvePipelineConfig(preset, overrides) {
|
||||
const base = preset != null ? PIPELINE_PRESETS[preset] : DEFAULT_PIPELINE_CONFIG;
|
||||
return { ...base, ...overrides };
|
||||
|
||||
2
dist/utils/maskSegmentRuntime.js.map
vendored
2
dist/utils/maskSegmentRuntime.js.map
vendored
File diff suppressed because one or more lines are too long
17
dist/utils/maskSegmentation.d.ts
vendored
17
dist/utils/maskSegmentation.d.ts
vendored
@ -55,21 +55,8 @@ export type SegmentRegion = {
|
||||
thinStrip?: boolean;
|
||||
};
|
||||
export declare function buildRegionOutlinePolygons(reg: SegmentRegion): NormPoint[][];
|
||||
export declare function buildRegionOutlinePathForRegion(regionId: number, regions: SegmentRegion[], maskData: RegionMaskData, rect: {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}, normSeed?: {
|
||||
x: number;
|
||||
y: number;
|
||||
}): SkPath;
|
||||
export declare function buildAllRegionOutlinePaths(regions: SegmentRegion[], maskData: RegionMaskData, rect: {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}): Map<number, SkPath>;
|
||||
import { buildAllRegionOutlinePaths, buildRegionOutlinePathForRegion } from './maskOutlinePaths';
|
||||
export { buildAllRegionOutlinePaths, buildRegionOutlinePathForRegion };
|
||||
/** 从二值图逐行条带构建蒙版(供 Skia PathBuilder 使用) */
|
||||
export declare function appendMaskBinaryToPathBuilder(binary: Uint8Array, cols: number, rows: number, rect: {
|
||||
x: number;
|
||||
|
||||
2
dist/utils/maskSegmentation.d.ts.map
vendored
2
dist/utils/maskSegmentation.d.ts.map
vendored
@ -1 +1 @@
|
||||
{"version":3,"file":"maskSegmentation.d.ts","sourceRoot":"","sources":["../../src/utils/maskSegmentation.ts"],"names":[],"mappings":"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"}
|
||||
{"version":3,"file":"maskSegmentation.d.ts","sourceRoot":"","sources":["../../src/utils/maskSegmentation.ts"],"names":[],"mappings":"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"}
|
||||
419
dist/utils/maskSegmentation.js
vendored
419
dist/utils/maskSegmentation.js
vendored
@ -24,422 +24,9 @@ export function buildRegionOutlinePolygons(reg) {
|
||||
}
|
||||
return [bboxToPolygon(reg.bbox)];
|
||||
}
|
||||
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;
|
||||
}
|
||||
import { buildAllRegionOutlinePaths, buildRegionOutlinePathForRegion } from './maskOutlinePaths';
|
||||
// Re-export for backward compatibility
|
||||
export { buildAllRegionOutlinePaths, buildRegionOutlinePathForRegion };
|
||||
function isBaseboardEntry(entry) {
|
||||
return entry.name === BASEBOARD_SEMANTIC_NAME;
|
||||
}
|
||||
|
||||
2
dist/utils/maskSegmentation.js.map
vendored
2
dist/utils/maskSegmentation.js.map
vendored
File diff suppressed because one or more lines are too long
@ -19,7 +19,6 @@ import {
|
||||
buildRegionOutlinePathForRegion,
|
||||
downsampleMaskDataForPaths,
|
||||
extractRegionsFromMaskBufferSync,
|
||||
isBaseboardMaskPixel,
|
||||
upscaleBinaryMask,
|
||||
type RegionMaskData,
|
||||
type SegmentRegion,
|
||||
@ -85,458 +84,31 @@ export type {
|
||||
MaskSemanticColor,
|
||||
} from './MaskSegmentCanvas.types';
|
||||
|
||||
/* ==========================================================================
|
||||
* 类型
|
||||
* ========================================================================== */
|
||||
type PaintResourceLayers = {
|
||||
lowFreqImage: SkImage;
|
||||
highFreqImage: SkImage;
|
||||
};
|
||||
|
||||
type ContainRect = {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
};
|
||||
|
||||
function bgrColorEquals(a: BgrColor, b: BgrColor): boolean {
|
||||
return a.b === b.b && a.g === b.g && a.r === b.r;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
* 几何工具
|
||||
* ========================================================================== */
|
||||
function rectsEqual(a: ContainRect, b: ContainRect): boolean {
|
||||
return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;
|
||||
}
|
||||
|
||||
function getContainRect(
|
||||
canvasW: number,
|
||||
canvasH: number,
|
||||
imgW: number,
|
||||
imgH: number,
|
||||
): ContainRect {
|
||||
const imgAspect = imgW / imgH;
|
||||
const canvasAspect = canvasW / canvasH;
|
||||
|
||||
if (imgAspect > canvasAspect) {
|
||||
const w = canvasW;
|
||||
const h = canvasW / imgAspect;
|
||||
return { x: 0, y: (canvasH - h) / 2, w, h };
|
||||
}
|
||||
|
||||
const h = canvasH;
|
||||
const w = canvasH * imgAspect;
|
||||
return { x: (canvasW - w) / 2, y: 0, w, h };
|
||||
}
|
||||
|
||||
function canvasToNormalized(
|
||||
cx: number,
|
||||
cy: number,
|
||||
canvasW: number,
|
||||
canvasH: number,
|
||||
imgW: number,
|
||||
imgH: number,
|
||||
): { x: number; y: number } | null {
|
||||
const rect = getContainRect(canvasW, canvasH, imgW, imgH);
|
||||
if (
|
||||
cx < rect.x ||
|
||||
cx > rect.x + rect.w ||
|
||||
cy < rect.y ||
|
||||
cy > rect.y + rect.h
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
x: (cx - rect.x) / rect.w,
|
||||
y: (cy - rect.y) / rect.h,
|
||||
};
|
||||
}
|
||||
|
||||
/** Skia matrix: pan → scale around viewport center. Matches screenToCanvasCoords. */
|
||||
function buildZoomPanMatrix(
|
||||
panX: number,
|
||||
panY: number,
|
||||
scale: number,
|
||||
canvasW: number,
|
||||
canvasH: number,
|
||||
) {
|
||||
const cx = canvasW / 2;
|
||||
const cy = canvasH / 2;
|
||||
const m = Skia.Matrix();
|
||||
m.translate(panX, panY);
|
||||
m.translate(cx, cy);
|
||||
m.scale(scale, scale);
|
||||
m.translate(-cx, -cy);
|
||||
return m;
|
||||
}
|
||||
|
||||
/** Clamp pan so scaled containRect does not expose empty margins beyond the viewport. */
|
||||
function clampPanOffset(
|
||||
pan: { x: number; y: number },
|
||||
scale: number,
|
||||
canvasW: number,
|
||||
canvasH: number,
|
||||
containRect: ContainRect | null,
|
||||
): { x: number; y: number } {
|
||||
if (!containRect || scale <= 1 || canvasW <= 0 || canvasH <= 0) {
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
const cx = canvasW / 2;
|
||||
const cy = canvasH / 2;
|
||||
const r = containRect;
|
||||
|
||||
const scaledMinX = cx + scale * (r.x - cx);
|
||||
const scaledMaxX = cx + scale * (r.x + r.w - cx);
|
||||
const scaledMinY = cy + scale * (r.y - cy);
|
||||
const scaledMaxY = cy + scale * (r.y + r.h - cy);
|
||||
|
||||
let x = pan.x;
|
||||
let y = pan.y;
|
||||
|
||||
if (scaledMaxX - scaledMinX > canvasW) {
|
||||
x = Math.max(canvasW - scaledMaxX, Math.min(-scaledMinX, x));
|
||||
} else {
|
||||
x = 0;
|
||||
}
|
||||
|
||||
if (scaledMaxY - scaledMinY > canvasH) {
|
||||
y = Math.max(canvasH - scaledMaxY, Math.min(-scaledMinY, y));
|
||||
} else {
|
||||
y = 0;
|
||||
}
|
||||
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of the Skia Group transform applied during pinch-zoom.
|
||||
* Converts a raw touch point (screen pixels) back to the canvas coordinate
|
||||
* space where the image and regions are positioned before any scale/pan.
|
||||
* When zoomScale ≤ 1 (no zoom), returns the input unchanged.
|
||||
*/
|
||||
function screenToCanvasCoords(
|
||||
screenX: number,
|
||||
screenY: number,
|
||||
canvasW: number,
|
||||
canvasH: number,
|
||||
zoomScale: number,
|
||||
panOffset: { x: number; y: number },
|
||||
): { x: number; y: number } {
|
||||
if (zoomScale <= 1) return { x: screenX, y: screenY };
|
||||
return {
|
||||
x: (screenX - panOffset.x - canvasW / 2) / zoomScale + canvasW / 2,
|
||||
y: (screenY - panOffset.y - canvasH / 2) / zoomScale + canvasH / 2,
|
||||
};
|
||||
}
|
||||
|
||||
function pointInPolygon(
|
||||
x: number,
|
||||
y: number,
|
||||
points: { x: number; y: number }[],
|
||||
): boolean {
|
||||
let inside = false;
|
||||
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
|
||||
const xi = points[i].x;
|
||||
const yi = points[i].y;
|
||||
const xj = points[j].x;
|
||||
const yj = points[j].y;
|
||||
const intersect =
|
||||
yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
|
||||
if (intersect) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
function pointInPolygonWithPadding(
|
||||
x: number,
|
||||
y: number,
|
||||
points: { x: number; y: number }[],
|
||||
padding: number,
|
||||
): boolean {
|
||||
if (points.length < 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let minX = points[0].x;
|
||||
let maxX = points[0].x;
|
||||
let minY = points[0].y;
|
||||
let maxY = points[0].y;
|
||||
for (const point of points) {
|
||||
minX = Math.min(minX, point.x);
|
||||
maxX = Math.max(maxX, point.x);
|
||||
minY = Math.min(minY, point.y);
|
||||
maxY = Math.max(maxY, point.y);
|
||||
}
|
||||
|
||||
if (
|
||||
x >= minX - padding &&
|
||||
x <= maxX + padding &&
|
||||
y >= minY - padding &&
|
||||
y <= maxY + padding
|
||||
) {
|
||||
if (maxY - minY < padding * 2.5 || maxX - minX < padding * 2.5) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return pointInPolygon(x, y, points);
|
||||
}
|
||||
|
||||
function getRegionHitPolygons(reg: SegmentRegion): { x: number; y: number }[][] {
|
||||
return reg.hitPolygons && reg.hitPolygons.length > 0
|
||||
? reg.hitPolygons
|
||||
: reg.polygons;
|
||||
}
|
||||
|
||||
function pointHitsRegion(
|
||||
x: number,
|
||||
y: number,
|
||||
reg: SegmentRegion,
|
||||
options?: { thinPadding?: number },
|
||||
): boolean {
|
||||
const interaction = getMaskSegmentRuntimeConfig().interaction;
|
||||
const thinPadding = options?.thinPadding ?? interaction.thinStripPadding;
|
||||
const padding = reg.thinStrip ? thinPadding : interaction.regionPadding;
|
||||
return getRegionHitPolygons(reg).some(
|
||||
poly => poly.length >= 3 && pointInPolygonWithPadding(x, y, poly, padding),
|
||||
);
|
||||
}
|
||||
|
||||
function pointStrictlyHitsRegion(x: number, y: number, reg: SegmentRegion): boolean {
|
||||
return getRegionHitPolygons(reg).some(
|
||||
poly => poly.length >= 3 && pointInPolygon(x, y, poly),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveRegionHit(
|
||||
regions: SegmentRegion[],
|
||||
x: number,
|
||||
y: number,
|
||||
): number | null {
|
||||
const hits: SegmentRegion[] = [];
|
||||
|
||||
for (const reg of regions) {
|
||||
const bboxPad = reg.thinStrip ? 0.005 : 0;
|
||||
const b = reg.bbox;
|
||||
if (
|
||||
x < b.x - bboxPad ||
|
||||
x > b.x + b.w + bboxPad ||
|
||||
y < b.y - bboxPad ||
|
||||
y > b.y + b.h + bboxPad
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (pointHitsRegion(x, y, reg)) {
|
||||
hits.push(reg);
|
||||
}
|
||||
}
|
||||
|
||||
if (hits.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (hits.length === 1) {
|
||||
return hits[0].id;
|
||||
}
|
||||
|
||||
const strictNonThin = hits.filter(
|
||||
reg => !reg.thinStrip && pointStrictlyHitsRegion(x, y, reg),
|
||||
);
|
||||
if (strictNonThin.length > 0) {
|
||||
strictNonThin.sort((a, b) => a.area - b.area);
|
||||
return strictNonThin[0].id;
|
||||
}
|
||||
|
||||
const strictThin = hits.filter(
|
||||
reg => reg.thinStrip && pointStrictlyHitsRegion(x, y, reg),
|
||||
);
|
||||
if (strictThin.length > 0) {
|
||||
strictThin.sort((a, b) => a.area - b.area);
|
||||
return strictThin[0].id;
|
||||
}
|
||||
|
||||
const nonThin = hits.filter(reg => !reg.thinStrip);
|
||||
if (nonThin.length > 0) {
|
||||
nonThin.sort((a, b) => a.area - b.area);
|
||||
return nonThin[0].id;
|
||||
}
|
||||
|
||||
hits.sort((a, b) => a.area - b.area);
|
||||
return hits[0].id;
|
||||
}
|
||||
|
||||
function pickKickRegionFromMask(
|
||||
normX: number,
|
||||
normY: number,
|
||||
pick: { buffer: Uint8Array; cols: number; rows: number },
|
||||
kickRegionId: number,
|
||||
baseboardPickMask?: Uint8Array | null,
|
||||
strict = false,
|
||||
): number | null {
|
||||
const cx = Math.floor(normX * pick.cols);
|
||||
const cy = Math.floor(normY * pick.rows);
|
||||
if (cx < 0 || cy < 0 || cx >= pick.cols || cy >= pick.rows) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (strict) {
|
||||
if (baseboardPickMask) {
|
||||
return baseboardPickMask[cy * pick.cols + cx] ? kickRegionId : null;
|
||||
}
|
||||
return isBaseboardMaskPixel(pick.buffer, pick.cols, pick.rows, cx, cy)
|
||||
? kickRegionId
|
||||
: null;
|
||||
}
|
||||
|
||||
const interaction = getMaskSegmentRuntimeConfig().interaction;
|
||||
const radius = Math.max(
|
||||
interaction.kickMaskPickRadiusPx,
|
||||
Math.floor(pick.cols * 0.022),
|
||||
);
|
||||
const radiusSq = radius * radius;
|
||||
|
||||
for (let dy = -radius; dy <= radius; dy++) {
|
||||
for (let dx = -radius; dx <= radius; dx++) {
|
||||
if (dx * dx + dy * dy > radiusSq) {
|
||||
continue;
|
||||
}
|
||||
const x = cx + dx;
|
||||
const y = cy + dy;
|
||||
if (x < 0 || y < 0 || x >= pick.cols || y >= pick.rows) {
|
||||
continue;
|
||||
}
|
||||
if (baseboardPickMask) {
|
||||
if (baseboardPickMask[y * pick.cols + x]) {
|
||||
return kickRegionId;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
isBaseboardMaskPixel(
|
||||
pick.buffer,
|
||||
pick.cols,
|
||||
pick.rows,
|
||||
x,
|
||||
y,
|
||||
)
|
||||
) {
|
||||
return kickRegionId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickKickNearStrip(
|
||||
normX: number,
|
||||
normY: number,
|
||||
kickReg: SegmentRegion,
|
||||
): boolean {
|
||||
const polys = kickReg.hitPolygons ?? kickReg.polygons;
|
||||
const pad = getMaskSegmentRuntimeConfig().interaction.thinStripPadding + 0.004;
|
||||
return polys.some(
|
||||
poly =>
|
||||
poly.length >= 3 && pointInPolygonWithPadding(normX, normY, poly, pad),
|
||||
);
|
||||
}
|
||||
|
||||
function lookupRegionFromPickMap(
|
||||
normX: number,
|
||||
normY: number,
|
||||
pick: { buffer: Uint8Array; cols: number; rows: number },
|
||||
radiusPx = getMaskSegmentRuntimeConfig().interaction.pickMapSearchRadiusPx,
|
||||
): number | null {
|
||||
const cx = Math.min(
|
||||
pick.cols - 1,
|
||||
Math.max(0, Math.floor(normX * pick.cols)),
|
||||
);
|
||||
const cy = Math.min(
|
||||
pick.rows - 1,
|
||||
Math.max(0, Math.floor(normY * pick.rows)),
|
||||
);
|
||||
|
||||
const readCode = (x: number, y: number) => pick.buffer[y * pick.cols + x];
|
||||
|
||||
const center = readCode(cx, cy);
|
||||
if (center > 0) {
|
||||
return center - 1;
|
||||
}
|
||||
|
||||
if (radiusPx <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const r = Math.max(4, radiusPx);
|
||||
const rSq = r * r;
|
||||
for (let dy = -r; dy <= r; dy++) {
|
||||
for (let dx = -r; dx <= r; dx++) {
|
||||
if (dx * dx + dy * dy > rSq) {
|
||||
continue;
|
||||
}
|
||||
const x = cx + dx;
|
||||
const y = cy + dy;
|
||||
if (x < 0 || y < 0 || x >= pick.cols || y >= pick.rows) {
|
||||
continue;
|
||||
}
|
||||
const code = readCode(x, y);
|
||||
if (code > 0) {
|
||||
return code - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function releasePaintResourceLayers(layers: PaintResourceLayers | null) {
|
||||
if (!layers) {
|
||||
return;
|
||||
}
|
||||
layers.lowFreqImage.dispose();
|
||||
layers.highFreqImage.dispose();
|
||||
}
|
||||
|
||||
function releaseOriginSkImage(image: SkImage | null) {
|
||||
if (image) {
|
||||
image.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
type WorkScaledBgr = {
|
||||
buffer: Uint8Array;
|
||||
cols: number;
|
||||
rows: number;
|
||||
};
|
||||
|
||||
async function prepareWorkScaledBgrBuffer(
|
||||
bgrBuffer: Uint8Array,
|
||||
cols: number,
|
||||
rows: number,
|
||||
workScale: number,
|
||||
): Promise<WorkScaledBgr> {
|
||||
if (workScale >= 1) {
|
||||
return { buffer: bgrBuffer, cols, rows };
|
||||
}
|
||||
const workCols = Math.floor(cols * workScale);
|
||||
const workRows = Math.floor(rows * workScale);
|
||||
const buffer = resizeBgrBuffer(bgrBuffer, cols, rows, workCols, workRows);
|
||||
return { buffer, cols: workCols, rows: workRows };
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
* 分段计时工具(仅开发环境生效)
|
||||
* ========================================================================== */
|
||||
let _timeLogTs = 0;
|
||||
function timeLog(tag: string) {
|
||||
if (!__DEV__) return;
|
||||
const now = performance.now();
|
||||
const dt = _timeLogTs ? now - _timeLogTs : 0;
|
||||
console.log(`[⏱ ${tag}] ${dt.toFixed(2)} ms`);
|
||||
_timeLogTs = now;
|
||||
}
|
||||
import {
|
||||
type PaintResourceLayers,
|
||||
type ContainRect,
|
||||
type WorkScaledBgr,
|
||||
bgrColorEquals,
|
||||
rectsEqual,
|
||||
getContainRect,
|
||||
canvasToNormalized,
|
||||
buildZoomPanMatrix,
|
||||
clampPanOffset,
|
||||
screenToCanvasCoords,
|
||||
pointInPolygon,
|
||||
pointInPolygonWithPadding,
|
||||
getRegionHitPolygons,
|
||||
pointHitsRegion,
|
||||
pointStrictlyHitsRegion,
|
||||
resolveRegionHit,
|
||||
pickKickRegionFromMask,
|
||||
pickKickNearStrip,
|
||||
lookupRegionFromPickMap,
|
||||
releasePaintResourceLayers,
|
||||
releaseOriginSkImage,
|
||||
prepareWorkScaledBgrBuffer,
|
||||
timeLog,
|
||||
} from '../utils/canvasGeometry';
|
||||
|
||||
/* ==========================================================================
|
||||
* 组件主体
|
||||
|
||||
474
src/utils/canvasGeometry.ts
Normal file
474
src/utils/canvasGeometry.ts
Normal file
@ -0,0 +1,474 @@
|
||||
import { Skia, type SkImage } from '@shopify/react-native-skia';
|
||||
import type { SegmentRegion } from './maskSegmentation';
|
||||
import { isBaseboardMaskPixel } from './maskSegmentation';
|
||||
import { getMaskSegmentRuntimeConfig } from './maskSegmentRuntime';
|
||||
import { resizeBgrBuffer } from './pngImage';
|
||||
import type { BgrColor } from '../components/MaskSegmentCanvas.types';
|
||||
|
||||
/* ==========================================================================
|
||||
* 类型
|
||||
* ========================================================================== */
|
||||
export type PaintResourceLayers = {
|
||||
lowFreqImage: SkImage;
|
||||
highFreqImage: SkImage;
|
||||
};
|
||||
|
||||
export type ContainRect = {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
};
|
||||
|
||||
export type WorkScaledBgr = {
|
||||
buffer: Uint8Array;
|
||||
cols: number;
|
||||
rows: number;
|
||||
};
|
||||
|
||||
/* ==========================================================================
|
||||
* 颜色工具
|
||||
* ========================================================================== */
|
||||
export function bgrColorEquals(a: BgrColor, b: BgrColor): boolean {
|
||||
return a.b === b.b && a.g === b.g && a.r === b.r;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
* 几何工具
|
||||
* ========================================================================== */
|
||||
export function rectsEqual(a: ContainRect, b: ContainRect): boolean {
|
||||
return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;
|
||||
}
|
||||
|
||||
export function getContainRect(
|
||||
canvasW: number,
|
||||
canvasH: number,
|
||||
imgW: number,
|
||||
imgH: number,
|
||||
): ContainRect {
|
||||
const imgAspect = imgW / imgH;
|
||||
const canvasAspect = canvasW / canvasH;
|
||||
|
||||
if (imgAspect > canvasAspect) {
|
||||
const w = canvasW;
|
||||
const h = canvasW / imgAspect;
|
||||
return { x: 0, y: (canvasH - h) / 2, w, h };
|
||||
}
|
||||
|
||||
const h = canvasH;
|
||||
const w = canvasH * imgAspect;
|
||||
return { x: (canvasW - w) / 2, y: 0, w, h };
|
||||
}
|
||||
|
||||
export function canvasToNormalized(
|
||||
cx: number,
|
||||
cy: number,
|
||||
canvasW: number,
|
||||
canvasH: number,
|
||||
imgW: number,
|
||||
imgH: number,
|
||||
): { x: number; y: number } | null {
|
||||
const rect = getContainRect(canvasW, canvasH, imgW, imgH);
|
||||
if (
|
||||
cx < rect.x ||
|
||||
cx > rect.x + rect.w ||
|
||||
cy < rect.y ||
|
||||
cy > rect.y + rect.h
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
x: (cx - rect.x) / rect.w,
|
||||
y: (cy - rect.y) / rect.h,
|
||||
};
|
||||
}
|
||||
|
||||
/** Skia matrix: pan → scale around viewport center. Matches screenToCanvasCoords. */
|
||||
export function buildZoomPanMatrix(
|
||||
panX: number,
|
||||
panY: number,
|
||||
scale: number,
|
||||
canvasW: number,
|
||||
canvasH: number,
|
||||
) {
|
||||
const cx = canvasW / 2;
|
||||
const cy = canvasH / 2;
|
||||
const m = Skia.Matrix();
|
||||
m.translate(panX, panY);
|
||||
m.translate(cx, cy);
|
||||
m.scale(scale, scale);
|
||||
m.translate(-cx, -cy);
|
||||
return m;
|
||||
}
|
||||
|
||||
/** Clamp pan so scaled containRect does not expose empty margins beyond the viewport. */
|
||||
export function clampPanOffset(
|
||||
pan: { x: number; y: number },
|
||||
scale: number,
|
||||
canvasW: number,
|
||||
canvasH: number,
|
||||
containRect: ContainRect | null,
|
||||
): { x: number; y: number } {
|
||||
if (!containRect || scale <= 1 || canvasW <= 0 || canvasH <= 0) {
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
const cx = canvasW / 2;
|
||||
const cy = canvasH / 2;
|
||||
const r = containRect;
|
||||
|
||||
const scaledMinX = cx + scale * (r.x - cx);
|
||||
const scaledMaxX = cx + scale * (r.x + r.w - cx);
|
||||
const scaledMinY = cy + scale * (r.y - cy);
|
||||
const scaledMaxY = cy + scale * (r.y + r.h - cy);
|
||||
|
||||
let x = pan.x;
|
||||
let y = pan.y;
|
||||
|
||||
if (scaledMaxX - scaledMinX > canvasW) {
|
||||
x = Math.max(canvasW - scaledMaxX, Math.min(-scaledMinX, x));
|
||||
} else {
|
||||
x = 0;
|
||||
}
|
||||
|
||||
if (scaledMaxY - scaledMinY > canvasH) {
|
||||
y = Math.max(canvasH - scaledMaxY, Math.min(-scaledMinY, y));
|
||||
} else {
|
||||
y = 0;
|
||||
}
|
||||
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of the Skia Group transform applied during pinch-zoom.
|
||||
* Converts a raw touch point (screen pixels) back to the canvas coordinate
|
||||
* space where the image and regions are positioned before any scale/pan.
|
||||
* When zoomScale ≤ 1 (no zoom), returns the input unchanged.
|
||||
*/
|
||||
export function screenToCanvasCoords(
|
||||
screenX: number,
|
||||
screenY: number,
|
||||
canvasW: number,
|
||||
canvasH: number,
|
||||
zoomScale: number,
|
||||
panOffset: { x: number; y: number },
|
||||
): { x: number; y: number } {
|
||||
if (zoomScale <= 1) return { x: screenX, y: screenY };
|
||||
return {
|
||||
x: (screenX - panOffset.x - canvasW / 2) / zoomScale + canvasW / 2,
|
||||
y: (screenY - panOffset.y - canvasH / 2) / zoomScale + canvasH / 2,
|
||||
};
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
* 点击检测
|
||||
* ========================================================================== */
|
||||
export function pointInPolygon(
|
||||
x: number,
|
||||
y: number,
|
||||
points: { x: number; y: number }[],
|
||||
): boolean {
|
||||
let inside = false;
|
||||
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
|
||||
const xi = points[i].x;
|
||||
const yi = points[i].y;
|
||||
const xj = points[j].x;
|
||||
const yj = points[j].y;
|
||||
const intersect =
|
||||
yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
|
||||
if (intersect) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
export function pointInPolygonWithPadding(
|
||||
x: number,
|
||||
y: number,
|
||||
points: { x: number; y: number }[],
|
||||
padding: number,
|
||||
): boolean {
|
||||
if (points.length < 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let minX = points[0].x;
|
||||
let maxX = points[0].x;
|
||||
let minY = points[0].y;
|
||||
let maxY = points[0].y;
|
||||
for (const point of points) {
|
||||
minX = Math.min(minX, point.x);
|
||||
maxX = Math.max(maxX, point.x);
|
||||
minY = Math.min(minY, point.y);
|
||||
maxY = Math.max(maxY, point.y);
|
||||
}
|
||||
|
||||
if (
|
||||
x >= minX - padding &&
|
||||
x <= maxX + padding &&
|
||||
y >= minY - padding &&
|
||||
y <= maxY + padding
|
||||
) {
|
||||
if (maxY - minY < padding * 2.5 || maxX - minX < padding * 2.5) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return pointInPolygon(x, y, points);
|
||||
}
|
||||
|
||||
export function getRegionHitPolygons(reg: SegmentRegion): { x: number; y: number }[][] {
|
||||
return reg.hitPolygons && reg.hitPolygons.length > 0
|
||||
? reg.hitPolygons
|
||||
: reg.polygons;
|
||||
}
|
||||
|
||||
export function pointHitsRegion(
|
||||
x: number,
|
||||
y: number,
|
||||
reg: SegmentRegion,
|
||||
options?: { thinPadding?: number },
|
||||
): boolean {
|
||||
const interaction = getMaskSegmentRuntimeConfig().interaction;
|
||||
const thinPadding = options?.thinPadding ?? interaction.thinStripPadding;
|
||||
const padding = reg.thinStrip ? thinPadding : interaction.regionPadding;
|
||||
return getRegionHitPolygons(reg).some(
|
||||
poly => poly.length >= 3 && pointInPolygonWithPadding(x, y, poly, padding),
|
||||
);
|
||||
}
|
||||
|
||||
export function pointStrictlyHitsRegion(x: number, y: number, reg: SegmentRegion): boolean {
|
||||
return getRegionHitPolygons(reg).some(
|
||||
poly => poly.length >= 3 && pointInPolygon(x, y, poly),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveRegionHit(
|
||||
regions: SegmentRegion[],
|
||||
x: number,
|
||||
y: number,
|
||||
): number | null {
|
||||
const hits: SegmentRegion[] = [];
|
||||
|
||||
for (const reg of regions) {
|
||||
const bboxPad = reg.thinStrip ? 0.005 : 0;
|
||||
const b = reg.bbox;
|
||||
if (
|
||||
x < b.x - bboxPad ||
|
||||
x > b.x + b.w + bboxPad ||
|
||||
y < b.y - bboxPad ||
|
||||
y > b.y + b.h + bboxPad
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (pointHitsRegion(x, y, reg)) {
|
||||
hits.push(reg);
|
||||
}
|
||||
}
|
||||
|
||||
if (hits.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (hits.length === 1) {
|
||||
return hits[0].id;
|
||||
}
|
||||
|
||||
const strictNonThin = hits.filter(
|
||||
reg => !reg.thinStrip && pointStrictlyHitsRegion(x, y, reg),
|
||||
);
|
||||
if (strictNonThin.length > 0) {
|
||||
strictNonThin.sort((a, b) => a.area - b.area);
|
||||
return strictNonThin[0].id;
|
||||
}
|
||||
|
||||
const strictThin = hits.filter(
|
||||
reg => reg.thinStrip && pointStrictlyHitsRegion(x, y, reg),
|
||||
);
|
||||
if (strictThin.length > 0) {
|
||||
strictThin.sort((a, b) => a.area - b.area);
|
||||
return strictThin[0].id;
|
||||
}
|
||||
|
||||
const nonThin = hits.filter(reg => !reg.thinStrip);
|
||||
if (nonThin.length > 0) {
|
||||
nonThin.sort((a, b) => a.area - b.area);
|
||||
return nonThin[0].id;
|
||||
}
|
||||
|
||||
hits.sort((a, b) => a.area - b.area);
|
||||
return hits[0].id;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
* 踢脚线/掩码拾取
|
||||
* ========================================================================== */
|
||||
export function pickKickRegionFromMask(
|
||||
normX: number,
|
||||
normY: number,
|
||||
pick: { buffer: Uint8Array; cols: number; rows: number },
|
||||
kickRegionId: number,
|
||||
baseboardPickMask?: Uint8Array | null,
|
||||
strict = false,
|
||||
): number | null {
|
||||
const cx = Math.floor(normX * pick.cols);
|
||||
const cy = Math.floor(normY * pick.rows);
|
||||
if (cx < 0 || cy < 0 || cx >= pick.cols || cy >= pick.rows) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (strict) {
|
||||
if (baseboardPickMask) {
|
||||
return baseboardPickMask[cy * pick.cols + cx] ? kickRegionId : null;
|
||||
}
|
||||
return isBaseboardMaskPixel(pick.buffer, pick.cols, pick.rows, cx, cy)
|
||||
? kickRegionId
|
||||
: null;
|
||||
}
|
||||
|
||||
const interaction = getMaskSegmentRuntimeConfig().interaction;
|
||||
const radius = Math.max(
|
||||
interaction.kickMaskPickRadiusPx,
|
||||
Math.floor(pick.cols * 0.022),
|
||||
);
|
||||
const radiusSq = radius * radius;
|
||||
|
||||
for (let dy = -radius; dy <= radius; dy++) {
|
||||
for (let dx = -radius; dx <= radius; dx++) {
|
||||
if (dx * dx + dy * dy > radiusSq) {
|
||||
continue;
|
||||
}
|
||||
const x = cx + dx;
|
||||
const y = cy + dy;
|
||||
if (x < 0 || y < 0 || x >= pick.cols || y >= pick.rows) {
|
||||
continue;
|
||||
}
|
||||
if (baseboardPickMask) {
|
||||
if (baseboardPickMask[y * pick.cols + x]) {
|
||||
return kickRegionId;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
isBaseboardMaskPixel(
|
||||
pick.buffer,
|
||||
pick.cols,
|
||||
pick.rows,
|
||||
x,
|
||||
y,
|
||||
)
|
||||
) {
|
||||
return kickRegionId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function pickKickNearStrip(
|
||||
normX: number,
|
||||
normY: number,
|
||||
kickReg: SegmentRegion,
|
||||
): boolean {
|
||||
const polys = kickReg.hitPolygons ?? kickReg.polygons;
|
||||
const pad = getMaskSegmentRuntimeConfig().interaction.thinStripPadding + 0.004;
|
||||
return polys.some(
|
||||
poly =>
|
||||
poly.length >= 3 && pointInPolygonWithPadding(normX, normY, poly, pad),
|
||||
);
|
||||
}
|
||||
|
||||
export function lookupRegionFromPickMap(
|
||||
normX: number,
|
||||
normY: number,
|
||||
pick: { buffer: Uint8Array; cols: number; rows: number },
|
||||
radiusPx = getMaskSegmentRuntimeConfig().interaction.pickMapSearchRadiusPx,
|
||||
): number | null {
|
||||
const cx = Math.min(
|
||||
pick.cols - 1,
|
||||
Math.max(0, Math.floor(normX * pick.cols)),
|
||||
);
|
||||
const cy = Math.min(
|
||||
pick.rows - 1,
|
||||
Math.max(0, Math.floor(normY * pick.rows)),
|
||||
);
|
||||
|
||||
const readCode = (x: number, y: number) => pick.buffer[y * pick.cols + x];
|
||||
|
||||
const center = readCode(cx, cy);
|
||||
if (center > 0) {
|
||||
return center - 1;
|
||||
}
|
||||
|
||||
if (radiusPx <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const r = Math.max(4, radiusPx);
|
||||
const rSq = r * r;
|
||||
for (let dy = -r; dy <= r; dy++) {
|
||||
for (let dx = -r; dx <= r; dx++) {
|
||||
if (dx * dx + dy * dy > rSq) {
|
||||
continue;
|
||||
}
|
||||
const x = cx + dx;
|
||||
const y = cy + dy;
|
||||
if (x < 0 || y < 0 || x >= pick.cols || y >= pick.rows) {
|
||||
continue;
|
||||
}
|
||||
const code = readCode(x, y);
|
||||
if (code > 0) {
|
||||
return code - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
* 资源释放
|
||||
* ========================================================================== */
|
||||
export function releasePaintResourceLayers(layers: PaintResourceLayers | null) {
|
||||
if (!layers) {
|
||||
return;
|
||||
}
|
||||
layers.lowFreqImage.dispose();
|
||||
layers.highFreqImage.dispose();
|
||||
}
|
||||
|
||||
export function releaseOriginSkImage(image: SkImage | null) {
|
||||
if (image) {
|
||||
image.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
* 工作缓冲区缩放
|
||||
* ========================================================================== */
|
||||
export async function prepareWorkScaledBgrBuffer(
|
||||
bgrBuffer: Uint8Array,
|
||||
cols: number,
|
||||
rows: number,
|
||||
workScale: number,
|
||||
): Promise<WorkScaledBgr> {
|
||||
if (workScale >= 1) {
|
||||
return { buffer: bgrBuffer, cols, rows };
|
||||
}
|
||||
const workCols = Math.floor(cols * workScale);
|
||||
const workRows = Math.floor(rows * workScale);
|
||||
const buffer = resizeBgrBuffer(bgrBuffer, cols, rows, workCols, workRows);
|
||||
return { buffer, cols: workCols, rows: workRows };
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
* 分段计时工具(仅开发环境生效)
|
||||
* ========================================================================== */
|
||||
let _timeLogTs = 0;
|
||||
export function timeLog(tag: string) {
|
||||
if (!__DEV__) return;
|
||||
const now = performance.now();
|
||||
const dt = _timeLogTs ? now - _timeLogTs : 0;
|
||||
console.log(`[⏱ ${tag}] ${dt.toFixed(2)} ms`);
|
||||
_timeLogTs = now;
|
||||
}
|
||||
556
src/utils/maskOutlinePaths.ts
Normal file
556
src/utils/maskOutlinePaths.ts
Normal file
@ -0,0 +1,556 @@
|
||||
import { Skia, type SkPath } from '@shopify/react-native-skia';
|
||||
import { getMaskSegmentRuntimeConfig } from './maskSegmentRuntime';
|
||||
import type { SegmentRegion, RegionMaskData } from './maskSegmentation';
|
||||
|
||||
/** 分区虚线高亮:从与填充蒙版相同的像素网格提取外轮廓 */
|
||||
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<string, GridEdge[]>();
|
||||
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<string>();
|
||||
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<number, Uint8Array> {
|
||||
const { labels, baseboardBinary, cols, rows, wallSubLabels } = maskData;
|
||||
const size = cols * rows;
|
||||
const binaries = new Map<number, Uint8Array>();
|
||||
const semanticColors = getMaskSegmentRuntimeConfig().mask.semanticColors;
|
||||
const regionIdBySemantic = new Int32Array(semanticColors.length);
|
||||
regionIdBySemantic.fill(-1);
|
||||
const wallSubRegionIds = new Map<number, number>();
|
||||
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<number, SkPath> {
|
||||
const { cols, rows } = maskData;
|
||||
const binaries = extractRegionBinaries(regions, maskData);
|
||||
const map = new Map<number, SkPath>();
|
||||
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;
|
||||
}
|
||||
@ -51,7 +51,7 @@ export const PIPELINE_PRESETS: Record<PipelinePreset, Required<PipelineConfig>>
|
||||
low: PIPELINE_LOW,
|
||||
};
|
||||
|
||||
export const DEFAULT_PIPELINE_CONFIG: Required<PipelineConfig> = PIPELINE_HIGH;
|
||||
export const DEFAULT_PIPELINE_CONFIG: Required<PipelineConfig> = PIPELINE_MEDIUM;
|
||||
|
||||
export function resolvePipelineConfig(
|
||||
preset?: PipelinePreset,
|
||||
|
||||
@ -77,558 +77,10 @@ export function buildRegionOutlinePolygons(reg: SegmentRegion): NormPoint[][] {
|
||||
return [bboxToPolygon(reg.bbox)];
|
||||
}
|
||||
|
||||
/** 分区虚线高亮:从与填充蒙版相同的像素网格提取外轮廓 */
|
||||
type GridPoint = { x: number; y: number };
|
||||
import { buildAllRegionOutlinePaths, buildRegionOutlinePathForRegion } from './maskOutlinePaths';
|
||||
|
||||
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<string, GridEdge[]>();
|
||||
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<string>();
|
||||
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<number, Uint8Array> {
|
||||
const { labels, baseboardBinary, cols, rows, wallSubLabels } = maskData;
|
||||
const size = cols * rows;
|
||||
const binaries = new Map<number, Uint8Array>();
|
||||
const semanticColors = getMaskSegmentRuntimeConfig().mask.semanticColors;
|
||||
const regionIdBySemantic = new Int32Array(semanticColors.length);
|
||||
regionIdBySemantic.fill(-1);
|
||||
const wallSubRegionIds = new Map<number, number>();
|
||||
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<number, SkPath> {
|
||||
const { cols, rows } = maskData;
|
||||
const binaries = extractRegionBinaries(regions, maskData);
|
||||
const map = new Map<number, SkPath>();
|
||||
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;
|
||||
}
|
||||
// Re-export for backward compatibility
|
||||
export { buildAllRegionOutlinePaths, buildRegionOutlinePathForRegion };
|
||||
|
||||
function isBaseboardEntry(entry: PaletteEntry): boolean {
|
||||
return entry.name === BASEBOARD_SEMANTIC_NAME;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user