/** * Pure geometry functions for splitting a polygon by a line. * * No OpenLayers dependency — operates on raw coordinate arrays. */ /** * Compute the intersection point of two 2D line segments. * Segment A: p1→p2, Segment B: p3→p4. * * @param {number[]} p1 * @param {number[]} p2 * @param {number[]} p3 * @param {number[]} p4 * @param {number} [eps=1e-10] tolerance for parallel check * @returns {{ point: number[], t: number, u: number } | null} * t = parametric position on segment A (0–1), * u = parametric position on segment B (0–1) */ function segmentIntersection(p1, p2, p3, p4, eps = 1e-10) { const dx1 = p2[0] - p1[0]; const dy1 = p2[1] - p1[1]; const dx2 = p4[0] - p3[0]; const dy2 = p4[1] - p3[1]; const denom = dx1 * dy2 - dy1 * dx2; if (Math.abs(denom) < eps) return null; // parallel / collinear const dx3 = p3[0] - p1[0]; const dy3 = p3[1] - p1[1]; const t = (dx3 * dy2 - dy3 * dx2) / denom; const u = (dx3 * dy1 - dy3 * dx1) / denom; if (t < -eps || t > 1 + eps || u < -eps || u > 1 + eps) return null; return { point: [p1[0] + t * dx1, p1[1] + t * dy1], t: Math.max(0, Math.min(1, t)), u: Math.max(0, Math.min(1, u)), }; } /** * Signed area of a ring (shoelace formula). * Positive = counter-clockwise, negative = clockwise. */ 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; } /** * Test whether a point is inside a ring (ray-casting algorithm). */ function pointInRing(pt, ring) { let inside = false; for (let i = 0, j = ring.length - 2; i < ring.length - 1; j = i++) { const xi = ring[i][0], yi = ring[i][1]; const xj = ring[j][0], yj = ring[j][1]; if (((yi > pt[1]) !== (yj > pt[1])) && (pt[0] < (xj - xi) * (pt[1] - yi) / (yj - yi) + xi)) { inside = !inside; } } return inside; } /** * Squared distance between two points. */ function dist2(a, b) { return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2; } /** * Find all intersection points between a cutting line and a polygon ring. * * @param {number[][]} ring Closed ring coordinates (first === last) * @param {number[][]} line LineString coordinates (2+ points) * @returns {Array<{ point: number[], ringSegIdx: number, ringT: number, lineSegIdx: number, lineT: number }>} */ function findIntersections(ring, line) { const hits = []; const eps = 1e-10; for (let li = 0; li < line.length - 1; li++) { for (let ri = 0; ri < ring.length - 1; ri++) { const ix = segmentIntersection(ring[ri], ring[ri + 1], line[li], line[li + 1], eps); if (!ix) continue; // Skip if intersection is at the very start of the ring segment // but was already caught as the end of the previous segment const pt = ix.point; // Avoid duplicate hits at shared vertices let isDup = false; for (const h of hits) { if (dist2(h.point, pt) < 1e-6) { isDup = true; break; } } if (isDup) continue; hits.push({ point: pt, ringSegIdx: ri, ringT: ix.t, lineSegIdx: li, lineT: ix.u, }); } } // Sort by position along the cutting line hits.sort((a, b) => { if (a.lineSegIdx !== b.lineSegIdx) return a.lineSegIdx - b.lineSegIdx; return a.lineT - b.lineT; }); return hits; } /** * Insert intersection points into a ring, returning the expanded ring * and the new indices of the inserted points. * * @param {number[][]} ring Closed ring (first === last) * @param {Array<{ point: number[], ringSegIdx: number, ringT: number }>} hits * Sorted by ringSegIdx then ringT. * @returns {{ ring: number[][], indices: number[] }} */ function insertPointsIntoRing(ring, hits) { // Sort hits by ring position (segment index, then parametric t) so // we can insert from back to front without shifting earlier indices. const sorted = hits.map((h, i) => ({ ...h, origOrder: i })); sorted.sort((a, b) => { if (a.ringSegIdx !== b.ringSegIdx) return a.ringSegIdx - b.ringSegIdx; return a.ringT - b.ringT; }); const expanded = ring.slice(); // copy const indices = new Array(sorted.length); // Insert from the end so that earlier insertions don't shift later indices. for (let k = sorted.length - 1; k >= 0; k--) { const h = sorted[k]; const insertIdx = h.ringSegIdx + 1; // Check if this point is essentially identical to an existing vertex const snapDist = 1e-6; if (dist2(h.point, expanded[h.ringSegIdx]) < snapDist) { indices[h.origOrder] = h.ringSegIdx; continue; } if (dist2(h.point, expanded[h.ringSegIdx + 1]) < snapDist) { indices[h.origOrder] = h.ringSegIdx + 1; continue; } // Insert the new point expanded.splice(insertIdx, 0, h.point); indices[h.origOrder] = insertIdx; // Adjust indices for all previously recorded insertions // that reference a position >= insertIdx for (let j = k + 1; j < sorted.length; j++) { if (indices[sorted[j].origOrder] >= insertIdx) { indices[sorted[j].origOrder]++; } } } return { ring: expanded, indices }; } /** * Extract a slice of a ring from index i0 to i1 (going forward, wrapping). * Both endpoints are included. * * @param {number[][]} ring Closed ring (first === last); length includes closing vertex * @param {number} i0 Start index (inclusive) * @param {number} i1 End index (inclusive) * @returns {number[][]} */ function ringSlice(ring, i0, i1) { const n = ring.length - 1; // number of unique vertices (ring is closed) // Normalise indices into the [0, n-1] range const start = ((i0 % n) + n) % n; const end = ((i1 % n) + n) % n; const result = []; let idx = start; while (true) { result.push(ring[idx]); if (idx === end) break; idx = (idx + 1) % n; } return result; } /** * Extract the cutting line segment between two intersection points. * * @param {number[][]} line Full cutting line coordinates * @param {{ point: number[], lineSegIdx: number, lineT: number }} hit0 * @param {{ point: number[], lineSegIdx: number, lineT: number }} hit1 * @returns {number[][]} Coordinates from hit0.point to hit1.point along the line */ function cuttingLineSlice(line, hit0, hit1) { const result = [hit0.point]; // Include all intermediate line vertices between the two hit segments const startSeg = hit0.lineSegIdx; const endSeg = hit1.lineSegIdx; for (let i = startSeg + 1; i <= endSeg; i++) { result.push(line[i]); } // Add the end intersection point if it's not the same as the last vertex if (dist2(result[result.length - 1], hit1.point) > 1e-10) { result.push(hit1.point); } return result; } /** * Ensure a ring has the desired winding order. * @param {number[][]} ring Closed ring * @param {boolean} ccw true for counter-clockwise * @returns {number[][]} */ function ensureWinding(ring, ccw) { const area = signedArea(ring); if ((ccw && area < 0) || (!ccw && area > 0)) { return ring.slice().reverse(); } return ring; } /** * Close a ring (ensure first === last). */ function closeRing(coords) { if (coords.length < 2) return coords; const first = coords[0]; const last = coords[coords.length - 1]; if (dist2(first, last) > 1e-10) { return [...coords, first.slice()]; } return coords; } /** * Extend a cutting line so that both endpoints lie outside the polygon ring. * If an endpoint is inside, we extend the first/last segment outward past the * bounding box diagonal so it definitely exits. * * @param {number[][]} line Cutting line coordinates * @param {number[][]} ring Closed polygon ring * @returns {number[][]} Extended line (may be the original if already outside) */ function extendLineOutsideRing(line, ring) { // Compute bounding-box diagonal for a generous extension distance let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const pt of ring) { if (pt[0] < minX) minX = pt[0]; if (pt[1] < minY) minY = pt[1]; if (pt[0] > maxX) maxX = pt[0]; if (pt[1] > maxY) maxY = pt[1]; } const diag = Math.sqrt((maxX - minX) ** 2 + (maxY - minY) ** 2) || 1; const result = line.slice(); // Extend start if inside if (pointInRing(result[0], ring)) { const p0 = result[0]; const p1 = result[1]; const dx = p0[0] - p1[0]; const dy = p0[1] - p1[1]; const len = Math.sqrt(dx * dx + dy * dy) || 1; const scale = diag * 2 / len; result[0] = [p0[0] + dx * scale, p0[1] + dy * scale]; } // Extend end if inside const last = result.length - 1; if (pointInRing(result[last], ring)) { const pN = result[last]; const pN1 = result[last - 1]; const dx = pN[0] - pN1[0]; const dy = pN[1] - pN1[1]; const len = Math.sqrt(dx * dx + dy * dy) || 1; const scale = diag * 2 / len; result[last] = [pN[0] + dx * scale, pN[1] + dy * scale]; } return result; } /** * Split a polygon by a cutting line. * * The cutting line can start or end inside the polygon — the algorithm will * automatically extend it outward so it crosses the boundary at exactly 2 * points. Multi-vertex cutting lines (with corners or approximated arcs) * are fully supported. * * @param {number[][][]} polygonCoords Polygon coordinates: * [exteriorRing, ...holeRings] where each ring is closed (first === last) * @param {number[][]} lineCoords Cutting line coordinates (2+ points) * @returns {number[][][][] | null} Two polygon coordinate arrays, or null if split failed */ export function splitPolygonByLine(polygonCoords, lineCoords) { const exteriorRing = polygonCoords[0]; const holes = polygonCoords.slice(1); // Extend the cutting line if its endpoints are inside the polygon const extendedLine = extendLineOutsideRing(lineCoords, exteriorRing); // 1. Find intersections between cutting line and exterior ring const hits = findIntersections(exteriorRing, extendedLine); // We need exactly 2 intersection points for a simple split if (hits.length !== 2) { console.warn(`[polygonSplit] Expected 2 intersections, got ${hits.length}`); return null; } const [hit0, hit1] = hits; // 2. Insert intersection points into the ring const { ring: expandedRing, indices } = insertPointsIntoRing(exteriorRing, hits); const idx0 = indices[0]; const idx1 = indices[1]; // Ensure idx0 < idx1 for consistent traversal const [iA, iB] = idx0 < idx1 ? [idx0, idx1] : [idx1, idx0]; const [hitA, hitB] = idx0 < idx1 ? [hit0, hit1] : [hit1, hit0]; // 3. Get the cutting line segment between the two intersection points const cutForward = idx0 < idx1 ? cuttingLineSlice(extendedLine, hit0, hit1) : cuttingLineSlice(extendedLine, hit1, hit0); const cutReverse = cutForward.slice().reverse(); // 4. Build two polygon rings // Ring A: walk ring from iA to iB (forward), then cutting line reversed back to iA const sliceAB = ringSlice(expandedRing, iA, iB); const ringA = closeRing([...sliceAB, ...cutReverse.slice(1)]); // Ring B: walk ring from iB to iA (wrapping), then cutting line forward back to iB const sliceBA = ringSlice(expandedRing, iB, iA); const ringB = closeRing([...sliceBA, ...cutForward.slice(1)]); // 5. Match winding order to original const originalCCW = signedArea(exteriorRing) > 0; const finalA = ensureWinding(ringA, originalCCW); const finalB = ensureWinding(ringB, originalCCW); // 6. Build polygon coordinate arrays, assigning holes to the correct piece const polyA = [finalA]; const polyB = [finalB]; for (const hole of holes) { // Use the centroid of the hole to determine containment const centroid = holeCentroid(hole); if (pointInRing(centroid, finalA)) { polyA.push(hole); } else { polyB.push(hole); } } return [polyA, polyB]; } /** * Compute the centroid of a closed ring. */ function holeCentroid(ring) { let cx = 0, cy = 0; const n = ring.length - 1; // exclude closing vertex for (let i = 0; i < n; i++) { cx += ring[i][0]; cy += ring[i][1]; } return [cx / n, cy / n]; }