diff --git a/components/label/LabelNossr.tsx b/components/label/LabelNossr.tsx index 42eb107..21d989f 100644 --- a/components/label/LabelNossr.tsx +++ b/components/label/LabelNossr.tsx @@ -1511,6 +1511,7 @@ const LabelPage = ({ const restoreDrawShortcutSnapshot = useCallback(() => { useKeyboardStore.getState().setDrawAction("none") + useKeyboardStore.getState().setSelectedObjectEditTarget(null) const snapshot = drawShortcutSnapshotRef.current if (!snapshot) return @@ -1562,6 +1563,7 @@ const LabelPage = ({ setActiveOperation(selectedContext.operationId) usePaperStore.getState().setContinuePolygonTarget(null) + useKeyboardStore.getState().setSelectedObjectEditTarget(selectedContext) setEditMode(true) setDrawOption("default") useKeyboardStore.getState().setDrawAction(mode) @@ -1692,29 +1694,6 @@ const LabelPage = ({ [] ) - const removeObjectFromGroupMap = useCallback( - (imageId: string, id: number) => { - const nextPathGroupMap = safeClone( - useRightToolsStore.getState().pathGroupMap - ) - const imageGroupMap = nextPathGroupMap.get(imageId) - if (!imageGroupMap) return - - for (let [groupId, pathIds] of imageGroupMap.entries()) { - const nextPathIds = pathIds.filter((pathId) => pathId !== id) - if (nextPathIds.length > 1) { - imageGroupMap.set(groupId, nextPathIds) - } else { - imageGroupMap.delete(groupId) - } - } - - if (!imageGroupMap.size) nextPathGroupMap.delete(imageId) - useRightToolsStore.getState().setPathGroupMap(nextPathGroupMap) - }, - [] - ) - const handleContinueSelectedPolygon = useCallback(() => { const selectedContext = resolveSelectedObjectContext() if (!selectedContext) { @@ -1738,6 +1717,7 @@ const LabelPage = ({ setPressA(false) setActiveOperation(selectedContext.operationId) useKeyboardStore.getState().setDrawAction("none") + useKeyboardStore.getState().setSelectedObjectEditTarget(null) usePaperStore.getState().setContinuePolygonTarget({ imageId: selectedContext.imageId, operationId: selectedContext.operationId, @@ -1758,70 +1738,6 @@ const LabelPage = ({ setPressA, ]) - const hasShapeSegments = useCallback((shape: any) => { - if (!shape) return false - if (shape instanceof paper.CompoundPath) { - return (shape.children as paper.Path[]).some( - (child) => child.segments?.length - ) - } - if (shape instanceof paper.Path) { - return !!shape.segments?.length - } - return false - }, []) - - const createWorkingPolygonShape = useCallback((items: any[]) => { - const compound = items.find((item) => item instanceof paper.CompoundPath) - if (compound) { - return (compound as paper.CompoundPath).clone({ insert: false }) as - | paper.Path - | paper.CompoundPath - } - - const paths = items.filter( - (item) => item instanceof paper.Path - ) as paper.Path[] - if (!paths.length) return null - if (paths.length === 1) { - return paths[0].clone({ insert: false }) as - | paper.Path - | paper.CompoundPath - } - - const merged = new paper.CompoundPath({ insert: false }) - paths.forEach((path) => { - merged.addChild(path.clone({ insert: false })) - }) - return merged as paper.Path | paper.CompoundPath - }, []) - - const collectPolygonPoints = useCallback( - (shape: paper.Path | paper.CompoundPath) => { - const polygonPoints: any[] = [] - const polygonHollowPoints: any[] = [] - - const collectPath = (path: paper.Path) => { - if (!path.segments?.length) return - const pathData = JSON.parse(path.exportJSON({ precision: 20 })) - if (path.clockwise) { - polygonPoints.push(pathData[1]?.segments) - } else { - polygonHollowPoints.push(pathData[1]?.segments) - } - } - - if (shape instanceof paper.CompoundPath) { - ;(shape.children as paper.Path[]).forEach((path) => collectPath(path)) - } else { - collectPath(shape) - } - - return { polygonPoints, polygonHollowPoints } - }, - [] - ) - const applySelectedPolygonOverlap = useCallback( (mode: "subtract" | "unite") => { const selectedContext = resolveSelectedObjectContext() @@ -1842,138 +1758,9 @@ const LabelPage = ({ }) return } - - const group = usePaperStore.getState().group - if (!group) return - - const targetItems = group - .getItems({ data: { id: selectedContext.objectId } }) - .filter((item) => item.data?.type === "polygon") - if (!targetItems.length) return - - let workingShape = createWorkingPolygonShape(targetItems) - if (!workingShape || !hasShapeSegments(workingShape)) return - - const otherPolygonPaths: paper.Path[] = [] - group.children.forEach((item: any) => { - if ( - !item.visible || - !item.data?.id || - item.data.id === selectedContext.objectId || - item.data.type !== "polygon" - ) - return - if (item instanceof paper.Path) { - if (item.segments?.length) otherPolygonPaths.push(item) - } else if (item instanceof paper.CompoundPath) { - ;(item.children as paper.Path[]).forEach((child) => { - if (child.segments?.length) otherPolygonPaths.push(child) - }) - } - }) - - let changed = false - - for (const eachPath of otherPolygonPaths) { - if (!workingShape || !hasShapeSegments(workingShape)) break - - const eachPathClone = eachPath.clone({ insert: false }) as paper.Path - const intersectionPath = (workingShape as any).intersect( - eachPathClone, - { - insert: false, - } - ) as paper.Path | paper.CompoundPath - const hasIntersection = hasShapeSegments(intersectionPath) - intersectionPath?.remove() - if (!hasIntersection) { - eachPathClone.remove() - continue - } - - const nextShape: paper.Path | paper.CompoundPath = - mode === "subtract" - ? ((workingShape as any).subtract(eachPathClone, { - insert: false, - }) as paper.Path | paper.CompoundPath) - : ((workingShape as any).unite(eachPathClone, { - insert: false, - }) as paper.Path | paper.CompoundPath) - eachPathClone.remove() - workingShape?.remove() - workingShape = nextShape - changed = true - } - - if (!changed) { - workingShape?.remove() - return - } - if (!workingShape) return - - const raster = usePaperStore.getState().rasterPath - if (raster && hasShapeSegments(workingShape)) { - const clippedShape = (workingShape as any).intersect(raster, { - insert: false, - trace: false, - }) as paper.Path | paper.CompoundPath - workingShape?.remove() - workingShape = clippedShape - } - if (!workingShape) return - - const { polygonPoints, polygonHollowPoints } = - collectPolygonPoints(workingShape) - workingShape?.remove() - - const nextLabel = safeClone(useLabelStore.getState().label) - const imageLabel = nextLabel.get(selectedContext.imageId) - if (!imageLabel) return - const operationPaths = safeClone( - imageLabel.get(selectedContext.operationId) || [] - ) - const selectedIndex = operationPaths.findIndex( - (item) => item[0] === selectedContext.objectId - ) - if (selectedIndex === -1) return - - const currentTuple = operationPaths[selectedIndex] - if (polygonPoints.length) { - operationPaths[selectedIndex] = [ - selectedContext.objectId, - polygonPoints, - safeClone(currentTuple?.[2] || {}), - polygonHollowPoints, - ] - imageLabel.set(selectedContext.operationId, operationPaths) - } else { - imageLabel.set( - selectedContext.operationId, - operationPaths.filter((item) => item[0] !== selectedContext.objectId) - ) - removeObjectFromGroupMap( - selectedContext.imageId, - selectedContext.objectId - ) - } - - useLabelStore.getState().setLabel(nextLabel) - useLabelStore.getState().pushStateStack(nextLabel) - - rerenderActiveImageByLabel( - selectedContext.imageId, - imageLabel, - polygonPoints.length ? [selectedContext.objectId] : [] - ) + usePaperStore.getState().applySelectedPolygonBooleanOperation(mode) }, - [ - collectPolygonPoints, - createWorkingPolygonShape, - hasShapeSegments, - removeObjectFromGroupMap, - rerenderActiveImageByLabel, - resolveSelectedObjectContext, - ] + [resolveSelectedObjectContext] ) const applySelectedBrushIntersectUnite = useCallback(() => { @@ -2583,6 +2370,7 @@ const LabelPage = ({ useEffect(() => { useKeyboardStore.getState().setCopyDataIds([]) useKeyboardStore.getState().setDrawAction("none") + useKeyboardStore.getState().setSelectedObjectEditTarget(null) usePaperStore.getState().setContinuePolygonTarget(null) drawShortcutSnapshotRef.current = null }, [activeImage]) @@ -2638,7 +2426,7 @@ const LabelPage = ({ useEffect(() => { if (!isView) { - const delay = 10 * 60000 + const delay = 5 * 60000 // 编辑模式下,修改labelData时启动或更新定时器 useTimerStore.getState().startTimer(() => { useTopToolsStore.getState().setIsView(true) diff --git a/components/label/api/label/index.ts b/components/label/api/label/index.ts index 47f4f58..d2139a3 100644 --- a/components/label/api/label/index.ts +++ b/components/label/api/label/index.ts @@ -123,6 +123,7 @@ export const getAuxiliaryAnnotation = (data: { background_points?: Array<[number, number]> boxes?: Array<[number, number, number, number]> } + contour_approximation?: number }) => { return httpFetch<{ contours: Array> diff --git a/components/label/components/PaperContainer.tsx b/components/label/components/PaperContainer.tsx index 5042816..d354a2b 100644 --- a/components/label/components/PaperContainer.tsx +++ b/components/label/components/PaperContainer.tsx @@ -518,7 +518,7 @@ const PaperContainer = ( token: number imageName: string } | null>(null) - const renderModePanRef = useRef({ + const middleMousePanRef = useRef({ active: false, lastX: 0, lastY: 0, @@ -997,6 +997,7 @@ const PaperContainer = ( : {}), ...(normalizedBoxes.length ? { boxes: normalizedBoxes } : {}), }, + contour_approximation: 0, }) const { contours = [], hollows = [] } = data || {} const outerPaths: any[] = [] @@ -1050,6 +1051,8 @@ const PaperContainer = ( }) } catch (error) { console.log(error) + } finally { + notifications.hide("sam2") } } catch (error: any) { console.log(error) @@ -1429,26 +1432,29 @@ const PaperContainer = ( }) .filter((path): path is paper.Path => !!path) || [] - addCompoundPath( - renderCompoundPath, - [...outerPaths, ...innerPaths], - { - strokeColor: strokeColor, - strokeWidth: 2, - // fillColor: fillColor, - fillColor: blankColor, - visible: !paperPathStatus[renderImageId + item[0]]?.["HIDE"], - }, - { - type: "polygon", - id: item[0], - imageId: renderImageId, - operationId: operationId, - fillColor: fillColor, - blankColor: blankColor, - selected: selectedIds.includes(item[0]), - } - ) + if (!(outerPaths.length === 1 && !innerPaths.length)) { + addCompoundPath( + renderCompoundPath, + [...outerPaths, ...innerPaths], + { + strokeColor: strokeColor, + strokeWidth: 2, + // fillColor: fillColor, + fillColor: blankColor, + visible: + !paperPathStatus[renderImageId + item[0]]?.["HIDE"], + }, + { + type: "polygon", + id: item[0], + imageId: renderImageId, + operationId: operationId, + fillColor: fillColor, + blankColor: blankColor, + selected: selectedIds.includes(item[0]), + } + ) + } // renderCompoundPath(usePaperStore.getState().group!, { children }); // item[1].forEach((child) => { @@ -2337,42 +2343,72 @@ const PaperContainer = ( syncRenderModeVisuals, ]) - const stopRenderModePan = useCallback(() => { - renderModePanRef.current.active = false + const hidePaperContextMenu = useCallback(() => { + useOtherToolsStore.getState().setShowContextMenu({ + showContextMenu: false, + position: { top: 0, left: 0 }, + imageId: "", + operationId: "", + id: 0, + }) }, []) + const startMiddleMousePan = useCallback( + (clientX: number, clientY: number) => { + middleMousePanRef.current = { + active: true, + lastX: clientX, + lastY: clientY, + } + }, + [] + ) + + const stopMiddleMousePan = useCallback(() => { + middleMousePanRef.current.active = false + }, []) + + useEffect(() => { + const container = containerRef.current + if (!container) return + + const handleMouseDownCapture = (event: MouseEvent) => { + if (event.button !== 1) return + + const target = event.target as HTMLElement | null + if (target?.closest('[data-label-context-menu="true"]')) return + + event.preventDefault() + event.stopPropagation() + hidePaperContextMenu() + startMiddleMousePan(event.clientX, event.clientY) + } + + container.addEventListener("mousedown", handleMouseDownCapture, true) + + return () => { + container.removeEventListener("mousedown", handleMouseDownCapture, true) + stopMiddleMousePan() + } + }, [hidePaperContextMenu, startMiddleMousePan, stopMiddleMousePan]) + const handleRenderModeOverlayMouseDown = useCallback( (event: React.MouseEvent) => { event.preventDefault() event.stopPropagation() - useOtherToolsStore.getState().setShowContextMenu({ - showContextMenu: false, - position: { top: 0, left: 0 }, - imageId: "", - operationId: "", - id: 0, - }) + hidePaperContextMenu() if (event.button !== 1) return - renderModePanRef.current = { - active: true, - lastX: event.clientX, - lastY: event.clientY, - } + startMiddleMousePan(event.clientX, event.clientY) }, - [] + [hidePaperContextMenu, startMiddleMousePan] ) useEffect(() => { - if (!renderMode) { - stopRenderModePan() - return - } - const handleMouseMove = (event: MouseEvent) => { - const currentPan = renderModePanRef.current + const currentPan = middleMousePanRef.current if (!currentPan.active) return event.preventDefault() @@ -2384,26 +2420,29 @@ const PaperContainer = ( if (!deltaX && !deltaY) return group.position = group.position.add(new paper.Point(deltaX, deltaY)) - renderModePanRef.current.lastX = event.clientX - renderModePanRef.current.lastY = event.clientY + middleMousePanRef.current.lastX = event.clientX + middleMousePanRef.current.lastY = event.clientY } const handleMouseUp = (event: MouseEvent) => { if (event.button === 1) { event.preventDefault() } - stopRenderModePan() + + stopMiddleMousePan() } window.addEventListener("mousemove", handleMouseMove, { passive: false }) window.addEventListener("mouseup", handleMouseUp, { passive: false }) + window.addEventListener("blur", stopMiddleMousePan) return () => { window.removeEventListener("mousemove", handleMouseMove) window.removeEventListener("mouseup", handleMouseUp) - stopRenderModePan() + window.removeEventListener("blur", stopMiddleMousePan) + stopMiddleMousePan() } - }, [renderMode, stopRenderModePan]) + }, [stopMiddleMousePan]) const getTargetGroupPosition = useCallback( ({ diff --git a/components/label/useKeyBoardStore.tsx b/components/label/useKeyBoardStore.tsx index a9e26bb..0dd39f6 100644 --- a/components/label/useKeyBoardStore.tsx +++ b/components/label/useKeyBoardStore.tsx @@ -1,5 +1,12 @@ import { create } from "zustand" +export interface SelectedObjectEditTarget { + imageId: string + operationId: string + objectId: number + objectType: string +} + interface KeyboardStore { ctrl: boolean setCtrl: (v: boolean) => void @@ -7,6 +14,8 @@ interface KeyboardStore { setShift: (v: boolean) => void drawAction: "none" | "add" | "subtract" setDrawAction: (mode: "none" | "add" | "subtract") => void + selectedObjectEditTarget: SelectedObjectEditTarget | null + setSelectedObjectEditTarget: (target: SelectedObjectEditTarget | null) => void copyDataIds: number[] // ctrl + c 复制数据 setCopyDataIds: (ids: number[]) => void } @@ -15,9 +24,12 @@ export const useKeyboardStore = create((set) => ({ ctrl: false, shift: false, drawAction: "none", + selectedObjectEditTarget: null, copyDataIds: [], setCtrl: (v) => set((state) => ({ ...state, ctrl: v })), setShift: (v) => set((state) => ({ ...state, shift: v })), setDrawAction: (mode) => set((state) => ({ ...state, drawAction: mode })), + setSelectedObjectEditTarget: (target) => + set((state) => ({ ...state, selectedObjectEditTarget: target })), setCopyDataIds: (ids) => set((state) => ({ ...state, copyDataIds: ids })), })) diff --git a/components/label/usePaperStore.ts b/components/label/usePaperStore.ts index 420cda8..caff1dd 100644 --- a/components/label/usePaperStore.ts +++ b/components/label/usePaperStore.ts @@ -7,6 +7,7 @@ import { Comment } from "./api/label/typing" import { Project } from "./api/project/typing" import { initialDetail, + type LabelData, type LabelPathTuple, useKeyEventStore, useLabelStore, @@ -20,7 +21,392 @@ import { usePaperSupportStore } from "./usePaperSupportStore" import { useRightToolsStore } from "./useRightToolsStore" import { DEFAULT_NODE_SIZE, useTopToolsStore } from "./useTopToolsStore" import { safeClone } from "./utils/clone" -import { getObjectTagVisibility } from "./utils/objectVisibility" +import { + applyBooleanAdapter, + findBestMatchingOuterContourIndex, + multiPolygonToStoredContours, + paperShapeToMultiPolygon, +} from "./utils/geometry/booleanAdapter" +import { + getObjectTagVisibility, + syncSelectedObjectsVisualState, +} from "./utils/objectVisibility" + +type BooleanPipelineDrawOption = "default" | "intersect" | "unite" +type BooleanPipelineRasterClipOrder = "none" | "before" | "after" | "both" + +const getSafePaperPathData = (data: any) => { + const nextData: Record = {} + Object.entries(data || {}).forEach(([key, value]) => { + if (typeof value === "function") return + nextData[key] = value + }) + return nextData +} + +const syncPaperCompoundPolygonChildrenData = ( + shape: paper.Path | paper.CompoundPath | null, + phase: "clip" | "intersect" | "unite" +) => { + if (!(shape instanceof paper.CompoundPath)) return + + const shapeData = getSafePaperPathData(shape.data || {}) + ;(shape.children as paper.Path[]).forEach((child) => { + if (phase === "clip" && child.data && Object.keys(child.data).length) { + return + } + + child.data = safeClone( + phase === "unite" + ? Object.assign({}, shapeData) + : Object.assign({}, shapeData, { + isHollowPolygon: !child.clockwise, + }) + ) + }) +} + +const replacePaperWorkingPolygonShape = ( + currentShape: paper.Path | paper.CompoundPath | null, + nextShape: paper.Path | paper.CompoundPath | null, + phase: "clip" | "intersect" | "unite" +) => { + const originalParent = currentShape?.parent + const originalIndex = currentShape?.index ?? -1 + + if (!nextShape) { + currentShape?.remove() + return null + } + + syncPaperCompoundPolygonChildrenData(nextShape, phase) + currentShape?.remove() + + if (!nextShape.parent && originalParent && "insertChild" in originalParent) { + originalParent.insertChild( + Math.min(originalIndex, originalParent.children.length), + nextShape + ) + } + + if (nextShape.parent === usePaperStore.getState().group) { + insertItemBelowTags(nextShape) + } + + return nextShape +} + +const clonePaperColor = ( + color: paper.Color | string | null | undefined +): paper.Color | null => { + if (color instanceof paper.Color) { + return color.clone() + } + + if (typeof color === "string") { + return new paper.Color(color) + } + + return null +} + +const createPaperPathFromGeometryContour = ({ + contour, + baseData, + strokeColor, + strokeWidth, + fillColor, + visible, + opacity, + isHollowPolygon, + selected, +}: { + contour: Array<[number, number]> + baseData: Record + strokeColor: paper.Color | string | null | undefined + strokeWidth: number + fillColor: paper.Color | string | null | undefined + visible: boolean + opacity: number + isHollowPolygon: boolean + selected: boolean +}) => { + const path = new paper.Path({ insert: false }) + + contour.slice(0, -1).forEach(([x, y]) => { + path.add(new paper.Point(x, y)) + }) + + path.closed = true + path.data = safeClone( + Object.assign({}, baseData, { + isHollowPolygon, + selected, + }) + ) + path.strokeColor = clonePaperColor(strokeColor) + path.strokeWidth = strokeWidth + path.fillColor = clonePaperColor(fillColor) + path.visible = visible + path.opacity = opacity + + return path +} + +const createPaperPolygonShapeFromGeometry = ({ + geometry, + sourceShape, +}: { + geometry: ReturnType + sourceShape: paper.Path | paper.CompoundPath +}) => { + const { points, hollowPoints } = multiPolygonToStoredContours(geometry) + if (!points.length) return null + + const baseData = getSafePaperPathData(sourceShape.data || {}) + const strokeColor = clonePaperColor(sourceShape.strokeColor) + const fillColor = clonePaperColor(sourceShape.fillColor) + const visible = sourceShape.visible + const opacity = sourceShape.opacity + const strokeWidth = sourceShape.strokeWidth + let selectedAssigned = false + + const outerPaths = points.map((contour) => { + const selected = !!baseData.selected && !selectedAssigned + if (selected) { + selectedAssigned = true + } + + return createPaperPathFromGeometryContour({ + contour, + baseData, + strokeColor, + strokeWidth, + fillColor, + visible, + opacity, + isHollowPolygon: false, + selected, + }) + }) + + const holePaths = hollowPoints.map((contour) => + createPaperPathFromGeometryContour({ + contour, + baseData, + strokeColor, + strokeWidth, + fillColor, + visible, + opacity, + isHollowPolygon: true, + selected: false, + }) + ) + + if (outerPaths.length === 1 && !holePaths.length) { + return outerPaths[0] + } + + const compoundPath = new paper.CompoundPath({ insert: false }) + ;[...outerPaths, ...holePaths].forEach((child) => { + compoundPath.addChild(child) + }) + compoundPath.data = safeClone(baseData) + compoundPath.strokeColor = clonePaperColor(strokeColor) + compoundPath.strokeWidth = strokeWidth + compoundPath.fillColor = clonePaperColor(fillColor) + compoundPath.visible = visible + compoundPath.opacity = opacity + + return compoundPath +} + +const paperItemsToMultiPolygon = ( + items: Array +) => { + if (!items.length) return [] as ReturnType + + if (items.length === 1) { + return paperShapeToMultiPolygon(items[0]) + } + + const paths = items.flatMap((item) => { + if (item instanceof paper.CompoundPath) { + return item.children as paper.Path[] + } + return [item] + }) + + return paperShapeToMultiPolygon({ children: paths }) +} + +const hasGeometryIntersection = ( + subject: ReturnType, + operand: ReturnType +) => { + if (!subject.length || !operand.length) return false + + return ( + applyBooleanAdapter({ + subject, + operands: [operand], + mode: "intersect", + }).geometry.length > 0 + ) +} + +const collectVisiblePaperPolygonOperandGeometries = ({ + group, + excludedObjectId = null, + subjectGeometry = null, +}: { + group: paper.Group | null + excludedObjectId?: number | null + subjectGeometry?: ReturnType | null +}) => { + const groupedItems = new Map>() + + group?.children?.forEach((item) => { + if (!(item instanceof paper.Path || item instanceof paper.CompoundPath)) { + return + } + + if (!item.visible || !item.data?.id || item.data?.type !== "polygon") { + return + } + + const objectId = Number(item.data.id) + if (excludedObjectId !== null && objectId === excludedObjectId) { + return + } + + const currentItems = groupedItems.get(objectId) || [] + currentItems.push(item) + groupedItems.set(objectId, currentItems) + }) + + return Array.from(groupedItems.values()) + .map((items) => paperItemsToMultiPolygon(items)) + .filter((geometry) => { + if (!geometry.length) return false + if (!subjectGeometry?.length) return true + return hasGeometryIntersection(subjectGeometry, geometry) + }) +} + +const clipPaperPolygonShapeToRaster = ( + shape: paper.Path | paper.CompoundPath | null, + rasterPath: paper.Path | null +) => { + if (!shape || !rasterPath) return shape + + const clippedShape = createPaperPolygonShapeFromGeometry({ + geometry: applyBooleanAdapter({ + subject: paperShapeToMultiPolygon(shape), + mode: "unite", + rasterClip: paperShapeToMultiPolygon(rasterPath), + }).geometry, + sourceShape: shape, + }) + + return replacePaperWorkingPolygonShape(shape, clippedShape, "clip") +} + +const applyGeometryBooleanWithVisibleOperands = ({ + group, + shape, + excludedObjectId = null, + mode, +}: { + group: paper.Group | null + shape: paper.Path | paper.CompoundPath | null + excludedObjectId?: number | null + mode: "subtract" | "unite" +}) => { + if (!shape) return null + + const subjectGeometry = paperShapeToMultiPolygon(shape) + if (!subjectGeometry.length) { + return replacePaperWorkingPolygonShape( + shape, + null, + mode === "subtract" ? "intersect" : "unite" + ) + } + + const operands = collectVisiblePaperPolygonOperandGeometries({ + group, + excludedObjectId, + subjectGeometry, + }) + if (!operands.length) return shape + + const nextShape = createPaperPolygonShapeFromGeometry({ + geometry: applyBooleanAdapter({ + subject: subjectGeometry, + operands, + mode, + }).geometry, + sourceShape: shape, + }) + + return replacePaperWorkingPolygonShape( + shape, + nextShape, + mode === "subtract" ? "intersect" : "unite" + ) +} + +const executePaperPolygonBooleanPipeline = ({ + group, + rasterPath, + shape, + drawOption = "default", + excludedObjectId = null, + rasterClipOrder = "after", +}: { + group: paper.Group | null + rasterPath: paper.Path | null + shape: paper.Path | paper.CompoundPath | null + drawOption?: BooleanPipelineDrawOption + excludedObjectId?: number | null + rasterClipOrder?: BooleanPipelineRasterClipOrder +}) => { + let nextShape = shape + const nextShapeObjectId = nextShape?.data?.id + const resolvedExcludedObjectId = + excludedObjectId ?? + (nextShapeObjectId === undefined || nextShapeObjectId === null + ? null + : Number(nextShapeObjectId)) + + if (nextShape && ["before", "both"].includes(rasterClipOrder)) { + nextShape = clipPaperPolygonShapeToRaster(nextShape, rasterPath) + } + + if (drawOption === "intersect") { + nextShape = applyGeometryBooleanWithVisibleOperands({ + group, + shape: nextShape, + excludedObjectId: resolvedExcludedObjectId, + mode: "subtract", + }) + } else if (drawOption === "unite") { + nextShape = applyGeometryBooleanWithVisibleOperands({ + group, + shape: nextShape, + excludedObjectId: resolvedExcludedObjectId, + mode: "unite", + }) + } + + if (nextShape && ["after", "both"].includes(rasterClipOrder)) { + nextShape = clipPaperPolygonShapeToRaster(nextShape, rasterPath) + } + + return nextShape +} interface ContinuePolygonTarget { imageId: string @@ -165,7 +551,7 @@ interface PaperState { item: paper.Segment, index: number, id: number, - color: any + _color: any ) => paper.Path | null setPaperMode: ( modeParam: @@ -183,6 +569,12 @@ interface PaperState { getItemsById: (id: number) => Array setContinuePolygonTarget: (target: ContinuePolygonTarget | null) => void beginContinuePolygonDraft: () => boolean + applySelectedPolygonBooleanOperation: ( + mode: "subtract" | "unite", + options?: { + draftPath?: paper.Path | null + } + ) => boolean setLoadingData: (flag: boolean) => void refreshNodeVisualSize: () => void resizeGhostRequestToken: number @@ -227,13 +619,141 @@ const insertItemBelowTags = (item: paper.Item | null | undefined) => { item.insertBelow(firstTagItem) } +const clonePathGroupMap = ( + source: Map> +): Map> => { + const nextMap = new Map>() + source.forEach((groupMap, imageId) => { + nextMap.set( + imageId, + new Map( + Array.from(groupMap.entries()).map(([groupId, pathIds]) => [ + groupId, + [...pathIds], + ]) + ) + ) + }) + return nextMap +} + +const clearPathParentGroupMetadata = ( + labelData: LabelData, + imageId: string, + pathId: number +) => { + let changed = false + + usePaperStore + .getState() + .getItemsById(pathId) + .forEach((item) => { + if (!item.data?.parentGroupId) return + item.data = Object.assign({}, item.data, { + parentGroupId: null, + }) + changed = true + }) + + const imageLabel = labelData.get(imageId) + if (!imageLabel) return changed + + Array.from(imageLabel.entries()).forEach(([operationId, paths]) => { + let operationChanged = false + const nextPaths = paths.map((item) => { + if (item[0] !== pathId) return item + if (!item[2]?.parentGroupId) return item + + operationChanged = true + changed = true + return [ + item[0], + item[1], + { + ...(item[2] && typeof item[2] === "object" ? item[2] : {}), + parentGroupId: null, + }, + item[3], + ] + }) + + if (operationChanged) { + imageLabel.set(operationId, nextPaths as typeof paths) + } + }) + + return changed +} + +const syncDeletedPathGroupState = ({ + imageId, + removedPathIds, + removedParentGroupIds, + labelData, + updateLabelStore = false, +}: { + imageId: string + removedPathIds: number[] + removedParentGroupIds: Array + labelData: LabelData + updateLabelStore?: boolean +}) => { + const nextPathGroupMap = clonePathGroupMap( + useRightToolsStore.getState().pathGroupMap + ) + const imageGroupMap = nextPathGroupMap.get(imageId) + const clearedPathIds = new Set() + + removedParentGroupIds.forEach((parentGroupId, index) => { + if (!parentGroupId || !imageGroupMap?.has(parentGroupId)) return + + const nextGroupIds = + imageGroupMap + .get(parentGroupId) + ?.filter((id) => id !== removedPathIds[index]) || [] + + if (nextGroupIds.length > 1) { + imageGroupMap.set(parentGroupId, nextGroupIds) + return + } + + imageGroupMap.delete(parentGroupId) + nextGroupIds.forEach((pathId) => { + if (clearedPathIds.has(pathId)) return + clearedPathIds.add(pathId) + clearPathParentGroupMetadata(labelData, imageId, pathId) + }) + }) + + if (imageGroupMap && !imageGroupMap.size) { + nextPathGroupMap.delete(imageId) + } + + useRightToolsStore.getState().setPathGroupMap(nextPathGroupMap) + + if (updateLabelStore) { + useLabelStore.getState().setLabel(labelData) + } + + return labelData +} + const handleDelete = () => { const group = usePaperStore.getState().group! const imageId = useBottomToolsStore.getState().activeImage - let ids = useObjectStore.getState().selectedPath[imageId] + let ids = useObjectStore.getState().selectedPath[imageId] || [] let operationIds: any[] = [] let parentGroupIds: any[] = [] + const otherToolsState = useOtherToolsStore.getState() + if ( + otherToolsState.showContextMenu && + otherToolsState.imageId === imageId && + ids?.includes(otherToolsState.id) + ) { + hidePaperContextMenu() + } + ids.forEach((id, index) => { const items = group.getItems({ data: { id } }) items.forEach((item: any) => { @@ -252,23 +772,6 @@ const handleDelete = () => { ) }) - // handle group path update - if (parentGroupIds.length) { - console.log(parentGroupIds) - const groupMap = useRightToolsStore.getState().pathGroupMap.get(imageId) - parentGroupIds.forEach((parentGroupId, index) => { - if (parentGroupId) { - const currentGroupIds = groupMap - ?.get(parentGroupId) - ?.filter((v) => v !== ids[index]) - if (!currentGroupIds || currentGroupIds?.length <= 1) { - groupMap?.delete(parentGroupId) - } else { - groupMap?.set(parentGroupId, currentGroupIds ?? []) - } - } - }) - } const currentLabelData = safeClone(useLabelStore.getState().label) operationIds.forEach((operationId, index) => { let result = @@ -278,8 +781,15 @@ const handleDelete = () => { ?.filter((item) => item[0] !== ids[index]) ?? [] currentLabelData.get(imageId)?.set(operationId, result) }) + syncDeletedPathGroupState({ + imageId, + removedPathIds: ids, + removedParentGroupIds: parentGroupIds, + labelData: currentLabelData, + }) useLabelStore.getState().setLabel(currentLabelData) useLabelStore.getState().pushStateStack(currentLabelData) + syncSelectedObjectsVisualState(imageId) // ids.forEach((id) => { // usePaperStore // .getState() @@ -313,7 +823,10 @@ const handleDelete = () => { const cleanupDeletedObjectItems = ( imageId: string, pathId?: number, - parentGroupId?: number + parentGroupId?: number, + options: { + pushState?: boolean + } = {} ) => { if (!pathId) return @@ -327,20 +840,27 @@ const cleanupDeletedObjectItems = ( }) useObjectStore.getState().updateSelectedPath(imageId, "DELETE", pathId) - - if (!parentGroupId) return - - const groupMap = useRightToolsStore.getState().pathGroupMap.get(imageId) - const currentGroupIds = groupMap - ?.get(parentGroupId) - ?.filter((id) => id !== pathId) - - if (!currentGroupIds || currentGroupIds.length <= 1) { - groupMap?.delete(parentGroupId) - return + const otherToolsState = useOtherToolsStore.getState() + if ( + otherToolsState.showContextMenu && + otherToolsState.imageId === imageId && + otherToolsState.id === pathId + ) { + hidePaperContextMenu() } - groupMap?.set(parentGroupId, currentGroupIds) + const nextLabelData = safeClone(useLabelStore.getState().label) + syncDeletedPathGroupState({ + imageId, + removedPathIds: [pathId], + removedParentGroupIds: [parentGroupId], + labelData: nextLabelData, + updateLabelStore: true, + }) + if (options.pushState !== false) { + useLabelStore.getState().pushStateStack(nextLabelData) + } + syncSelectedObjectsVisualState(imageId) } const handleShift = (flag: boolean) => { @@ -1049,6 +1569,7 @@ export const usePaperStore = create((set) => ({ currentMousePosition: [0, 0], loadingData: false, beginContinuePolygonDraft: () => false, + applySelectedPolygonBooleanOperation: () => false, init: ( initEl: HTMLCanvasElement, initScope: paper.PaperScope, @@ -2399,51 +2920,6 @@ export const usePaperStore = create((set) => ({ usePaperSupportStore.getState().handleCircles(currentPath) } - const handlePolygonIntersectRaster = (path: paper.Path) => { - let intersections = path.getIntersections( - usePaperStore.getState().rasterPath! - ) - let validIntersections = intersections.filter((intersection) => { - // 检查交点是否在两个路径的可见部分 - return ( - path.contains(intersection.point) && - usePaperStore.getState().rasterPath!.contains(intersection.point) - ) - }) - - if (validIntersections.length) { - let oldPolygon: any = path - console.log("处理前", path) - newDrawPath = oldPolygon.intersect( - usePaperStore.getState().rasterPath! - ) as any - console.log("after", newDrawPath) - // if (!newDrawPath?.clockwise) newDrawPath?.reverse(); - if (newDrawPath instanceof paper.CompoundPath) - (newDrawPath.children as paper.Path[]).forEach((child) => { - if ( - !child.data || - (child.data && !Object.keys(child.data).length) - ) - child.data = safeClone(newDrawPath?.data || {}) - }) - oldPolygon.remove() - oldPolygon = null - } else { - // path整体移动到图片外 清空path的点 - if ( - !usePaperStore - .getState() - .rasterPath!.bounds.contains(path.bounds) && - newDrawPath instanceof paper.Path - ) { - newDrawPath.segments = [] - } - } - usePaperSupportStore.getState().handleBufferPaths(newDrawPath) - usePaperSupportStore.getState().handleCircles(newDrawPath) - } - const handlePointIntersectRaster = (path: paper.Path) => { path.segments = path.segments.filter((segment) => { return usePaperStore @@ -2458,97 +2934,6 @@ export const usePaperStore = create((set) => ({ usePaperSupportStore.getState().handleText(path) } - const handlePolygonIntersect = () => { - if (panMode !== "stroke" && newDrawPath) { - const handleCurrentPathSub = (eachPath: paper.Path) => { - if (newDrawPath) { - let oldPolygon: any = newDrawPath - let oldParent: any = newDrawPath.parent - - newDrawPath = oldPolygon.subtract(eachPath) - - // if (!newDrawPath?.clockwise) newDrawPath?.reverse(); - if (newDrawPath instanceof paper.CompoundPath) { - ;(newDrawPath.children as paper.Path[]).forEach((child) => { - child.data = safeClone( - Object.assign({}, newDrawPath?.data || {}, { - isHollowPolygon: !child.clockwise, - }) - ) - }) - if (oldParent instanceof paper.CompoundPath) { - ;(oldParent.children as paper.Path[]).forEach((child) => { - child.data = safeClone( - Object.assign({}, newDrawPath?.data || {}, { - isHollowPolygon: !child.clockwise, - }) - ) - }) - } - } - - oldPolygon.remove() - oldPolygon = null - } - } - - usePaperStore.getState().group?.children!.forEach((item: any) => { - if ( - !item.data.id || - item.data.type !== "polygon" || - !item.visible || - item.data.id === newDrawPath?.data.id - ) - return - if (item instanceof paper.Path) { - if (item.segments.length) { - handleCurrentPathSub(item) - } - } else if (item instanceof paper.CompoundPath) { - ;(item.children as paper.Path[]).forEach((child) => { - if ( - child?.segments?.length && - child.data.id !== newDrawPath?.data.id - ) { - handleCurrentPathSub(child) - } - }) - } - }) - } - } - - const handlePolygonUnite = () => { - const handleCurrentPathUnite = (eachPath: paper.Path) => { - if (!newDrawPath) return - let intersectionPath = newDrawPath.intersect(eachPath, { - insert: false, - }) as paper.Path - if (intersectionPath.segments?.length) { - let oldPolygon: any = newDrawPath - newDrawPath = oldPolygon.unite(eachPath) as any - // if (!newDrawPath?.clockwise) newDrawPath?.reverse(); - if (newDrawPath instanceof paper.CompoundPath) - (newDrawPath.children as paper.Path[]).forEach((child) => { - child.data = safeClone(newDrawPath?.data || {}) - }) - oldPolygon.remove() - oldPolygon = null - } - } - usePaperStore.getState().group?.children!.forEach((item: any) => { - if (!item.data.id || item.data.type !== "polygon" || !item.visible) - return - if (item instanceof paper.CompoundPath) { - ;(item.children as paper.Path[]).forEach((child) => { - handleCurrentPathUnite(child) - }) - } else if (item instanceof paper.Path) { - handleCurrentPathUnite(item) - } - }) - } - const getNewDetail = (detailPathData: { imageId: string operationId: any @@ -2654,22 +3039,18 @@ export const usePaperStore = create((set) => ({ } console.log("保存当前被选中路径的结果", newDrawPath) - // 边界裁剪 - handlePolygonIntersectRaster(newDrawPath as any) - + const nextDrawPath = executePaperPolygonBooleanPipeline({ + group: usePaperStore.getState().group, + rasterPath: usePaperStore.getState().rasterPath, + shape: newDrawPath as paper.Path | paper.CompoundPath, + drawOption: useTopToolsStore.getState() + .drawOption as BooleanPipelineDrawOption, + rasterClipOrder: "before", + }) + newDrawPath = nextDrawPath as any usePaperSupportStore.getState().handleCircles(newDrawPath) console.log("边界裁剪之后", newDrawPath) - if (useTopToolsStore.getState().drawOption === "intersect") { - // 裁剪 - handlePolygonIntersect() - usePaperSupportStore.getState().handleCircles(newDrawPath) - } else if (useTopToolsStore.getState().drawOption === "unite") { - // 融合 - handlePolygonUnite() - usePaperSupportStore.getState().handleCircles(newDrawPath) - } - // 路径已发生改变则清空之前存的镂空区域,后续无需处理 // if ( // newDrawPath instanceof paper.CompoundPath || @@ -2684,6 +3065,7 @@ export const usePaperStore = create((set) => ({ console.log("裁剪之后", newDrawPath) if ( + !newDrawPath || newDrawPath instanceof paper.CompoundPath || (newDrawPath instanceof paper.Path && newDrawPath.area !== area) ) { @@ -2705,9 +3087,6 @@ export const usePaperStore = create((set) => ({ }) } - // 边界裁剪 - // handlePolygonIntersectRaster(newDrawPath as any); - // if ( // newDrawPath instanceof paper.CompoundPath || // (newDrawPath instanceof paper.Path && newDrawPath.area !== area) @@ -2816,25 +3195,17 @@ export const usePaperStore = create((set) => ({ ) let handleSavePaths = (paths: paper.Path[]) => { - let polygonPoints: any[] = [] - let polygonHollowPoints: any[] = [] - let bool = paths.some((child) => { - return child.segments.length > 0 - }) + const { points: polygonPoints, hollowPoints: polygonHollowPoints } = + multiPolygonToStoredContours( + paperShapeToMultiPolygon( + paths.length === 1 + ? paths[0] + : ({ children: paths } as { children: paper.Path[] }) + ) + ) + let bool = polygonPoints.length > 0 if (bool) { - ;(paths as paper.Path[]).forEach((path) => { - if (path.data.type !== "polygon") return - let pathData = JSON.parse(path.exportJSON({ precision: 20 })) - // 在镂空区域中 镂空区域路径方向为false - if (!path.data.isHollowPolygon) { - polygonPoints.push(pathData[1]?.segments) - } else { - if (path.data.id && path.segments?.length) { - polygonHollowPoints.push(pathData[1]?.segments) - } - } - }) console.log( "编辑后最终保存结果", polygonPoints, @@ -3780,92 +4151,27 @@ export const usePaperStore = create((set) => ({ return true } - const handlePolygonIntersectRaster = (savePath: paper.Path) => { - let oldPolygon: any = savePath - polygon = oldPolygon.intersect( - usePaperStore.getState().rasterPath! - ) as any - if (polygon instanceof paper.CompoundPath) - (polygon.children as paper.Path[]).forEach((child) => { - if (!child.data || (child.data && !Object.keys(child.data).length)) - child.data = safeClone( - Object.assign( - { isHollowPolygon: !child.clockwise }, - polygon?.data || {} - ) - ) - }) - oldPolygon.remove() - oldPolygon = null - } - - const handlePolygonIntersect = (path: paper.Path) => { - const handleCurrentPathSub = (eachPath: paper.Path) => { - if (!polygon) return - let oldPolygon: any = polygon - polygon = oldPolygon?.subtract(eachPath) as any - if (polygon instanceof paper.CompoundPath) - (polygon.children as paper.Path[]).forEach((child) => { - child.data = safeClone( - Object.assign( - { isHollowPolygon: !child.clockwise }, - polygon?.data || {} - ) - ) - }) - oldPolygon?.remove() - oldPolygon = null - } - - usePaperStore.getState().group?.children!.forEach((item: any) => { - if (!item.data.id || item.data.type !== "polygon" || !item.visible) - return - if (item instanceof paper.Path) { - if (item?.segments?.length && item.data.id !== path?.data.id) { - handleCurrentPathSub(item) - } - } else if ( - item instanceof paper.CompoundPath && - item.data.id !== path.data.id - ) { - ;(item.children as paper.Path[]).forEach((child) => { - if (child?.segments?.length && child.data.id !== path?.data.id) { - handleCurrentPathSub(child) - } - }) - } + const runPaperPolygonBooleanPipeline = ({ + shape, + drawOption = "default", + excludedObjectId = null, + rasterClipOrder = "after", + }: { + shape: paper.Path | paper.CompoundPath | null + drawOption?: BooleanPipelineDrawOption + excludedObjectId?: number | null + rasterClipOrder?: BooleanPipelineRasterClipOrder + }) => { + polygon = executePaperPolygonBooleanPipeline({ + group: usePaperStore.getState().group, + rasterPath: usePaperStore.getState().rasterPath, + shape, + drawOption, + excludedObjectId, + rasterClipOrder, }) - } - const handlePolygonUnite = () => { - const handleCurrentPathUnite = (eachPath: paper.Path) => { - if (!polygon) return - let intersectionPath = polygon.intersect(eachPath, { - insert: false, - }) as paper.Path - if (intersectionPath.segments?.length) { - let oldPolygon: any = polygon - polygon = oldPolygon.unite(eachPath) as any - if (polygon instanceof paper.CompoundPath) - (polygon.children as paper.Path[]).forEach((child) => { - child.data = safeClone(polygon?.data || {}) - }) - oldPolygon?.remove() - oldPolygon = null - } - } - - usePaperStore.getState().group?.children!.forEach((item: any) => { - if (!item.data.id || item.data.type !== "polygon" || !item.visible) - return - if (item instanceof paper.CompoundPath) { - ;(item.children as paper.Path[]).forEach((child) => { - handleCurrentPathUnite(child) - }) - } else if (item instanceof paper.Path) { - handleCurrentPathUnite(item) - } - }) + return polygon } // paper path data里可能带函数(例如事件回调), 不能直接structuredClone @@ -3878,188 +4184,102 @@ export const usePaperStore = create((set) => ({ return nextData } - const getSegmentPoint = (segment: any): [number, number] | null => { - if ( - Array.isArray(segment) && - segment.length >= 2 && - typeof segment[0] === "number" && - typeof segment[1] === "number" - ) { - return [segment[0], segment[1]] - } - if ( - Array.isArray(segment) && - Array.isArray(segment[0]) && - segment[0].length >= 2 && - typeof segment[0][0] === "number" && - typeof segment[0][1] === "number" - ) { - return [segment[0][0], segment[0][1]] - } - if ( - segment?.point && - typeof segment.point.x === "number" && - typeof segment.point.y === "number" - ) { - return [segment.point.x, segment.point.y] - } - if ( - segment?.point && - Array.isArray(segment.point) && - segment.point.length >= 2 && - typeof segment.point[0] === "number" && - typeof segment.point[1] === "number" - ) { - return [segment.point[0], segment.point[1]] - } - return null + const restoreSelectedPolygonFillColor = () => { + const activeImage = useBottomToolsStore.getState().activeImage + const selectedIds = + useObjectStore.getState().selectedPath[activeImage] || [] + if (!selectedIds.length) return + + selectedIds.forEach((id) => { + usePaperStore + .getState() + .getItemsById(id) + .forEach((item) => { + if (item.data?.type !== "polygon") return + if (item instanceof paper.Path) { + if (!item.data?.isHollowPolygon) { + item.fillColor = item.data.fillColor || item.fillColor || null + } + return + } + if (item instanceof paper.CompoundPath) { + item.fillColor = item.data.fillColor || item.fillColor || null + } + }) + }) } - const normalizePolygonFromSegments = (segments: any[]) => { - const polygon = (segments || []) - .map((segment) => getSegmentPoint(segment)) - .filter((point): point is [number, number] => !!point) - - if (polygon.length > 1) { - const [firstX, firstY] = polygon[0] - const [lastX, lastY] = polygon[polygon.length - 1] - if ( - Math.abs(firstX - lastX) < 0.000001 && - Math.abs(firstY - lastY) < 0.000001 - ) { - polygon.pop() - } - } - return polygon - } - - const isPointInPolygon = ( - point: [number, number], - polygon: [number, number][] + const hasShapeSegments = ( + shape: paper.Path | paper.CompoundPath | null ) => { - if (polygon.length < 3) return false - const [x, y] = point - let inside = false - for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { - const [xi, yi] = polygon[i] - const [xj, yj] = polygon[j] - const dy = yj - yi - if (yi > y !== yj > y) { - const crossX = ((xj - xi) * (y - yi)) / (dy || 0.000001) + xi - if (x < crossX) inside = !inside - } + if (!shape) return false + if (shape instanceof paper.CompoundPath) { + return (shape.children as paper.Path[]).some( + (child) => child.segments?.length + ) } - return inside + return !!shape.segments?.length } - const getHollowFlagsByContainment = (allSegments: any[][]) => { - const polygons = allSegments.map((segments) => - normalizePolygonFromSegments(segments) + const createWorkingPolygonShape = (items: Array) => { + const compound = items.find((item) => item instanceof paper.CompoundPath) + if (compound instanceof paper.CompoundPath) { + return compound.clone({ insert: false }) as + | paper.Path + | paper.CompoundPath + } + + const paths = items.filter( + (item): item is paper.Path => item instanceof paper.Path ) - return polygons.map((polygon, index) => { - if (polygon.length < 3) return false - const samplePoint = polygon[0] - let containDepth = 0 - polygons.forEach((otherPolygon, otherIndex) => { - if (otherIndex === index || otherPolygon.length < 3) return - if (isPointInPolygon(samplePoint, otherPolygon)) containDepth += 1 - }) - return containDepth % 2 === 1 + if (!paths.length) return null + if (paths.length === 1) { + return paths[0].clone({ insert: false }) as paper.Path + } + + const merged = new paper.CompoundPath({ insert: false }) + paths.forEach((path) => { + merged.addChild(path.clone({ insert: false })) }) + return merged as paper.CompoundPath } - const handleSelectedPolygonSubtract = (draftPath: paper.Path) => { + const getSelectedPolygonContext = () => { const activeImage = useBottomToolsStore.getState().activeImage + const lockedEditTarget = + useKeyboardStore.getState().selectedObjectEditTarget const selectedIds = useObjectStore.getState().selectedPath[activeImage] || [] - if (selectedIds.length !== 1) return false + const selectedId = + lockedEditTarget && + lockedEditTarget.imageId === activeImage && + lockedEditTarget.objectType === "polygon" + ? lockedEditTarget.objectId + : selectedIds.length === 1 + ? selectedIds[0] + : null + if (selectedId === null) return null - const selectedId = selectedIds[0] const targetShapes = usePaperStore .getState() .getItemsById(selectedId) .filter((item) => item.data?.type === "polygon") - - const targetShape = - targetShapes.find((item) => item instanceof paper.CompoundPath) || - targetShapes.find( - (item) => item instanceof paper.Path && !item.data?.isHollowPolygon - ) || - targetShapes.find((item) => item instanceof paper.Path) - - if (!targetShape) return false - - let clippedDraft: paper.Path | paper.CompoundPath | null = - draftPath.intersect(usePaperStore.getState().rasterPath!, { - insert: false, - trace: false, - }) as paper.Path | paper.CompoundPath - - draftPath.remove() - - const draftHasSegments = - clippedDraft instanceof paper.CompoundPath - ? (clippedDraft.children as paper.Path[]).some( - (child) => child.segments?.length - ) - : clippedDraft.segments?.length - if (!draftHasSegments) { - clippedDraft?.remove() - clippedDraft = null - return false - } - - const targetData = getSafePathData(targetShape.data || {}) - let nextPolygon = ( - targetShape as paper.Path | paper.CompoundPath - ).subtract(clippedDraft) as paper.Path | paper.CompoundPath - clippedDraft?.remove() - clippedDraft = null - targetShape.remove() - - const points: any[] = [] - const hollowPoints: any[] = [] - let lastOuterContourPoints: paper.Point[] = [] - const nextPaths = - nextPolygon instanceof paper.CompoundPath - ? (nextPolygon.children as paper.Path[]) - : ([nextPolygon] as paper.Path[]) - const nextPathSegments = nextPaths.map((path) => { - if (!path.segments?.length) return null - const pathData = JSON.parse(path.exportJSON({ precision: 20 })) - return pathData?.[1]?.segments || null - }) - const hollowFlags = getHollowFlagsByContainment( - nextPathSegments.map((segments) => segments || []) - ) - - if (nextPolygon instanceof paper.CompoundPath) { - nextPolygon.data = Object.assign({}, targetData) - } - nextPaths.forEach((path, index) => { - const segments = nextPathSegments[index] - if (!segments?.length) return - const isHollowPolygon = !!hollowFlags[index] - path.data = Object.assign({}, targetData, { - isHollowPolygon, - }) - if (isHollowPolygon) { - hollowPoints.push(segments) - } else { - points.push(segments) - lastOuterContourPoints = getContinueContourPoints(segments) - } - }) + if (!targetShapes.length) return null const operationId = ( - targetData.operationId || - useTopToolsStore.getState().activeOperation || - "" + lockedEditTarget && + lockedEditTarget.imageId === activeImage && + lockedEditTarget.objectId === selectedId + ? lockedEditTarget.operationId + : targetShapes.find((item) => !!item.data)?.data?.operationId || + useTopToolsStore.getState().activeOperation || + "" ).toString() - if (!operationId) { - nextPolygon.remove() - return false - } + if (!operationId) return null + + const targetData = getSafePathData( + targetShapes.find((item) => !!item.data)?.data || {} + ) const prevDetail = safeClone( useLabelStore .getState() @@ -4067,169 +4287,359 @@ export const usePaperStore = create((set) => ({ initialDetail ) + return { + activeImage, + selectedId, + targetData, + targetShapes, + operationId, + prevDetail, + } + } + + const rebuildPersistedSelectedPolygon = ( + context: NonNullable> + ) => { + const group = usePaperStore.getState().group + if (!group) return null + + const persistedTuple = + useLabelStore + .getState() + .label.get(context.activeImage) + ?.get(context.operationId) + ?.find((item) => item[0] === context.selectedId) || null + if (!persistedTuple) return null + + const visible = + !useObjectStore.getState().pathStatus[ + context.activeImage + context.selectedId + ]?.HIDE + const selected = ( + useObjectStore.getState().selectedPath[context.activeImage] || [] + ).includes(context.selectedId) + const fillColor = + context.targetData.fillColor || + usePaperStore.getState().toolOption.fillColor + const blankColor = + context.targetData.blankColor || + fillColor || + usePaperStore.getState().toolOption.blankColor + const strokeColor = + context.targetData.strokeColor || + usePaperStore.getState().toolOption.strokeColor + const strokeWidth = + context.targetData.strokeWidth || + usePaperStore.getState().toolOption.strokeWidth + const baseData = Object.assign({}, context.targetData, { + type: "polygon", + id: context.selectedId, + imageId: context.activeImage, + operationId: context.operationId, + fillColor, + blankColor, + strokeColor, + strokeWidth, + selected, + }) + + group + .getItems({ data: { id: context.selectedId } }) + .filter( + (item) => item.data?.type === "polygon" && item.parent === group + ) + .forEach((item) => { + item.remove() + }) + + const buildPolygonPath = (segments: any[], isHollowPolygon: boolean) => { + const contourPoints = (segments || []) + .map((segment) => getPointFromSegment(segment)) + .filter((point): point is paper.Point => point !== null) + if (!contourPoints.length) return null + + return usePaperStore.getState().addPolygon( + renderPath, + contourPoints, + { + strokeColor, + strokeWidth, + fillColor: isHollowPolygon ? fillColor : blankColor, + visible, + }, + Object.assign({}, baseData, { + isHollowPolygon, + }) + ) + } + + const outerPaths = + persistedTuple[1] + ?.map((segments) => buildPolygonPath(segments, false)) + .filter((item): item is paper.Path => !!item) || [] + const innerPaths = + persistedTuple[3] + ?.map((segments) => buildPolygonPath(segments, true)) + .filter((item): item is paper.Path => !!item) || [] + const children = [...outerPaths, ...innerPaths] + if (!children.length) return null + if (outerPaths.length === 1 && !innerPaths.length) { + return outerPaths[0] + } + + return usePaperStore.getState().addCompoundPath( + renderCompoundPath, + children, + { + strokeColor, + strokeWidth, + fillColor: blankColor, + visible, + }, + baseData + ) + } + + const clearPolygonBooleanHelperItems = () => { + if (selectedCircles.length) { + selectedCircles.forEach((item) => { + item.remove() + }) + selectedCircles = [] + } + clearPolygonMagnet() + } + + const getEditedPolygonDetail = ( + context: NonNullable> + ) => { + const currentDetail = useLabelStore + .getState() + .getLabelDetailDataByKeys( + context.activeImage, + context.operationId, + context.selectedId + ) + return currentDetail + ? { + ...currentDetail, + first_modified_timestamp: + currentDetail.first_modified_timestamp || dayjs().unix(), + first_modified_uid: currentDetail.first_modified_uid || user_id, + last_modified_timestamp: dayjs().unix(), + last_modified_uid: user_id, + } + : { + ...initialDetail, + create_timestamp: dayjs().unix(), + category_id: Number(context.operationId), + } + } + + const resolvePreferredContinueOuterContourIndex = ( + context: NonNullable>, + contours: any[] + ) => { + if (!contours.length) return -1 + + const continueTarget = { + imageId: context.activeImage, + operationId: context.operationId, + objectId: context.selectedId, + } + const continueHint = continueDraftHints.get( + getContinueDraftHintKey(continueTarget) + ) + + if ( + continueHint && + continueHint.contourIndex >= 0 && + continueHint.contourIndex < contours.length && + contours[continueHint.contourIndex]?.length > 2 + ) { + return continueHint.contourIndex + } + + const continuePaths = getContinueCandidatePaths(context.selectedId) + const continuePath = continuePaths[continuePaths.length - 1] + const continuePathPoints = continuePath?.segments?.length + ? removeDuplicatedClosingPoint( + continuePath.segments.map( + (segment) => new paper.Point(segment.point) + ) + ) + : [] + + if (continuePathPoints.length) { + const matchedIndex = findMatchingContinueContourIndex( + continuePathPoints, + contours + ) + if (matchedIndex >= 0) { + return matchedIndex + } + } + + for (let index = contours.length - 1; index >= 0; index -= 1) { + if (contours[index]?.length > 2) { + return index + } + } + + return contours.length ? contours.length - 1 : -1 + } + + const persistSelectedPolygonGeometryResult = ( + context: NonNullable>, + geometry: ReturnType + ) => { + const previousTuple = + useLabelStore + .getState() + .label.get(context.activeImage) + ?.get(context.operationId) + ?.find((item) => item[0] === context.selectedId) || null + const previousPoints = safeClone(previousTuple?.[1] || []) + const preferredContourIndex = resolvePreferredContinueOuterContourIndex( + context, + previousPoints + ) + const newDetail = getEditedPolygonDetail(context) + const { points, hollowPoints } = multiPolygonToStoredContours(geometry) + const nextContinueContourIndex = findBestMatchingOuterContourIndex({ + previousContours: previousPoints, + nextContours: points, + preferredIndex: preferredContourIndex, + }) + const nextContinueContourSegments = + points[ + nextContinueContourIndex >= 0 + ? nextContinueContourIndex + : points.length - 1 + ] || [] + if (points.length) { useLabelStore .getState() - .setOperation(activeImage, operationId, [ - selectedId, + .setOperation(context.activeImage, context.operationId, [ + context.selectedId, points, - prevDetail, + newDetail, hollowPoints, ]) setContinueDraftHint( { - imageId: activeImage, - operationId, - objectId: selectedId, + imageId: context.activeImage, + operationId: context.operationId, + objectId: context.selectedId, }, - points.length - 1, - lastOuterContourPoints + nextContinueContourIndex >= 0 + ? nextContinueContourIndex + : points.length - 1, + getContinueContourPoints(nextContinueContourSegments) ) useObjectStore .getState() - .updateSelectedPath(activeImage, "ADD", selectedId) + .updateSelectedPath(context.activeImage, "ADD", context.selectedId) } else { - nextPolygon.remove() useLabelStore .getState() - .deleteOperation(activeImage, operationId, selectedId) - useObjectStore.getState().updateSelectedPath(activeImage, "ADD") - const nextLabel = safeClone(useLabelStore.getState().label) - useLabelStore.getState().setLabel(nextLabel) - useLabelStore.getState().pushStateStack(nextLabel) + .deleteOperation( + context.activeImage, + context.operationId, + context.selectedId + ) + cleanupDeletedObjectItems( + context.activeImage, + context.selectedId, + context.targetData.parentGroupId + ) } + rebuildPersistedSelectedPolygon(context) + syncSelectedObjectsVisualState(context.activeImage) forceUpdate() return true } - const handleSelectedPolygonAdd = (draftPath: paper.Path) => { - const activeImage = useBottomToolsStore.getState().activeImage - const selectedIds = - useObjectStore.getState().selectedPath[activeImage] || [] - if (selectedIds.length !== 1) return false + const applySelectedPolygonBooleanOperation = ( + mode: "subtract" | "unite", + options: { + draftPath?: paper.Path | null + } = {} + ) => { + const context = getSelectedPolygonContext() + if (!context) { + options.draftPath?.remove() + return false + } + const prevDrawOption = useTopToolsStore.getState().drawOption + const nextDrawOption = mode === "subtract" ? "intersect" : "unite" + const baseShape = createWorkingPolygonShape(context.targetShapes) + const rasterGeometry = paperShapeToMultiPolygon( + usePaperStore.getState().rasterPath + ) + const overlapMode = nextDrawOption === "intersect" ? "subtract" : "unite" - const selectedId = selectedIds[0] - const targetShapes = usePaperStore - .getState() - .getItemsById(selectedId) - .filter((item) => item.data?.type === "polygon") - - const targetShape = - targetShapes.find((item) => item instanceof paper.CompoundPath) || - targetShapes.find( - (item) => item instanceof paper.Path && !item.data?.isHollowPolygon - ) || - targetShapes.find((item) => item instanceof paper.Path) - - if (!targetShape) return false - - let clippedDraft: paper.Path | paper.CompoundPath | null = - draftPath.intersect(usePaperStore.getState().rasterPath!, { - insert: false, - trace: false, - }) as paper.Path | paper.CompoundPath - - draftPath.remove() - - const draftHasSegments = - clippedDraft instanceof paper.CompoundPath - ? (clippedDraft.children as paper.Path[]).some( - (child) => child.segments?.length - ) - : clippedDraft.segments?.length - if (!draftHasSegments) { - clippedDraft?.remove() - clippedDraft = null + if (!baseShape || !hasShapeSegments(baseShape)) { + options.draftPath?.remove() + baseShape?.remove() return false } - const targetData = getSafePathData(targetShape.data || {}) - let nextPolygon = (targetShape as paper.Path | paper.CompoundPath).unite( - clippedDraft - ) as paper.Path | paper.CompoundPath - clippedDraft?.remove() - clippedDraft = null - targetShape.remove() - - const points: any[] = [] - const hollowPoints: any[] = [] - let lastOuterContourPoints: paper.Point[] = [] - const nextPaths = - nextPolygon instanceof paper.CompoundPath - ? (nextPolygon.children as paper.Path[]) - : ([nextPolygon] as paper.Path[]) - const nextPathSegments = nextPaths.map((path) => { - if (!path.segments?.length) return null - const pathData = JSON.parse(path.exportJSON({ precision: 20 })) - return pathData?.[1]?.segments || null - }) - const hollowFlags = getHollowFlagsByContainment( - nextPathSegments.map((segments) => segments || []) - ) - - if (nextPolygon instanceof paper.CompoundPath) { - nextPolygon.data = Object.assign({}, targetData) + let workingGeometry = paperShapeToMultiPolygon(baseShape) + baseShape.remove() + if (!workingGeometry.length) { + options.draftPath?.remove() + return false } - nextPaths.forEach((path, index) => { - const segments = nextPathSegments[index] - if (!segments?.length) return - const isHollowPolygon = !!hollowFlags[index] - path.data = Object.assign({}, targetData, { - isHollowPolygon, - }) - if (isHollowPolygon) { - hollowPoints.push(segments) - } else { - points.push(segments) - lastOuterContourPoints = getContinueContourPoints(segments) + + useTopToolsStore.getState().setDrawOption(nextDrawOption) + try { + if (options.draftPath) { + const clippedDraftGeometry = applyBooleanAdapter({ + subject: paperShapeToMultiPolygon(options.draftPath), + mode: "unite", + rasterClip: rasterGeometry.length ? rasterGeometry : null, + }).geometry + options.draftPath.remove() + + if (!clippedDraftGeometry.length) { + clearPolygonBooleanHelperItems() + polygon = null + return false + } + + workingGeometry = applyBooleanAdapter({ + subject: workingGeometry, + operands: [clippedDraftGeometry], + mode, + }).geometry } - }) - const operationId = ( - targetData.operationId || - useTopToolsStore.getState().activeOperation || - "" - ).toString() - if (!operationId) { - nextPolygon.remove() - return false - } - const prevDetail = safeClone( - useLabelStore - .getState() - .getLabelDetailDataByKeys(activeImage, operationId, selectedId) || - initialDetail - ) + const overlapOperands = collectVisiblePaperPolygonOperandGeometries({ + group: usePaperStore.getState().group, + excludedObjectId: context.selectedId, + subjectGeometry: workingGeometry, + }) - if (points.length) { - useLabelStore - .getState() - .setOperation(activeImage, operationId, [ - selectedId, - points, - prevDetail, - hollowPoints, - ]) - setContinueDraftHint( - { - imageId: activeImage, - operationId, - objectId: selectedId, - }, - points.length - 1, - lastOuterContourPoints + workingGeometry = applyBooleanAdapter({ + subject: workingGeometry, + operands: overlapOperands, + mode: overlapMode, + rasterClip: rasterGeometry.length ? rasterGeometry : null, + }).geometry + + const persisted = persistSelectedPolygonGeometryResult( + context, + workingGeometry ) - useObjectStore - .getState() - .updateSelectedPath(activeImage, "ADD", selectedId) - } else { - nextPolygon.remove() - return false + clearPolygonBooleanHelperItems() + polygon = null + return persisted + } finally { + useTopToolsStore.getState().setDrawOption(prevDrawOption) } - - forceUpdate() - return true } polygonTool.onMouseDown = (e: { @@ -4310,7 +4720,9 @@ export const usePaperStore = create((set) => ({ if (useKeyboardStore.getState().drawAction === "subtract") { ;(polygon as paper.Path).closePath() - handleSelectedPolygonSubtract(polygon as paper.Path) + applySelectedPolygonBooleanOperation("subtract", { + draftPath: polygon as paper.Path, + }) selectedCircles.forEach((item) => { item.remove() @@ -4339,7 +4751,9 @@ export const usePaperStore = create((set) => ({ : null if (selectedItem?.data?.type === "polygon") { ;(polygon as paper.Path).closePath() - handleSelectedPolygonAdd(polygon as paper.Path) + applySelectedPolygonBooleanOperation("unite", { + draftPath: polygon as paper.Path, + }) selectedCircles.forEach((item) => { item.remove() @@ -4418,25 +4832,14 @@ export const usePaperStore = create((set) => ({ } } - if (useTopToolsStore.getState().drawOption === "intersect") { - // 裁剪 - handlePolygonIntersect(polygon as paper.Path) - handleCircles(polygon) - } else if (useTopToolsStore.getState().drawOption === "unite") { - // 融合 - handlePolygonUnite() - handleCircles(polygon) - } + polygon = runPaperPolygonBooleanPipeline({ + shape: polygon, + drawOption: useTopToolsStore.getState() + .drawOption as BooleanPipelineDrawOption, + rasterClipOrder: "after", + }) + handleCircles(polygon) console.log("裁剪之后", polygon) - - // if (useTopToolsStore.getState().drawOption === "default") { - // if (useTopToolsStore.getState().pressA) - // compoundPolygon = polygon.clone(); - // } - - // 去除超出图片边界的部分 - handlePolygonIntersectRaster(polygon as any) - // 去除超出图片边界的部分 console.log("边界裁剪之后", polygon) let category = useTopToolsStore @@ -4557,8 +4960,10 @@ export const usePaperStore = create((set) => ({ useRightToolsStore.getState().pushPathId(polygon.data.id) } + const polygonData = polygon?.data + // 如果该类别存在子属性,则弹出子属性对话框 - if (category && category.sub_attributes.length) { + if (polygonData?.id && category && category.sub_attributes.length) { const { top, left } = getShowContextMenuPosition( e.point, category!, @@ -4568,25 +4973,27 @@ export const usePaperStore = create((set) => ({ useOtherToolsStore.getState().setShowContextMenu({ showContextMenu: true, position: { top, left }, - imageId: polygon.data.imageId, - operationId: polygon.data.operationId, - id: polygon.data.id, + imageId: polygonData.imageId, + operationId: polygonData.operationId, + id: polygonData.id, }) } // 设置其为selectedPath - useObjectStore - .getState() - .updateSelectedPath( - useBottomToolsStore.getState().activeImage, - "ADD", - polygon.data.id - ) + if (polygonData?.id) { + useObjectStore + .getState() + .updateSelectedPath( + useBottomToolsStore.getState().activeImage, + "ADD", + polygonData.id + ) + } forceUpdate() if (compoundPolygon instanceof paper.CompoundPath) { - polygon.remove() + polygon?.remove() polygon = null points = [] } else { @@ -4625,6 +5032,12 @@ export const usePaperStore = create((set) => ({ .forEach((item) => { resetPaperShapeFillColor(item) }) + if ( + useTopToolsStore.getState().pressA && + useKeyboardStore.getState().drawAction !== "none" + ) { + restoreSelectedPolygonFillColor() + } } let temp = renderPath(usePaperStore.getState().group!, {}) @@ -4804,6 +5217,8 @@ export const usePaperStore = create((set) => ({ ...state, polygonTool: polygonTool, beginContinuePolygonDraft: () => startContinuePolygonDraft(), + applySelectedPolygonBooleanOperation: (mode, options) => + applySelectedPolygonBooleanOperation(mode, options), })) }, addRectangle: ( @@ -6234,14 +6649,19 @@ export const usePaperStore = create((set) => ({ } return circle }, - getBuffPath: (item: paper.Segment, index: number, id: number, color: any) => { + getBuffPath: ( + item: paper.Segment, + index: number, + id: number, + _color: any + ) => { if (!item.next) return null let bufferPath: paper.Path = new paper.Path( Object.assign( { parent: usePaperStore.getState().group!, segments: [item.point, item.next], - strokeColor: color, // 设置为透明,以便不可见 + strokeColor: new paper.Color(0, 0, 0, 0), // 保留 hit 区域,但本体不可见 strokeScaling: false, strokeWidth: 6, // 适度放宽 hover 区域,但避免干扰对象外默认点击 strokeCap: "round", diff --git a/components/label/utils/geometry/booleanAdapter.ts b/components/label/utils/geometry/booleanAdapter.ts new file mode 100644 index 0000000..78d787b --- /dev/null +++ b/components/label/utils/geometry/booleanAdapter.ts @@ -0,0 +1,806 @@ +import type { MultiPolygon, Pair, Polygon, Ring } from "polygon-clipping" +import * as polygonClipping from "polygon-clipping" + +export type GeometryPoint = Pair +export type NormalizedRing = Ring +export type NormalizedPolygon = Polygon +export type NormalizedMultiPolygon = MultiPolygon + +export type BooleanAdapterMode = "unite" | "subtract" | "intersect" | "xor" + +export interface BooleanAdapterInput { + subject: NormalizedMultiPolygon + operands?: NormalizedMultiPolygon[] + mode: BooleanAdapterMode + rasterClip?: NormalizedMultiPolygon | null +} + +export interface BooleanAdapterMeta { + polygonCount: number + outerRingCount: number + holeCount: number + isEmpty: boolean +} + +export interface BooleanAdapterResult { + geometry: NormalizedMultiPolygon + meta: BooleanAdapterMeta +} + +export type SerializableContour = Array< + | GeometryPoint + | { x: number; y: number } + | { point?: { x: number; y: number } } +> + +export interface PaperLikePathShape { + segments?: SerializableContour + data?: unknown +} + +export interface PaperLikeCompoundPathShape { + children?: PaperLikePathShape[] + data?: unknown +} + +export type LabelTupleLike = [ + number, + SerializableContour[], + unknown, + SerializableContour[], +] + +export const EMPTY_MULTI_POLYGON: NormalizedMultiPolygon = [] +const POINT_EPSILON = 0.000001 +const MIN_RING_AREA = 0.0001 + +const clonePoint = ([x, y]: GeometryPoint): GeometryPoint => [x, y] + +const isSameNumber = (a: number, b: number) => Math.abs(a - b) <= POINT_EPSILON + +const isSamePoint = (a: GeometryPoint, b: GeometryPoint) => + isSameNumber(a[0], b[0]) && isSameNumber(a[1], b[1]) + +const getGeometryPoint = (value: any): GeometryPoint | null => { + if (!value) return null + + if ( + Array.isArray(value) && + value.length >= 2 && + typeof value[0] === "number" && + typeof value[1] === "number" + ) { + return [value[0], value[1]] + } + + if (typeof value.x === "number" && typeof value.y === "number") { + return [value.x, value.y] + } + + if ( + value.point && + typeof value.point.x === "number" && + typeof value.point.y === "number" + ) { + return [value.point.x, value.point.y] + } + + return null +} + +const normalizeOpenRing = (ring: NormalizedRing | null | undefined) => { + if (!ring?.length) return null + + const deduped = ring.reduce((result, point) => { + const nextPoint = clonePoint(point) + const lastPoint = result[result.length - 1] + if (lastPoint && isSamePoint(lastPoint, nextPoint)) return result + result.push(nextPoint) + return result + }, []) + + while ( + deduped.length > 1 && + isSamePoint(deduped[0], deduped[deduped.length - 1]) + ) { + deduped.pop() + } + + return deduped.length >= 3 ? deduped : null +} + +const closeRing = (ring: NormalizedRing) => [...ring, clonePoint(ring[0])] + +const getRingSignedArea = (ring: NormalizedRing) => { + let area = 0 + for (let index = 0; index < ring.length - 1; index++) { + const [x1, y1] = ring[index] + const [x2, y2] = ring[index + 1] + area += x1 * y2 - x2 * y1 + } + return area / 2 +} + +const rotateRingToStableStart = (ring: NormalizedRing) => { + if (!ring.length) return ring + + let startIndex = 0 + for (let index = 1; index < ring.length; index += 1) { + const currentPoint = ring[index] + const startPoint = ring[startIndex] + if ( + currentPoint[0] < startPoint[0] - POINT_EPSILON || + (isSameNumber(currentPoint[0], startPoint[0]) && + currentPoint[1] < startPoint[1] - POINT_EPSILON) + ) { + startIndex = index + } + } + + return [...ring.slice(startIndex), ...ring.slice(0, startIndex)] +} + +const normalizeRing = ( + ring: NormalizedRing | null | undefined, + options: { + clockwise?: boolean + } = {} +) => { + const openRing = normalizeOpenRing(ring) + if (!openRing) return null + + let nextRing = openRing + let signedArea = getRingSignedArea(closeRing(nextRing)) + if (Math.abs(signedArea) <= MIN_RING_AREA) return null + + if (typeof options.clockwise === "boolean") { + const shouldReverse = options.clockwise ? signedArea < 0 : signedArea > 0 + if (shouldReverse) { + nextRing = [...nextRing].reverse() + signedArea = getRingSignedArea(closeRing(nextRing)) + } + } + + const stableRing = rotateRingToStableStart(nextRing) + const closedRing = closeRing(stableRing) + + // 闭环后至少需要 4 个点,且面积不能退化到近似 0。 + return closedRing.length >= 4 && + Math.abs(getRingSignedArea(closedRing)) > MIN_RING_AREA + ? closedRing + : null +} + +export const contourToRing = ( + contour: SerializableContour | null | undefined +): NormalizedRing | null => { + if (!contour?.length) return null + + return normalizeRing( + contour + .map((point) => getGeometryPoint(point)) + .filter((point): point is GeometryPoint => !!point) + ) +} + +const normalizePolygon = (polygon: NormalizedPolygon | null | undefined) => { + if (!polygon?.length) return null + + const [outerRing, ...holeRings] = polygon + const nextOuterRing = normalizeRing(outerRing, { clockwise: true }) + if (!nextOuterRing) return null + + const rings = [ + nextOuterRing, + ...holeRings + .map((ring) => normalizeRing(ring, { clockwise: false })) + .filter((ring): ring is NormalizedRing => !!ring), + ] + + if (!rings.length) return null + + return rings +} + +export const normalizeMultiPolygon = ( + geometry: NormalizedMultiPolygon | null | undefined +): NormalizedMultiPolygon => { + if (!geometry?.length) return [] + + return geometry + .map((polygon) => normalizePolygon(polygon)) + .filter((polygon): polygon is NormalizedPolygon => !!polygon) +} + +const getRingSamplePoint = (ring: NormalizedRing): GeometryPoint => { + const withoutClosure = ring.slice(0, -1) + if (!withoutClosure.length) { + return clonePoint(ring[0]) + } + + const bounds = getRingBounds(ring) + const candidates: GeometryPoint[] = [ + [(bounds.minX + bounds.maxX) / 2, (bounds.minY + bounds.maxY) / 2], + ] + + const [sumX, sumY] = withoutClosure.reduce( + (result, [x, y]) => [result[0] + x, result[1] + y], + [0, 0] + ) + candidates.push([sumX / withoutClosure.length, sumY / withoutClosure.length]) + + let leftMostIndex = 0 + for (let index = 1; index < withoutClosure.length; index += 1) { + const currentPoint = withoutClosure[index] + const leftMostPoint = withoutClosure[leftMostIndex] + if ( + currentPoint[0] < leftMostPoint[0] - POINT_EPSILON || + (isSameNumber(currentPoint[0], leftMostPoint[0]) && + currentPoint[1] < leftMostPoint[1] - POINT_EPSILON) + ) { + leftMostIndex = index + } + } + + const previousPoint = + withoutClosure[ + (leftMostIndex - 1 + withoutClosure.length) % withoutClosure.length + ] + const currentPoint = withoutClosure[leftMostIndex] + const nextPoint = withoutClosure[(leftMostIndex + 1) % withoutClosure.length] + candidates.push([ + (previousPoint[0] + currentPoint[0] + nextPoint[0]) / 3, + (previousPoint[1] + currentPoint[1] + nextPoint[1]) / 3, + ]) + + for (const candidate of candidates) { + if (isPointInsideRing(candidate, ring)) { + return candidate + } + } + + return clonePoint(currentPoint) +} + +const isPointInsideRing = (point: GeometryPoint, ring: NormalizedRing) => { + let inside = false + + for ( + let current = 0, previous = ring.length - 1; + current < ring.length; + previous = current++ + ) { + const [currentX, currentY] = ring[current] + const [previousX, previousY] = ring[previous] + const intersects = + currentY > point[1] !== previousY > point[1] && + point[0] < + ((previousX - currentX) * (point[1] - currentY)) / + (previousY - currentY || Number.EPSILON) + + currentX + + if (intersects) { + inside = !inside + } + } + + return inside +} + +type RingEntry = { + ring: NormalizedRing + sourceIndex: number + signedArea: number + absArea: number + samplePoint: GeometryPoint +} + +type RingBounds = { + minX: number + minY: number + maxX: number + maxY: number +} + +const createRingEntries = (rings: Array) => { + return rings + .map((ring, sourceIndex) => { + if (!ring) return null + const signedArea = getRingSignedArea(ring) + return { + ring, + sourceIndex, + signedArea, + absArea: Math.abs(signedArea), + samplePoint: getRingSamplePoint(ring), + } + }) + .filter((entry): entry is RingEntry => !!entry) +} + +const getRingBounds = (ring: NormalizedRing): RingBounds => { + return ring.reduce( + (result, [x, y]) => ({ + minX: Math.min(result.minX, x), + minY: Math.min(result.minY, y), + maxX: Math.max(result.maxX, x), + maxY: Math.max(result.maxY, y), + }), + { + minX: Number.POSITIVE_INFINITY, + minY: Number.POSITIVE_INFINITY, + maxX: Number.NEGATIVE_INFINITY, + maxY: Number.NEGATIVE_INFINITY, + } + ) +} + +const compareRingEntriesForSerialization = ( + left: RingEntry, + right: RingEntry +) => { + if (right.absArea !== left.absArea) { + return right.absArea - left.absArea + } + + const leftBounds = getRingBounds(left.ring) + const rightBounds = getRingBounds(right.ring) + + if (leftBounds.minX !== rightBounds.minX) { + return leftBounds.minX - rightBounds.minX + } + if (leftBounds.minY !== rightBounds.minY) { + return leftBounds.minY - rightBounds.minY + } + if (leftBounds.maxX !== rightBounds.maxX) { + return leftBounds.maxX - rightBounds.maxX + } + if (leftBounds.maxY !== rightBounds.maxY) { + return leftBounds.maxY - rightBounds.maxY + } + + return left.sourceIndex - right.sourceIndex +} + +const getRingBoundsArea = (bounds: RingBounds) => { + const width = Math.max(0, bounds.maxX - bounds.minX) + const height = Math.max(0, bounds.maxY - bounds.minY) + return width * height +} + +const getRingBoundsIntersectionArea = ( + leftBounds: RingBounds, + rightBounds: RingBounds +) => { + const overlapWidth = + Math.min(leftBounds.maxX, rightBounds.maxX) - + Math.max(leftBounds.minX, rightBounds.minX) + const overlapHeight = + Math.min(leftBounds.maxY, rightBounds.maxY) - + Math.max(leftBounds.minY, rightBounds.minY) + + if (overlapWidth <= 0 || overlapHeight <= 0) { + return 0 + } + + return overlapWidth * overlapHeight +} + +const getBoundsCenterPoint = (bounds: RingBounds): GeometryPoint => [ + (bounds.minX + bounds.maxX) / 2, + (bounds.minY + bounds.maxY) / 2, +] + +const getPointDistance = (left: GeometryPoint, right: GeometryPoint) => + Math.hypot(left[0] - right[0], left[1] - right[1]) + +const isSameRingOrder = ( + left: GeometryPoint[], + right: GeometryPoint[] +): boolean => { + if (left.length !== right.length || !left.length) return false + + for (let offset = 0; offset < right.length; offset += 1) { + let matched = true + for (let index = 0; index < left.length; index += 1) { + if (!isSamePoint(left[index], right[(index + offset) % right.length])) { + matched = false + break + } + } + if (matched) return true + } + + return false +} + +const isSameRing = (left: NormalizedRing, right: NormalizedRing) => { + const leftOpenRing = normalizeOpenRing(left) + const rightOpenRing = normalizeOpenRing(right) + if (!leftOpenRing || !rightOpenRing) return false + + return ( + isSameRingOrder(leftOpenRing, rightOpenRing) || + isSameRingOrder(leftOpenRing, [...rightOpenRing].reverse()) + ) +} + +const getRingMatchScore = (reference: RingEntry, candidate: RingEntry) => { + if (isSameRing(reference.ring, candidate.ring)) { + return Number.MAX_SAFE_INTEGER + } + + const referenceBounds = getRingBounds(reference.ring) + const candidateBounds = getRingBounds(candidate.ring) + const overlapArea = getRingBoundsIntersectionArea( + referenceBounds, + candidateBounds + ) + const smallerBoundsArea = Math.max( + Math.min( + getRingBoundsArea(referenceBounds), + getRingBoundsArea(candidateBounds) + ), + POINT_EPSILON + ) + const overlapRatio = overlapArea / smallerBoundsArea + const areaRatio = + Math.min(reference.absArea, candidate.absArea) / + Math.max(reference.absArea, candidate.absArea, POINT_EPSILON) + const centerDistance = getPointDistance( + getBoundsCenterPoint(referenceBounds), + getBoundsCenterPoint(candidateBounds) + ) + const referenceInsideCandidate = isPointInsideRing( + reference.samplePoint, + candidate.ring + ) + const candidateInsideReference = isPointInsideRing( + candidate.samplePoint, + reference.ring + ) + + return ( + (referenceInsideCandidate ? 1000000 : 0) + + (candidateInsideReference ? 500000 : 0) + + overlapRatio * 1000 + + areaRatio * 100 - + centerDistance + ) +} + +const attachHolesToOuters = ( + outerEntries: RingEntry[], + holeEntries: RingEntry[] +): NormalizedMultiPolygon => { + const polygons = outerEntries.map((outerEntry) => ({ + outer: outerEntry, + holes: [] as RingEntry[], + })) + + holeEntries.forEach((holeEntry) => { + const owner = polygons + .filter((polygon) => + isPointInsideRing(holeEntry.samplePoint, polygon.outer.ring) + ) + .sort((left, right) => left.outer.absArea - right.outer.absArea)[0] + + if (owner) { + owner.holes.push(holeEntry) + } + }) + + return polygons.map(({ outer, holes }) => [ + outer.ring, + ...holes + .sort((left, right) => left.sourceIndex - right.sourceIndex) + .map((hole) => hole.ring), + ]) +} + +export const contoursToMultiPolygon = ({ + outerContours = [], + holeContours = [], +}: { + outerContours?: Array + holeContours?: Array +}): NormalizedMultiPolygon => { + const outerEntries = createRingEntries( + outerContours.map((contour) => contourToRing(contour)) + ).sort((left, right) => left.sourceIndex - right.sourceIndex) + const holeEntries = createRingEntries( + holeContours.map((contour) => contourToRing(contour)) + ) + + return normalizeMultiPolygon(attachHolesToOuters(outerEntries, holeEntries)) +} + +export const labelTupleToMultiPolygon = ( + tuple: + | LabelTupleLike + | { + 1: SerializableContour[] + 3: SerializableContour[] + } + | null + | undefined +) => { + if (!tuple) return EMPTY_MULTI_POLYGON + + return contoursToMultiPolygon({ + outerContours: tuple[1] as SerializableContour[], + holeContours: tuple[3] as SerializableContour[], + }) +} + +export const paperShapeToMultiPolygon = ( + shape: PaperLikePathShape | PaperLikeCompoundPathShape | null | undefined +) => { + if (!shape) return EMPTY_MULTI_POLYGON + + const rings = Array.isArray((shape as PaperLikeCompoundPathShape).children) + ? ((shape as PaperLikeCompoundPathShape).children || []) + .map((child) => contourToRing(child.segments)) + .filter((ring): ring is NormalizedRing => !!ring) + : [contourToRing((shape as PaperLikePathShape).segments)].filter( + (ring): ring is NormalizedRing => !!ring + ) + + if (!rings.length) return EMPTY_MULTI_POLYGON + + const ringEntries = createRingEntries(rings) + const classified = ringEntries.map((entry, entryIndex) => { + const depth = ringEntries.reduce((count, otherEntry, otherIndex) => { + if (entryIndex === otherIndex) return count + return isPointInsideRing(entry.samplePoint, otherEntry.ring) + ? count + 1 + : count + }, 0) + + return { + ...entry, + isHole: depth % 2 === 1, + } + }) + + if (classified.length && !classified.some((entry) => !entry.isHole)) { + const largestOuter = classified.reduce((result, entry) => { + if (!result) return entry + return entry.absArea > result.absArea ? entry : result + }, classified[0]) + + if (largestOuter) { + largestOuter.isHole = false + } + } + + return normalizeMultiPolygon( + attachHolesToOuters( + classified.filter((entry) => !entry.isHole), + classified.filter((entry) => entry.isHole) + ) + ) +} + +export const multiPolygonToStoredContours = ( + geometry: NormalizedMultiPolygon | null | undefined +) => { + const normalizedGeometry = normalizeMultiPolygon(geometry) + const sortedGeometry = normalizedGeometry + .map((polygon) => { + const [outerRing, ...holeRings] = polygon + if (!outerRing) return null + + const outerEntry = createRingEntries([outerRing])[0] + const holeEntries = createRingEntries(holeRings).sort( + compareRingEntriesForSerialization + ) + + return { + outer: outerEntry, + holes: holeEntries, + } + }) + .filter( + ( + polygon + ): polygon is { + outer: RingEntry + holes: RingEntry[] + } => !!polygon + ) + .sort((left, right) => + compareRingEntriesForSerialization(left.outer, right.outer) + ) + + const points = sortedGeometry.map(({ outer }) => + outer.ring.map((point) => clonePoint(point)) + ) + + const hollowPoints = sortedGeometry.flatMap(({ holes }) => + holes.map((hole) => hole.ring.map((point) => clonePoint(point))) + ) + + return { + points, + hollowPoints, + } +} + +export const multiPolygonToLabelTuple = ( + objectId: number, + detail: unknown, + geometry: NormalizedMultiPolygon | null | undefined +): LabelTupleLike => { + const { points, hollowPoints } = multiPolygonToStoredContours(geometry) + return [objectId, points, detail, hollowPoints] +} + +const getLastValidContourIndex = ( + contours: Array +) => { + for (let index = contours.length - 1; index >= 0; index -= 1) { + if (contourToRing(contours[index])) { + return index + } + } + return -1 +} + +export const findBestMatchingOuterContourIndex = ({ + previousContours = [], + nextContours = [], + preferredIndex = null, +}: { + previousContours?: Array + nextContours?: Array + preferredIndex?: number | null +}) => { + const nextEntries = createRingEntries( + nextContours.map((contour) => contourToRing(contour)) + ) + if (!nextEntries.length) return -1 + + const fallbackIndex = (() => { + if ( + preferredIndex !== null && + preferredIndex >= 0 && + preferredIndex < nextContours.length && + contourToRing(nextContours[preferredIndex]) + ) { + return preferredIndex + } + + const lastValidContourIndex = getLastValidContourIndex(nextContours) + return lastValidContourIndex >= 0 + ? lastValidContourIndex + : nextEntries[0].sourceIndex + })() + + const previousRings = previousContours.map((contour) => + contourToRing(contour) + ) + const preferredReferenceRing = + preferredIndex !== null && + preferredIndex >= 0 && + preferredIndex < previousRings.length + ? previousRings[preferredIndex] + : null + const fallbackReferenceRing = + preferredReferenceRing || + [...previousRings] + .reverse() + .find((ring): ring is NormalizedRing => !!ring) || + null + + if (!fallbackReferenceRing) { + return fallbackIndex + } + + const referenceEntry = createRingEntries([fallbackReferenceRing])[0] + const bestMatch = nextEntries.reduce<{ + entry: RingEntry + score: number + } | null>((result, entry) => { + const score = getRingMatchScore(referenceEntry, entry) + if (!result) { + return { entry, score } + } + + if (score > result.score + POINT_EPSILON) { + return { entry, score } + } + + if ( + isSameNumber(score, result.score) && + compareRingEntriesForSerialization(entry, result.entry) < 0 + ) { + return { entry, score } + } + + return result + }, null) + + return bestMatch?.entry.sourceIndex ?? fallbackIndex +} + +const buildMeta = (geometry: NormalizedMultiPolygon): BooleanAdapterMeta => { + const outerRingCount = geometry.length + const holeCount = geometry.reduce((count, polygon) => { + return count + Math.max(0, polygon.length - 1) + }, 0) + + return { + polygonCount: geometry.length, + outerRingCount, + holeCount, + isEmpty: geometry.length === 0, + } +} + +const unionAll = (geometries: NormalizedMultiPolygon[]) => { + const [first, ...rest] = geometries + if (!first?.length) return [] + return polygonClipping.union(first, ...rest) +} + +const applyMode = ( + mode: BooleanAdapterMode, + subject: NormalizedMultiPolygon, + operands: NormalizedMultiPolygon[] +) => { + if (!subject.length) { + return mode === "unite" ? unionAll(operands) : [] + } + + if (!operands.length) { + return subject + } + + switch (mode) { + case "unite": + return polygonClipping.union(subject, ...operands) + case "subtract": + return polygonClipping.difference(subject, ...operands) + case "intersect": + return polygonClipping.intersection(subject, ...operands) + case "xor": + return polygonClipping.xor(subject, ...operands) + default: + return subject + } +} + +export const applyBooleanAdapter = ({ + subject, + operands = [], + mode, + rasterClip = null, +}: BooleanAdapterInput): BooleanAdapterResult => { + const normalizedSubject = normalizeMultiPolygon(subject) + const normalizedOperands = operands + .map((geometry) => normalizeMultiPolygon(geometry)) + .filter((geometry) => geometry.length) + + let nextGeometry = normalizeMultiPolygon( + applyMode(mode, normalizedSubject, normalizedOperands) + ) + + if (rasterClip?.length && nextGeometry.length) { + nextGeometry = normalizeMultiPolygon( + polygonClipping.intersection( + nextGeometry, + normalizeMultiPolygon(rasterClip) + ) + ) + } + + return { + geometry: nextGeometry, + meta: buildMeta(nextGeometry), + } +} + +// TODO phase 2: +// 1. 为 concave ring 补更强的 interior point / overlap 计算,减少极端轮廓下的误判。 +// 2. 为几何层追加最小可重复验证入口,覆盖多次 a/d/,/. 链式操作。 diff --git a/components/label/utils/objectVisibility.ts b/components/label/utils/objectVisibility.ts index 2ae76ca..21b526b 100644 --- a/components/label/utils/objectVisibility.ts +++ b/components/label/utils/objectVisibility.ts @@ -13,6 +13,13 @@ const getTagVisibilityByConfig = (tagCategory?: string) => { return true } +const resetSelectedItemVisual = (item: paper.Item) => { + if (!(item instanceof paper.Path || item instanceof paper.CompoundPath)) + return + if (!["rectangle", "polygon"].includes(item.data?.type || "")) return + item.fillColor = item.data?.blankColor || item.fillColor || null +} + export const getObjectTagVisibility = ( imageId: string, objectId: number, @@ -30,17 +37,12 @@ const setSelectedItemFalse = (id: number) => { if (item.data.type === "mask" || item.data.type === "text") { item.remove() } + resetSelectedItemVisual(item) if (item.data.selected) item.data.selected = false }) } const clearHiddenObjectSelectionState = (imageId: string, objectId: number) => { - const { selectedPath, updateSelectedPath } = useObjectStore.getState() - if (selectedPath[imageId]?.includes(objectId)) { - updateSelectedPath(imageId, "DELETE", objectId) - usePaperSupportStore.getState().clearAll() - } - const otherToolsState = useOtherToolsStore.getState() if ( otherToolsState.showContextMenu && @@ -84,6 +86,69 @@ const syncObjectItemsVisibility = ( }) } +const applySelectedItemVisual = (item: paper.Item) => { + if (!(item instanceof paper.Path || item instanceof paper.CompoundPath)) + return + const itemType = item.data?.type + if (!["rectangle", "polygon", "brush", "point"].includes(itemType || "")) + return + + if (itemType === "rectangle" && item instanceof paper.Path) { + item.data.selected = true + usePaperSupportStore.getState().handleBufferPaths(item) + usePaperSupportStore.getState().handleCircles(item) + item.fillColor = item.data.fillColor || item.fillColor || null + } else if (itemType === "brush") { + item.data.selected = true + if (item instanceof paper.Path) { + usePaperSupportStore.getState().handleMask(item) + usePaperSupportStore.getState().handleBufferPaths(item) + usePaperSupportStore.getState().handleCircles(item) + } + } else if (itemType === "point" && item instanceof paper.Path) { + item.data.selected = true + usePaperSupportStore.getState().handleMask(item) + usePaperSupportStore.getState().handleText(item) + usePaperSupportStore.getState().handleBufferPaths(item) + usePaperSupportStore.getState().handleCircles(item) + } else if (itemType === "polygon") { + item.data.selected = true + if (!(item instanceof paper.Path) || !item.data?.isHollowPolygon) { + item.fillColor = item.data.fillColor || item.fillColor || null + } + } + + item.bringToFront() +} + +export const syncSelectedObjectsVisualState = (imageId: string) => { + const group = usePaperStore.getState().group + if (!group) return + + group + .getItems({ + match: (item: paper.Item) => !!item.data?.selected, + }) + .forEach((item) => { + resetSelectedItemVisual(item) + item.data.selected = false + }) + + usePaperSupportStore.getState().clearAll() + + const { selectedPath, pathStatus } = useObjectStore.getState() + ;(selectedPath[imageId] || []).forEach((selectedId) => { + if (pathStatus[imageId + selectedId]?.HIDE) return + usePaperStore + .getState() + .getItemsById(selectedId) + .forEach((item) => { + if (item.data?.type === "tag") return + applySelectedItemVisual(item) + }) + }) +} + export const toggleObjectHideByShortcut = ( imageId: string, operationId: string, @@ -98,6 +163,7 @@ export const toggleObjectHideByShortcut = ( syncObjectItemsVisibility(imageId, objectId, hide) setSelectedItemFalse(objectId) clearHiddenObjectSelectionState(imageId, objectId) + syncSelectedObjectsVisualState(imageId) const operationChildren = storeLabel.get(imageId)?.get(operationId) || [] const hasVisibleChild = operationChildren.some((child) => { @@ -115,4 +181,5 @@ export const toggleObjectHideByShortcut = ( updateOperationStatus(imageId + operationId, "HIDE", hide) updatePathStatus(imageId + objectId, "HIDE", hide) syncObjectItemsVisibility(imageId, objectId, hide) + syncSelectedObjectsVisualState(imageId) } diff --git a/package.json b/package.json index cc440cf..2868c87 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "msgpack-lite": "^0.1.26", "next": "15.5.7", "paper": "^0.12.18", + "polygon-clipping": "^0.15.7", "react": "19.2.1", "react-dom": "19.2.1", "react-window": "^2.2.3",