diff --git a/components/label/components/PaperContainer.tsx b/components/label/components/PaperContainer.tsx index 5042816..d7b8c39 100644 --- a/components/label/components/PaperContainer.tsx +++ b/components/label/components/PaperContainer.tsx @@ -1429,26 +1429,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) => { diff --git a/components/label/usePaperStore.ts b/components/label/usePaperStore.ts index 7945303..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: @@ -233,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) => { @@ -258,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 = @@ -284,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() @@ -319,7 +823,10 @@ const handleDelete = () => { const cleanupDeletedObjectItems = ( imageId: string, pathId?: number, - parentGroupId?: number + parentGroupId?: number, + options: { + pushState?: boolean + } = {} ) => { if (!pathId) return @@ -333,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) => { @@ -2406,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 @@ -2465,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 @@ -2661,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 || @@ -2691,6 +3065,7 @@ export const usePaperStore = create((set) => ({ console.log("裁剪之后", newDrawPath) if ( + !newDrawPath || newDrawPath instanceof paper.CompoundPath || (newDrawPath instanceof paper.Path && newDrawPath.area !== area) ) { @@ -2712,9 +3087,6 @@ export const usePaperStore = create((set) => ({ }) } - // 边界裁剪 - // handlePolygonIntersectRaster(newDrawPath as any); - // if ( // newDrawPath instanceof paper.CompoundPath || // (newDrawPath instanceof paper.Path && newDrawPath.area !== area) @@ -2823,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, @@ -3787,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 @@ -3998,248 +4297,6 @@ export const usePaperStore = create((set) => ({ } } - const removeSelectedPolygonRootItems = ( - context: NonNullable> - ) => { - const group = usePaperStore.getState().group - context.targetShapes - .filter((item) => item.parent === group) - .forEach((item) => { - item.remove() - }) - } - - const prepareWorkingPolygonShape = ( - shape: paper.Path | paper.CompoundPath, - context: NonNullable> - ) => { - const visible = context.targetData.visible !== false - 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, - selected: true, - }) - - let selectedAssigned = false - const applyPathStyle = (path: paper.Path, isHollowPolygon: boolean) => { - path.data = Object.assign( - {}, - baseData, - getSafePathData(path.data || {}), - { - isHollowPolygon, - selected: !selectedAssigned && !isHollowPolygon, - } - ) - path.strokeColor = strokeColor - path.strokeWidth = strokeWidth - path.visible = visible - if (!isHollowPolygon) { - path.fillColor = fillColor - } - path.closed = true - if (!selectedAssigned && !isHollowPolygon) { - selectedAssigned = true - } - } - - if (shape instanceof paper.CompoundPath) { - const hollowChildren = new Set() - const contourEntries = (shape.children as paper.Path[]) - .filter((child) => child.segments?.length) - .map((child, index) => ({ - child, - index, - samplePoint: getPathInteriorPoint(child), - isHollow: false, - })) - - contourEntries.forEach((entry, entryIndex) => { - const nestingDepth = contourEntries.reduce( - (count, other, otherIndex) => { - if (entryIndex === otherIndex) return count - return other.child.contains(entry.samplePoint) ? count + 1 : count - }, - 0 - ) - entry.isHollow = nestingDepth % 2 === 1 - }) - - if ( - contourEntries.length && - !contourEntries.some((entry) => !entry.isHollow) - ) { - const largestContour = contourEntries.reduce((result, entry) => { - if (!result) return entry - return Math.abs(entry.child.area) > Math.abs(result.child.area) - ? entry - : result - }, contourEntries[0]) - if (largestContour) { - largestContour.isHollow = false - } - } - - contourEntries.forEach((entry) => { - if (entry.isHollow) { - hollowChildren.add(entry.child) - } - }) - - shape.data = Object.assign({}, baseData) - shape.strokeColor = strokeColor - shape.strokeWidth = strokeWidth - shape.fillColor = fillColor - shape.visible = visible - ;(shape.children as paper.Path[]).forEach((child) => { - applyPathStyle(child, hollowChildren.has(child)) - }) - if (!selectedAssigned && shape.children[0] instanceof paper.Path) { - ;(shape.children[0] as paper.Path).data = Object.assign( - {}, - shape.children[0].data || {}, - { selected: true } - ) - } - } else { - applyPathStyle(shape, !!shape.data?.isHollowPolygon) - } - - const group = usePaperStore.getState().group - if (group && shape.parent !== group) { - group.addChild(shape) - } - insertItemBelowTags(shape) - return shape - } - - const getPathInteriorPoint = (path: paper.Path) => { - const interiorPoint = (path as any).interiorPoint - if ( - interiorPoint instanceof paper.Point && - path.contains(interiorPoint) - ) { - return new paper.Point(interiorPoint) - } - - if (path.contains(path.position)) { - return new paper.Point(path.position) - } - - const boundsCenter = path.bounds.center - if (path.contains(boundsCenter)) { - return new paper.Point(boundsCenter) - } - - if (path.segments?.length) { - const centroid = path.segments.reduce( - (result, segment) => result.add(segment.point), - new paper.Point(0, 0) - ) - const averagePoint = centroid.divide(path.segments.length) - if (path.contains(averagePoint)) { - return averagePoint - } - - const firstPoint = path.segments[0].point - const centerPoint = firstPoint.add(boundsCenter).divide(2) - if (path.contains(centerPoint)) { - return centerPoint - } - - return new paper.Point(firstPoint) - } - - return new paper.Point(path.position) - } - - const serializeCompoundPolygonContours = ( - currentPolygon: paper.CompoundPath - ) => { - const hollowChildren = new Set() - const contourEntries = (currentPolygon.children as paper.Path[]) - .filter((path) => path.segments?.length) - .map((path, index) => { - const pathData = JSON.parse(path.exportJSON({ precision: 20 })) - return { - index, - path, - samplePoint: getPathInteriorPoint(path), - segments: pathData[1]?.segments || [], - isHollow: false, - } - }) - - contourEntries.forEach((entry, entryIndex) => { - const nestingDepth = contourEntries.reduce( - (count, other, otherIndex) => { - if (entryIndex === otherIndex) return count - return other.path.contains(entry.samplePoint) ? count + 1 : count - }, - 0 - ) - entry.isHollow = nestingDepth % 2 === 1 - }) - - if ( - contourEntries.length && - !contourEntries.some((entry) => !entry.isHollow) - ) { - const largestContour = contourEntries.reduce((result, entry) => { - if (!result) return entry - return Math.abs(entry.path.area) > Math.abs(result.path.area) - ? entry - : result - }, contourEntries[0]) - if (largestContour) { - largestContour.isHollow = false - } - } - - contourEntries.forEach((entry) => { - if (entry.isHollow) { - hollowChildren.add(entry.path) - } - }) - - const points: any[] = [] - const hollowPoints: any[] = [] - let lastOuterContourPoints: paper.Point[] = [] - - contourEntries.forEach((entry) => { - if (!entry.segments.length) return - if (hollowChildren.has(entry.path)) { - hollowPoints.push(entry.segments) - return - } - points.push(entry.segments) - lastOuterContourPoints = getContinueContourPoints(entry.segments) - }) - - return { - points, - hollowPoints, - lastOuterContourPoints, - } - } - const rebuildPersistedSelectedPolygon = ( context: NonNullable> ) => { @@ -4326,6 +4383,9 @@ export const usePaperStore = create((set) => ({ .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, @@ -4376,66 +4436,96 @@ export const usePaperStore = create((set) => ({ } } - const persistSelectedPolygonResult = ( - context: NonNullable> + const resolvePreferredContinueOuterContourIndex = ( + context: NonNullable>, + contours: any[] ) => { - const currentPolygon = polygon - const newDetail = getEditedPolygonDetail(context) + if (!contours.length) return -1 - if (currentPolygon instanceof paper.CompoundPath) { - const { points, hollowPoints, lastOuterContourPoints } = - serializeCompoundPolygonContours(currentPolygon) + const continueTarget = { + imageId: context.activeImage, + operationId: context.operationId, + objectId: context.selectedId, + } + const continueHint = continueDraftHints.get( + getContinueDraftHintKey(continueTarget) + ) - if (points.length) { - useLabelStore - .getState() - .setOperation(context.activeImage, context.operationId, [ - context.selectedId, - points, - newDetail, - hollowPoints, - ]) - setContinueDraftHint( - { - imageId: context.activeImage, - operationId: context.operationId, - objectId: context.selectedId, - }, - points.length - 1, - lastOuterContourPoints - ) - useObjectStore - .getState() - .updateSelectedPath(context.activeImage, "ADD", context.selectedId) - } else { - useLabelStore - .getState() - .deleteOperation( - context.activeImage, - context.operationId, - context.selectedId - ) - cleanupDeletedObjectItems( - context.activeImage, - context.selectedId, - context.targetData.parentGroupId - ) - } - } else if ( - currentPolygon instanceof paper.Path && - currentPolygon.segments?.length + if ( + continueHint && + continueHint.contourIndex >= 0 && + continueHint.contourIndex < contours.length && + contours[continueHint.contourIndex]?.length > 2 ) { - const pathData = JSON.parse( - currentPolygon.exportJSON({ precision: 20 }) + 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 ) - const currentContourSegments = pathData[1]?.segments || [] + 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(context.activeImage, context.operationId, [ context.selectedId, - [currentContourSegments], + points, newDetail, - [], + hollowPoints, ]) setContinueDraftHint( { @@ -4443,8 +4533,10 @@ export const usePaperStore = create((set) => ({ operationId: context.operationId, objectId: context.selectedId, }, - 0, - getContinueContourPoints(currentContourSegments) + nextContinueContourIndex >= 0 + ? nextContinueContourIndex + : points.length - 1, + getContinueContourPoints(nextContinueContourSegments) ) useObjectStore .getState() @@ -4465,7 +4557,7 @@ export const usePaperStore = create((set) => ({ } rebuildPersistedSelectedPolygon(context) - restoreSelectedPolygonFillColor() + syncSelectedObjectsVisualState(context.activeImage) forceUpdate() return true } @@ -4484,7 +4576,10 @@ export const usePaperStore = create((set) => ({ const prevDrawOption = useTopToolsStore.getState().drawOption const nextDrawOption = mode === "subtract" ? "intersect" : "unite" const baseShape = createWorkingPolygonShape(context.targetShapes) - let clippedDraft: paper.Path | paper.CompoundPath | null = null + const rasterGeometry = paperShapeToMultiPolygon( + usePaperStore.getState().rasterPath + ) + const overlapMode = nextDrawOption === "intersect" ? "subtract" : "unite" if (!baseShape || !hasShapeSegments(baseShape)) { options.draftPath?.remove() @@ -4492,57 +4587,53 @@ export const usePaperStore = create((set) => ({ return false } - if (options.draftPath) { - clippedDraft = options.draftPath.intersect( - usePaperStore.getState().rasterPath!, - { - insert: false, - trace: false, - } - ) as paper.Path | paper.CompoundPath - options.draftPath.remove() - - if (!hasShapeSegments(clippedDraft)) { - clippedDraft?.remove() - baseShape.remove() - return false - } + let workingGeometry = paperShapeToMultiPolygon(baseShape) + baseShape.remove() + if (!workingGeometry.length) { + options.draftPath?.remove() + return false } - removeSelectedPolygonRootItems(context) - let workingShape = prepareWorkingPolygonShape(baseShape, context) - - if (clippedDraft) { - const nextShape = - mode === "subtract" - ? (workingShape.subtract(clippedDraft, { - insert: false, - }) as paper.Path | paper.CompoundPath) - : (workingShape.unite(clippedDraft, { - insert: false, - }) as paper.Path | paper.CompoundPath) - clippedDraft.remove() - workingShape.remove() - workingShape = prepareWorkingPolygonShape(nextShape, context) - } - - polygon = workingShape - useTopToolsStore.getState().setDrawOption(nextDrawOption) try { - if (useTopToolsStore.getState().drawOption === "intersect") { - handlePolygonIntersect(polygon as paper.Path) - handleCircles(polygon) - } else if (useTopToolsStore.getState().drawOption === "unite") { - handlePolygonUnite() - handleCircles(polygon) + 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 } - handlePolygonIntersectRaster(polygon as any) - if (polygon && hasShapeSegments(polygon)) { - polygon = prepareWorkingPolygonShape(polygon, context) - } - const persisted = persistSelectedPolygonResult(context) + const overlapOperands = collectVisiblePaperPolygonOperandGeometries({ + group: usePaperStore.getState().group, + excludedObjectId: context.selectedId, + subjectGeometry: workingGeometry, + }) + + workingGeometry = applyBooleanAdapter({ + subject: workingGeometry, + operands: overlapOperands, + mode: overlapMode, + rasterClip: rasterGeometry.length ? rasterGeometry : null, + }).geometry + + const persisted = persistSelectedPolygonGeometryResult( + context, + workingGeometry + ) clearPolygonBooleanHelperItems() polygon = null return persisted @@ -4741,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 @@ -4880,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!, @@ -4891,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 { @@ -6565,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 20312f9..21b526b 100644 --- a/components/label/utils/objectVisibility.ts +++ b/components/label/utils/objectVisibility.ts @@ -121,7 +121,7 @@ const applySelectedItemVisual = (item: paper.Item) => { item.bringToFront() } -const syncSelectedObjectsVisualState = (imageId: string) => { +export const syncSelectedObjectsVisualState = (imageId: string) => { const group = usePaperStore.getState().group if (!group) return 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",