From abf963f79b6ad1684df99805ac9088f3a2369a71 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Mon, 1 Jun 2026 12:10:49 +0900 Subject: [PATCH 01/27] Initial classes --- src/three/plugins/mvt/HierarchicalLock.js | 120 ++++++++++++++++ src/three/plugins/mvt/MVTAnnotationsPlugin.js | 72 ++++++++++ .../plugins/mvt/ScreenOccupationManager.js | 131 ++++++++++++++++++ 3 files changed, 323 insertions(+) create mode 100644 src/three/plugins/mvt/HierarchicalLock.js create mode 100644 src/three/plugins/mvt/MVTAnnotationsPlugin.js create mode 100644 src/three/plugins/mvt/ScreenOccupationManager.js diff --git a/src/three/plugins/mvt/HierarchicalLock.js b/src/three/plugins/mvt/HierarchicalLock.js new file mode 100644 index 000000000..3bd94e3a5 --- /dev/null +++ b/src/three/plugins/mvt/HierarchicalLock.js @@ -0,0 +1,120 @@ +import { EventDispatcher } from 'three'; + +const getKey = ( x, y, l ) => { + + return `${ x }_${ y }_${ l }`; + +}; + +export class HierarchicalLock extends EventDispatcher { + + constructor() { + + super(); + + this.locks = {}; + + } + + lock( x, y, level ) { + + this._accrueLock( x, y, level, true ); + + } + + unlock( x, y, level ) { + + this._accrueLock( 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 = true; + this.dispatchEvent( { + type: 'toggle', + active, x, y, level, + } ); + + } + + if ( lock.ref === 0 && lock.override === 0 ) { + + delete locks[ key ]; + + } + + } + + _accrueLock( 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..e9f5bcbda --- /dev/null +++ b/src/three/plugins/mvt/MVTAnnotationsPlugin.js @@ -0,0 +1,72 @@ +import { Group } from 'three'; +import { HierarchicalLock } from './HierarchicalLock.js'; +import { ScreenOccupationManager } from './ScreenOccupationManager.js'; + +export class MVTAnnotationsPlugin { + + constructor( options = {} ) { + + const { + overlay, + camera = null, + scene = null, + } = options; + + this.overlay = overlay; + this.locks = new HierarchicalLock(); + this.occupancy = new ScreenOccupationManager(); + this.scene = scene; + this.camera = camera; + this.group = new Group(); + + // TODO: add "points" manager for icons + // TODO: add "text" manager for text + // TODO: add "collision" manager for screen space organization + // TODO: add a "fade" manager for hiding an showing annotations + + } + + setCamera( camera ) { + + this.camera = camera; + // TODO + + } + + init( tiles ) { + + const { locks, group, overlay } = this; + this.tiles = tiles; + tiles.group.add( group ); + + this._onUpdateAfter = () => { + + // TODO: update visible text, points based on screen space conflicts. + + }; + + // TODO: calculate "visible" regions and "lock" them on the overlay, similar to + // the image overlay plugin. + + // TODO: register for region visibility toggle events for the overlay, locking and + // unlocking sub tiles associated with those regions + + locks.addEventListener( 'toggle', ( { x, y, level, active } ) => { + + // TODO: add / remove items from the group or associated managers, "settling" + // them as they are added + + } ); + + tiles.addEventListener( 'after-update', this._onUpdateAfter ); + + } + + dispose() { + + this.group.removeFromParent(); + this.tiles.removeEventListener( 'after-update', this._onUpdateAfter ); + + } + +} diff --git a/src/three/plugins/mvt/ScreenOccupationManager.js b/src/three/plugins/mvt/ScreenOccupationManager.js new file mode 100644 index 000000000..008cc4041 --- /dev/null +++ b/src/three/plugins/mvt/ScreenOccupationManager.js @@ -0,0 +1,131 @@ +import { EventDispatcher, Vector2 } from 'three'; + +export class ScreenOccupationManager extends EventDispatcher { + + constructor() { + + super(); + + // camera + this.camera = null; + + // occupancy cells + this.resolution = new Vector2( 1, 1 ); + this.size = 25; + this.cells = new Uint8Array( 1 ); + + // items + this.items = []; + this.visible = new Set(); + this.prevVisible = new Set(); + this.added = new Set(); + this.removed = new Set(); + + this.sortCallback = () => 0; + + } + + update() { + + const { + camera, + resolution, + size, + items, + visible, + prevVisible, + added, + removed, + } = this; + + // resize the occupation cells + const width = Math.ceil( resolution.width / size ); + const height = Math.ceil( resolution.height / size ); + if ( this.cells.length !== width * height ) { + + this.cells = new Uint8Array( width * height ); + + } else { + + this.cells.fill( 0 ); + + } + + // sort the items + items.sort( this.sortCallback ); + + // save the visible set so we can know which had been removed + removed.clear(); + added.clear(); + visible.clear(); + + for ( let i = 0, l = items.length; i < l; i ++ ) { + + const item = items[ i ]; + let canDisplay = true; + + if ( camera !== null ) { + + // TODO: + // - transform the shape to the screen + // - check occupancy + + } + + if ( canDisplay ) { + + // TODO: mark occupancy if possible + + visible.add( item ); + if ( ! prevVisible.has( item ) ) { + + added.add( item ); + + } + + } else if ( prevVisible.has( item ) ) { + + removed.delete( item ); + + } + + } + + // swap the visibility + [ this.visible, this.prevVisible ] = [ this.prevVisible, this.visible ]; + + // events + if ( added.size > 0 ) { + + this.dispatchEvent( { type: 'added', items: added } ); + + } + + if ( removed.size > 0 ) { + + this.dispatchEvent( { type: 'removed', items: removed } ); + + } + + } + + register( item ) { + + // TODO: how to register / handle non-linear layouts for text - custom callback? + this.items.push( item ); + + } + + unregister( item ) { + + const { items } = this; + const index = items.indexOf( item ); + if ( index !== - 1 ) { + + items.splice( index, 1 ); + + } + + } + +} From 7170090b0f3582fd3527e8371d0f4092d6453781 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Mon, 1 Jun 2026 12:17:40 +0900 Subject: [PATCH 02/27] Updates --- src/three/plugins/images/ImageOverlayPlugin.js | 16 ++++++++++++++-- src/three/plugins/mvt/MVTAnnotationsPlugin.js | 18 ++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/three/plugins/images/ImageOverlayPlugin.js b/src/three/plugins/images/ImageOverlayPlugin.js index 76f2f8f75..e4b38a789 100644 --- a/src/three/plugins/images/ImageOverlayPlugin.js +++ b/src/three/plugins/images/ImageOverlayPlugin.js @@ -1,7 +1,7 @@ /** @import { WebGLRenderer } from 'three' */ /** @import { WMTSTileMatrix } from './WMTSImageSource.js' */ /** @import { VectorTileStyle } from './utils/VectorShapeCanvasRenderer.js' */ -import { Color, BufferAttribute, Matrix4, Vector3, Box3, Triangle, CanvasTexture } from 'three'; +import { Color, BufferAttribute, Matrix4, Vector3, Box3, Triangle, CanvasTexture, EventDispatcher } from 'three'; import { PriorityQueue, PriorityQueueItemRemovedError, unifiedPriorityCallback } from '3d-tiles-renderer/core'; import { CesiumIonAuth, GoogleCloudAuth } from '3d-tiles-renderer/core/plugins'; import { XYZImageSource } from './sources/XYZImageSource.js'; @@ -1373,7 +1373,7 @@ export class ImageOverlayPlugin { * @param {boolean} [options.alphaInvert=false] If true, inverts the alpha channel before * applying the mask or blend. */ -export class ImageOverlay { +export class ImageOverlay extends EventDispatcher { get isPlanarProjection() { @@ -1383,6 +1383,8 @@ export class ImageOverlay { constructor( options = {} ) { + super(); + const { opacity = 1, color = 0xffffff, @@ -1488,6 +1490,11 @@ export class ImageOverlay { entry = { range: [ ...range ], count: 0 }; _visibleRegionCounts.set( key, entry ); + this.dispatchEvent( { + type: 'region-visibility-change', + range, + visible, + } ); } @@ -1500,6 +1507,11 @@ export class ImageOverlay { } else if ( entry.count === 0 ) { _visibleRegionCounts.delete( key ); + this.dispatchEvent( { + type: 'region-visibility-change', + range, + visible, + } ); } diff --git a/src/three/plugins/mvt/MVTAnnotationsPlugin.js b/src/three/plugins/mvt/MVTAnnotationsPlugin.js index e9f5bcbda..f545ed741 100644 --- a/src/three/plugins/mvt/MVTAnnotationsPlugin.js +++ b/src/three/plugins/mvt/MVTAnnotationsPlugin.js @@ -45,6 +45,22 @@ export class MVTAnnotationsPlugin { }; + this._onRegionChange = ( { range, visible } ) => { + + // TODO: iterate over tiles within region, mark locks + + if ( visible ) { + + // locks.lock( x, y, l ); + + } else { + + // locks.unlock( x, y, l ); + + } + + }; + // TODO: calculate "visible" regions and "lock" them on the overlay, similar to // the image overlay plugin. @@ -59,6 +75,7 @@ export class MVTAnnotationsPlugin { } ); tiles.addEventListener( 'after-update', this._onUpdateAfter ); + overlay.addEventListener( 'region-visibility-change', this._onRegionChange ); } @@ -66,6 +83,7 @@ export class MVTAnnotationsPlugin { this.group.removeFromParent(); this.tiles.removeEventListener( 'after-update', this._onUpdateAfter ); + this.overlay.removeEventListener( 'region-visibility-change', this._onRegionChange ); } From cf71cb6691d0f1eab8dec0403f32c5187612d65b Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Mon, 1 Jun 2026 13:11:20 +0900 Subject: [PATCH 03/27] Updates --- src/three/plugins/mvt/MVTAnnotationsPlugin.js | 100 +++++++++++++++--- 1 file changed, 85 insertions(+), 15 deletions(-) diff --git a/src/three/plugins/mvt/MVTAnnotationsPlugin.js b/src/three/plugins/mvt/MVTAnnotationsPlugin.js index f545ed741..8aa9c0f99 100644 --- a/src/three/plugins/mvt/MVTAnnotationsPlugin.js +++ b/src/three/plugins/mvt/MVTAnnotationsPlugin.js @@ -1,11 +1,28 @@ -import { Group } from 'three'; +import { Group, Matrix4 } from 'three'; import { HierarchicalLock } from './HierarchicalLock.js'; import { ScreenOccupationManager } from './ScreenOccupationManager.js'; +import { getMeshesCartographicRange } from '../images/overlays/utils.js'; +const _matrix = /* @__PURE__ */ new Matrix4(); export class MVTAnnotationsPlugin { + get camera() { + + return this.occupancy.camera; + + } + + set camera( v ) { + + this.occupancy.camera = v; + + } + constructor( options = {} ) { + this.priority = Infinity; + this.name = 'MVT_ANNOTATIONS_PLUGIN'; + const { overlay, camera = null, @@ -13,15 +30,16 @@ export class MVTAnnotationsPlugin { } = options; this.overlay = overlay; + this.locks = new HierarchicalLock(); this.occupancy = new ScreenOccupationManager(); + this.group = new Group(); + this.scene = scene; this.camera = camera; - this.group = new Group(); // TODO: add "points" manager for icons // TODO: add "text" manager for text - // TODO: add "collision" manager for screen space organization // TODO: add a "fade" manager for hiding an showing annotations } @@ -29,19 +47,55 @@ export class MVTAnnotationsPlugin { setCamera( camera ) { this.camera = camera; - // TODO } init( tiles ) { - const { locks, group, overlay } = this; + const { locks, group, overlay, occupancy } = this; + + // init container this.tiles = tiles; tiles.group.add( group ); + // event callbacks + this._onVisibilityChange = ( { scene, visible } ) => { + + // TODO: this currently only work with ellipsoidal projection + let meshes = []; + scene.updateMatrixWorld(); + scene.traverse( c => { + + if ( c.isMesh ) { + + meshes.push( c ); + + } + + } ); + + _matrix.identity(); + if ( scene.parent !== null ) { + + _matrix.copy( tiles.group.matrixWorldInverse ); + + } + + // TODO: why are we passing range vs region here? + const { range } = getMeshesCartographicRange( meshes, tiles.ellipsoid, _matrix, overlay.projection ); + overlay.setRegionVisible( range, visible ); + + // TODO: lock necessary sub MVT tile content on load to prepare + // - do not delay tiles + // - do not "lock" sub tile content until it's loaded + // - what happens if only one of the sub tiles is loaded / locked? Display parent + children? + + }; + this._onUpdateAfter = () => { - // TODO: update visible text, points based on screen space conflicts. + // update visible text, points based on screen space conflicts + occupancy.update(); }; @@ -61,30 +115,46 @@ export class MVTAnnotationsPlugin { }; - // TODO: calculate "visible" regions and "lock" them on the overlay, similar to - // the image overlay plugin. - - // TODO: register for region visibility toggle events for the overlay, locking and - // unlocking sub tiles associated with those regions + this._onLockToggle = ( { x, y, level, active } ) => { - locks.addEventListener( 'toggle', ( { x, y, level, active } ) => { - - // TODO: add / remove items from the group or associated managers, "settling" + // TODO: + // - retrieve the associated tile annotations + // - add / remove items from the group or associated managers, "settling" // them as they are added - } ); + }; + // register events + locks.addEventListener( 'toggle', this._onLockToggle ); tiles.addEventListener( 'after-update', this._onUpdateAfter ); + tiles.addEventListener( 'tile-visibility-change', this._onVisibilityChange ); overlay.addEventListener( 'region-visibility-change', this._onRegionChange ); + // + + // late initialization + tiles.forEachLoadedModel( ( scene, tile ) => { + + this._onVisibilityChange( { scene, tile, visible: true } ); + + } ); + } dispose() { this.group.removeFromParent(); + this.locks.removeEventListener( 'toggle', this._onLockToggle ); this.tiles.removeEventListener( 'after-update', this._onUpdateAfter ); + this.tiles.removeEventListener( 'tile-visibility-change', this._onVisibilityChange ); this.overlay.removeEventListener( 'region-visibility-change', this._onRegionChange ); + this.tiles.forEachLoadedModel( ( scene, tile ) => { + + this._onVisibilityChange( { scene, tile, visible: false } ); + + } ); + } } From 7e3c64b00387123d4b0ce23254f858b7c95f79a5 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Mon, 1 Jun 2026 13:55:42 +0900 Subject: [PATCH 04/27] Updates --- src/three/plugins/mvt/HierarchicalLock.js | 83 ++++++++++++++++++++--- 1 file changed, 75 insertions(+), 8 deletions(-) diff --git a/src/three/plugins/mvt/HierarchicalLock.js b/src/three/plugins/mvt/HierarchicalLock.js index 3bd94e3a5..d269cd1b0 100644 --- a/src/three/plugins/mvt/HierarchicalLock.js +++ b/src/three/plugins/mvt/HierarchicalLock.js @@ -6,6 +6,11 @@ const getKey = ( 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() { @@ -16,18 +21,56 @@ export class HierarchicalLock extends EventDispatcher { } + markActive( x, y, level ) { + + const bx = Math.floor( x / 2 ) * 2; + const by = Math.floor( y / 2 ) * 2; + + this._accrueActive( bx + 0, by + 0, level, true ); + this._accrueActive( bx + 1, by + 0, level, true ); + this._accrueActive( bx + 0, by + 1, level, true ); + this._accrueActive( bx + 1, by + 1, level, true ); + + } + + markInactive( x, y, level ) { + + const bx = Math.floor( x / 2 ) * 2; + const by = Math.floor( y / 2 ) * 2; + + this._accrueActive( bx + 0, by + 0, level, false ); + this._accrueActive( bx + 1, by + 0, level, false ); + this._accrueActive( bx + 0, by + 1, level, false ); + this._accrueActive( bx + 1, by + 1, level, false ); + + } + lock( x, y, level ) { - this._accrueLock( x, y, level, true ); + const bx = Math.floor( x / 2 ) * 2; + const by = Math.floor( y / 2 ) * 2; + + this._accrueAvailable( bx + 0, by + 0, level, true ); + this._accrueAvailable( bx + 1, by + 0, level, true ); + this._accrueAvailable( bx + 0, by + 1, level, true ); + this._accrueAvailable( bx + 1, by + 1, level, true ); } unlock( x, y, level ) { - this._accrueLock( x, y, level, false ); + const bx = Math.floor( x / 2 ) * 2; + const by = Math.floor( y / 2 ) * 2; + + this._accrueAvailable( bx + 0, by + 0, level, false ); + this._accrueAvailable( bx + 1, by + 0, level, false ); + this._accrueAvailable( bx + 0, by + 1, level, false ); + this._accrueAvailable( bx + 1, by + 1, level, false ); } + // + _initLock( x, y, l ) { const { locks } = this; @@ -38,9 +81,11 @@ export class HierarchicalLock extends EventDispatcher { x, y, level: l, + present: 0, ref: 0, override: 0, - dispatched: false, + activeDispatched: false, + presentDispatched: false, }; } @@ -76,18 +121,29 @@ export class HierarchicalLock extends EventDispatcher { const { locks } = this; const lock = locks[ key ]; const active = lock.ref > 0 && lock.override === 0; - if ( active !== lock.dispatched ) { + if ( active !== lock.activeDispatched ) { const { x, y, level } = lock; - lock.dispatched = true; + lock.activeDispatched = active; this.dispatchEvent( { - type: 'toggle', + type: 'active-toggle', active, x, y, level, } ); } - if ( lock.ref === 0 && lock.override === 0 ) { + if ( Boolean( lock.present ) !== lock.presentDispatched ) { + + const { x, y, level } = lock; + lock.activeDispatched = lock.present; + this.dispatchEvent( { + type: 'present-toggle', + active, x, y, level, + } ); + + } + + if ( lock.ref === 0 && lock.override === 0 && lock.present === 0 ) { delete locks[ key ]; @@ -95,7 +151,7 @@ export class HierarchicalLock extends EventDispatcher { } - _accrueLock( x, y, level, incr ) { + _accrueActive( x, y, level, incr ) { let key = getKey( x, y, level ); this._initLock( x, y, level ); @@ -117,4 +173,15 @@ export class HierarchicalLock extends EventDispatcher { } + _accrueAvailable( x, y, level, incr ) { + + const { locks } = this; + const key = getKey( x, y, level ); + this._initLock( x, y, level ); + locks[ key ].present += incr ? 1 : - 1; + + this._resolveEvents( key ); + + } + } From 33cbf9a8bea2975b4c7bd0984e4c52652fd16fdc Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Mon, 1 Jun 2026 16:29:41 +0900 Subject: [PATCH 05/27] More roughing out --- src/three/plugins/mvt/HierarchicalLock.js | 51 +--------- src/three/plugins/mvt/MVTAnnotationsPlugin.js | 97 ++++++++++++++++--- 2 files changed, 85 insertions(+), 63 deletions(-) diff --git a/src/three/plugins/mvt/HierarchicalLock.js b/src/three/plugins/mvt/HierarchicalLock.js index d269cd1b0..71e98ec74 100644 --- a/src/three/plugins/mvt/HierarchicalLock.js +++ b/src/three/plugins/mvt/HierarchicalLock.js @@ -23,49 +23,13 @@ export class HierarchicalLock extends EventDispatcher { markActive( x, y, level ) { - const bx = Math.floor( x / 2 ) * 2; - const by = Math.floor( y / 2 ) * 2; - - this._accrueActive( bx + 0, by + 0, level, true ); - this._accrueActive( bx + 1, by + 0, level, true ); - this._accrueActive( bx + 0, by + 1, level, true ); - this._accrueActive( bx + 1, by + 1, level, true ); + this._accrueActive( x, y, level, true ); } markInactive( x, y, level ) { - const bx = Math.floor( x / 2 ) * 2; - const by = Math.floor( y / 2 ) * 2; - - this._accrueActive( bx + 0, by + 0, level, false ); - this._accrueActive( bx + 1, by + 0, level, false ); - this._accrueActive( bx + 0, by + 1, level, false ); - this._accrueActive( bx + 1, by + 1, level, false ); - - } - - lock( x, y, level ) { - - const bx = Math.floor( x / 2 ) * 2; - const by = Math.floor( y / 2 ) * 2; - - this._accrueAvailable( bx + 0, by + 0, level, true ); - this._accrueAvailable( bx + 1, by + 0, level, true ); - this._accrueAvailable( bx + 0, by + 1, level, true ); - this._accrueAvailable( bx + 1, by + 1, level, true ); - - } - - unlock( x, y, level ) { - - const bx = Math.floor( x / 2 ) * 2; - const by = Math.floor( y / 2 ) * 2; - - this._accrueAvailable( bx + 0, by + 0, level, false ); - this._accrueAvailable( bx + 1, by + 0, level, false ); - this._accrueAvailable( bx + 0, by + 1, level, false ); - this._accrueAvailable( bx + 1, by + 1, level, false ); + this._accrueActive( x, y, level, false ); } @@ -173,15 +137,4 @@ export class HierarchicalLock extends EventDispatcher { } - _accrueAvailable( x, y, level, incr ) { - - const { locks } = this; - const key = getKey( x, y, level ); - this._initLock( x, y, level ); - locks[ key ].present += incr ? 1 : - 1; - - this._resolveEvents( key ); - - } - } diff --git a/src/three/plugins/mvt/MVTAnnotationsPlugin.js b/src/three/plugins/mvt/MVTAnnotationsPlugin.js index 8aa9c0f99..0eb67c3d6 100644 --- a/src/three/plugins/mvt/MVTAnnotationsPlugin.js +++ b/src/three/plugins/mvt/MVTAnnotationsPlugin.js @@ -3,6 +3,11 @@ import { HierarchicalLock } from './HierarchicalLock.js'; import { ScreenOccupationManager } from './ScreenOccupationManager.js'; import { getMeshesCartographicRange } from '../images/overlays/utils.js'; +// TODO: +// - allow for blocking tile loads optionally +// - "fetch data" override needs to be handled differently? Switch to default download +// queue / process queue, instead (generated surface has issue, too) + const _matrix = /* @__PURE__ */ new Matrix4(); export class MVTAnnotationsPlugin { @@ -38,6 +43,8 @@ export class MVTAnnotationsPlugin { this.scene = scene; this.camera = camera; + this.tileInfo = new Map(); + // TODO: add "points" manager for icons // TODO: add "text" manager for text // TODO: add a "fade" manager for hiding an showing annotations @@ -52,14 +59,22 @@ export class MVTAnnotationsPlugin { init( tiles ) { - const { locks, group, overlay, occupancy } = this; + const { locks, group, overlay, occupancy, tileInfo } = this; // init container this.tiles = tiles; tiles.group.add( group ); + this._onDownloadStart = () => { + + // TODO: use built-in region if available + + }; + // event callbacks - this._onVisibilityChange = ( { scene, visible } ) => { + this._onModelLoad = async ( { tile, scene } ) => { + + // TODO: move to "process model" // TODO: this currently only work with ellipsoidal projection let meshes = []; @@ -83,7 +98,36 @@ export class MVTAnnotationsPlugin { // TODO: why are we passing range vs region here? const { range } = getMeshesCartographicRange( meshes, tiles.ellipsoid, _matrix, overlay.projection ); - overlay.setRegionVisible( range, visible ); + const info = { + range, + loaded: false, + disposed: false, + }; + + tileInfo.set( tile, info ); + + let promises = []; + + // TODO: lock all related MVT sub tiles in a 2x2 pattern + + await Promise.all( promises ); + + info.loaded = true; + if ( info.disposed ) { + + return; + + } + + if ( tiles.visibleTiles.has( tile ) ) { + + // TODO: mark all tiles as "active" if visible in a 2x2 pattern + + } + + + // + // TODO: lock necessary sub MVT tile content on load to prepare // - do not delay tiles @@ -92,29 +136,46 @@ export class MVTAnnotationsPlugin { }; - this._onUpdateAfter = () => { + this._onModelDispose = ( { tile } ) => { - // update visible text, points based on screen space conflicts - occupancy.update(); + const info = tileInfo.get( tile ); + const { range } = info; + + // TODO: unlock all MVT sub tiles in a 2x2 pattern + + tileInfo.delete( tile ); + info.disposed = true; }; - this._onRegionChange = ( { range, visible } ) => { + this._onVisibilityChange = ( { tile, visible } ) => { + + const { loaded, range } = tileInfo.get( tile ); + if ( loaded ) { - // TODO: iterate over tiles within region, mark locks + // TODO: mark all tiles as "active" if visible in a 2x2 pattern + if ( visible ) { - if ( visible ) { + // locks.markActive( x, y, l ); - // locks.lock( x, y, l ); + } else { - } else { + // locks.markInactive( x, y, l ); + + } - // locks.unlock( x, y, l ); } }; + this._onUpdateAfter = () => { + + // update visible text, points based on screen space conflicts + occupancy.update(); + + }; + this._onLockToggle = ( { x, y, level, active } ) => { // TODO: @@ -128,7 +189,8 @@ export class MVTAnnotationsPlugin { locks.addEventListener( 'toggle', this._onLockToggle ); tiles.addEventListener( 'after-update', this._onUpdateAfter ); tiles.addEventListener( 'tile-visibility-change', this._onVisibilityChange ); - overlay.addEventListener( 'region-visibility-change', this._onRegionChange ); + tiles.addEventListener( 'load-model', this._onModelLoad ); + tiles.addEventListener( 'dispose-model', this._onModelDispose ); // @@ -147,7 +209,8 @@ export class MVTAnnotationsPlugin { this.locks.removeEventListener( 'toggle', this._onLockToggle ); this.tiles.removeEventListener( 'after-update', this._onUpdateAfter ); this.tiles.removeEventListener( 'tile-visibility-change', this._onVisibilityChange ); - this.overlay.removeEventListener( 'region-visibility-change', this._onRegionChange ); + this.tiles.removeEventListener( 'load-model', this._onModelLoad ); + this.tiles.removeEventListener( 'dispose-model', this._onModelDispose ); this.tiles.forEachLoadedModel( ( scene, tile ) => { @@ -157,4 +220,10 @@ export class MVTAnnotationsPlugin { } + async processTileModel( scene, tile ) { + + // TODO: await content + + } + } From c7be76d779dd1bd5ff50c6391d473c515e6c26ac Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Mon, 1 Jun 2026 16:29:53 +0900 Subject: [PATCH 06/27] Clean up event dispatcher --- src/three/plugins/images/ImageOverlayPlugin.js | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/three/plugins/images/ImageOverlayPlugin.js b/src/three/plugins/images/ImageOverlayPlugin.js index e4b38a789..37e504414 100644 --- a/src/three/plugins/images/ImageOverlayPlugin.js +++ b/src/three/plugins/images/ImageOverlayPlugin.js @@ -1373,7 +1373,7 @@ export class ImageOverlayPlugin { * @param {boolean} [options.alphaInvert=false] If true, inverts the alpha channel before * applying the mask or blend. */ -export class ImageOverlay extends EventDispatcher { +export class ImageOverlay { get isPlanarProjection() { @@ -1383,8 +1383,6 @@ export class ImageOverlay extends EventDispatcher { constructor( options = {} ) { - super(); - const { opacity = 1, color = 0xffffff, @@ -1490,11 +1488,6 @@ export class ImageOverlay extends EventDispatcher { entry = { range: [ ...range ], count: 0 }; _visibleRegionCounts.set( key, entry ); - this.dispatchEvent( { - type: 'region-visibility-change', - range, - visible, - } ); } @@ -1507,11 +1500,6 @@ export class ImageOverlay extends EventDispatcher { } else if ( entry.count === 0 ) { _visibleRegionCounts.delete( key ); - this.dispatchEvent( { - type: 'region-visibility-change', - range, - visible, - } ); } From 84ef3cf2cd768418c927175b619eac9d7fa41844 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Mon, 1 Jun 2026 22:19:39 +0900 Subject: [PATCH 07/27] Updates --- .../plugins/images/ImageOverlayPlugin.js | 2 +- src/three/plugins/mvt/MVTAnnotationsPlugin.js | 264 ++++++++++++------ 2 files changed, 182 insertions(+), 84 deletions(-) diff --git a/src/three/plugins/images/ImageOverlayPlugin.js b/src/three/plugins/images/ImageOverlayPlugin.js index 37e504414..76f2f8f75 100644 --- a/src/three/plugins/images/ImageOverlayPlugin.js +++ b/src/three/plugins/images/ImageOverlayPlugin.js @@ -1,7 +1,7 @@ /** @import { WebGLRenderer } from 'three' */ /** @import { WMTSTileMatrix } from './WMTSImageSource.js' */ /** @import { VectorTileStyle } from './utils/VectorShapeCanvasRenderer.js' */ -import { Color, BufferAttribute, Matrix4, Vector3, Box3, Triangle, CanvasTexture, EventDispatcher } from 'three'; +import { Color, BufferAttribute, Matrix4, Vector3, Box3, Triangle, CanvasTexture } from 'three'; import { PriorityQueue, PriorityQueueItemRemovedError, unifiedPriorityCallback } from '3d-tiles-renderer/core'; import { CesiumIonAuth, GoogleCloudAuth } from '3d-tiles-renderer/core/plugins'; import { XYZImageSource } from './sources/XYZImageSource.js'; diff --git a/src/three/plugins/mvt/MVTAnnotationsPlugin.js b/src/three/plugins/mvt/MVTAnnotationsPlugin.js index 0eb67c3d6..83045dac5 100644 --- a/src/three/plugins/mvt/MVTAnnotationsPlugin.js +++ b/src/three/plugins/mvt/MVTAnnotationsPlugin.js @@ -1,12 +1,29 @@ import { Group, Matrix4 } from 'three'; import { HierarchicalLock } from './HierarchicalLock.js'; import { ScreenOccupationManager } from './ScreenOccupationManager.js'; -import { getMeshesCartographicRange } from '../images/overlays/utils.js'; +import { forEachTileInBounds, getMeshesCartographicRange } from '../images/overlays/utils.js'; // TODO: -// - allow for blocking tile loads optionally // - "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(); export class MVTAnnotationsPlugin { @@ -23,6 +40,12 @@ export class MVTAnnotationsPlugin { } + get contentCache() { + + return this.overlay.imageSource._contentCache; + + } + constructor( options = {} ) { this.priority = Infinity; @@ -65,105 +88,60 @@ export class MVTAnnotationsPlugin { this.tiles = tiles; tiles.group.add( group ); - this._onDownloadStart = () => { - - // TODO: use built-in region if available + this._onDownloadStart = ( { tile } ) => { - }; - - // event callbacks - this._onModelLoad = async ( { tile, scene } ) => { - - // TODO: move to "process model" - - // TODO: this currently only work with ellipsoidal projection - let meshes = []; - scene.updateMatrixWorld(); - scene.traverse( c => { - - if ( c.isMesh ) { - - meshes.push( c ); - - } - - } ); - - _matrix.identity(); - if ( scene.parent !== null ) { - - _matrix.copy( tiles.group.matrixWorldInverse ); - - } - - // TODO: why are we passing range vs region here? - const { range } = getMeshesCartographicRange( meshes, tiles.ellipsoid, _matrix, overlay.projection ); const info = { - range, + range: null, loaded: false, disposed: false, }; tileInfo.set( tile, info ); - let promises = []; + if ( overlay.isReady && tile.boundingVolume.region ) { - // TODO: lock all related MVT sub tiles in a 2x2 pattern + // 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 ); - await Promise.all( promises ); + info.range = range; - info.loaded = true; - if ( info.disposed ) { + // TODO: we need to avoid double locking here and below with no synchronized release + // const { contentCache } = this; + // this._forEach2x2TileInBounds( range, ( x, y, l ) => { - return; + // // lock MVT content in a 2x2 pattern + // contentCache.lock( x, y, l ); - } - - if ( tiles.visibleTiles.has( tile ) ) { - - // TODO: mark all tiles as "active" if visible in a 2x2 pattern + // } ); } - - // - - - // TODO: lock necessary sub MVT tile content on load to prepare - // - do not delay tiles - // - do not "lock" sub tile content until it's loaded - // - what happens if only one of the sub tiles is loaded / locked? Display parent + children? - - }; - - this._onModelDispose = ( { tile } ) => { - - const info = tileInfo.get( tile ); - const { range } = info; - - // TODO: unlock all MVT sub tiles in a 2x2 pattern - - tileInfo.delete( tile ); - info.disposed = true; - }; + // event callbacks this._onVisibilityChange = ( { tile, visible } ) => { const { loaded, range } = tileInfo.get( tile ); if ( loaded ) { - // TODO: mark all tiles as "active" if visible in a 2x2 pattern - if ( visible ) { + this._forEach2x2TileInBounds( range, ( x, y, l ) => { - // locks.markActive( x, y, l ); + // mark all tiles as "active" if visible in a 2x2 pattern + if ( visible ) { - } else { + locks.markActive( x, y, l ); - // locks.markInactive( x, y, l ); + } else { - } + locks.markInactive( x, y, l ); + } + + } ); } @@ -178,10 +156,29 @@ export class MVTAnnotationsPlugin { this._onLockToggle = ( { x, y, level, active } ) => { - // TODO: - // - retrieve the associated tile annotations - // - add / remove items from the group or associated managers, "settling" - // them as they are added + const { contentCache } = this; + if ( active ) { + + const tile = contentCache.get( x, y, level ); + + // TODO: + // - read content + // - settle content + // - create instances with unique ids + // - cache content associated with x y + // - add them to the occupancy grid + + // CONSIDERATIONS + // - pre-load and cache "loaded" versions of annotations + // - allow for "deferred" versions of these callbacks so that + // parent tiles can stick around while target tiles are settled + // - only settle those that are visible + + } else { + + // TODO: read instances from cache, release the content from grid + + } }; @@ -189,15 +186,13 @@ export class MVTAnnotationsPlugin { locks.addEventListener( 'toggle', this._onLockToggle ); tiles.addEventListener( 'after-update', this._onUpdateAfter ); tiles.addEventListener( 'tile-visibility-change', this._onVisibilityChange ); - tiles.addEventListener( 'load-model', this._onModelLoad ); - tiles.addEventListener( 'dispose-model', this._onModelDispose ); // // late initialization tiles.forEachLoadedModel( ( scene, tile ) => { - this._onVisibilityChange( { scene, tile, visible: true } ); + this.processTileModel( scene, tile ); } ); @@ -209,8 +204,6 @@ export class MVTAnnotationsPlugin { this.locks.removeEventListener( 'toggle', this._onLockToggle ); this.tiles.removeEventListener( 'after-update', this._onUpdateAfter ); this.tiles.removeEventListener( 'tile-visibility-change', this._onVisibilityChange ); - this.tiles.removeEventListener( 'load-model', this._onModelLoad ); - this.tiles.removeEventListener( 'dispose-model', this._onModelDispose ); this.tiles.forEachLoadedModel( ( scene, tile ) => { @@ -222,7 +215,112 @@ export class MVTAnnotationsPlugin { async processTileModel( scene, tile ) { - // TODO: await content + const { overlay, tiles, tileInfo, locks } = this; + if ( ! overlay.isReady ) { + + await overlay.whenReady(); + + } + + const info = tileInfo.get( tile ); + 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 ) ); + + } ); + + await Promise.all( promises ); + + info.loaded = true; + if ( info.disposed ) { + + return; + + } + + 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 ); + + } ); + + } + + } + + disposeTile( tile ) { + + const { tileInfo, contentCache } = this; + const info = tileInfo.get( tile ); + + 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 ); + + } ); } From ef52555c07144bce087db372440f77911a46bfa3 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Mon, 1 Jun 2026 22:50:47 +0900 Subject: [PATCH 08/27] Small fixes --- src/three/plugins/mvt/HierarchicalLock.js | 23 ++++--------------- src/three/plugins/mvt/MVTAnnotationsPlugin.js | 14 +++++++++-- .../plugins/mvt/ScreenOccupationManager.js | 2 +- 3 files changed, 18 insertions(+), 21 deletions(-) diff --git a/src/three/plugins/mvt/HierarchicalLock.js b/src/three/plugins/mvt/HierarchicalLock.js index 71e98ec74..5d3a335c8 100644 --- a/src/three/plugins/mvt/HierarchicalLock.js +++ b/src/three/plugins/mvt/HierarchicalLock.js @@ -45,11 +45,9 @@ export class HierarchicalLock extends EventDispatcher { x, y, level: l, - present: 0, ref: 0, override: 0, - activeDispatched: false, - presentDispatched: false, + dispatched: false, }; } @@ -85,29 +83,18 @@ export class HierarchicalLock extends EventDispatcher { const { locks } = this; const lock = locks[ key ]; const active = lock.ref > 0 && lock.override === 0; - if ( active !== lock.activeDispatched ) { + if ( active !== lock.dispatched ) { const { x, y, level } = lock; - lock.activeDispatched = active; + lock.dispatched = active; this.dispatchEvent( { - type: 'active-toggle', + type: 'toggle', active, x, y, level, } ); } - if ( Boolean( lock.present ) !== lock.presentDispatched ) { - - const { x, y, level } = lock; - lock.activeDispatched = lock.present; - this.dispatchEvent( { - type: 'present-toggle', - active, x, y, level, - } ); - - } - - if ( lock.ref === 0 && lock.override === 0 && lock.present === 0 ) { + if ( lock.ref === 0 && lock.override === 0 ) { delete locks[ key ]; diff --git a/src/three/plugins/mvt/MVTAnnotationsPlugin.js b/src/three/plugins/mvt/MVTAnnotationsPlugin.js index 83045dac5..be74c0c58 100644 --- a/src/three/plugins/mvt/MVTAnnotationsPlugin.js +++ b/src/three/plugins/mvt/MVTAnnotationsPlugin.js @@ -88,7 +88,7 @@ export class MVTAnnotationsPlugin { this.tiles = tiles; tiles.group.add( group ); - this._onDownloadStart = ( { tile } ) => { + this._onTileDownloadStart = ( { tile } ) => { const info = { range: null, @@ -186,6 +186,7 @@ export class MVTAnnotationsPlugin { locks.addEventListener( 'toggle', this._onLockToggle ); tiles.addEventListener( 'after-update', this._onUpdateAfter ); tiles.addEventListener( 'tile-visibility-change', this._onVisibilityChange ); + tiles.addEventListener( 'tile-download-start', this._onTileDownloadStart ); // @@ -204,6 +205,7 @@ export class MVTAnnotationsPlugin { this.locks.removeEventListener( 'toggle', this._onLockToggle ); this.tiles.removeEventListener( 'after-update', this._onUpdateAfter ); this.tiles.removeEventListener( 'tile-visibility-change', this._onVisibilityChange ); + this.tiles.removeEventListener( 'tile-download-start', this._onTileDownloadStart ); this.tiles.forEachLoadedModel( ( scene, tile ) => { @@ -256,7 +258,15 @@ export class MVTAnnotationsPlugin { } ); - await Promise.all( promises ); + try { + + await Promise.all( promises ); + + } catch { + + return; + + } info.loaded = true; if ( info.disposed ) { diff --git a/src/three/plugins/mvt/ScreenOccupationManager.js b/src/three/plugins/mvt/ScreenOccupationManager.js index 008cc4041..7596f3267 100644 --- a/src/three/plugins/mvt/ScreenOccupationManager.js +++ b/src/three/plugins/mvt/ScreenOccupationManager.js @@ -85,7 +85,7 @@ export class ScreenOccupationManager extends EventDispatcher { } else if ( prevVisible.has( item ) ) { - removed.delete( item ); + removed.add( item ); } From 37875022b382b2ac438c313f21302fd3908a27e3 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Tue, 2 Jun 2026 01:12:31 +0900 Subject: [PATCH 09/27] Add notes --- .../plugins/mvt/ScreenOccupationManager.js | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/three/plugins/mvt/ScreenOccupationManager.js b/src/three/plugins/mvt/ScreenOccupationManager.js index 7596f3267..31e6876dc 100644 --- a/src/three/plugins/mvt/ScreenOccupationManager.js +++ b/src/three/plugins/mvt/ScreenOccupationManager.js @@ -1,5 +1,27 @@ import { EventDispatcher, Vector2 } from 'three'; +// ScreenOccupationManager handles screen-space collision de-confliction for annotations. +// +// LoD transition strategy: +// When the active MVT tile set changes (LoD change), annotations are reconciled by feature ID: +// +// 1. STABLE annotations (feature ID present in both old and new LoD): +// - Remain registered and visible at their existing world position. +// - Their elevation is updated asynchronously via the raycast queue once the new terrain +// tile mesh is available. The occupancy grid is updated in-place when the new position settles. +// +// 2. DISAPPEARED annotations (feature ID present in old LoD, absent in new): +// - Unregistered and faded out immediately when the old MVT tile is released. +// +// 3. NEW annotations (feature ID absent in old LoD, present in new): +// - Queued for elevation raycasting. Not registered until raycasting is complete. +// - Processed in descending priority order (rank / importance) so that high-priority +// annotations claim grid cells first, preventing low-priority annotations from +// blocking them and then being evicted. +// +// The occupancy grid is always in a valid state — stable annotations never leave the grid +// during a transition, so there are no frames where previously visible content disappears. + export class ScreenOccupationManager extends EventDispatcher { constructor() { From 2e786458f3a637cee06cd039f9342845d297796a Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Tue, 2 Jun 2026 13:02:29 +0900 Subject: [PATCH 10/27] Clean up --- .../plugins/mvt/ScreenOccupationManager.js | 120 ++++++++++++++---- 1 file changed, 96 insertions(+), 24 deletions(-) diff --git a/src/three/plugins/mvt/ScreenOccupationManager.js b/src/three/plugins/mvt/ScreenOccupationManager.js index 31e6876dc..d97542853 100644 --- a/src/three/plugins/mvt/ScreenOccupationManager.js +++ b/src/three/plugins/mvt/ScreenOccupationManager.js @@ -22,6 +22,26 @@ import { EventDispatcher, Vector2 } from 'three'; // The occupancy grid is always in a valid state — stable annotations never leave the grid // during a transition, so there are no frames where previously visible content disappears. +export class AnnotationItem { + + constructor() { + + this.id = ''; + + } + + updateTransform( camera ) { + + } + + evaluate( handle ) { + + return false; + + } + +} + export class ScreenOccupationManager extends EventDispatcher { constructor() { @@ -41,12 +61,62 @@ export class ScreenOccupationManager extends EventDispatcher { this.visible = new Set(); this.prevVisible = new Set(); this.added = new Set(); - this.removed = new Set(); + this.handle = { + test: ( x, y, r ) => { + + const { cells } = this; + return this._cellRange( x, y, r, ( x, y, i ) => { + + return cells[ i ] !== 0; + + } ); + + }, + 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 } = this; + const width = Math.ceil( resolution.width / size ); + const height = Math.ceil( resolution.height / size ); + const x0 = Math.max( 0, Math.floor( ( x - r ) / size ) ); + const y0 = Math.max( 0, Math.floor( ( y - r ) / size ) ); + const x1 = Math.min( width - 1, Math.floor( ( x + r ) / size ) ); + const y1 = Math.min( height - 1, Math.floor( ( y + 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 { @@ -57,7 +127,7 @@ export class ScreenOccupationManager extends EventDispatcher { visible, prevVisible, added, - removed, + handle, } = this; // resize the occupation cells @@ -73,49 +143,47 @@ export class ScreenOccupationManager extends EventDispatcher { } + // transform the shape to the screen + if ( camera !== null ) { + + for ( let i = 0, l = items.length; i < l; i ++ ) { + + items[ i ].updateTransform( camera ); + + } + + } + // sort the items items.sort( this.sortCallback ); - // save the visible set so we can know which had been removed - removed.clear(); + // prevVisible starts as last frame's visible set; items placed this frame are + // deleted from it, leaving only items that disappeared (the removed set) added.clear(); visible.clear(); for ( let i = 0, l = items.length; i < l; i ++ ) { const item = items[ i ]; - let canDisplay = true; - - if ( camera !== null ) { - - // TODO: - // - transform the shape to the screen - // - check occupancy - - } - if ( canDisplay ) { - - // TODO: mark occupancy if possible + // check & mark occupancy + if ( camera && item.evaluate( handle ) ) { visible.add( item ); if ( ! prevVisible.has( item ) ) { added.add( item ); - } + } else { - } else if ( prevVisible.has( item ) ) { + prevVisible.delete( item ); - removed.add( item ); + } } } - // swap the visibility - [ this.visible, this.prevVisible ] = [ this.prevVisible, this.visible ]; - // events if ( added.size > 0 ) { @@ -123,12 +191,16 @@ export class ScreenOccupationManager extends EventDispatcher { } - if ( removed.size > 0 ) { + if ( this.prevVisible.size > 0 ) { - this.dispatchEvent( { type: 'removed', items: removed } ); + // prev visible now only contains the "removed" items + this.dispatchEvent( { type: 'removed', items: this.prevVisible } ); } + // swap the visibility for next update + [ this.visible, this.prevVisible ] = [ this.prevVisible, this.visible ]; + } register( item ) { From d35add833aa72bdfa6b4db586f2a3a582a3ef24e Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Tue, 2 Jun 2026 13:25:30 +0900 Subject: [PATCH 11/27] Add some point annotation logic --- src/three/plugins/mvt/MVTAnnotationsPlugin.js | 11 +++- .../plugins/mvt/ScreenOccupationManager.js | 60 ++++++++++++++++++- src/three/renderer/tiles/TilesRenderer.js | 15 +++++ 3 files changed, 81 insertions(+), 5 deletions(-) diff --git a/src/three/plugins/mvt/MVTAnnotationsPlugin.js b/src/three/plugins/mvt/MVTAnnotationsPlugin.js index be74c0c58..f3ed7dc48 100644 --- a/src/three/plugins/mvt/MVTAnnotationsPlugin.js +++ b/src/three/plugins/mvt/MVTAnnotationsPlugin.js @@ -149,6 +149,13 @@ export class MVTAnnotationsPlugin { this._onUpdateAfter = () => { + // sync camera resolution into occupancy grid + if ( this.camera !== null ) { + + tiles.getResolution( this.camera, occupancy.resolution ); + + } + // update visible text, points based on screen space conflicts occupancy.update(); @@ -184,7 +191,7 @@ export class MVTAnnotationsPlugin { // register events locks.addEventListener( 'toggle', this._onLockToggle ); - tiles.addEventListener( 'after-update', this._onUpdateAfter ); + tiles.addEventListener( 'update-after', this._onUpdateAfter ); tiles.addEventListener( 'tile-visibility-change', this._onVisibilityChange ); tiles.addEventListener( 'tile-download-start', this._onTileDownloadStart ); @@ -203,7 +210,7 @@ export class MVTAnnotationsPlugin { this.group.removeFromParent(); this.locks.removeEventListener( 'toggle', this._onLockToggle ); - this.tiles.removeEventListener( 'after-update', this._onUpdateAfter ); + this.tiles.removeEventListener( 'update-after', this._onUpdateAfter ); this.tiles.removeEventListener( 'tile-visibility-change', this._onVisibilityChange ); this.tiles.removeEventListener( 'tile-download-start', this._onTileDownloadStart ); diff --git a/src/three/plugins/mvt/ScreenOccupationManager.js b/src/three/plugins/mvt/ScreenOccupationManager.js index d97542853..1310ec578 100644 --- a/src/three/plugins/mvt/ScreenOccupationManager.js +++ b/src/three/plugins/mvt/ScreenOccupationManager.js @@ -1,4 +1,4 @@ -import { EventDispatcher, Vector2 } from 'three'; +import { EventDispatcher, Vector2, Vector3, WebGPUCoordinateSystem } from 'three'; // ScreenOccupationManager handles screen-space collision de-confliction for annotations. // @@ -27,10 +27,12 @@ export class AnnotationItem { constructor() { this.id = ''; + this.layer = ''; + this.properties = null; } - updateTransform( camera ) { + updateTransform( camera, resolution ) { } @@ -42,6 +44,58 @@ export class AnnotationItem { } +export class PointAnnotationItem extends AnnotationItem { + + constructor() { + + super(); + + this.position = new Vector3(); + this.radius = 10; + + // x/y = screen pixels, z = NDC depth (z > 1 means behind camera) + this._screenPos = new Vector3(); + + } + + updateTransform( camera, resolution ) { + + const screenPos = this._screenPos; + const position = this.position; + + screenPos.copy( position ).project( camera ); + + const zMin = camera.coordinateSystem === WebGPUCoordinateSystem ? 0 : - 1; + 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 < zMin || z > 1 ) ? 1 : 0; + + } + + evaluate( handle ) { + + const { _screenPos, radius } = this; + if ( _screenPos.z !== 0 ) { + + 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() { @@ -148,7 +202,7 @@ export class ScreenOccupationManager extends EventDispatcher { for ( let i = 0, l = items.length; i < l; i ++ ) { - items[ i ].updateTransform( camera ); + items[ i ].updateTransform( camera, resolution ); } 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. From 7302e45374de92d5607f343e3219b69fb506b49d Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Tue, 2 Jun 2026 14:47:26 +0900 Subject: [PATCH 12/27] Updates --- src/three/plugins/mvt/MVTAnnotationsPlugin.js | 103 +++++++++++++++--- 1 file changed, 86 insertions(+), 17 deletions(-) diff --git a/src/three/plugins/mvt/MVTAnnotationsPlugin.js b/src/three/plugins/mvt/MVTAnnotationsPlugin.js index f3ed7dc48..fd55cf7ff 100644 --- a/src/three/plugins/mvt/MVTAnnotationsPlugin.js +++ b/src/three/plugins/mvt/MVTAnnotationsPlugin.js @@ -1,6 +1,6 @@ -import { Group, Matrix4 } from 'three'; +import { Group, MathUtils, Matrix4 } from 'three'; import { HierarchicalLock } from './HierarchicalLock.js'; -import { ScreenOccupationManager } from './ScreenOccupationManager.js'; +import { PointAnnotationItem, ScreenOccupationManager } from './ScreenOccupationManager.js'; import { forEachTileInBounds, getMeshesCartographicRange } from '../images/overlays/utils.js'; // TODO: @@ -67,8 +67,12 @@ export class MVTAnnotationsPlugin { 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; - // TODO: add "points" manager for icons // TODO: add "text" manager for text // TODO: add a "fade" manager for hiding an showing annotations @@ -163,27 +167,92 @@ export class MVTAnnotationsPlugin { this._onLockToggle = ( { x, y, level, active } ) => { - const { contentCache } = this; + const key = `${ x }_${ y }_${ level }`; + if ( active ) { - const tile = contentCache.get( x, y, level ); + 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 ) { + + 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; + + } - // TODO: - // - read content - // - settle content - // - create instances with unique ids - // - cache content associated with x y - // - add them to the occupancy grid + // retrieve the geometry + const geometry = feature.loadGeometry(); + for ( const [ point ] of geometry ) { - // CONSIDERATIONS - // - pre-load and cache "loaded" versions of annotations - // - allow for "deferred" versions of these callbacks so that - // parent tiles can stick around while target tiles are settled - // - only settle those that are visible + 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(); + item.id = 'id' in feature ? `${ layerName }_${ feature.id }` : `${ x }_${ y }_${ level }_${ layerName }_${ i }`; + item.layer = layerName; + item.properties = feature.properties; + tiles.ellipsoid.getCartographicToPosition( lat, lon, 0, item.position ); + + occupancy.register( item ); + items.push( item ); + + } + + } + + } + + tileItems.set( key, items ); } else { - // TODO: read instances from cache, release the content from grid + const { occupancy, tileItems } = this; + const items = tileItems.get( key ); + if ( items ) { + + for ( const item of items ) { + + occupancy.unregister( item ); + + } + + tileItems.delete( key ); + + } } From df8826c832f4c6cc82daf971e4ed8780008d6a3b Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Tue, 2 Jun 2026 15:28:26 +0900 Subject: [PATCH 13/27] clean up --- src/three/plugins/mvt/MVTAnnotationsPlugin.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/three/plugins/mvt/MVTAnnotationsPlugin.js b/src/three/plugins/mvt/MVTAnnotationsPlugin.js index fd55cf7ff..311a7fe54 100644 --- a/src/three/plugins/mvt/MVTAnnotationsPlugin.js +++ b/src/three/plugins/mvt/MVTAnnotationsPlugin.js @@ -300,7 +300,15 @@ export class MVTAnnotationsPlugin { } - const info = tileInfo.get( tile ); + // 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; @@ -368,6 +376,7 @@ export class MVTAnnotationsPlugin { const { tileInfo, contentCache } = this; const info = tileInfo.get( tile ); + if ( ! info ) return; this._forEach2x2TileInBounds( info.range, ( x, y, l ) => { From 9385e01c8ae07ac7072570e134f4d9ab6b84747d Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Tue, 2 Jun 2026 16:57:45 +0900 Subject: [PATCH 14/27] Add points --- example/three/pmtiles.js | 7 ++++ src/three/plugins/mvt/MVTAnnotationsPlugin.js | 36 ++++++++++++++++--- 2 files changed, 39 insertions(+), 4 deletions(-) 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/three/plugins/mvt/MVTAnnotationsPlugin.js b/src/three/plugins/mvt/MVTAnnotationsPlugin.js index 311a7fe54..15dff9c9e 100644 --- a/src/three/plugins/mvt/MVTAnnotationsPlugin.js +++ b/src/three/plugins/mvt/MVTAnnotationsPlugin.js @@ -1,4 +1,4 @@ -import { Group, MathUtils, Matrix4 } from 'three'; +import { BufferAttribute, BufferGeometry, Group, MathUtils, Matrix4, Points, PointsMaterial } from 'three'; import { HierarchicalLock } from './HierarchicalLock.js'; import { PointAnnotationItem, ScreenOccupationManager } from './ScreenOccupationManager.js'; import { forEachTileInBounds, getMeshesCartographicRange } from '../images/overlays/utils.js'; @@ -68,6 +68,7 @@ export class MVTAnnotationsPlugin { this.tileInfo = new Map(); this.tileItems = new Map(); + this.tilePoints = new Map(); // callback to filter which features become annotations: // getAnnotation( layerName, properties ) → boolean @@ -225,7 +226,7 @@ export class MVTAnnotationsPlugin { item.id = 'id' in feature ? `${ layerName }_${ feature.id }` : `${ x }_${ y }_${ level }_${ layerName }_${ i }`; item.layer = layerName; item.properties = feature.properties; - tiles.ellipsoid.getCartographicToPosition( lat, lon, 0, item.position ); + tiles.ellipsoid.getCartographicToPosition( lat, lon, 100000, item.position ); occupancy.register( item ); items.push( item ); @@ -238,9 +239,23 @@ export class MVTAnnotationsPlugin { tileItems.set( key, items ); + const positions = new Float32Array( items.length * 3 ); + for ( let j = 0; j < items.length; j ++ ) { + + items[ j ].position.toArray( positions, j * 3 ); + + } + + const geometry = new BufferGeometry(); + geometry.setAttribute( 'position', new BufferAttribute( positions, 3 ) ); + const points = new Points( geometry, new PointsMaterial( { size: 10, sizeAttenuation: false } ) ); + group.add( points ); + points.updateMatrixWorld( true ); + this.tilePoints.set( key, points ); + } else { - const { occupancy, tileItems } = this; + const { occupancy, tileItems, tilePoints } = this; const items = tileItems.get( key ); if ( items ) { @@ -254,6 +269,15 @@ export class MVTAnnotationsPlugin { } + const points = tilePoints.get( key ); + if ( points ) { + + points.removeFromParent(); + points.geometry.dispose(); + tilePoints.delete( key ); + + } + } }; @@ -376,7 +400,11 @@ export class MVTAnnotationsPlugin { const { tileInfo, contentCache } = this; const info = tileInfo.get( tile ); - if ( ! info ) return; + if ( ! info ) { + + return; + + } this._forEach2x2TileInBounds( info.range, ( x, y, l ) => { From 9578ffe5d5a6a92b3be11b28baab6bc477d8612b Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Tue, 2 Jun 2026 17:56:22 +0900 Subject: [PATCH 15/27] Occupancy debug --- src/three/plugins/mvt/MVTAnnotationsPlugin.js | 187 +++++++++++++----- .../plugins/mvt/ScreenOccupationManager.js | 28 +-- 2 files changed, 157 insertions(+), 58 deletions(-) diff --git a/src/three/plugins/mvt/MVTAnnotationsPlugin.js b/src/three/plugins/mvt/MVTAnnotationsPlugin.js index 15dff9c9e..99fccf243 100644 --- a/src/three/plugins/mvt/MVTAnnotationsPlugin.js +++ b/src/three/plugins/mvt/MVTAnnotationsPlugin.js @@ -26,20 +26,9 @@ function collectMeshes( object ) { } const _matrix = /* @__PURE__ */ new Matrix4(); +const _ndcMatrix = /* @__PURE__ */ new Matrix4(); export class MVTAnnotationsPlugin { - get camera() { - - return this.occupancy.camera; - - } - - set camera( v ) { - - this.occupancy.camera = v; - - } - get contentCache() { return this.overlay.imageSource._contentCache; @@ -55,6 +44,7 @@ export class MVTAnnotationsPlugin { overlay, camera = null, scene = null, + displayOccupancyGrid = true, } = options; this.overlay = overlay; @@ -68,11 +58,11 @@ export class MVTAnnotationsPlugin { this.tileInfo = new Map(); this.tileItems = new Map(); - this.tilePoints = new Map(); // callback to filter which features become annotations: // getAnnotation( layerName, properties ) → boolean this.getAnnotation = null; + this.displayOccupancyGrid = displayOccupancyGrid; // TODO: add "text" manager for text // TODO: add a "fade" manager for hiding an showing annotations @@ -89,6 +79,20 @@ export class MVTAnnotationsPlugin { const { locks, group, overlay, occupancy, tileInfo } = this; + // 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; + + const points = new Points(); + points.material.size = 10; + points.material.sizeAttenuation = false; + points.frustumCulled = false; + group.add( points ); + points.updateMatrixWorld( true ); + this.POINTS = points; + // init container this.tiles = tiles; tiles.group.add( group ); @@ -152,18 +156,78 @@ export class MVTAnnotationsPlugin { }; + // single Points object updated from the occupancy visible set + const pointsGeometry = new BufferGeometry(); + this.points = new Points( pointsGeometry, new PointsMaterial( { size: 10, sizeAttenuation: false } ) ); + group.add( this.points ); + + const visibleItems = new Set(); + let pointsDirty = false; + occupancy.addEventListener( 'added', ( { items } ) => { + + for ( const item of items ) visibleItems.add( item ); + pointsDirty = true; + + } ); + occupancy.addEventListener( 'removed', ( { items } ) => { + + for ( const item of items ) visibleItems.delete( item ); + pointsDirty = true; + + } ); + this._visibleItems = visibleItems; + + // sort: stable (already visible) items first so they hold their cells (hysteresis), + // then closest to camera, then bottom-to-top on screen + occupancy.sortCallback = ( a, b ) => { + + if ( a._depth !== b._depth ) { + + return a._depth - b._depth; + + } else { + + return b._screenPos.y - a._screenPos.y; + + } + + }; + this._onUpdateAfter = () => { - // sync camera resolution into occupancy grid + // sync camera resolution and NDC matrix 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; + + } else { + + occupancy.matrix = null; + } // update visible text, points based on screen space conflicts occupancy.update(); + if ( pointsDirty ) { + + // console.log( occupancy.items, [ ...this._visibleItems ] ); + this._rebuildPoints( [ ...this._visibleItems ], this.POINTS ); + + } + + if ( this.displayOccupancyGrid ) { + + this._drawDebugGrid(); + + } + }; this._onLockToggle = ( { x, y, level, active } ) => { @@ -226,7 +290,7 @@ export class MVTAnnotationsPlugin { item.id = 'id' in feature ? `${ layerName }_${ feature.id }` : `${ x }_${ y }_${ level }_${ layerName }_${ i }`; item.layer = layerName; item.properties = feature.properties; - tiles.ellipsoid.getCartographicToPosition( lat, lon, 100000, item.position ); + tiles.ellipsoid.getCartographicToPosition( lat, lon, 10, item.position ); occupancy.register( item ); items.push( item ); @@ -239,23 +303,9 @@ export class MVTAnnotationsPlugin { tileItems.set( key, items ); - const positions = new Float32Array( items.length * 3 ); - for ( let j = 0; j < items.length; j ++ ) { - - items[ j ].position.toArray( positions, j * 3 ); - - } - - const geometry = new BufferGeometry(); - geometry.setAttribute( 'position', new BufferAttribute( positions, 3 ) ); - const points = new Points( geometry, new PointsMaterial( { size: 10, sizeAttenuation: false } ) ); - group.add( points ); - points.updateMatrixWorld( true ); - this.tilePoints.set( key, points ); - } else { - const { occupancy, tileItems, tilePoints } = this; + const { occupancy, tileItems } = this; const items = tileItems.get( key ); if ( items ) { @@ -269,15 +319,6 @@ export class MVTAnnotationsPlugin { } - const points = tilePoints.get( key ); - if ( points ) { - - points.removeFromParent(); - points.geometry.dispose(); - tilePoints.delete( key ); - - } - } }; @@ -301,6 +342,7 @@ export class MVTAnnotationsPlugin { dispose() { + this._debugCanvas.remove(); this.group.removeFromParent(); this.locks.removeEventListener( 'toggle', this._onLockToggle ); this.tiles.removeEventListener( 'update-after', this._onUpdateAfter ); @@ -376,13 +418,21 @@ export class MVTAnnotationsPlugin { } - info.loaded = true; 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 @@ -396,6 +446,51 @@ export class MVTAnnotationsPlugin { } + _drawDebugGrid() { + + const { occupancy, _debugCanvas } = this; + const { cells, size, resolution } = occupancy; + const dpr = window.devicePixelRatio; + const cols = Math.ceil( resolution.width / size ); + const rows = Math.ceil( resolution.height / size ); + + _debugCanvas.width = dpr * resolution.width; + _debugCanvas.height = dpr * resolution.height; + + const drawSize = size * dpr; + const ctx = _debugCanvas.getContext( '2d' ); + 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 ); + + } + + } + + } + + _rebuildPoints( items, target ) { + + const visible = [ ...items ]; + const positions = new Float32Array( visible.length * 3 ); + for ( let i = 0; i < visible.length; i ++ ) { + + visible[ i ].position.toArray( positions, i * 3 ); + + } + + target.geometry.setAttribute( 'position', new BufferAttribute( positions, 3 ) ); + target.geometry.dispose(); + + } + disposeTile( tile ) { const { tileInfo, contentCache } = this; @@ -406,12 +501,16 @@ export class MVTAnnotationsPlugin { } - this._forEach2x2TileInBounds( info.range, ( x, y, l ) => { + if ( info.loaded ) { + + this._forEach2x2TileInBounds( info.range, ( x, y, l ) => { - // unlock all MVT sub tiles in a 2x2 pattern - contentCache.release( x, y, l ); + // unlock all MVT sub tiles in a 2x2 pattern + contentCache.release( x, y, l ); - } ); + } ); + + } tileInfo.delete( tile ); info.disposed = true; diff --git a/src/three/plugins/mvt/ScreenOccupationManager.js b/src/three/plugins/mvt/ScreenOccupationManager.js index 1310ec578..6543f65bc 100644 --- a/src/three/plugins/mvt/ScreenOccupationManager.js +++ b/src/three/plugins/mvt/ScreenOccupationManager.js @@ -1,4 +1,4 @@ -import { EventDispatcher, Vector2, Vector3, WebGPUCoordinateSystem } from 'three'; +import { EventDispatcher, Vector2, Vector3 } from 'three'; // ScreenOccupationManager handles screen-space collision de-confliction for annotations. // @@ -32,7 +32,7 @@ export class AnnotationItem { } - updateTransform( camera, resolution ) { + updateTransform( matrix, resolution ) { } @@ -51,25 +51,25 @@ export class PointAnnotationItem extends AnnotationItem { super(); this.position = new Vector3(); - this.radius = 10; + this.radius = 5; // x/y = screen pixels, z = NDC depth (z > 1 means behind camera) this._screenPos = new Vector3(); + this._depth = 0; } - updateTransform( camera, resolution ) { + updateTransform( matrix, resolution ) { const screenPos = this._screenPos; - const position = this.position; - screenPos.copy( position ).project( camera ); + screenPos.copy( this.position ).applyMatrix4( matrix ); - const zMin = camera.coordinateSystem === WebGPUCoordinateSystem ? 0 : - 1; 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 < zMin || z > 1 ) ? 1 : 0; + screenPos.z = ( z < - 1 || z > 1 ) ? 1 : 0; + this._depth = z; } @@ -102,8 +102,8 @@ export class ScreenOccupationManager extends EventDispatcher { super(); - // camera - this.camera = null; + // projection matrix: projectionMatrix * matrixWorldInverse * tilesGroup.matrixWorld + this.matrix = null; // occupancy cells this.resolution = new Vector2( 1, 1 ); @@ -174,7 +174,7 @@ export class ScreenOccupationManager extends EventDispatcher { update() { const { - camera, + matrix, resolution, size, items, @@ -198,11 +198,11 @@ export class ScreenOccupationManager extends EventDispatcher { } // transform the shape to the screen - if ( camera !== null ) { + if ( matrix !== null ) { for ( let i = 0, l = items.length; i < l; i ++ ) { - items[ i ].updateTransform( camera, resolution ); + items[ i ].updateTransform( matrix, resolution ); } @@ -221,7 +221,7 @@ export class ScreenOccupationManager extends EventDispatcher { const item = items[ i ]; // check & mark occupancy - if ( camera && item.evaluate( handle ) ) { + if ( matrix !== null && item.evaluate( handle ) ) { visible.add( item ); if ( ! prevVisible.has( item ) ) { From 75f0953e845433e56a73eeb0740e3d0c946c05cc Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Tue, 2 Jun 2026 17:59:41 +0900 Subject: [PATCH 16/27] Fix --- src/three/plugins/mvt/MVTAnnotationsPlugin.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/three/plugins/mvt/MVTAnnotationsPlugin.js b/src/three/plugins/mvt/MVTAnnotationsPlugin.js index 99fccf243..76be185b5 100644 --- a/src/three/plugins/mvt/MVTAnnotationsPlugin.js +++ b/src/three/plugins/mvt/MVTAnnotationsPlugin.js @@ -44,7 +44,7 @@ export class MVTAnnotationsPlugin { overlay, camera = null, scene = null, - displayOccupancyGrid = true, + displayOccupancyGrid = false, } = options; this.overlay = overlay; @@ -66,6 +66,8 @@ export class MVTAnnotationsPlugin { // 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 } @@ -217,7 +219,7 @@ export class MVTAnnotationsPlugin { if ( pointsDirty ) { - // console.log( occupancy.items, [ ...this._visibleItems ] ); + pointsDirty = false; this._rebuildPoints( [ ...this._visibleItems ], this.POINTS ); } From e321a3fccfc38995dee3bac779f2ceda770d5dea Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Tue, 2 Jun 2026 18:24:16 +0900 Subject: [PATCH 17/27] Fixes --- src/three/plugins/mvt/MVTAnnotationsPlugin.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/three/plugins/mvt/MVTAnnotationsPlugin.js b/src/three/plugins/mvt/MVTAnnotationsPlugin.js index 76be185b5..aca145f80 100644 --- a/src/three/plugins/mvt/MVTAnnotationsPlugin.js +++ b/src/three/plugins/mvt/MVTAnnotationsPlugin.js @@ -164,17 +164,16 @@ export class MVTAnnotationsPlugin { group.add( this.points ); const visibleItems = new Set(); - let pointsDirty = false; occupancy.addEventListener( 'added', ( { items } ) => { for ( const item of items ) visibleItems.add( item ); - pointsDirty = true; + this._pointsDirty = true; } ); occupancy.addEventListener( 'removed', ( { items } ) => { for ( const item of items ) visibleItems.delete( item ); - pointsDirty = true; + this._pointsDirty = true; } ); this._visibleItems = visibleItems; @@ -217,9 +216,9 @@ export class MVTAnnotationsPlugin { // update visible text, points based on screen space conflicts occupancy.update(); - if ( pointsDirty ) { + if ( this._pointsDirty ) { - pointsDirty = false; + this._pointsDirty = false; this._rebuildPoints( [ ...this._visibleItems ], this.POINTS ); } From f7f9db30a8f127750cbf65ce4bc302ca26db51d4 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Tue, 2 Jun 2026 19:14:20 +0900 Subject: [PATCH 18/27] Initial raycaster implementation --- example/three/googleMapsExample.js | 15 ++++++ src/three/plugins/mvt/MVTAnnotationsPlugin.js | 53 +++++++++++++++++-- 2 files changed, 64 insertions(+), 4 deletions(-) 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/src/three/plugins/mvt/MVTAnnotationsPlugin.js b/src/three/plugins/mvt/MVTAnnotationsPlugin.js index aca145f80..bc906bef4 100644 --- a/src/three/plugins/mvt/MVTAnnotationsPlugin.js +++ b/src/three/plugins/mvt/MVTAnnotationsPlugin.js @@ -1,4 +1,5 @@ -import { BufferAttribute, BufferGeometry, Group, MathUtils, Matrix4, Points, PointsMaterial } from 'three'; +import { BufferAttribute, BufferGeometry, Group, MathUtils, Matrix4, Points, PointsMaterial, Raycaster } from 'three'; +import { PriorityQueue } from '3d-tiles-renderer/core'; import { HierarchicalLock } from './HierarchicalLock.js'; import { PointAnnotationItem, ScreenOccupationManager } from './ScreenOccupationManager.js'; import { forEachTileInBounds, getMeshesCartographicRange } from '../images/overlays/utils.js'; @@ -27,6 +28,7 @@ function collectMeshes( object ) { const _matrix = /* @__PURE__ */ new Matrix4(); const _ndcMatrix = /* @__PURE__ */ new Matrix4(); +const _raycaster = /* @__PURE__ */ new Raycaster(); export class MVTAnnotationsPlugin { get contentCache() { @@ -64,6 +66,11 @@ export class MVTAnnotationsPlugin { this.getAnnotation = null; this.displayOccupancyGrid = displayOccupancyGrid; + this._raycastQueue = new PriorityQueue(); + this._raycastQueue.maxJobs = 10; + this._raycastQueue.priorityCallback = () => 0; + this._pointsDirty = false; + // 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" @@ -136,7 +143,8 @@ export class MVTAnnotationsPlugin { // event callbacks this._onVisibilityChange = ( { tile, visible } ) => { - const { loaded, range } = tileInfo.get( tile ); + const info = tileInfo.get( tile ); + const { loaded, range } = info; if ( loaded ) { this._forEach2x2TileInBounds( range, ( x, y, l ) => { @@ -291,7 +299,7 @@ export class MVTAnnotationsPlugin { item.id = 'id' in feature ? `${ layerName }_${ feature.id }` : `${ x }_${ y }_${ level }_${ layerName }_${ i }`; item.layer = layerName; item.properties = feature.properties; - tiles.ellipsoid.getCartographicToPosition( lat, lon, 10, item.position ); + tiles.ellipsoid.getCartographicToPosition( lat, lon, 100, item.position ); occupancy.register( item ); items.push( item ); @@ -303,16 +311,22 @@ export class MVTAnnotationsPlugin { } tileItems.set( key, items ); + for ( const item of items ) { + + this._addToRaycastQueue( item ); + + } } else { - const { occupancy, tileItems } = this; + const { occupancy, tileItems, _raycastQueue } = this; const items = tileItems.get( key ); if ( items ) { for ( const item of items ) { occupancy.unregister( item ); + _raycastQueue.remove( item ); } @@ -477,6 +491,37 @@ export class MVTAnnotationsPlugin { } + _addToRaycastQueue( item ) { + + this._raycastQueue.add( item, () => { + + const { tiles } = this; + + // outward normal ≈ normalized position (WGS84 is <0.4% from spherical) + const { origin, direction } = _raycaster.ray; + direction.copy( item.position ).normalize(); + origin.copy( item.position ).addScaledVector( direction, 1e7 ); + direction.negate(); + + origin.applyMatrix4( tiles.group.matrixWorld ); + direction.transformDirection( tiles.group.matrixWorld ); + + const hits = _raycaster.intersectObject( tiles.group, true ); + if ( hits.length > 0 ) { + + hits[ 0 ].point.applyMatrix4( tiles.group.matrixWorldInverse ); + item.position.copy( hits[ 0 ].point ); + this._pointsDirty = true; + return; + + } + + // NOTE: missed - leave where it is + + } ); + + } + _rebuildPoints( items, target ) { const visible = [ ...items ]; From fceb8757e37561fc3c93d8e043f50fef5f62b407 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Tue, 2 Jun 2026 19:40:49 +0900 Subject: [PATCH 19/27] Fixes to tiles renderer --- src/core/renderer/tiles/TilesRendererBase.js | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/core/renderer/tiles/TilesRendererBase.js b/src/core/renderer/tiles/TilesRendererBase.js index 49fd42270..5e9e0877e 100644 --- a/src/core/renderer/tiles/TilesRendererBase.js +++ b/src/core/renderer/tiles/TilesRendererBase.js @@ -1067,18 +1067,28 @@ export class TilesRendererBase { // TODO: are these necessary? Are we disposing tiles when they are currently visible? 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 ) { From 97e486189093481801333f73737dfb38c088686d Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Tue, 2 Jun 2026 19:40:57 +0900 Subject: [PATCH 20/27] comment --- src/core/renderer/tiles/TilesRendererBase.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/renderer/tiles/TilesRendererBase.js b/src/core/renderer/tiles/TilesRendererBase.js index 5e9e0877e..5ea98cde6 100644 --- a/src/core/renderer/tiles/TilesRendererBase.js +++ b/src/core/renderer/tiles/TilesRendererBase.js @@ -1064,7 +1064,7 @@ 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 ) { if ( tile.internal.hasRenderableContent ) { From df4a87ddbb815a6d0fad02a839050bb6414664cb Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Tue, 2 Jun 2026 19:42:33 +0900 Subject: [PATCH 21/27] Fix jitter, add perspective handling for occupancy grid --- src/three/plugins/mvt/MVTAnnotationsPlugin.js | 54 +++++++++++++------ .../plugins/mvt/ScreenOccupationManager.js | 40 ++++++++++++-- 2 files changed, 73 insertions(+), 21 deletions(-) diff --git a/src/three/plugins/mvt/MVTAnnotationsPlugin.js b/src/three/plugins/mvt/MVTAnnotationsPlugin.js index bc906bef4..f02994f37 100644 --- a/src/three/plugins/mvt/MVTAnnotationsPlugin.js +++ b/src/three/plugins/mvt/MVTAnnotationsPlugin.js @@ -1,4 +1,4 @@ -import { BufferAttribute, BufferGeometry, Group, MathUtils, Matrix4, Points, PointsMaterial, Raycaster } from 'three'; +import { BufferAttribute, BufferGeometry, Group, MathUtils, Matrix4, Points, PointsMaterial, Raycaster, Vector3 } from 'three'; import { PriorityQueue } from '3d-tiles-renderer/core'; import { HierarchicalLock } from './HierarchicalLock.js'; import { PointAnnotationItem, ScreenOccupationManager } from './ScreenOccupationManager.js'; @@ -29,6 +29,7 @@ function collectMeshes( object ) { 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() { @@ -69,7 +70,6 @@ export class MVTAnnotationsPlugin { this._raycastQueue = new PriorityQueue(); this._raycastQueue.maxJobs = 10; this._raycastQueue.priorityCallback = () => 0; - this._pointsDirty = false; // TODO: add "text" manager for text // TODO: add a "fade" manager for hiding an showing annotations @@ -175,13 +175,11 @@ export class MVTAnnotationsPlugin { occupancy.addEventListener( 'added', ( { items } ) => { for ( const item of items ) visibleItems.add( item ); - this._pointsDirty = true; } ); occupancy.addEventListener( 'removed', ( { items } ) => { for ( const item of items ) visibleItems.delete( item ); - this._pointsDirty = true; } ); this._visibleItems = visibleItems; @@ -204,7 +202,7 @@ export class MVTAnnotationsPlugin { this._onUpdateAfter = () => { - // sync camera resolution and NDC matrix into occupancy grid + // sync camera resolution, NDC matrix, and local camera position into occupancy grid if ( this.camera !== null ) { tiles.getResolution( this.camera, occupancy.resolution ); @@ -215,22 +213,32 @@ export class MVTAnnotationsPlugin { .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; } - // update visible text, points based on screen space conflicts + // update visible points based on screen-space conflicts occupancy.update(); - if ( this._pointsDirty ) { + // camera-relative rendering: position the Points object at the camera so that + // buffer coordinates are small offsets — avoids Float32 precision jitter at globe scale + if ( this.camera !== null ) { - this._pointsDirty = false; - this._rebuildPoints( [ ...this._visibleItems ], this.POINTS ); + this.POINTS.position.copy( _cameraLocalPos ); + this.POINTS.updateMatrixWorld( true ); } + this._rebuildPoints( [ ...this._visibleItems ], this.POINTS ); + if ( this.displayOccupancyGrid ) { this._drawDebugGrid(); @@ -299,7 +307,7 @@ export class MVTAnnotationsPlugin { item.id = 'id' in feature ? `${ layerName }_${ feature.id }` : `${ x }_${ y }_${ level }_${ layerName }_${ i }`; item.layer = layerName; item.properties = feature.properties; - tiles.ellipsoid.getCartographicToPosition( lat, lon, 100, item.position ); + tiles.ellipsoid.getCartographicToPosition( lat, lon, 0, item.position ); occupancy.register( item ); items.push( item ); @@ -511,7 +519,6 @@ export class MVTAnnotationsPlugin { hits[ 0 ].point.applyMatrix4( tiles.group.matrixWorldInverse ); item.position.copy( hits[ 0 ].point ); - this._pointsDirty = true; return; } @@ -524,16 +531,29 @@ export class MVTAnnotationsPlugin { _rebuildPoints( items, target ) { - const visible = [ ...items ]; - const positions = new Float32Array( visible.length * 3 ); - for ( let i = 0; i < visible.length; i ++ ) { + const count = items.length; + const origin = target.position; + + let posAttr = target.geometry.getAttribute( 'position' ); + if ( ! posAttr || posAttr.count !== count ) { + + target.geometry.dispose(); + posAttr = new BufferAttribute( new Float32Array( count * 3 ), 3 ); + target.geometry.setAttribute( 'position', posAttr ); + + } + + const arr = posAttr.array; + for ( let i = 0; i < count; i ++ ) { - visible[ i ].position.toArray( positions, i * 3 ); + const p = items[ i ].position; + arr[ i * 3 + 0 ] = p.x - origin.x; + arr[ i * 3 + 1 ] = p.y - origin.y; + arr[ i * 3 + 2 ] = p.z - origin.z; } - target.geometry.setAttribute( 'position', new BufferAttribute( positions, 3 ) ); - target.geometry.dispose(); + posAttr.needsUpdate = true; } diff --git a/src/three/plugins/mvt/ScreenOccupationManager.js b/src/three/plugins/mvt/ScreenOccupationManager.js index 6543f65bc..bf275fc4b 100644 --- a/src/three/plugins/mvt/ScreenOccupationManager.js +++ b/src/three/plugins/mvt/ScreenOccupationManager.js @@ -22,6 +22,9 @@ import { EventDispatcher, Vector2, Vector3 } from 'three'; // The occupancy grid is always in a valid state — stable annotations never leave the grid // during a transition, so there are no frames where previously visible content disappears. +// suppress annotations within ~6° of the globe horizon +const PERSPECTIVE_CULL_THRESHOLD = 0.1; + export class AnnotationItem { constructor() { @@ -32,7 +35,7 @@ export class AnnotationItem { } - updateTransform( matrix, resolution ) { + updateTransform( matrix, resolution, cameraPosition ) { } @@ -56,10 +59,11 @@ export class PointAnnotationItem extends AnnotationItem { // 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 ) { + updateTransform( matrix, resolution, cameraPosition ) { const screenPos = this._screenPos; @@ -71,17 +75,41 @@ export class PointAnnotationItem extends AnnotationItem { screenPos.z = ( z < - 1 || z > 1 ) ? 1 : 0; this._depth = z; + // facing ratio: dot( surface normal, direction to camera ) + // surface normal ≈ normalize( position ) for 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; + + } + } evaluate( handle ) { - const { _screenPos, radius } = this; + const { _screenPos, radius, _facingRatio } = this; if ( _screenPos.z !== 0 ) { return false; } + if ( _facingRatio < PERSPECTIVE_CULL_THRESHOLD ) { + + return false; + + } + if ( handle.test( _screenPos.x, _screenPos.y, radius ) ) { return false; @@ -105,6 +133,9 @@ export class ScreenOccupationManager extends EventDispatcher { // 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; @@ -175,6 +206,7 @@ export class ScreenOccupationManager extends EventDispatcher { const { matrix, + cameraPosition, resolution, size, items, @@ -202,7 +234,7 @@ export class ScreenOccupationManager extends EventDispatcher { for ( let i = 0, l = items.length; i < l; i ++ ) { - items[ i ].updateTransform( matrix, resolution ); + items[ i ].updateTransform( matrix, resolution, cameraPosition ); } From 28185e3d9c475af024b28d4ba9508325edd7b5c1 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Tue, 2 Jun 2026 19:54:49 +0900 Subject: [PATCH 22/27] Fix debug canvas handling --- src/three/plugins/mvt/MVTAnnotationsPlugin.js | 44 +++++++++++++------ 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/src/three/plugins/mvt/MVTAnnotationsPlugin.js b/src/three/plugins/mvt/MVTAnnotationsPlugin.js index f02994f37..1aa21043a 100644 --- a/src/three/plugins/mvt/MVTAnnotationsPlugin.js +++ b/src/three/plugins/mvt/MVTAnnotationsPlugin.js @@ -88,12 +88,6 @@ export class MVTAnnotationsPlugin { const { locks, group, overlay, occupancy, tileInfo } = this; - // 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; - const points = new Points(); points.material.size = 10; points.material.sizeAttenuation = false; @@ -238,12 +232,7 @@ export class MVTAnnotationsPlugin { } this._rebuildPoints( [ ...this._visibleItems ], this.POINTS ); - - if ( this.displayOccupancyGrid ) { - - this._drawDebugGrid(); - - } + this._updateDebugGrid(); }; @@ -365,7 +354,12 @@ export class MVTAnnotationsPlugin { dispose() { - this._debugCanvas.remove(); + if ( this._debugCanvas ) { + + this._debugCanvas.remove(); + + } + this.group.removeFromParent(); this.locks.removeEventListener( 'toggle', this._onLockToggle ); this.tiles.removeEventListener( 'update-after', this._onUpdateAfter ); @@ -469,7 +463,29 @@ export class MVTAnnotationsPlugin { } - _drawDebugGrid() { + _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; + + } + const { occupancy, _debugCanvas } = this; const { cells, size, resolution } = occupancy; From 1671084ce67f3d81f43557eda83d29da2d047211 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Tue, 2 Jun 2026 21:10:39 +0900 Subject: [PATCH 23/27] Simplifications --- src/three/plugins/mvt/MVTAnnotationsPlugin.js | 20 ++- .../plugins/mvt/ScreenOccupationManager.js | 114 ++++++++++++++---- 2 files changed, 97 insertions(+), 37 deletions(-) diff --git a/src/three/plugins/mvt/MVTAnnotationsPlugin.js b/src/three/plugins/mvt/MVTAnnotationsPlugin.js index 1aa21043a..cf5c8ea33 100644 --- a/src/three/plugins/mvt/MVTAnnotationsPlugin.js +++ b/src/three/plugins/mvt/MVTAnnotationsPlugin.js @@ -91,6 +91,9 @@ export class MVTAnnotationsPlugin { const points = new Points(); points.material.size = 10; points.material.sizeAttenuation = false; + points.material.depthWrite = false; + points.material.depthTest = false; + points.renderOrder = 1000; points.frustumCulled = false; group.add( points ); points.updateMatrixWorld( true ); @@ -165,18 +168,7 @@ export class MVTAnnotationsPlugin { this.points = new Points( pointsGeometry, new PointsMaterial( { size: 10, sizeAttenuation: false } ) ); group.add( this.points ); - const visibleItems = new Set(); - occupancy.addEventListener( 'added', ( { items } ) => { - - for ( const item of items ) visibleItems.add( item ); - - } ); - occupancy.addEventListener( 'removed', ( { items } ) => { - - for ( const item of items ) visibleItems.delete( item ); - - } ); - this._visibleItems = visibleItems; + this._visibleItems = occupancy.visible; // sort: stable (already visible) items first so they hold their cells (hysteresis), // then closest to camera, then bottom-to-top on screen @@ -293,7 +285,9 @@ export class MVTAnnotationsPlugin { const [ lon, lat ] = tiling.toCartographicPoint( u, v ); const item = new PointAnnotationItem(); - item.id = 'id' in feature ? `${ layerName }_${ feature.id }` : `${ x }_${ y }_${ level }_${ layerName }_${ i }`; + // 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; tiles.ellipsoid.getCartographicToPosition( lat, lon, 0, item.position ); diff --git a/src/three/plugins/mvt/ScreenOccupationManager.js b/src/three/plugins/mvt/ScreenOccupationManager.js index bf275fc4b..3257dd580 100644 --- a/src/three/plugins/mvt/ScreenOccupationManager.js +++ b/src/three/plugins/mvt/ScreenOccupationManager.js @@ -22,7 +22,9 @@ import { EventDispatcher, Vector2, Vector3 } from 'three'; // The occupancy grid is always in a valid state — stable annotations never leave the grid // during a transition, so there are no frames where previously visible content disappears. -// suppress annotations within ~6° of the globe horizon +// TODO: we need to handle delayed removal further. It's possible that these delays belong in the parent system, instead + +// suppress annotations within ~6 degrees of the globe horizon const PERSPECTIVE_CULL_THRESHOLD = 0.1; export class AnnotationItem { @@ -32,6 +34,7 @@ export class AnnotationItem { this.id = ''; this.layer = ''; this.properties = null; + this._visibleDuration = 0; } @@ -79,6 +82,7 @@ export class PointAnnotationItem extends AnnotationItem { // surface normal ≈ normalize( position ) for WGS84 if ( cameraPosition !== null ) { + // TODO: fix this 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; @@ -138,14 +142,22 @@ export class ScreenOccupationManager extends EventDispatcher { // occupancy cells this.resolution = new Vector2( 1, 1 ); - this.size = 25; + this.size = 25 / window.devicePixelRatio; this.cells = new Uint8Array( 1 ); // items this.items = []; this.visible = new Set(); - this.prevVisible = new Set(); this.added = new Set(); + this.removed = new Set(); + + // seconds an item must be continuously occupied/absent before show/hide fires + this.delay = 0.25; + this._lastUpdateTime = - 1; + + // keyed registries for LoD-coherent replacement + this._itemsById = new Map(); + this._savedDurations = new Map(); this.handle = { test: ( x, y, r ) => { @@ -211,11 +223,17 @@ export class ScreenOccupationManager extends EventDispatcher { size, items, visible, - prevVisible, added, + removed, handle, + delay, } = this; + // compute delta time, capped to avoid large jumps after tab suspension + const now = performance.now() / 1000; + const dt = this._lastUpdateTime < 0 ? 0 : Math.min( now - this._lastUpdateTime, 0.1 ); + this._lastUpdateTime = now; + // resize the occupation cells const width = Math.ceil( resolution.width / size ); const height = Math.ceil( resolution.height / size ); @@ -243,62 +261,100 @@ export class ScreenOccupationManager extends EventDispatcher { // sort the items items.sort( this.sortCallback ); - // prevVisible starts as last frame's visible set; items placed this frame are - // deleted from it, leaving only items that disappeared (the removed set) added.clear(); - visible.clear(); + removed.clear(); for ( let i = 0, l = items.length; i < l; i ++ ) { const item = items[ i ]; + const occupied = matrix !== null && item.evaluate( handle ); - // check & mark occupancy - if ( matrix !== null && item.evaluate( handle ) ) { + // increment duration while occupied, decrement while absent (floored at 0) + if ( occupied ) { - visible.add( item ); - if ( ! prevVisible.has( item ) ) { + item._visibleDuration = Math.min( item._visibleDuration + dt, delay ); - added.add( item ); + } else { - } else { + item._visibleDuration = Math.max( item._visibleDuration - dt, 0 ); - prevVisible.delete( item ); + } - } + const wasVisible = visible.has( item ); + const visibleDuration = item._visibleDuration; + + // delay === 0: show only when currently occupied (avoids threshold=0 ambiguity) + if ( ! wasVisible && visibleDuration === delay ) { + + visible.add( item ); + added.add( item ); + + } else if ( wasVisible && ! occupied && visibleDuration === 0 ) { + + visible.delete( item ); + removed.add( item ); } } - // events if ( added.size > 0 ) { this.dispatchEvent( { type: 'added', items: added } ); } - if ( this.prevVisible.size > 0 ) { + if ( removed.size > 0 ) { - // prev visible now only contains the "removed" items - this.dispatchEvent( { type: 'removed', items: this.prevVisible } ); + this.dispatchEvent( { type: 'removed', items: removed } ); } - // swap the visibility for next update - [ this.visible, this.prevVisible ] = [ this.prevVisible, this.visible ]; - } register( item ) { // TODO: how to register / handle non-linear layouts for text - custom callback? - this.items.push( item ); + const { _itemsById, _savedDurations, items, visible } = this; + + const existing = _itemsById.get( item.id ); + if ( existing ) { + + // simultaneous replacement: silently swap — no events, same duration, same visible slot + item._visibleDuration = existing._visibleDuration; + if ( visible.has( existing ) ) { + + visible.delete( existing ); + visible.add( item ); + + } + + const idx = items.indexOf( existing ); + if ( idx !== - 1 ) items.splice( idx, 1 ); + + } else if ( _savedDurations.has( item.id ) ) { + + // sequential replacement: restore state from the item that was unregistered first + const saved = _savedDurations.get( item.id ); + item._visibleDuration = saved.duration; + if ( saved.wasVisible ) { + + visible.add( item ); + + } + + _savedDurations.delete( item.id ); + + } + + _itemsById.set( item.id, item ); + items.push( item ); } unregister( item ) { - const { items } = this; + const { items, visible, _itemsById, _savedDurations } = this; const index = items.indexOf( item ); if ( index !== - 1 ) { @@ -306,6 +362,16 @@ export class ScreenOccupationManager extends EventDispatcher { } + if ( _itemsById.get( item.id ) === item ) { + + _savedDurations.set( item.id, { duration: item._visibleDuration, wasVisible: visible.has( item ) } ); + _itemsById.delete( item.id ); + + } + + visible.delete( item ); + item._visibleDuration = 0; + } } From c2f67c97b6533073a9c3183269f39d848cf25981 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Tue, 2 Jun 2026 21:32:51 +0900 Subject: [PATCH 24/27] Update --- src/three/plugins/mvt/GlyphAtlasTexture.js | 157 +++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 src/three/plugins/mvt/GlyphAtlasTexture.js diff --git a/src/three/plugins/mvt/GlyphAtlasTexture.js b/src/three/plugins/mvt/GlyphAtlasTexture.js new file mode 100644 index 000000000..2bd1a9565 --- /dev/null +++ b/src/three/plugins/mvt/GlyphAtlasTexture.js @@ -0,0 +1,157 @@ +// Shelf-packer glyph atlas extending CanvasTexture so it can be passed directly +// to any Three.js material. The canvas is accessible via the inherited .image field. +// +// Items are packed into horizontal shelves. Each shelf is as tall as the first +// item placed on it; subsequent items on the same shelf must fit within that +// height. Released slots are pooled and reused before opening new shelf space. +// +// Usage: +// const atlas = new GlyphAtlasTexture( 2048, 2048 ); +// const slot = atlas.allocate( 'my-key', 64, 64 ); // { x, y, w, h } in pixels +// atlas.draw( 'my-key', ( ctx, x, y, w, h ) => { ... } ); +// atlas.release( 'my-key' ); + +import { CanvasTexture } from 'three'; + +export class GlyphAtlasTexture extends CanvasTexture { + + constructor( width = 2048, height = 2048 ) { + + const canvas = document.createElement( 'canvas' ); + canvas.width = width; + canvas.height = height; + + super( canvas ); + + this.ctx = canvas.getContext( '2d' ); + + // key → { x, y, w, h } + this._slots = new Map(); + + // active shelves: { y, h, nextX } + this._shelves = []; + + // slots freed by release(), available for reuse + this._freeList = []; + + this._nextShelfY = 0; + + } + + // Returns true if key has an allocated slot. + has( key ) { + + return this._slots.has( key ); + + } + + // Returns the slot for key, or null if not allocated. + get( key ) { + + return this._slots.get( key ) ?? null; + + } + + // Allocates a w×h region for key and returns its { x, y, w, h } pixel rect. + // Returns the existing slot if key is already allocated. + // Returns null if the atlas is full. + allocate( key, width, height ) { + + if ( this._slots.has( key ) ) { + + return this._slots.get( key ); + + } + + // prefer a previously freed slot that fits + for ( let i = 0; i < this._freeList.length; i ++ ) { + + const free = this._freeList[ i ]; + if ( free.w >= width && free.h >= height ) { + + this._freeList.splice( i, 1 ); + const slot = { x: free.x, y: free.y, w: width, h: height }; + this._slots.set( key, slot ); + return slot; + + } + + } + + // try to extend an existing shelf + for ( const shelf of this._shelves ) { + + if ( shelf.h >= height && this.image.width - shelf.nextX >= width ) { + + const slot = { x: shelf.nextX, y: shelf.y, w: width, h: height }; + shelf.nextX += width; + this._slots.set( key, slot ); + return slot; + + } + + } + + // open a new shelf + if ( this._nextShelfY + height > this.image.height ) { + + return null; + + } + + const shelf = { y: this._nextShelfY, h: height, nextX: width }; + this._shelves.push( shelf ); + this._nextShelfY += height; + + const slot = { x: 0, y: shelf.y, w: width, h: height }; + this._slots.set( key, slot ); + return slot; + + } + + // Invokes callback( ctx, x, y, w, h ) clipped to the slot for key, + // then marks the texture as needing a GPU upload. + // No-op if key has no allocated slot. + draw( key, callback ) { + + const slot = this._slots.get( key ); + if ( ! slot ) return; + + const { ctx } = this; + 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; + + } + + // Clears the slot for key and returns it to the free pool. + release( key ) { + + const slot = this._slots.get( key ); + if ( ! slot ) return; + + this.ctx.clearRect( slot.x, slot.y, slot.w, slot.h ); + this._freeList.push( slot ); + this._slots.delete( key ); + this.needsUpdate = true; + + } + + // Resets the atlas to empty. + clear() { + + this._slots.clear(); + this._shelves.length = 0; + this._freeList.length = 0; + this._nextShelfY = 0; + this.ctx.clearRect( 0, 0, this.image.width, this.image.height ); + this.needsUpdate = true; + + } + +} From 1c6cef1d2e0d4dc0d7223825c2d7d64bf6766a30 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Wed, 3 Jun 2026 14:48:59 +0900 Subject: [PATCH 25/27] Add glyp atlases, screen manager update --- src/three/plugins/mvt/FontAtlasTexture.js | 73 ++++++ src/three/plugins/mvt/GlyphAtlasTexture.js | 243 +++++++++++------- .../plugins/mvt/ScreenOccupationManager.js | 129 +++------- 3 files changed, 262 insertions(+), 183 deletions(-) create mode 100644 src/three/plugins/mvt/FontAtlasTexture.js 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 index 2bd1a9565..a8e1fbd42 100644 --- a/src/three/plugins/mvt/GlyphAtlasTexture.js +++ b/src/three/plugins/mvt/GlyphAtlasTexture.js @@ -1,40 +1,21 @@ -// Shelf-packer glyph atlas extending CanvasTexture so it can be passed directly -// to any Three.js material. The canvas is accessible via the inherited .image field. -// -// Items are packed into horizontal shelves. Each shelf is as tall as the first -// item placed on it; subsequent items on the same shelf must fit within that -// height. Released slots are pooled and reused before opening new shelf space. -// -// Usage: -// const atlas = new GlyphAtlasTexture( 2048, 2048 ); -// const slot = atlas.allocate( 'my-key', 64, 64 ); // { x, y, w, h } in pixels -// atlas.draw( 'my-key', ( ctx, x, y, w, h ) => { ... } ); -// atlas.release( 'my-key' ); - import { CanvasTexture } from 'three'; export class GlyphAtlasTexture extends CanvasTexture { - constructor( width = 2048, height = 2048 ) { - - const canvas = document.createElement( 'canvas' ); - canvas.width = width; - canvas.height = height; + constructor( slotCount, slotSize ) { - super( canvas ); + super( null ); - this.ctx = canvas.getContext( '2d' ); + this.slotSize = 0; - // key → { x, y, w, h } + // key → slot index this._slots = new Map(); - - // active shelves: { y, h, nextX } - this._shelves = []; - - // slots freed by release(), available for reuse this._freeList = []; + this._nextIndex = 0; + this._capacity = 0; + this._columns = 0; - this._nextShelfY = 0; + this.resize( slotCount, slotSize ); } @@ -45,99 +26,126 @@ export class GlyphAtlasTexture extends CanvasTexture { } - // Returns the slot for key, or null if not allocated. + // Returns the slot { x, y, w, h } for key, or null if not allocated. get( key ) { - return this._slots.get( key ) ?? null; + const { _slots } = this; + if ( ! _slots.has( key ) ) { - } + return null; - // Allocates a w×h region for key and returns its { x, y, w, h } pixel rect. - // Returns the existing slot if key is already allocated. - // Returns null if the atlas is full. - allocate( key, width, height ) { + } - if ( this._slots.has( key ) ) { + return this._indexToSlot( _slots.get( key ) ); - return this._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' ) { - // prefer a previously freed slot that fits - for ( let i = 0; i < this._freeList.length; i ++ ) { + return this._draw( key, ( ctx, x, y, w, h ) => { - const free = this._freeList[ i ]; - if ( free.w >= width && free.h >= height ) { + ctx.font = font; + ctx.fillStyle = color; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText( char, x + w / 2, y + h / 2 ); - this._freeList.splice( i, 1 ); - const slot = { x: free.x, y: free.y, w: width, h: height }; - this._slots.set( key, slot ); - return slot; + } ); - } + } - } + // 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 ) { - // try to extend an existing shelf - for ( const shelf of this._shelves ) { + return this._draw( key, ( ctx, x, y, w, h ) => { - if ( shelf.h >= height && this.image.width - shelf.nextX >= width ) { + ctx.drawImage( image, x, y, w, h ); - const slot = { x: shelf.nextX, y: shelf.y, w: width, h: height }; - shelf.nextX += width; - this._slots.set( key, slot ); - return slot; + } ); - } + } - } + // 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 } = {} ) { - // open a new shelf - if ( this._nextShelfY + height > this.image.height ) { + return this._draw( key, ( ctx, x, y ) => { - return null; + ctx.save(); + ctx.translate( x, y ); - } + if ( fillStyle !== null ) { - const shelf = { y: this._nextShelfY, h: height, nextX: width }; - this._shelves.push( shelf ); - this._nextShelfY += height; + ctx.fillStyle = fillStyle; + ctx.fill( path2D ); - const slot = { x: 0, y: shelf.y, w: width, h: height }; - this._slots.set( key, slot ); - return slot; + } - } + if ( strokeStyle !== null ) { - // Invokes callback( ctx, x, y, w, h ) clipped to the slot for key, - // then marks the texture as needing a GPU upload. - // No-op if key has no allocated slot. - draw( key, callback ) { + ctx.strokeStyle = strokeStyle; + ctx.lineWidth = lineWidth; + ctx.stroke( path2D ); - const slot = this._slots.get( key ); - if ( ! slot ) return; + } - const { ctx } = this; - 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(); + ctx.restore(); - this.needsUpdate = true; + } ); } // Clears the slot for key and returns it to the free pool. release( key ) { - const slot = this._slots.get( key ); - if ( ! slot ) return; + 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' ); - this.ctx.clearRect( slot.x, slot.y, slot.w, slot.h ); - this._freeList.push( slot ); - this._slots.delete( key ); + // 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; } @@ -146,12 +154,71 @@ export class GlyphAtlasTexture extends CanvasTexture { clear() { this._slots.clear(); - this._shelves.length = 0; this._freeList.length = 0; - this._nextShelfY = 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/ScreenOccupationManager.js b/src/three/plugins/mvt/ScreenOccupationManager.js index 3257dd580..5386a10c9 100644 --- a/src/three/plugins/mvt/ScreenOccupationManager.js +++ b/src/three/plugins/mvt/ScreenOccupationManager.js @@ -1,29 +1,5 @@ import { EventDispatcher, Vector2, Vector3 } from 'three'; -// ScreenOccupationManager handles screen-space collision de-confliction for annotations. -// -// LoD transition strategy: -// When the active MVT tile set changes (LoD change), annotations are reconciled by feature ID: -// -// 1. STABLE annotations (feature ID present in both old and new LoD): -// - Remain registered and visible at their existing world position. -// - Their elevation is updated asynchronously via the raycast queue once the new terrain -// tile mesh is available. The occupancy grid is updated in-place when the new position settles. -// -// 2. DISAPPEARED annotations (feature ID present in old LoD, absent in new): -// - Unregistered and faded out immediately when the old MVT tile is released. -// -// 3. NEW annotations (feature ID absent in old LoD, present in new): -// - Queued for elevation raycasting. Not registered until raycasting is complete. -// - Processed in descending priority order (rank / importance) so that high-priority -// annotations claim grid cells first, preventing low-priority annotations from -// blocking them and then being evicted. -// -// The occupancy grid is always in a valid state — stable annotations never leave the grid -// during a transition, so there are no frames where previously visible content disappears. - -// TODO: we need to handle delayed removal further. It's possible that these delays belong in the parent system, instead - // suppress annotations within ~6 degrees of the globe horizon const PERSPECTIVE_CULL_THRESHOLD = 0.1; @@ -34,7 +10,6 @@ export class AnnotationItem { this.id = ''; this.layer = ''; this.properties = null; - this._visibleDuration = 0; } @@ -82,7 +57,6 @@ export class PointAnnotationItem extends AnnotationItem { // surface normal ≈ normalize( position ) for WGS84 if ( cameraPosition !== null ) { - // TODO: fix this 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; @@ -148,16 +122,11 @@ export class ScreenOccupationManager extends EventDispatcher { // items this.items = []; this.visible = new Set(); + this.prevVisible = new Set(); this.added = new Set(); - this.removed = new Set(); - - // seconds an item must be continuously occupied/absent before show/hide fires - this.delay = 0.25; - this._lastUpdateTime = - 1; - // keyed registries for LoD-coherent replacement + // prevents duplicate items during simultaneous LoD tile swaps this._itemsById = new Map(); - this._savedDurations = new Map(); this.handle = { test: ( x, y, r ) => { @@ -216,23 +185,14 @@ export class ScreenOccupationManager extends EventDispatcher { update() { - const { - matrix, - cameraPosition, - resolution, - size, - items, - visible, - added, - removed, - handle, - delay, - } = this; - - // compute delta time, capped to avoid large jumps after tab suspension - const now = performance.now() / 1000; - const dt = this._lastUpdateTime < 0 ? 0 : Math.min( now - this._lastUpdateTime, 0.1 ); - this._lastUpdateTime = now; + 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 const width = Math.ceil( resolution.width / size ); @@ -247,7 +207,7 @@ export class ScreenOccupationManager extends EventDispatcher { } - // transform the shape to the screen + // transform items to screen space if ( matrix !== null ) { for ( let i = 0, l = items.length; i < l; i ++ ) { @@ -259,40 +219,24 @@ export class ScreenOccupationManager extends EventDispatcher { } // sort the items - items.sort( this.sortCallback ); - - added.clear(); - removed.clear(); + items.sort( sortCallback ); + // evaluate occupancy into the fresh visible set for ( let i = 0, l = items.length; i < l; i ++ ) { const item = items[ i ]; - const occupied = matrix !== null && item.evaluate( handle ); - - // increment duration while occupied, decrement while absent (floored at 0) - if ( occupied ) { - - item._visibleDuration = Math.min( item._visibleDuration + dt, delay ); - - } else { - - item._visibleDuration = Math.max( item._visibleDuration - dt, 0 ); + if ( matrix !== null && item.evaluate( handle ) ) { - } - - const wasVisible = visible.has( item ); - const visibleDuration = item._visibleDuration; + visible.add( item ); + if ( ! prevVisible.has( item ) ) { - // delay === 0: show only when currently occupied (avoids threshold=0 ambiguity) - if ( ! wasVisible && visibleDuration === delay ) { + added.add( item ); - visible.add( item ); - added.add( item ); + } else { - } else if ( wasVisible && ! occupied && visibleDuration === 0 ) { + prevVisible.delete( item ); - visible.delete( item ); - removed.add( item ); + } } @@ -304,9 +248,9 @@ export class ScreenOccupationManager extends EventDispatcher { } - if ( removed.size > 0 ) { + if ( prevVisible.size > 0 ) { - this.dispatchEvent( { type: 'removed', items: removed } ); + this.dispatchEvent( { type: 'removed', items: prevVisible } ); } @@ -314,14 +258,12 @@ export class ScreenOccupationManager extends EventDispatcher { register( item ) { - // TODO: how to register / handle non-linear layouts for text - custom callback? - const { _itemsById, _savedDurations, items, visible } = this; + const { _itemsById, items, visible, prevVisible } = this; const existing = _itemsById.get( item.id ); if ( existing ) { - // simultaneous replacement: silently swap — no events, same duration, same visible slot - item._visibleDuration = existing._visibleDuration; + // simultaneous LoD swap: replace the old item in-place if ( visible.has( existing ) ) { visible.delete( existing ); @@ -329,21 +271,19 @@ export class ScreenOccupationManager extends EventDispatcher { } - const idx = items.indexOf( existing ); - if ( idx !== - 1 ) items.splice( idx, 1 ); + if ( prevVisible.has( existing ) ) { - } else if ( _savedDurations.has( item.id ) ) { + prevVisible.delete( existing ); + prevVisible.add( item ); - // sequential replacement: restore state from the item that was unregistered first - const saved = _savedDurations.get( item.id ); - item._visibleDuration = saved.duration; - if ( saved.wasVisible ) { + } - visible.add( item ); + const idx = items.indexOf( existing ); + if ( idx !== - 1 ) { - } + items.splice( idx, 1 ); - _savedDurations.delete( item.id ); + } } @@ -354,7 +294,7 @@ export class ScreenOccupationManager extends EventDispatcher { unregister( item ) { - const { items, visible, _itemsById, _savedDurations } = this; + const { items, visible, prevVisible, _itemsById } = this; const index = items.indexOf( item ); if ( index !== - 1 ) { @@ -364,13 +304,12 @@ export class ScreenOccupationManager extends EventDispatcher { if ( _itemsById.get( item.id ) === item ) { - _savedDurations.set( item.id, { duration: item._visibleDuration, wasVisible: visible.has( item ) } ); _itemsById.delete( item.id ); } visible.delete( item ); - item._visibleDuration = 0; + prevVisible.delete( item ); } From 5c11d3aed91692d133dacf142927643aec285615 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Wed, 3 Jun 2026 15:36:22 +0900 Subject: [PATCH 26/27] Improve the delay --- .../mvt/DelayedScreenOccupationManager.js | 200 ++++++++++++++++++ src/three/plugins/mvt/MVTAnnotationsPlugin.js | 5 +- .../plugins/mvt/ScreenOccupationManager.js | 3 +- 3 files changed, 204 insertions(+), 4 deletions(-) create mode 100644 src/three/plugins/mvt/DelayedScreenOccupationManager.js diff --git a/src/three/plugins/mvt/DelayedScreenOccupationManager.js b/src/three/plugins/mvt/DelayedScreenOccupationManager.js new file mode 100644 index 000000000..22f7062fa --- /dev/null +++ b/src/three/plugins/mvt/DelayedScreenOccupationManager.js @@ -0,0 +1,200 @@ +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; + + } + + get sortCallback() { + + return this._inner.sortCallback; + + } + + set sortCallback( v ) { + + this._inner.sortCallback = v; + + } + + constructor() { + + super(); + + this._inner = new ScreenOccupationManager(); + + this.visible = new Set(); + this.showDelay = 0.1; + this.hideDelay = 0.5; + + // 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 ) { + + 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/MVTAnnotationsPlugin.js b/src/three/plugins/mvt/MVTAnnotationsPlugin.js index cf5c8ea33..0c758e6dd 100644 --- a/src/three/plugins/mvt/MVTAnnotationsPlugin.js +++ b/src/three/plugins/mvt/MVTAnnotationsPlugin.js @@ -1,7 +1,8 @@ import { BufferAttribute, BufferGeometry, Group, MathUtils, Matrix4, Points, PointsMaterial, Raycaster, Vector3 } from 'three'; import { PriorityQueue } from '3d-tiles-renderer/core'; import { HierarchicalLock } from './HierarchicalLock.js'; -import { PointAnnotationItem, ScreenOccupationManager } from './ScreenOccupationManager.js'; +import { PointAnnotationItem } from './ScreenOccupationManager.js'; +import { DelayedScreenOccupationManager } from './DelayedScreenOccupationManager.js'; import { forEachTileInBounds, getMeshesCartographicRange } from '../images/overlays/utils.js'; // TODO: @@ -53,7 +54,7 @@ export class MVTAnnotationsPlugin { this.overlay = overlay; this.locks = new HierarchicalLock(); - this.occupancy = new ScreenOccupationManager(); + this.occupancy = new DelayedScreenOccupationManager(); this.group = new Group(); this.scene = scene; diff --git a/src/three/plugins/mvt/ScreenOccupationManager.js b/src/three/plugins/mvt/ScreenOccupationManager.js index 5386a10c9..9d9f5142a 100644 --- a/src/three/plugins/mvt/ScreenOccupationManager.js +++ b/src/three/plugins/mvt/ScreenOccupationManager.js @@ -294,7 +294,7 @@ export class ScreenOccupationManager extends EventDispatcher { unregister( item ) { - const { items, visible, prevVisible, _itemsById } = this; + const { items, prevVisible, _itemsById } = this; const index = items.indexOf( item ); if ( index !== - 1 ) { @@ -308,7 +308,6 @@ export class ScreenOccupationManager extends EventDispatcher { } - visible.delete( item ); prevVisible.delete( item ); } From 0e873eb0efdac29e099afe3de74c0ab438a63df0 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Wed, 3 Jun 2026 15:46:38 +0900 Subject: [PATCH 27/27] Update --- .../mvt/DelayedScreenOccupationManager.js | 28 +++++++++++++++++++ .../plugins/mvt/ScreenOccupationManager.js | 6 ++++ 2 files changed, 34 insertions(+) diff --git a/src/three/plugins/mvt/DelayedScreenOccupationManager.js b/src/three/plugins/mvt/DelayedScreenOccupationManager.js index 22f7062fa..ad110c64b 100644 --- a/src/three/plugins/mvt/DelayedScreenOccupationManager.js +++ b/src/three/plugins/mvt/DelayedScreenOccupationManager.js @@ -117,6 +117,34 @@ export class DelayedScreenOccupationManager extends EventDispatcher { register( item ) { + const { _showTimers, _hideTimers, visible } = this; + const existing = this._inner.getById( item.id ); + if ( existing !== undefined ) { + + // LoD swap: transfer timer and visibility state from old instance to new + if ( _showTimers.has( existing ) ) { + + _showTimers.set( item, _showTimers.get( existing ) ); + _showTimers.delete( existing ); + + } + + if ( _hideTimers.has( existing ) ) { + + _hideTimers.set( item, _hideTimers.get( existing ) ); + _hideTimers.delete( existing ); + + } + + if ( visible.has( existing ) ) { + + visible.delete( existing ); + visible.add( item ); + + } + + } + this._inner.register( item ); } diff --git a/src/three/plugins/mvt/ScreenOccupationManager.js b/src/three/plugins/mvt/ScreenOccupationManager.js index 9d9f5142a..55060c92e 100644 --- a/src/three/plugins/mvt/ScreenOccupationManager.js +++ b/src/three/plugins/mvt/ScreenOccupationManager.js @@ -256,6 +256,12 @@ export class ScreenOccupationManager extends EventDispatcher { } + getById( id ) { + + return this._itemsById.get( id ); + + } + register( item ) { const { _itemsById, items, visible, prevVisible } = this;