Merge branch 'zh' into 'main'
Zh See merge request prism/lable/labelimage!14
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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<Array<[[number, number]]>>
|
||||
|
||||
@@ -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,6 +1432,7 @@ const PaperContainer = (
|
||||
})
|
||||
.filter((path): path is paper.Path => !!path) || []
|
||||
|
||||
if (!(outerPaths.length === 1 && !innerPaths.length)) {
|
||||
addCompoundPath(
|
||||
renderCompoundPath,
|
||||
[...outerPaths, ...innerPaths],
|
||||
@@ -1437,7 +1441,8 @@ const PaperContainer = (
|
||||
strokeWidth: 2,
|
||||
// fillColor: fillColor,
|
||||
fillColor: blankColor,
|
||||
visible: !paperPathStatus[renderImageId + item[0]]?.["HIDE"],
|
||||
visible:
|
||||
!paperPathStatus[renderImageId + item[0]]?.["HIDE"],
|
||||
},
|
||||
{
|
||||
type: "polygon",
|
||||
@@ -1449,6 +1454,7 @@ const PaperContainer = (
|
||||
selected: selectedIds.includes(item[0]),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// renderCompoundPath(usePaperStore.getState().group!, { children });
|
||||
// item[1].forEach((child) => {
|
||||
@@ -2337,15 +2343,7 @@ const PaperContainer = (
|
||||
syncRenderModeVisuals,
|
||||
])
|
||||
|
||||
const stopRenderModePan = useCallback(() => {
|
||||
renderModePanRef.current.active = false
|
||||
}, [])
|
||||
|
||||
const handleRenderModeOverlayMouseDown = useCallback(
|
||||
(event: React.MouseEvent<HTMLDivElement>) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const hidePaperContextMenu = useCallback(() => {
|
||||
useOtherToolsStore.getState().setShowContextMenu({
|
||||
showContextMenu: false,
|
||||
position: { top: 0, left: 0 },
|
||||
@@ -2353,26 +2351,64 @@ const PaperContainer = (
|
||||
operationId: "",
|
||||
id: 0,
|
||||
})
|
||||
}, [])
|
||||
|
||||
if (event.button !== 1) return
|
||||
|
||||
renderModePanRef.current = {
|
||||
const startMiddleMousePan = useCallback(
|
||||
(clientX: number, clientY: number) => {
|
||||
middleMousePanRef.current = {
|
||||
active: true,
|
||||
lastX: event.clientX,
|
||||
lastY: event.clientY,
|
||||
lastX: clientX,
|
||||
lastY: clientY,
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const stopMiddleMousePan = useCallback(() => {
|
||||
middleMousePanRef.current.active = false
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!renderMode) {
|
||||
stopRenderModePan()
|
||||
return
|
||||
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<HTMLDivElement>) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
hidePaperContextMenu()
|
||||
|
||||
if (event.button !== 1) return
|
||||
|
||||
startMiddleMousePan(event.clientX, event.clientY)
|
||||
},
|
||||
[hidePaperContextMenu, startMiddleMousePan]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
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(
|
||||
({
|
||||
|
||||
@@ -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<KeyboardStore>((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 })),
|
||||
}))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
806
components/label/utils/geometry/booleanAdapter.ts
Normal file
806
components/label/utils/geometry/booleanAdapter.ts
Normal file
@@ -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<NormalizedRing>((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<NormalizedRing | null | undefined>) => {
|
||||
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<RingBounds>(
|
||||
(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<SerializableContour | null | undefined>
|
||||
holeContours?: Array<SerializableContour | null | undefined>
|
||||
}): 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<SerializableContour | null | undefined>
|
||||
) => {
|
||||
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<SerializableContour | null | undefined>
|
||||
nextContours?: Array<SerializableContour | null | undefined>
|
||||
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/,/. 链式操作。
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user