diff --git a/example/three/annotationsExample.js b/example/three/annotationsExample.js index 8eef49966..722a76b13 100644 --- a/example/three/annotationsExample.js +++ b/example/three/annotationsExample.js @@ -25,6 +25,7 @@ import { 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 @@ -110,6 +111,7 @@ const params = { let controls, scene, renderer, camera, tiles; let annotationsPoints = null; +let characterPoints = null; // raycasting const pointer = new Vector2(); @@ -120,6 +122,30 @@ let lastClientX = 0, lastClientY = 0; init(); animate(); +// route occupancy annotations to the right renderer: point annotations expose a `position`, +// text-anchor annotations expose `characterPositions` +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 }; + +} + function reinstantiateTiles() { if ( tiles ) { @@ -151,7 +177,16 @@ function reinstantiateTiles() { else return 'name' in properties; }, - onAnnotationsUpdate: ( added, removed ) => annotationsPoints.update( added, removed ), + onAnnotationsUpdate: ( added, removed ) => { + + const a = splitAnnotations( added ); + const r = splitAnnotations( removed ); + annotationsPoints.update( a.points, r.points ); + characterPoints.update( a.text, r.text ); + + }, + // deferred: characterPoints is created below, but this only runs at update time + measureChar: char => characterPoints.measureChar( char ), } ) ); // use the camera cartographic region plugin to prevent particularly low-lod @@ -167,8 +202,6 @@ function reinstantiateTiles() { } ) ); annotationsPoints = new AnnotationPoints( { - size: 20, - glyphSize: 2 * 20 * renderer.getPixelRatio(), getKind: ( layer, properties ) => { return KIND_TO_ICON[ properties.kind ] || null; @@ -196,6 +229,12 @@ function reinstantiateTiles() { tiles.group.add( annotationsPoints ); + characterPoints = new CharacterPoints( { + strokeStyle: '#3f3e4c', + strokeWidth: 6 * renderer.getPixelRatio(), + } ); + tiles.group.add( characterPoints ); + tiles.group.rotation.x = - Math.PI / 2; scene.add( tiles.group ); diff --git a/example/three/src/plugins/mvt/AnnotationPoints.js b/example/three/src/plugins/mvt/AnnotationPoints.js index 3c48c797b..29ce31e7d 100644 --- a/example/three/src/plugins/mvt/AnnotationPoints.js +++ b/example/three/src/plugins/mvt/AnnotationPoints.js @@ -1,6 +1,6 @@ -import { BufferAttribute, BufferGeometry, Matrix4, Points, Vector2, Vector3, Vector4 } from 'three'; -import { GlyphAtlasTexture } from '3d-tiles-renderer/plugins'; +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(); @@ -10,54 +10,27 @@ const _ssRay = /* @__PURE__ */ new Vector2(); const _ssPoint = /* @__PURE__ */ new Vector2(); const _worldPoint = /* @__PURE__ */ new Vector3(); -export class AnnotationPoints extends Points { - - get size() { - - return this.material.size; - - } - - set size( v ) { - - this.material.size = v; - - } +export class AnnotationPoints extends GlyphPoints { constructor( options = {} ) { const { getKind = () => null, - size = 20, - glyphSize = 20, + size = 18, + glyphSize = 18 * window.devicePixelRatio, slotCount = 64, } = options; - - super( new BufferGeometry(), new GlyphMaterial() ); + super( new GlyphMaterial() ); this.getKind = getKind; - - this.renderOrder = 1000; - this.frustumCulled = false; - - this.fadeInDuration = 0.3; - this.fadeOutDuration = 0.3; this.size = size; // Viewport size in pixels — must be kept current by the owner (plugin updates each frame). this.resolution = new Vector2(); - - // Map — keyed by stable id, not object reference. - // entry: { item, fade: 0..1, state: 'in' | 'visible' | 'out' } - this._entryMap = new Map(); - this._orderedEntries = []; this.needsUpdate = false; - this._lastUpdateTime = - 1; - this.glyphAtlas = new GlyphAtlasTexture( slotCount, glyphSize ); - this.material.glyphTexture = this.glyphAtlas; - this.glyphAtlas.getSlotSize( this.material.glyphCellSize ); + this.glyphAtlas.resize( slotCount, glyphSize ); } @@ -71,107 +44,6 @@ export class AnnotationPoints extends Points { } - onAfterRender( renderer, scene, camera ) { - - const { parent } = this; - - // transform the root of this object to be near the camera to avoid gpu jitter - if ( parent ) { - - _mvMatrix.copy( parent.matrixWorld ).invert(); - - } else { - - _mvMatrix.identity(); - - } - - this.position.setFromMatrixPosition( camera.matrixWorld ).applyMatrix4( _mvMatrix ); - this.updateMatrixWorld( true ); - - } - - // Call when the occupation manager fires a change. Returns true while any point is still animating. - update( added, removed ) { - - const now = performance.now() / 1000; - const dt = this._lastUpdateTime < 0 ? 0 : Math.min( now - this._lastUpdateTime, 0.1 ); - this._lastUpdateTime = now; - - // Add new items, update LoD-swapped references, reverse in-progress fade-outs. - const { _entryMap, _orderedEntries } = 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 { - - existing.item = item; // keep reference fresh (LoD swap) - if ( existing.state === 'out' ) existing.state = 'in'; - - } - - } - - // Start fade-out for removed items. - for ( const item of removed ) { - - const entry = _entryMap.get( item.id ); - if ( entry && entry.state !== 'out' ) { - - entry.state = 'out'; - - } - - } - - // Tick fades; collect fully-faded-out items for removal. - const toRemove = []; - for ( const [ id, entry ] of _entryMap ) { - - if ( entry.state === 'in' ) { - - entry.fade = Math.min( 1, entry.fade + dt / this.fadeInDuration ); - if ( entry.fade >= 1 ) { - - entry.state = 'visible'; - - } - - } else if ( entry.state === 'out' ) { - - entry.fade = Math.max( 0, entry.fade - dt / this.fadeOutDuration ); - if ( entry.fade <= 0 ) { - - toRemove.push( id ); - - } - - } - - } - - if ( toRemove.length > 0 ) { - - for ( const id of toRemove ) { - - _entryMap.delete( id ); - - } - - this._orderedEntries = _orderedEntries.filter( e => _entryMap.has( e.item.id ) ); - - } - - this._updateGeometry(); - - } - raycast( raycaster, intersects ) { const camera = raycaster.camera; diff --git a/example/three/src/plugins/mvt/CharacterPoints.js b/example/three/src/plugins/mvt/CharacterPoints.js new file mode 100644 index 000000000..5aa9b8245 --- /dev/null +++ b/example/three/src/plugins/mvt/CharacterPoints.js @@ -0,0 +1,146 @@ +import { BufferAttribute } from 'three'; +import { GlyphMaterial } from './GlyphMaterial.js'; +import { GlyphPoints } from './GlyphPoints.js'; + +export class CharacterPoints extends GlyphPoints { + + constructor( options = {} ) { + + const { + size = 16, + glyphSize = 16 * window.devicePixelRatio, + slotCount = 256, + font = null, + strokeStyle = 'black', + strokeWidth = 0, + } = options; + + super( new GlyphMaterial( { 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 Arial`; + + // canvas context for measuring advance widths, normalized to em units ( width / fontSize ) + this._advanceCache = new Map(); + + // black halo so glyphs read over the imagery + this._strokeStyle = strokeStyle; + this._strokeWidth = strokeWidth; + + this.glyphAtlas.resize( slotCount, glyphSize ); + + } + + // advance width of `char` in em units ( fraction of the font size ), cached per character + measureChar( char ) { + + const { _advanceCache, material, glyphAtlas, _font } = this; + if ( ! _advanceCache.has( char ) ) { + + const multiplier = material.size / glyphAtlas.slotSize; + const info = glyphAtlas.measureChar( char, _font ); + const advance = info.width + 2; + + _advanceCache.set( char, advance * multiplier ); + + } + + return _advanceCache.get( char ); + + } + + // uv bounds of the glyph for `char`, rasterizing it into the atlas on first use + _glyphUV( char ) { + + // TODO: we need to use a smart counter to determine when + // to free a slot + const { glyphAtlas } = this; + if ( ! glyphAtlas.has( char ) ) { + + glyphAtlas.drawChar( char, char, { + font: this._font, + color: 'white', + strokeStyle: this._strokeStyle, + strokeWidth: this._strokeWidth, + } ); + + } + + return glyphAtlas.getUV( char ); + + } + + _updateGeometry() { + + const { _orderedEntries, geometry, position } = this; + + // total character count across all entries ( including fading ones ) + let count = 0; + for ( const entry of _orderedEntries ) { + + count += entry.item.characterPositions.length; + + } + + // 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 ); + + let i = 0; + for ( const entry of _orderedEntries ) { + + const anchor = entry.item; + const { fade } = entry; + const positions = anchor.characterPositions; + const angles = anchor.characterAngles; + 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 = this._glyphUV( text[ c ] ); + 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 ++; + + } + + } + + posAttr.needsUpdate = true; + glyphUVAttr.needsUpdate = true; + alphaAttr.needsUpdate = true; + angleAttr.needsUpdate = true; + + } + +} diff --git a/example/three/src/plugins/mvt/GlyphMaterial.js b/example/three/src/plugins/mvt/GlyphMaterial.js index c0ad1ac3d..605c8a522 100644 --- a/example/three/src/plugins/mvt/GlyphMaterial.js +++ b/example/three/src/plugins/mvt/GlyphMaterial.js @@ -1,16 +1,23 @@ import { PointsMaterial, Vector2 } from 'three'; +import { GlyphAtlasTexture } from '3d-tiles-renderer/plugins'; export class GlyphMaterial extends PointsMaterial { - get glyphTexture() { + get glyphAtlas() { - return this._glyphTexture; + return this._glyphAtlas; } - set glyphTexture( v ) { + set glyphAtlas( v ) { + + this._glyphAtlas = v; + if ( v !== null ) { + + v.getSlotSize( this._glyphCellSize ); + + } - this._glyphTexture = v; if ( this._uniforms ) { this._uniforms.glyphAtlas.value = v; @@ -28,7 +35,6 @@ export class GlyphMaterial extends PointsMaterial { constructor( parameters = {} ) { const { - glyphAtlas = null, size = 25, sizeAttenuation = false, ...rest @@ -40,14 +46,15 @@ export class GlyphMaterial extends PointsMaterial { this.depthTest = false; this.depthWrite = false; - // glyph atlas state — set before first render; setters sync to uniforms after compile - this._glyphTexture = glyphAtlas; + // 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._uniforms = null; this.onBeforeCompile = ( shader ) => { - shader.uniforms.glyphAtlas = { value: this._glyphTexture }; + shader.uniforms.glyphAtlas = { value: this._glyphAtlas }; shader.uniforms.glyphCellSize = { value: this._glyphCellSize }; this._uniforms = shader.uniforms; @@ -57,8 +64,10 @@ export class GlyphMaterial extends PointsMaterial { #include attribute vec2 glyphUV; attribute float alpha; + attribute float angle; varying vec2 vGlyphUV; varying float vAlpha; + varying float vAngle; ` ); @@ -68,39 +77,57 @@ export class GlyphMaterial extends PointsMaterial { #include vGlyphUV = glyphUV; vAlpha = alpha; + vAngle = angle; ` ); - shader.fragmentShader = shader.fragmentShader.replace( - '#include ', - /* glsl */` - #include + shader.fragmentShader = /* glsl */` + uniform sampler2D glyphAtlas; uniform vec2 glyphCellSize; varying vec2 vGlyphUV; varying float vAlpha; - ` - ); + varying float vAngle; - shader.fragmentShader = shader.fragmentShader.replace( - '#include ', - /* glsl */` - if ( vGlyphUV.x >= 0.0 ) { + void main() { + + vec4 diffuseColor = vec4( 0.0 ); + if ( vGlyphUV.x >= 0.0 ) { + + // rotate the point-sprite lookup around its center so the glyph follows + // the path direction; clamp keeps the rotated corners inside the slot + vec2 pc = gl_PointCoord - 0.5; + float c = cos( vAngle ); + float s = sin( vAngle ); + pc = vec2( c * pc.x + s * pc.y, - s * pc.x + c * pc.y ) + 0.5; + pc = clamp( pc, 0.0, 1.0 ); + + vec4 glyph = texture2D( glyphAtlas, vGlyphUV + pc * glyphCellSize * vec2( 1.0, - 1.0 ) ); + diffuseColor = glyph; + + } + + diffuseColor.a *= vAlpha; + gl_FragColor = diffuseColor; + + #include + #include + #include - vec4 glyph = texture2D( glyphAtlas, vGlyphUV + gl_PointCoord * glyphCellSize * vec2( 1.0, - 1.0 ) ); - outgoingLight = mix( outgoingLight, glyph.rgb, glyph.a ); - diffuseColor.a = glyph.a; } - diffuseColor.a *= vAlpha; - #include - ` - ); + `; }; } + onBeforeRender() { + + this._glyphAtlas.getSlotSize( this._glyphCellSize ); + + } + } diff --git a/example/three/src/plugins/mvt/GlyphPoints.js b/example/three/src/plugins/mvt/GlyphPoints.js new file mode 100644 index 000000000..75b888bbc --- /dev/null +++ b/example/three/src/plugins/mvt/GlyphPoints.js @@ -0,0 +1,145 @@ +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; + + } + + // 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'; + + } + + } + + const toRemove = []; + 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 ) { + + toRemove.push( id ); + + } + + } + + } + + if ( toRemove.length > 0 ) { + + for ( const id of toRemove ) { + + _entryMap.delete( id ); + + } + + 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/mvt/GlyphAtlasTexture.js b/src/three/plugins/mvt/GlyphAtlasTexture.js index 6df50fb73..375b48f41 100644 --- a/src/three/plugins/mvt/GlyphAtlasTexture.js +++ b/src/three/plugins/mvt/GlyphAtlasTexture.js @@ -20,10 +20,12 @@ export class GlyphAtlasTexture extends CanvasTexture { * @param {number} slotCount - Maximum number of slots in the atlas. * @param {number} slotSize - Width and height of each slot in pixels. */ - constructor( slotCount, slotSize ) { + constructor( slotCount = 32, slotSize = 64 ) { super( null ); + this.generateMipmaps = false; + this.slotSize = 0; // key -> slot index @@ -102,12 +104,15 @@ export class GlyphAtlasTexture extends CanvasTexture { } /** - * Renders a single character centered in the slot. + * Renders a single character in the slot, centered on its ink bounding box. * @param {string} key * @param {string} char - The character to draw. * @param {Object} [styles={}] * @param {string} [styles.font=''] CSS font string (e.g. `'bold 48px sans-serif'`). * @param {string} [styles.color='white'] CSS fill color. + * @param {string|null} [styles.strokeStyle=null] CSS stroke color drawn under the fill, or + * null to skip the stroke. + * @param {number} [styles.strokeWidth=1] Stroke width in atlas pixels. * @returns {{ x: number, y: number, w: number, h: number }} The allocated slot. * @throws If the atlas is full. */ @@ -116,20 +121,47 @@ export class GlyphAtlasTexture extends CanvasTexture { const { font = '', color = 'white', + strokeStyle = null, + strokeWidth = 1, } = styles; return this._draw( key, ( ctx, x, y, w, h ) => { + const cx = x + w / 2; + const cy = y + h / 2; + + // center the glyph by its ink bounding box width + const m = this.measureChar( char ); + const drawX = cx - ( m.actualBoundingBoxRight + m.actualBoundingBoxLeft ) / 2; + const drawY = cy + h / 4; + + // stroke first so the fill sits on top of the halo + if ( strokeStyle !== null ) { + + ctx.font = font; + ctx.lineJoin = 'round'; + ctx.lineWidth = strokeWidth; + ctx.strokeStyle = strokeStyle; + ctx.strokeText( char, drawX, drawY ); + + } + ctx.font = font; ctx.fillStyle = color; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText( char, x + w / 2, y + h / 2 ); + ctx.fillText( char, drawX, drawY ); } ); } + measureChar( char, font ) { + + const { ctx } = this; + ctx.font = font; + return ctx.measureText( char ); + + } + /** * Draws a `CanvasImageSource` into the slot, scaled to fit. * @param {string} key diff --git a/src/three/plugins/mvt/MVTAnnotationsPlugin.js b/src/three/plugins/mvt/MVTAnnotationsPlugin.js index 556a4c66d..1132e3874 100644 --- a/src/three/plugins/mvt/MVTAnnotationsPlugin.js +++ b/src/three/plugins/mvt/MVTAnnotationsPlugin.js @@ -54,6 +54,8 @@ function collectMeshes( object ) { * content is parsed for point features. * @param {Camera} [options.camera=null] - Initial camera. Can be updated with `setCamera()`. * @param {Scene} [options.scene=null] - Three.js scene reference (stored for caller use). + * @param {( char: string ) => number} [options.measureChar] - Returns a per-character + * advance width used for text label spacing. Temporary hook — wiring to be cleaned up. */ export class MVTAnnotationsPlugin { @@ -80,6 +82,7 @@ export class MVTAnnotationsPlugin { }, filterAnnotation = () => false, onAnnotationsUpdate = () => {}, + measureChar = () => 1, camera = null, } = options; @@ -89,6 +92,7 @@ export class MVTAnnotationsPlugin { this.sortCallback = sortCallback; this.filterAnnotation = filterAnnotation; this.onAnnotationsUpdate = onAnnotationsUpdate; + this.measureChar = measureChar; // hierarchy for managing tile loading and visibility this.hierarchy = new MVTHierarchy(); @@ -238,6 +242,7 @@ export class MVTAnnotationsPlugin { anchorManager.update(); anchorManager.added.forEach( item => { + item.measureChar = this.measureChar; occupancy.register( item ); } ); diff --git a/src/three/plugins/mvt/ScreenOccupationManager.js b/src/three/plugins/mvt/ScreenOccupationManager.js index 1cb0fb6d6..4a809e27b 100644 --- a/src/three/plugins/mvt/ScreenOccupationManager.js +++ b/src/three/plugins/mvt/ScreenOccupationManager.js @@ -30,10 +30,6 @@ export class OccupancyAnnotation { } - copyPosition( source ) { - - } - } export class ScreenOccupationManager extends EventDispatcher { diff --git a/src/three/plugins/mvt/SettlingManager.js b/src/three/plugins/mvt/SettlingManager.js index 518e3faaf..2fbccf412 100644 --- a/src/three/plugins/mvt/SettlingManager.js +++ b/src/three/plugins/mvt/SettlingManager.js @@ -152,18 +152,18 @@ export class SettlingManager { } - _getSettlingRay( lat, lon ) { + _getSettlingRay( lat, lon, raycaster ) { // construct a downward ray at the given cartographic point in the local tiles frame const { tiles } = this; - const { origin, direction } = _raycaster.ray; + const { origin, direction } = raycaster.ray; tiles.ellipsoid.getCartographicToPosition( lat, lon, 1e8, origin ); tiles.ellipsoid.getCartographicToPosition( lat, lon, 0, direction ); direction.sub( origin ).normalize(); - _raycaster.far = 2 * 1e8; - _raycaster.firstHitOnly = true; + raycaster.far = 2 * 1e8; + raycaster.firstHitOnly = true; } @@ -174,7 +174,7 @@ export class SettlingManager { const { origin, direction } = _raycaster.ray; // build the local ray and transform to world space for raycasting - this._getSettlingRay( lat, lon ); + this._getSettlingRay( lat, lon, _raycaster ); origin.applyMatrix4( tiles.group.matrixWorld ); direction.transformDirection( tiles.group.matrixWorld ); @@ -236,8 +236,9 @@ export class SettlingManager { // check if the middle anchor rays intersects the frustum const { anchorPositions } = item; const anchorPosition = anchorPositions[ anchorPositions.length >> 1 ]; + const { lat, lon } = anchorPosition; - this._getSettlingRay( anchorPosition.lat, anchorPosition.lon ); + this._getSettlingRay( lat, lon, _raycaster ); if ( rayIntersectsFrustum( _raycaster, frustum ) ) { intersectingFrustum.add( item ); @@ -249,7 +250,7 @@ export class SettlingManager { } else { // check if the point projection ray intersects the frustum - this._getSettlingRay( item.lat, item.lon ); + this._getSettlingRay( item.lat, item.lon, _raycaster ); if ( rayIntersectsFrustum( _raycaster, frustum ) ) { intersectingFrustum.add( item ); @@ -371,6 +372,10 @@ export class SettlingManager { } + // draped positions changed — force the screen transform to recompute even if the + // camera is static, so the anchor can place without waiting for camera motion + item.needsUpdate = true; + } else { // settle the point onto the surface diff --git a/src/three/plugins/mvt/TextAnchorManager.js b/src/three/plugins/mvt/TextAnchorManager.js index f118540d4..c05bd55f1 100644 --- a/src/three/plugins/mvt/TextAnchorManager.js +++ b/src/three/plugins/mvt/TextAnchorManager.js @@ -1,13 +1,5 @@ import { TextAnchorAnnotation } from './annotations/TextAnchorAnnotation.js'; -// true if the cartographic point lies within the tile range -function rangeContains( range, lat, lon ) { - - const [ minLon, minLat, maxLon, maxLat ] = range; - return lon >= minLon && lon <= maxLon && lat >= minLat && lat <= maxLat; - -} - // Manages the set of anchors per export class TextAnchorManager { @@ -36,7 +28,7 @@ export class TextAnchorManager { anchors.forEach( anchor => { - if ( anchor.referencePaths.length === 0 ) { + if ( anchor.isEmpty() ) { anchors.delete( anchor ); removed.add( anchor ); @@ -126,7 +118,8 @@ export class TextAnchorManager { } - const { range, lodLevel } = newLines[ 0 ]; + // all lines are expected to be of the same LoD and tile if passed in at once + const baseLine = newLines[ 0 ]; // For each existing anchor, match it to the closest new anchor in the relevant set const anchorSet = _anchorsById.get( id ); @@ -137,8 +130,8 @@ export class TextAnchorManager { let bestLine = null; let bestIndex = - 1; if ( - ! rangeContains( range, anchor.lat, anchor.lon ) || - anchor.referencePaths.find( ref => ref.line.lodLevel === lodLevel ) + ! baseLine.hasCoverage( anchor.lat, anchor.lon ) || + anchor.hasLoD( baseLine.lodLevel ) ) { return; @@ -191,7 +184,7 @@ export class TextAnchorManager { const anchor = new TextAnchorAnnotation( id ); anchor.addLine( line, index ); - if ( rangeContains( line.range, anchor.lat, anchor.lon ) ) { + if ( line.hasCoverage( anchor.lat, anchor.lon ) ) { anchorPosition.ref = anchor; anchorSet.add( anchor ); diff --git a/src/three/plugins/mvt/annotations/LineAnnotation.js b/src/three/plugins/mvt/annotations/LineAnnotation.js index c973f37cb..d968a0c12 100644 --- a/src/three/plugins/mvt/annotations/LineAnnotation.js +++ b/src/three/plugins/mvt/annotations/LineAnnotation.js @@ -1,28 +1,17 @@ -import { MathUtils, Vector3 } from 'three'; +import { MathUtils, Vector3, Vector2, Matrix4 } from 'three'; import { OccupancyAnnotation } from '../ScreenOccupationManager.js'; -/** - * A stitched, subsampled polyline parsed from MVT line ( type 2 ) content. Holds the - * per-sample cartographic coordinates and a parallel array of settled world positions - * ( populated later during raycast settling ), plus the feature metadata. - * - * This is the unit of "settling" for labeled lines. It is intentionally NOT registered - * directly into the screen occupation grid — anchors derived from these paths are what - * occupy the grid. - * @private - */ +// Share path annotation used for text anchors export class LineAnnotation extends OccupancyAnnotation { - /** - * Number of samples in the path. - * @returns {number} - */ + // number of points in the path get count() { return this.lat.length; } + // number of anchors get anchorCount() { return this.anchorPositions.length; @@ -33,6 +22,7 @@ export class LineAnnotation extends OccupancyAnnotation { super(); + // the range of the tile this line is associated with this.range = null; // per-sample cartographic coordinates in radians @@ -42,23 +32,103 @@ export class LineAnnotation extends OccupancyAnnotation { // per-sample settled positions in tiles.group local space, filled during settling this.positions = []; - // anchors placed along the path, each `{ i0, i1, alpha, lat, lon }` + // anchors placed along the path, each { i0, i1, alpha, lat, lon, ref } this.anchorPositions = []; - } + // screen positions, cumulative length used for calculating text layout + this.screenPositions = []; + this.cumulativeLen = []; - copyPosition() { + // cache variables + this.cachedMatrix = new Matrix4(); + this.cachedResolution = new Vector2(); - throw new Error(); + // set true when settling updates "positions" so updateTransform recomputes the screen + // projection even when the camera hasn't moved. + this.needsUpdate = false; } + // overrides evaluate() { throw new Error(); } + // update screen space points and cumulative values for text placement + updateTransform( matrix, resolution, cameraPosition ) { + + const { + positions, + screenPositions, + cachedMatrix, + cachedResolution, + cumulativeLen, + } = this; + + if ( + ! this.needsUpdate && + cachedMatrix.equals( matrix ) && + cachedResolution.equals( resolution ) + ) { + + return; + + } + + this.needsUpdate = false; + cachedMatrix.copy( matrix ); + cachedResolution.copy( resolution ); + + while ( screenPositions.length < positions.length ) { + + screenPositions.push( new Vector3() ); + + } + + for ( let i = 0, l = screenPositions.length; i < l; i ++ ) { + + const position = positions[ i ]; + const screenPos = screenPositions[ i ]; + + // project to screen space + screenPos.copy( position ).applyMatrix4( matrix ); + + // transform to resolution coordinates + screenPos.x = ( screenPos.x * 0.5 + 0.5 ) * resolution.width; + screenPos.y = ( - screenPos.y * 0.5 + 0.5 ) * resolution.height; + screenPos.z = MathUtils.mapLinear( screenPos.z, - 1, 1, 0, 1 ); + + } + + // roll up the cumulative placement + cumulativeLen.length = screenPositions.length; + cumulativeLen[ 0 ] = 0; + for ( let i = 1; i < screenPositions.length; i ++ ) { + + const p0 = screenPositions[ i - 1 ]; + const p1 = screenPositions[ i ]; + const dx = p1.x - p0.x; + const dy = p1.y - p0.y; + const len = Math.sqrt( dx * dx + dy * dy ); + cumulativeLen[ i ] = cumulativeLen[ i - 1 ] + len; + + } + + } + + // + + // whether a lat / lon falls within the same tile as this line + hasCoverage( lat, lon ) { + + // e + const [ minLon, minLat, maxLon, maxLat ] = this.range; + return lon >= minLon && lon <= maxLon && lat >= minLat && lat <= maxLat; + + } + // Place anchors along a path at a fixed "spacing" (geographic, in radians), recording the // bounding sample indices. Short paths receive a single anchor at their midpoint. generateAnchors( spacing ) { @@ -77,7 +147,8 @@ export class LineAnnotation extends OccupancyAnnotation { const lon0 = lon[ i ]; const lon1 = lon[ i + 1 ]; - // TODO: we should figure out a better way to handle this spacing... + // TODO: this is using some rough sphere math to space the anchors evenly regardless of + // lat but we should figure out a better way to handle this spacing const latMid = 0.5 * ( lat0 + lat1 ); const dLat = lat1 - lat0; const dLon = ( lon1 - lon0 ) * Math.cos( latMid ); @@ -87,8 +158,7 @@ export class LineAnnotation extends OccupancyAnnotation { } - // first anchor offset half a spacing in, fall back to the midpoint for - // short paths + // first anchor offset half a spacing in, fall back to the midpoint for short paths let target = spacing * 0.5; if ( target > totalLength ) { @@ -173,13 +243,12 @@ function subsamplePath( points, spacing ) { } - // parse the vector tile geometry into line annotations export function parseLineAnnotations( vectorTile, x, y, level, tiling, filter, target = [] ) { // TODO: this needs to scale based on LoD rather than a fixed - this is hackily-scaled below - // anchor spacing in radians ( geographic ). Density - // tracks real-world length, independent of the tile's zoom / size + // anchor spacing in radians. Density tracks real-world length, independent of the tile's + // zoom / size const anchorSpacing = 500000 / 6378137; const subsampleFraction = 1 / 64; const tileBounds = tiling.getTileBounds( x, y, level, true, false ); diff --git a/src/three/plugins/mvt/annotations/PointAnnotation.js b/src/three/plugins/mvt/annotations/PointAnnotation.js index f11ef9680..23d04cbc2 100644 --- a/src/three/plugins/mvt/annotations/PointAnnotation.js +++ b/src/three/plugins/mvt/annotations/PointAnnotation.js @@ -14,7 +14,7 @@ export class PointAnnotation extends OccupancyAnnotation { this.position = new Vector3(); this.lat = 0; this.lon = 0; - this.radius = 32; + this.radius = 28; this.screenPos = new Vector3(); this._facingAngle = 0; @@ -23,8 +23,7 @@ export class PointAnnotation extends OccupancyAnnotation { updateTransform( matrix, resolution, cameraPosition ) { - const { position } = this; - const screenPos = this.screenPos; + const { position, screenPos } = this; // project to screen space screenPos.copy( position ).applyMatrix4( matrix ); @@ -50,13 +49,6 @@ export class PointAnnotation extends OccupancyAnnotation { } - copyPosition( source ) { - - this.position.copy( source.position ); - this.ready = source.ready; - - } - evaluate( handle ) { const { screenPos, radius, _facingAngle } = this; diff --git a/src/three/plugins/mvt/annotations/PointAnnotationManager.js b/src/three/plugins/mvt/annotations/PointAnnotationManager.js index e0945d363..4c17dc2a5 100644 --- a/src/three/plugins/mvt/annotations/PointAnnotationManager.js +++ b/src/three/plugins/mvt/annotations/PointAnnotationManager.js @@ -12,6 +12,8 @@ export class PointAnnotationManager { add( annotation ) { + // TODO: we need to "refine" the point annotation positions based on LoD level + // so we always have the most precise lat / lon position const { annotations, added } = this; const { id } = annotation; if ( ! annotations.has( id ) ) { diff --git a/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js b/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js index 479d9bb56..f8e27fc0d 100644 --- a/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js +++ b/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js @@ -1,11 +1,20 @@ +import { Vector3, MathUtils } from 'three'; import { OccupancyAnnotation } from '../ScreenOccupationManager.js'; +// reject labels whose projected baseline turns more than this in total ( radians ), since +// sharply curving text becomes hard to read +const MAX_LABEL_ANGLE = Math.PI / 2; + +// scratch reused across evaluate() calls ( synchronous, single pass ) +const _segIndices = []; +const _segAlphas = []; +const _vec = /* @__PURE__ */ new Vector3(); + // A text anchor that lays on a give line and stores references to path from different LoDs, // choosing the best one to "snap" to. let annotationIndex = 0; export class TextAnchorAnnotation extends OccupancyAnnotation { - // TODO: cache these - possibly update in "evaluate" get lat() { return this.getActiveReference().lat; @@ -18,6 +27,13 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { } + get text() { + + // TODO: this should be derivable from the user specified driver + return this.properties.name || ''; + + } + get ready() { return this.getActiveReference().line.ready; @@ -45,7 +61,277 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { this.id = `${ id }_${ annotationIndex ++ }`; this.referencePaths = []; - this._lastUsed = null; + this._activeReference = null; + + // local position & angle per character + this.characterPositions = []; + this.characterAngles = []; + + // per-character advance width provider (pixels) + this.measureChar = () => 1; + + // total advance width of the label ( screen px ) and per-character footprint radius, + // recomputed each layout ( individual advances come from the cached measureChar ) + this._totalWidth = 0; + this._charRadius = 1; + + } + + // overrides + evaluate( handle ) { + + const { text } = this; + if ( ! text ) { + + return false; + + } + + // TODO: update the "active reference" here to avoid iteration every frame + const { line } = this.getActiveReference(); + const { cumulativeLen } = line; + if ( ! line.ready ) { + + return false; + + } + + if ( cumulativeLen.length < 2 ) { + + return false; + + } + + const maxCharWidth = this.measureChar( 'M' ); + this._updateTotalWidth(); + this._charRadius = Math.sqrt( maxCharWidth ** 2 ) / 2; + + _segIndices.length = text.length; + _segAlphas.length = text.length; + + // lay out and test every character; bail if it can't fit or the baseline curves too sharply + if ( ! this._layoutCharacters( handle, _segIndices, _segAlphas ) ) { + + return false; + + } + + // it fits: commit occupancy marks, world positions, and baseline angles + this._placeCharacters( handle, _segIndices, _segAlphas ); + return true; + + } + + // sum the label's per-character advance widths ( screen px ) into `_totalWidth`; the individual + // widths come straight from the cached measureChar during layout + _updateTotalWidth() { + + const { text } = this; + let total = 0; + for ( let k = 0, l = text.length; k < l; k ++ ) { + + total += this.measureChar( text[ k ] ); + + } + + this._totalWidth = total; + + } + + // determine the reading direction based on the positioning of the end points + _getTextDirection() { + + const { _totalWidth } = this; + const { line, i0, i1, alpha } = this.getActiveReference(); + const { cumulativeLen, screenPositions } = line; + const anchorOffset = MathUtils.lerp( cumulativeLen[ i0 ], cumulativeLen[ i1 ], alpha ); + + // the label is centered on the anchor, so its ends sit half a total-width to each side + const halfWidth = _totalWidth * 0.5; + const startOffset = anchorOffset - halfWidth; + const endOffset = anchorOffset + halfWidth; + + let startIndex = 0; + let startAlpha = 0; + let endIndex = cumulativeLen.length - 2; + let endAlpha = 1; + for ( let i = 0, l = cumulativeLen.length - 2; i < l; i ++ ) { + + const n = i + 1; + + const l0 = cumulativeLen[ i ]; + const l1 = cumulativeLen[ n ]; + + if ( startOffset >= l0 && startOffset <= l1 ) { + + startIndex = i; + startAlpha = MathUtils.mapLinear( startOffset, l0, l1, 0, 1 ); + + } + + if ( endOffset >= l0 && endOffset <= l1 ) { + + endIndex = i; + endAlpha = MathUtils.mapLinear( endOffset, l0, l1, 0, 1 ); + + } + + } + + const firstX = _vec.lerpVectors( screenPositions[ startIndex ], screenPositions[ startIndex + 1 ], startAlpha ).x; + const lastX = _vec.lerpVectors( screenPositions[ endIndex ], screenPositions[ endIndex + 1 ], endAlpha ).x; + return lastX < firstX; + + } + + // march the characters out from the anchor in both directions, centered, measuring and testing + // each one so a string that doesn't fit leaves no marks behind. records per-character segment + // index / alpha into the module scratch. returns the reading-direction flip on success, or + // null if the label can't be placed. + // TODO: also reject foreshortened paths (tiny screen-space segments) + _layoutCharacters( handle, outputIndices, outputAlphas ) { + + const { text, _totalWidth, _charRadius } = this; + const { line, i0, i1, alpha } = this.getActiveReference(); + const { cumulativeLen, screenPositions } = line; + const anchorOffset = MathUtils.lerp( cumulativeLen[ i0 ], cumulativeLen[ i1 ], alpha ); + const flip = this._getTextDirection(); + + const pointCount = screenPositions.length; + const totalLength = cumulativeLen[ cumulativeLen.length - 1 ]; + const length = text.length; + + let seg = 0; + let charCursor = 0; + let prevAngle = 0; + let totalTurn = 0; + for ( let i = 0; i < length; i ++ ) { + + // place each character's center along the arc by its advance, centered on the anchor + const slot = flip ? length - 1 - i : i; + const advance = this.measureChar( text[ slot ] ); + const charCenter = charCursor + advance * 0.5 - _totalWidth * 0.5; + charCursor += advance; + + // absolute target position relative to the line + const target = anchorOffset + charCenter; + + // the path is too short on screen to hold the whole string + if ( target < 0 || target > totalLength ) { + + return false; + + } + + // advance to the segment containing "target" + while ( seg < pointCount - 2 && cumulativeLen[ seg + 1 ] < target ) { + + seg ++; + + } + + const segNext = seg + 1; + const segLength = cumulativeLen[ segNext ] - cumulativeLen[ seg ]; + const segAlpha = segLength > 0 ? ( target - cumulativeLen[ seg ] ) / segLength : 0; + + const p0 = screenPositions[ seg ]; + const p1 = screenPositions[ segNext ]; + _vec.lerpVectors( p0, p1, segAlpha ); + + // off-screen in depth, or colliding with an already-placed annotation + if ( _vec.z < 0 || _vec.z > 1 || handle.test( _vec.x, _vec.y, _charRadius ) ) { + + return false; + + } + + // accumulate the absolute turn between consecutive character tangents and reject + // paths that curve too sharply across the label to stay readable + const angle = Math.atan2( p1.y - p0.y, p1.x - p0.x ); + if ( i > 0 ) { + + const delta = Math.atan2( Math.sin( angle - prevAngle ), Math.cos( angle - prevAngle ) ); + totalTurn += Math.abs( delta ); + if ( totalTurn > MAX_LABEL_ANGLE ) { + + return false; + + } + + } + + prevAngle = angle; + + outputIndices[ slot ] = seg; + outputAlphas[ slot ] = segAlpha; + + } + + return true; + + } + + // commit a successful layout: mark occupancy and record a world-space position + baseline + // angle per character, applying the reading-direction flip + _placeCharacters( handle, segIndices, segAlphas ) { + + const { characterPositions, characterAngles, text, _charRadius } = this; + const { line } = this.getActiveReference(); + const { screenPositions, positions } = line; + + const flip = this._getTextDirection(); + const length = text.length; + + while ( characterPositions.length < length ) { + + characterPositions.push( new Vector3() ); + + } + + characterPositions.length = length; + characterAngles.length = length; + + for ( let i = 0; i < length; i ++ ) { + + // the layout already applied the flip when storing per-character data, so index it + // straight; flip only affects the baseline angle's sign below + const index = segIndices[ i ]; + const segAlpha = segAlphas[ i ]; + + const p0 = screenPositions[ index ]; + const p1 = screenPositions[ index + 1 ]; + handle.mark( p0.x + ( p1.x - p0.x ) * segAlpha, p0.y + ( p1.y - p0.y ) * segAlpha, _charRadius ); + + characterPositions[ i ].lerpVectors( positions[ index ], positions[ index + 1 ], segAlpha ); + + // baseline angle from the segment direction ( screen space, y down ), pointing in the + // reading direction so glyphs stay upright after a flip + const dx = ( p1.x - p0.x ) * ( flip ? - 1 : 1 ); + const dy = ( p1.y - p0.y ) * ( flip ? - 1 : 1 ); + characterAngles[ i ] = Math.atan2( dy, dx ); + + } + + } + + updateTransform( matrix, resolution, cameraPosition ) { + + // update the screen positions for shared line + this.updateActiveReference(); + this.getActiveReference().line.updateTransform( matrix, resolution, cameraPosition ); + + } + + // anchor functions + isEmpty() { + + return this.referencePaths.length === 0; + + } + + hasLoD( lod ) { + + return this.referencePaths.find( ref => ref.line.lodLevel === lod ); } @@ -59,11 +345,17 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { // the highest-LoD entry whose path is settled, used for placement getActiveReference() { - const { referencePaths, _lastUsed } = this; + return this._activeReference; + + } + + updateActiveReference() { + + const { referencePaths, _activeReference } = this; const target = referencePaths[ 0 ] ?? null; if ( target?.line.ready ) { - this._lastUsed = target; + this._activeReference = target; return target; } else { @@ -80,15 +372,15 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { } - result = result ?? _lastUsed; + result = result ?? _activeReference; - if ( _lastUsed && _lastUsed.line.ready && _lastUsed.line.lodLevel > result.line.lodLevel ) { + if ( _activeReference && _activeReference.line.ready && _activeReference.line.lodLevel > result.line.lodLevel ) { - result = _lastUsed; + result = _activeReference; } - this._lastUsed = result; + this._activeReference = result; return result; } @@ -120,7 +412,7 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { } ); - return slotIndex; + this.updateActiveReference(); } @@ -138,6 +430,8 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { } + this.updateActiveReference(); + } }