UNPKG

3d-tiles-renderer

Version:

https://github.com/AnalyticalGraphicsInc/3d-tiles/tree/master/specification

432 lines (285 loc) 10.9 kB
import { CanvasTexture, MathUtils, Vector3 } from 'three'; import { TiledImageSource } from './TiledImageSource.js'; import { ProjectionScheme } from '../utils/ProjectionScheme.js'; import { WGS84_ELLIPSOID } from '3d-tiles-renderer/three'; // TODO: Add support for limited bounds // TODO: Add support for padding of tiles to avoid clipping "wide" elements // TODO: Need to clip / fix geojson shapes across the 180 degree boundary // TODO: Add support for easy regeneration when colors / styles / geojson change // TODO: Consider option to support world-space thickness definitions. Eg world-space point size or line thickness in meters. // function for calculating the the change in arc length at a given cartographic point // in order to preserve a circular look when drawing points const _v0 = /* @__PURE__ */ new Vector3(); const _v1 = /* @__PURE__ */ new Vector3(); function calculateArcRatioAtPoint( ellipsoid, lat, lon ) { const DELTA = 0.01; ellipsoid.getCartographicToPosition( lat, lon, 0, _v0 ); ellipsoid.getCartographicToPosition( lat + DELTA, lon, 0, _v1 ); const latDelta = _v0.distanceTo( _v1 ); ellipsoid.getCartographicToPosition( lat, lon + DELTA, 0, _v1 ); const lonDelta = _v0.distanceTo( _v1 ); return lonDelta / latDelta; } export class GeoJSONImageSource extends TiledImageSource { constructor( { geojson = null, url = null, // URL or GeoJson object can be provided tileDimension = 256, levels = 20, pointRadius = 6, strokeStyle = 'white', strokeWidth = 2, fillStyle = 'rgba( 255, 255, 255, 0.5 )', ...rest } = {} ) { super( rest ); this.geojson = geojson; this.url = url; this.tileDimension = tileDimension; this.levels = levels; this.pointRadius = pointRadius; this.strokeStyle = strokeStyle; this.strokeWidth = strokeWidth; this.fillStyle = fillStyle; } async init() { // configure tiling to content bounds and levels const { tiling, levels, tileDimension, geojson, url } = this; const projection = new ProjectionScheme(); tiling.setProjection( projection ); tiling.setContentBounds( ...projection.getBounds() ); tiling.generateLevels( levels, projection.tileCountX, projection.tileCountY, { tilePixelWidth: tileDimension, tilePixelHeight: tileDimension, }, ); // If a URL was provided and no geojson object yet, fetch it now (use fetchData so overlay can inject headers) if ( ! geojson && url ) { const res = await this.fetchData( url ); this.geojson = await res.json(); } // TODO: If geojson present, compute bounds from data (with padding) and set as content bounds. // Falls back to full projection bounds if no geojson or unable to compute an extent. // seems that this approach to optimize rendering by defining bounds // doesn't work if not using a very large offset for the bounds, maybe an error? const geoBounds = this._geoJSONBounds( 50 ).map( v => v * MathUtils.DEG2RAD ); this.tiling.setContentBounds( ...geoBounds ); } // main fetch per tile - > returns .Texture async fetchItem( tokens, signal ) { return this.drawCanvasImage( tokens ); } drawCanvasImage( tokens ) { const { tiling, tileDimension, geojson } = this; const [ x, y, level ] = tokens; const tileBoundsDeg = tiling.getTileBounds( x, y, level, false, false ).map( v => MathUtils.RAD2DEG * v ); // create canvas const canvas = document.createElement( 'canvas' ); canvas.width = tileDimension; canvas.height = tileDimension; // draw features const ctx = canvas.getContext( '2d' ); const features = this._featuresFromGeoJSON( geojson ); for ( let i = 0; i < features.length; i ++ ) { const feature = features[ i ]; if ( this._featureIntersectsTile( feature, tileBoundsDeg ) ) { this._drawFeatureOnCanvas( ctx, feature, tileBoundsDeg, canvas.width, canvas.height ); } } const tex = new CanvasTexture( canvas ); tex.needsUpdate = true; return tex; } // bbox quick test in projected units _featureIntersectsTile( feature, boundsDeg ) { const featureBoundsDeg = this._getFeatureBounds( feature ); if ( ! featureBoundsDeg ) { return false; } // check for intersection between bounds const [ fminX, fminY, fmaxX, fmaxY ] = featureBoundsDeg; const [ minX, minY, maxX, maxY ] = boundsDeg; return ! ( fmaxX < minX || fminX > maxX || fmaxY < minY || fminY > maxY ); } _getFeatureBounds( feature ) { const { geometry } = feature; if ( ! geometry ) { return null; } const { type, coordinates } = geometry; let minLon = Infinity; let minLat = Infinity; let maxLon = - Infinity; let maxLat = - Infinity; const expandBoundsByPoint = ( lon, lat ) => { minLon = Math.min( minLon, lon ); maxLon = Math.max( maxLon, lon ); minLat = Math.min( minLat, lat ); maxLat = Math.max( maxLat, lat ); }; if ( type === 'Point' ) { expandBoundsByPoint( coordinates[ 0 ], coordinates[ 1 ] ); } else if ( type === 'MultiPoint' || type === 'LineString' ) { coordinates.forEach( c => expandBoundsByPoint( c[ 0 ], c[ 1 ] ) ); } else if ( type === 'MultiLineString' || type === 'Polygon' ) { coordinates.forEach( ring => ring.forEach( c => expandBoundsByPoint( c[ 0 ], c[ 1 ] ) ) ); } else if ( type === 'MultiPolygon' ) { coordinates.forEach( polygon => polygon.forEach( ring => ring.forEach( c => expandBoundsByPoint( c[ 0 ], c[ 1 ] ) ) ), ); } return [ minLon, minLat, maxLon, maxLat ]; } // Normalize top-level geojson into an array of Feature objects _featuresFromGeoJSON( root ) { const type = root.type; const geomTypes = new Set( [ 'Point', 'MultiPoint', 'LineString', 'MultiLineString', 'Polygon', 'MultiPolygon' ] ); if ( type === 'FeatureCollection' ) { return root.features; } else if ( type === 'Feature' ) { return [ root ]; } else if ( type === 'GeometryCollection' ) { return root.geometries.map( g => ( { type: 'Feature', geometry: g, properties: {} } ) ); } else if ( geomTypes.has( type ) ) { return [ { type: 'Feature', geometry: root, properties: {} } ]; } else { return []; } } // draw feature on canvas ( assumes intersects already ) _drawFeatureOnCanvas( ctx, feature, tileBoundsDeg, width, height ) { const { geometry = null, properties = {} } = feature; if ( ! geometry ) { // A feature may have null geometry in GeoJSON return; } const [ minLonDeg, minLatDeg, maxLonDeg, maxLatDeg ] = tileBoundsDeg; const strokeStyle = properties.strokeStyle || this.strokeStyle; const fillStyle = properties.fillStyle || this.fillStyle; const pointRadius = properties.pointRadius || this.pointRadius; const strokeWidth = properties.strokeWidth || this.strokeWidth; ctx.save(); ctx.strokeStyle = strokeStyle; ctx.fillStyle = fillStyle; ctx.lineWidth = strokeWidth; // Compute pixel from cartographic coordinates and tile bounds const arr = new Array( 2 ); const projectPoint = ( lon, lat, target = arr ) => { // canvas y origin is top, projection y increases north -> flip const x = MathUtils.mapLinear( lon, minLonDeg, maxLonDeg, 0, width ); const y = height - MathUtils.mapLinear( lat, minLatDeg, maxLatDeg, 0, height ); // round to integer to gain performance // https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Optimizing_canvas#avoid_floating-point_coordinates_and_use_integers_instead target[ 0 ] = Math.round( x ); target[ 1 ] = Math.round( y ); return target; }; const calculateAspectRatio = ( lon, lat ) => { // calculates the aspect ratio with which to draw points const latRad = lat * MathUtils.DEG2RAD; const lonRad = lon * MathUtils.DEG2RAD; const pxLat = ( maxLatDeg - minLatDeg ) / height; const pxLon = ( maxLonDeg - minLonDeg ) / width; const pixelRatio = pxLon / pxLat; // TODO: this should use the ellipsoid defined on the relevant tiles renderer return pixelRatio * calculateArcRatioAtPoint( WGS84_ELLIPSOID, latRad, lonRad ); }; const type = geometry.type; if ( type === 'Point' ) { const [ lon, lat ] = geometry.coordinates; const [ px, py ] = projectPoint( lon, lat ); const drawRatio = calculateAspectRatio( lon, lat ); ctx.beginPath(); ctx.ellipse( px, py, pointRadius / drawRatio, pointRadius, 0, 0, Math.PI * 2 ); ctx.fill(); ctx.stroke(); } else if ( type === 'MultiPoint' ) { geometry.coordinates.forEach( ( [ lon, lat ] ) => { const [ px, py ] = projectPoint( lon, lat ); const drawRatio = calculateAspectRatio( lon, lat ); ctx.beginPath(); ctx.ellipse( px, py, pointRadius / drawRatio, pointRadius, 0, 0, Math.PI * 2 ); ctx.fill(); ctx.stroke(); } ); } else if ( type === 'LineString' ) { ctx.beginPath(); geometry.coordinates.forEach( ( [ lon, lat ], i ) => { const [ px, py ] = projectPoint( lon, lat ); if ( i === 0 ) { ctx.moveTo( px, py ); } else { ctx.lineTo( px, py ); } } ); ctx.stroke(); } else if ( type === 'MultiLineString' ) { ctx.beginPath(); geometry.coordinates.forEach( ( line ) => { line.forEach( ( [ lon, lat ], i ) => { const [ px, py ] = projectPoint( lon, lat ); if ( i === 0 ) { ctx.moveTo( px, py ); } else { ctx.lineTo( px, py ); } } ); } ); ctx.stroke(); } else if ( type === 'Polygon' ) { ctx.beginPath(); geometry.coordinates.forEach( ( ring, rIndex ) => { ring.forEach( ( [ lon, lat ], i ) => { const [ px, py ] = projectPoint( lon, lat ); if ( i === 0 ) { ctx.moveTo( px, py ); } else { ctx.lineTo( px, py ); } } ); ctx.closePath(); } ); ctx.fill( 'evenodd' ); ctx.stroke(); } else if ( type === 'MultiPolygon' ) { geometry.coordinates.forEach( ( polygon ) => { ctx.beginPath(); polygon.forEach( ( ring, rIndex ) => { ring.forEach( ( [ lon, lat ], i ) => { const [ px, py ] = projectPoint( lon, lat ); if ( i === 0 ) { ctx.moveTo( px, py ); } else { ctx.lineTo( px, py ); } } ); ctx.closePath(); } ); ctx.fill( 'evenodd' ); ctx.stroke(); } ); } ctx.restore(); } // Compute geographic bounds in degrees from current geojson. _geoJSONBounds() { // TODO: add support for padding the bounding boxes const features = this._featuresFromGeoJSON( this.geojson ); let minLon = Infinity; let minLat = Infinity; let maxLon = - Infinity; let maxLat = - Infinity; features.forEach( feature => { const [ fMinLon, fMinLat, fMaxLon, fMaxLat ] = this._getFeatureBounds( feature ); minLon = Math.min( minLon, fMinLon ); minLat = Math.min( minLat, fMinLat ); maxLon = Math.max( maxLon, fMaxLon ); maxLat = Math.max( maxLat, fMaxLat ); } ); return [ minLon, minLat, maxLon, maxLat ]; } }