fix(operation): polygon-clippy

This commit is contained in:
zhangheng
2026-04-17 17:37:27 +08:00
parent 6c981d465b
commit 5168f539e0
5 changed files with 1579 additions and 680 deletions

View File

@@ -1429,26 +1429,29 @@ const PaperContainer = (
})
.filter((path): path is paper.Path => !!path) || []
addCompoundPath(
renderCompoundPath,
[...outerPaths, ...innerPaths],
{
strokeColor: strokeColor,
strokeWidth: 2,
// fillColor: fillColor,
fillColor: blankColor,
visible: !paperPathStatus[renderImageId + item[0]]?.["HIDE"],
},
{
type: "polygon",
id: item[0],
imageId: renderImageId,
operationId: operationId,
fillColor: fillColor,
blankColor: blankColor,
selected: selectedIds.includes(item[0]),
}
)
if (!(outerPaths.length === 1 && !innerPaths.length)) {
addCompoundPath(
renderCompoundPath,
[...outerPaths, ...innerPaths],
{
strokeColor: strokeColor,
strokeWidth: 2,
// fillColor: fillColor,
fillColor: blankColor,
visible:
!paperPathStatus[renderImageId + item[0]]?.["HIDE"],
},
{
type: "polygon",
id: item[0],
imageId: renderImageId,
operationId: operationId,
fillColor: fillColor,
blankColor: blankColor,
selected: selectedIds.includes(item[0]),
}
)
}
// renderCompoundPath(usePaperStore.getState().group!, { children });
// item[1].forEach((child) => {

File diff suppressed because it is too large Load Diff

View 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/,/. 链式操作。

View File

@@ -121,7 +121,7 @@ const applySelectedItemVisual = (item: paper.Item) => {
item.bringToFront()
}
const syncSelectedObjectsVisualState = (imageId: string) => {
export const syncSelectedObjectsVisualState = (imageId: string) => {
const group = usePaperStore.getState().group
if (!group) return

View File

@@ -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",