UNPKG

@kylebarron/snap-to-tin

Version:

Snap vector features to the faces of a triangulated irregular network (TIN).

561 lines (553 loc) 23 kB
(function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory(require('lineclip'), require('flatbush'), require('lodash.orderby')) : typeof define === 'function' && define.amd ? define(['lineclip', 'flatbush', 'lodash.orderby'], factory) : (global = global || self, global['snap-features-to-mesh'] = factory(global.lineclip, global.Flatbush, global.orderBy)); }(this, (function (lineclip, Flatbush, orderBy) { 'use strict'; lineclip = lineclip && Object.prototype.hasOwnProperty.call(lineclip, 'default') ? lineclip['default'] : lineclip; Flatbush = Flatbush && Object.prototype.hasOwnProperty.call(Flatbush, 'default') ? Flatbush['default'] : Flatbush; orderBy = orderBy && Object.prototype.hasOwnProperty.call(orderBy, 'default') ? orderBy['default'] : orderBy; function interpolateTriangle(point, triangle) { const az = triangle[2]; const bz = triangle[5]; const cz = triangle[8]; // Find the mix of a, b, and c to use const mix = barycentric2d(point, triangle); // If point is outside triangle, return null if (mix[0] < 0 || 1 < mix[0] || mix[1] < 0 || 1 < mix[1] || mix[2] < 0 || 1 < mix[2]) { return null; } // Find the correct z based on that mix const interpolatedZ = mix[0] * az + mix[1] * bz + mix[2] * cz; return [point[0], point[1], interpolatedZ]; } // Interpolate when point is known to be on triangle edge // Can be much faster than working with barycentric coordinates function interpolateEdge(triangle, point) { // loop over each edge until you find one where the point is on the line for (const edge of triangleToEdges(triangle)) { const start = edge[0]; const end = edge[1]; const onLine = pointOnLine2d(start, end, point); if (!onLine) continue; // percent distance from start to end const pctAlong = distanceLine2d(start, point) / distanceLine2d(start, end); const z = start[2] + pctAlong * (end[2] - start[2]); return [point[0], point[1], z]; } return null; } // https://stackoverflow.com/a/11912171 function pointOnLine2d(a, b, point) { return floatIsClose(distanceLine2d(a, point) + distanceLine2d(b, point) - distanceLine2d(a, b), 0); } function distanceLine2d(a, b) { const dx = b[0] - a[0]; const dy = b[1] - a[1]; return Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2)); } function floatIsClose(a, b, eps = 1e-10) { return Math.abs(a - b) < eps; } // Modfied slightly from https://stackoverflow.com/a/24392281 // returns intersection point if the line from a->b intersects with c->d // Otherwise returns false function lineLineIntersection2d(a, b, c, d) { // ∆x1 * ∆y2 - ∆x2 * ∆y1 const det = (b[0] - a[0]) * (d[1] - c[1]) - (d[0] - c[0]) * (b[1] - a[1]); if (det === 0) { // NOTE: lines are parallel return null; } // pct distance along each line const lambda = ((d[1] - c[1]) * (d[0] - a[0]) + (c[0] - d[0]) * (d[1] - a[1])) / det; const gamma = ((a[1] - b[1]) * (d[0] - a[0]) + (b[0] - a[0]) * (d[1] - a[1])) / det; if (!(0 <= lambda && lambda <= 1 && 0 <= gamma && gamma <= 1)) { // intersects outside the line segments return null; } // With the current implementation, lambda is correctly the percent distance along the first line // from a to b, but gamma is the percent distance **back** from d to c It isn't worth my time to // figure out how to change the function, but just keep that in mind. // Find intersection point // Use lambda for pct along a-b const x = a[0] + lambda * (b[0] - a[0]); const y = a[1] + lambda * (b[1] - a[1]); return [x, y]; } // Test line-line intersection among line and each edge of the triangle function lineTriangleIntersect2d(line, triangle) { // loop over each edge const intersectionPoints = []; for (const edge of triangleToEdges(triangle)) { const intersectionPoint = lineLineIntersection2d(line[0], line[1], edge[0], edge[1]); if (intersectionPoint) { intersectionPoints.push(intersectionPoint); } } return intersectionPoints; } function* triangleToEdges(triangle) { for (let i = 0; i < 3; i++) { let edge = []; if (i === 0) { edge.push(triangleVertex(0, triangle)); edge.push(triangleVertex(1, triangle)); } else if (i === 1) { edge.push(triangleVertex(1, triangle)); edge.push(triangleVertex(2, triangle)); } else if (i === 2) { edge.push(triangleVertex(2, triangle)); edge.push(triangleVertex(0, triangle)); } yield edge; } } function triangleVertex(i, triangle) { return triangle.subarray(i * 3, (i + 1) * 3); } // Split line into desired number of segments function splitLine2d(line, nSegments) { const [start, end] = line; const lineSegments = []; for (let i = 0; i < nSegments; i++) { // _i_th part of the way from min to max const a = start[0] + (i / nSegments) * (end[0] - start[0]); const b = start[1] + (i / nSegments) * (end[1] - start[1]); const c = start[0] + ((i + 1) / nSegments) * (end[0] - start[0]); const d = start[1] + ((i + 1) / nSegments) * (end[1] - start[1]); lineSegments.push([ [a, b], [c, d] ]); } return lineSegments; } function triangleToBounds(triangle) { if (triangle.length !== 9) { throw new Error(`Incorrect length of triangle: ${triangle.length}`); } const minX = Math.min(triangle[0], triangle[3], triangle[6]); const maxX = Math.max(triangle[0], triangle[3], triangle[6]); const minY = Math.min(triangle[1], triangle[4], triangle[7]); const maxY = Math.max(triangle[1], triangle[4], triangle[7]); return [minX, minY, maxX, maxY]; } function pointInTriangle2d(p, triangle) { const [x, y, z] = barycentric2d(p, triangle); return x >= 0 && y >= 0 && z >= 0; } // From https://stackoverflow.com/a/14382692 function barycentric2d(p, triangle) { const p0 = triangle.subarray(0, 3); const p1 = triangle.subarray(3, 6); const p2 = triangle.subarray(6, 9); const area = 0.5 * (-p1[1] * p2[0] + p0[1] * (-p1[0] + p2[0]) + p0[0] * (p1[1] - p2[1]) + p1[0] * p2[1]); const s = (1 / (2 * area)) * (p0[1] * p2[0] - p0[0] * p2[1] + (p2[1] - p0[1]) * p[0] + (p0[0] - p2[0]) * p[1]); const t = (1 / (2 * area)) * (p0[0] * p1[1] - p0[1] * p1[0] + (p0[1] - p1[1]) * p[0] + (p1[0] - p0[0]) * p[1]); return [1 - s - t, s, t]; } // Get triangles from terrain function constructRTree(indices, positions) { // Create list of objects for insertion into RTree const triangles = createTriangles(indices, positions); // initialize Flatbush for # of items // each triangle has 3 vertices of 3 coordinates each // 16 is default for nodeSize // store coordinates in flatbush internally as Float32Array const index = new Flatbush(triangles.length / 9, 16, Float32Array); // fill it with bounding boxes of triangles for (let i = 0; i < triangles.length / 9; i++) { const triangle = triangles.subarray(i * 9, (i + 1) * 9); const [minX, minY, maxX, maxY] = triangleToBounds(triangle); index.add(minX, minY, maxX, maxY); } // perform the indexing index.finish(); return { index, triangles }; } function createTriangles(indices, positions) { const triangles = new Float32Array(indices.length * 3); for (let i = 0; i < indices.length; i += 3) { // The indices within `positions` of the three vertices of the triangle const aIndex = indices[i]; const bIndex = indices[i + 1]; const cIndex = indices[i + 2]; // The three vertices of the triangle, where each vertex is an array of [x, y, z] const a = positions.subarray(aIndex * 3, (aIndex + 1) * 3); const b = positions.subarray(bIndex * 3, (bIndex + 1) * 3); const c = positions.subarray(cIndex * 3, (cIndex + 1) * 3); triangles.set(a, i * 3); triangles.set(b, (i + 1) * 3); triangles.set(c, (i + 2) * 3); } return triangles; } function searchLineInIndex(line, index, maxPctArea = 0.01) { // Reduce total area searched in rtree to reduce false positives const indexArea = getIndexArea(index); const nSegments = getNumLineSegments(line, indexArea, maxPctArea); const lineSegments = splitLine2d(line, nSegments); const resultIndices = new Set(); for (const lineSegment of lineSegments) { const [minX, minY] = lineSegment[0]; const [maxX, maxY] = lineSegment[1]; index .search(minX, minY, maxX, maxY) .forEach(item => resultIndices.add(item)); } return Array.from(resultIndices); } function getIndexArea(index) { let area; if (index.minX !== Infinity && index.minY !== Infinity && index.maxX !== -Infinity && index.maxY !== -Infinity) { area = (index.maxX - index.minX) * (index.maxY - index.minY); } return area; } function getNumLineSegments(line, indexArea, maxPctArea = 0.01) { if (!indexArea) { return 1; } const [minX, minY] = line[0]; const [maxX, maxY] = line[1]; const searchArea = (maxX - minX) * (maxY - minY); const pctSearch = searchArea / indexArea; return Math.max(1, Math.ceil(pctSearch / maxPctArea)); } // Find elevation of point function handlePoint(point, index, triangles) { // Search index for point const [x, y] = point.slice(0, 2); // array of TypedArrays of length 9 const candidateTriangles = index .search(x, y, x, y) .map(i => triangles.subarray(i * 9, (i + 1) * 9)); // Find true positives from rtree results // Since I'm working with triangles and not square boxes, it's possible that a // point could be inside the triangle's bounding box but outside the triangle // itself. // array of TypedArrays of length 9 const filteredResults = candidateTriangles.filter(result => { if (pointInTriangle2d(point, result)) return result; }); // Not sure why this is sometimes empty after filtering?? if (filteredResults.length === 0) { return null; } // Now linearly interpolate elevation within this triangle // TypedArray of length 9 const triangle = filteredResults[0]; return interpolateTriangle(point, triangle); } // Add coordinates for LineString // // Note: you can't instantiate a new TypedArray with the number of // coordinates, because you don't know how many edges you'll be // crossing on the mesh // // For now I'll just return an array of arrays of coordinates // // But keep in mind you could do a two-pass approach: // First loop over each line segment, searching the rtree index for each. // Create an array of arrays of indexes that correspond to each segment. // That gives you an upper bound to the number of triangles, so you could create // a TypedArray using that upper bound function handleLineString(line, index, triangles) { const nCoords = line.length; const newCoords = []; // Loop over each coordinate pair for (let i = 0; i < nCoords - 1; i++) { const start = line[i]; const end = line[i + 1]; // Find z value of beginning endpoint of line segment const newStart = handlePoint(start, index, triangles); if (newStart) { newCoords.push(newStart); } // Find intermediate points of line segment const lineZ = handleLineSegment([start, end], index, triangles); if (lineZ) { for (const coord of lineZ) { newCoords.push(coord); } } } // Find z value of endpoint of polyline const endPoint = line[line.length - 1]; const newEnd = handlePoint(endPoint, index, triangles); if (newEnd) { newCoords.push(newEnd); } // Return view on filled elements return newCoords; } // Find intersections between line segment and triangle edges // This does not handle line segment endpoints function handleLineSegment(lineSegment, index, triangles) { const [start, end] = lineSegment; // Sometimes the start and end points can be the same, usually from clipping if (start[0] === end[0] && start[1] === end[1]) return null; // Find edges that this line segment crosses // First search in rtree. This is fast but has false-positives const candidateTrianglesIndices = searchLineInIndex(lineSegment, index); // Find points where line segment intersects triangles // # of possible triangles * # of possible intersections per triangle (2) * // (x, y, z) coordLength let intersectionPoints = new Float32Array(candidateTrianglesIndices.length * 2 * 3); let intersectionPointsIndex = 0; // NOTE that intersectionPoints by default has 2x duplicates! // This is because every edge crossed is part of two triangles! // To simplify, I'll deduplicate on x. NOTE: This could be problematic for // vertical lines, but you can't put arrays in a Set, so it's good enough for // now const xVals = new Set(); for (const index of candidateTrianglesIndices) { const triangle = triangles.subarray(index * 9, (index + 1) * 9); // Possibly empty array of points where line segment intersects triangle const intersections = lineTriangleIntersect2d(lineSegment, triangle); if (!intersections || intersections.length === 0) continue; // Otherwise, has one or more intersection point(s) // Fill intersectionPoints for (const intersection of intersections) { // Skip if there already exists a position with this x coordinate if (xVals.has(intersection[0])) continue; xVals.add(intersection[0]); // Find z coord const newPoint = interpolateEdge(triangle, intersection); // Add to array if (newPoint) { intersectionPoints.set(newPoint, intersectionPointsIndex * 3); intersectionPointsIndex++; } } } // Filter array to size of filled points intersectionPoints = intersectionPoints.subarray(0, intersectionPointsIndex * 3); // sort points in order from start to end // I'll convert intersectionPoints into an array of coords to simplify const coords = []; for (let i = 0; i < intersectionPoints.length / 3; i++) { coords.push(intersectionPoints.subarray(i * 3, (i + 1) * 3)); } const deltaX = end[0] - start[0]; const deltaY = end[1] - start[1]; let sorted; if (deltaX > 0) { sorted = orderBy(coords, c => c[0], "asc"); } else if (deltaX < 0) { sorted = orderBy(coords, c => c[0], "desc"); } else if (deltaY > 0) { sorted = orderBy(coords, c => c[1], "asc"); } else if (deltaY < 0) { sorted = orderBy(coords, c => c[1], "desc"); } else { throw new Error("start and end point same???"); } return sorted; } class SnapFeatures { constructor(options) { // Snap arbitrary GeoJSON features this.snapFeatures = options => { const { features } = options; const newFeatures = []; for (const feature of features) { const geometryType = feature.geometry.type; if (geometryType === "Point") { const coord = feature.geometry.coordinates; const newCoord = this._handlePoint(coord); if (!newCoord) continue; feature.geometry.coordinates = newCoord; newFeatures.push(feature); } else if (geometryType === "MultiPoint") { const newCoords = []; for (const point of feature.geometry.coordinates) { const newPoint = this._handlePoint(point); if (newPoint) newCoords.push(newPoint); } feature.geometry.coordinates = newCoords; newFeatures.push(feature); } else if (geometryType === "LineString") { // An array of one or more LineStrings const newLines = this._handleLine(feature.geometry.coordinates); if (!newLines) continue; // Single LineString if (newLines.length === 1) { feature.geometry.coordinates = newLines[0]; } else { feature.geometry.type = "MultiLineString"; feature.geometry.coordinates = newLines; } newFeatures.push(feature); } else if (geometryType === "MultiLineString") { const newCoords = []; for (const line of feature.geometry.coordinates) { const newLines = this._handleLine(line); if (!newLines) continue; // Single LineString if (newLines.length === 1) { newCoords.push(newLines[0]); } else { newCoords.push.apply(newLines); } } feature.geometry.coordinates = newCoords; newFeatures.push(feature); } } return newFeatures; }; this._handlePoint = (coord) => { if (this.bounds && this.bounds.length === 4) { // Make sure coordinate is within bounds if (coord[0] < this.bounds[0] || coord[0] > this.bounds[2] || coord[1] < this.bounds[1] || coord[1] > this.bounds[3]) { return; } } return handlePoint(coord, this.index, this.triangles); }; this._handleLine = (coords) => { // Clip line to box let clippedLine = [coords]; if (this.bounds && this.bounds.length === 4) { clippedLine = lineclip(coords, this.bounds); if (clippedLine.length === 0) return; } const newLineSegments = []; for (const lineSegment of clippedLine) { newLineSegments.push(handleLineString(lineSegment, this.index, this.triangles)); } return newLineSegments; }; // Snap typedArray of points this.snapPoints = options => { const { positions, coordLength = 2, featureIds } = options; const newPoints = new Float32Array((positions.length / coordLength) * 3); const newFeatureIds = new Uint32Array((featureIds && featureIds.length) || 0); let pointIndex = 0; // Iterate over vertex index for (let i = 0; i < positions.length / coordLength; i++) { const coord = positions.subarray(i * coordLength, (i + 1) * coordLength); const newPoint = this._handlePoint(coord); if (newPoint) { newPoints.set(newPoint, pointIndex * 3); if (featureIds) { newFeatureIds[pointIndex] = featureIds[i]; } pointIndex++; } } // Filter array to size of filled points return { positions: newPoints.subarray(0, pointIndex * 3), featureIds: featureIds }; }; // Snap typedArray of lines this.snapLines = options => { const { positions, pathIndices, coordLength = 2, featureIds } = options; const newLines = []; const newFeatureIds = []; // Loop over each LineString, as defined by pathIndices const loopIndices = pathIndices ? pathIndices : [0, positions.length]; for (let i = 0; i < loopIndices.length - 1; i++) { const positionStartIndex = loopIndices[i]; const positionEndIndex = loopIndices[i + 1]; // Make array of coordinates const line = []; for (let j = positionStartIndex; j < positionEndIndex; j++) { line.push(positions.subarray(j * coordLength, (j + 1) * coordLength)); } const newLineSegments = this._handleLine(line); if (!newLineSegments) continue; const objectId = featureIds && featureIds[i]; for (const newLineSegment of newLineSegments) { newLines.push(newLineSegment); if (objectId) newFeatureIds.push(objectId); } } // Create binary arrays const newPositions = []; const newPathIndices = []; const newNewFeatureIds = []; let positionIndex = 0; for (let i = 0; i < newLines.length; i++) { const line = newLines[i]; newPositions.push.apply(line); newPathIndices.push(positionIndex); if (featureIds) { for (let j = 0; j < line.length; j++) { newNewFeatureIds.push(newFeatureIds[i]); } } positionIndex += line.length; } // Backfill last index newPathIndices.push(newPositions.length); return { positions: Float32Array.from(newPositions), pathIndices: Uint32Array.from(newPathIndices), featureIds: Uint32Array.from(newNewFeatureIds) }; }; const { indices, positions, bounds = [-Infinity, -Infinity, Infinity, Infinity] } = options; const { index, triangles } = constructRTree(indices, positions); this.index = index; this.triangles = triangles; // Intersection of provided bounds and rtree bounds this.bounds = [ Math.max(bounds[0], index.minX), Math.max(bounds[1], index.minY), Math.min(bounds[2], index.maxX), Math.min(bounds[3], index.maxY) ]; } } return SnapFeatures; })));