UNPKG

@giro3d/giro3d

Version:

A JS/WebGL framework for 3D geospatial data visualization

757 lines (728 loc) 24.3 kB
import { LineString } from 'ol/geom'; import { BufferAttribute, BufferGeometry, DoubleSide, EventDispatcher, MeshBasicMaterial, MeshLambertMaterial, SpriteMaterial, SRGBColorSpace, Vector3 } from 'three'; import { LineGeometry } from 'three/examples/jsm/lines/LineGeometry.js'; import { LineMaterial } from 'three/examples/jsm/lines/LineMaterial.js'; import { getFullFillStyle, getFullPointStyle, getFullStrokeStyle, hashStyle } from '../../core/FeatureTypes'; import RequestQueue from '../../core/RequestQueue'; import Fetcher from '../../utils/Fetcher'; import { triangulate } from '../../utils/tessellator'; import LineStringMesh from './LineStringMesh'; import MultiLineStringMesh from './MultiLineStringMesh'; import MultiPointMesh from './MultiPointMesh'; import MultiPolygonMesh from './MultiPolygonMesh'; import PointMesh from './PointMesh'; import PolygonMesh from './PolygonMesh'; import SurfaceMesh from './SurfaceMesh'; const VERT_STRIDE = 3; // 3 elements per vertex position (X, Y, Z) const X = 0; const Y = 1; const Z = 2; function isTexture(o) { return o?.isTexture ?? false; } const ZERO = new Vector3(0, 0, 0); /** * This methods prepares vertices for three.js with coordinates coming from openlayers. * * It does 2 things: * * - flatten the array while removing the last vertex of each rings * - builds the new hole indices taking into account vertex removals * * @param coordinates - The coordinate of the closed shape that form the roof. * @param stride - The stride in the coordinate array (2 for XY, 3 for XYZ) * @param offset - The offset to apply to vertex positions. * the first/last point * @param elevation - The elevation. */ function createFloorVertices(params) { // iterate on polygon and holes const holesIndices = []; let currentIndex = 0; const positions = []; const { coordinates, offset, ignoreZ, elevation, stride } = params; for (const ring of coordinates) { // NOTE: rings coming from openlayers are auto-closing, so we need to remove the last vertex // of each ring here if (currentIndex > 0) { holesIndices.push(currentIndex); } for (let i = 0; i < ring.length - 1; i++) { currentIndex++; const coord = ring[i]; positions.push(coord[X] - offset.x); positions.push(coord[Y] - offset.y); let z = 0; if (!ignoreZ) { if (stride === 3) { z = coord[Z]; } else if (elevation != null) { z = Array.isArray(elevation) ? elevation[i] : elevation; } } z -= offset.z; positions.push(z); } } return { flatCoordinates: positions, holes: holesIndices }; } /** * Create a roof, basically a copy of the floor with faces shifted by 'pointcount' elem * * NOTE: at the moment, this method must be executed before `createWallForRings`, because we copy * the indices array as it is. * * @param positions - a flat array of coordinates * @param pointCount - the number of points to read from position, starting with the first vertex * @param indices - the indices to duplicate for the roof * @param extrusionOffset - the extrusion offset(s) to apply to the roof element. */ function createRoof(positions, pointCount, indices, extrusionOffset) { for (let i = 0; i < pointCount; i++) { positions.push(positions[i * VERT_STRIDE + X]); positions.push(positions[i * VERT_STRIDE + Y]); const zOffset = Array.isArray(extrusionOffset) ? extrusionOffset[i] : extrusionOffset; positions.push(positions[i * VERT_STRIDE + Z] + zOffset); } const iLength = indices.length; for (let i = 0; i < iLength; i++) { indices.push(indices[i] + pointCount); } } /** * This methods creates vertex and faces for the walls * * @param positions - The array containing the positions of the vertices. * @param start - vertex in positions to start with * @param end - vertex in positions to end with * @param indices - The index array. * @param extrusionOffset - The extrusion distance. */ function createWallForRings(positions, start, end, indices, extrusionOffset) { // Each side is formed by the A, B, C, D vertices, where A is the current coordinate, // and B is the next coordinate (thus the segment AB is one side of the polygon). // C and D are the same points but with a Z offset. // Note that each side has its own vertices, as vertices of sides are not shared with // other sides (i.e duplicated) in order to have faceted normals for each side. let vertexOffset = 0; const pointCount = positions.length / 3; for (let i = start; i < end; i++) { const idxA = i * VERT_STRIDE; const iB = i + 1 === end ? start : i + 1; const idxB = iB * VERT_STRIDE; const Ax = positions[idxA + X]; const Ay = positions[idxA + Y]; const Az = positions[idxA + Z]; const Bx = positions[idxB + X]; const By = positions[idxB + Y]; const Bz = positions[idxB + Z]; const zOffsetA = Array.isArray(extrusionOffset) ? extrusionOffset[i] : extrusionOffset; const zOffsetB = Array.isArray(extrusionOffset) ? extrusionOffset[iB] : extrusionOffset; // +Z top // A B // (Ax, Ay, zMax) ---- (Bx, By, zMax) // | | // | | // (Ax, Ay, zMin) ---- (Bx, By, zMin) // C D // -Z bottom positions.push(Ax, Ay, Az); // A positions.push(Bx, By, Bz); // B positions.push(Ax, Ay, Az + zOffsetA); // C positions.push(Bx, By, Bz + zOffsetB); // D // The indices of the side are the following // [A, B, C, C, B, D] to form the two triangles. const B = 1; const C = 2; const idx = pointCount + vertexOffset; indices.push(idx + 0); indices.push(idx + B); indices.push(idx + C); indices.push(idx + C); indices.push(idx + B); indices.push(idx + 3); vertexOffset += 4; } } function createSurfaces(polygon, options) { const stride = polygon.getStride(); // First we compute the positions of the top vertices (that make the 'floor'). // note that in some dataset, it's the roof and user needs to extrusionOffset down. const coordinates = polygon.getCoordinates(); const { flatCoordinates, holes } = createFloorVertices({ coordinates, stride, ignoreZ: options.ignoreZ ?? false, offset: options.origin ?? ZERO, elevation: options.elevation }); const pointCount = flatCoordinates.length / 3; const triangles = triangulate(flatCoordinates, holes); if (options.extrusionOffset != null) { createRoof(flatCoordinates, pointCount, triangles, options.extrusionOffset); createWallForRings(flatCoordinates, 0, holes[0] || pointCount, triangles, options.extrusionOffset); for (let i = 0; i < holes.length; i++) { createWallForRings(flatCoordinates, holes[i], holes[i + 1] || pointCount, triangles, options.extrusionOffset); } } const positions = new Float32Array(flatCoordinates); const indices = positions.length <= 65536 ? new Uint16Array(triangles) : new Uint32Array(triangles); return { positions, indices }; } const tempOrigin = new Vector3(); function createPositionBuffer(coordinates, options) { const bufferSize = 3 * coordinates.length; const result = new Float32Array(bufferSize); const origin = tempOrigin; const ignoreZ = options.ignoreZ; if (options.origin) { origin.copy(options.origin); } else { origin.set(0, 0, 0); } for (let i = 0; i < coordinates.length; i++) { const p = coordinates[i]; const i0 = i * 3; const x = p[0]; const y = p[1]; const z = ignoreZ === true ? 0 : p[2] ?? 0; result[i0 + 0] = x - origin.x; result[i0 + 1] = y - origin.y; result[i0 + 2] = z - origin.z; } return result; } /** * Generates three.js meshes from OpenLayers geometries. * * Supported geometries: * - Point / MultiPoint * - LineString / MultiLineString * - Polygon / MultiPolygon, 2D or 3D (extruded). * * Important note: features with the same styles will share the same material instance, to * avoid duplication and improve performance. This means that modifying the material will * affect all geometries that use it. */ export default class GeometryConverter extends EventDispatcher { _materialCache = new Map(); _downloadQueue = new RequestQueue(); _downloadedTextures = new Map(); _disposed = false; constructor(options) { super(); this._shadedSurfaceMaterialGenerator = options?.shadedSurfaceMaterialGenerator ?? this.getShadedSurfaceMaterial.bind(this); this._unshadedSurfaceMaterialGenerator = options?.unshadedSurfaceMaterialGenerator ?? this.getUnshadedSurfaceMaterial.bind(this); this._lineMaterialGenerator = options?.lineMaterialGenerator ?? this.getLineMaterial.bind(this); this._pointMaterialGenerator = options?.pointMaterialGenerator ?? this.getSpriteMaterial.bind(this); } /** * Gets whether this generator is disposed. A disposed generator can no longer be used. */ get disposed() { return this._disposed; } get materialCount() { return this._materialCache.size; } // Convenience overloads /** * Converts a {@link Point}. * @param geometry - The `Point` to convert. * @param options - The options. */ /** * Converts a {@link MultiPoint}. * @param geometry - The `MultiPoint` to convert. * @param options - The options. */ /** * Converts a {@link MultiPoint} or {@link Point}. * @param geometry - The `MultiPoint` or `Point` to convert. * @param options - The options. */ /** * Converts a {@link Polygon}. * @param geometry - The `Polygon` to convert. * @param options - The options. */ /** * Converts a {@link MultiPolygon}. * * Note: if the `MultiPolygon` has only one polygon, then a {@link PolygonMesh} is returned instead of a {@link MultiPolygonMesh}. * @param geometry - The `MultiPolygon` to convert. * @param options - The options. */ /** * Converts a {@link Polygon}. * @param geometry - The `Polygon` to convert. * @param options - The options. */ /** * Converts a {@link LineString}. * @param geometry - The `LineString` to convert. * @param options - The options. */ /** * Converts a {@link MultiLineString}. * * Note: if the `MultiLineString` has only one polygon, then a {@link LineStringMesh} is returned instead of a {@link MultiLineStringMesh}. * @param geometry - The `MultiLineString` to convert. * @param options - The options. */ /** * Converts a {@link MultiLineString} or {@link LineString}. * * Note: if the `MultiLineString` has only one polygon, then a {@link LineStringMesh} is returned instead of a {@link MultiLineStringMesh}. * @param geometry - The `MultiLineString` or `LineString` to convert. * @param options - The options. */ /** * Create 3D objects from the input geometry and options. * @param geometry - The geometry to transform. * @param options - The options. * @returns The generated 3D object(s). */ build(geometry, options) { options = options ?? {}; this.setDefaultOrigin(geometry, options); let result; switch (geometry.getType()) { case 'LineString': result = this.buildLineString(geometry, options); break; case 'MultiLineString': result = this.buildMultiLineString(geometry, options); break; case 'Point': result = this.buildPoint(geometry, options); break; case 'MultiPoint': result = this.buildMultiPoint(geometry, options); break; case 'Polygon': result = this.buildPolygon(geometry, options); break; case 'MultiPolygon': result = this.buildMultiPolygon(geometry, options); break; default: throw new Error('unimplemented'); } this.finalize(result, options); return result; } updatePolygonMesh(mesh, options) { if (options.stroke && mesh.linearRings == null) { // If the style is added, we have to create the rings const rings = this.getPolygonRings(mesh.source, options); mesh.linearRings = rings; } else if (!options.stroke && mesh.linearRings != null) { // If the style is removed, we have to remove the rings mesh.linearRings = null; } else if (mesh.linearRings) { // Else, just update the existing rings with the new style const stroke = getFullStrokeStyle(options.stroke); const lineMaterial = this._lineMaterialGenerator(stroke); mesh.linearRings.forEach(ring => ring.update({ material: lineMaterial, opacity: stroke.opacity, renderOrder: stroke.renderOrder })); } if (!options.fill && mesh.surface != null) { // If there is a surface, but no surface style, we must hide the existing surface mesh.surface.visible = false; } else if (options.fill && mesh.surface == null) { // If the surface does not exist, we have to create it const surface = this.getSurfaceMesh(mesh.source, options); mesh.surface = surface; } else if (options.fill && mesh.surface) { const fill = getFullFillStyle(options.fill); const surfacematerial = fill.shading === true ? this._shadedSurfaceMaterialGenerator(fill) : this._unshadedSurfaceMaterialGenerator(fill); mesh.surface.update({ material: surfacematerial, opacity: fill.opacity, renderOrder: fill.renderOrder }); } } updateMultiPolygonMesh(mesh, options) { mesh.traversePolygons(obj => this.updatePolygonMesh(obj, options)); } updateMultiLineStringMesh(mesh, options) { mesh.traverseLineStrings(obj => this.updateLineStringMesh(obj, options)); } updateLineStringMesh(mesh, options) { const style = getFullStrokeStyle(options); const lineMaterial = this._lineMaterialGenerator(style); mesh.update({ material: lineMaterial, opacity: style.opacity, renderOrder: style.renderOrder }); } updatePointMesh(mesh, style) { const fullStyle = getFullPointStyle(style); const material = this._pointMaterialGenerator(fullStyle); mesh.update({ material, pointSize: fullStyle.pointSize, opacity: fullStyle.opacity, renderOrder: fullStyle.renderOrder }); } updateSurfaceMesh(mesh, options) { if (mesh.parent == null) { throw new Error('mesh has no parent polygon'); } this.updatePolygonMesh(mesh.parent, options); } /** * Perform the last transformation on generated objects. * @param object - The object to finalize. * @param options - Options */ finalize(object, options) { if (options.origin) { object.position.copy(options.origin); } object.traverse(desc => { desc.updateMatrix(); }); object.updateMatrixWorld(true); } getSurfaceGeometry(polygon, options) { const { positions, indices } = createSurfaces(polygon, options); const surfaceGeometry = new BufferGeometry(); surfaceGeometry.setAttribute('position', new BufferAttribute(positions, 3)); surfaceGeometry.setIndex(new BufferAttribute(indices, 1)); surfaceGeometry.computeBoundingBox(); surfaceGeometry.computeBoundingSphere(); return surfaceGeometry; } /** * If origin has not be set, compute a default origin point by taking the first * coordinate of the geometry. */ setDefaultOrigin(geometry, options) { if (options.origin != null) { return; } let first; switch (geometry.getType()) { case 'LineString': case 'LinearRing': case 'Polygon': case 'MultiLineString': case 'MultiPolygon': first = geometry.getFirstCoordinate(); break; default: // TODO What to do with other types (GeometryCollection) ? return; } if (first != null) { const x = first[0] ?? 0; const y = first[1] ?? 0; const z = first[2] ?? 0; options.origin = new Vector3(x, y, z); } } getSurfaceMesh(polygon, options) { const fill = getFullFillStyle(options.fill); const material = fill.shading === true ? this._shadedSurfaceMaterialGenerator(fill) : this._unshadedSurfaceMaterialGenerator(fill); const geometry = this.getSurfaceGeometry(polygon, options); const surface = new SurfaceMesh({ geometry, material, opacity: fill.opacity }); // Surfaces can either be extruded (3D) or non-extruded (2D). if (fill.shading === true) { geometry.computeVertexNormals(); } surface.renderOrder = fill.renderOrder; return surface; } getPolygonRings(polygon, options) { const ringCount = polygon.getLinearRingCount(); const linearRings = []; for (let i = 0; i < ringCount; i++) { const inputRing = polygon.getLinearRing(i); if (inputRing) { const lineString = new LineString(inputRing.getCoordinates()); const ring = this.buildLineString(lineString, { origin: options.origin, ignoreZ: options.ignoreZ, ...options.stroke }); linearRings.push(ring); } } return linearRings; } buildPolygon(polygon, options) { let surface = undefined; let linearRings = undefined; if (options.fill) { surface = this.getSurfaceMesh(polygon, options); } // If line style is specified, we draw the linear rings of the polygon if (options.stroke) { linearRings = this.getPolygonRings(polygon, options); } const result = new PolygonMesh({ source: polygon, surface, linearRings, isExtruded: options.extrusionOffset != null }); return result; } buildMultiPolygon(multiPolygon, options) { const inputGeometries = multiPolygon.getPolygons(); // Optimization if (inputGeometries.length === 1) { return this.buildPolygon(inputGeometries[0], options); } const polygons = []; for (const polygon of inputGeometries) { const p = this.buildPolygon(polygon, options); polygons.push(p); } const result = new MultiPolygonMesh(polygons); return result; } buildPointMesh(point, options) { const style = getFullPointStyle(options); const material = this._pointMaterialGenerator(style); const coordinate = point.getCoordinates(); const pointMesh = new PointMesh({ material, opacity: style.opacity, pointSize: style.pointSize }); pointMesh.renderOrder = style.renderOrder; pointMesh.position.setX(coordinate[0] ?? 0); pointMesh.position.setY(coordinate[1] ?? 0); pointMesh.position.setZ(coordinate[2] ?? 0); return pointMesh; } buildPoint(point, options) { return this.buildPointMesh(point, options); } buildMultiPoint(multiPoint, options) { return new MultiPointMesh(multiPoint.getPoints().map(p => this.buildPointMesh(p, options))); } getShadedSurfaceMaterial(style) { if (style == null) { throw new Error('missing style'); } const key = hashStyle('shaded-surface', style); if (this._materialCache.has(key)) { return this._materialCache.get(key); } const { color, opacity, depthTest } = style; const material = new MeshLambertMaterial({ color, opacity, transparent: opacity < 1, side: DoubleSide, depthTest, depthWrite: depthTest }); this._materialCache.set(key, material); return material; } getUnshadedSurfaceMaterial(style) { if (style == null) { throw new Error('missing style'); } const key = hashStyle('unshaded-surface', style); if (this._materialCache.has(key)) { return this._materialCache.get(key); } const { color, opacity, depthTest } = style; const material = new MeshBasicMaterial({ color, opacity, transparent: opacity < 1, side: DoubleSide, depthTest, depthWrite: depthTest }); this._materialCache.set(key, material); return material; } getSpriteMaterial(style) { if (style == null) { throw new Error('missing style'); } // TODO support point shapes // TODO support image placement (hotspot) const styleKey = hashStyle('sprite', style); if (this._materialCache.has(styleKey)) { return this._materialCache.get(styleKey); } const { color, opacity, sizeAttenuation, depthTest } = style; const result = new SpriteMaterial({ color, opacity, transparent: true, sizeAttenuation, depthTest, depthWrite: depthTest, map: style.image != null ? isTexture(style.image) ? style.image : this.getCachedTexture(style.image) : null }); // Download image from URL if (typeof style.image === 'string' && result.map == null) { // Hide material until the image is loaded to avoid displaying a blank square. result.visible = false; // Download the image this.loadRemoteTexture(style.image).then(texture => { result.map = texture; result.needsUpdate = true; result.visible = true; result.transparent = true; }).catch(console.error); } this._materialCache.set(styleKey, result); return result; } getCachedTexture(url) { const cached = this._downloadedTextures.get(url); if (cached) { return cached; } return null; } loadRemoteTexture(url) { const cached = this._downloadedTextures.get(url); if (cached) { return Promise.resolve(cached); } return this._downloadQueue.enqueue({ id: url, request: () => this.fetchTexture(url) }); } fetchTexture(url) { // Download the image return Fetcher.texture(url, { flipY: true }).then(texture => { texture.colorSpace = SRGBColorSpace; this._downloadedTextures.set(url, texture); texture.generateMipmaps = true; this.dispatchEvent({ type: 'texture-loaded', texture }); return texture; }); } getLineMaterial(style) { if (style == null) { throw new Error('missing style'); } const styleKey = hashStyle('line', style); if (this._materialCache.has(styleKey)) { return this._materialCache.get(styleKey); } const { color, lineWidth, opacity, lineWidthUnits, depthTest } = style; const material = new LineMaterial({ color, linewidth: lineWidth, // Notice the different case opacity, transparent: opacity < 1, worldUnits: lineWidthUnits === 'world', depthTest, depthWrite: depthTest }); this._materialCache.set(styleKey, material); return material; } getLineGeometry(coordinates, options) { const result = new LineGeometry(); result.setPositions(createPositionBuffer(coordinates, options)); result.computeBoundingBox(); return result; } buildLineString(geometry, options) { const fullStyle = getFullStrokeStyle(options); const lineStringMesh = new LineStringMesh(this.getLineGeometry(geometry.getCoordinates(), options), this._lineMaterialGenerator(fullStyle), fullStyle.opacity); lineStringMesh.renderOrder = fullStyle.renderOrder; return lineStringMesh; } buildMultiLineString(geometry, options) { const lineStrings = geometry.getLineStrings(); // Optimization if (lineStrings.length === 1) { return this.buildLineString(lineStrings[0], options); } const meshes = []; for (const line of lineStrings) { const lineStringMesh = this.buildLineString(line, options); meshes.push(lineStringMesh); } return new MultiLineStringMesh(meshes); } /** * Disposes this generator and all cached materials. Once disposed, this generator cannot be used anymore. */ dispose({ disposeTextures = true, disposeMaterials = true }) { if (this._disposed) { return; } if (disposeTextures) { this._downloadedTextures.forEach(texture => texture.dispose()); } if (disposeMaterials) { this._materialCache.forEach(material => material.dispose()); } this._downloadedTextures.clear(); this._materialCache.clear(); } }