/** * Pure geometry functions for dividing a polygon into N equal-area pieces. * * No OpenLayers dependency — operates on raw coordinate arrays. * * The algorithm finds the polygon's longest edge, then places N-1 cutting * lines perpendicular to that edge. Each cutting-line position is found * via binary search so that the piece it cuts off has exactly 1/N of the * remaining area. The actual cut is delegated to `splitPolygonByLine()`. */ import { splitPolygonByLine } from './polygonSplit.js'; // ── Utility helpers (self-contained) ───────────────────────────────────────── function dist2(a, b) { return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2; } /** * Signed area of a ring (shoelace formula). */ function signedArea(ring) { let area = 0; for (let i = 0, n = ring.length; i < n - 1; i++) { area += ring[i][0] * ring[i + 1][1] - ring[i + 1][0] * ring[i][1]; } return area / 2; } /** * Absolute polygon area, accounting for holes. */ function polygonArea(coords) { let area = Math.abs(signedArea(coords[0])); for (let i = 1; i < coords.length; i++) { area -= Math.abs(signedArea(coords[i])); } return area; } /** * Find the longest edge of a ring and return direction vectors. * * @param {number[][]} ring Closed ring * @returns {{ p0: number[], p1: number[], along: number[], perp: number[] }} * `along` = unit vector along the longest edge, * `perp` = unit vector perpendicular to `along` (rotated 90° CCW) */ function longestEdge(ring) { const n = ring.length - 1; // unique vertices let bestLen = -1; let bestI = 0; for (let i = 0; i < n; i++) { const d = dist2(ring[i], ring[i + 1]); if (d > bestLen) { bestLen = d; bestI = i; } } const p0 = ring[bestI]; const p1 = ring[bestI + 1]; const len = Math.sqrt(bestLen); const along = [(p1[0] - p0[0]) / len, (p1[1] - p0[1]) / len]; // Perpendicular: rotate 90° CCW const perp = [-along[1], along[0]]; return { p0, p1, along, perp }; } /** * Build a cutting line perpendicular to `along` at parameter `t`. * * The line passes through `origin + t * along` and extends `extent` units * in both `perp` directions — long enough to fully cross the polygon. */ function makeCuttingLine(origin, along, perp, t, extent) { const cx = origin[0] + t * along[0]; const cy = origin[1] + t * along[1]; return [ [cx - extent * perp[0], cy - extent * perp[1]], [cx + extent * perp[0], cy + extent * perp[1]], ]; } /** * Project the centroid of a polygon's exterior ring onto the `along` axis. * Returns the scalar parameter `t` relative to `origin`. */ function centroidT(coords, origin, along) { const ring = coords[0]; const n = ring.length - 1; let sx = 0, sy = 0; for (let i = 0; i < n; i++) { sx += ring[i][0]; sy += ring[i][1]; } const cx = sx / n - origin[0]; const cy = sy / n - origin[1]; return cx * along[0] + cy * along[1]; } // ── Main export ────────────────────────────────────────────────────────────── /** * Divide a polygon into N equal-area pieces by parallel cuts perpendicular * to a user-selected edge. * * @param {number[][][]} polygonCoords Polygon coordinates [ring, ...holes] * @param {number} n Number of pieces (must be >= 1) * @param {number[][]} edgeCoords The selected edge `[p0, p1]` — cuts will be * perpendicular to this edge direction. * @returns {{ pieces: number[][][][], error?: undefined } | { pieces: null, error: string }} */ export function dividePolygon(polygonCoords, n, edgeCoords) { if (!Number.isInteger(n) || n < 1) { return { pieces: null, error: 'Number of divisions must be a positive integer.' }; } if (n === 1) { return { pieces: [polygonCoords] }; } const ring = polygonCoords[0]; const totalArea = polygonArea(polygonCoords); if (totalArea < 1e-6) { return { pieces: null, error: 'Polygon has no measurable area.' }; } // 1. Determine cutting direction from the selected edge let p0, along, perp; if (edgeCoords && edgeCoords.length === 2) { p0 = edgeCoords[0]; const dx = edgeCoords[1][0] - edgeCoords[0][0]; const dy = edgeCoords[1][1] - edgeCoords[0][1]; const len = Math.sqrt(dx * dx + dy * dy); if (len < 1e-10) { return { pieces: null, error: 'Selected edge has zero length.' }; } along = [dx / len, dy / len]; perp = [-along[1], along[0]]; } else { // Fallback: use longest edge const edge = longestEdge(ring); p0 = edge.p0; along = edge.along; perp = edge.perp; } const origin = p0; // 2. Project all vertices onto the `along` axis to find extent const nVerts = ring.length - 1; let tMin = Infinity, tMax = -Infinity; for (let i = 0; i < nVerts; i++) { const dx = ring[i][0] - origin[0]; const dy = ring[i][1] - origin[1]; const t = dx * along[0] + dy * along[1]; if (t < tMin) tMin = t; if (t > tMax) tMax = t; } // Cutting line extent: enough to cross the polygon in the `perp` direction let perpMin = Infinity, perpMax = -Infinity; for (let i = 0; i < nVerts; i++) { const dx = ring[i][0] - origin[0]; const dy = ring[i][1] - origin[1]; const p = dx * perp[0] + dy * perp[1]; if (p < perpMin) perpMin = p; if (p > perpMax) perpMax = p; } const extent = (perpMax - perpMin) * 1.5; // generous overshoot // 3. Iteratively cut pieces const pieces = []; let remaining = polygonCoords; let remainingCount = n; for (let i = 0; i < n - 1; i++) { const remainingArea = polygonArea(remaining); const targetArea = remainingArea / remainingCount; // Re-project the remaining polygon to get its current t-range const remRing = remaining[0]; const remN = remRing.length - 1; let rMin = Infinity, rMax = -Infinity; for (let j = 0; j < remN; j++) { const dx = remRing[j][0] - origin[0]; const dy = remRing[j][1] - origin[1]; const t = dx * along[0] + dy * along[1]; if (t < rMin) rMin = t; if (t > rMax) rMax = t; } // Binary search for the cutting position let lo = rMin; let hi = rMax; let bestT = (lo + hi) / 2; let bestPiece = null; let bestRemaining = null; let bestError = Infinity; for (let iter = 0; iter < 40; iter++) { const mid = (lo + hi) / 2; const line = makeCuttingLine(origin, along, perp, mid, extent); const result = splitPolygonByLine(remaining, line); if (!result) { // Cutting line didn't produce a valid split — nudge and retry // Try slightly shifted positions const nudge = (hi - lo) * 0.01; const lineA = makeCuttingLine(origin, along, perp, mid + nudge, extent); const resultA = splitPolygonByLine(remaining, lineA); if (resultA) { const [halfA, halfB] = resultA; const tA = centroidT(halfA, origin, along); const tB = centroidT(halfB, origin, along); const nearPiece = tA < tB ? halfA : halfB; const farPiece = tA < tB ? halfB : halfA; const nearArea = polygonArea(nearPiece); const err = Math.abs(nearArea - targetArea); if (err < bestError) { bestError = err; bestT = mid + nudge; bestPiece = nearPiece; bestRemaining = farPiece; } } // Try the other direction const lineB = makeCuttingLine(origin, along, perp, mid - nudge, extent); const resultB = splitPolygonByLine(remaining, lineB); if (resultB) { const [halfA, halfB] = resultB; const tA = centroidT(halfA, origin, along); const tB = centroidT(halfB, origin, along); const nearPiece = tA < tB ? halfA : halfB; const farPiece = tA < tB ? halfB : halfA; const nearArea = polygonArea(nearPiece); const err = Math.abs(nearArea - targetArea); if (err < bestError) { bestError = err; bestT = mid - nudge; bestPiece = nearPiece; bestRemaining = farPiece; } } // Bisect anyway to keep converging lo = mid; continue; } const [halfA, halfB] = result; const tA = centroidT(halfA, origin, along); const tB = centroidT(halfB, origin, along); const nearPiece = tA < tB ? halfA : halfB; const farPiece = tA < tB ? halfB : halfA; const nearArea = polygonArea(nearPiece); const err = Math.abs(nearArea - targetArea); if (err < bestError) { bestError = err; bestT = mid; bestPiece = nearPiece; bestRemaining = farPiece; } // Converged? if (err / remainingArea < 0.001) break; // Adjust search range if (nearArea < targetArea) { lo = mid; // need to cut farther out } else { hi = mid; // need to cut closer } } if (!bestPiece || !bestRemaining) { return { pieces: null, error: `Could not find a valid cut for piece ${i + 1} of ${n}. The polygon shape may be too irregular for equal division.`, }; } pieces.push(bestPiece); remaining = bestRemaining; remainingCount--; } // The last remaining piece is the Nth piece pieces.push(remaining); return { pieces }; }