From e4aae0a7af18e5b8477724460d5c7794af510f2e Mon Sep 17 00:00:00 2001 From: zhangheng Date: Fri, 3 Apr 2026 18:06:52 +0800 Subject: [PATCH] feat(shortcut): ,/ --- components/label/LabelNossr.tsx | 438 +++++++++++++++++++++++++++++- components/label/usePaperStore.ts | 139 ++++++++-- 2 files changed, 551 insertions(+), 26 deletions(-) diff --git a/components/label/LabelNossr.tsx b/components/label/LabelNossr.tsx index 7637ae4..600ca45 100644 --- a/components/label/LabelNossr.tsx +++ b/components/label/LabelNossr.tsx @@ -12,6 +12,7 @@ import ScaleComponent from "./components/ScaleComponent" import { Box, Button, Flex, LoadingOverlay, Stack } from "@mantine/core" import { notifications, showNotification } from "@mantine/notifications" +import paper from "paper" import { getLabelResult, getServerImage } from "./api/label" import { Comment, @@ -56,8 +57,8 @@ import { import { useTopToolsStore } from "./useTopToolsStore" import { findGroupKey } from "./util" import { safeClone } from "./utils/clone" -import { toggleObjectHideByShortcut } from "./utils/objectVisibility" import { labelTypeMap } from "./utils/constants" +import { toggleObjectHideByShortcut } from "./utils/objectVisibility" // Splitter component - will need custom implementation or alternative @@ -1511,6 +1512,395 @@ const LabelPage = ({ ) }, [resolveSelectedObjectContext]) + const rerenderActiveImageByLabel = useCallback( + ( + imageId: string, + imageLabel: Map, + selectedIds: number[] + ) => { + const paperGroup = usePaperStore.getState().group + if (paperGroup) { + paperGroup + .getItems({}) + .filter((item) => item.data.id) + .forEach((item) => { + item.remove() + }) + + for (const [categoryId] of imageLabel.entries()) { + paperContainerRef.current?.renderPolygons?.(categoryId, imageLabel) + } + } + + useObjectStore.setState((state) => ({ + ...state, + selectedPath: { + ...state.selectedPath, + [imageId]: selectedIds, + }, + })) + }, + [] + ) + + 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 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() + if (!selectedContext) { + notifications.show({ + color: "yellow", + message: "请先选中单个对象", + }) + return + } + if (selectedContext.objectType !== "polygon") { + notifications.show({ + color: "yellow", + message: + mode === "subtract" + ? "镂空快捷键仅支持 polygon 类型对象" + : "融合快捷键仅支持 polygon/brush 类型对象", + }) + 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] : [] + ) + }, + [ + collectPolygonPoints, + createWorkingPolygonShape, + hasShapeSegments, + removeObjectFromGroupMap, + rerenderActiveImageByLabel, + resolveSelectedObjectContext, + ] + ) + + const applySelectedBrushIntersectUnite = useCallback(() => { + const selectedContext = resolveSelectedObjectContext() + if (!selectedContext) { + notifications.show({ + color: "yellow", + message: "请先选中单个对象", + }) + return + } + if (selectedContext.objectType !== "brush") { + notifications.show({ + color: "yellow", + message: "融合快捷键仅支持 polygon/brush 类型对象", + }) + return + } + + const group = usePaperStore.getState().group + if (!group) return + + const selectedBrushPaths = group + .getItems({ data: { id: selectedContext.objectId } }) + .filter((item) => item.data?.type === "brush") + .flatMap((item) => { + if (item instanceof paper.CompoundPath) + return item.children as paper.Path[] + if (item instanceof paper.Path) return [item] + return [] + }) + .filter((path) => path.segments?.length) + + if (!selectedBrushPaths.length) return + + const otherBrushPathMap = new Map() + group.children.forEach((item: any) => { + if ( + !item.visible || + !item.data?.id || + item.data.id === selectedContext.objectId || + item.data.type !== "brush" + ) + return + + const targetId = Number(item.data.id) + const currentPaths = otherBrushPathMap.get(targetId) || [] + if (item instanceof paper.CompoundPath) { + otherBrushPathMap.set(targetId, [ + ...currentPaths, + ...(item.children as paper.Path[]), + ]) + } else if (item instanceof paper.Path) { + otherBrushPathMap.set(targetId, [...currentPaths, item]) + } + }) + + const intersectingIds = new Set() + for (const [otherId, paths] of otherBrushPathMap.entries()) { + const hasIntersection = paths.some((otherPath) => { + return selectedBrushPaths.some((selectedPath) => { + if (!selectedPath.bounds.intersects(otherPath.bounds)) return false + return selectedPath.getIntersections(otherPath).length > 0 + }) + }) + if (hasIntersection) intersectingIds.add(otherId) + } + if (!intersectingIds.size) return + + const nextLabel = safeClone(useLabelStore.getState().label) + const imageLabel = nextLabel.get(selectedContext.imageId) + if (!imageLabel) return + + const selectedOperationPaths = safeClone( + imageLabel.get(selectedContext.operationId) || [] + ) + const selectedIndex = selectedOperationPaths.findIndex( + (item) => item[0] === selectedContext.objectId + ) + if (selectedIndex === -1) return + + const selectedTuple = selectedOperationPaths[selectedIndex] + const mergedPoints = safeClone(selectedTuple?.[1] || []) + const pointSet = new Set( + mergedPoints.map((pointItem) => JSON.stringify(pointItem)) + ) + + let appendedCount = 0 + imageLabel.forEach((paths) => { + paths.forEach((item) => { + if (!intersectingIds.has(item[0])) return + ;(item[1] || []).forEach((pathPoints: any) => { + const key = JSON.stringify(pathPoints) + if (pointSet.has(key)) return + pointSet.add(key) + mergedPoints.push(safeClone(pathPoints)) + appendedCount++ + }) + }) + }) + + if (!appendedCount) return + + selectedOperationPaths[selectedIndex] = [ + selectedContext.objectId, + mergedPoints, + safeClone(selectedTuple?.[2] || {}), + safeClone(selectedTuple?.[3] || []), + ] + imageLabel.set(selectedContext.operationId, selectedOperationPaths) + + useLabelStore.getState().setLabel(nextLabel) + useLabelStore.getState().pushStateStack(nextLabel) + + rerenderActiveImageByLabel(selectedContext.imageId, imageLabel, [ + selectedContext.objectId, + ]) + }, [rerenderActiveImageByLabel, resolveSelectedObjectContext]) + const handleMergeSelectedObjects = useCallback(() => { const activeImage = useBottomToolsStore.getState().activeImage const selectedIds = @@ -1655,7 +2045,7 @@ const LabelPage = ({ const mode = usePaperStore.getState().mode const lowerKey = e.key.toLowerCase() - if (e.repeat && ["a", "d", "h"].includes(lowerKey)) return + if (e.repeat && ["a", "d", "h", ",", "."].includes(lowerKey)) return if (!e.ctrlKey && !e.metaKey && !e.altKey && lowerKey === "h") { e.preventDefault() @@ -1675,6 +2065,47 @@ const LabelPage = ({ return } + if ( + !e.ctrlKey && + !e.metaKey && + !e.altKey && + [",", ","].includes(e.key) + ) { + e.preventDefault() + applySelectedPolygonOverlap("subtract") + return + } + + if ( + !e.ctrlKey && + !e.metaKey && + !e.altKey && + [".", "。"].includes(e.key) + ) { + e.preventDefault() + const selectedContext = resolveSelectedObjectContext() + if (!selectedContext) { + notifications.show({ + color: "yellow", + message: "请先选中单个对象", + }) + return + } + if (selectedContext.objectType === "polygon") { + applySelectedPolygonOverlap("unite") + return + } + if (selectedContext.objectType === "brush") { + applySelectedBrushIntersectUnite() + return + } + notifications.show({ + color: "yellow", + message: "融合快捷键仅支持 polygon/brush 类型对象", + }) + return + } + if (e.altKey && e.key.toLowerCase() === "x") { e.preventDefault() if (!isView) topRef.current?.commitTaskByActionKey?.() @@ -1896,6 +2327,8 @@ const LabelPage = ({ element?.removeEventListener("mousemove", mousemove) } }, [ + applySelectedBrushIntersectUnite, + applySelectedPolygonOverlap, editMode, enterSelectedObjectDrawMode, focusInput, @@ -1911,6 +2344,7 @@ const LabelPage = ({ setPressA, setScale, setShift, + resolveSelectedObjectContext, ]) useEffect(() => { diff --git a/components/label/usePaperStore.ts b/components/label/usePaperStore.ts index e11ff42..a3bd420 100644 --- a/components/label/usePaperStore.ts +++ b/components/label/usePaperStore.ts @@ -2866,6 +2866,96 @@ 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 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][] + ) => { + 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 + } + } + return inside + } + + const getHollowFlagsByContainment = (allSegments: any[][]) => { + const polygons = allSegments.map((segments) => + normalizePolygonFromSegments(segments) + ) + 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 + }) + } + const handleSelectedPolygonSubtract = (draftPath: paper.Path) => { const activeImage = useBottomToolsStore.getState().activeImage const selectedIds = @@ -2915,36 +3005,37 @@ export const usePaperStore = create((set) => ({ clippedDraft = null targetShape.remove() - if (nextPolygon instanceof paper.CompoundPath) { - nextPolygon.data = Object.assign({}, targetData) - ;(nextPolygon.children as paper.Path[]).forEach((child) => { - child.data = Object.assign({}, targetData, { - isHollowPolygon: !child.clockwise, - }) - }) - } else { - nextPolygon.data = Object.assign({}, targetData) - } - const points: any[] = [] const hollowPoints: any[] = [] - const collectPathPoints = (path: paper.Path) => { - if (!path.segments?.length) return + 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 })) - if (path.clockwise) { - points.push(pathData[1]?.segments) - } else { - hollowPoints.push(pathData[1]?.segments) - } - } + return pathData?.[1]?.segments || null + }) + const hollowFlags = getHollowFlagsByContainment( + nextPathSegments.map((segments) => segments || []) + ) if (nextPolygon instanceof paper.CompoundPath) { - ;(nextPolygon.children as paper.Path[]).forEach((child) => { - collectPathPoints(child) - }) - } else { - collectPathPoints(nextPolygon) + 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) + } + }) const operationId = ( targetData.operationId ||