UNPKG

itowns

Version:

A JS/WebGL framework for 3D geospatial data visualization

612 lines (594 loc) 23.5 kB
import * as THREE from 'three'; import Earcut from 'earcut'; import { FEATURE_TYPES } from "../Core/Feature.js"; import ReferLayerProperties from "../Layer/ReferencingLayerProperties.js"; import { deprecatedFeature2MeshOptions } from "../Core/Deprecated/Undeprecator.js"; import { Extent, Coordinates, OrientationUtils } from '@itowns/geographic'; import Style, { StyleContext } from "../Core/Style.js"; const coord = new Coordinates('EPSG:4326', 0, 0, 0); const context = new StyleContext(); const defaultStyle = new Style(); let style; const dim_ref = new THREE.Vector2(); const dim = new THREE.Vector2(); const normal = new THREE.Vector3(); const baseCoord = new THREE.Vector3(); const topCoord = new THREE.Vector3(); const inverseScale = new THREE.Vector3(); const extent = new Extent('EPSG:4326', 0, 0, 0, 0); const _color = new THREE.Color(); const maxValueUint8 = 2 ** 8 - 1; const maxValueUint16 = 2 ** 16 - 1; const maxValueUint32 = 2 ** 32 - 1; const crsWGS84 = 'EPSG:4326'; class FeatureMesh extends THREE.Group { #currentCrs; #originalCrs; #collection = (() => new THREE.Group())(); #place = (() => new THREE.Group())(); constructor(meshes, collection) { super(); this.meshes = new THREE.Group().add(...meshes); this.#collection = new THREE.Group().add(this.meshes); this.#collection.quaternion.copy(collection.quaternion); this.#collection.position.copy(collection.position); this.#collection.scale.copy(collection.scale); this.#collection.updateMatrix(); this.#originalCrs = collection.crs; this.#currentCrs = this.#originalCrs; this.extent = collection.extent; this.add(this.#place.add(this.#collection)); } as(crs) { if (this.#currentCrs !== crs) { this.#currentCrs = crs; if (crs == this.#originalCrs) { // reset transformation this.place.position.set(0, 0, 0); this.position.set(0, 0, 0); this.scale.set(1, 1, 1); this.quaternion.identity(); } else { // calculate the scale transformation to transform the feature.extent // to feature.extent.as(crs) coord.crs = this.#originalCrs; // TODO: An extent here could be either a geographic extent (for // features from WFS) or a tiled extent (for features from MVT). // Unify both behavior. if (this.extent.isExtent) { extent.copy(this.extent).applyMatrix4(this.#collection.matrix); extent.as(coord.crs, extent); } else { this.extent.toExtent(coord.crs, extent); } extent.spatialEuclideanDimensions(dim_ref); extent.planarDimensions(dim); if (dim.x && dim.y) { this.scale.copy(dim_ref).divide(dim).setZ(1); } // Position and orientation // remove original position this.#place.position.copy(this.#collection.position).negate(); // get mesh coordinate coord.setFromVector3(this.#collection.position); // get method to calculate orientation const crsInput = this.#originalCrs == 'EPSG:3857' ? crsWGS84 : this.#originalCrs; const crs2crs = OrientationUtils.quaternionFromCRSToCRS(crsInput, crs); // calculate orientation to crs crs2crs(coord.as(crsWGS84), this.quaternion); // transform position to crs coord.as(crs, coord).toVector3(this.position); } } return this; } } function toColor(color) { if (color) { if (color.type == 'Color') { return color; } else { return _color.set(color); } } else { return _color.set(Math.random() * 0xffffff); } } function getIntArrayFromSize(data, size) { if (size <= maxValueUint8) { return new Uint8Array(data); } else if (size <= maxValueUint16) { return new Uint16Array(data); } else { return new Uint32Array(data); } } function separateMeshes(object3D) { const meshes = []; object3D.updateMatrixWorld(); object3D.traverse(element => { if (element instanceof THREE.Mesh) { element.updateMatrixWorld(); element.geometry.applyMatrix4(element.matrixWorld); meshes.push(element); } }); return meshes; } /** * Add indices for the side faces. * We loop over the contour and create a side face made of two triangles. * * For a ring made of (n) coordinates, there are (n*2) vertices. * The (n) first vertices are on the roof, the (n) other vertices are on the floor. * * If index (i) is on the roof, index (i+length) is on the floor. * * @param {number[]} indices - Array of indices to push to * @param {number} length - Total vertices count in the geom (excluding the extrusion ones) * @param {number} offset * @param {number} count * @param {boolean} isClockWise - Wrapping direction */ function addExtrudedPolygonSideFaces(indices, length, offset, count, isClockWise) { // loop over contour length, and for each point of the contour, // add indices to make two triangle, that make the side face const startIndice = indices.length; indices.length += (count - 1) * 6; for (let i = offset, j = startIndice; i < offset + count - 1; ++i, ++j) { if (isClockWise) { // first triangle indices indices[j] = i; indices[++j] = i + length; indices[++j] = i + 1; // second triangle indices indices[++j] = i + 1; indices[++j] = i + length; indices[++j] = i + length + 1; } else { // first triangle indices indices[j] = i + length; indices[++j] = i; indices[++j] = i + length + 1; // second triangle indices indices[++j] = i + length + 1; indices[++j] = i; indices[++j] = i + 1; } } } function featureToPoint(feature, options) { const ptsIn = feature.vertices; const colors = new Uint8Array(ptsIn.length); const batchIds = new Uint32Array(ptsIn.length); const batchId = options.batchId || ((p, id) => id); let featureId = 0; const vertices = new Float32Array(ptsIn); inverseScale.setFromMatrixScale(context.collection.matrixWorldInverse); normal.set(0, 0, 1).multiply(inverseScale); const pointMaterialSize = []; context.setFeature(feature); for (const geometry of feature.geometries) { const start = geometry.indices[0].offset; const count = geometry.indices[0].count; const id = batchId(geometry.properties, featureId); context.setGeometry(geometry); for (let v = start * 3, j = start; j < start + count; v += 3, j += 1) { if (feature.normals) { normal.fromArray(feature.normals, v).multiply(inverseScale); } const localCoord = context.setLocalCoordinatesFromArray(feature.vertices, v); style.setContext(context); const { base_altitude, color, radius } = style.point; coord.copy(localCoord).applyMatrix4(context.collection.matrixWorld); if (coord.crs == 'EPSG:4978') { // altitude convertion from geocentered to elevation (from ground) coord.as('EPSG:4326', coord); } // Calculate the new coordinates using the elevation shift (baseCoord) baseCoord.copy(normal).multiplyScalar(base_altitude - coord.z).add(localCoord) // and update the geometry buffer (vertices). .toArray(vertices, v); toColor(color).multiplyScalar(255).toArray(colors, v); if (!pointMaterialSize.includes(radius)) { pointMaterialSize.push(radius); } batchIds[j] = id; } featureId++; } const geom = new THREE.BufferGeometry(); geom.setAttribute('position', new THREE.BufferAttribute(vertices, 3)); geom.setAttribute('color', new THREE.BufferAttribute(colors, 3, true)); geom.setAttribute('batchId', new THREE.BufferAttribute(batchIds, 1)); options.pointMaterial.size = pointMaterialSize[0]; if (pointMaterialSize.length > 1) { // TODO CREATE material for each feature console.warn('Too many differents point.radius, only the first one will be used'); } return new THREE.Points(geom, options.pointMaterial); } function featureToLine(feature, options) { const ptsIn = feature.vertices; const colors = new Uint8Array(ptsIn.length); const count = ptsIn.length / 3; const batchIds = new Uint32Array(count); const batchId = options.batchId || ((p, id) => id); let featureId = 0; const vertices = new Float32Array(ptsIn.length); const geom = new THREE.BufferGeometry(); const lineMaterialWidth = []; context.setFeature(feature); const countIndices = (count - feature.geometries.length) * 2; const indices = getIntArrayFromSize(countIndices, count); let i = 0; inverseScale.setFromMatrixScale(context.collection.matrixWorldInverse); normal.set(0, 0, 1).multiply(inverseScale); // Multi line case for (const geometry of feature.geometries) { context.setGeometry(geometry); const id = batchId(geometry.properties, featureId); const start = geometry.indices[0].offset; // To avoid integer overflow with indice value (16 bits) if (start > 0xffff) { console.warn('Feature to Line: integer overflow, too many points in lines'); break; } const count = geometry.indices[0].count; const end = start + count; for (let v = start * 3, j = start; j < end; v += 3, j += 1) { if (j < end - 1) { if (j < 0xffff) { indices[i++] = j; indices[i++] = j + 1; } else { break; } } if (feature.normals) { normal.fromArray(feature.normals, v).multiply(inverseScale); } const localCoord = context.setLocalCoordinatesFromArray(feature.vertices, v); style.setContext(context); const { base_altitude, color, width } = style.stroke; coord.copy(localCoord).applyMatrix4(context.collection.matrixWorld); if (coord.crs == 'EPSG:4978') { // altitude convertion from geocentered to elevation (from ground) coord.as('EPSG:4326', coord); } // Calculate the new coordinates using the elevation shift (baseCoord) baseCoord.copy(normal).multiplyScalar(base_altitude - coord.z).add(localCoord) // and update the geometry buffer (vertices). .toArray(vertices, v); toColor(color).multiplyScalar(255).toArray(colors, v); if (!lineMaterialWidth.includes(width)) { lineMaterialWidth.push(width); } batchIds[j] = id; } featureId++; } options.lineMaterial.linewidth = lineMaterialWidth[0]; if (lineMaterialWidth.length > 1) { // TODO CREATE material for each feature console.warn('Too many differents stroke.width, only the first one will be used'); } geom.setAttribute('position', new THREE.BufferAttribute(vertices, 3)); geom.setAttribute('color', new THREE.BufferAttribute(colors, 3, true)); geom.setAttribute('batchId', new THREE.BufferAttribute(batchIds, 1)); geom.setIndex(new THREE.BufferAttribute(indices, 1)); return new THREE.LineSegments(geom, options.lineMaterial); } function featureToPolygon(feature, options) { const vertices = new Float32Array(feature.vertices); const colors = new Uint8Array(feature.vertices.length); const indices = []; const batchIds = new Uint32Array(vertices.length / 3); const batchId = options.batchId || ((p, id) => id); context.setFeature(feature); inverseScale.setFromMatrixScale(context.collection.matrixWorldInverse); normal.set(0, 0, 1).multiply(inverseScale); let featureId = 0; for (const geometry of feature.geometries) { const start = geometry.indices[0].offset; // To avoid integer overflow with index value (32 bits) if (start > maxValueUint32) { console.warn('Feature to Polygon: integer overflow, too many points in polygons'); break; } context.setGeometry(geometry); const lastIndice = geometry.indices.slice(-1)[0]; const end = lastIndice.offset + lastIndice.count; const startIn = start * 3; const id = batchId(geometry.properties, featureId); for (let i = startIn, b = start; i < startIn + (end - start) * 3; i += 3, b += 1) { if (feature.normals) { normal.fromArray(feature.normals, i).multiply(inverseScale); } const localCoord = context.setLocalCoordinatesFromArray(feature.vertices, i); style.setContext(context); const { base_altitude, color } = style.fill; coord.copy(localCoord).applyMatrix4(context.collection.matrixWorld); if (coord.crs == 'EPSG:4978') { // altitude convertion from geocentered to elevation (from ground) coord.as('EPSG:4326', coord); } // Calculate the new coordinates using the elevation shift (baseCoord) baseCoord.copy(normal).multiplyScalar(base_altitude - coord.z).add(localCoord) // and update the geometry buffer (vertices). .toArray(vertices, i); toColor(color).multiplyScalar(255).toArray(colors, i); batchIds[b] = id; } featureId++; const geomVertices = vertices.slice(start * 3, end * 3); const holesOffsets = geometry.indices.map(i => i.offset - start).slice(1); const triangles = Earcut(geomVertices, holesOffsets, 3); const startIndice = indices.length; indices.length += triangles.length; for (let i = 0; i < triangles.length; i++) { indices[startIndice + i] = triangles[i] + start; } } const geom = new THREE.BufferGeometry(); geom.setAttribute('position', new THREE.BufferAttribute(vertices, 3)); geom.setAttribute('color', new THREE.BufferAttribute(colors, 3, true)); geom.setAttribute('batchId', new THREE.BufferAttribute(batchIds, 1)); geom.setIndex(new THREE.BufferAttribute(getIntArrayFromSize(indices, vertices.length / 3), 1)); return new THREE.Mesh(geom, options.polygonMaterial); } function area(contour, offset, count) { offset *= 3; const n = offset + count * 3; let a = 0.0; for (let p = n - 3, q = offset; q < n; p = q, q += 3) { a += contour[p] * contour[q + 1] - contour[q] * contour[p + 1]; } return a * 0.5; } function featureToExtrudedPolygon(feature, options) { const ptsIn = feature.vertices; const vertices = new Float32Array(ptsIn.length * 2); const totalVertices = ptsIn.length / 3; const colors = new Uint8Array(ptsIn.length * 2); const indices = []; const batchIds = new Uint32Array(vertices.length / 3); const batchId = options.batchId || ((p, id) => id); let featureId = 0; context.setFeature(feature); inverseScale.setFromMatrixScale(context.collection.matrixWorldInverse); normal.set(0, 0, 1).multiply(inverseScale); coord.setCrs(context.collection.crs); for (const geometry of feature.geometries) { context.setGeometry(geometry); const start = geometry.indices[0].offset; const lastIndice = geometry.indices.slice(-1)[0]; const end = lastIndice.offset + lastIndice.count; const count = end - start; const isClockWise = geometry.indices[0].ccw ?? area(ptsIn, start, count) < 0; const startIn = start * 3; const startTop = start + totalVertices; const id = batchId(geometry.properties, featureId); for (let i = startIn, t = startIn + ptsIn.length, b = start; i < startIn + count * 3; i += 3, t += 3, b += 1) { if (feature.normals) { normal.fromArray(feature.normals, i).multiply(inverseScale); } const localCoord = context.setLocalCoordinatesFromArray(ptsIn, i); style.setContext(context); const { base_altitude, extrusion_height, color } = style.fill; coord.copy(localCoord).applyMatrix4(context.collection.matrixWorld); if (coord.crs == 'EPSG:4978') { // altitude convertion from geocentered to elevation (from ground) coord.as('EPSG:4326', coord); } // Calculate the new base coordinates using the elevation shift (baseCoord) baseCoord.copy(normal).multiplyScalar(base_altitude - coord.z).add(localCoord) // and update the geometry buffer (vertices). .toArray(vertices, i); batchIds[b] = id; // populate top geometry buffers topCoord.copy(normal).multiplyScalar(extrusion_height).add(baseCoord).toArray(vertices, t); batchIds[b + totalVertices] = id; // coloring base and top mesh const meshColor = toColor(color).multiplyScalar(255); meshColor.toArray(colors, t); // top meshColor.multiplyScalar(0.5).toArray(colors, i); // base is half dark } featureId++; const geomVertices = vertices.slice(startTop * 3, (end + totalVertices) * 3); const holesOffsets = geometry.indices.map(i => i.offset - start).slice(1); const triangles = Earcut(geomVertices, holesOffsets, 3); const startIndice = indices.length; indices.length += triangles.length; for (let i = 0; i < triangles.length; i++) { indices[startIndice + i] = triangles[i] + startTop; } // add extruded contour addExtrudedPolygonSideFaces(indices, totalVertices, geometry.indices[0].offset, geometry.indices[0].count, isClockWise); // add extruded holes for (let i = 1; i < geometry.indices.length; i++) { const indice = geometry.indices[i]; addExtrudedPolygonSideFaces(indices, totalVertices, indice.offset, indice.count, !(indice.ccw ?? isClockWise)); } } const geom = new THREE.BufferGeometry(); geom.setAttribute('position', new THREE.BufferAttribute(vertices, 3)); geom.setAttribute('color', new THREE.BufferAttribute(colors, 3, true)); geom.setAttribute('batchId', new THREE.BufferAttribute(batchIds, 1)); geom.setIndex(new THREE.BufferAttribute(getIntArrayFromSize(indices, vertices.length / 3), 1)); return new THREE.Mesh(geom, options.polygonMaterial); } /** * Created Instanced object from mesh * * @param {THREE.MESH} mesh Model 3D to instanciate * @param {*} count number of instances to create (int) * @param {*} ptsIn positions of instanced (array double) * @returns {THREE.InstancedMesh} Instanced mesh */ function createInstancedMesh(mesh, count, ptsIn) { const instancedMesh = new THREE.InstancedMesh(mesh.geometry, mesh.material, count); let index = 0; for (let i = 0; i < count * 3; i += 3) { const mat = new THREE.Matrix4(); mat.setPosition(ptsIn[i], ptsIn[i + 1], ptsIn[i + 2]); instancedMesh.setMatrixAt(index, mat); index++; } instancedMesh.instanceMatrix.needsUpdate = true; return instancedMesh; } /** * Convert a {@link Feature} of type POINT to a Instanced meshes * * @param {Object} feature * @returns {THREE.Mesh} mesh or GROUP of THREE.InstancedMesh */ function pointsToInstancedMeshes(feature) { const ptsIn = feature.vertices; const count = feature.geometries.length; const modelObject = style.point.model.object; if (modelObject instanceof THREE.Mesh) { return createInstancedMesh(modelObject, count, ptsIn); } else if (modelObject instanceof THREE.Object3D) { const group = new THREE.Group(); // Get independent meshes from more complexe object const meshes = separateMeshes(modelObject); meshes.forEach(mesh => group.add(createInstancedMesh(mesh, count, ptsIn))); return group; } else { throw new Error('The format of the model object provided in the style (layer.style.point.model.object) is not supported. Only THREE.Mesh or THREE.Object3D are supported.'); } } /** * Convert a {@link Feature} to a Mesh * @param {Feature} feature - the feature to convert * @param {Object} options - options controlling the conversion * * @return {THREE.Mesh} mesh or GROUP of THREE.InstancedMesh */ function featureToMesh(feature, options) { if (!feature.vertices) { return; } let mesh; switch (feature.type) { case FEATURE_TYPES.POINT: if (style.point?.model?.object) { try { mesh = pointsToInstancedMeshes(feature); mesh.isInstancedMesh = true; } catch (e) { mesh = featureToPoint(feature, options); } } else { mesh = featureToPoint(feature, options); } break; case FEATURE_TYPES.LINE: mesh = featureToLine(feature, options); break; case FEATURE_TYPES.POLYGON: if (style.fill && Object.keys(style.fill).includes('extrusion_height')) { mesh = featureToExtrudedPolygon(feature, options); } else { mesh = featureToPolygon(feature, options); } break; default: } if (!mesh.isInstancedMesh) { mesh.material.vertexColors = true; mesh.material.color = new THREE.Color(0xffffff); } mesh.feature = feature; return mesh; } /** * @module Feature2Mesh */ export default { /** * Return a function that converts [Features]{@link module:GeoJsonParser} to Meshes. Feature collection will be converted to a * a THREE.Group. * * @param {Object} options - options controlling the conversion * @param {function} [options.batchId] - optional function to create batchId attribute. * It is passed the feature property and the feature index. As the batchId is using an unsigned int structure on 32 bits, * the batchId could be between 0 and 4,294,967,295. * @param {StyleOptions} [options.style] - optional style properties. Only needed if the convert is used without instancing * a layer beforehand. * @return {function} * @example <caption>Example usage of batchId with featureId.</caption> * view.addLayer({ * id: 'WFS Buildings', * type: 'geometry', * update: itowns.FeatureProcessing.update, * convert: itowns.Feature2Mesh.convert({ * batchId: (property, featureId) => featureId, * }), * filter: acceptFeature, * source, * }); * * @example <caption>Example usage of batchId with property.</caption> * view.addLayer({ * id: 'WFS Buildings', * type: 'geometry', * update: itowns.FeatureProcessing.update, * convert: itowns.Feature2Mesh.convert({ * batchId: (property, featureId) => property.house ? 10 : featureId, * }), * filter: acceptFeature, * source, * }); */ convert() { let options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; deprecatedFeature2MeshOptions(options); return function (collection) { if (!collection) { return; } if (!options.pointMaterial) { // Opacity and wireframe refered with layer properties // TODO: next step is move these properties to Style options.pointMaterial = ReferLayerProperties(new THREE.PointsMaterial(), this); options.lineMaterial = ReferLayerProperties(new THREE.LineBasicMaterial(), this); options.polygonMaterial = ReferLayerProperties(new THREE.MeshBasicMaterial(), this); } // In the case we didn't instanciate the layer (this) before the convert, we can pass // style properties (@link StyleOptions) using options.style. // This is usually done in some tests and if you want to use Feature2Mesh.convert() // as in examples/source_file_gpx_3d.html. style = this?.style || (options.style ? new Style(options.style) : defaultStyle); context.setCollection(collection); const features = collection.features; if (!features || features.length == 0) { return; } const meshes = features.map(feature => { const mesh = featureToMesh(feature, options); mesh.layer = this; return mesh; }); const featureNode = new FeatureMesh(meshes, collection); return featureNode; }; } };