@kylebarron/snap-to-tin
Version:
Snap vector features to the faces of a triangulated irregular network (TIN).
561 lines (553 loc) • 23 kB
JavaScript
(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;
})));