- 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>
306 lines
11 KiB
JavaScript
306 lines
11 KiB
JavaScript
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
|