fix(operation): polygon-clippy
This commit is contained in:
@@ -1429,26 +1429,29 @@ const PaperContainer = (
|
|||||||
})
|
})
|
||||||
.filter((path): path is paper.Path => !!path) || []
|
.filter((path): path is paper.Path => !!path) || []
|
||||||
|
|
||||||
addCompoundPath(
|
if (!(outerPaths.length === 1 && !innerPaths.length)) {
|
||||||
renderCompoundPath,
|
addCompoundPath(
|
||||||
[...outerPaths, ...innerPaths],
|
renderCompoundPath,
|
||||||
{
|
[...outerPaths, ...innerPaths],
|
||||||
strokeColor: strokeColor,
|
{
|
||||||
strokeWidth: 2,
|
strokeColor: strokeColor,
|
||||||
// fillColor: fillColor,
|
strokeWidth: 2,
|
||||||
fillColor: blankColor,
|
// fillColor: fillColor,
|
||||||
visible: !paperPathStatus[renderImageId + item[0]]?.["HIDE"],
|
fillColor: blankColor,
|
||||||
},
|
visible:
|
||||||
{
|
!paperPathStatus[renderImageId + item[0]]?.["HIDE"],
|
||||||
type: "polygon",
|
},
|
||||||
id: item[0],
|
{
|
||||||
imageId: renderImageId,
|
type: "polygon",
|
||||||
operationId: operationId,
|
id: item[0],
|
||||||
fillColor: fillColor,
|
imageId: renderImageId,
|
||||||
blankColor: blankColor,
|
operationId: operationId,
|
||||||
selected: selectedIds.includes(item[0]),
|
fillColor: fillColor,
|
||||||
}
|
blankColor: blankColor,
|
||||||
)
|
selected: selectedIds.includes(item[0]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// renderCompoundPath(usePaperStore.getState().group!, { children });
|
// renderCompoundPath(usePaperStore.getState().group!, { children });
|
||||||
// item[1].forEach((child) => {
|
// item[1].forEach((child) => {
|
||||||
|
|||||||
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/,/. 链式操作。
|
||||||
@@ -121,7 +121,7 @@ const applySelectedItemVisual = (item: paper.Item) => {
|
|||||||
item.bringToFront()
|
item.bringToFront()
|
||||||
}
|
}
|
||||||
|
|
||||||
const syncSelectedObjectsVisualState = (imageId: string) => {
|
export const syncSelectedObjectsVisualState = (imageId: string) => {
|
||||||
const group = usePaperStore.getState().group
|
const group = usePaperStore.getState().group
|
||||||
if (!group) return
|
if (!group) return
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
"msgpack-lite": "^0.1.26",
|
"msgpack-lite": "^0.1.26",
|
||||||
"next": "15.5.7",
|
"next": "15.5.7",
|
||||||
"paper": "^0.12.18",
|
"paper": "^0.12.18",
|
||||||
|
"polygon-clipping": "^0.15.7",
|
||||||
"react": "19.2.1",
|
"react": "19.2.1",
|
||||||
"react-dom": "19.2.1",
|
"react-dom": "19.2.1",
|
||||||
"react-window": "^2.2.3",
|
"react-window": "^2.2.3",
|
||||||
|
|||||||
Reference in New Issue
Block a user