import type { MultiPolygon, Pair, Polygon, Ring } from "polygon-clipping" import * as polygonClipping from "polygon-clipping" export type GeometryPoint = Pair export type NormalizedRing = Ring export type NormalizedPolygon = Polygon export type NormalizedMultiPolygon = MultiPolygon export type BooleanAdapterMode = "unite" | "subtract" | "intersect" | "xor" export interface BooleanAdapterInput { subject: NormalizedMultiPolygon operands?: NormalizedMultiPolygon[] mode: BooleanAdapterMode rasterClip?: NormalizedMultiPolygon | null } export interface BooleanAdapterMeta { polygonCount: number outerRingCount: number holeCount: number isEmpty: boolean } export interface BooleanAdapterResult { geometry: NormalizedMultiPolygon meta: BooleanAdapterMeta } export type SerializableContour = Array< | GeometryPoint | { x: number; y: number } | { point?: { x: number; y: number } } > export interface PaperLikePathShape { segments?: SerializableContour data?: unknown } export interface PaperLikeCompoundPathShape { children?: PaperLikePathShape[] data?: unknown } export type LabelTupleLike = [ number, SerializableContour[], unknown, SerializableContour[], ] export const EMPTY_MULTI_POLYGON: NormalizedMultiPolygon = [] const POINT_EPSILON = 0.000001 const MIN_RING_AREA = 0.0001 const clonePoint = ([x, y]: GeometryPoint): GeometryPoint => [x, y] const isSameNumber = (a: number, b: number) => Math.abs(a - b) <= POINT_EPSILON const isSamePoint = (a: GeometryPoint, b: GeometryPoint) => isSameNumber(a[0], b[0]) && isSameNumber(a[1], b[1]) const getGeometryPoint = (value: any): GeometryPoint | null => { if (!value) return null if ( Array.isArray(value) && value.length >= 2 && typeof value[0] === "number" && typeof value[1] === "number" ) { return [value[0], value[1]] } if (typeof value.x === "number" && typeof value.y === "number") { return [value.x, value.y] } if ( value.point && typeof value.point.x === "number" && typeof value.point.y === "number" ) { return [value.point.x, value.point.y] } return null } const normalizeOpenRing = (ring: NormalizedRing | null | undefined) => { if (!ring?.length) return null const deduped = ring.reduce((result, point) => { const nextPoint = clonePoint(point) const lastPoint = result[result.length - 1] if (lastPoint && isSamePoint(lastPoint, nextPoint)) return result result.push(nextPoint) return result }, []) while ( deduped.length > 1 && isSamePoint(deduped[0], deduped[deduped.length - 1]) ) { deduped.pop() } return deduped.length >= 3 ? deduped : null } const closeRing = (ring: NormalizedRing) => [...ring, clonePoint(ring[0])] const getRingSignedArea = (ring: NormalizedRing) => { let area = 0 for (let index = 0; index < ring.length - 1; index++) { const [x1, y1] = ring[index] const [x2, y2] = ring[index + 1] area += x1 * y2 - x2 * y1 } return area / 2 } const rotateRingToStableStart = (ring: NormalizedRing) => { if (!ring.length) return ring let startIndex = 0 for (let index = 1; index < ring.length; index += 1) { const currentPoint = ring[index] const startPoint = ring[startIndex] if ( currentPoint[0] < startPoint[0] - POINT_EPSILON || (isSameNumber(currentPoint[0], startPoint[0]) && currentPoint[1] < startPoint[1] - POINT_EPSILON) ) { startIndex = index } } return [...ring.slice(startIndex), ...ring.slice(0, startIndex)] } const normalizeRing = ( ring: NormalizedRing | null | undefined, options: { clockwise?: boolean } = {} ) => { const openRing = normalizeOpenRing(ring) if (!openRing) return null let nextRing = openRing let signedArea = getRingSignedArea(closeRing(nextRing)) if (Math.abs(signedArea) <= MIN_RING_AREA) return null if (typeof options.clockwise === "boolean") { const shouldReverse = options.clockwise ? signedArea < 0 : signedArea > 0 if (shouldReverse) { nextRing = [...nextRing].reverse() signedArea = getRingSignedArea(closeRing(nextRing)) } } const stableRing = rotateRingToStableStart(nextRing) const closedRing = closeRing(stableRing) // 闭环后至少需要 4 个点,且面积不能退化到近似 0。 return closedRing.length >= 4 && Math.abs(getRingSignedArea(closedRing)) > MIN_RING_AREA ? closedRing : null } export const contourToRing = ( contour: SerializableContour | null | undefined ): NormalizedRing | null => { if (!contour?.length) return null return normalizeRing( contour .map((point) => getGeometryPoint(point)) .filter((point): point is GeometryPoint => !!point) ) } const normalizePolygon = (polygon: NormalizedPolygon | null | undefined) => { if (!polygon?.length) return null const [outerRing, ...holeRings] = polygon const nextOuterRing = normalizeRing(outerRing, { clockwise: true }) if (!nextOuterRing) return null const rings = [ nextOuterRing, ...holeRings .map((ring) => normalizeRing(ring, { clockwise: false })) .filter((ring): ring is NormalizedRing => !!ring), ] if (!rings.length) return null return rings } export const normalizeMultiPolygon = ( geometry: NormalizedMultiPolygon | null | undefined ): NormalizedMultiPolygon => { if (!geometry?.length) return [] return geometry .map((polygon) => normalizePolygon(polygon)) .filter((polygon): polygon is NormalizedPolygon => !!polygon) } const getRingSamplePoint = (ring: NormalizedRing): GeometryPoint => { const withoutClosure = ring.slice(0, -1) if (!withoutClosure.length) { return clonePoint(ring[0]) } const bounds = getRingBounds(ring) const candidates: GeometryPoint[] = [ [(bounds.minX + bounds.maxX) / 2, (bounds.minY + bounds.maxY) / 2], ] const [sumX, sumY] = withoutClosure.reduce( (result, [x, y]) => [result[0] + x, result[1] + y], [0, 0] ) candidates.push([sumX / withoutClosure.length, sumY / withoutClosure.length]) let leftMostIndex = 0 for (let index = 1; index < withoutClosure.length; index += 1) { const currentPoint = withoutClosure[index] const leftMostPoint = withoutClosure[leftMostIndex] if ( currentPoint[0] < leftMostPoint[0] - POINT_EPSILON || (isSameNumber(currentPoint[0], leftMostPoint[0]) && currentPoint[1] < leftMostPoint[1] - POINT_EPSILON) ) { leftMostIndex = index } } const previousPoint = withoutClosure[ (leftMostIndex - 1 + withoutClosure.length) % withoutClosure.length ] const currentPoint = withoutClosure[leftMostIndex] const nextPoint = withoutClosure[(leftMostIndex + 1) % withoutClosure.length] candidates.push([ (previousPoint[0] + currentPoint[0] + nextPoint[0]) / 3, (previousPoint[1] + currentPoint[1] + nextPoint[1]) / 3, ]) for (const candidate of candidates) { if (isPointInsideRing(candidate, ring)) { return candidate } } return clonePoint(currentPoint) } const isPointInsideRing = (point: GeometryPoint, ring: NormalizedRing) => { let inside = false for ( let current = 0, previous = ring.length - 1; current < ring.length; previous = current++ ) { const [currentX, currentY] = ring[current] const [previousX, previousY] = ring[previous] const intersects = currentY > point[1] !== previousY > point[1] && point[0] < ((previousX - currentX) * (point[1] - currentY)) / (previousY - currentY || Number.EPSILON) + currentX if (intersects) { inside = !inside } } return inside } type RingEntry = { ring: NormalizedRing sourceIndex: number signedArea: number absArea: number samplePoint: GeometryPoint } type RingBounds = { minX: number minY: number maxX: number maxY: number } const createRingEntries = (rings: Array) => { return rings .map((ring, sourceIndex) => { if (!ring) return null const signedArea = getRingSignedArea(ring) return { ring, sourceIndex, signedArea, absArea: Math.abs(signedArea), samplePoint: getRingSamplePoint(ring), } }) .filter((entry): entry is RingEntry => !!entry) } const getRingBounds = (ring: NormalizedRing): RingBounds => { return ring.reduce( (result, [x, y]) => ({ minX: Math.min(result.minX, x), minY: Math.min(result.minY, y), maxX: Math.max(result.maxX, x), maxY: Math.max(result.maxY, y), }), { minX: Number.POSITIVE_INFINITY, minY: Number.POSITIVE_INFINITY, maxX: Number.NEGATIVE_INFINITY, maxY: Number.NEGATIVE_INFINITY, } ) } const compareRingEntriesForSerialization = ( left: RingEntry, right: RingEntry ) => { if (right.absArea !== left.absArea) { return right.absArea - left.absArea } const leftBounds = getRingBounds(left.ring) const rightBounds = getRingBounds(right.ring) if (leftBounds.minX !== rightBounds.minX) { return leftBounds.minX - rightBounds.minX } if (leftBounds.minY !== rightBounds.minY) { return leftBounds.minY - rightBounds.minY } if (leftBounds.maxX !== rightBounds.maxX) { return leftBounds.maxX - rightBounds.maxX } if (leftBounds.maxY !== rightBounds.maxY) { return leftBounds.maxY - rightBounds.maxY } return left.sourceIndex - right.sourceIndex } const getRingBoundsArea = (bounds: RingBounds) => { const width = Math.max(0, bounds.maxX - bounds.minX) const height = Math.max(0, bounds.maxY - bounds.minY) return width * height } const getRingBoundsIntersectionArea = ( leftBounds: RingBounds, rightBounds: RingBounds ) => { const overlapWidth = Math.min(leftBounds.maxX, rightBounds.maxX) - Math.max(leftBounds.minX, rightBounds.minX) const overlapHeight = Math.min(leftBounds.maxY, rightBounds.maxY) - Math.max(leftBounds.minY, rightBounds.minY) if (overlapWidth <= 0 || overlapHeight <= 0) { return 0 } return overlapWidth * overlapHeight } const getBoundsCenterPoint = (bounds: RingBounds): GeometryPoint => [ (bounds.minX + bounds.maxX) / 2, (bounds.minY + bounds.maxY) / 2, ] const getPointDistance = (left: GeometryPoint, right: GeometryPoint) => Math.hypot(left[0] - right[0], left[1] - right[1]) const isSameRingOrder = ( left: GeometryPoint[], right: GeometryPoint[] ): boolean => { if (left.length !== right.length || !left.length) return false for (let offset = 0; offset < right.length; offset += 1) { let matched = true for (let index = 0; index < left.length; index += 1) { if (!isSamePoint(left[index], right[(index + offset) % right.length])) { matched = false break } } if (matched) return true } return false } const isSameRing = (left: NormalizedRing, right: NormalizedRing) => { const leftOpenRing = normalizeOpenRing(left) const rightOpenRing = normalizeOpenRing(right) if (!leftOpenRing || !rightOpenRing) return false return ( isSameRingOrder(leftOpenRing, rightOpenRing) || isSameRingOrder(leftOpenRing, [...rightOpenRing].reverse()) ) } const getRingMatchScore = (reference: RingEntry, candidate: RingEntry) => { if (isSameRing(reference.ring, candidate.ring)) { return Number.MAX_SAFE_INTEGER } const referenceBounds = getRingBounds(reference.ring) const candidateBounds = getRingBounds(candidate.ring) const overlapArea = getRingBoundsIntersectionArea( referenceBounds, candidateBounds ) const smallerBoundsArea = Math.max( Math.min( getRingBoundsArea(referenceBounds), getRingBoundsArea(candidateBounds) ), POINT_EPSILON ) const overlapRatio = overlapArea / smallerBoundsArea const areaRatio = Math.min(reference.absArea, candidate.absArea) / Math.max(reference.absArea, candidate.absArea, POINT_EPSILON) const centerDistance = getPointDistance( getBoundsCenterPoint(referenceBounds), getBoundsCenterPoint(candidateBounds) ) const referenceInsideCandidate = isPointInsideRing( reference.samplePoint, candidate.ring ) const candidateInsideReference = isPointInsideRing( candidate.samplePoint, reference.ring ) return ( (referenceInsideCandidate ? 1000000 : 0) + (candidateInsideReference ? 500000 : 0) + overlapRatio * 1000 + areaRatio * 100 - centerDistance ) } const attachHolesToOuters = ( outerEntries: RingEntry[], holeEntries: RingEntry[] ): NormalizedMultiPolygon => { const polygons = outerEntries.map((outerEntry) => ({ outer: outerEntry, holes: [] as RingEntry[], })) holeEntries.forEach((holeEntry) => { const owner = polygons .filter((polygon) => isPointInsideRing(holeEntry.samplePoint, polygon.outer.ring) ) .sort((left, right) => left.outer.absArea - right.outer.absArea)[0] if (owner) { owner.holes.push(holeEntry) } }) return polygons.map(({ outer, holes }) => [ outer.ring, ...holes .sort((left, right) => left.sourceIndex - right.sourceIndex) .map((hole) => hole.ring), ]) } export const contoursToMultiPolygon = ({ outerContours = [], holeContours = [], }: { outerContours?: Array holeContours?: Array }): NormalizedMultiPolygon => { const outerEntries = createRingEntries( outerContours.map((contour) => contourToRing(contour)) ).sort((left, right) => left.sourceIndex - right.sourceIndex) const holeEntries = createRingEntries( holeContours.map((contour) => contourToRing(contour)) ) return normalizeMultiPolygon(attachHolesToOuters(outerEntries, holeEntries)) } export const labelTupleToMultiPolygon = ( tuple: | LabelTupleLike | { 1: SerializableContour[] 3: SerializableContour[] } | null | undefined ) => { if (!tuple) return EMPTY_MULTI_POLYGON return contoursToMultiPolygon({ outerContours: tuple[1] as SerializableContour[], holeContours: tuple[3] as SerializableContour[], }) } export const paperShapeToMultiPolygon = ( shape: PaperLikePathShape | PaperLikeCompoundPathShape | null | undefined ) => { if (!shape) return EMPTY_MULTI_POLYGON const rings = Array.isArray((shape as PaperLikeCompoundPathShape).children) ? ((shape as PaperLikeCompoundPathShape).children || []) .map((child) => contourToRing(child.segments)) .filter((ring): ring is NormalizedRing => !!ring) : [contourToRing((shape as PaperLikePathShape).segments)].filter( (ring): ring is NormalizedRing => !!ring ) if (!rings.length) return EMPTY_MULTI_POLYGON const ringEntries = createRingEntries(rings) const classified = ringEntries.map((entry, entryIndex) => { const depth = ringEntries.reduce((count, otherEntry, otherIndex) => { if (entryIndex === otherIndex) return count return isPointInsideRing(entry.samplePoint, otherEntry.ring) ? count + 1 : count }, 0) return { ...entry, isHole: depth % 2 === 1, } }) if (classified.length && !classified.some((entry) => !entry.isHole)) { const largestOuter = classified.reduce((result, entry) => { if (!result) return entry return entry.absArea > result.absArea ? entry : result }, classified[0]) if (largestOuter) { largestOuter.isHole = false } } return normalizeMultiPolygon( attachHolesToOuters( classified.filter((entry) => !entry.isHole), classified.filter((entry) => entry.isHole) ) ) } export const multiPolygonToStoredContours = ( geometry: NormalizedMultiPolygon | null | undefined ) => { const normalizedGeometry = normalizeMultiPolygon(geometry) const sortedGeometry = normalizedGeometry .map((polygon) => { const [outerRing, ...holeRings] = polygon if (!outerRing) return null const outerEntry = createRingEntries([outerRing])[0] const holeEntries = createRingEntries(holeRings).sort( compareRingEntriesForSerialization ) return { outer: outerEntry, holes: holeEntries, } }) .filter( ( polygon ): polygon is { outer: RingEntry holes: RingEntry[] } => !!polygon ) .sort((left, right) => compareRingEntriesForSerialization(left.outer, right.outer) ) const points = sortedGeometry.map(({ outer }) => outer.ring.map((point) => clonePoint(point)) ) const hollowPoints = sortedGeometry.flatMap(({ holes }) => holes.map((hole) => hole.ring.map((point) => clonePoint(point))) ) return { points, hollowPoints, } } export const multiPolygonToLabelTuple = ( objectId: number, detail: unknown, geometry: NormalizedMultiPolygon | null | undefined ): LabelTupleLike => { const { points, hollowPoints } = multiPolygonToStoredContours(geometry) return [objectId, points, detail, hollowPoints] } const getLastValidContourIndex = ( contours: Array ) => { for (let index = contours.length - 1; index >= 0; index -= 1) { if (contourToRing(contours[index])) { return index } } return -1 } export const findBestMatchingOuterContourIndex = ({ previousContours = [], nextContours = [], preferredIndex = null, }: { previousContours?: Array nextContours?: Array preferredIndex?: number | null }) => { const nextEntries = createRingEntries( nextContours.map((contour) => contourToRing(contour)) ) if (!nextEntries.length) return -1 const fallbackIndex = (() => { if ( preferredIndex !== null && preferredIndex >= 0 && preferredIndex < nextContours.length && contourToRing(nextContours[preferredIndex]) ) { return preferredIndex } const lastValidContourIndex = getLastValidContourIndex(nextContours) return lastValidContourIndex >= 0 ? lastValidContourIndex : nextEntries[0].sourceIndex })() const previousRings = previousContours.map((contour) => contourToRing(contour) ) const preferredReferenceRing = preferredIndex !== null && preferredIndex >= 0 && preferredIndex < previousRings.length ? previousRings[preferredIndex] : null const fallbackReferenceRing = preferredReferenceRing || [...previousRings] .reverse() .find((ring): ring is NormalizedRing => !!ring) || null if (!fallbackReferenceRing) { return fallbackIndex } const referenceEntry = createRingEntries([fallbackReferenceRing])[0] const bestMatch = nextEntries.reduce<{ entry: RingEntry score: number } | null>((result, entry) => { const score = getRingMatchScore(referenceEntry, entry) if (!result) { return { entry, score } } if (score > result.score + POINT_EPSILON) { return { entry, score } } if ( isSameNumber(score, result.score) && compareRingEntriesForSerialization(entry, result.entry) < 0 ) { return { entry, score } } return result }, null) return bestMatch?.entry.sourceIndex ?? fallbackIndex } const buildMeta = (geometry: NormalizedMultiPolygon): BooleanAdapterMeta => { const outerRingCount = geometry.length const holeCount = geometry.reduce((count, polygon) => { return count + Math.max(0, polygon.length - 1) }, 0) return { polygonCount: geometry.length, outerRingCount, holeCount, isEmpty: geometry.length === 0, } } const unionAll = (geometries: NormalizedMultiPolygon[]) => { const [first, ...rest] = geometries if (!first?.length) return [] return polygonClipping.union(first, ...rest) } const applyMode = ( mode: BooleanAdapterMode, subject: NormalizedMultiPolygon, operands: NormalizedMultiPolygon[] ) => { if (!subject.length) { return mode === "unite" ? unionAll(operands) : [] } if (!operands.length) { return subject } switch (mode) { case "unite": return polygonClipping.union(subject, ...operands) case "subtract": return polygonClipping.difference(subject, ...operands) case "intersect": return polygonClipping.intersection(subject, ...operands) case "xor": return polygonClipping.xor(subject, ...operands) default: return subject } } export const applyBooleanAdapter = ({ subject, operands = [], mode, rasterClip = null, }: BooleanAdapterInput): BooleanAdapterResult => { const normalizedSubject = normalizeMultiPolygon(subject) const normalizedOperands = operands .map((geometry) => normalizeMultiPolygon(geometry)) .filter((geometry) => geometry.length) let nextGeometry = normalizeMultiPolygon( applyMode(mode, normalizedSubject, normalizedOperands) ) if (rasterClip?.length && nextGeometry.length) { nextGeometry = normalizeMultiPolygon( polygonClipping.intersection( nextGeometry, normalizeMultiPolygon(rasterClip) ) ) } return { geometry: nextGeometry, meta: buildMeta(nextGeometry), } } // TODO phase 2: // 1. 为 concave ring 补更强的 interior point / overlap 计算,减少极端轮廓下的误判。 // 2. 为几何层追加最小可重复验证入口,覆盖多次 a/d/,/. 链式操作。