369 lines
10 KiB
TypeScript
369 lines
10 KiB
TypeScript
export type Point2D = [number, number]
|
|
|
|
const EPSILON = 1e-7
|
|
const KEY_SCALE = 1e6
|
|
|
|
type Edge = {
|
|
start: Point2D
|
|
end: Point2D
|
|
polygonIndex: number
|
|
splits: number[]
|
|
}
|
|
|
|
type Segment = {
|
|
start: Point2D
|
|
end: Point2D
|
|
used: boolean
|
|
}
|
|
|
|
function pointsEqual(a: Point2D, b: Point2D, tolerance = EPSILON) {
|
|
return Math.hypot(a[0] - b[0], a[1] - b[1]) <= tolerance
|
|
}
|
|
|
|
function pointKey(point: Point2D) {
|
|
return `${Math.round(point[0] * KEY_SCALE)}:${Math.round(point[1] * KEY_SCALE)}`
|
|
}
|
|
|
|
function interpolate(a: Point2D, b: Point2D, t: number): Point2D {
|
|
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]
|
|
}
|
|
|
|
function cross(ax: number, ay: number, bx: number, by: number) {
|
|
return ax * by - ay * bx
|
|
}
|
|
|
|
function polygonArea(points: Point2D[]) {
|
|
let area = 0
|
|
for (let i = 0; i < points.length; i++) {
|
|
const current = points[i]!
|
|
const next = points[(i + 1) % points.length]!
|
|
area += current[0] * next[1] - next[0] * current[1]
|
|
}
|
|
return area / 2
|
|
}
|
|
|
|
function pointOnSegment(point: Point2D, start: Point2D, end: Point2D) {
|
|
const dx = end[0] - start[0]
|
|
const dz = end[1] - start[1]
|
|
const crossValue = cross(point[0] - start[0], point[1] - start[1], dx, dz)
|
|
if (Math.abs(crossValue) > EPSILON) return false
|
|
|
|
const dot =
|
|
(point[0] - start[0]) * (point[0] - end[0]) + (point[1] - start[1]) * (point[1] - end[1])
|
|
return dot <= EPSILON
|
|
}
|
|
|
|
function pointInPolygonOrOnBoundary(point: Point2D, polygon: Point2D[]) {
|
|
if (
|
|
polygon.some((start, index) =>
|
|
pointOnSegment(point, start, polygon[(index + 1) % polygon.length]!),
|
|
)
|
|
) {
|
|
return true
|
|
}
|
|
|
|
return pointInPolygon(point, polygon)
|
|
}
|
|
|
|
function pointInPolygon(point: Point2D, polygon: Point2D[]) {
|
|
let inside = false
|
|
|
|
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
|
const pi = polygon[i]!
|
|
const pj = polygon[j]!
|
|
|
|
if (pointOnSegment(point, pj, pi)) return false
|
|
|
|
const intersects =
|
|
pi[1] > point[1] !== pj[1] > point[1] &&
|
|
point[0] < ((pj[0] - pi[0]) * (point[1] - pi[1])) / (pj[1] - pi[1]) + pi[0]
|
|
|
|
if (intersects) inside = !inside
|
|
}
|
|
|
|
return inside
|
|
}
|
|
|
|
function normalizeRing(ring: Point2D[]) {
|
|
const normalized: Point2D[] = []
|
|
|
|
for (const [x, z] of ring) {
|
|
if (!Number.isFinite(x) || !Number.isFinite(z)) continue
|
|
|
|
const point: Point2D = [x, z]
|
|
const previous = normalized[normalized.length - 1]
|
|
if (!previous || !pointsEqual(previous, point)) {
|
|
normalized.push(point)
|
|
}
|
|
}
|
|
|
|
const first = normalized[0]
|
|
const last = normalized[normalized.length - 1]
|
|
if (first && last && pointsEqual(first, last)) {
|
|
normalized.pop()
|
|
}
|
|
|
|
if (normalized.length < 3 || Math.abs(polygonArea(normalized)) <= EPSILON) return []
|
|
return polygonArea(normalized) < 0 ? [...normalized].reverse() : normalized
|
|
}
|
|
|
|
function addSplit(edge: Edge, t: number) {
|
|
if (t < -EPSILON || t > 1 + EPSILON) return
|
|
const clamped = Math.max(0, Math.min(1, t))
|
|
if (edge.splits.some((split) => Math.abs(split - clamped) <= EPSILON)) return
|
|
edge.splits.push(clamped)
|
|
}
|
|
|
|
function parameterOnEdge(point: Point2D, edge: Edge) {
|
|
const dx = edge.end[0] - edge.start[0]
|
|
const dz = edge.end[1] - edge.start[1]
|
|
const lengthSquared = dx * dx + dz * dz
|
|
if (lengthSquared <= EPSILON) return 0
|
|
return ((point[0] - edge.start[0]) * dx + (point[1] - edge.start[1]) * dz) / lengthSquared
|
|
}
|
|
|
|
function addIntersectionSplits(left: Edge, right: Edge) {
|
|
const rx = left.end[0] - left.start[0]
|
|
const rz = left.end[1] - left.start[1]
|
|
const sx = right.end[0] - right.start[0]
|
|
const sz = right.end[1] - right.start[1]
|
|
const qpx = right.start[0] - left.start[0]
|
|
const qpz = right.start[1] - left.start[1]
|
|
const denominator = cross(rx, rz, sx, sz)
|
|
const numerator = cross(qpx, qpz, rx, rz)
|
|
|
|
if (Math.abs(denominator) <= EPSILON) {
|
|
if (Math.abs(numerator) > EPSILON) return
|
|
|
|
for (const point of [left.start, left.end, right.start, right.end]) {
|
|
if (
|
|
pointOnSegment(point, left.start, left.end) &&
|
|
pointOnSegment(point, right.start, right.end)
|
|
) {
|
|
addSplit(left, parameterOnEdge(point, left))
|
|
addSplit(right, parameterOnEdge(point, right))
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
const t = cross(qpx, qpz, sx, sz) / denominator
|
|
const u = cross(qpx, qpz, rx, rz) / denominator
|
|
if (t < -EPSILON || t > 1 + EPSILON || u < -EPSILON || u > 1 + EPSILON) return
|
|
|
|
addSplit(left, t)
|
|
addSplit(right, u)
|
|
}
|
|
|
|
function buildEdges(polygons: Point2D[][]) {
|
|
const edges: Edge[] = []
|
|
|
|
polygons.forEach((polygon, polygonIndex) => {
|
|
for (let i = 0; i < polygon.length; i++) {
|
|
edges.push({
|
|
start: polygon[i]!,
|
|
end: polygon[(i + 1) % polygon.length]!,
|
|
polygonIndex,
|
|
splits: [0, 1],
|
|
})
|
|
}
|
|
})
|
|
|
|
for (let i = 0; i < edges.length; i++) {
|
|
for (let j = i + 1; j < edges.length; j++) {
|
|
const left = edges[i]!
|
|
const right = edges[j]!
|
|
if (left.polygonIndex === right.polygonIndex) continue
|
|
addIntersectionSplits(left, right)
|
|
}
|
|
}
|
|
|
|
return edges
|
|
}
|
|
|
|
function buildBoundarySegments(edges: Edge[], polygons: Point2D[][]) {
|
|
const segments: Segment[] = []
|
|
|
|
for (const edge of edges) {
|
|
const splits = [...edge.splits].sort((a, b) => a - b)
|
|
|
|
for (let i = 0; i < splits.length - 1; i++) {
|
|
const startT = splits[i]!
|
|
const endT = splits[i + 1]!
|
|
if (endT - startT <= EPSILON) continue
|
|
|
|
const start = interpolate(edge.start, edge.end, startT)
|
|
const end = interpolate(edge.start, edge.end, endT)
|
|
const mid = interpolate(edge.start, edge.end, (startT + endT) / 2)
|
|
const insideAnother = polygons.some(
|
|
(polygon, index) => index !== edge.polygonIndex && pointInPolygon(mid, polygon),
|
|
)
|
|
|
|
if (!insideAnother) {
|
|
segments.push({ start, end, used: false })
|
|
}
|
|
}
|
|
}
|
|
|
|
return removeDuplicateInteriorSegments(segments)
|
|
}
|
|
|
|
function segmentKey(segment: Segment) {
|
|
const start = pointKey(segment.start)
|
|
const end = pointKey(segment.end)
|
|
return start < end ? `${start}|${end}` : `${end}|${start}`
|
|
}
|
|
|
|
function removeDuplicateInteriorSegments(segments: Segment[]) {
|
|
const groups = new Map<string, Segment[]>()
|
|
for (const segment of segments) {
|
|
const key = segmentKey(segment)
|
|
const group = groups.get(key)
|
|
if (group) {
|
|
group.push(segment)
|
|
} else {
|
|
groups.set(key, [segment])
|
|
}
|
|
}
|
|
|
|
const result: Segment[] = []
|
|
for (const group of groups.values()) {
|
|
if (group.length === 1) {
|
|
result.push(group[0]!)
|
|
continue
|
|
}
|
|
|
|
const firstStart = pointKey(group[0]!.start)
|
|
const firstEnd = pointKey(group[0]!.end)
|
|
const hasOppositeDirection = group.some(
|
|
(segment) => pointKey(segment.start) === firstEnd && pointKey(segment.end) === firstStart,
|
|
)
|
|
|
|
if (!hasOppositeDirection) {
|
|
result.push(group[0]!)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
function assembleRings(segments: Segment[]) {
|
|
const byStart = new Map<string, Segment[]>()
|
|
for (const segment of segments) {
|
|
const key = pointKey(segment.start)
|
|
const group = byStart.get(key)
|
|
if (group) {
|
|
group.push(segment)
|
|
} else {
|
|
byStart.set(key, [segment])
|
|
}
|
|
}
|
|
|
|
const rings: Point2D[][] = []
|
|
|
|
for (const firstSegment of segments) {
|
|
if (firstSegment.used) continue
|
|
|
|
firstSegment.used = true
|
|
const ring: Point2D[] = [firstSegment.start, firstSegment.end]
|
|
const startKey = pointKey(firstSegment.start)
|
|
let currentKey = pointKey(firstSegment.end)
|
|
|
|
while (currentKey !== startKey) {
|
|
const next = byStart.get(currentKey)?.find((segment) => !segment.used)
|
|
if (!next) break
|
|
|
|
next.used = true
|
|
ring.push(next.end)
|
|
currentKey = pointKey(next.end)
|
|
}
|
|
|
|
if (currentKey !== startKey) continue
|
|
|
|
const last = ring[ring.length - 1]
|
|
if (last && pointsEqual(ring[0]!, last)) {
|
|
ring.pop()
|
|
}
|
|
|
|
const normalized = normalizeRing(ring)
|
|
if (normalized.length >= 3) {
|
|
rings.push(normalized)
|
|
}
|
|
}
|
|
|
|
return rings
|
|
}
|
|
|
|
export function unionPolygons(polygons: Point2D[][]): Point2D[][] {
|
|
const validPolygons = polygons.map(normalizeRing).filter((polygon) => polygon.length >= 3)
|
|
if (validPolygons.length <= 1) return validPolygons
|
|
|
|
const edges = buildEdges(validPolygons)
|
|
const segments = buildBoundarySegments(edges, validPolygons)
|
|
const rings = assembleRings(segments)
|
|
|
|
return rings.length > 0 ? rings : validPolygons
|
|
}
|
|
|
|
function buildDifferenceBoundarySegments(edges: Edge[], polygons: Point2D[][]) {
|
|
const subject = polygons[0]
|
|
const cutters = polygons.slice(1)
|
|
if (!subject) return []
|
|
|
|
const segments: Segment[] = []
|
|
|
|
for (const edge of edges) {
|
|
const splits = [...edge.splits].sort((a, b) => a - b)
|
|
|
|
for (let i = 0; i < splits.length - 1; i++) {
|
|
const startT = splits[i]!
|
|
const endT = splits[i + 1]!
|
|
if (endT - startT <= EPSILON) continue
|
|
|
|
const start = interpolate(edge.start, edge.end, startT)
|
|
const end = interpolate(edge.start, edge.end, endT)
|
|
const mid = interpolate(edge.start, edge.end, (startT + endT) / 2)
|
|
|
|
if (edge.polygonIndex === 0) {
|
|
const insideCutter = cutters.some((cutter) => pointInPolygonOrOnBoundary(mid, cutter))
|
|
if (!insideCutter) {
|
|
segments.push({ start, end, used: false })
|
|
}
|
|
continue
|
|
}
|
|
|
|
const insideSubject = pointInPolygon(mid, subject)
|
|
const insideAnotherCutter = cutters.some(
|
|
(cutter, cutterIndex) =>
|
|
cutterIndex !== edge.polygonIndex - 1 && pointInPolygonOrOnBoundary(mid, cutter),
|
|
)
|
|
|
|
if (insideSubject && !insideAnotherCutter) {
|
|
segments.push({ start: end, end: start, used: false })
|
|
}
|
|
}
|
|
}
|
|
|
|
return removeDuplicateInteriorSegments(segments)
|
|
}
|
|
|
|
export function subtractPolygonsFromPolygon(subject: Point2D[], cutters: Point2D[][]): Point2D[][] {
|
|
const validSubject = normalizeRing(subject)
|
|
if (validSubject.length < 3) return []
|
|
|
|
const validCutters = cutters.map(normalizeRing).filter((polygon) => polygon.length >= 3)
|
|
if (validCutters.length === 0) return [validSubject]
|
|
|
|
const polygons = [validSubject, ...validCutters]
|
|
const edges = buildEdges(polygons)
|
|
const segments = buildDifferenceBoundarySegments(edges, polygons)
|
|
const rings = assembleRings(segments)
|
|
|
|
if (rings.length > 0) return rings
|
|
|
|
const fullyCovered = validSubject.every((point) =>
|
|
validCutters.some((cutter) => pointInPolygonOrOnBoundary(point, cutter)),
|
|
)
|
|
return fullyCovered ? [] : [validSubject]
|
|
}
|