2026-06-26 07:23:52 +00:00
import { jsx as _jsx , Fragment as _Fragment , jsxs as _jsxs } from "react/jsx-runtime" ;
import { useRef , useState , useEffect , useCallback , useMemo , forwardRef , useImperativeHandle , } from 'react' ;
import { View , StyleSheet , Button , Dimensions , Text , TouchableOpacity , ScrollView , } from 'react-native' ;
import { Gesture , GestureDetector } from 'react-native-gesture-handler' ;
import { runOnJS } from 'react-native-reanimated' ;
import { launchImageLibrary } from 'react-native-image-picker' ;
import cv from '../utils/opencvAdapter' ;
import { buildAllRegionOutlinePaths , buildRegionOutlinePathForRegion , downsampleMaskDataForPaths , extractRegionsFromMaskBufferSync , isBaseboardMaskPixel , upscaleBinaryMask , } from '../utils/maskSegmentation' ;
2026-07-01 05:41:17 +00:00
import { splitWallRegionsByTexture } from '../utils/wallTextureSplit' ;
2026-06-26 07:23:52 +00:00
import { clearDerivedImageCache , readPngBgrBuffer , prewarmPngBgrCache , resizeBgrBuffer , } from '../utils/pngImage' ;
2026-07-01 03:24:43 +00:00
import { hashUrl , resolveImageUrl } from '../utils/resolveImageUrl' ;
2026-06-26 07:23:52 +00:00
import { compositePaintedImage } from '../utils/compositePaintedImage' ;
import { paintedRegionsFingerprint , resolveExportResultForDestDir , } from '../utils/exportUtils' ;
import { preparePaintResourcesFromWorkBuffer , releaseFreqLayerImages , } from '../utils/freqLayerPrep' ;
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' ;
/ * = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = =
* 配置常量 ( 屏幕相关 ; 其余见 maskSegmentRuntime )
* === === === === === === === === === === === === === === === === === === === === === === === === == * /
const { width : SCREEN _WIDTH } = Dimensions . get ( 'window' ) ;
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 ,
} ;
}
/ * *
* 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 } ;
// Reverse: translate(-pan) → unscale around center → translate(+center)
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 ;
}
/** BGR → 屏幕 RGB */
function bgrToCss ( b , g , r ) {
return ` rgb( ${ r } , ${ g } , ${ b } ) ` ;
}
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 ;
}
/ * = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = =
* 组件主体
* === === === === === === === === === === === === === === === === === === === === === === === === == * /
const MaskSegmentCanvas = forwardRef ( function MaskSegmentCanvas ( props , ref ) {
const { originUrl : originUrlProp , maskUrl : maskUrlProp , originImgPath : originImgPathLegacy , maskImgPath : maskImgPathLegacy , maskConfig , pipelinePreset , pipelineConfig , paintConfig , interactionConfig , semanticColors , regionOutlineColor , initialSession , initialPaintColor , initialPaintConfigJson , showDebugPickers = true , showToolbar = true , showColorBar = true , showStatusRow = true , showOverlayButtons = true , disabled = false , style , canvasStyle , maxHeight , undoButtonStyle , compareButtonStyle , undoButtonTextStyle , compareButtonTextStyle , undoButtonText = '撤销' , compareButtonText = '对比原图' , compareExitButtonText = '退出对比' , renderUndoButton , renderCompareButton , onWatch , onPaintCallback , onError , autoExportOnReady , onExported , } = props ;
const originSource = originUrlProp ? ? originImgPathLegacy ? ? '' ;
const maskSource = maskUrlProp ? ? maskImgPathLegacy ? ? '' ;
const resolvedMaskConfig = useMemo ( ( ) => semanticColors
? { ... maskConfig , semanticColors }
: maskConfig , [ maskConfig , semanticColors ] ) ;
const resolvedPaintConfig = useMemo ( ( ) => regionOutlineColor
? { ... paintConfig , regionOverlayFill : regionOutlineColor }
: paintConfig , [ paintConfig , regionOutlineColor ] ) ;
const [ resolvedOriginPath , setResolvedOriginPath ] = useState ( '' ) ;
const [ resolvedMaskPath , setResolvedMaskPath ] = useState ( '' ) ;
const [ originImgPath , setOriginImgPath ] = useState ( resolvedOriginPath ) ;
const [ maskImgPath , setMaskImgPath ] = useState ( resolvedMaskPath ) ;
// Latest desired image paths (updated when the internal path states settle).
// Used by segmentAndPrepareLayers to decide whether a stale runId (from effect cleanup due to
// unrelated parent re-renders) should actually abort the current async read/segment work.
// If the image pair we are processing is still the one the caller ultimately wants, we continue.
const latestOriginPathRef = useRef ( '' ) ;
const latestMaskPathRef = useRef ( '' ) ;
const resolvedPipelineConfig = useMemo ( ( ) => resolvePipelineConfig ( pipelinePreset , pipelineConfig ) , [ pipelinePreset , pipelineConfig ] ) ;
const runtimeRef = useRef ( createRuntimeConfig ( {
maskConfig : resolvedMaskConfig ,
pipelineConfig : resolvedPipelineConfig ,
paintConfig : resolvedPaintConfig ,
interactionConfig ,
} ) ) ;
// Track last *values* we pushed for paintConfig. We only call the global setMaskSegmentRuntimeConfig
// (which bumps runtimeRevision) when the actual numbers change. This prevents repeated parent
// re-renders that pass a new object literal with identical values from causing:
// - global revision bump
// - paintColorMap useMemo invalidation (full-res Uint8Array + boxBlur + Skia image alloc)
// - extra main-thread work during the critical segmentation / freq-layers / outline paths window.
// The local runtimeRef is still kept in sync for any synchronous readers.
const lastAppliedPaintConfigRef = useRef ( null ) ;
const lastAppliedPipelineSignatureRef = useRef ( null ) ;
useEffect ( ( ) => {
const prevPaint = lastAppliedPaintConfigRef . current ;
const currPaint = resolvedPaintConfig || { } ;
const paintKeys = [
'colorBaseOpacity' ,
'lLightOpacity' ,
'textureOpacity' ,
'lLowBlurKernel' ,
'lLowContrast' ,
'lLowBrightness' ,
'lHighGain' ,
'maskFeatherColor' ,
'maskFeatherTexture' ,
'regionOverlayFill' ,
] ;
const paintChanged = ! prevPaint || paintKeys . some ( ( k ) => currPaint [ k ] !== prevPaint [ k ] ) ;
const pipelineSignature = JSON . stringify ( resolvedPipelineConfig ) ;
const pipelineChanged = lastAppliedPipelineSignatureRef . current !== pipelineSignature ;
if ( paintChanged || pipelineChanged ) {
if ( paintChanged ) {
lastAppliedPaintConfigRef . current = { ... currPaint } ;
}
if ( pipelineChanged ) {
lastAppliedPipelineSignatureRef . current = pipelineSignature ;
}
runtimeRef . current = setMaskSegmentRuntimeConfig ( {
maskConfig : resolvedMaskConfig ,
pipelineConfig : resolvedPipelineConfig ,
paintConfig : resolvedPaintConfig ,
interactionConfig ,
} ) ;
}
} , [
resolvedMaskConfig ,
resolvedPipelineConfig ,
resolvedPaintConfig ,
interactionConfig ,
] ) ;
const paintPalette = runtimeRef . current . paint . palette ;
const paintRuntime = getMaskSegmentRuntimeConfig ( ) . paint ;
const interactionRuntime = getMaskSegmentRuntimeConfig ( ) . interaction ;
const onWatchRef = useRef ( onWatch ) ;
const onPaintCallbackRef = useRef ( onPaintCallback ) ;
const onErrorRef = useRef ( onError ) ;
const onExportedRef = useRef ( onExported ) ;
useEffect ( ( ) => {
onWatchRef . current = onWatch ;
onPaintCallbackRef . current = onPaintCallback ;
onErrorRef . current = onError ;
onExportedRef . current = onExported ;
} , [ onWatch , onPaintCallback , onError , onExported ] ) ;
const watchStartRef = useRef ( 0 ) ;
const lastWatchStateRef = useRef ( null ) ;
const lastWatchSignatureRef = useRef ( null ) ;
const emitWatch = useCallback ( ( state , detail ) => {
const signature = [
state ,
detail ? . regionCount ? ? '' ,
detail ? . maskPathsReady ? ? '' ,
detail ? . freqLayersReady ? ? '' ,
detail ? . errorMessage ? ? '' ,
] . join ( '|' ) ;
if ( lastWatchSignatureRef . current === signature ) {
return ;
}
lastWatchSignatureRef . current = signature ;
lastWatchStateRef . current = state ;
const durationMs = watchStartRef . current
? performance . now ( ) - watchStartRef . current
: 0 ;
onWatchRef . current ? . ( state , durationMs , detail ) ;
} , [ ] ) ;
const reportError = useCallback ( ( message , error ) => {
emitWatch ( 'error' , { errorMessage : message } ) ;
if ( onErrorRef . current ) {
onErrorRef . current ( message , error ) ;
}
else if ( _ _DEV _ _ ) {
console . error ( '[MaskSegment]' , message , error ) ;
}
} , [ emitWatch ] ) ;
const [ customPaintColor , setCustomPaintColor ] = useState ( initialPaintColor ? ? null ) ;
const customPaintConfigJsonRef = useRef ( initialPaintConfigJson ) ;
const originUrlRef = useRef ( originSource ) ;
const maskUrlRef = useRef ( maskSource ) ;
useEffect ( ( ) => {
originUrlRef . current = originSource ;
maskUrlRef . current = maskSource ;
} , [ originSource , maskSource ] ) ;
useEffect ( ( ) => {
let cancelled = false ;
if ( ! originSource || ! maskSource ) {
setResolvedOriginPath ( '' ) ;
setResolvedMaskPath ( '' ) ;
return ;
}
void ( async ( ) => {
try {
const [ originPath , maskPath ] = await Promise . all ( [
2026-07-01 03:24:43 +00:00
resolveImageUrl ( originSource , ` origin_ ${ hashUrl ( originSource ) } .png ` ) ,
resolveImageUrl ( maskSource , ` mask_ ${ hashUrl ( maskSource ) } .png ` ) ,
2026-06-26 07:23:52 +00:00
] ) ;
if ( ! cancelled ) {
setResolvedOriginPath ( originPath ) ;
setResolvedMaskPath ( maskPath ) ;
}
}
catch ( e ) {
if ( ! cancelled ) {
const msg = e instanceof Error ? e . message : String ( e ) ;
reportError ( msg , e ) ;
}
}
} ) ( ) ;
return ( ) => {
cancelled = true ;
} ;
} , [ originSource , maskSource , reportError ] ) ;
useEffect ( ( ) => {
setOriginImgPath ( resolvedOriginPath ) ;
setMaskImgPath ( resolvedMaskPath ) ;
if ( resolvedOriginPath && resolvedMaskPath ) {
prewarmPngBgrCache ( [ resolvedOriginPath , resolvedMaskPath ] ) ;
}
} , [ resolvedOriginPath , resolvedMaskPath ] ) ;
// Keep latest desired paths for cancellation decisions inside the long async segment pipeline.
useEffect ( ( ) => {
latestOriginPathRef . current = originImgPath || '' ;
latestMaskPathRef . current = maskImgPath || '' ;
} , [ originImgPath , maskImgPath ] ) ;
const [ paintResourceLayers , setPaintResourceLayers ] = useState ( null ) ;
const paintResourceLayersRef = useRef ( null ) ;
const paintColorMapSkImgRef = useRef ( null ) ;
const [ activeBrushIndex , setActiveBrushIndex ] = useState ( null ) ;
const [ paintedRegions , setPaintedRegions ] = useState ( ( ) => new Map ( ) ) ;
const paintedRegionsRef = useRef ( new Map ( ) ) ;
const [ paintHistory , setPaintHistory ] = useState ( [ ] ) ;
// Seed the ref with the initial empty map so early reads (before any paint effect)
// are consistent.
paintedRegionsRef . current = paintedRegions ;
const [ heldRegionId , setHeldRegionId ] = useState ( null ) ;
const [ heldRegionAnchor , setHeldRegionAnchor ] = useState ( null ) ;
const [ initFlashRegionId , setInitFlashRegionId ] = useState ( null ) ;
const initFlashTimerRef = useRef ( null ) ;
const initFlashIndexRef = useRef ( 0 ) ;
const initFlashActiveRef = useRef ( false ) ;
// List of regions still eligible for the init dashed-outline flash (discovery aid).
// Computed at the start of the flash loop, excluding any that are already painted.
// This ensures that on continue-edit (or any partial seed), already-colored regions
// do not get the flashing dashed outline.
const initFlashListRef = useRef ( [ ] ) ;
// Guard so that initialSession (seed from host bootstrap or scheme) is applied only once
// after segmentsReady. Prevents later prop identity changes (e.g. caused by host slot/brush
// selection re-renders) from calling restoreSession again, which would clobber live
// paintedRegions with a re-derived snapshot (often causing already-painted regions to
// "follow" the newly selected brush color).
const hasAppliedInitialSessionRef = useRef ( false ) ;
// Keep a ref to the absolute latest paintedRegions so that imperative save()
// (called from host performSaveProject for new schemes, or manually) and
// internal composites always see the most up-to-date painted state, even
// if the useImperativeHandle closure was created in a prior render.
// This fixes cases where the last user paint's colors were missing from
// the recolored After image captured at "save scheme" time.
useEffect ( ( ) => {
paintedRegionsRef . current = paintedRegions ;
} , [ paintedRegions ] ) ;
// Cached export from the most recent auto-export or save() — keyed by painted fingerprint.
const lastExportCacheRef = useRef ( null ) ;
const autoExportDebounceRef = useRef ( null ) ;
const exportInFlightRef = useRef ( false ) ;
const regionsRef = useRef ( [ ] ) ;
const maskPickRef = useRef ( null ) ;
const regionPickRef = useRef ( null ) ;
const regionMaskDataRef = useRef ( null ) ;
const workBufferRef = useRef ( null ) ;
const paintLayersPromiseRef = useRef ( null ) ;
const loadPaintLayersRef = useRef ( ( ) => Promise . resolve ( ) ) ;
const [ paintResourcesReady , setPaintResourcesReady ] = useState ( false ) ;
const [ layersLoading , setLayersLoading ] = useState ( false ) ;
const [ maskPathsReady , setMaskPathsReady ] = useState ( false ) ;
const baseboardPickMaskRef = useRef ( null ) ;
const kickRegionIdRef = useRef ( null ) ;
const [ regionPalette , setRegionPalette ] = useState ( [ ] ) ;
const [ regionCount , setRegionCount ] = useState ( 0 ) ;
const [ imageSize , setImageSize ] = useState ( null ) ;
// High-resolution offscreen Canvas (sized to the work buffer resolution) whose content
// is the full shader composition (PaintShaderLayer at 0,0,workW,workH). On save() we
// call makeImageSnapshot() on it to get a "what you see in the editor, at source res"
// PNG bytes. This is the preferred "保存快照" path and avoids CPU recolor entirely
// for the exported After.
const highResExportCanvasRef = useCanvasRef ( ) ;
const [ exportCanvasSize , setExportCanvasSize ] = useState ( null ) ;
// Gate the (potentially expensive) high-res snapshot canvas so it is only mounted
// after the user (or initialSession seed) has painted at least one region. This keeps
// idle segmentation / no-paint cases cheap.
const [ highResSnapshotEnabled , setHighResSnapshotEnabled ] = useState ( false ) ;
// Layout measurement for the root container of this component. Declared early so
// the canvasW/canvasH memos (which decide the viewport rect for zoom centering,
// containRect placement, clipping, and gesture coordinate mapping) can close over it.
// When the host passes a fitted frame (VisualizationScreen's canvasFrame with explicit
// w/h derived from safe area + aspect, or scheme cards), we size our internal Skia
// canvas + gesture layer + zoom transform to that exact allocated rect.
const [ layoutWidth , setLayoutWidth ] = useState ( null ) ;
const [ layoutHeight , setLayoutHeight ] = useState ( null ) ;
const [ segmentsReady , setSegmentsReady ] = useState ( false ) ;
const segmentsReadyRef = useRef ( false ) ;
const maskPathsReadyRef = useRef ( false ) ;
const [ canvasInteractive , setCanvasInteractive ] = useState ( false ) ;
const [ segError , setSegError ] = useState ( '' ) ;
const [ compareMode , setCompareMode ] = useState ( false ) ;
const [ isRefreshing , setIsRefreshing ] = useState ( false ) ;
const [ originSkImg , setOriginSkImg ] = useState ( null ) ;
const originSkImgRef = useRef ( null ) ;
const lowFreqSkImg = paintResourceLayers ? . lowFreqImage ? ? null ;
const highFreqSkImg = paintResourceLayers ? . highFreqImage ? ? null ;
const canvasBaseW = SCREEN _WIDTH - 20 ;
// The "viewport" size for this canvas component: the rect inside which we place
// the contained image, apply the zoom Group transform (centered), clip, and receive
// gestures (tap + two-finger pinch). When we have a real onLayout from the host
// (VisualizationScreen's aspect-fitted canvasFrame, or scheme card preview area),
// we use the *allocated pixel size* directly as our viewport.
//
// Accurate canvasW/H is still critical for:
// - Correct centering of the scaled content around the viewport center.
// - Proper containRect (letterbox/centering of the source photo inside the viewport).
// - Clip rect and gesture-to-canvas coordinate conversion used by painting.
//
// Previously the code fell back to aspect-derived sizes even after layout, which
// could cause the effective viewport to not match the host frame. Using the onLayout
// result (with maxHeight fallback only when no layout yet) keeps zoom, clip, and
// touch mapping consistent with what the user actually sees and touches.
const viewportW = useMemo ( ( ) => {
if ( layoutWidth != null && layoutHeight != null ) {
// Primary path for viz screen and scheme cards: the exact size the host
// decided for this component (after its own safe-area + aspect fit).
return layoutWidth ;
}
if ( ! maxHeight || maxHeight <= 0 ) {
return canvasBaseW ;
}
// Fallback (no layout yet, or other usages that pass maxHeight without a
// tightly sized parent frame). Replicate a contain-style budget.
const availableW = canvasBaseW ;
let auxHeight = 0 ;
if ( showToolbar )
auxHeight += 40 ;
if ( showStatusRow )
auxHeight += 30 ;
if ( showColorBar )
auxHeight += 70 ;
const availableH = Math . max ( 100 , maxHeight - 20 - auxHeight ) ;
const imgAspect = imageSize ? imageSize . w / imageSize . h : 1 ;
const containerAspect = availableW / availableH ;
if ( containerAspect > imgAspect ) {
return Math . floor ( availableH * imgAspect ) ;
}
return availableW ;
} , [ layoutWidth , layoutHeight , maxHeight , showToolbar , showStatusRow , showColorBar , canvasBaseW , imageSize ] ) ;
const viewportH = useMemo ( ( ) => {
if ( layoutWidth != null && layoutHeight != null ) {
return layoutHeight ;
}
if ( ! maxHeight || maxHeight <= 0 ) {
const imgAspect = imageSize ? imageSize . w / imageSize . h : 1 ;
return Math . floor ( viewportW / imgAspect ) ;
}
let auxHeight = 0 ;
if ( showToolbar )
auxHeight += 40 ;
if ( showStatusRow )
auxHeight += 30 ;
if ( showColorBar )
auxHeight += 70 ;
return Math . max ( 100 , maxHeight - 20 - auxHeight ) ;
} , [ layoutWidth , layoutHeight , maxHeight , showToolbar , showStatusRow , showColorBar , viewportW , imageSize ] ) ;
// For the rest of the component, "canvasW/H" means the viewport rect size.
// All zoom center, wrap size, touch layer, Canvas size, clip, containRect, and
// gesture coordinate mapping are based on this, so that two-finger zoom centering
// and single-finger tap painting stay consistent with the host-allocated area.
const canvasW = viewportW ;
const canvasH = viewportH ;
// Refs synced to the latest viewport size so that async callbacks
// (segmentAndPrepareLayers) always read post-layout values instead of
// stale closure captures. This fixes dashed-outline offset to the bottom
// when the initial pathMapRect was computed with fallback SCREEN_WIDTH.
// Declared before segmentAndPrepareLayers so the async body can reference them.
const canvasWRef = useRef ( canvasW ) ;
const canvasHRef = useRef ( canvasH ) ;
// ── Pinch-zoom (two-finger only; single-finger drag/pan disabled) ─────────
const [ zoomScale , setZoomScale ] = useState ( 1 ) ;
const [ panOffset , setPanOffset ] = useState ( { x : 0 , y : 0 } ) ;
// Refs for gesture callbacks (closures don't capture fresh state mid-gesture)
const zoomScaleRef = useRef ( 1 ) ;
const panOffsetRef = useRef ( { x : 0 , y : 0 } ) ;
// Baseline value captured at gesture start to avoid jump on re-creation for pinch
const zoomBaseRef = useRef ( 1 ) ;
// Ref to the latest containRect (the actual placed photo rect inside the viewport).
const containRectRef = useRef ( null ) ;
useEffect ( ( ) => { zoomScaleRef . current = zoomScale ; } , [ zoomScale ] ) ;
useEffect ( ( ) => { panOffsetRef . current = panOffset ; } , [ panOffset ] ) ;
const resetZoom = useCallback ( ( ) => {
setZoomScale ( 1 ) ;
setPanOffset ( { x : 0 , y : 0 } ) ;
zoomScaleRef . current = 1 ;
panOffsetRef . current = { x : 0 , y : 0 } ;
} , [ ] ) ;
const containRect = useMemo ( ( ) => {
if ( ! imageSize )
return null ;
return getContainRect ( canvasW , canvasH , imageSize . w , imageSize . h ) ;
} , [ imageSize , canvasW , canvasH ] ) ;
// Keep a ref in sync so early-defined callbacks can read the
// latest containRect without TDZ or stale closure issues.
useEffect ( ( ) => {
containRectRef . current = containRect ;
} , [ containRect ] ) ;
const segmentRunIdRef = useRef ( 0 ) ;
const lastSegmentKeyRef = useRef ( '' ) ;
const segmentInFlightKeyRef = useRef ( '' ) ;
const maskPathsContainRectRef = useRef ( null ) ;
useEffect ( ( ) => {
paintResourceLayersRef . current = paintResourceLayers ;
} , [ paintResourceLayers ] ) ;
useEffect ( ( ) => {
segmentsReadyRef . current = segmentsReady ;
} , [ segmentsReady ] ) ;
useEffect ( ( ) => {
maskPathsReadyRef . current = maskPathsReady ;
} , [ maskPathsReady ] ) ;
const emitLayersReadyIfReady = useCallback ( ( ) => {
if ( ! segmentsReadyRef . current || ! paintResourceLayersRef . current ) {
return ;
}
emitWatch ( 'layers_ready' , {
regionCount : regionsRef . current . length ,
maskPathsReady : maskPathsReadyRef . current ,
freqLayersReady : true ,
} ) ;
} , [ emitWatch ] ) ;
const emitMaskPathsReadyIfReady = useCallback ( ( ) => {
if ( ! segmentsReadyRef . current || ! maskPathsReadyRef . current ) {
return ;
}
emitWatch ( 'mask_paths_ready' , {
regionCount : regionsRef . current . length ,
maskPathsReady : true ,
freqLayersReady : true ,
} ) ;
} , [ emitWatch ] ) ;
const emitInteractiveIfReady = useCallback ( ( ) => {
if ( ! segmentsReadyRef . current || ! paintResourceLayersRef . current ) {
return ;
}
emitLayersReadyIfReady ( ) ;
emitWatch ( 'interactive' , {
regionCount : regionsRef . current . length ,
maskPathsReady : maskPathsReadyRef . current ,
freqLayersReady : true ,
} ) ;
setCanvasInteractive ( true ) ;
} , [ emitWatch , emitLayersReadyIfReady ] ) ;
const paintColorMapSkImg = useMemo ( ( ) => {
const pick = regionPickRef . current ;
paintColorMapSkImgRef . current ? . dispose ( ) ;
// Early out: no pick buffer yet, or no regions have been painted (initial load or fresh session).
// Avoids repeated full-resolution RGBA allocation + boxBlur (for maskFeather) + Skia.Image.MakeImage
// on every re-render during the hot init path. When initialSession restores paints, paintedRegions
// will update and legitimately trigger a single build of the (feathered) color map.
if ( ! pick || paintedRegions . size === 0 ) {
paintColorMapSkImgRef . current = null ;
return null ;
}
const map = createPaintColorMapForPaint ( pick . buffer , pick . cols , pick . rows , paintedRegions ) ;
paintColorMapSkImgRef . current = map ;
return map ;
} , [ paintedRegions , paintResourcesReady , segmentsReady , getMaskRuntimeRevision ( ) ] ) ;
const paintedRegionConfigRef = useRef ( new Map ( ) ) ;
const segmentAndPrepareLayers = useCallback ( async ( originPath , maskPath ) => {
const runId = ++ segmentRunIdRef . current ;
// isCancelled: a runId bump alone is not enough to abort if the image pair we were asked to process
// is still the latest desired pair from the caller (protects against effect cleanups caused by
// unrelated parent re-renders / state updates that do not change the two images).
const isCancelled = ( ) => {
if ( runId === segmentRunIdRef . current )
return false ;
const desiredOrigin = latestOriginPathRef . current || originPath ;
const desiredMask = latestMaskPathRef . current || maskPath ;
const stillWanted = desiredOrigin === originPath && desiredMask === maskPath ;
return ! stillWanted ;
} ;
const pipeline = getMaskSegmentRuntimeConfig ( ) . pipeline ;
watchStartRef . current = performance . now ( ) ;
lastWatchStateRef . current = null ;
lastWatchSignatureRef . current = null ;
emitWatch ( 'init' ) ;
timeLog ( '▶ start segmentation' ) ;
segmentsReadyRef . current = false ;
setSegmentsReady ( false ) ;
setSegError ( '' ) ;
setActiveBrushIndex ( null ) ;
const clearedPainted = new Map ( ) ;
paintedRegionsRef . current = clearedPainted ;
setPaintedRegions ( clearedPainted ) ;
setPaintHistory ( [ ] ) ;
setHeldRegionId ( null ) ;
setHeldRegionAnchor ( null ) ;
setInitFlashRegionId ( null ) ;
initFlashActiveRef . current = false ;
initFlashIndexRef . current = 0 ;
initFlashListRef . current = [ ] ;
if ( initFlashTimerRef . current ) {
clearTimeout ( initFlashTimerRef . current ) ;
initFlashTimerRef . current = null ;
}
hasAppliedInitialSessionRef . current = false ;
lastExportCacheRef . current = null ;
if ( autoExportDebounceRef . current ) {
clearTimeout ( autoExportDebounceRef . current ) ;
autoExportDebounceRef . current = null ;
}
exportInFlightRef . current = false ;
regionsRef . current = [ ] ;
maskPickRef . current = null ;
regionPickRef . current = null ;
regionMaskDataRef . current = null ;
workBufferRef . current = null ;
paintLayersPromiseRef . current = null ;
setRegionOutlinePaths ( new Map ( ) ) ;
setMaskPathsReady ( false ) ;
setPaintResourcesReady ( false ) ;
setLayersLoading ( false ) ;
baseboardPickMaskRef . current = null ;
kickRegionIdRef . current = null ;
maskPathsContainRectRef . current = null ;
setRegionPalette ( [ ] ) ;
setRegionCount ( 0 ) ;
// Reset high-res snapshot state so a fresh segmentation starts clean (no stale size/ref).
setExportCanvasSize ( null ) ;
setHighResSnapshotEnabled ( false ) ;
const prevLayers = paintResourceLayersRef . current ;
if ( prevLayers ) {
releasePaintResourceLayers ( prevLayers ) ;
paintResourceLayersRef . current = null ;
setPaintResourceLayers ( null ) ;
}
releaseOriginSkImage ( originSkImgRef . current ) ;
originSkImgRef . current = null ;
setOriginSkImg ( null ) ;
try {
timeLog ( '▶ reading origin PNG' ) ;
const originPromise = readPngBgrBuffer ( originPath ) ;
timeLog ( '▶ reading mask PNG' ) ;
const maskPromise = readPngBgrBuffer ( maskPath ) ;
const originDecoded = await originPromise ;
const afterOriginCancelled = isCancelled ( ) ;
if ( afterOriginCancelled ) {
return ;
}
const imgW = originDecoded . cols ;
const imgH = originDecoded . rows ;
setImageSize ( { w : imgW , h : imgH } ) ;
let scale = 1 ;
if ( Math . max ( imgW , imgH ) > pipeline . maxImageLongSide ) {
scale = pipeline . maxImageLongSide / Math . max ( imgW , imgH ) ;
}
const segW = Math . floor ( imgW * scale ) ;
const segH = Math . floor ( imgH * scale ) ;
const minArea = pipeline . minContourArea * scale * scale ;
const workScaledTask = prepareWorkScaledBgrBuffer ( originDecoded . buffer , imgW , imgH , scale ) . then ( workScaled => {
if ( isCancelled ( ) ) {
return workScaled ;
}
workBufferRef . current = workScaled ;
setExportCanvasSize ( { w : workScaled . cols , h : workScaled . rows } ) ;
// Enable the offscreen high-res export canvas as soon as we know the work resolution.
// This gives the hidden <Canvas> time to mount and commit before autoExport/save()
// tries to snapshot it. The inner renderFullResPainted() will draw cheap origin until
// paints + shader textures are ready. This greatly increases the chance that the
// preferred makeImageSnapshot path succeeds for rich exports (instead of falling
// through to the drawAsImage reconstruction).
setHighResSnapshotEnabled ( true ) ;
void loadPaintLayersRef . current ( ) ;
return workScaled ;
} ) ;
const segmentTask = maskPromise . then ( async ( maskDecoded ) => {
if ( isCancelled ( ) ) {
throw new Error ( 'cancelled' ) ;
}
timeLog ( '▶ PNG read completed' ) ;
emitWatch ( 'images_loaded' ) ;
timeLog ( ` ▶ image size: ${ imgW } x ${ imgH } ` ) ;
const { buffer : maskBuffer , cols : maskW , rows : maskH } = maskDecoded ;
const segMaskBuffer = resizeBgrBuffer ( maskBuffer , maskW , maskH , segW , segH ) ;
timeLog ( ` ▶ mask scale: ${ scale . toFixed ( 3 ) } ` ) ;
emitWatch ( 'mask_aligned' ) ;
return extractRegionsFromMaskBufferSync ( segMaskBuffer , segW , segH , {
minArea ,
approxEpsilon : pipeline . contourApproxEpsilon ,
} ) ;
} ) ;
void maskPromise . then ( async ( maskDecoded ) => {
const { buffer : maskBuffer , cols : maskW , rows : maskH } = maskDecoded ;
let pickBuffer ;
if ( maskW !== imgW || maskH !== imgH ) {
pickBuffer = await cv . resizeBgrBuffer ( maskBuffer , maskW , maskH , imgW , imgH ) ;
}
else {
pickBuffer = new Uint8Array ( maskBuffer ) ;
}
if ( runId !== segmentRunIdRef . current ) {
return ;
}
maskPickRef . current = {
buffer : pickBuffer ,
cols : imgW ,
rows : imgH ,
} ;
} ) ;
2026-07-01 05:41:17 +00:00
const [ segmentResultRaw , workScaled ] = await Promise . all ( [
2026-06-26 07:23:52 +00:00
segmentTask ,
workScaledTask ,
] ) ;
if ( isCancelled ( ) ) {
return ;
}
2026-07-01 05:41:17 +00:00
let segmentResult = segmentResultRaw ;
if ( getMaskSegmentRuntimeConfig ( ) . mask . splitWalls ) {
segmentResult = splitWallRegionsByTexture ( segmentResult , workScaled . buffer , segW , segH , minArea ) ;
}
2026-06-26 07:23:52 +00:00
const paintPromise = paintLayersPromiseRef . current ? ? Promise . resolve ( ) ;
emitWatch ( 'mask_sampled' , { regionCount : segmentResult . regions . length } ) ;
timeLog ( ` ▶ segmentation completed, valid regions: ${ segmentResult . regions . length } ` ) ;
const validRegions = segmentResult . regions ;
if ( _ _DEV _ _ && validRegions . length === 0 ) {
console . warn ( '[MaskSegment] not recognized any valid regions, please check if the mask is a pure color region image' ) ;
}
let finalRegions = validRegions ;
if ( finalRegions . length > pipeline . maxRegions ) {
finalRegions = finalRegions . slice ( 0 , pipeline . maxRegions ) ;
}
regionsRef . current = finalRegions ;
regionPickRef . current = segmentResult . pickMap ;
regionMaskDataRef . current = {
labels : segmentResult . labels ,
baseboardBinary : segmentResult . baseboardBinary ,
cols : segmentResult . segCols ,
rows : segmentResult . segRows ,
2026-07-01 05:41:17 +00:00
wallSubLabels : segmentResult . wallSubLabels ,
2026-06-26 07:23:52 +00:00
} ;
baseboardPickMaskRef . current = null ;
kickRegionIdRef . current =
finalRegions . find ( reg => reg . thinStrip ) ? . id ? ? null ;
const pathMapRect = getContainRect ( canvasWRef . current , canvasHRef . current , imgW , imgH ) ;
maskPathsContainRectRef . current = pathMapRect ;
setRegionOutlinePaths ( new Map ( ) ) ;
setMaskPathsReady ( false ) ;
setRegionPalette ( finalRegions ) ;
setRegionCount ( finalRegions . length ) ;
lastSegmentKeyRef . current = ` ${ originPath } | ${ maskPath } ` ;
segmentsReadyRef . current = true ;
setSegmentsReady ( true ) ;
emitWatch ( 'regions_ready' , { regionCount : finalRegions . length } ) ;
emitInteractiveIfReady ( ) ;
void ( async ( ) => {
if ( runId !== segmentRunIdRef . current ) {
return ;
}
const pathMaskData = downsampleMaskDataForPaths ( regionMaskDataRef . current , pipeline . maskPathMaxLongSide ) ;
const outlines = buildAllRegionOutlinePaths ( finalRegions , pathMaskData , pathMapRect ) ;
if ( runId !== segmentRunIdRef . current ) {
return ;
}
setRegionOutlinePaths ( outlines ) ;
setMaskPathsReady ( true ) ;
maskPathsReadyRef . current = true ;
emitMaskPathsReadyIfReady ( ) ;
} ) ( ) ;
void ( async ( ) => {
if ( runId !== segmentRunIdRef . current ) {
return ;
}
await paintPromise ;
if ( runId !== segmentRunIdRef . current ) {
return ;
}
baseboardPickMaskRef . current = upscaleBinaryMask ( segmentResult . baseboardBinary , segW , segH , imgW , imgH ) ;
} ) ( ) ;
}
catch ( e ) {
const msg = e instanceof Error ? e . message : String ( e ) ;
if ( ! isCancelled ( ) ) {
console . error ( '[SDK-SEGMENT] segmentation failed' , e ) ;
setSegError ( msg ) ;
reportError ( msg , e ) ;
}
}
} , [ emitWatch , emitInteractiveIfReady , emitMaskPathsReadyIfReady , reportError ] ) ;
const loadPaintLayersIfNeeded = useCallback ( ( ) => {
2026-07-01 03:24:43 +00:00
// Use the ref (synced in segmentAndPrepareLayers cleanup) rather than
// paintResourcesReady state — after switching origin/mask URLs the state
// may still read true for one frame while layers were already released.
if ( paintResourceLayersRef . current ) {
2026-06-26 07:23:52 +00:00
return Promise . resolve ( ) ;
}
if ( paintLayersPromiseRef . current ) {
return paintLayersPromiseRef . current ;
}
const work = workBufferRef . current ;
if ( ! work ) {
return Promise . resolve ( ) ;
}
const runId = segmentRunIdRef . current ;
let promise ;
promise = ( async ( ) => {
setLayersLoading ( true ) ;
timeLog ( '▶ start loading paint shader textures' ) ;
try {
const result = await preparePaintResourcesFromWorkBuffer ( work . buffer , work . cols , work . rows , layers => {
if ( runId !== segmentRunIdRef . current ) {
releaseFreqLayerImages ( layers ) ;
return ;
}
setPaintResourceLayers ( layers ) ;
paintResourceLayersRef . current = layers ;
setPaintResourcesReady ( true ) ;
timeLog ( '▶ paint shader textures ready' ) ;
emitInteractiveIfReady ( ) ;
} ) ;
if ( runId !== segmentRunIdRef . current ) {
if ( result ) {
result . originImage . dispose ( ) ;
releasePaintResourceLayers ( result . layers ) ;
}
return ;
}
if ( ! result ) {
return ;
}
releaseOriginSkImage ( originSkImgRef . current ) ;
originSkImgRef . current = result . originImage ;
setOriginSkImg ( result . originImage ) ;
timeLog ( '▶ origin Skia work resolution' ) ;
if ( ! paintResourceLayersRef . current ) {
setPaintResourceLayers ( result . layers ) ;
paintResourceLayersRef . current = result . layers ;
setPaintResourcesReady ( true ) ;
}
emitInteractiveIfReady ( ) ;
}
catch ( error ) {
if ( _ _DEV _ _ ) {
console . warn ( '[MaskSegment] failed to prepare paint shader textures' , error ) ;
}
}
finally {
setLayersLoading ( false ) ;
if ( paintLayersPromiseRef . current === promise ) {
paintLayersPromiseRef . current = null ;
}
}
} ) ( ) ;
paintLayersPromiseRef . current = promise ;
return promise ;
2026-07-01 03:24:43 +00:00
} , [ emitInteractiveIfReady ] ) ;
2026-06-26 07:23:52 +00:00
loadPaintLayersRef . current = loadPaintLayersIfNeeded ;
const pickOriginImage = async ( ) => {
const res = await launchImageLibrary ( { mediaType : 'photo' } ) ;
const uri = res . assets ? . [ 0 ] ? . uri ;
if ( ! uri ) {
return ;
}
const pngPath = await cv . ensurePngPath ( uri , ` picked_origin_ ${ Date . now ( ) } .png ` ) ;
setOriginImgPath ( pngPath ) ;
} ;
const pickMaskImage = async ( ) => {
const res = await launchImageLibrary ( { mediaType : 'photo' } ) ;
const uri = res . assets ? . [ 0 ] ? . uri ;
if ( ! uri ) {
return ;
}
const pngPath = await cv . ensurePngPath ( uri , ` picked_mask_ ${ Date . now ( ) } .png ` ) ;
setMaskImgPath ( pngPath ) ;
} ;
const clearCacheAndResegment = useCallback ( async ( ) => {
if ( isRefreshing || ! originImgPath || ! maskImgPath ) {
return ;
}
setIsRefreshing ( true ) ;
try {
resetZoom ( ) ;
const layers = paintResourceLayersRef . current ;
if ( layers ) {
releasePaintResourceLayers ( layers ) ;
paintResourceLayersRef . current = null ;
setPaintResourceLayers ( null ) ;
}
await clearDerivedImageCache ( ) ;
lastSegmentKeyRef . current = '' ;
await segmentAndPrepareLayers ( originImgPath , maskImgPath ) ;
}
catch ( e ) {
const msg = e instanceof Error ? e . message : String ( e ) ;
setSegError ( msg ) ;
reportError ( msg , e ) ;
}
finally {
setIsRefreshing ( false ) ;
}
} , [
isRefreshing ,
originImgPath ,
maskImgPath ,
segmentAndPrepareLayers ,
reportError ,
resetZoom ,
] ) ;
useEffect ( ( ) => {
if ( ! originImgPath || ! maskImgPath ) {
return ;
}
const segmentKey = ` ${ originImgPath } | ${ maskImgPath } ` ;
if ( lastSegmentKeyRef . current === segmentKey ||
segmentInFlightKeyRef . current === segmentKey ) {
return ;
}
segmentInFlightKeyRef . current = segmentKey ;
void segmentAndPrepareLayers ( originImgPath , maskImgPath ) . finally ( ( ) => {
if ( segmentInFlightKeyRef . current === segmentKey ) {
segmentInFlightKeyRef . current = '' ;
}
} ) ;
return ( ) => {
segmentRunIdRef . current += 1 ;
if ( segmentInFlightKeyRef . current === segmentKey ) {
segmentInFlightKeyRef . current = '' ;
}
const layers = paintResourceLayersRef . current ;
if ( layers ) {
releasePaintResourceLayers ( layers ) ;
paintResourceLayersRef . current = null ;
}
paintColorMapSkImgRef . current ? . dispose ( ) ;
paintColorMapSkImgRef . current = null ;
regionsRef . current = [ ] ;
// Also reset any pending init flash state (the full reset will also run at start of
// the next segmentAndPrepareLayers, but this keeps things clean if the effect
// re-triggers segmentation while a flash sequence was in flight).
initFlashListRef . current = [ ] ;
initFlashActiveRef . current = false ;
initFlashIndexRef . current = 0 ;
if ( initFlashTimerRef . current ) {
clearTimeout ( initFlashTimerRef . current ) ;
initFlashTimerRef . current = null ;
}
setInitFlashRegionId ( null ) ;
} ;
} , [ originImgPath , maskImgPath ] ) ;
const buildPaintedRecords = useCallback ( ( ) => {
const records = [ ] ;
// Prefer the live ref so getPaintedRegions() / session() used by host for
// colorParams at save time sees the exact same data as the save() composite.
const src = paintedRegionsRef . current && paintedRegionsRef . current . size > 0
? paintedRegionsRef . current
: paintedRegions ;
for ( const [ regionId , color ] of src ) {
const region = regionsRef . current . find ( reg => reg . id === regionId ) ;
records . push ( {
regionId ,
regionName : region ? . name ? ? String ( regionId ) ,
color ,
configJson : paintedRegionConfigRef . current . get ( regionId ) ,
} ) ;
}
return records ;
} , [ paintedRegions ] ) ;
const restoreSession = useCallback ( ( session ) => {
const nextPainted = new Map ( ) ;
paintedRegionConfigRef . current = new Map ( ) ;
const currentRegions = regionsRef . current || [ ] ;
const nameToRealId = new Map ( ) ;
for ( const r of currentRegions ) {
if ( r && r . name ) {
const key = String ( r . name ) . trim ( ) . toLowerCase ( ) ;
if ( key && ! nameToRealId . has ( key ) ) {
nameToRealId . set ( key , r . id ) ;
}
}
}
// Build oldId -> regionName from the incoming seed (for paintHistory remapping).
const oldIdToName = new Map ( ) ;
for ( const item of session . painted || [ ] ) {
if ( item && typeof item . regionId === 'number' && item . regionName ) {
oldIdToName . set ( item . regionId , String ( item . regionName ) ) ;
}
}
// Resolve directly by name. Name is unique within a segmentation so there is no
// need for id-based heuristics — the region name is the authoritative identity.
for ( const item of session . painted || [ ] ) {
if ( ! item )
continue ;
let targetId = item . regionId ;
if ( item . regionName ) {
const key = String ( item . regionName ) . trim ( ) . toLowerCase ( ) ;
const realId = nameToRealId . get ( key ) ;
if ( typeof realId === 'number' ) {
targetId = realId ;
}
}
nextPainted . set ( targetId , item . color ) ;
if ( item . configJson ) {
paintedRegionConfigRef . current . set ( targetId , item . configJson ) ;
}
}
// Remap paintHistory via names.
const resolvedHistory = [ ] ;
for ( const oldId of session . paintHistory || [ ] ) {
if ( typeof oldId !== 'number' )
continue ;
const nm = oldIdToName . get ( oldId ) ;
if ( nm ) {
const real = nameToRealId . get ( String ( nm ) . trim ( ) . toLowerCase ( ) ) ;
if ( typeof real === 'number' ) {
resolvedHistory . push ( real ) ;
continue ;
}
}
// Fallback: keep oldId if it happens to be valid in current segmentation.
if ( currentRegions . some ( ( r ) => r && r . id === oldId ) ) {
resolvedHistory . push ( oldId ) ;
}
}
paintedRegionsRef . current = nextPainted ;
setPaintedRegions ( nextPainted ) ;
setPaintHistory ( resolvedHistory . length ? resolvedHistory : [ ... ( session . paintHistory || [ ] ) ] ) ;
if ( session . currentColor ) {
setCustomPaintColor ( session . currentColor ) ;
customPaintConfigJsonRef . current = session . currentColorConfigJson ;
setActiveBrushIndex ( null ) ;
}
} , [ ] ) ;
useEffect ( ( ) => {
if ( ! initialSession || ! segmentsReady ) {
return ;
}
if ( hasAppliedInitialSessionRef . current ) {
// Already seeded once for this canvas instance / segmentation. Do not re-apply on
// subsequent initialSession prop changes (host may pass new object refs when its
// vizSlots or selected brush changes). Re-applying would reset paintedRegions to
// whatever the (often partial) seed snapshot contains.
return ;
}
hasAppliedInitialSessionRef . current = true ;
restoreSession ( initialSession ) ;
} , [ initialSession , segmentsReady , restoreSession ] ) ;
useEffect ( ( ) => {
return ( ) => {
if ( initFlashTimerRef . current ) {
clearTimeout ( initFlashTimerRef . current ) ;
}
} ;
} , [ ] ) ;
const stopInitRegionFlash = useCallback ( ( ) => {
initFlashActiveRef . current = false ;
if ( initFlashTimerRef . current ) {
clearTimeout ( initFlashTimerRef . current ) ;
initFlashTimerRef . current = null ;
}
setInitFlashRegionId ( null ) ;
} , [ ] ) ;
const startInitRegionFlashLoop = useCallback ( ( ) => {
const ir = getMaskSegmentRuntimeConfig ( ) . interaction ;
if ( ! ir . enableInitRegionFlash ) {
return ;
}
if ( initFlashActiveRef . current ) {
return ;
}
const allRegions = regionsRef . current ;
if ( allRegions . length === 0 ) {
return ;
}
// Filter out painted regions and tiny regions (noise / thin strips
// that produce negligible overlays). Threshold: 0.2% of total image area.
const imgSize = imageSizeRef2 . current ;
const minFlashArea = imgSize
? Math . max ( 500 , imgSize . w * imgSize . h * 0.002 )
: 500 ;
initFlashListRef . current = allRegions . filter ( ( r ) => ! paintedRegionsRef . current . has ( r . id ) && r . area >= minFlashArea ) ;
initFlashActiveRef . current = true ;
initFlashIndexRef . current = 0 ;
const showNext = ( ) => {
if ( ! initFlashActiveRef . current || initFlashListRef . current . length === 0 ) {
return ;
}
const list = initFlashListRef . current ;
const idx = initFlashIndexRef . current ;
if ( idx >= list . length ) {
// One full pass of dashed outline flashes (one per *unpainted* region) is enough.
// Stop automatically; onUserInteraction can stop early.
stopInitRegionFlash ( ) ;
return ;
}
setInitFlashRegionId ( list [ idx ] . id ) ;
initFlashIndexRef . current += 1 ;
initFlashTimerRef . current = setTimeout ( showNext , ir . initRegionFlashMs ) ;
} ;
showNext ( ) ;
} , [ ] ) ;
const onUserInteraction = useCallback ( ( ) => {
stopInitRegionFlash ( ) ;
} , [ stopInitRegionFlash ] ) ;
// Once any region has been painted, stop the entire discovery flash immediately.
// No individual pruning — it's all or nothing: either 0 painted → cycle through
// all regions, or ≥1 painted → stop flashing entirely.
useEffect ( ( ) => {
if ( ! initFlashActiveRef . current )
return ;
if ( paintedRegionsRef . current . size > 0 ) {
stopInitRegionFlash ( ) ;
}
} , [ paintedRegions , stopInitRegionFlash ] ) ;
useEffect ( ( ) => {
if ( segmentsReady && maskPathsReady && containRect && regionCount > 0 && canvasInteractive ) {
if ( ! initFlashActiveRef . current ) {
startInitRegionFlashLoop ( ) ;
}
return ;
}
if ( ! segmentsReady ) {
stopInitRegionFlash ( ) ;
setCanvasInteractive ( false ) ;
}
} , [
segmentsReady ,
maskPathsReady ,
containRect ,
regionCount ,
canvasInteractive ,
startInitRegionFlashLoop ,
stopInitRegionFlash ,
] ) ;
const getActiveBrushColor = useCallback ( ( ) => {
if ( customPaintColor ) {
return customPaintColor ;
}
if ( activeBrushIndex == null ) {
return null ;
}
return paintPalette [ activeBrushIndex ] ? ? null ;
} , [ customPaintColor , activeBrushIndex , paintPalette ] ) ;
const hasActiveBrush = customPaintColor != null || activeBrushIndex != null ;
const applyPaintToRegion = useCallback ( ( targetRegionId , color ) => {
let applied = false ;
setPaintedRegions ( prev => {
const existing = prev . get ( targetRegionId ) ;
if ( existing && bgrColorEquals ( existing , color ) ) {
return prev ;
}
applied = true ;
setPaintHistory ( history => {
const last = history [ history . length - 1 ] ;
if ( last === targetRegionId ) {
return history ;
}
return [ ... history . filter ( id => id !== targetRegionId ) , targetRegionId ] ;
} ) ;
const next = new Map ( prev ) ;
next . set ( targetRegionId , color ) ;
paintedRegionsRef . current = next ;
return next ;
} ) ;
if ( applied ) {
const configJson = customPaintConfigJsonRef . current ? ?
paintedRegionConfigRef . current . get ( targetRegionId ) ;
if ( customPaintConfigJsonRef . current ) {
paintedRegionConfigRef . current . set ( targetRegionId , customPaintConfigJsonRef . current ) ;
}
const region = regionsRef . current . find ( reg => reg . id === targetRegionId ) ;
onPaintCallbackRef . current ? . ( {
kind : 'painted' ,
regionId : targetRegionId ,
regionName : region ? . name ? ? String ( targetRegionId ) ,
color ,
configJson ,
} ) ;
}
} , [ ] ) ;
const findRegionAtPoint = useCallback ( ( x , y , strict = false ) => {
if ( ! segmentsReady || ! imageSize || regionsRef . current . length === 0 ) {
return null ;
}
const norm = canvasToNormalized ( x , y , canvasW , canvasH , imageSize . w , imageSize . h ) ;
if ( ! norm ) {
return null ;
}
const regionPick = regionPickRef . current ;
if ( regionPick ) {
const pickHit = lookupRegionFromPickMap ( norm . x , norm . y , regionPick , strict
? 0
: getMaskSegmentRuntimeConfig ( ) . interaction . pickMapSearchRadiusPx ) ;
if ( pickHit != null ) {
return pickHit ;
}
if ( strict ) {
return null ;
}
}
const pick = maskPickRef . current ;
const kickId = kickRegionIdRef . current ;
if ( ! strict ) {
const polygonHit = resolveRegionHit ( regionsRef . current , norm . x , norm . y ) ;
if ( polygonHit != null ) {
return polygonHit ;
}
}
if ( pick && kickId != null ) {
const pickMask = baseboardPickMaskRef . current ;
const kickHit = pickKickRegionFromMask ( norm . x , norm . y , pick , kickId , pickMask , strict ) ;
if ( kickHit != null ) {
return kickHit ;
}
if ( ! strict ) {
const kickReg = regionsRef . current . find ( reg => reg . id === kickId ) ;
if ( kickReg && pickKickNearStrip ( norm . x , norm . y , kickReg ) ) {
return kickId ;
}
}
}
return null ;
} , [ segmentsReady , imageSize , canvasW , canvasH ] ) ;
const selectBrushColor = useCallback ( ( brushIndex ) => {
onUserInteraction ( ) ;
setCustomPaintColor ( null ) ;
customPaintConfigJsonRef . current = undefined ;
setActiveBrushIndex ( brushIndex ) ;
void loadPaintLayersIfNeeded ( ) ;
} , [ onUserInteraction , loadPaintLayersIfNeeded ] ) ;
const onCanvasTap = useCallback ( ( x , y ) => {
onUserInteraction ( ) ;
if ( ! segmentsReady ||
! imageSize ||
regionsRef . current . length === 0 ||
! hasActiveBrush ||
! paintResourcesReady ||
disabled ) {
return ;
}
const regionId = findRegionAtPoint ( x , y ) ;
if ( regionId == null ) {
return ;
}
const brushColor = getActiveBrushColor ( ) ;
if ( ! brushColor ) {
return ;
}
applyPaintToRegion ( regionId , brushColor ) ;
if ( ! paintResourcesReady && ! paintLayersPromiseRef . current ) {
void loadPaintLayersIfNeeded ( ) ;
}
} , [
segmentsReady ,
imageSize ,
hasActiveBrush ,
paintResourcesReady ,
disabled ,
findRegionAtPoint ,
getActiveBrushColor ,
applyPaintToRegion ,
onUserInteraction ,
loadPaintLayersIfNeeded ,
] ) ;
// ── Canvas-size & state refs for gesture callbacks ─────────────────────
// Declared after their targets so useRef receives the current value.
// canvasWRef / canvasHRef are declared earlier (before segmentAndPrepareLayers)
// so the async body can read the latest layout size. Their sync useEffect is below.
const hasActiveBrushRef = useRef ( hasActiveBrush ) ;
const disabledRef = useRef ( disabled ) ;
const segmentsReadyRef2 = useRef ( segmentsReady ) ;
const imageSizeRef2 = useRef ( imageSize ) ;
useEffect ( ( ) => { canvasWRef . current = canvasW ; } , [ canvasW ] ) ;
useEffect ( ( ) => { canvasHRef . current = canvasH ; } , [ canvasH ] ) ;
useEffect ( ( ) => { hasActiveBrushRef . current = hasActiveBrush ; } , [ hasActiveBrush ] ) ;
useEffect ( ( ) => { disabledRef . current = disabled ; } , [ disabled ] ) ;
useEffect ( ( ) => { segmentsReadyRef2 . current = segmentsReady ; } , [ segmentsReady ] ) ;
useEffect ( ( ) => { imageSizeRef2 . current = imageSize ; } , [ imageSize ] ) ;
// Stable refs for functions called inside gesture closures
const findRegionAtPointRef = useRef ( findRegionAtPoint ) ;
const onCanvasTapRef = useRef ( onCanvasTap ) ;
2026-07-01 03:24:43 +00:00
const onUserInteractionRef = useRef ( onUserInteraction ) ;
const resetZoomRef = useRef ( resetZoom ) ;
2026-06-26 07:23:52 +00:00
useEffect ( ( ) => { findRegionAtPointRef . current = findRegionAtPoint ; } , [ findRegionAtPoint ] ) ;
useEffect ( ( ) => { onCanvasTapRef . current = onCanvasTap ; } , [ onCanvasTap ] ) ;
2026-07-01 03:24:43 +00:00
useEffect ( ( ) => { onUserInteractionRef . current = onUserInteraction ; } , [ onUserInteraction ] ) ;
useEffect ( ( ) => { resetZoomRef . current = resetZoom ; } , [ resetZoom ] ) ;
2026-06-26 07:23:52 +00:00
const undoSelection = useCallback ( ( ) => {
onUserInteraction ( ) ;
setPaintHistory ( history => {
if ( history . length === 0 ) {
return history ;
}
const lastId = history [ history . length - 1 ] ;
setPaintedRegions ( prev => {
const next = new Map ( prev ) ;
next . delete ( lastId ) ;
paintedRegionsRef . current = next ; // sync for imperative readers
return next ;
} ) ;
paintedRegionConfigRef . current . delete ( lastId ) ;
return history . slice ( 0 , - 1 ) ;
} ) ;
} , [ onUserInteraction ] ) ;
const clearAllPaint = useCallback ( ( ) => {
onUserInteraction ( ) ;
const cleared = new Map ( ) ;
paintedRegionsRef . current = cleared ;
setPaintedRegions ( cleared ) ;
setPaintHistory ( [ ] ) ;
paintedRegionConfigRef . current = new Map ( ) ;
lastExportCacheRef . current = null ;
resetZoom ( ) ;
} , [ onUserInteraction , resetZoom ] ) ;
const captureHighResExportPngBase64 = useCallback ( async ( ) => {
try {
const c = highResExportCanvasRef . current ;
const sz = exportCanvasSize ;
if ( ! c || ! sz ) {
return undefined ;
}
let snap = c . makeImageSnapshot ? . ( ) ;
if ( ! snap ) {
await new Promise ( ( r ) => requestAnimationFrame ( ( ) => r ( ) ) ) ;
snap = c . makeImageSnapshot ? . ( ) ;
}
if ( ! snap ) {
return undefined ;
}
const enc = snap . encodeToBase64 ;
if ( typeof enc !== 'function' ) {
return undefined ;
}
const b64 = enc . call ( snap ) || '' ;
return b64 . length > 0 ? b64 : undefined ;
}
catch ( e ) {
console . warn ( '[VIZ-SAVE] highResExportCanvas makeImageSnapshot failed:' , e ) ;
return undefined ;
}
} , [ exportCanvasSize ] ) ;
const runExportPipeline = useCallback ( async ( livePainted , destDir ) => {
const work = workBufferRef . current ;
const pick = regionPickRef . current ;
if ( ! work || ! pick ) {
throw new Error ( 'image not ready, cannot save' ) ;
}
let snapshotPngBase64 ;
if ( livePainted . size > 0 ) {
snapshotPngBase64 = await captureHighResExportPngBase64 ( ) ;
}
const layers = paintResourceLayersRef . current ;
const map = paintColorMapSkImgRef . current ;
const originForExport = originSkImgRef . current ? ? layers ? . lowFreqImage ? ? null ;
const shaderTextures = ! snapshotPngBase64 && originForExport && layers && map && paintResourcesReady
? {
originImage : originForExport ,
paintColorMap : map ,
lowFreqImage : layers . lowFreqImage ,
highFreqImage : layers . highFreqImage ,
}
: undefined ;
const result = await compositePaintedImage ( {
originBuffer : work . buffer ,
cols : work . cols ,
rows : work . rows ,
pickBuffer : pick . buffer ,
paintedRegions : livePainted ,
destDir ,
... ( snapshotPngBase64 ? { exportPngBase64 : snapshotPngBase64 } : { } ) ,
shaderTextures ,
renderWidth : work . cols ,
renderHeight : work . rows ,
} ) ;
lastExportCacheRef . current = {
fingerprint : paintedRegionsFingerprint ( livePainted ) ,
result ,
} ;
return result ;
} , [ captureHighResExportPngBase64 , paintResourcesReady ] ) ;
// Debounced background export — pre-warms the save() cache after each paint change.
useEffect ( ( ) => {
if ( ! autoExportOnReady ) {
return ;
}
if ( ! segmentsReady || ! paintResourcesReady ) {
return ;
}
if ( initialSession && ! hasAppliedInitialSessionRef . current ) {
return ;
}
if ( paintedRegions . size === 0 ) {
return ;
}
if ( autoExportDebounceRef . current ) {
clearTimeout ( autoExportDebounceRef . current ) ;
}
autoExportDebounceRef . current = setTimeout ( ( ) => {
autoExportDebounceRef . current = null ;
if ( exportInFlightRef . current ) {
return ;
}
const livePainted = paintedRegionsRef . current ;
if ( ! livePainted || livePainted . size === 0 ) {
return ;
}
exportInFlightRef . current = true ;
void ( async ( ) => {
try {
const result = await runExportPipeline ( livePainted ) ;
onExportedRef . current ? . ( result ) ;
}
catch ( e ) {
console . log ( '[VIZ-SAVE] debounced autoExport threw (non-fatal):' , e ) ;
}
finally {
exportInFlightRef . current = false ;
}
} ) ( ) ;
} , 400 ) ;
return ( ) => {
if ( autoExportDebounceRef . current ) {
clearTimeout ( autoExportDebounceRef . current ) ;
autoExportDebounceRef . current = null ;
}
} ;
} , [
autoExportOnReady ,
segmentsReady ,
paintResourcesReady ,
initialSession ,
paintedRegions ,
runExportPipeline ,
] ) ;
useImperativeHandle ( ref , ( ) => ( {
reset : undoSelection ,
swap : ( showOrigin ) => {
onUserInteraction ( ) ;
if ( showOrigin === undefined ) {
setCompareMode ( v => ! v ) ;
}
else {
setCompareMode ( showOrigin ) ;
}
} ,
save : async ( options ) => {
const livePainted = paintedRegionsRef . current && paintedRegionsRef . current . size > 0
? paintedRegionsRef . current
: paintedRegions ;
const fp = paintedRegionsFingerprint ( livePainted ) ;
const cached = lastExportCacheRef . current ;
if ( cached ? . fingerprint === fp && cached . result ) {
return resolveExportResultForDestDir ( cached . result , options ? . destDir ) ;
}
try {
return await runExportPipeline ( livePainted , options ? . destDir ) ;
}
catch ( e ) {
console . error ( '[VIZ-SAVE] SDK save() composite threw:' , e ) ;
throw e ;
}
} ,
getLastExport : ( ) => lastExportCacheRef . current ? . result ? ? null ,
session : ( ) => ( {
version : 1 ,
originUrl : originUrlRef . current ,
maskUrl : maskUrlRef . current ,
painted : buildPaintedRecords ( ) ,
paintHistory : [ ... paintHistory ] ,
currentColor : customPaintColor ? ? undefined ,
currentColorConfigJson : customPaintConfigJsonRef . current ,
savedAt : Date . now ( ) ,
} ) ,
loadSession : restoreSession ,
setPaintColor : ( color , configJson ) => {
setCustomPaintColor ( color ) ;
customPaintConfigJsonRef . current = configJson ;
setActiveBrushIndex ( null ) ;
} ,
setMaskConfig : config => {
setMaskSegmentRuntimeConfig ( { maskConfig : config } ) ;
runtimeRef . current = getMaskSegmentRuntimeConfig ( ) ;
if ( originImgPath && maskImgPath ) {
lastSegmentKeyRef . current = '' ;
void segmentAndPrepareLayers ( originImgPath , maskImgPath ) ;
}
} ,
clearAllPaint ,
undoSelection ,
resegment : clearCacheAndResegment ,
getRegions : ( ) => [ ... regionsRef . current ] ,
getPaintedRegions : ( ) => buildPaintedRecords ( ) ,
} ) , [
undoSelection ,
onUserInteraction ,
paintedRegions ,
paintHistory ,
customPaintColor ,
buildPaintedRecords ,
restoreSession ,
clearAllPaint ,
clearCacheAndResegment ,
runExportPipeline ,
] ) ;
const [ regionOutlinePaths , setRegionOutlinePaths ] = useState ( new Map ( ) ) ;
useEffect ( ( ) => {
if ( ! segmentsReady || ! containRect || regionPalette . length === 0 ) {
if ( ! segmentsReady ) {
setRegionOutlinePaths ( new Map ( ) ) ;
setMaskPathsReady ( false ) ;
maskPathsContainRectRef . current = null ;
}
return ;
}
const maskData = regionMaskDataRef . current ;
if ( ! maskData ) {
setRegionOutlinePaths ( new Map ( ) ) ;
setMaskPathsReady ( false ) ;
return ;
}
if ( maskPathsContainRectRef . current &&
rectsEqual ( maskPathsContainRectRef . current , containRect ) ) {
return ;
}
const pathStart = _ _DEV _ _ ? performance . now ( ) : 0 ;
const pathMaskData = downsampleMaskDataForPaths ( maskData , getMaskSegmentRuntimeConfig ( ) . pipeline . maskPathMaxLongSide ) ;
const outlines = buildAllRegionOutlinePaths ( regionPalette , pathMaskData , containRect ) ;
maskPathsContainRectRef . current = containRect ;
setRegionOutlinePaths ( outlines ) ;
setMaskPathsReady ( true ) ;
maskPathsReadyRef . current = true ;
emitMaskPathsReadyIfReady ( ) ;
} , [ segmentsReady , containRect , regionPalette , emitMaskPathsReadyIfReady ] ) ;
const heldOutlinePath = useMemo ( ( ) => {
if ( heldRegionId == null ||
heldRegionAnchor == null ||
! containRect ||
regionPalette . length === 0 ) {
return null ;
}
const maskData = regionMaskDataRef . current ;
if ( ! maskData ) {
return null ;
}
const pathMaskData = downsampleMaskDataForPaths ( maskData , getMaskSegmentRuntimeConfig ( ) . pipeline . maskPathMaxLongSide ) ;
return buildRegionOutlinePathForRegion ( heldRegionId , regionPalette , pathMaskData , containRect , heldRegionAnchor ) ;
} , [ heldRegionId , heldRegionAnchor , containRect , regionPalette ] ) ;
const renderImageLayer = ( image , opacity = 1 ) => {
if ( ! image || ! containRect )
return null ;
return ( _jsx ( SkiaImage , { image : image , x : containRect . x , y : containRect . y , width : containRect . w , height : containRect . h , opacity : opacity } ) ) ;
} ;
const renderRegionMaskOverlay = ( regionId , keyPrefix ) => {
const path = regionOutlinePaths . get ( regionId ) ;
if ( ! path || ! containRect )
return null ;
return ( _jsxs ( _Fragment , { children : [ _jsx ( Path , { path : path , color : paintRuntime . regionOverlayFill , style : "fill" , opacity : 0.05 } , ` ${ keyPrefix } -fill- ${ regionId } ` ) , _jsx ( Path , { path : path , color : paintRuntime . regionOverlayFill , style : "stroke" , strokeWidth : 3 , strokeJoin : "round" , antiAlias : true , children : _jsx ( DashPathEffect , { intervals : [ 8 , 6 ] } ) } , ` ${ keyPrefix } -stroke- ${ regionId } ` ) ] } ) ) ;
} ;
// ── Zoom transform for the Skia Group ─────────────────────────────────
// Note: single-finger pan/drag is disabled. Zoom is always centered around the
// viewport center (no additional panOffset translation). The panOffset state
// is kept (and forced to 0 during pinch) for compatibility with screenToCanvasCoords
// inverse mapping used by tap/paint logic, and for resetZoom.
const zoomTransform = useMemo ( ( ) => {
if ( zoomScale <= 1 )
return undefined ;
return [
{ translateX : 0 , translateY : 0 } ,
{ translateX : canvasW / 2 , translateY : canvasH / 2 } ,
{ scale : zoomScale } ,
{ translateX : - canvasW / 2 , translateY : - canvasH / 2 } ,
] ;
} , [ zoomScale , canvasW , canvasH ] ) ;
const renderDraw = ( ) => {
const displayImg = originSkImg ? ? lowFreqSkImg ;
if ( ! displayImg || ! containRect ) {
return null ;
}
const showOverlay = ! compareMode && segmentsReady ;
const shaderReady = paintColorMapSkImg &&
lowFreqSkImg &&
highFreqSkImg &&
paintResourcesReady ;
const useShader = ! compareMode && paintedRegions . size > 0 && shaderReady ;
const shaderOrigin = originSkImg ? ? lowFreqSkImg ;
// Background color for areas of the viewport that become visible around the
// zoomed (centered) photo content. Using a light gray that matches common
// preview container backgrounds prevents "mosaic-like colored blocks"
// (shader leakage or edge sampling artifacts) from appearing outside the
// photo rect when the content is scaled up.
const previewBg = '#F0F1F3' ;
return ( _jsxs ( _Fragment , { children : [ _jsx ( Rect , { x : 0 , y : 0 , width : canvasW , height : canvasH , color : previewBg } ) , _jsx ( Group , { clip : Skia . XYWHRect ( 0 , 0 , canvasW , canvasH ) , children : _jsxs ( Group , { transform : zoomTransform , children : [ useShader && shaderOrigin ? ( _jsx ( PaintShaderLayer , { originImage : shaderOrigin , paintColorMap : paintColorMapSkImg , lowFreqImage : lowFreqSkImg , highFreqImage : highFreqSkImg , x : containRect . x , y : containRect . y , width : containRect . w , height : containRect . h , showOrigin : false } ) ) : ( renderImageLayer ( displayImg ) ) , showOverlay &&
initFlashRegionId != null &&
renderRegionMaskOverlay ( initFlashRegionId , 'init-overlay' ) , showOverlay &&
heldRegionId != null &&
! paintedRegions . has ( heldRegionId ) &&
renderRegionMaskOverlay ( heldRegionId , 'hold-overlay' ) ] } ) } ) ] } ) ) ;
} ;
// Full-bleed (0,0 to work size) composition for the high-res export snapshot canvas.
// No UI overlays (dashes, held, flash). When painted + shader ready we use the exact
// same PaintShaderLayer the user sees in the editor, so makeImageSnapshot() gives
const renderFullResPainted = ( ) => {
const sz = exportCanvasSize ;
if ( ! sz )
return null ;
const ew = sz . w ;
const eh = sz . h ;
const shaderReady = paintColorMapSkImg &&
lowFreqSkImg &&
highFreqSkImg &&
paintResourcesReady ;
const useShader = paintedRegions . size > 0 && shaderReady ;
const shaderOrigin = originSkImg ? ? lowFreqSkImg ;
if ( useShader && shaderOrigin ) {
return ( _jsx ( PaintShaderLayer , { originImage : shaderOrigin , paintColorMap : paintColorMapSkImg , lowFreqImage : lowFreqSkImg , highFreqImage : highFreqSkImg , x : 0 , y : 0 , width : ew , height : eh , showOrigin : false } ) ) ;
}
// No paints yet or shader not ready — export the (scaled) origin as-is.
const displayImg = originSkImg ? ? lowFreqSkImg ;
if ( displayImg ) {
return ( _jsx ( SkiaImage , { image : displayImg , x : 0 , y : 0 , width : ew , height : eh } ) ) ;
}
return null ;
} ;
// ── Gesture: tap (single-finger paint / highlight / brush_required) ────
const tapGesture = useMemo ( ( ) => {
const onBeginJS = ( x , y ) => {
// Immediate hold highlight — fires on touch-down regardless of brush state.
// When a brush is active, this lets the user preview which region they're
// about to paint before lifting their finger.
2026-07-01 03:24:43 +00:00
onUserInteractionRef . current ? . ( ) ;
2026-06-26 07:23:52 +00:00
const coords = screenToCanvasCoords ( x , y , canvasWRef . current , canvasHRef . current , zoomScaleRef . current , panOffsetRef . current ) ;
const regionId = findRegionAtPointRef . current ( coords . x , coords . y , true ) ;
if ( regionId == null || ! imageSizeRef2 . current ) {
setHeldRegionId ( null ) ;
setHeldRegionAnchor ( null ) ;
return ;
}
if ( paintedRegionsRef . current && paintedRegionsRef . current . has ( regionId ) ) {
setHeldRegionId ( null ) ;
setHeldRegionAnchor ( null ) ;
return ;
}
const norm = canvasToNormalized ( coords . x , coords . y , canvasWRef . current , canvasHRef . current , imageSizeRef2 . current . w , imageSizeRef2 . current . h ) ;
setHeldRegionId ( regionId ) ;
setHeldRegionAnchor ( norm ) ;
} ;
const onEndJS = ( x , y , success ) => {
if ( ! success )
return ;
const coords = screenToCanvasCoords ( x , y , canvasWRef . current , canvasHRef . current , zoomScaleRef . current , panOffsetRef . current ) ;
if ( hasActiveBrushRef . current ) {
onCanvasTapRef . current ( coords . x , coords . y ) ;
}
else {
// Brush_required
onUserInteraction ( ) ;
if ( disabledRef . current || ! segmentsReadyRef2 . current || ! imageSizeRef2 . current )
return ;
const regionId = findRegionAtPointRef . current ( coords . x , coords . y ) ;
if ( regionId == null )
return ;
const region = regionsRef . current . find ( r => r . id === regionId ) ;
onPaintCallbackRef . current ? . ( {
kind : 'brush_required' ,
hint : '请先选择笔刷颜色' ,
regionId ,
regionName : region ? . name ? ? String ( regionId ) ,
} ) ;
}
} ;
const onFinalizeJS = ( x , y ) => {
// Cleanup hold highlight (replaces onCanvasPressOut)
if ( hasActiveBrushRef . current ) {
setHeldRegionId ( null ) ;
setHeldRegionAnchor ( null ) ;
return ;
}
const coords = screenToCanvasCoords ( x , y , canvasWRef . current , canvasHRef . current , zoomScaleRef . current , panOffsetRef . current ) ;
onCanvasTapRef . current ( coords . x , coords . y ) ;
setHeldRegionId ( null ) ;
setHeldRegionAnchor ( null ) ;
} ;
return Gesture . Tap ( )
. onBegin ( ( e ) => {
'worklet' ;
runOnJS ( onBeginJS ) ( e . x , e . y ) ;
} )
. onEnd ( ( e , success ) => {
'worklet' ;
runOnJS ( onEndJS ) ( e . x , e . y , success ) ;
} )
. onFinalize ( ( e ) => {
'worklet' ;
runOnJS ( onFinalizeJS ) ( e . x , e . y ) ;
} ) ;
2026-07-01 03:24:43 +00:00
} , [ ] ) ;
2026-06-26 07:23:52 +00:00
// ── Gesture: pinch-zoom (two-finger scale; max 5× ) ─────────────────────
const pinchGesture = useMemo ( ( ) => {
const onStartJS = ( ) => {
zoomBaseRef . current = zoomScaleRef . current ;
} ;
const onUpdateJS = ( scale ) => {
const newScale = Math . max ( 1 , Math . min ( zoomBaseRef . current * scale , 5 ) ) ;
setZoomScale ( newScale ) ;
zoomScaleRef . current = newScale ;
// Lock pan offset to zero: only two-finger pinch zoom is supported.
// Single-finger drag/pan after zoom is intentionally disabled per product decision.
// Zoom is always centered; no additional translate from panOffset is applied in practice.
setPanOffset ( { x : 0 , y : 0 } ) ;
panOffsetRef . current = { x : 0 , y : 0 } ;
} ;
const onEndJS = ( ) => {
if ( zoomScaleRef . current <= 1.01 ) {
2026-07-01 03:24:43 +00:00
resetZoomRef . current ? . ( ) ;
2026-06-26 07:23:52 +00:00
}
} ;
return Gesture . Pinch ( )
. onStart ( ( ) => {
'worklet' ;
runOnJS ( onStartJS ) ( ) ;
} )
. onUpdate ( ( e ) => {
'worklet' ;
runOnJS ( onUpdateJS ) ( e . scale ) ;
} )
. onEnd ( ( ) => {
'worklet' ;
runOnJS ( onEndJS ) ( ) ;
} ) ;
2026-07-01 03:24:43 +00:00
} , [ ] ) ;
2026-06-26 07:23:52 +00:00
// ── Composed: Race ensures single-tap and pinch-zoom never conflict ─────────
2026-07-01 03:24:43 +00:00
// 依赖置空 + 内部的 tap/pinch 也用 [],保证整个手势描述符树只在 mount 时创建一次。
// 避免初始化阶段反复创建导致 Reanimated / RNGH 内部节点重复分配。
const composedGesture = useMemo ( ( ) => Gesture . Race ( tapGesture , pinchGesture ) , [ ] ) ;
2026-06-26 07:23:52 +00:00
return ( _jsxs ( View , { style : [ styles . container , style ] , onLayout : ( e ) => {
const { width , height } = e . nativeEvent . layout ;
setLayoutWidth ( width ) ;
setLayoutHeight ( height ) ;
} , children : [ ( showToolbar || showDebugPickers || showStatusRow || showColorBar ) ? ( _jsxs ( ScrollView , { style : styles . scroll , showsVerticalScrollIndicator : false , contentContainerStyle : styles . scrollContent , keyboardShouldPersistTaps : "always" , children : [ showToolbar && ( _jsx ( View , { style : styles . toolbarRow , children : _jsx ( Button , { title : isRefreshing ? 're-segmenting…' : 'clear cache and re-segment' , onPress : clearCacheAndResegment , disabled : isRefreshing || ! originImgPath || ! maskImgPath } ) } ) ) , showDebugPickers && ( _jsxs ( View , { style : styles . btnRow , children : [ _jsx ( Button , { title : "pick origin image" , onPress : pickOriginImage } ) , _jsx ( View , { style : styles . btnGap } ) , _jsx ( Button , { title : "pick mask image" , onPress : pickMaskImage } ) ] } ) ) , showStatusRow && ( _jsx ( View , { style : styles . statusRow , children : segError ? ( _jsxs ( Text , { style : styles . err , children : [ "\u274C " , segError ] } ) ) : ! segmentsReady ? ( _jsx ( Text , { style : styles . hint , children : "\u23F3 segmenting\u2026" } ) ) : layersLoading || ! paintResourcesReady ? ( _jsxs ( Text , { style : styles . hint , children : [ "\u2705 " , regionCount , " regions \u00B7 Shader textures preparing\u2026" ] } ) ) : ! ( originSkImg ? ? lowFreqSkImg ) ? ( _jsx ( Text , { style : styles . hint , children : "\u23F3 image loading\u2026" } ) ) : ( _jsxs ( Text , { style : styles . hint , children : [ "\u2705 " , regionCount , " regions \u00B7 painted " , paintedRegions . size , " \u00B7" , ' ' , hasActiveBrush
? customPaintColor
? 'custom paint color'
: ` paint color ${ ( activeBrushIndex ? ? 0 ) + 1 } `
: 'please select the bottom paint color first' , ' · ' , compareMode ? 'compare original image' : 'paint mode' ] } ) ) } ) ) , showColorBar && ( _jsxs ( View , { style : styles . colorBar , children : [ _jsx ( Text , { style : styles . colorBarLabel , children : "paint color (tap to select, then tap canvas to paint)" } ) , _jsxs ( View , { style : styles . colorSwatches , children : [ paintPalette . map ( ( color , index ) => {
const isActive = activeBrushIndex === index && customPaintColor == null ;
const { b , g , r } = color ;
return ( _jsx ( TouchableOpacity , { style : [
styles . colorSwatch ,
{ backgroundColor : bgrToCss ( b , g , r ) } ,
isActive && styles . colorSwatchSelected ,
] , activeOpacity : 0.8 , disabled : ! segmentsReady || disabled , onPress : ( ) => selectBrushColor ( index ) } , index ) ) ;
} ) , ! segmentsReady && ( _jsx ( Text , { style : styles . colorBarEmpty , children : "loading\u2026" } ) ) ] } ) , customPaintColor && ( _jsx ( Text , { style : styles . hint , children : "current custom paint color is set by ref.setPaintColor" } ) ) ] } ) ) ] } ) ) : null , _jsx ( View , { style : [ styles . canvasOuter , canvasStyle ] , children : _jsxs ( View , { style : [ styles . canvasWrap , { width : canvasW , height : canvasH } ] , children : [ _jsx ( GestureDetector , { gesture : composedGesture , children : _jsx ( View , { style : [ styles . canvasTouchLayer , { width : canvasW , height : canvasH } ] , children : _jsx ( Canvas , { style : { width : canvasW , height : canvasH } , pointerEvents : "none" , children : renderDraw ( ) } ) } ) } ) , showOverlayButtons &&
( renderUndoButton ? ( renderUndoButton ( {
onPress : undoSelection ,
disabled : paintHistory . length === 0 ,
text : undoButtonText ,
} ) ) : ( _jsx ( TouchableOpacity , { style : [ styles . overlayBtn , styles . btnBottomLeft , undoButtonStyle ] , activeOpacity : 0.7 , disabled : paintHistory . length === 0 || disabled , onPress : undoSelection , children : _jsx ( Text , { style : [
styles . btnText ,
undoButtonTextStyle ,
{ opacity : paintHistory . length === 0 ? 0.4 : 1 } ,
] , children : undoButtonText } ) } ) ) ) , showOverlayButtons &&
( renderCompareButton ? ( renderCompareButton ( {
onPress : ( ) => {
onUserInteraction ( ) ;
setCompareMode ( v => ! v ) ;
} ,
text : compareMode ? compareExitButtonText : compareButtonText ,
} ) ) : ( _jsx ( TouchableOpacity , { style : [
styles . overlayBtn ,
styles . btnBottomRight ,
compareButtonStyle ,
] , activeOpacity : 0.7 , disabled : disabled , onPress : ( ) => {
onUserInteraction ( ) ;
setCompareMode ( v => ! v ) ;
} , children : _jsx ( Text , { style : [ styles . btnText , compareButtonTextStyle ] , children : compareMode ? compareExitButtonText : compareButtonText } ) } ) ) ) ] } ) } ) , highResSnapshotEnabled && exportCanvasSize ? ( _jsx ( View , { style : {
position : 'absolute' ,
left : - exportCanvasSize . w - 200 ,
top : 0 ,
width : exportCanvasSize . w ,
height : exportCanvasSize . h ,
opacity : 0 ,
overflow : 'hidden' ,
} , pointerEvents : "none" , children : _jsx ( Canvas , { ref : highResExportCanvasRef , style : { width : exportCanvasSize . w , height : exportCanvasSize . h } , pointerEvents : "none" , children : _jsx ( Group , { children : renderFullResPainted ( ) } ) } ) } ) ) : null ] } ) ) ;
} ) ;
const styles = StyleSheet . create ( {
container : {
flex : 1 ,
backgroundColor : '#fff' ,
} ,
scroll : {
flex : 1 ,
} ,
scrollContent : {
padding : 10 ,
paddingBottom : 28 ,
} ,
toolbarRow : {
marginBottom : 8 ,
} ,
btnRow : {
flexDirection : 'row' ,
marginBottom : 8 ,
} ,
btnGap : {
width : 10 ,
} ,
statusRow : {
marginBottom : 8 ,
} ,
hint : {
color : '#333' ,
fontSize : 13 ,
} ,
err : {
color : '#c33' ,
fontSize : 13 ,
} ,
canvasWrap : {
position : 'relative' ,
backgroundColor : '#f5f5f5' ,
borderWidth : StyleSheet . hairlineWidth ,
borderColor : '#ddd' ,
overflow : 'hidden' ,
} ,
canvasOuter : {
alignItems : 'center' ,
justifyContent : 'center' ,
// When the internal control ScrollView is omitted (the normal case for
// VisualizationScreen and non-interactive scheme previews), this makes the
// canvas area fill the bounds provided by the host (via style + maxHeight)
// and centers the fixed-size image rect. This also ensures the
// GestureDetector sits in the right place to receive taps and two-finger pinches.
flex : 1 ,
} ,
canvasTouchLayer : {
flex : 1 ,
} ,
canvas : {
flex : 1 ,
} ,
overlayBtn : {
position : 'absolute' ,
bottom : 10 ,
paddingHorizontal : 14 ,
paddingVertical : 7 ,
backgroundColor : 'rgba(0, 0, 0, 0.62)' ,
borderRadius : 6 ,
} ,
btnBottomLeft : {
left : 10 ,
} ,
btnBottomRight : {
right : 10 ,
} ,
btnText : {
color : '#fff' ,
fontSize : 13 ,
fontWeight : '600' ,
} ,
colorBar : {
marginTop : 12 ,
paddingVertical : 10 ,
paddingHorizontal : 4 ,
borderTopWidth : StyleSheet . hairlineWidth ,
borderTopColor : '#e0e0e0' ,
} ,
colorBarLabel : {
fontSize : 12 ,
color : '#666' ,
marginBottom : 10 ,
} ,
colorSwatches : {
flexDirection : 'row' ,
flexWrap : 'wrap' ,
alignItems : 'center' ,
gap : 12 ,
} ,
colorSwatch : {
width : 44 ,
height : 44 ,
borderRadius : 22 ,
borderWidth : 2 ,
borderColor : 'rgba(0,0,0,0.12)' ,
alignItems : 'center' ,
justifyContent : 'center' ,
} ,
colorSwatchSelected : {
borderColor : '#1e96ff' ,
borderWidth : 3 ,
transform : [ { scale : 1.08 } ] ,
} ,
colorSwatchPainted : {
borderColor : 'rgba(255,255,255,0.9)' ,
} ,
colorSwatchDot : {
width : 8 ,
height : 8 ,
borderRadius : 4 ,
backgroundColor : 'rgba(255,255,255,0.92)' ,
borderWidth : 1 ,
borderColor : 'rgba(0,0,0,0.25)' ,
} ,
colorBarEmpty : {
fontSize : 13 ,
color : '#999' ,
} ,
} ) ;
export default MaskSegmentCanvas ;
//# sourceMappingURL=MaskSegmentCanvas.js.map