diff --git a/example/three/googleMapsExample.js b/example/three/googleMapsExample.js index a39feb113..af7200bfb 100644 --- a/example/three/googleMapsExample.js +++ b/example/three/googleMapsExample.js @@ -14,7 +14,10 @@ import { GLTFExtensionsPlugin, BatchedTilesPlugin, CesiumIonAuthPlugin, + ImageOverlayPlugin, + PMTilesOverlay, } from '3d-tiles-renderer/plugins'; +import { MVTAnnotationsPlugin } from '../../src/three/plugins/mvt/MVTAnnotationsPlugin.js'; import { Scene, WebGLRenderer, @@ -89,6 +92,17 @@ function reinstantiateTiles() { } + const overlay = new PMTilesOverlay( { + url: 'https://data.source.coop/protomaps/openstreetmap/v4.pmtiles', + } ); + + tiles.registerPlugin( new ImageOverlayPlugin( { overlays: [ overlay ], renderer } ) ); + tiles.registerPlugin( new MVTAnnotationsPlugin( { + overlay, + camera: transition.camera, + scene, + } ) ); + tiles.group.rotation.x = - Math.PI / 2; scene.add( tiles.group ); @@ -123,6 +137,7 @@ function init() { tiles.deleteCamera( prevCamera ); tiles.setCamera( camera ); controls.setCamera( camera ); + tiles.getPluginByName( 'MVT_ANNOTATIONS_PLUGIN' )?.setCamera( camera ); } ); diff --git a/example/three/pmtiles.js b/example/three/pmtiles.js index 92e1d1e0d..f4560f422 100644 --- a/example/three/pmtiles.js +++ b/example/three/pmtiles.js @@ -10,6 +10,7 @@ import { PMTilesOverlay, GeneratedSurfacePlugin, } from '3d-tiles-renderer/plugins'; +import { MVTAnnotationsPlugin } from '../../src/three/plugins/mvt/MVTAnnotationsPlugin.js'; import GUI from 'three/addons/libs/lil-gui.module.min.js'; // Protomaps "Light" theme β€” from protomaps/basemaps flavors.ts @@ -139,6 +140,12 @@ function init() { overlays: [ overlay ], renderer, } ) ); + const annotationsPlugin = new MVTAnnotationsPlugin( { + overlay, + camera, + } ); + // annotationsPlugin.getAnnotation = ( layerName ) => layerName === 'places'; + tiles.registerPlugin( annotationsPlugin ); tiles.setCamera( camera ); tiles.group.rotation.x = - Math.PI / 2; diff --git a/src/core/renderer/tiles/TilesRendererBase.js b/src/core/renderer/tiles/TilesRendererBase.js index 49fd42270..5ea98cde6 100644 --- a/src/core/renderer/tiles/TilesRendererBase.js +++ b/src/core/renderer/tiles/TilesRendererBase.js @@ -1064,21 +1064,31 @@ export class TilesRendererBase { disposeTile( tile ) { - // TODO: are these necessary? Are we disposing tiles when they are currently visible? + // Need to mirror the "traverseFunctions" behavior for empty tiles (eg internal tile sets) if ( tile.traversal.visible ) { - this.invokeOnePlugin( plugin => plugin.setTileVisible && plugin.setTileVisible( tile, false ) ); + if ( tile.internal.hasRenderableContent ) { + + this.invokeOnePlugin( plugin => plugin.setTileVisible && plugin.setTileVisible( tile, false ) ); + + } else { + + this.invokeOnePlugin( plugin => plugin.setEmptyTileVisible && plugin.setEmptyTileVisible( tile, false ) ); + + } + tile.traversal.visible = false; } - if ( tile.traversal.active ) { + if ( tile.traversal.active && tile.internal.hasRenderableContent ) { this.invokeOnePlugin( plugin => plugin.setTileActive && plugin.setTileActive( tile, false ) ); - tile.traversal.active = false; } + tile.traversal.active = false; + const { scene } = tile.engineData; if ( scene ) { diff --git a/src/three/plugins/mvt/AnnotationGlyphAtlasTexture.js b/src/three/plugins/mvt/AnnotationGlyphAtlasTexture.js new file mode 100644 index 000000000..942aee3b1 --- /dev/null +++ b/src/three/plugins/mvt/AnnotationGlyphAtlasTexture.js @@ -0,0 +1,76 @@ +import { GlyphAtlasTexture } from './GlyphAtlasTexture.js'; +import { CATEGORY_COLORS } from './annotationColors.js'; + +const CATEGORY_EMOJI = { + tangerine: '🍴', + green: '🌳', + lapis: '✈️', + slategray: 'πŸŽ“', + blue: 'πŸ›’', + pink: '🎭', + red: 'πŸ₯', + turquoise: '🏨', +}; + +const GLYPH_SIZE = 64; +// 16 slots gives a 4Γ—4 grid (256Γ—256 canvas) β€” room to add more categories later +const GLYPH_SLOT_COUNT = 16; + +export class AnnotationGlyphAtlasTexture extends GlyphAtlasTexture { + + constructor() { + + super( GLYPH_SLOT_COUNT, GLYPH_SIZE ); + + const font = `${ Math.round( GLYPH_SIZE * 0.65 ) }px serif`; + for ( const [ category, emoji ] of Object.entries( CATEGORY_EMOJI ) ) { + + this._draw( category, ( ctx, x, y, w, h ) => { + + const cx = x + w / 2; + const cy = y + h / 2; + ctx.font = font; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + // strokeText has no effect on color emoji β€” use stacked drop-shadow + // filters (which follow actual pixel shape) to build a solid white outline + const r = 3; + ctx.filter = `drop-shadow( ${ r }px 0px 1px white) drop-shadow(-${ r }px 0px 1px white) drop-shadow(0px ${ r }px 1px white) drop-shadow(0px -${ r }px 1px white)`; + ctx.fillText( emoji, cx, cy ); + ctx.filter = 'none'; + ctx.fillText( emoji, cx, cy ); + + } ); + + } + + } + + // Returns { uvX, uvY } β€” top-left corner of the slot in GPU texture space, + // with flipY applied (canvas top β†’ V=1). Returns null for unknown categories. + getCategoryUV( category ) { + + const slot = this.get( category ); + if ( slot === null ) return null; + + const { width, height } = this.image; + return { + uvX: slot.x / width, + uvY: ( height - slot.y ) / height, + }; + + } + + // UV size of one slot. Y is negative so gl_PointCoord.y (0=top β†’ 1=bottom) + // scans downward in texture space (decreasing V). + get glyphCellUVSize() { + + const { width, height } = this.image; + return { u: this.slotSize / width, v: - this.slotSize / height }; + + } + +} + +export { CATEGORY_EMOJI }; diff --git a/src/three/plugins/mvt/AnnotationsPoints.js b/src/three/plugins/mvt/AnnotationsPoints.js new file mode 100644 index 000000000..3b4ba6d6c --- /dev/null +++ b/src/three/plugins/mvt/AnnotationsPoints.js @@ -0,0 +1,184 @@ +import { BufferAttribute, BufferGeometry, Color, Points } from 'three'; +import { getAnnotationColor, getAnnotationCategory } from './annotationColors.js'; + +const _color = /* @__PURE__ */ new Color(); + +export class AnnotationsPoints extends Points { + + constructor( material ) { + + super( new BufferGeometry(), material ); + + this.renderOrder = 1000; + this.frustumCulled = false; + + this.fadeInDuration = 0.3; + this.fadeOutDuration = 0.3; + + // Map β€” keyed by stable id, not object reference. + // entry: { item, fade: 0..1, state: 'in' | 'visible' | 'out' } + this._entryMap = new Map(); + this._orderedEntries = []; + this._structureDirty = false; + + } + + // Call every frame. visibleItems is DelayedScreenOccupationManager.visible (a Set). + // Returns true while any point is still animating. + update( dt, visibleItems, glyphAtlas ) { + + // Build idβ†’item map for the current visible set so we can look up by id. + const visibleById = new Map(); + for ( const item of visibleItems ) { + + visibleById.set( item.id, item ); + + } + + // Add new items, update LoD-swapped references, reverse in-progress fade-outs. + for ( const [ id, item ] of visibleById ) { + + const existing = this._entryMap.get( id ); + if ( ! existing ) { + + const entry = { item, fade: 0, state: 'in' }; + this._entryMap.set( id, entry ); + this._orderedEntries.push( entry ); + this._structureDirty = true; + + } else { + + existing.item = item; // keep reference fresh (LoD swap) + if ( existing.state === 'out' ) existing.state = 'in'; + + } + + } + + // Start fade-out for items that left the visible set. + for ( const [ id, entry ] of this._entryMap ) { + + if ( ! visibleById.has( id ) && entry.state !== 'out' ) { + + entry.state = 'out'; + + } + + } + + // Tick fades; collect fully-faded-out items for removal. + const toRemove = []; + for ( const [ id, entry ] of this._entryMap ) { + + if ( entry.state === 'in' ) { + + entry.fade = Math.min( 1, entry.fade + dt / this.fadeInDuration ); + if ( entry.fade >= 1 ) entry.state = 'visible'; + + } else if ( entry.state === 'out' ) { + + entry.fade = Math.max( 0, entry.fade - dt / this.fadeOutDuration ); + if ( entry.fade <= 0 ) toRemove.push( id ); + + } + + } + + if ( toRemove.length > 0 ) { + + for ( const id of toRemove ) this._entryMap.delete( id ); + this._orderedEntries = this._orderedEntries.filter( e => this._entryMap.has( e.item.id ) ); + this._structureDirty = true; + + } + + const origin = this.position; + if ( this._structureDirty ) { + + this._rebuildGeometry( origin, glyphAtlas ); + this._structureDirty = false; + + } else { + + this._updateDynamic( origin ); + + } + + for ( const entry of this._entryMap.values() ) { + + if ( entry.state !== 'visible' ) return true; + + } + + return false; + + } + + _rebuildGeometry( origin, glyphAtlas ) { + + const entries = this._orderedEntries; + const count = entries.length; + + this.geometry.dispose(); + + const posAttr = new BufferAttribute( new Float32Array( count * 3 ), 3 ); + const colorAttr = new BufferAttribute( new Float32Array( count * 3 ), 3 ); + const glyphUVAttr = new BufferAttribute( new Float32Array( count * 2 ), 2 ); + const alphaAttr = new BufferAttribute( new Float32Array( count ), 1 ); + + this.geometry.setAttribute( 'position', posAttr ); + this.geometry.setAttribute( 'color', colorAttr ); + this.geometry.setAttribute( 'glyphUV', glyphUVAttr ); + this.geometry.setAttribute( 'alpha', alphaAttr ); + + for ( let i = 0; i < count; i ++ ) { + + const { item, fade } = entries[ i ]; + const p = item.position; + posAttr.array[ i * 3 + 0 ] = p.x - origin.x; + posAttr.array[ i * 3 + 1 ] = p.y - origin.y; + posAttr.array[ i * 3 + 2 ] = p.z - origin.z; + + getAnnotationColor( item.layer, item.properties, _color ); + colorAttr.array[ i * 3 + 0 ] = _color.r; + colorAttr.array[ i * 3 + 1 ] = _color.g; + colorAttr.array[ i * 3 + 2 ] = _color.b; + + const category = getAnnotationCategory( item.layer, item.properties ); + const uv = category !== null ? glyphAtlas.getCategoryUV( category ) : null; + glyphUVAttr.array[ i * 2 + 0 ] = uv !== null ? uv.uvX : - 1; + glyphUVAttr.array[ i * 2 + 1 ] = uv !== null ? uv.uvY : - 1; + + alphaAttr.array[ i ] = fade; + + } + + } + + _updateDynamic( origin ) { + + const entries = this._orderedEntries; + const count = entries.length; + if ( count === 0 ) return; + + const posAttr = this.geometry.getAttribute( 'position' ); + const alphaAttr = this.geometry.getAttribute( 'alpha' ); + if ( ! posAttr || ! alphaAttr ) return; + + for ( let i = 0; i < count; i ++ ) { + + const { item, fade } = entries[ i ]; + const p = item.position; + posAttr.array[ i * 3 + 0 ] = p.x - origin.x; + posAttr.array[ i * 3 + 1 ] = p.y - origin.y; + posAttr.array[ i * 3 + 2 ] = p.z - origin.z; + alphaAttr.array[ i ] = fade; + + } + + posAttr.needsUpdate = true; + alphaAttr.needsUpdate = true; + + } + +} diff --git a/src/three/plugins/mvt/CirclePointsMaterial.js b/src/three/plugins/mvt/CirclePointsMaterial.js new file mode 100644 index 000000000..29eb44ed2 --- /dev/null +++ b/src/three/plugins/mvt/CirclePointsMaterial.js @@ -0,0 +1,102 @@ +import { PointsMaterial, Vector2 } from 'three'; + +export class CirclePointsMaterial extends PointsMaterial { + + constructor( parameters ) { + + super( parameters ); + + this.alphaToCoverage = true; + this.vertexColors = true; + this.transparent = true; + + // glyph atlas state β€” set before first render; setters sync to uniforms after compile + this._glyphTexture = null; + this._glyphCellSize = new Vector2(); + this._uniforms = null; + + this.onBeforeCompile = ( shader ) => { + + shader.uniforms.glyphAtlas = { value: this._glyphTexture }; + // Pass the same Vector2 reference so in-place updates are reflected automatically + shader.uniforms.glyphCellSize = { value: this._glyphCellSize }; + this._uniforms = shader.uniforms; + + // Vertex: declare attribute + varying, assign in main + shader.vertexShader = shader.vertexShader.replace( + '#include ', + /* glsl */` + #include + attribute vec2 glyphUV; + attribute float alpha; + varying vec2 vGlyphUV; + varying float vAlpha; + ` + ); + + shader.vertexShader = shader.vertexShader.replace( + '#include ', + /* glsl */` + #include + vGlyphUV = glyphUV; + vAlpha = alpha; + ` + ); + + // Fragment: declare varying + uniforms, circle mask + glyph composite + shader.fragmentShader = shader.fragmentShader.replace( + '#include ', + /* glsl */` + #include + uniform sampler2D glyphAtlas; + uniform vec2 glyphCellSize; + varying vec2 vGlyphUV; + varying float vAlpha; + ` + ); + + shader.fragmentShader = shader.fragmentShader.replace( + '#include ', + /* glsl */` + float _dist = length( gl_PointCoord - 0.5 ); + float _fw = fwidth( _dist ); + float _circleAlpha = 1.0 - smoothstep( 0.5 - _fw * 2.0, 0.5, _dist ); + if ( _circleAlpha < 0.001 ) discard; + diffuseColor.a *= _circleAlpha; + if ( vGlyphUV.x >= 0.0 ) { + vec4 _glyph = texture2D( glyphAtlas, vGlyphUV + gl_PointCoord * glyphCellSize ); + outgoingLight = mix( outgoingLight, _glyph.rgb, _glyph.a ); + // outgoingLight = _glyph; + // diffuseColor.a = _glyph.a; + } + diffuseColor.a *= vAlpha; + #include + ` + ); + + }; + + this.customProgramCacheKey = () => 'CirclePointsMaterial'; + + } + + get glyphTexture() { + + return this._glyphTexture; + + } + + set glyphTexture( v ) { + + this._glyphTexture = v; + if ( this._uniforms ) this._uniforms.glyphAtlas.value = v; + + } + + get glyphCellSize() { + + return this._glyphCellSize; + + } + +} diff --git a/src/three/plugins/mvt/DelayedScreenOccupationManager.js b/src/three/plugins/mvt/DelayedScreenOccupationManager.js new file mode 100644 index 000000000..c8f0e924e --- /dev/null +++ b/src/three/plugins/mvt/DelayedScreenOccupationManager.js @@ -0,0 +1,218 @@ +import { EventDispatcher } from 'three'; +import { ScreenOccupationManager } from './ScreenOccupationManager.js'; + +export class DelayedScreenOccupationManager extends EventDispatcher { + + get matrix() { + + return this._inner.matrix; + + } + + set matrix( v ) { + + this._inner.matrix = v; + + } + + get cameraPosition() { + + return this._inner.cameraPosition; + + } + + set cameraPosition( v ) { + + this._inner.cameraPosition = v; + + } + + get resolution() { + + return this._inner.resolution; + + } + + get size() { + + return this._inner.size; + + } + + set size( v ) { + + this._inner.size = v; + + } + + get cells() { + + return this._inner.cells; + + } + + getById( id ) { + + return this._inner.getById( id ); + + } + + get sortCallback() { + + return this._inner.sortCallback; + + } + + set sortCallback( v ) { + + this._inner.sortCallback = v; + + } + + get buffer() { + + return this._inner.buffer; + + } + + set buffer( v ) { + + this._inner.buffer = v; + + } + + constructor() { + + super(); + + this._inner = new ScreenOccupationManager(); + + this.visible = new Set(); + this.showDelay = 0.15; + this.hideDelay = 0.25; + + // item -> timer + this._showTimers = new Map(); + this._hideTimers = new Map(); + this._lastUpdateTime = - 1; + + this.added = new Set(); + this.removed = new Set(); + + this._inner.addEventListener( 'added', ( { items } ) => { + + const { _showTimers, _hideTimers, visible } = this; + for ( const item of items ) { + + _hideTimers.delete( item ); + if ( ! visible.has( item ) ) { + + _showTimers.set( item, 0 ); + + } + + } + + } ); + + this._inner.addEventListener( 'removed', ( { items } ) => { + + const { _showTimers, _hideTimers, visible } = this; + for ( const item of items ) { + + _showTimers.delete( item ); + if ( visible.has( item ) ) { + + _hideTimers.set( item, 0 ); + + } + + } + + } ); + + } + + register( item ) { + + return this._inner.register( item ); + + } + + unregister( item ) { + + this._inner.unregister( item ); + + } + + update() { + + const now = performance.now() / 1000; + const dt = this._lastUpdateTime < 0 ? 0 : Math.min( now - this._lastUpdateTime, 0.1 ); + this._lastUpdateTime = now; + + // fires 'added'/'removed' synchronously, populating the timers + this._inner.update(); + + const { + _showTimers, + _hideTimers, + visible, + added, + removed, + showDelay, + hideDelay, + } = this; + + added.clear(); + removed.clear(); + + for ( const [ item, elapsed ] of _showTimers ) { + + const next = elapsed + dt; + if ( next >= showDelay ) { + + _showTimers.delete( item ); + visible.add( item ); + added.add( item ); + + } else { + + _showTimers.set( item, next ); + + } + + } + + for ( const [ item, elapsed ] of _hideTimers ) { + + const next = elapsed + dt; + if ( next >= hideDelay ) { + + _hideTimers.delete( item ); + visible.delete( item ); + removed.add( item ); + + } else { + + _hideTimers.set( item, next ); + + } + + } + + if ( added.size > 0 ) { + + this.dispatchEvent( { type: 'added', items: added } ); + + } + + if ( removed.size > 0 ) { + + this.dispatchEvent( { type: 'removed', items: removed } ); + + } + + } + +} diff --git a/src/three/plugins/mvt/FontAtlasTexture.js b/src/three/plugins/mvt/FontAtlasTexture.js new file mode 100644 index 000000000..bf048d543 --- /dev/null +++ b/src/three/plugins/mvt/FontAtlasTexture.js @@ -0,0 +1,73 @@ +import { GlyphAtlasTexture } from './GlyphAtlasTexture.js'; + +export class FontAtlasTexture extends GlyphAtlasTexture { + + constructor( slotCount, slotSize, font, color = 'white' ) { + + super( slotCount, slotSize ); + + this.font = font; + this.color = color; + this._refCounts = new Map(); + this._evictionQueue = new Set(); + + } + + // Increments the reference count for char, drawing it into the atlas if not already present. + // Returns the slot { x, y, w, h }. + add( char ) { + + const { _refCounts, _evictionQueue, font, color } = this; + const count = ( _refCounts.get( char ) ?? 0 ) + 1; + _refCounts.set( char, count ); + + if ( this.has( char ) ) { + + // already in atlas β€” pull it out of the eviction queue + _evictionQueue.delete( char ); + + } else { + + if ( this.isFull ) { + + const candidate = _evictionQueue.values().next().value; + if ( candidate === undefined ) { + + throw new Error( 'FontAtlasTexture: atlas is full.' ); + + } + + _evictionQueue.delete( candidate ); + _refCounts.delete( candidate ); + super.release( candidate ); + + } + + this.drawGlyph( char, char, font, color ); + + } + + return this.get( char ); + + } + + // Decrements the reference count for char. The slot is kept in the atlas + // and only evicted when space is needed for a new glyph. + release( char ) { + + const { _refCounts, _evictionQueue } = this; + const count = _refCounts.get( char ) ?? 0; + if ( count <= 1 ) { + + _refCounts.set( char, 0 ); + _evictionQueue.add( char ); + + } else { + + _refCounts.set( char, count - 1 ); + + } + + } + +} diff --git a/src/three/plugins/mvt/GlyphAtlasTexture.js b/src/three/plugins/mvt/GlyphAtlasTexture.js new file mode 100644 index 000000000..a8e1fbd42 --- /dev/null +++ b/src/three/plugins/mvt/GlyphAtlasTexture.js @@ -0,0 +1,224 @@ +import { CanvasTexture } from 'three'; + +export class GlyphAtlasTexture extends CanvasTexture { + + constructor( slotCount, slotSize ) { + + super( null ); + + this.slotSize = 0; + + // key β†’ slot index + this._slots = new Map(); + this._freeList = []; + this._nextIndex = 0; + this._capacity = 0; + this._columns = 0; + + this.resize( slotCount, slotSize ); + + } + + // Returns true if key has an allocated slot. + has( key ) { + + return this._slots.has( key ); + + } + + // Returns the slot { x, y, w, h } for key, or null if not allocated. + get( key ) { + + const { _slots } = this; + if ( ! _slots.has( key ) ) { + + return null; + + } + + return this._indexToSlot( _slots.get( key ) ); + + } + + // Renders a single character centered in the slot using the given CSS font string and color. + // Returns the slot on success. Throws if the atlas is full. + drawGlyph( key, char, font, color = 'white' ) { + + return this._draw( key, ( ctx, x, y, w, h ) => { + + ctx.font = font; + ctx.fillStyle = color; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText( char, x + w / 2, y + h / 2 ); + + } ); + + } + + // Draws any CanvasImageSource (ImageBitmap, HTMLImageElement, HTMLCanvasElement, etc.) + // into the slot, scaled to fit. Caller is responsible for loading the image. + // Returns the slot on success. Throws if the atlas is full. + drawImage( key, image ) { + + return this._draw( key, ( ctx, x, y, w, h ) => { + + ctx.drawImage( image, x, y, w, h ); + + } ); + + } + + // Renders a Path2D into the slot. Path coordinates are slot-local (origin at top-left of slot). + // Returns the slot on success. Throws if the atlas is full. + drawPath( key, path2D, { fillStyle = null, strokeStyle = null, lineWidth = 1 } = {} ) { + + return this._draw( key, ( ctx, x, y ) => { + + ctx.save(); + ctx.translate( x, y ); + + if ( fillStyle !== null ) { + + ctx.fillStyle = fillStyle; + ctx.fill( path2D ); + + } + + if ( strokeStyle !== null ) { + + ctx.strokeStyle = strokeStyle; + ctx.lineWidth = lineWidth; + ctx.stroke( path2D ); + + } + + ctx.restore(); + + } ); + + } + + // Clears the slot for key and returns it to the free pool. + release( key ) { + + const { _slots, _freeList } = this; + if ( ! _slots.has( key ) ) { + + return; + + } + + const index = _slots.get( key ); + _freeList.push( index ); + _slots.delete( key ); + + } + + // Resizes the atlas to a new slot count and optional slot size, copying all + // existing slot content into their new positions on the resized canvas. + resize( slotCount, slotSize = this.slotSize ) { + + const oldCanvas = this.image; + const oldColumns = this._columns; + const oldSlotSize = this.slotSize; + + const columns = Math.ceil( Math.sqrt( slotCount ) ); + const canvas = document.createElement( 'canvas' ); + canvas.width = columns * slotSize; + canvas.height = columns * slotSize; + + const ctx = canvas.getContext( '2d' ); + + // copy each allocated slot from its old grid position to its new one + for ( const index of this._slots.values() ) { + + const srcX = ( index % oldColumns ) * oldSlotSize; + const srcY = Math.floor( index / oldColumns ) * oldSlotSize; + const dstX = ( index % columns ) * slotSize; + const dstY = Math.floor( index / columns ) * slotSize; + ctx.drawImage( oldCanvas, srcX, srcY, oldSlotSize, oldSlotSize, dstX, dstY, slotSize, slotSize ); + + } + + this.image = canvas; + this.ctx = ctx; + this.slotSize = slotSize; + this._columns = columns; + this._capacity = slotCount; + this.needsUpdate = true; + + } + + // Resets the atlas to empty. + clear() { + + this._slots.clear(); + this._freeList.length = 0; + this._nextIndex = 0; + this.ctx.clearRect( 0, 0, this.image.width, this.image.height ); + this.needsUpdate = true; + + } + + get isFull() { + + return this._freeList.length === 0 && this._nextIndex >= this._capacity; + + } + + _draw( key, callback ) { + + const { _freeList, _capacity, _slots, ctx } = this; + + let index; + if ( _slots.has( key ) ) { + + index = _slots.get( key ); + + } else { + + if ( _freeList.length > 0 ) { + + index = _freeList.pop(); + + } else if ( this._nextIndex < _capacity ) { + + index = this._nextIndex ++; + + } else { + + throw new Error( 'GlyphAtlasTexture: atlas is full. Call resize() to increase capacity.' ); + + } + + _slots.set( key, index ); + + } + + const slot = this._indexToSlot( index ); + ctx.save(); + ctx.beginPath(); + ctx.rect( slot.x, slot.y, slot.w, slot.h ); + ctx.clip(); + callback( ctx, slot.x, slot.y, slot.w, slot.h ); + ctx.restore(); + + this.needsUpdate = true; + return slot; + + } + + _indexToSlot( index ) { + + const { _columns, slotSize } = this; + return { + x: ( index % _columns ) * slotSize, + y: Math.floor( index / _columns ) * slotSize, + w: slotSize, + h: slotSize, + }; + + } + +} diff --git a/src/three/plugins/mvt/HierarchicalLock.js b/src/three/plugins/mvt/HierarchicalLock.js new file mode 100644 index 000000000..5d3a335c8 --- /dev/null +++ b/src/three/plugins/mvt/HierarchicalLock.js @@ -0,0 +1,127 @@ +import { EventDispatcher } from 'three'; + +const getKey = ( x, y, l ) => { + + return `${ x }_${ y }_${ l }`; + +}; + +// "available" indicates that a tile should be loaded and available +// "active" indicates the given tile is visible and should be displayed, overriding all parents +// If a single child is marked as active then all siblings are, as well, to prevent gaps +// TODO: This may need to handle async operations or perhaps a separate data structure should be used for +// "available" vs "active"? +export class HierarchicalLock extends EventDispatcher { + + constructor() { + + super(); + + this.locks = {}; + + } + + markActive( x, y, level ) { + + this._accrueActive( x, y, level, true ); + + } + + markInactive( x, y, level ) { + + this._accrueActive( x, y, level, false ); + + } + + // + + _initLock( x, y, l ) { + + const { locks } = this; + const key = getKey( x, y, l ); + if ( ! ( key in locks ) ) { + + locks[ key ] = { + x, + y, + level: l, + ref: 0, + override: 0, + dispatched: false, + }; + + } + + } + + _accrueRef( key, incr ) { + + const { locks } = this; + locks[ key ].ref += incr ? 1 : - 1; + if ( locks[ key ].ref < 0 ) { + + throw new Error(); + + } + + } + + _accrueOverride( key, incr ) { + + const { locks } = this; + locks[ key ].override += incr ? 1 : - 1; + if ( locks[ key ].override < 0 ) { + + throw new Error(); + + } + + } + + _resolveEvents( key ) { + + const { locks } = this; + const lock = locks[ key ]; + const active = lock.ref > 0 && lock.override === 0; + if ( active !== lock.dispatched ) { + + const { x, y, level } = lock; + lock.dispatched = active; + this.dispatchEvent( { + type: 'toggle', + active, x, y, level, + } ); + + } + + if ( lock.ref === 0 && lock.override === 0 ) { + + delete locks[ key ]; + + } + + } + + _accrueActive( x, y, level, incr ) { + + let key = getKey( x, y, level ); + this._initLock( x, y, level ); + this._accrueRef( key, incr ); + this._resolveEvents( key ); + + while ( level > 0 ) { + + level --; + x >>= 1; + y >>= 1; + + let key = getKey( x, y, level ); + this._initLock( x, y, level ); + this._accrueOverride( key, incr ); + this._resolveEvents( key ); + + } + + } + +} diff --git a/src/three/plugins/mvt/MVTAnnotationsPlugin.js b/src/three/plugins/mvt/MVTAnnotationsPlugin.js new file mode 100644 index 000000000..f21be3693 --- /dev/null +++ b/src/three/plugins/mvt/MVTAnnotationsPlugin.js @@ -0,0 +1,707 @@ +import { Group, MathUtils, Matrix4, Raycaster, Vector3 } from 'three'; +import { CirclePointsMaterial } from './CirclePointsMaterial.js'; +import { AnnotationGlyphAtlasTexture } from './AnnotationGlyphAtlasTexture.js'; +import { AnnotationsPoints } from './AnnotationsPoints.js'; +import { HierarchicalLock } from './HierarchicalLock.js'; +import { PointAnnotationItem } from './ScreenOccupationManager.js'; +import { DelayedScreenOccupationManager } from './DelayedScreenOccupationManager.js'; +import { forEachTileInBounds, getMeshesCartographicRange } from '../images/overlays/utils.js'; + +// TODO: +// - "fetch data" override needs to be handled differently? Switch to default download +// queue / process queue, instead (generated surface has issue, too) +// - consider continue to lock child tiles when zooming out, avoiding parent tile gaps + +function collectMeshes( object ) { + + const meshes = []; + object.traverse( c => { + + if ( c.isMesh ) { + + meshes.push( c ); + + } + + } ); + + return meshes; + +} + +const _matrix = /* @__PURE__ */ new Matrix4(); +const _ndcMatrix = /* @__PURE__ */ new Matrix4(); +const _raycaster = /* @__PURE__ */ new Raycaster(); +const _cameraLocalPos = /* @__PURE__ */ new Vector3(); +export class MVTAnnotationsPlugin { + + get contentCache() { + + return this.overlay.imageSource._contentCache; + + } + + constructor( options = {} ) { + + this.priority = Infinity; + this.name = 'MVT_ANNOTATIONS_PLUGIN'; + + const { + overlay, + camera = null, + scene = null, + displayOccupancyGrid = true, + } = options; + + this.overlay = overlay; + + this.locks = new HierarchicalLock(); + this.occupancy = new DelayedScreenOccupationManager(); + this.group = new Group(); + + this.scene = scene; + this.camera = camera; + + this.tileInfo = new Map(); + this.tileItems = new Map(); + + // callback to filter which features become annotations: + // getAnnotation( layerName, properties ) β†’ boolean + this.getAnnotation = null; + this.displayOccupancyGrid = displayOccupancyGrid; + + this._raycastQueue = []; + this._raycastQueueSet = new Set(); + this.maxRaycastTimeMs = 2; + + // TODO: add "text" manager for text + // TODO: add a "fade" manager for hiding an showing annotations + // TODO: debounce occupancy decisions β€” wait N frames before dispatching "added" / "removed" + // so transient conflicts (camera micro-movement) don't cause visible flicker + + } + + setCamera( camera ) { + + this.camera = camera; + + } + + init( tiles ) { + + const { locks, group, overlay, occupancy, tileInfo } = this; + + this._glyphAtlas = new AnnotationGlyphAtlasTexture(); + + const pointsMaterial = new CirclePointsMaterial( { + size: 30, + sizeAttenuation: false, + depthWrite: false, + depthTest: false, + } ); + pointsMaterial.glyphTexture = this._glyphAtlas; + const { u, v } = this._glyphAtlas.glyphCellUVSize; + pointsMaterial.glyphCellSize.set( u, v ); + + this._annotationsPoints = new AnnotationsPoints( pointsMaterial ); + group.add( this._annotationsPoints ); + this._lastUpdateTime = - 1; + + // init container + this.tiles = tiles; + tiles.group.add( group ); + + this._onTileDownloadStart = ( { tile } ) => { + + const info = { + range: null, + loaded: false, + disposed: false, + }; + + tileInfo.set( tile, info ); + + if ( overlay.isReady && tile.boundingVolume.region ) { + + // If the tile has a region bounding volume then mark the tiles to preload, clamped to the extents of + // the overlay image + const [ minLon, minLat, maxLon, maxLat ] = tile.boundingVolume.region; + let range = [ minLon, minLat, maxLon, maxLat ]; + range = overlay.projection.clampToBounds( range ); + range = overlay.projection.toNormalizedRange( range ); + + info.range = range; + + // TODO: we need to avoid double locking here and below with no synchronized release + // const { contentCache } = this; + // this._forEach2x2TileInBounds( range, ( x, y, l ) => { + + // // lock MVT content in a 2x2 pattern + // contentCache.lock( x, y, l ); + + // } ); + + } + + }; + + // event callbacks + this._onVisibilityChange = ( { tile, visible } ) => { + + const info = tileInfo.get( tile ); + + // TODO: the ImageOverlay Tile Splits is causing an issue here. + if ( ! info ) return; + + const { loaded, range } = info; + if ( loaded ) { + + this._forEach2x2TileInBounds( range, ( x, y, l ) => { + + // mark all tiles as "active" if visible in a 2x2 pattern + if ( visible ) { + + locks.markActive( x, y, l ); + + } else { + + locks.markInactive( x, y, l ); + + } + + } ); + + } + + }; + + + // sort: already-visible items first, then by pmap:rank ascending, then closest to camera, then bottom-to-top on screen + occupancy.sortCallback = ( a, b ) => { + + const aVis = occupancy.visible.has( a ) ? 0 : 1; + const bVis = occupancy.visible.has( b ) ? 0 : 1; + if ( aVis !== bVis ) return aVis - bVis; + + const rankA = a.properties[ 'rank' ] ?? a.properties[ 'pmap:rank' ] ?? Infinity; + const rankB = b.properties[ 'rank' ] ?? b.properties[ 'pmap:rank' ] ?? Infinity; + if ( rankA !== rankB ) { + + return rankA - rankB; + + } + + if ( a._depth !== b._depth ) { + + return a._depth - b._depth; + + } + + return b._screenPos.y - a._screenPos.y; + + }; + + this._onUpdateAfter = () => { + + // sync camera resolution, NDC matrix, and local camera position into occupancy grid + if ( this.camera !== null ) { + + tiles.getResolution( this.camera, occupancy.resolution ); + + _ndcMatrix + .copy( tiles.group.matrixWorld ) + .premultiply( this.camera.matrixWorldInverse ) + .premultiply( this.camera.projectionMatrix ); + occupancy.matrix = _ndcMatrix; + + // camera position in tiles.group local space β€” used for perspective culling and RTE + _matrix.copy( tiles.group.matrixWorld ).invert(); + _cameraLocalPos.setFromMatrixPosition( this.camera.matrixWorld ).applyMatrix4( _matrix ); + occupancy.cameraPosition = _cameraLocalPos; + + } else { + + occupancy.matrix = null; + occupancy.cameraPosition = null; + + } + + const now = performance.now() / 1000; + const dt = this._lastUpdateTime < 0 ? 0 : Math.min( now - this._lastUpdateTime, 0.1 ); + this._lastUpdateTime = now; + + // update visible points based on screen-space conflicts + this._processRaycastQueue(); + // fires 'added'/'removed' β†’ AnnotationsPoints.addItems/removeItems + occupancy.update(); + + // camera-relative rendering: position object at camera so buffer coords are + // small offsets β€” avoids Float32 precision jitter at globe scale + if ( this.camera !== null ) { + + this._annotationsPoints.position.copy( _cameraLocalPos ); + this._annotationsPoints.updateMatrixWorld( true ); + + } + + this._annotationsPoints.update( dt, occupancy.visible, this._glyphAtlas ); + this._updateDebugGrid(); + + }; + + this._onLockToggle = ( { x, y, level, active } ) => { + + const key = `${ x }_${ y }_${ level }`; + + if ( active ) { + + const { contentCache, occupancy, getAnnotation, tileItems } = this; + const { tiling } = overlay; + const vectorTile = contentCache.get( x, y, level ); + if ( ! vectorTile ) { + + return; + + } + + // get the normalized tile bound + const tileBounds = tiling.getTileBounds( x, y, level, true, false ); + const [ tMinX, tMinY, tMaxX, tMaxY ] = tileBounds; + const items = []; + + // iterate over all the layers + for ( const layerName in vectorTile.layers ) { + + if ( layerName === 'places' ) continue; + + const layer = vectorTile.layers[ layerName ]; + const extent = layer.extent; + + for ( let i = 0; i < layer.length; i ++ ) { + + // process only points + const feature = layer.feature( i ); + if ( feature.type !== 1 ) { + + continue; + + } + + if ( getAnnotation !== null && ! getAnnotation( layerName, layer.properties ) ) { + + continue; + + } + + // retrieve the geometry + const geometry = feature.loadGeometry(); + for ( const [ point ] of geometry ) { + + // TODO: is this necessary? + if ( point.x < 0 || point.x > extent || point.y < 0 || point.y > extent ) { + + continue; + + } + + const u = MathUtils.lerp( tMinX, tMaxX, point.x / extent ); + // tile Y=0 is geographic north; with flipY the V axis increases northward + // so we invert vf when flipY is set + const vf = point.y / extent; + const v = tiling.flipY + ? MathUtils.lerp( tMaxY, tMinY, vf ) + : MathUtils.lerp( tMinY, tMaxY, vf ); + + const [ lon, lat ] = tiling.toCartographicPoint( u, v ); + + const item = new PointAnnotationItem(); + // feature.id is the OSM element ID (node/way/relation) preserved by Planetiler + // across all zoom levels β€” stable and unique for cross-LoD annotation replacement. + item.id = `${ layerName }:${ feature.id }`; + item.layer = layerName; + item.properties = feature.properties; + item.lat = lat; + item.lon = lon; + item.lodLevel = level; + tiles.ellipsoid.getCartographicToPosition( lat, lon, 0, item.position ); + + const canonical = occupancy.register( item ); + items.push( canonical ); + this._enqueueRaycast( canonical ); + + } + + } + + } + + tileItems.set( key, items ); + this._enqueueRaycastAll(); + + } else { + + const { occupancy, tileItems } = this; + const items = tileItems.get( key ); + if ( items ) { + + for ( const item of items ) { + + occupancy.unregister( item ); + + } + + tileItems.delete( key ); + + } + + this._enqueueRaycastAll(); + + } + + }; + + // register events + locks.addEventListener( 'toggle', this._onLockToggle ); + tiles.addEventListener( 'update-after', this._onUpdateAfter ); + tiles.addEventListener( 'tile-visibility-change', this._onVisibilityChange ); + tiles.addEventListener( 'tile-download-start', this._onTileDownloadStart ); + + // + + // late initialization + tiles.forEachLoadedModel( ( scene, tile ) => { + + this.processTileModel( scene, tile ); + + } ); + + } + + dispose() { + + if ( this._debugCanvas ) { + + this._debugCanvas.remove(); + + } + + this.group.removeFromParent(); + this.locks.removeEventListener( 'toggle', this._onLockToggle ); + this.tiles.removeEventListener( 'update-after', this._onUpdateAfter ); + this.tiles.removeEventListener( 'tile-visibility-change', this._onVisibilityChange ); + this.tiles.removeEventListener( 'tile-download-start', this._onTileDownloadStart ); + + this.tiles.forEachLoadedModel( ( scene, tile ) => { + + this._onVisibilityChange( { scene, tile, visible: false } ); + + } ); + + } + + async processTileModel( scene, tile ) { + + const { overlay, tiles, tileInfo, locks } = this; + if ( ! overlay.isReady ) { + + await overlay.whenReady(); + + } + + // we may have added the plugin after some tiles started loading + let info = tileInfo.get( tile ); + if ( ! info ) { + + info = { range: null, loaded: false, disposed: false }; + tileInfo.set( tile, info ); + + } + + if ( info.disposed ) { + + return; + + } + + if ( info.range === null ) { + + // TODO: this currently only work with ellipsoidal projection + _matrix.identity(); + if ( scene.parent !== null ) { + + _matrix.copy( tiles.group.matrixWorldInverse ); + + } + + // TODO: why are we passing range vs region here? + scene.updateMatrixWorld(); + const meshes = collectMeshes( scene ); + const { range } = getMeshesCartographicRange( meshes, tiles.ellipsoid, _matrix, overlay.projection ); + info.range = range; + + } + + // lock all related MVT sub tiles in a 2x2 pattern + const { contentCache } = this; + const promises = []; + this._forEach2x2TileInBounds( info.range, ( x, y, l ) => { + + promises.push( contentCache.lock( x, y, l ) ); + + } ); + + try { + + await Promise.all( promises ); + + } catch { + + return; + + } + + if ( info.disposed ) { + + // disposeTile already ran and skipped release because info.loaded was false β€” + // we own the locks now, so release them here + this._forEach2x2TileInBounds( info.range, ( x, y, l ) => { + + contentCache.release( x, y, l ); + + } ); + return; + + } + + info.loaded = true; + + if ( tiles.visibleTiles.has( tile ) ) { + + // mark all tiles as "active" if visible in a 2x2 pattern + this._forEach2x2TileInBounds( info.range, ( x, y, l ) => { + + locks.markActive( x, y, l ); + + } ); + + } + + } + + _updateDebugGrid() { + + const { displayOccupancyGrid } = this; + if ( ! displayOccupancyGrid ) { + + if ( this._debugCanvas ) { + + _debugCanvas.remove(); + + } + + return; + + } else if ( displayOccupancyGrid && ! this._debugCanvas ) { + + // debug occupancy grid overlay + const debugCanvas = document.createElement( 'canvas' ); + debugCanvas.style.cssText = 'position:fixed;top:0;left:0;pointer-events:none;opacity:0.5;'; + document.body.appendChild( debugCanvas ); + this._debugCanvas = debugCanvas; + + } + + // TODO: see if we can simplify this + const { occupancy, _debugCanvas } = this; + const { cells, size, resolution, buffer } = occupancy; + const dpr = window.devicePixelRatio; + const bufferX = resolution.width * buffer; + const bufferY = resolution.height * buffer; + const cols = Math.ceil( ( resolution.width + 2 * bufferX ) / size ); + const rows = Math.ceil( ( resolution.height + 2 * bufferY ) / size ); + + _debugCanvas.width = Math.round( dpr * ( resolution.width + 2 * bufferX ) ); + _debugCanvas.height = Math.round( dpr * ( resolution.height + 2 * bufferY ) ); + _debugCanvas.style.width = `${ resolution.width + 2 * bufferX }px`; + _debugCanvas.style.height = `${ resolution.height + 2 * bufferY }px`; + _debugCanvas.style.left = `${ - bufferX }px`; + _debugCanvas.style.top = `${ - bufferY }px`; + + const drawSize = size * dpr; + const ctx = _debugCanvas.getContext( '2d' ); + ctx.clearRect( 0, 0, _debugCanvas.width, _debugCanvas.height ); + for ( let cy = 0; cy < rows; cy ++ ) { + + for ( let cx = 0; cx < cols; cx ++ ) { + + const occupied = cells[ cy * cols + cx ] !== 0; + ctx.fillStyle = occupied ? 'rgba( 255, 80, 80, 0.6 )' : 'rgba( 80, 255, 80, 0.15 )'; + ctx.fillRect( cx * drawSize + 0.5, cy * drawSize + 0.5, drawSize - 1, drawSize - 1 ); + ctx.strokeStyle = occupied ? 'rgba( 255, 80, 80, 1 )' : 'rgba( 80, 255, 80, 0.25 )'; + ctx.lineWidth = 1; + ctx.strokeRect( cx * drawSize + 0.5, cy * drawSize + 0.5, drawSize - 1, drawSize - 1 ); + + } + + } + + } + + _enqueueRaycast( item ) { + + if ( this._raycastQueueSet.has( item ) ) { + + return; + + } + + // item.ready = false; + this._raycastQueueSet.add( item ); + this._raycastQueue.push( item ); + + } + + _enqueueRaycastAll() { + + for ( const items of this.tileItems.values() ) { + + for ( const item of items ) { + + this._enqueueRaycast( item ); + + } + + } + + } + + _processRaycastQueue() { + + const { _raycastQueue, _raycastQueueSet, occupancy, maxRaycastTimeMs } = this; + const { visible, sortCallback } = occupancy; + + // sort ascending: visible first, then by occupancy priority β€” highest priority ends up at tail for pop() + _raycastQueue.sort( ( a, b ) => { + + const aVis = visible.has( a ) ? 1 : 0; + const bVis = visible.has( b ) ? 1 : 0; + if ( aVis !== bVis ) { + + return aVis - bVis; + + } + + return - sortCallback( a, b ); + + } ); + + const deadline = performance.now() + maxRaycastTimeMs; + while ( _raycastQueue.length > 0 ) { + + if ( performance.now() >= deadline ) break; + + const item = _raycastQueue.pop(); + _raycastQueueSet.delete( item ); + + // skip items replaced by a LoD swap + if ( occupancy.getById( item.id ) !== item ) { + + continue; + + } + + this._raycastItem( item ); + + } + + } + + _raycastItem( item ) { + + const { tiles } = this; + const { origin, direction } = _raycaster.ray; + + // origin: 10,000km above the surface at this feature's geodetic position + tiles.ellipsoid.getCartographicToPosition( item.lat, item.lon, 1e6, origin ); + + // direction: from high altitude toward the ellipsoid surface (geodetically correct) + tiles.ellipsoid.getCartographicToPosition( item.lat, item.lon, 0, direction ); + direction.sub( origin ).normalize(); + + origin.applyMatrix4( tiles.group.matrixWorld ); + direction.transformDirection( tiles.group.matrixWorld ); + + _raycaster.firstHitOnly = true; + _raycaster.far = 2 * 1e6; + const hits = _raycaster.intersectObject( tiles.group, true ); + if ( hits.length > 0 ) { + + hits[ 0 ].point.applyMatrix4( tiles.group.matrixWorldInverse ); + item.position.copy( hits[ 0 ].point ); + + } else { + + tiles.ellipsoid.getCartographicToPosition( item.lat, item.lon, 0, item.position ); + + } + + item.ready = true; + + } + + disposeTile( tile ) { + + const { tileInfo, contentCache } = this; + const info = tileInfo.get( tile ); + if ( ! info ) { + + return; + + } + + if ( info.loaded ) { + + this._forEach2x2TileInBounds( info.range, ( x, y, l ) => { + + // unlock all MVT sub tiles in a 2x2 pattern + contentCache.release( x, y, l ); + + } ); + + } + + tileInfo.delete( tile ); + info.disposed = true; + + } + + // + + _forEach2x2TileInBounds( range, callback ) { + + // fire these in 2x2 chunks so that sibling tiles are guaranteed to be present + const { overlay } = this; + const { tiling } = overlay; + const level = overlay.calculateLevel( range ); + + if ( ! overlay.isReady ) { + + throw new Error(); + + } + + forEachTileInBounds( range, level, tiling, ( x, y, l ) => { + + const bx = Math.floor( x * 0.5 ) * 2; + const by = Math.floor( y * 0.5 ) * 2; + + callback( bx + 0, by + 0, l ); + callback( bx + 1, by + 0, l ); + callback( bx + 0, by + 1, l ); + callback( bx + 1, by + 1, l ); + + } ); + + } + +} diff --git a/src/three/plugins/mvt/ScreenOccupationManager.js b/src/three/plugins/mvt/ScreenOccupationManager.js new file mode 100644 index 000000000..f45dd3f8e --- /dev/null +++ b/src/three/plugins/mvt/ScreenOccupationManager.js @@ -0,0 +1,350 @@ +import { EventDispatcher, Vector2, Vector3 } from 'three'; + +// suppress annotations within ~6 degrees of the globe horizon +const PERSPECTIVE_CULL_THRESHOLD = 0.1; + +export class AnnotationItem { + + constructor() { + + this.id = ''; + this.layer = ''; + this.properties = null; + this.ready = false; + this.lodLevel = 0; + this._refCount = 0; + + } + + updateTransform( matrix, resolution, cameraPosition ) { + + } + + evaluate( handle ) { + + return false; + + } + + copyPosition( source ) { + + } + +} + +export class PointAnnotationItem extends AnnotationItem { + + constructor() { + + super(); + + this.position = new Vector3(); + this.lat = 0; + this.lon = 0; + this.radius = 16; + + // x/y = screen pixels, z = NDC depth (z > 1 means behind camera) + this._screenPos = new Vector3(); + this._depth = 0; + this._facingRatio = 1; + + } + + updateTransform( matrix, resolution, cameraPosition ) { + + const screenPos = this._screenPos; + + screenPos.copy( this.position ).applyMatrix4( matrix ); + + const z = screenPos.z; + screenPos.x = ( screenPos.x * 0.5 + 0.5 ) * resolution.width; + screenPos.y = ( - screenPos.y * 0.5 + 0.5 ) * resolution.height; + screenPos.z = ( z < - 1 || z > 1 ) ? 1 : 0; + this._depth = z; + + // facing ratio: dot( surface normal, direction to camera ) + // TODO: store geodetic normal on the item at creation time and use it here instead of + // normalize( position ), which is only a spherical approximation (<0.2Β° error on WGS84) + if ( cameraPosition !== null ) { + + const px = this.position.x, py = this.position.y, pz = this.position.z; + const pLen = Math.sqrt( px * px + py * py + pz * pz ); + const dx = cameraPosition.x - px, dy = cameraPosition.y - py, dz = cameraPosition.z - pz; + const dLen = Math.sqrt( dx * dx + dy * dy + dz * dz ); + this._facingRatio = ( pLen > 0 && dLen > 0 ) + ? ( px * dx + py * dy + pz * dz ) / ( pLen * dLen ) + : 1; + + } else { + + this._facingRatio = 1; + + } + + } + + copyPosition( source ) { + + this.position.copy( source.position ); + this.ready = true; + + } + + evaluate( handle ) { + + const { _screenPos, radius, _facingRatio } = this; + if ( ! this.ready ) { + + return false; + + } + + if ( _screenPos.z !== 0 ) { + + return false; + + } + + if ( _facingRatio < PERSPECTIVE_CULL_THRESHOLD ) { + + return false; + + } + + if ( handle.test( _screenPos.x, _screenPos.y, radius ) ) { + + return false; + + } + + handle.mark( _screenPos.x, _screenPos.y, radius ); + return true; + + } + +} + + +export class ScreenOccupationManager extends EventDispatcher { + + constructor() { + + super(); + + // projection matrix: projectionMatrix * matrixWorldInverse * tilesGroup.matrixWorld + this.matrix = null; + + // camera position in tiles.group local space, for perspective culling + this.cameraPosition = null; + + // occupancy cells + this.resolution = new Vector2( 1, 1 ); + this.size = 25 / window.devicePixelRatio; + this.cells = new Uint8Array( 1 ); + + // fraction of the shorter viewport dimension to extend evaluation beyond screen edges + this.buffer = 0.15; + + // items + this.items = []; + this.visible = new Set(); + this.prevVisible = new Set(); + this.added = new Set(); + + // prevents duplicate items during simultaneous LoD tile swaps + this._itemsById = new Map(); + + this.handle = { + test: ( x, y, r ) => { + + const { cells } = this; + let hasCells = false; + const blocked = this._cellRange( x, y, r, ( x, y, i ) => { + + hasCells = true; + return cells[ i ] !== 0; + + } ); + return blocked || ! hasCells; + + }, + mark: ( x, y, r ) => { + + const { cells } = this; + return this._cellRange( x, y, r, ( x, y, i ) => { + + cells[ i ] = 1; + return false; + + } ); + + }, + }; + this.sortCallback = () => 0; + + } + + _cellRange( x, y, r, callback ) { + + const { size, resolution, buffer } = this; + const bufferX = resolution.width * buffer; + const bufferY = resolution.height * buffer; + const width = Math.ceil( ( resolution.width + 2 * bufferX ) / size ); + const height = Math.ceil( ( resolution.height + 2 * bufferY ) / size ); + const ox = x + bufferX; + const oy = y + bufferY; + const x0 = Math.max( 0, Math.floor( ( ox - r ) / size ) ); + const y0 = Math.max( 0, Math.floor( ( oy - r ) / size ) ); + const x1 = Math.min( width - 1, Math.floor( ( ox + r ) / size ) ); + const y1 = Math.min( height - 1, Math.floor( ( oy + r ) / size ) ); + + for ( let cy = y0; cy <= y1; cy ++ ) { + + for ( let cx = x0; cx <= x1; cx ++ ) { + + if ( callback( cx, cy, cy * width + cx ) === true ) { + + return true; + + } + + } + + } + + return false; + + } + + update() { + + const { matrix, cameraPosition, resolution, size, items, added, handle, sortCallback } = this; + + // swap visible and prevVisible β€” prevVisible now holds last frame's result + [ this.visible, this.prevVisible ] = [ this.prevVisible, this.visible ]; + + const { visible, prevVisible } = this; + visible.clear(); + added.clear(); + + // resize the occupation cells to cover the extended viewport + const bufferX = resolution.width * this.buffer; + const bufferY = resolution.height * this.buffer; + const width = Math.ceil( ( resolution.width + 2 * bufferX ) / size ); + const height = Math.ceil( ( resolution.height + 2 * bufferY ) / size ); + if ( this.cells.length !== width * height ) { + + this.cells = new Uint8Array( width * height ); + + } else { + + this.cells.fill( 0 ); + + } + + // transform items to screen space + if ( matrix !== null ) { + + for ( let i = 0, l = items.length; i < l; i ++ ) { + + items[ i ].updateTransform( matrix, resolution, cameraPosition ); + + } + + } + + // sort the items + items.sort( sortCallback ); + + // evaluate occupancy into the fresh visible set + for ( let i = 0, l = items.length; i < l; i ++ ) { + + const item = items[ i ]; + if ( matrix !== null && item.evaluate( handle ) ) { + + visible.add( item ); + if ( ! prevVisible.has( item ) ) { + + added.add( item ); + + } else { + + prevVisible.delete( item ); + + } + + } + + } + + if ( added.size > 0 ) { + + this.dispatchEvent( { type: 'added', items: added } ); + + } + + if ( prevVisible.size > 0 ) { + + this.dispatchEvent( { type: 'removed', items: prevVisible } ); + + } + + } + + getById( id ) { + + return this._itemsById.get( id ); + + } + + register( item ) { + + const { _itemsById, items } = this; + + const existing = _itemsById.get( item.id ); + if ( existing ) { + + existing._refCount ++; + if ( item.lodLevel > existing.lodLevel ) { + + existing.lodLevel = item.lodLevel; + existing.lat = item.lat; + existing.lon = item.lon; + + } + + return existing; + + } + + item._refCount = 1; + _itemsById.set( item.id, item ); + items.push( item ); + return item; + + } + + unregister( item ) { + + item._refCount --; + if ( item._refCount > 0 ) return; + + const { items, prevVisible, _itemsById } = this; + const index = items.indexOf( item ); + if ( index !== - 1 ) { + + items.splice( index, 1 ); + + } + + if ( _itemsById.get( item.id ) === item ) { + + _itemsById.delete( item.id ); + + } + + prevVisible.delete( item ); + + } + +} diff --git a/src/three/plugins/mvt/annotationColors.js b/src/three/plugins/mvt/annotationColors.js new file mode 100644 index 000000000..94941b7aa --- /dev/null +++ b/src/three/plugins/mvt/annotationColors.js @@ -0,0 +1,137 @@ +// Protomaps basemaps LIGHT theme palette (protomaps/basemaps) +export const CATEGORY_COLORS = { + tangerine: 0xCB6704, + green: 0x20834D, + lapis: 0x315BCF, + slategray: 0x6A5B8F, + blue: 0x1A8CBD, + pink: 0xEF56BA, + red: 0xF2567A, + turquoise: 0x00C3D4, +}; + +export const KIND_CATEGORY = { + + // Food & Drink + cafe: 'tangerine', + coffee_shop: 'tangerine', + restaurant: 'tangerine', + fast_food: 'tangerine', + bar: 'tangerine', + pub: 'tangerine', + biergarten: 'tangerine', + nightclub: 'tangerine', + bakery: 'tangerine', + food_court: 'tangerine', + ice_cream: 'tangerine', + + // Nature & Recreation + park: 'green', + garden: 'green', + forest: 'green', + nature_reserve: 'green', + beach: 'green', + peak: 'green', + volcano: 'green', + marina: 'green', + zoo: 'green', + bench: 'green', + picnic_site: 'green', + wetland: 'green', + + // Education & Civic + school: 'slategray', + university: 'slategray', + college: 'slategray', + kindergarten: 'slategray', + library: 'slategray', + stadium: 'slategray', + post_office: 'slategray', + townhall: 'slategray', + courthouse: 'slategray', + community_centre: 'slategray', + social_facility: 'slategray', + place_of_worship: 'slategray', + prison: 'slategray', + drinking_water: 'slategray', + toilets: 'slategray', + + // Shopping & Retail + supermarket: 'blue', + grocery: 'blue', + convenience: 'blue', + mall: 'blue', + department_store: 'blue', + clothes: 'blue', + electronics: 'blue', + books: 'blue', + beauty: 'blue', + hairdresser: 'blue', + pharmacy: 'blue', + bank: 'blue', + atm: 'blue', + + // Transport + airport: 'lapis', + airfield: 'lapis', + aerodrome: 'lapis', + train_station: 'lapis', + station: 'lapis', + subway_entrance: 'lapis', + bus_stop: 'lapis', + ferry_terminal: 'lapis', + helipad: 'lapis', + taxi: 'lapis', + + // Culture & Attractions + museum: 'pink', + theatre: 'pink', + cinema: 'pink', + gallery: 'pink', + arts_centre: 'pink', + attraction: 'pink', + artwork: 'pink', + theme_park: 'pink', + viewpoint: 'pink', + + // Healthcare & Emergency + hospital: 'red', + doctors: 'red', + clinic: 'red', + dentist: 'red', + veterinary: 'red', + fire_station: 'red', + police: 'red', + + // Accommodation & Leisure + hotel: 'turquoise', + motel: 'turquoise', + hostel: 'turquoise', + guest_house: 'turquoise', + camp_site: 'turquoise', + caravan_site: 'turquoise', + aquarium: 'turquoise', + sports_centre: 'turquoise', + swimming_pool: 'turquoise', + golf_course: 'turquoise', + fitness_centre: 'turquoise', + playground: 'turquoise', + +}; + +export const DEFAULT_COLOR = 0xA0A0A0; + +export function getAnnotationCategory( layer, properties ) { + + const kind = properties.kind ?? properties[ 'pmap:kind' ] ?? layer; + const base = typeof kind === 'string' ? kind.split( '/' )[ 0 ] : kind; + return KIND_CATEGORY[ base ] ?? null; + +} + +export function getAnnotationColor( layer, properties, target ) { + + const category = getAnnotationCategory( layer, properties ); + return target.setHex( category !== null ? CATEGORY_COLORS[ category ] : DEFAULT_COLOR ); + +} diff --git a/src/three/renderer/tiles/TilesRenderer.js b/src/three/renderer/tiles/TilesRenderer.js index c94db657b..ba8a788bd 100644 --- a/src/three/renderer/tiles/TilesRenderer.js +++ b/src/three/renderer/tiles/TilesRenderer.js @@ -346,6 +346,21 @@ export class TilesRenderer extends TilesRendererBase { } + /** + * Returns the render resolution previously set for a registered camera. + * @param {Camera} camera - A previously registered camera. + * @param {Vector2} target - Vector2 to write the result into. + * @returns {Vector2|null} The target with width/height filled in, or null if the camera is not registered. + */ + getResolution( camera, target ) { + + const vec = this.cameraMap.get( camera ); + if ( ! vec ) return null; + + return target.copy( vec ); + + } + /** * Sets the render resolution for a camera by reading the current size from a WebGLRenderer. * @param {Camera} camera - A previously registered camera.