diff --git a/eslint.config.js b/eslint.config.js index a6988e8fd..bb5a00b26 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -131,6 +131,7 @@ export default [ 'jsdoc/no-undefined-types': [ 'error', { definedTypes: [ 'ArrayBufferView', + 'Iterable', 'RequestInit', 'TypedArray', 'TypedArrayConstructor', diff --git a/example/three/annotationsExample.js b/example/three/annotationsExample.js index 2aadddde2..0911cbd78 100644 --- a/example/three/annotationsExample.js +++ b/example/three/annotationsExample.js @@ -12,6 +12,8 @@ import { PMTilesOverlay, MVTAnnotationsPlugin, MVTAnnotationsDriver, + MVTIconGlyphs, + MVTLabelGlyphs, UpdateOnChangePlugin, } from '3d-tiles-renderer/plugins'; import { LoadRegionPlugin } from '3d-tiles-renderer/three/plugins'; @@ -25,8 +27,6 @@ import { } from 'three'; import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js'; import { GUI } from 'three/addons/libs/lil-gui.module.min.js'; -import { AnnotationPoints } from './src/plugins/mvt/AnnotationPoints.js'; -import { CharacterPoints } from './src/plugins/mvt/CharacterPoints.js'; import { MeshBVHPlugin } from './src/plugins/MeshBVHPlugin.js'; // CDN source for the icons @@ -142,7 +142,7 @@ class ExampleAnnotationsDriver extends MVTAnnotationsDriver { const dpr = renderer.getPixelRatio(); // icons for point annotations - const annotationPoints = new AnnotationPoints( { + const annotationPoints = new MVTIconGlyphs( { getKind: ( layer, properties ) => { return KIND_TO_ICON[ properties.kind ] || 'point'; @@ -176,7 +176,7 @@ class ExampleAnnotationsDriver extends MVTAnnotationsDriver { ); // glyphs for text ( road ) annotations - const characterPoints = new CharacterPoints( { + const characterPoints = new MVTLabelGlyphs( { strokeStyle: '#3f3e4c', strokeWidth: 3 * dpr, } ); diff --git a/example/three/pmtiles.js b/example/three/pmtiles.js index 11cb7fb10..6260f9db3 100644 --- a/example/three/pmtiles.js +++ b/example/three/pmtiles.js @@ -13,7 +13,7 @@ import { import GUI from 'three/addons/libs/lil-gui.module.min.js'; // Protomaps "Light" theme — from protomaps/basemaps flavors.ts -const LAYERS_LIGHT = { +const LAYERS = { earth: { enabled: true, fill: '#e2dfda', order: 0 }, water: { enabled: true, fill: '#80deea', order: 1 }, landcover: { enabled: true, fill: '#c4e7d2', order: 2 }, @@ -27,84 +27,6 @@ const LAYERS_LIGHT = { pois: { enabled: true, fill: '#1a8cbd', radius: 3, order: 10 }, }; -/* -// Protomaps "Dark" theme — from protomaps/basemaps flavors.ts -const LAYERS_DARK = { - earth: { enabled: true, fill: '#1f1f1f', order: 0 }, - water: { enabled: true, fill: '#31353f', order: 1 }, - landcover: { enabled: true, fill: '#1c2925', order: 2 }, - landuse: { enabled: true, fill: '#1c2421', order: 3 }, - natural: { enabled: true, fill: '#212123', order: 4 }, - buildings: { enabled: true, fill: '#111111', order: 5 }, - roads: { enabled: true, stroke: '#3d3d3d', order: 6 }, - transit: { enabled: true, stroke: '#000000', order: 7 }, - boundaries: { enabled: true, stroke: '#5b6374', order: 8 }, - places: { enabled: true, fill: '#7a7a7a', order: 9 }, - pois: { enabled: true, fill: '#4299bb', radius: 3, order: 10 }, -}; - -// Protomaps "White" theme — from protomaps/basemaps flavors.ts -const LAYERS_WHITE = { - earth: { enabled: true, fill: '#ffffff', order: 0 }, - water: { enabled: true, fill: '#dcdcdc', order: 1 }, - landcover: { enabled: true, fill: '#fcfcfc', order: 2 }, - landuse: { enabled: true, fill: '#fcfcfc', order: 3 }, - natural: { enabled: true, fill: '#fafafa', order: 4 }, - buildings: { enabled: true, fill: '#efefef', order: 5 }, - roads: { enabled: true, stroke: '#ebebeb', order: 6 }, - transit: { enabled: true, stroke: '#d6d6d6', order: 7 }, - boundaries: { enabled: true, stroke: '#adadad', order: 8 }, - places: { enabled: true, fill: '#5c5c5c', order: 9 }, - pois: { enabled: true, fill: '#1a8cbd', radius: 3, order: 10 }, -}; - -// Protomaps "Black" theme — from protomaps/basemaps flavors.ts -const LAYERS_BLACK = { - earth: { enabled: true, fill: '#141414', order: 0 }, - water: { enabled: true, fill: '#333333', order: 1 }, - landcover: { enabled: true, fill: '#181818', order: 2 }, - landuse: { enabled: true, fill: '#181818', order: 3 }, - natural: { enabled: true, fill: '#161616', order: 4 }, - buildings: { enabled: true, fill: '#0a0a0a', order: 5 }, - roads: { enabled: true, stroke: '#3f3f3f', order: 6 }, - transit: { enabled: true, stroke: '#3f3f3f', order: 7 }, - boundaries: { enabled: true, stroke: '#707070', order: 8 }, - places: { enabled: true, fill: '#999999', order: 9 }, - pois: { enabled: true, fill: '#707070', radius: 3, order: 10 }, -}; - -// Pastel theme — each layer gets a distinct hue at high lightness, moderate saturation -const LAYERS_PASTEL = { - earth: { enabled: true, fill: '#f6f5f2', order: 0 }, - water: { enabled: true, fill: '#c2e0f4', order: 1 }, - landcover: { enabled: true, fill: '#d4ecd5', order: 2 }, - landuse: { enabled: true, fill: '#e6e4f2', order: 3 }, - natural: { enabled: true, fill: '#f3ebd1', order: 4 }, - buildings: { enabled: true, fill: '#f7e1e1', order: 5 }, - roads: { enabled: true, stroke: '#ffffff', order: 6 }, - transit: { enabled: true, stroke: '#ccbfe6', order: 7 }, - boundaries: { enabled: true, stroke: '#d9c2cd', order: 8 }, - places: { enabled: true, fill: '#5c5470', order: 9 }, - pois: { enabled: true, fill: '#e08a9b', radius: 3, order: 10 }, -}; - -const LAYERS_RADICAL = { - earth: { enabled: true, fill: '#0c001a', order: 0 }, - water: { enabled: true, fill: '#00f0ff', order: 1 }, - landcover: { enabled: true, fill: '#120036', order: 2 }, - landuse: { enabled: true, fill: '#ff0055', order: 3 }, - natural: { enabled: true, fill: '#00ff66', order: 4 }, - buildings: { enabled: true, fill: '#2d0066', order: 5 }, - roads: { enabled: true, stroke: '#ffdd00', order: 6 }, - transit: { enabled: true, stroke: '#ff00ff', order: 7 }, - boundaries: { enabled: true, stroke: '#00ffcc', order: 8 }, - places: { enabled: true, fill: '#ffffff', order: 9 }, - pois: { enabled: true, fill: '#ff9900', radius: 3, order: 10 }, -}; -*/ - -const LAYERS = LAYERS_LIGHT; - let scene, renderer, camera, controls, tiles, overlay; init(); diff --git a/example/three/src/plugins/mvt/AnnotationPoints.js b/example/three/src/plugins/mvt/AnnotationPoints.js deleted file mode 100644 index 29ce31e7d..000000000 --- a/example/three/src/plugins/mvt/AnnotationPoints.js +++ /dev/null @@ -1,163 +0,0 @@ -import { BufferAttribute, Matrix4, Vector2, Vector3, Vector4 } from 'three'; -import { GlyphMaterial } from './GlyphMaterial.js'; -import { GlyphPoints } from './GlyphPoints.js'; - -const _viewport = /* @__PURE__ */ new Vector4(); -const _mvMatrix = /* @__PURE__ */ new Matrix4(); -const _point4 = /* @__PURE__ */ new Vector4(); -const _ssOrigin = /* @__PURE__ */ new Vector4(); -const _ssRay = /* @__PURE__ */ new Vector2(); -const _ssPoint = /* @__PURE__ */ new Vector2(); -const _worldPoint = /* @__PURE__ */ new Vector3(); - -export class AnnotationPoints extends GlyphPoints { - - constructor( options = {} ) { - - const { - getKind = () => null, - size = 18, - glyphSize = 18 * window.devicePixelRatio, - slotCount = 64, - } = options; - - super( new GlyphMaterial() ); - - this.getKind = getKind; - this.size = size; - - // Viewport size in pixels — must be kept current by the owner (plugin updates each frame). - this.resolution = new Vector2(); - this.needsUpdate = false; - - this.glyphAtlas.resize( slotCount, glyphSize ); - - } - - onBeforeRender( renderer, scene, camera ) { - - const { resolution } = this; - - // use the active viewport (not getDrawingBufferSize) so raycasting matches the GPU's NDC→pixel mapping in sub-viewport scenarios - renderer.getViewport( _viewport ); - resolution.set( _viewport.z, _viewport.w ); - - } - - raycast( raycaster, intersects ) { - - const camera = raycaster.camera; - if ( ! camera ) return; - - const { geometry, material, matrixWorld, resolution } = this; - const posAttr = geometry.getAttribute( 'position' ); - if ( ! posAttr || posAttr.count === 0 ) return; - - const pointRadius = material.size / 2; // pixels - const near = - camera.near; - - // Project a point 1 unit along the ray into screen space for 2D comparison. - // Using the same centered screen-space convention as LineSegments2 - // (NDC * resolution/2, NOT NDC * resolution/2 + resolution/2). - raycaster.ray.at( 1, _ssOrigin ); - _ssOrigin.w = 1; - _ssOrigin.applyMatrix4( camera.matrixWorldInverse ); - _ssOrigin.applyMatrix4( camera.projectionMatrix ); - _ssOrigin.multiplyScalar( 1 / _ssOrigin.w ); - _ssRay.set( _ssOrigin.x * resolution.x / 2, _ssOrigin.y * resolution.y / 2 ); - - _mvMatrix.multiplyMatrices( camera.matrixWorldInverse, matrixWorld ); - - for ( let i = 0, l = this.geometry.drawRange.count; i < l; i ++ ) { - - _point4.fromBufferAttribute( posAttr, i ); - _point4.w = 1; - - // camera space - _point4.applyMatrix4( _mvMatrix ); - - // skip if behind near plane - if ( _point4.z > near ) continue; - - // clip → NDC - _point4.applyMatrix4( camera.projectionMatrix ); - _point4.multiplyScalar( 1 / _point4.w ); - - // skip if outside depth clip bounds - if ( _point4.z < - 1 || _point4.z > 1 ) continue; - - // centered screen space - _ssPoint.set( _point4.x * resolution.x / 2, _point4.y * resolution.y / 2 ); - - if ( _ssRay.distanceTo( _ssPoint ) > pointRadius ) continue; - - // hit — record 3D world position and distance along ray - _worldPoint.fromBufferAttribute( posAttr, i ).applyMatrix4( matrixWorld ); - - const entry = this._orderedEntries[ i ]; - intersects.push( { - distance: raycaster.ray.origin.distanceTo( _worldPoint ), - point: _worldPoint.clone(), - index: i, - face: null, - faceIndex: null, - object: this, - layer: entry?.item.layer ?? null, - properties: entry?.item.properties ?? null, - } ); - - } - - } - - _updateGeometry() { - - const origin = this.position; - const entries = this._orderedEntries; - const count = entries.length; - - const { geometry, getKind, glyphAtlas } = this; - let posAttr = geometry.getAttribute( 'position' ); - if ( ! posAttr || posAttr.count < count ) { - - geometry.dispose(); - posAttr = new BufferAttribute( new Float32Array( count * 3 ), 3 ); - geometry.setAttribute( 'position', posAttr ); - geometry.setAttribute( 'glyphUV', new BufferAttribute( new Float32Array( count * 2 ), 2 ) ); - geometry.setAttribute( 'alpha', new BufferAttribute( new Float32Array( count ), 1 ) ); - - } - - geometry.setDrawRange( 0, count ); - - const glyphUVAttr = geometry.getAttribute( 'glyphUV' ); - const alphaAttr = geometry.getAttribute( 'alpha' ); - for ( let i = 0; i < count; i ++ ) { - - const { item, fade } = entries[ i ]; - const pos = item.position; - posAttr.setXYZ( i, pos.x - origin.x, pos.y - origin.y, pos.z - origin.z ); - - const kind = getKind( item.layer, item.properties ); - if ( kind !== null && glyphAtlas.has( kind ) ) { - - const uv = glyphAtlas.getUV( kind ); - glyphUVAttr.setXY( i, uv.x, uv.y ); - - } else { - - glyphUVAttr.setXY( i, - 1, - 1 ); - - } - - alphaAttr.setX( i, fade ); - - } - - posAttr.needsUpdate = true; - glyphUVAttr.needsUpdate = true; - alphaAttr.needsUpdate = true; - - } - -} diff --git a/example/three/src/plugins/mvt/FontAtlasTexture.d.ts b/example/three/src/plugins/mvt/FontAtlasTexture.d.ts deleted file mode 100644 index 0b859b415..000000000 --- a/example/three/src/plugins/mvt/FontAtlasTexture.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { GlyphAtlasTexture, GlyphSlot } from '3d-tiles-renderer/plugins'; - -export class FontAtlasTexture extends GlyphAtlasTexture { - - font: string; - color: string; - - constructor( slotCount: number, slotSize: number, font: string, color?: string ); - - add( char: string ): GlyphSlot; - release( char: string ): void; - -} diff --git a/example/three/src/plugins/mvt/FontAtlasTexture.js b/example/three/src/plugins/mvt/FontAtlasTexture.js deleted file mode 100644 index 468ff7a57..000000000 --- a/example/three/src/plugins/mvt/FontAtlasTexture.js +++ /dev/null @@ -1,78 +0,0 @@ -import { GlyphAtlasTexture } from '3d-tiles-renderer/plugins'; - -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; - - if ( this.has( char ) ) { - - // already in atlas — pull it out of the eviction queue - _evictionQueue.delete( char ); - _refCounts.set( char, _refCounts.get( char ) + 1 ); - - } 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.drawChar( char, char, { font, color } ); - _refCounts.set( char, 1 ); - - } - - 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/example/three/src/plugins/mvt/GlyphPoints.js b/example/three/src/plugins/mvt/GlyphPoints.js deleted file mode 100644 index aada1f3bc..000000000 --- a/example/three/src/plugins/mvt/GlyphPoints.js +++ /dev/null @@ -1,149 +0,0 @@ -import { BufferGeometry, Matrix4, Points } from 'three'; - -const _mvMatrix = /* @__PURE__ */ new Matrix4(); - -export class GlyphPoints extends Points { - - get size() { - - return this.material.size; - - } - - set size( v ) { - - this.material.size = v; - - } - - get glyphAtlas() { - - return this.material.glyphAtlas; - - } - - constructor( material ) { - - super( new BufferGeometry(), material ); - - this.renderOrder = 1000; - this.frustumCulled = false; - - // fade settings - this.fadeInDuration = 0.3; - this.fadeOutDuration = 0.3; - - // Map keyed by stable id, - // plus an insertion-ordered list of the same entries for stable geometry layout - this._entryMap = new Map(); - this._orderedEntries = []; - this._lastUpdateTime = - 1; - - } - - dispose() { - - this.glyphAtlas.dispose(); - this.geometry.dispose(); - this.material.dispose(); - - } - - // callback for adding and remove items from the set of glyphs - update( added, removed ) { - - const now = performance.now() / 1000; - const dt = this._lastUpdateTime < 0 ? 0 : Math.min( now - this._lastUpdateTime, 0.1 ); - this._lastUpdateTime = now; - - const { _entryMap, _orderedEntries, fadeInDuration, fadeOutDuration } = this; - - for ( const item of added ) { - - const existing = _entryMap.get( item.id ); - if ( ! existing ) { - - const entry = { item, fade: 0, state: 'in' }; - _entryMap.set( item.id, entry ); - _orderedEntries.push( entry ); - - } else { - - // keep reference fresh (LoD swap) - existing.item = item; - if ( existing.state === 'out' ) existing.state = 'in'; - - } - - } - - for ( const item of removed ) { - - const entry = _entryMap.get( item.id ); - if ( entry && entry.state !== 'out' ) { - - entry.state = 'out'; - - } - - } - - let didRemove = false; - for ( const [ id, entry ] of _entryMap ) { - - if ( entry.state === 'in' ) { - - entry.fade = Math.min( 1, entry.fade + dt / fadeInDuration ); - if ( entry.fade >= 1 ) { - - entry.state = 'visible'; - - } - - } else if ( entry.state === 'out' ) { - - entry.fade = Math.max( 0, entry.fade - dt / fadeOutDuration ); - if ( entry.fade <= 0 ) { - - _entryMap.delete( id ); - didRemove = true; - - } - - } - - } - - if ( didRemove ) { - - this._orderedEntries = _orderedEntries.filter( e => _entryMap.has( e.item.id ) ); - - } - - this._updateGeometry(); - - } - - onAfterRender( renderer, scene, camera ) { - - // keep the root near the camera to avoid gpu jitter at globe scale - const { parent } = this; - if ( parent ) { - - _mvMatrix.copy( parent.matrixWorld ).invert(); - - } else { - - _mvMatrix.identity(); - - } - - this.position.setFromMatrixPosition( camera.matrixWorld ).applyMatrix4( _mvMatrix ); - this.updateMatrixWorld( true ); - - } - - // subclasses build their geometry from `this._orderedEntries` here - _updateGeometry() {} - -} diff --git a/src/three/plugins/images/ImageOverlayPlugin.js b/src/three/plugins/images/ImageOverlayPlugin.js index 4ecf5c01b..c1de93e97 100644 --- a/src/three/plugins/images/ImageOverlayPlugin.js +++ b/src/three/plugins/images/ImageOverlayPlugin.js @@ -1473,6 +1473,24 @@ export class ImageOverlay { } + lockTextureSafe( range ) { + + // locks a texture without risk of throwing due to abort + const result = this.lockTexture( range ); + if ( result instanceof Promise ) { + + result.catch( err => { + + if ( err.name !== 'AbortError' ) throw err; + + } ); + + } + + return result; + + } + releaseTexture( range, level = null ) { } @@ -1842,24 +1860,6 @@ export class GeoJSONOverlay extends ImageOverlay { } - lockTextureSafe( range ) { - - // locks a texture without risk of throwing due to abort - const result = this.lockTexture( range ); - if ( result instanceof Promise ) { - - result.catch( err => { - - if ( err.name !== 'AbortError' ) throw err; - - } ); - - } - - return result; - - } - releaseTexture( range ) { this.imageSource.release( ...range ); diff --git a/src/three/plugins/images/sources/MVTImageSource.js b/src/three/plugins/images/sources/MVTImageSource.js index 0352e2736..20ff42836 100644 --- a/src/three/plugins/images/sources/MVTImageSource.js +++ b/src/three/plugins/images/sources/MVTImageSource.js @@ -20,6 +20,27 @@ function importMVTDeps() { } +// Protomaps "Light" theme — from protomaps/basemaps flavors.ts +const DEFAULT_STYLES = { + earth: { fill: '#e2dfda', order: 0 }, + water: { fill: '#80deea', order: 1 }, + landcover: { fill: '#c4e7d2', order: 2 }, + landuse: { fill: '#cfddd5', order: 3 }, + natural: { fill: '#e2e0d7', order: 4 }, + buildings: { fill: '#cccccc', order: 5 }, + roads: { stroke: '#ebebeb', order: 6 }, + transit: { stroke: '#a7b1b3', order: 7 }, + boundaries: { stroke: '#adadad', order: 8 }, + places: { fill: '#5c5c5c', order: 9 }, + pois: { fill: '#1a8cbd', radius: 3, order: 10 }, +}; + +const DEFAULT_GET_STYLE = ( layer, properties ) => { + + return DEFAULT_STYLES[ layer ] ?? null; + +}; + // Fetches and caches parsed MVT tile content (vectorTile + tileBounds) keyed by (tx, ty, tl). export class MVTContentCache extends DataCache { @@ -154,7 +175,7 @@ export class MVTImageSource extends RegionImageSource { const { resolution = 512, - getStyle = () => null, + getStyle = null, contentCache, ...rest } = options; @@ -272,18 +293,15 @@ export class MVTImageSource extends RegionImageSource { _renderVectorTile( vectorTile ) { - const { _canvasRenderer, getStyle } = this; + const { _canvasRenderer } = this; + const getStyle = this.getStyle || DEFAULT_GET_STYLE; // Sort layers by user-defined order, falling back to alphabetical. const layerNames = [ ...Object.keys( vectorTile.layers ) ].sort( ( a, b ) => { - if ( getStyle ) { - - const orderA = getStyle( a, null )?.order ?? VectorShapeCanvasRenderer.DEFAULT_STYLE.order; - const orderB = getStyle( b, null )?.order ?? VectorShapeCanvasRenderer.DEFAULT_STYLE.order; - if ( orderA !== orderB ) return orderA - orderB; - - } + const orderA = getStyle( a, null )?.order ?? VectorShapeCanvasRenderer.DEFAULT_STYLE.order; + const orderB = getStyle( b, null )?.order ?? VectorShapeCanvasRenderer.DEFAULT_STYLE.order; + if ( orderA !== orderB ) return orderA - orderB; return a.localeCompare( b ); diff --git a/src/three/plugins/images/sources/PMTilesImageSource.js b/src/three/plugins/images/sources/PMTilesImageSource.js index 637710db2..72dea1e19 100644 --- a/src/three/plugins/images/sources/PMTilesImageSource.js +++ b/src/three/plugins/images/sources/PMTilesImageSource.js @@ -186,7 +186,7 @@ export class PMTilesImageSource extends RegionImageSource { const { resolution = 512, - getStyle = () => null, + getStyle = null, } = options; this._resolution = resolution; diff --git a/src/three/plugins/index.d.ts b/src/three/plugins/index.d.ts index d5eb8f576..6756d8691 100644 --- a/src/three/plugins/index.d.ts +++ b/src/three/plugins/index.d.ts @@ -21,7 +21,11 @@ export * from './gltf/GLTFStructuralMetadataExtension.js'; export * from './gltf/GLTFMeshFeaturesExtension.js'; // mvt utilities -export * from './mvt/GlyphAtlasTexture.js'; +export * from './mvt/MVTGlyphAtlasTexture.js'; +export * from './mvt/MVTGlyphMaterial.js'; +export * from './mvt/MVTGlyphs.js'; +export * from './mvt/MVTIconGlyphs.js'; +export * from './mvt/MVTLabelGlyphs.js'; // loaders export * from './loaders/WMTSCapabilitiesLoader.js'; diff --git a/src/three/plugins/index.js b/src/three/plugins/index.js index a11d1b9d6..49d365f0e 100644 --- a/src/three/plugins/index.js +++ b/src/three/plugins/index.js @@ -28,7 +28,11 @@ export * from './gltf/metadata/classes/MeshFeatures.js'; export * from './gltf/metadata/classes/StructuralMetadata.js'; // mvt utilities -export * from './mvt/GlyphAtlasTexture.js'; +export * from './mvt/MVTGlyphAtlasTexture.js'; +export * from './mvt/MVTGlyphMaterial.js'; +export * from './mvt/MVTGlyphs.js'; +export * from './mvt/MVTIconGlyphs.js'; +export * from './mvt/MVTLabelGlyphs.js'; // loaders export * from './loaders/WMTSCapabilitiesLoader.js'; diff --git a/src/three/plugins/mvt/MVTAnnotationsPlugin.d.ts b/src/three/plugins/mvt/MVTAnnotationsPlugin.d.ts index 4a45bd981..0100f74a2 100644 --- a/src/three/plugins/mvt/MVTAnnotationsPlugin.d.ts +++ b/src/three/plugins/mvt/MVTAnnotationsPlugin.d.ts @@ -1,24 +1,25 @@ -import { Camera, Vector3 } from 'three'; +import { Camera, Group } from 'three'; +import { MVTIconGlyphs } from './MVTIconGlyphs.js'; +import { MVTLabelGlyphs } from './MVTLabelGlyphs.js'; -export type GetAnnotationCallback = ( layerName: string, properties: Record ) => boolean; +export class MVTAnnotationsDriver { -export type SortAnnotationCallback = ( a: PointAnnotationItem, b: PointAnnotationItem ) => number; + group: Group; -export type AnnotationsUpdateCallback = ( added: Set, removed: Set ) => void; - -export class AnnotationItem { - - layer: string; - properties: Record | null; + filterAnnotation( layer: string, properties: Record, type: number ): boolean; + sortAnnotations( a: object, b: object ): number; + measureChar( char: string ): number; + getText( properties: Record ): string; + isAnnotationEnabled( properties: Record, type: number ): boolean; + onAnnotationsUpdate( added: Set, removed: Set ): void; + dispose(): void; } -export class PointAnnotationItem extends AnnotationItem { +export class DefaultMVTAnnotationsDriver extends MVTAnnotationsDriver { - position: Vector3; - lat: number; - lon: number; - radius: number; + icons: MVTIconGlyphs; + labels: MVTLabelGlyphs; } @@ -26,10 +27,7 @@ export interface MVTAnnotationsPluginOptions { overlay: object; camera?: Camera | null; - sortCallback?: SortAnnotationCallback; - filterAnnotation?: GetAnnotationCallback | null; - onAnnotationsUpdate?: AnnotationsUpdateCallback; - displayOccupancyGrid?: boolean; + driver?: MVTAnnotationsDriver; } @@ -40,13 +38,13 @@ export class MVTAnnotationsPlugin { overlay: object; camera: Camera | null; + driver: MVTAnnotationsDriver; - filterAnnotation: GetAnnotationCallback | null; - onAnnotationsUpdate: AnnotationsUpdateCallback; - - displayOccupancyGrid: boolean; - maxSettleTimeMs: number; + readonly contentCache: object; constructor( options: MVTAnnotationsPluginOptions ); + init( tiles: object ): Promise; + dispose(): void; + } diff --git a/src/three/plugins/mvt/MVTAnnotationsPlugin.js b/src/three/plugins/mvt/MVTAnnotationsPlugin.js index 691911cff..f3d674abe 100644 --- a/src/three/plugins/mvt/MVTAnnotationsPlugin.js +++ b/src/three/plugins/mvt/MVTAnnotationsPlugin.js @@ -11,6 +11,8 @@ import { forEachTileInBounds, getMeshesCartographicRange } from '../images/overl import { parsePointAnnotations } from './annotations/PointAnnotation.js'; import { HierarchyOverlay } from './debug/HierarchyOverlay.js'; import { PointAnnotationManager } from './annotations/PointAnnotationManager.js'; +import { MVTIconGlyphs } from './MVTIconGlyphs.js'; +import { MVTLabelGlyphs } from './MVTLabelGlyphs.js'; const _matrix = /* @__PURE__ */ new Matrix4(); @@ -143,6 +145,97 @@ export class MVTAnnotationsDriver { } +// route occupancy annotations to the right renderer: text anchors expose `characterPositions`, +// point annotations expose a `position` +function splitAnnotations( set ) { + + const points = []; + const text = []; + for ( const item of set ) { + + if ( item.characterPositions !== undefined ) { + + text.push( item ); + + } else { + + points.push( item ); + + } + + } + + return { points, text }; + +} + +/** + * Ready-to-use driver so `new MVTAnnotationsPlugin( { overlay } )` displays something without any + * setup: every point feature is drawn as a filled white circle and every named line as white, + * black-outlined Arial text. No feature filtering is applied. Supply a custom `MVTAnnotationsDriver` + * to the plugin to override this behavior. + */ +export class DefaultMVTAnnotationsDriver extends MVTAnnotationsDriver { + + constructor() { + + super(); + + const dpr = window.devicePixelRatio; + + // a single filled circle glyph, used for every point annotation + const icons = new MVTIconGlyphs( { fallback: 'default' } ); + icons.glyphAtlas.drawChar( 'default', '●', { + fillStyle: 'white', + strokeStyle: 'black', + strokeWidth: 3 * dpr, + font: '30px sans-serif', + } ); + + // white Arial road labels with a black outline + const labels = new MVTLabelGlyphs( { + fontFamily: 'Arial', + strokeStyle: 'black', + strokeWidth: 3 * dpr, + } ); + + this.group.add( icons, labels ); + this.icons = icons; + this.labels = labels; + + } + + // include every feature + filterAnnotation( layer, properties, type ) { + + return true; + + } + + measureChar( char ) { + + return this.labels.measureChar( char ); + + } + + onAnnotationsUpdate( added, removed ) { + + const a = splitAnnotations( added ); + const r = splitAnnotations( removed ); + this.icons.update( a.icons, r.icons ); + this.labels.update( a.text, r.text ); + + } + + dispose() { + + this.icons.dispose(); + this.labels.dispose(); + + } + +} + /** * Plugin that extracts point features from an MVT overlay and manages their screen-space * occupation, preventing label crowding via a hierarchical lock system and raycasted depth @@ -171,7 +264,7 @@ export class MVTAnnotationsPlugin { const { overlay, camera = null, - driver = new MVTAnnotationsDriver(), + driver = new DefaultMVTAnnotationsDriver(), } = options; // user settings diff --git a/src/three/plugins/mvt/GlyphAtlasTexture.d.ts b/src/three/plugins/mvt/MVTGlyphAtlasTexture.d.ts similarity index 95% rename from src/three/plugins/mvt/GlyphAtlasTexture.d.ts rename to src/three/plugins/mvt/MVTGlyphAtlasTexture.d.ts index af1bf827a..e1a451ab0 100644 --- a/src/three/plugins/mvt/GlyphAtlasTexture.d.ts +++ b/src/three/plugins/mvt/MVTGlyphAtlasTexture.d.ts @@ -20,7 +20,7 @@ export interface DrawSVGOptions { iconScale?: number; } -export class GlyphAtlasTexture extends CanvasTexture { +export class MVTGlyphAtlasTexture extends CanvasTexture { readonly slotSize: number; readonly isFull: boolean; diff --git a/src/three/plugins/mvt/GlyphAtlasTexture.js b/src/three/plugins/mvt/MVTGlyphAtlasTexture.js similarity index 98% rename from src/three/plugins/mvt/GlyphAtlasTexture.js rename to src/three/plugins/mvt/MVTGlyphAtlasTexture.js index 855e30095..404dda676 100644 --- a/src/three/plugins/mvt/GlyphAtlasTexture.js +++ b/src/three/plugins/mvt/MVTGlyphAtlasTexture.js @@ -5,7 +5,7 @@ import { CanvasTexture, SRGBColorSpace, Vector2 } from 'three'; * Slots are addressed by string key and can be drawn with text, images, or paths. * @extends CanvasTexture */ -export class GlyphAtlasTexture extends CanvasTexture { +export class MVTGlyphAtlasTexture extends CanvasTexture { /** * Returns true when all slots are allocated. @@ -432,7 +432,7 @@ export class GlyphAtlasTexture extends CanvasTexture { } else { - throw new Error( 'GlyphAtlasTexture: atlas is full. Call resize() to increase capacity.' ); + throw new Error( 'MVTGlyphAtlasTexture: atlas is full. Call resize() to increase capacity.' ); } diff --git a/src/three/plugins/mvt/MVTGlyphMaterial.d.ts b/src/three/plugins/mvt/MVTGlyphMaterial.d.ts new file mode 100644 index 000000000..d68b2ebd7 --- /dev/null +++ b/src/three/plugins/mvt/MVTGlyphMaterial.d.ts @@ -0,0 +1,11 @@ +import { PointsMaterial, PointsMaterialParameters, Vector2 } from 'three'; +import { MVTGlyphAtlasTexture } from './MVTGlyphAtlasTexture.js'; + +export class MVTGlyphMaterial extends PointsMaterial { + + glyphAtlas: MVTGlyphAtlasTexture; + readonly glyphCellSize: Vector2; + + constructor( parameters?: PointsMaterialParameters ); + +} diff --git a/example/three/src/plugins/mvt/GlyphMaterial.js b/src/three/plugins/mvt/MVTGlyphMaterial.js similarity index 77% rename from example/three/src/plugins/mvt/GlyphMaterial.js rename to src/three/plugins/mvt/MVTGlyphMaterial.js index 605c8a522..27b1d769c 100644 --- a/example/three/src/plugins/mvt/GlyphMaterial.js +++ b/src/three/plugins/mvt/MVTGlyphMaterial.js @@ -1,8 +1,17 @@ import { PointsMaterial, Vector2 } from 'three'; -import { GlyphAtlasTexture } from '3d-tiles-renderer/plugins'; - -export class GlyphMaterial extends PointsMaterial { - +import { MVTGlyphAtlasTexture } from './MVTGlyphAtlasTexture.js'; + +/** + * A `PointsMaterial` that draws each point sprite as a glyph from an `MVTGlyphAtlasTexture` with fading. + * @private + * @extends PointsMaterial + */ +export class MVTGlyphMaterial extends PointsMaterial { + + /** + * The glyph atlas sampled by this material. + * @type {MVTGlyphAtlasTexture} + */ get glyphAtlas() { return this._glyphAtlas; @@ -26,12 +35,21 @@ export class GlyphMaterial extends PointsMaterial { } + /** + * A single atlas slot's size in UV units. + * @type {Vector2} + */ get glyphCellSize() { return this._glyphCellSize; } + /** + * @param {Object} [parameters] - `PointsMaterial` parameters, plus the overrides below. + * @param {number} [parameters.size=25] - Point size in pixels. + * @param {boolean} [parameters.sizeAttenuation=false] - Whether point size shrinks with distance. + */ constructor( parameters = {} ) { const { @@ -49,7 +67,7 @@ export class GlyphMaterial extends PointsMaterial { // owns the glyph atlas ( unless one is provided ); the cell size is kept in sync with it // and pushed to the uniforms after compile this._glyphCellSize = new Vector2(); - this._glyphAtlas = new GlyphAtlasTexture(); + this._glyphAtlas = new MVTGlyphAtlasTexture(); this._uniforms = null; this.onBeforeCompile = ( shader ) => { diff --git a/src/three/plugins/mvt/MVTGlyphs.d.ts b/src/three/plugins/mvt/MVTGlyphs.d.ts new file mode 100644 index 000000000..de03bf245 --- /dev/null +++ b/src/three/plugins/mvt/MVTGlyphs.d.ts @@ -0,0 +1,19 @@ +import { Points, Raycaster, Vector2 } from 'three'; +import { MVTGlyphMaterial } from './MVTGlyphMaterial.js'; +import { MVTGlyphAtlasTexture } from './MVTGlyphAtlasTexture.js'; + +export class MVTGlyphs extends Points { + + size: number; + readonly glyphAtlas: MVTGlyphAtlasTexture; + resolution: Vector2; + fadeInDuration: number; + fadeOutDuration: number; + + constructor( material: MVTGlyphMaterial ); + + dispose(): void; + update( added: Iterable, removed: Iterable ): void; + raycast( raycaster: Raycaster, intersects: object[] ): void; + +} diff --git a/src/three/plugins/mvt/MVTGlyphs.js b/src/three/plugins/mvt/MVTGlyphs.js new file mode 100644 index 000000000..bd76a1948 --- /dev/null +++ b/src/three/plugins/mvt/MVTGlyphs.js @@ -0,0 +1,335 @@ +/** @import { MVTGlyphAtlasTexture } from './MVTGlyphAtlasTexture.js' */ +/** @import { MVTGlyphMaterial } from './MVTGlyphMaterial.js' */ +import { BufferAttribute, BufferGeometry, Matrix4, Points, Vector2, Vector3, Vector4 } from 'three'; + +const _mvMatrix = /* @__PURE__ */ new Matrix4(); +const _uvTarget = {}; + +// scratch reused across raycasts +const _viewport = /* @__PURE__ */ new Vector4(); +const _point4 = /* @__PURE__ */ new Vector4(); +const _ssOrigin = /* @__PURE__ */ new Vector4(); +const _ssRay = /* @__PURE__ */ new Vector2(); +const _ssPoint = /* @__PURE__ */ new Vector2(); +const _worldPoint = /* @__PURE__ */ new Vector3(); + +/** + * Base `Points` object that renders a batch of glyphs from a shared `MVTGlyphAtlasTexture`, fading + * each item in and out. + * @extends Points + */ +export class MVTGlyphs extends Points { + + /** + * Glyph size in pixels. + * @type {number} + */ + get size() { + + return this.material.size; + + } + + set size( v ) { + + this.material.size = v; + + } + + /** + * The glyph atlas this object samples from. + * @type {MVTGlyphAtlasTexture} + */ + get glyphAtlas() { + + return this.material.glyphAtlas; + + } + + /** + * @param {MVTGlyphMaterial} material - Material used to draw the glyphs. + */ + constructor( material ) { + + super( new BufferGeometry(), material ); + + this.renderOrder = 1000; + this.frustumCulled = false; + + // viewport size in pixels, refreshed each frame in onBeforeRender for screen-space raycasting + this.resolution = new Vector2(); + + /** + * Seconds a glyph takes to fade in. + * @type {number} + */ + this.fadeInDuration = 0.3; + + /** + * Seconds a glyph takes to fade out. + * @type {number} + */ + this.fadeOutDuration = 0.3; + + // Map keyed by stable id, + // plus an insertion-ordered list of the same entries for stable geometry layout + this._entryMap = new Map(); + this._orderedEntries = []; + this._lastUpdateTime = - 1; + + } + + /** + * Disposes the glyph atlas, geometry, and material. + * @returns {void} + */ + dispose() { + + this.glyphAtlas.dispose(); + this.geometry.dispose(); + this.material.dispose(); + + } + + /** + * Updates the rendered glyphs from a frame's visibility changes and advances the fades. Call + * once per frame. + * @private + * @param {Iterable} added - Items that became visible, each with a stable `id`. + * @param {Iterable} removed - Items that became hidden. + * @returns {void} + */ + update( added, removed ) { + + const now = performance.now() / 1000; + const dt = this._lastUpdateTime < 0 ? 0 : Math.min( now - this._lastUpdateTime, 0.1 ); + this._lastUpdateTime = now; + + const { _entryMap, _orderedEntries, fadeInDuration, fadeOutDuration } = this; + + for ( const item of added ) { + + const existing = _entryMap.get( item.id ); + if ( ! existing ) { + + const entry = { item, fade: 0, state: 'in' }; + _entryMap.set( item.id, entry ); + _orderedEntries.push( entry ); + + } else { + + // keep reference fresh (LoD swap) + existing.item = item; + if ( existing.state === 'out' ) existing.state = 'in'; + + } + + } + + for ( const item of removed ) { + + const entry = _entryMap.get( item.id ); + if ( entry && entry.state !== 'out' ) { + + entry.state = 'out'; + + } + + } + + let didRemove = false; + for ( const [ id, entry ] of _entryMap ) { + + if ( entry.state === 'in' ) { + + entry.fade = Math.min( 1, entry.fade + dt / fadeInDuration ); + if ( entry.fade >= 1 ) { + + entry.state = 'visible'; + + } + + } else if ( entry.state === 'out' ) { + + entry.fade = Math.max( 0, entry.fade - dt / fadeOutDuration ); + if ( entry.fade <= 0 ) { + + _entryMap.delete( id ); + didRemove = true; + + } + + } + + } + + if ( didRemove ) { + + this._orderedEntries = _orderedEntries.filter( e => _entryMap.has( e.item.id ) ); + + } + + this._updateGeometry(); + + } + + onBeforeRender( renderer, scene, camera ) { + + // use the active viewport (not getDrawingBufferSize) so raycasting matches the GPU's + // NDC→pixel mapping in sub-viewport scenarios + renderer.getViewport( _viewport ); + this.resolution.set( _viewport.z, _viewport.w ); + + } + + raycast( raycaster, intersects ) { + + const camera = raycaster.camera; + if ( ! camera ) return; + + const { geometry, material, matrixWorld, resolution } = this; + const posAttr = geometry.getAttribute( 'position' ); + if ( ! posAttr || posAttr.count === 0 ) return; + + const pointRadius = material.size / 2; // pixels + const near = - camera.near; + + // Project a point 1 unit along the ray into screen space for 2D comparison. + // Using the same centered screen-space convention as LineSegments2 + // (NDC * resolution/2, NOT NDC * resolution/2 + resolution/2). + raycaster.ray.at( 1, _ssOrigin ); + _ssOrigin.w = 1; + _ssOrigin.applyMatrix4( camera.matrixWorldInverse ); + _ssOrigin.applyMatrix4( camera.projectionMatrix ); + _ssOrigin.multiplyScalar( 1 / _ssOrigin.w ); + _ssRay.set( _ssOrigin.x * resolution.x / 2, _ssOrigin.y * resolution.y / 2 ); + + _mvMatrix.multiplyMatrices( camera.matrixWorldInverse, matrixWorld ); + + for ( let i = 0, l = this.geometry.drawRange.count; i < l; i ++ ) { + + _point4.fromBufferAttribute( posAttr, i ); + _point4.w = 1; + + // camera space + _point4.applyMatrix4( _mvMatrix ); + + // skip if behind near plane + if ( _point4.z > near ) continue; + + // clip → NDC + _point4.applyMatrix4( camera.projectionMatrix ); + _point4.multiplyScalar( 1 / _point4.w ); + + // skip if outside depth clip bounds + if ( _point4.z < - 1 || _point4.z > 1 ) continue; + + // centered screen space + _ssPoint.set( _point4.x * resolution.x / 2, _point4.y * resolution.y / 2 ); + + if ( _ssRay.distanceTo( _ssPoint ) > pointRadius ) continue; + + // hit — record 3D world position and distance along ray + _worldPoint.fromBufferAttribute( posAttr, i ).applyMatrix4( matrixWorld ); + + const entry = this._orderedEntries[ i ]; + intersects.push( { + distance: raycaster.ray.origin.distanceTo( _worldPoint ), + point: _worldPoint.clone(), + index: i, + face: null, + faceIndex: null, + object: this, + layer: entry?.item.layer ?? null, + properties: entry?.item.properties ?? null, + } ); + + } + + } + + onAfterRender( renderer, scene, camera ) { + + // keep the root near the camera to avoid gpu jitter at globe scale + const { parent } = this; + if ( parent ) { + + _mvMatrix.copy( parent.matrixWorld ).invert(); + + } else { + + _mvMatrix.identity(); + + } + + this.position.setFromMatrixPosition( camera.matrixWorld ).applyMatrix4( _mvMatrix ); + this.updateMatrixWorld( true ); + + } + + // subclasses build their geometry from here + _updateGeometry() {} + + // resize the shared per-glyph attribute buffers to hold `count` glyphs if necessary and set the draw + // range + _resizeGeometry( count ) { + + const { geometry } = this; + const posAttr = geometry.getAttribute( 'position' ); + if ( ! posAttr || posAttr.count < count ) { + + geometry.dispose(); + geometry.setAttribute( 'position', new BufferAttribute( new Float32Array( count * 3 ), 3 ) ); + geometry.setAttribute( 'glyphUV', new BufferAttribute( new Float32Array( count * 2 ), 2 ) ); + geometry.setAttribute( 'alpha', new BufferAttribute( new Float32Array( count ), 1 ) ); + geometry.setAttribute( 'angle', new BufferAttribute( new Float32Array( count ), 1 ) ); + + } + + geometry.setDrawRange( 0, count ); + + } + + // write a single glyph's attributes; position is stored relative to this.position ( the + // camera-local origin ) and `key` looks up the atlas slot, or -1 when it isn't present + _writeGlyph( i, pos, key, fade, angle = 0 ) { + + const { geometry, glyphAtlas } = this; + const origin = this.position; + const { + position: posAttr, + glyphUV: uvAttr, + alpha: alphaAttr, + angle: angleAttr, + } = geometry.attributes; + + posAttr.setXYZ( i, pos.x - origin.x, pos.y - origin.y, pos.z - origin.z ); + + if ( key !== null && glyphAtlas.has( key ) ) { + + const uv = glyphAtlas.getUV( key, _uvTarget ); + uvAttr.setXY( i, uv.x, uv.y ); + + } else { + + uvAttr.setXY( i, - 1, - 1 ); + + } + + alphaAttr.setX( i, fade ); + angleAttr.setX( i, angle ); + + } + + // flag the per-glyph attributes for upload + _markNeedsUpdate() { + + const { geometry } = this; + geometry.getAttribute( 'position' ).needsUpdate = true; + geometry.getAttribute( 'glyphUV' ).needsUpdate = true; + geometry.getAttribute( 'alpha' ).needsUpdate = true; + geometry.getAttribute( 'angle' ).needsUpdate = true; + + } + +} diff --git a/src/three/plugins/mvt/MVTIconGlyphs.d.ts b/src/three/plugins/mvt/MVTIconGlyphs.d.ts new file mode 100644 index 000000000..1e3ea6a73 --- /dev/null +++ b/src/three/plugins/mvt/MVTIconGlyphs.d.ts @@ -0,0 +1,20 @@ +import { MVTGlyphs } from './MVTGlyphs.js'; + +export interface MVTIconGlyphsOptions { + + getKind?: ( layer: string, properties: Record ) => string | null; + fallback?: string | null; + size?: number; + glyphSize?: number; + slotCount?: number; + +} + +export class MVTIconGlyphs extends MVTGlyphs { + + getKind: ( layer: string, properties: Record ) => string | null; + fallback: string | null; + + constructor( options?: MVTIconGlyphsOptions ); + +} diff --git a/src/three/plugins/mvt/MVTIconGlyphs.js b/src/three/plugins/mvt/MVTIconGlyphs.js new file mode 100644 index 000000000..7f2eb6456 --- /dev/null +++ b/src/three/plugins/mvt/MVTIconGlyphs.js @@ -0,0 +1,74 @@ +import { MVTGlyphMaterial } from './MVTGlyphMaterial.js'; +import { MVTGlyphs } from './MVTGlyphs.js'; + +/** + * Renders one icon glyph per point annotation. Each item's atlas key comes from `getKind`, or + * `fallback` when that key isn't in the atlas. + * @extends MVTGlyphs + */ +export class MVTIconGlyphs extends MVTGlyphs { + + /** + * @param {Object} [options] + * @param {( layer: string, properties: Object ) => ( string | null )} [options.getKind] - Returns + * the atlas key for an item, or null. + * @param {string|null} [options.fallback=null] - Atlas key drawn when `getKind`'s result is + * missing from the atlas; null draws nothing. + * @param {number} [options.size=18] - Glyph size in pixels. + * @param {number} [options.glyphSize] - Atlas slot size in pixels (defaults to `18 * devicePixelRatio`). + * @param {number} [options.slotCount=64] - Initial atlas slot capacity. + */ + constructor( options = {} ) { + + const { + getKind = () => null, + fallback = null, + size = 18, + glyphSize = 18 * window.devicePixelRatio, + slotCount = 64, + } = options; + + super( new MVTGlyphMaterial() ); + + /** + * Returns the atlas key for a given item, or null for none. + * @type {( layer: string, properties: Object ) => ( string | null )} + */ + this.getKind = getKind; + + /** + * Atlas key used when `getKind`'s result isn't present in the atlas ( null draws nothing ). + * @type {string|null} + */ + this.fallback = fallback; + this.size = size; + + this.glyphAtlas.resize( slotCount, glyphSize ); + + } + + _updateGeometry() { + + const { _orderedEntries, getKind, glyphAtlas, fallback } = this; + const count = _orderedEntries.length; + this._resizeGeometry( count ); + + for ( let i = 0; i < count; i ++ ) { + + const { item, fade } = _orderedEntries[ i ]; + let key = getKind( item.layer, item.properties ); + if ( key === null || ! glyphAtlas.has( key ) ) { + + key = fallback; + + } + + this._writeGlyph( i, item.position, key, fade ); + + } + + this._markNeedsUpdate(); + + } + +} diff --git a/src/three/plugins/mvt/MVTLabelGlyphs.d.ts b/src/three/plugins/mvt/MVTLabelGlyphs.d.ts new file mode 100644 index 000000000..3cd087904 --- /dev/null +++ b/src/three/plugins/mvt/MVTLabelGlyphs.d.ts @@ -0,0 +1,21 @@ +import { MVTGlyphs } from './MVTGlyphs.js'; + +export interface MVTLabelGlyphsOptions { + + size?: number; + glyphSize?: number; + slotCount?: number; + font?: string | null; + fontFamily?: string; + strokeStyle?: string; + strokeWidth?: number; + +} + +export class MVTLabelGlyphs extends MVTGlyphs { + + constructor( options?: MVTLabelGlyphsOptions ); + + measureChar( char: string ): number; + +} diff --git a/example/three/src/plugins/mvt/CharacterPoints.js b/src/three/plugins/mvt/MVTLabelGlyphs.js similarity index 61% rename from example/three/src/plugins/mvt/CharacterPoints.js rename to src/three/plugins/mvt/MVTLabelGlyphs.js index 2cdf334aa..91302dade 100644 --- a/example/three/src/plugins/mvt/CharacterPoints.js +++ b/src/three/plugins/mvt/MVTLabelGlyphs.js @@ -1,11 +1,24 @@ -import { BufferAttribute } from 'three'; -import { GlyphMaterial } from './GlyphMaterial.js'; -import { GlyphPoints } from './GlyphPoints.js'; - -const _uvTarget = {}; - -export class CharacterPoints extends GlyphPoints { - +import { MVTGlyphMaterial } from './MVTGlyphMaterial.js'; +import { MVTGlyphs } from './MVTGlyphs.js'; + +/** + * Renders text labels one glyph per character, laid out along each annotation's path. Characters are + * rasterized into the atlas on demand, so a label's text may change at any time. + * @extends MVTGlyphs + */ +export class MVTLabelGlyphs extends MVTGlyphs { + + /** + * @param {Object} [options] + * @param {number} [options.size=16] - Glyph size in pixels. + * @param {number} [options.glyphSize] - Atlas slot size in pixels (defaults to `16 * devicePixelRatio`). + * @param {number} [options.slotCount=64] - Initial atlas slot capacity ( grows as needed ). + * @param {string|null} [options.font=null] - Explicit CSS font string; overrides `fontFamily`. + * @param {string} [options.fontFamily='sans-serif'] - Font family used to build the CSS font when + * `font` isn't given. + * @param {string} [options.strokeStyle='black'] - Outline color drawn under each glyph. + * @param {number} [options.strokeWidth=0] - Outline width in atlas pixels ( 0 disables the outline ). + */ constructor( options = {} ) { const { @@ -13,15 +26,16 @@ export class CharacterPoints extends GlyphPoints { glyphSize = 16 * window.devicePixelRatio, slotCount = 64, font = null, + fontFamily = 'sans-serif', strokeStyle = 'black', strokeWidth = 0, } = options; - super( new GlyphMaterial( { size } ) ); + super( new MVTGlyphMaterial( { size } ) ); // CSS font used to rasterize glyphs, sized to fit the atlas slot const fontSize = Math.round( glyphSize * 0.7 ); - this._font = font ?? `400 ${ fontSize }px sans-serif`; + this._font = font ?? `400 ${ fontSize }px ${ fontFamily }`; // advance-width cache, keyed per character this._advanceCache = new Map(); @@ -40,7 +54,11 @@ export class CharacterPoints extends GlyphPoints { } - // advance width of `char` in em units ( fraction of the font size ), cached per character + /** + * Advance width of `char` in the label's size units, cached per character. + * @param {string} char - The character to measure. + * @returns {number} The advance width. + */ measureChar( char ) { const { _advanceCache, material, glyphAtlas, _font } = this; @@ -102,7 +120,7 @@ export class CharacterPoints extends GlyphPoints { _updateGeometry() { - const { _orderedEntries, _needed, geometry, position, glyphAtlas } = this; + const { _orderedEntries, _needed, glyphAtlas } = this; // collect the characters needed this frame from each items text, and the total glyph // count @@ -131,26 +149,7 @@ export class CharacterPoints extends GlyphPoints { } - // expand the geometry buffers if needed - let posAttr = geometry.getAttribute( 'position' ); - let glyphUVAttr = geometry.getAttribute( 'glyphUV' ); - let alphaAttr = geometry.getAttribute( 'alpha' ); - let angleAttr = geometry.getAttribute( 'angle' ); - if ( ! posAttr || posAttr.count < count ) { - - geometry.dispose(); - posAttr = new BufferAttribute( new Float32Array( count * 3 ), 3 ); - glyphUVAttr = new BufferAttribute( new Float32Array( count * 2 ), 2 ); - alphaAttr = new BufferAttribute( new Float32Array( count ), 1 ); - angleAttr = new BufferAttribute( new Float32Array( count ), 1 ); - geometry.setAttribute( 'position', posAttr ); - geometry.setAttribute( 'glyphUV', glyphUVAttr ); - geometry.setAttribute( 'alpha', alphaAttr ); - geometry.setAttribute( 'angle', angleAttr ); - - } - - geometry.setDrawRange( 0, count ); + this._resizeGeometry( count ); let i = 0; for ( const entry of _orderedEntries ) { @@ -162,32 +161,13 @@ export class CharacterPoints extends GlyphPoints { const text = anchor.text; for ( let c = 0, l = positions.length; c < l; c ++ ) { - const p = positions[ c ]; - posAttr.setXYZ( i, p.x - position.x, p.y - position.y, p.z - position.z ); - - const uv = glyphAtlas.getUV( text[ c ], _uvTarget ); - if ( uv !== null ) { - - glyphUVAttr.setXY( i, uv.x, uv.y ); - - } else { - - glyphUVAttr.setXY( i, - 1, - 1 ); - - } - - alphaAttr.setX( i, fade ); - angleAttr.setX( i, angles[ c ] ); - i ++; + this._writeGlyph( i ++, positions[ c ], text[ c ], fade, angles[ c ] ); } } - posAttr.needsUpdate = true; - glyphUVAttr.needsUpdate = true; - alphaAttr.needsUpdate = true; - angleAttr.needsUpdate = true; + this._markNeedsUpdate(); }