From c032fb935dcbcd08adb58eeb6df1cbe8374818e1 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Sun, 28 Jun 2026 13:05:55 +0900 Subject: [PATCH 01/18] Small updates --- .../plugins/mvt/ScreenOccupationManager.js | 4 -- src/three/plugins/mvt/TextAnchorManager.js | 11 +-- .../plugins/mvt/annotations/LineAnnotation.js | 67 ++++++++++++++----- .../mvt/annotations/PointAnnotation.js | 10 +-- .../mvt/annotations/PointAnnotationManager.js | 2 + .../mvt/annotations/TextAnchorAnnotation.js | 51 +++++++++++++- 6 files changed, 108 insertions(+), 37 deletions(-) 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/TextAnchorManager.js b/src/three/plugins/mvt/TextAnchorManager.js index f118540d4..c515014bd 100644 --- a/src/three/plugins/mvt/TextAnchorManager.js +++ b/src/three/plugins/mvt/TextAnchorManager.js @@ -36,7 +36,7 @@ export class TextAnchorManager { anchors.forEach( anchor => { - if ( anchor.referencePaths.length === 0 ) { + if ( anchor.isEmpty() ) { anchors.delete( anchor ); removed.add( anchor ); @@ -126,7 +126,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 +138,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 +192,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..993439dff 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,14 +32,20 @@ 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 and dirty variables + this.screenPositions = []; + this.cachedMatrix = new Matrix4(); + this.cachedResolution = new Vector2(); + } - copyPosition() { + hasCoverage( lat, lon ) { - throw new Error(); + const [ minLon, minLat, maxLon, maxLat ] = this.range; + return lon >= minLon && lon <= maxLon && lat >= minLat && lat <= maxLat; } @@ -59,6 +55,41 @@ export class LineAnnotation extends OccupancyAnnotation { } + updateTransform( matrix, resolution, cameraPosition ) { + + const { positions, screenPositions, cachedMatrix, cachedResolution } = this; + if ( cachedMatrix.equals( matrix ) && cachedResolution.equals( resolution ) ) { + + return; + + } + + 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 ); + + } + + } + // 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 ) { diff --git a/src/three/plugins/mvt/annotations/PointAnnotation.js b/src/three/plugins/mvt/annotations/PointAnnotation.js index f11ef9680..8b1685e03 100644 --- a/src/three/plugins/mvt/annotations/PointAnnotation.js +++ b/src/three/plugins/mvt/annotations/PointAnnotation.js @@ -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..37327c36f 100644 --- a/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js +++ b/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js @@ -5,7 +5,6 @@ import { OccupancyAnnotation } from '../ScreenOccupationManager.js'; let annotationIndex = 0; export class TextAnchorAnnotation extends OccupancyAnnotation { - // TODO: cache these - possibly update in "evaluate" get lat() { return this.getActiveReference().lat; @@ -18,6 +17,12 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { } + get text() { + + return this.properties.name || ''; + + } + get ready() { return this.getActiveReference().line.ready; @@ -49,6 +54,50 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { } + // overrides + evaluate() { + + // TODO: update the "active reference" here to avoid iteration every frame + const { text } = this; + const { screenPositions } = this.getActiveReference().line; + + if ( ! text ) { + + return false; + + } + + // TODO: + // 1. March along the path in both directions starting at the anchor, measuring + // out a distance long enough for all the letters while evaluating occupation. + // 2. Early out if the angle is too steep, corners too tight, crossing? + // 3. Determine the text direction by average path direction? Or flip character halfway through? + // Or early out if it flips? Or by lowest end point? + // 4. Place the characters along the line, orienting to the path, marking the + // locations in the grid. + + } + + updateTransform( matrix, resolution, cameraPosition ) { + + // update the screen positions for shared line + 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 ); + + } + getPosition( pos ) { const { i0, i1, alpha, line } = this.getActiveReference(); From 8a90f7cccf304ab850966b38c706c6eacb86db8e Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Tue, 30 Jun 2026 10:58:06 +0900 Subject: [PATCH 02/18] Add basic lettering --- example/three/annotationsExample.js | 45 ++++- .../three/src/plugins/mvt/CharacterPoints.js | 166 ++++++++++++++++++ .../three/src/plugins/mvt/GlyphMaterial.js | 34 ++-- src/three/plugins/mvt/GlyphAtlasTexture.js | 23 ++- .../mvt/annotations/TextAnchorAnnotation.js | 129 ++++++++++++-- 5 files changed, 364 insertions(+), 33 deletions(-) create mode 100644 example/three/src/plugins/mvt/CharacterPoints.js diff --git a/example/three/annotationsExample.js b/example/three/annotationsExample.js index 8eef49966..825c5158e 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,14 +177,21 @@ 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 ); + + }, } ) ); // use the camera cartographic region plugin to prevent particularly low-lod // tiles from loading beneath the camera, causing navigation issues. const cameraRegion = new CameraCartographicRegion( { camera, - radius: 1500, + radius: 2000, errorTarget: 5000, } ); @@ -196,6 +229,14 @@ function reinstantiateTiles() { tiles.group.add( annotationsPoints ); + characterPoints = new CharacterPoints( { + size: 16, + glyphSize: 2 * 16 * renderer.getPixelRatio(), + 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/CharacterPoints.js b/example/three/src/plugins/mvt/CharacterPoints.js new file mode 100644 index 000000000..dba5892a3 --- /dev/null +++ b/example/three/src/plugins/mvt/CharacterPoints.js @@ -0,0 +1,166 @@ +import { BufferAttribute, BufferGeometry, Matrix4, Points } from 'three'; +import { GlyphAtlasTexture } from '3d-tiles-renderer/plugins'; +import { GlyphMaterial } from './GlyphMaterial.js'; + +const _mvMatrix = /* @__PURE__ */ new Matrix4(); + +// Draws one glyph per character for the currently-visible text anchors. Each anchor's +// `characterPositions` (and the characters in its `text`) are recomputed every frame by its +// evaluate(), so the geometry is rebuilt on every update. Glyphs are rasterized lazily into a +// shared atlas the first time a character is seen. ( Orientation / kerning come later. ) +export class CharacterPoints extends Points { + + constructor( options = {} ) { + + const { + size = 16, + glyphSize = 32, + slotCount = 256, + font = null, + strokeStyle = 'black', + strokeWidth = null, + } = options; + + super( new BufferGeometry(), new GlyphMaterial( { size } ) ); + + this.renderOrder = 1001; + this.frustumCulled = false; + + // currently-visible text anchors + this._anchors = new Set(); + + // CSS font used to rasterize glyphs, sized to fit the atlas slot + this._font = font ?? `400 ${ Math.round( glyphSize * 0.7 ) }px sans-serif`; + + // black halo so glyphs read over the imagery + this._strokeStyle = strokeStyle; + this._strokeWidth = strokeWidth ?? Math.max( 1, Math.round( glyphSize * 0.08 ) ); + + this.glyphAtlas = new GlyphAtlasTexture( slotCount, glyphSize ); + this.material.glyphTexture = this.glyphAtlas; + this.glyphAtlas.getSlotSize( this.material.glyphCellSize ); + + } + + update( added, removed ) { + + const { _anchors } = this; + for ( const item of added ) { + + _anchors.add( item ); + + } + + for ( const item of removed ) { + + _anchors.delete( item ); + + } + + 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 ); + + } + + // uv bounds of the glyph for `char`, rasterizing it into the atlas on first use + _glyphUV( char ) { + + 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 origin = this.position; + const { _anchors, geometry } = this; + + // total visible character count + let count = 0; + for ( const anchor of _anchors ) { + + count += anchor.characterPositions.length; + + } + + let posAttr = geometry.getAttribute( 'position' ); + let glyphUVAttr = geometry.getAttribute( 'glyphUV' ); + let alphaAttr = geometry.getAttribute( 'alpha' ); + 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 ); + geometry.setAttribute( 'position', posAttr ); + geometry.setAttribute( 'glyphUV', glyphUVAttr ); + geometry.setAttribute( 'alpha', alphaAttr ); + + } + + geometry.setDrawRange( 0, count ); + + let i = 0; + for ( const anchor of _anchors ) { + + const positions = anchor.characterPositions; + const text = anchor.text; + for ( let c = 0, l = positions.length; c < l; c ++ ) { + + const p = positions[ c ]; + posAttr.setXYZ( i, p.x - origin.x, p.y - origin.y, p.z - origin.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, 1 ); + i ++; + + } + + } + + posAttr.needsUpdate = true; + glyphUVAttr.needsUpdate = true; + alphaAttr.needsUpdate = true; + + } + +} diff --git a/example/three/src/plugins/mvt/GlyphMaterial.js b/example/three/src/plugins/mvt/GlyphMaterial.js index c0ad1ac3d..6afee7a2a 100644 --- a/example/three/src/plugins/mvt/GlyphMaterial.js +++ b/example/three/src/plugins/mvt/GlyphMaterial.js @@ -71,33 +71,33 @@ export class GlyphMaterial extends PointsMaterial { ` ); - shader.fragmentShader = shader.fragmentShader.replace( - '#include ', - /* glsl */` - #include + shader.fragmentShader = /* glsl */` + uniform sampler2D glyphAtlas; uniform vec2 glyphCellSize; varying vec2 vGlyphUV; varying float vAlpha; - ` - ); - shader.fragmentShader = shader.fragmentShader.replace( - '#include ', - /* glsl */` - if ( vGlyphUV.x >= 0.0 ) { + void main() { + + vec4 diffuseColor = vec4( 0.0 ); + if ( vGlyphUV.x >= 0.0 ) { + + vec4 glyph = texture2D( glyphAtlas, vGlyphUV + gl_PointCoord * glyphCellSize * vec2( 1.0, - 1.0 ) ); + diffuseColor = glyph; + + } + + 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 - ` - ); + `; }; diff --git a/src/three/plugins/mvt/GlyphAtlasTexture.js b/src/three/plugins/mvt/GlyphAtlasTexture.js index 6df50fb73..2243819ee 100644 --- a/src/three/plugins/mvt/GlyphAtlasTexture.js +++ b/src/three/plugins/mvt/GlyphAtlasTexture.js @@ -108,6 +108,9 @@ export class GlyphAtlasTexture extends CanvasTexture { * @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,15 +119,31 @@ 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; + ctx.font = font; - ctx.fillStyle = color; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - ctx.fillText( char, x + w / 2, y + h / 2 ); + + // stroke first so the fill sits on top of the halo + if ( strokeStyle !== null ) { + + ctx.lineJoin = 'round'; + ctx.lineWidth = strokeWidth; + ctx.strokeStyle = strokeStyle; + ctx.strokeText( char, cx, cy ); + + } + + ctx.fillStyle = color; + ctx.fillText( char, cx, cy ); } ); diff --git a/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js b/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js index 37327c36f..2e97abd5a 100644 --- a/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js +++ b/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js @@ -1,5 +1,14 @@ +import { Vector3 } from 'three'; import { OccupancyAnnotation } from '../ScreenOccupationManager.js'; +// screen-space spacing / footprint per character, in pixels +const CHARACTER_SIZE = 12; + +// scratch reused across evaluate() calls ( synchronous, single pass ) +const _cumulative = []; +const _segIndices = []; +const _segAlphas = []; + // 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; @@ -52,29 +61,125 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { this.referencePaths = []; this._lastUsed = null; + // world-space ( tiles.group local ) position per character, filled by evaluate() + this.characterPositions = []; + } // overrides - evaluate() { + evaluate( handle ) { - // TODO: update the "active reference" here to avoid iteration every frame const { text } = this; - const { screenPositions } = this.getActiveReference().line; - if ( ! text ) { return false; } - // TODO: - // 1. March along the path in both directions starting at the anchor, measuring - // out a distance long enough for all the letters while evaluating occupation. - // 2. Early out if the angle is too steep, corners too tight, crossing? - // 3. Determine the text direction by average path direction? Or flip character halfway through? - // Or early out if it flips? Or by lowest end point? - // 4. Place the characters along the line, orienting to the path, marking the - // locations in the grid. + // TODO: update the "active reference" here to avoid iteration every frame + const { line, i0, alpha } = this.getActiveReference(); + if ( ! line.ready ) { + + return false; + + } + + const { screenPositions, positions } = line; + const sampleCount = positions.length; + if ( sampleCount < 2 ) { + + return false; + + } + + // cumulative 2d screen-space arc length per sample + _cumulative.length = sampleCount; + _cumulative[ 0 ] = 0; + for ( let i = 1; i < sampleCount; i ++ ) { + + const a = screenPositions[ i - 1 ]; + const b = screenPositions[ i ]; + const dx = b.x - a.x; + const dy = b.y - a.y; + _cumulative[ i ] = _cumulative[ i - 1 ] + Math.sqrt( dx * dx + dy * dy ); + + } + + const totalLength = _cumulative[ sampleCount - 1 ]; + + // arc length of the anchor along the screen-projected path + const anchorLength = _cumulative[ i0 ] + alpha * ( _cumulative[ i0 + 1 ] - _cumulative[ i0 ] ); + + // march the characters out from the anchor in both directions, centered. measure and + // test every character first so a string that doesn't fit leaves no marks behind. + // TODO: early out on tight corners / steep angles; determine direction + character flip + const length = text.length; + const halfChar = ( length - 1 ) * 0.5; + const radius = CHARACTER_SIZE / 2; + + let seg = 0; + for ( let k = 0; k < length; k ++ ) { + + const target = anchorLength + ( k - halfChar ) * CHARACTER_SIZE; + + // 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" ( monotonic across k ) + while ( seg < sampleCount - 2 && _cumulative[ seg + 1 ] < target ) { + + seg ++; + + } + + const segLength = _cumulative[ seg + 1 ] - _cumulative[ seg ]; + const segAlpha = segLength > 0 ? ( target - _cumulative[ seg ] ) / segLength : 0; + + const a = screenPositions[ seg ]; + const b = screenPositions[ seg + 1 ]; + const sx = a.x + ( b.x - a.x ) * segAlpha; + const sy = a.y + ( b.y - a.y ) * segAlpha; + const sz = a.z + ( b.z - a.z ) * segAlpha; + + // off-screen in depth, or colliding with an already-placed annotation + if ( sz < 0 || sz > 1 || handle.test( sx, sy, radius ) ) { + + return false; + + } + + _segIndices[ k ] = seg; + _segAlphas[ k ] = segAlpha; + + } + + // it fits: mark occupancy and record a world-space position per character + const { characterPositions } = this; + while ( characterPositions.length < length ) { + + characterPositions.push( new Vector3() ); + + } + + characterPositions.length = length; + for ( let k = 0; k < length; k ++ ) { + + const index = _segIndices[ k ]; + const segAlpha = _segAlphas[ k ]; + + const a = screenPositions[ index ]; + const b = screenPositions[ index + 1 ]; + handle.mark( a.x + ( b.x - a.x ) * segAlpha, a.y + ( b.y - a.y ) * segAlpha, radius ); + + characterPositions[ k ].lerpVectors( positions[ index ], positions[ index + 1 ], segAlpha ); + + } + + return true; } From 02bb0934ca13c39dac0d272e032ce7add27b026a Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Tue, 30 Jun 2026 22:30:40 +0900 Subject: [PATCH 03/18] Small fixes --- .../three/src/plugins/mvt/GlyphMaterial.js | 2 ++ src/three/plugins/mvt/SettlingManager.js | 15 ++++++----- .../mvt/annotations/TextAnchorAnnotation.js | 26 +++++++++++++++---- 3 files changed, 31 insertions(+), 12 deletions(-) diff --git a/example/three/src/plugins/mvt/GlyphMaterial.js b/example/three/src/plugins/mvt/GlyphMaterial.js index 6afee7a2a..70dc156cc 100644 --- a/example/three/src/plugins/mvt/GlyphMaterial.js +++ b/example/three/src/plugins/mvt/GlyphMaterial.js @@ -88,7 +88,9 @@ export class GlyphMaterial extends PointsMaterial { } + diffuseColor.a *= vAlpha; gl_FragColor = diffuseColor; + #include #include #include diff --git a/src/three/plugins/mvt/SettlingManager.js b/src/three/plugins/mvt/SettlingManager.js index 518e3faaf..c82997bc5 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 ); diff --git a/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js b/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js index 2e97abd5a..a568d919e 100644 --- a/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js +++ b/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js @@ -2,7 +2,7 @@ import { Vector3 } from 'three'; import { OccupancyAnnotation } from '../ScreenOccupationManager.js'; // screen-space spacing / footprint per character, in pixels -const CHARACTER_SIZE = 12; +const CHARACTER_SIZE = 10; // scratch reused across evaluate() calls ( synchronous, single pass ) const _cumulative = []; @@ -61,7 +61,7 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { this.referencePaths = []; this._lastUsed = null; - // world-space ( tiles.group local ) position per character, filled by evaluate() + // tiles.group local position per character, filled by evaluate() this.characterPositions = []; } @@ -112,12 +112,14 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { // march the characters out from the anchor in both directions, centered. measure and // test every character first so a string that doesn't fit leaves no marks behind. - // TODO: early out on tight corners / steep angles; determine direction + character flip + // TODO: early out on tight corners / steep angles const length = text.length; const halfChar = ( length - 1 ) * 0.5; const radius = CHARACTER_SIZE / 2; let seg = 0; + let firstX = 0; + let lastX = 0; for ( let k = 0; k < length; k ++ ) { const target = anchorLength + ( k - halfChar ) * CHARACTER_SIZE; @@ -152,6 +154,15 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { } + // track the screen x of the path ends to decide reading direction below + if ( k === 0 ) { + + firstX = sx; + + } + + lastX = sx; + _segIndices[ k ] = seg; _segAlphas[ k ] = segAlpha; @@ -166,10 +177,15 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { } characterPositions.length = length; + + // flip the character-to-slot mapping when the path runs right-to-left on screen so the + // label always reads left-to-right + const flip = lastX < firstX; for ( let k = 0; k < length; k ++ ) { - const index = _segIndices[ k ]; - const segAlpha = _segAlphas[ k ]; + const slot = flip ? length - 1 - k : k; + const index = _segIndices[ slot ]; + const segAlpha = _segAlphas[ slot ]; const a = screenPositions[ index ]; const b = screenPositions[ index + 1 ]; From 42d1ebf1789121f08e8dc9fd5d045bdb52dfecfc Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Tue, 30 Jun 2026 22:36:34 +0900 Subject: [PATCH 04/18] Add character rotation --- example/three/src/plugins/mvt/CharacterPoints.js | 6 ++++++ example/three/src/plugins/mvt/GlyphMaterial.js | 14 +++++++++++++- .../mvt/annotations/TextAnchorAnnotation.js | 12 +++++++++++- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/example/three/src/plugins/mvt/CharacterPoints.js b/example/three/src/plugins/mvt/CharacterPoints.js index dba5892a3..70cea67cb 100644 --- a/example/three/src/plugins/mvt/CharacterPoints.js +++ b/example/three/src/plugins/mvt/CharacterPoints.js @@ -115,15 +115,18 @@ export class CharacterPoints extends Points { 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 ); } @@ -133,6 +136,7 @@ export class CharacterPoints extends Points { for ( const anchor of _anchors ) { const positions = anchor.characterPositions; + const angles = anchor.characterAngles; const text = anchor.text; for ( let c = 0, l = positions.length; c < l; c ++ ) { @@ -151,6 +155,7 @@ export class CharacterPoints extends Points { } alphaAttr.setX( i, 1 ); + angleAttr.setX( i, angles[ c ] ); i ++; } @@ -160,6 +165,7 @@ export class CharacterPoints extends Points { 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 70dc156cc..9f82706f3 100644 --- a/example/three/src/plugins/mvt/GlyphMaterial.js +++ b/example/three/src/plugins/mvt/GlyphMaterial.js @@ -57,8 +57,10 @@ export class GlyphMaterial extends PointsMaterial { #include attribute vec2 glyphUV; attribute float alpha; + attribute float rotation; varying vec2 vGlyphUV; varying float vAlpha; + varying float vAngle; ` ); @@ -68,6 +70,7 @@ export class GlyphMaterial extends PointsMaterial { #include vGlyphUV = glyphUV; vAlpha = alpha; + vAngle = rotation; ` ); @@ -77,13 +80,22 @@ export class GlyphMaterial extends PointsMaterial { uniform vec2 glyphCellSize; varying vec2 vGlyphUV; varying float vAlpha; + varying float vAngle; void main() { vec4 diffuseColor = vec4( 0.0 ); if ( vGlyphUV.x >= 0.0 ) { - vec4 glyph = texture2D( glyphAtlas, vGlyphUV + gl_PointCoord * glyphCellSize * vec2( 1.0, - 1.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; } diff --git a/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js b/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js index a568d919e..80cbcd53a 100644 --- a/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js +++ b/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js @@ -64,6 +64,9 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { // tiles.group local position per character, filled by evaluate() this.characterPositions = []; + // screen-space baseline angle per character ( radians ), filled by evaluate() + this.characterAngles = []; + } // overrides @@ -169,7 +172,7 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { } // it fits: mark occupancy and record a world-space position per character - const { characterPositions } = this; + const { characterPositions, characterAngles } = this; while ( characterPositions.length < length ) { characterPositions.push( new Vector3() ); @@ -177,6 +180,7 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { } characterPositions.length = length; + characterAngles.length = length; // flip the character-to-slot mapping when the path runs right-to-left on screen so the // label always reads left-to-right @@ -193,6 +197,12 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { characterPositions[ k ].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 = ( b.x - a.x ) * ( flip ? - 1 : 1 ); + const dy = ( b.y - a.y ) * ( flip ? - 1 : 1 ); + characterAngles[ k ] = Math.atan2( dy, dx ); + } return true; From 46ec308dda24969795cc54a21d91e8c5428a0f0b Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Tue, 30 Jun 2026 22:43:13 +0900 Subject: [PATCH 05/18] Limit angle --- .../three/src/plugins/mvt/GlyphMaterial.js | 4 +-- .../mvt/annotations/TextAnchorAnnotation.js | 25 ++++++++++++++++++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/example/three/src/plugins/mvt/GlyphMaterial.js b/example/three/src/plugins/mvt/GlyphMaterial.js index 9f82706f3..ec94fdf56 100644 --- a/example/three/src/plugins/mvt/GlyphMaterial.js +++ b/example/three/src/plugins/mvt/GlyphMaterial.js @@ -57,7 +57,7 @@ export class GlyphMaterial extends PointsMaterial { #include attribute vec2 glyphUV; attribute float alpha; - attribute float rotation; + attribute float angle; varying vec2 vGlyphUV; varying float vAlpha; varying float vAngle; @@ -70,7 +70,7 @@ export class GlyphMaterial extends PointsMaterial { #include vGlyphUV = glyphUV; vAlpha = alpha; - vAngle = rotation; + vAngle = angle; ` ); diff --git a/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js b/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js index 80cbcd53a..95530d161 100644 --- a/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js +++ b/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js @@ -4,6 +4,10 @@ import { OccupancyAnnotation } from '../ScreenOccupationManager.js'; // screen-space spacing / footprint per character, in pixels const CHARACTER_SIZE = 10; +// 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 _cumulative = []; const _segIndices = []; @@ -115,7 +119,7 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { // march the characters out from the anchor in both directions, centered. measure and // test every character first so a string that doesn't fit leaves no marks behind. - // TODO: early out on tight corners / steep angles + // TODO: also reject foreshortened paths ( tiny screen-space segments ) const length = text.length; const halfChar = ( length - 1 ) * 0.5; const radius = CHARACTER_SIZE / 2; @@ -123,6 +127,8 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { let seg = 0; let firstX = 0; let lastX = 0; + let prevAngle = 0; + let totalTurn = 0; for ( let k = 0; k < length; k ++ ) { const target = anchorLength + ( k - halfChar ) * CHARACTER_SIZE; @@ -166,6 +172,23 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { lastX = sx; + // 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( b.y - a.y, b.x - a.x ); + if ( k > 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; + _segIndices[ k ] = seg; _segAlphas[ k ] = segAlpha; From a3aac5070ef418e3d7d01216abe9ef304b7ac16b Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Wed, 1 Jul 2026 10:42:04 +0900 Subject: [PATCH 06/18] Font --- example/three/annotationsExample.js | 6 +- .../three/src/plugins/mvt/CharacterPoints.js | 117 ++++++++++++++++-- src/three/plugins/mvt/GlyphAtlasTexture.js | 12 +- src/three/plugins/mvt/MVTAnnotationsPlugin.js | 7 ++ .../mvt/annotations/PointAnnotation.js | 2 +- .../mvt/annotations/TextAnchorAnnotation.js | 37 +++++- 6 files changed, 160 insertions(+), 21 deletions(-) diff --git a/example/three/annotationsExample.js b/example/three/annotationsExample.js index 825c5158e..caaf04fe8 100644 --- a/example/three/annotationsExample.js +++ b/example/three/annotationsExample.js @@ -185,6 +185,8 @@ function reinstantiateTiles() { characterPoints.update( a.text, r.text ); }, + // deferred: characterPoints is created below, but this only runs at update time + measureCharacter: char => characterPoints.measureCharacter( char ), } ) ); // use the camera cartographic region plugin to prevent particularly low-lod @@ -230,8 +232,8 @@ function reinstantiateTiles() { tiles.group.add( annotationsPoints ); characterPoints = new CharacterPoints( { - size: 16, - glyphSize: 2 * 16 * renderer.getPixelRatio(), + size: 14, + glyphSize: 2 * 14 * renderer.getPixelRatio(), strokeStyle: '#3f3e4c', strokeWidth: 6 * renderer.getPixelRatio(), } ); diff --git a/example/three/src/plugins/mvt/CharacterPoints.js b/example/three/src/plugins/mvt/CharacterPoints.js index 70cea67cb..8626db68c 100644 --- a/example/three/src/plugins/mvt/CharacterPoints.js +++ b/example/three/src/plugins/mvt/CharacterPoints.js @@ -13,7 +13,7 @@ export class CharacterPoints extends Points { constructor( options = {} ) { const { - size = 16, + size = 5, glyphSize = 32, slotCount = 256, font = null, @@ -26,11 +26,23 @@ export class CharacterPoints extends Points { this.renderOrder = 1001; this.frustumCulled = false; - // currently-visible text anchors - this._anchors = new Set(); + this.fadeInDuration = 0.3; + this.fadeOutDuration = 0.3; + + // Map keyed by stable id; entry: { item, fade: 0..1, state: 'in'|'visible'|'out' } + this._entryMap = new Map(); + this._orderedEntries = []; + this._lastUpdateTime = - 1; // CSS font used to rasterize glyphs, sized to fit the atlas slot - this._font = font ?? `400 ${ Math.round( glyphSize * 0.7 ) }px sans-serif`; + 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._measureSize = fontSize; + this._measureCtx = document.createElement( 'canvas' ).getContext( '2d' ); + this._measureCtx.font = this._font; + this._advanceCache = new Map(); // black halo so glyphs read over the imagery this._strokeStyle = strokeStyle; @@ -44,16 +56,77 @@ export class CharacterPoints extends Points { update( added, removed ) { - const { _anchors } = this; + const now = performance.now() / 1000; + const dt = this._lastUpdateTime < 0 ? 0 : Math.min( now - this._lastUpdateTime, 0.1 ); + this._lastUpdateTime = now; + + // add new anchors, refresh LoD-swapped references, reverse in-progress fade-outs + const { _entryMap, _orderedEntries } = this; for ( const item of added ) { - _anchors.add( item ); + 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 anchors for ( const item of removed ) { - _anchors.delete( item ); + const entry = _entryMap.get( item.id ); + if ( entry && entry.state !== 'out' ) { + + entry.state = 'out'; + + } + + } + + // tick fades; collect fully-faded-out anchors 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 ) ); } @@ -80,6 +153,22 @@ export class CharacterPoints extends Points { } + // advance width of `char` in em units ( fraction of the font size ), cached per character + measureCharacter( char ) { + + const { _advanceCache } = this; + let advance = _advanceCache.get( char ); + if ( advance === undefined ) { + + advance = this._measureCtx.measureText( char ).width / this._measureSize; + _advanceCache.set( char, advance ); + + } + + return advance + 0.15; + + } + // uv bounds of the glyph for `char`, rasterizing it into the atlas on first use _glyphUV( char ) { @@ -102,13 +191,13 @@ export class CharacterPoints extends Points { _updateGeometry() { const origin = this.position; - const { _anchors, geometry } = this; + const { _orderedEntries, geometry } = this; - // total visible character count + // total character count across all entries ( including fading ones ) let count = 0; - for ( const anchor of _anchors ) { + for ( const entry of _orderedEntries ) { - count += anchor.characterPositions.length; + count += entry.item.characterPositions.length; } @@ -133,8 +222,10 @@ export class CharacterPoints extends Points { geometry.setDrawRange( 0, count ); let i = 0; - for ( const anchor of _anchors ) { + for ( const entry of _orderedEntries ) { + const anchor = entry.item; + const { fade } = entry; const positions = anchor.characterPositions; const angles = anchor.characterAngles; const text = anchor.text; @@ -154,7 +245,7 @@ export class CharacterPoints extends Points { } - alphaAttr.setX( i, 1 ); + alphaAttr.setX( i, fade ); angleAttr.setX( i, angles[ c ] ); i ++; diff --git a/src/three/plugins/mvt/GlyphAtlasTexture.js b/src/three/plugins/mvt/GlyphAtlasTexture.js index 2243819ee..f7b0a018c 100644 --- a/src/three/plugins/mvt/GlyphAtlasTexture.js +++ b/src/three/plugins/mvt/GlyphAtlasTexture.js @@ -102,7 +102,7 @@ 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={}] @@ -132,18 +132,24 @@ export class GlyphAtlasTexture extends CanvasTexture { ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + // center the glyph by its ink bounding box, not its advance width, so asymmetric side + // bearings ( e.g. 'j', 'l', punctuation ) don't shift the visible glyph off center + const m = ctx.measureText( char ); + const drawX = cx - ( m.actualBoundingBoxRight - m.actualBoundingBoxLeft ) / 2; + const drawY = cy - ( m.actualBoundingBoxDescent - m.actualBoundingBoxAscent ) / 2; + // stroke first so the fill sits on top of the halo if ( strokeStyle !== null ) { ctx.lineJoin = 'round'; ctx.lineWidth = strokeWidth; ctx.strokeStyle = strokeStyle; - ctx.strokeText( char, cx, cy ); + ctx.strokeText( char, drawX, drawY ); } ctx.fillStyle = color; - ctx.fillText( char, cx, cy ); + ctx.fillText( char, drawX, drawY ); } ); diff --git a/src/three/plugins/mvt/MVTAnnotationsPlugin.js b/src/three/plugins/mvt/MVTAnnotationsPlugin.js index 556a4c66d..531880ae1 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.measureCharacter] - 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 = () => {}, + measureCharacter = () => 1, camera = null, } = options; @@ -90,6 +93,9 @@ export class MVTAnnotationsPlugin { this.filterAnnotation = filterAnnotation; this.onAnnotationsUpdate = onAnnotationsUpdate; + // TODO: temporary — provides per-character advance widths for text label layout + this.measureCharacter = measureCharacter; + // hierarchy for managing tile loading and visibility this.hierarchy = new MVTHierarchy(); this.occupancy = new DelayedScreenOccupationManager(); @@ -238,6 +244,7 @@ export class MVTAnnotationsPlugin { anchorManager.update(); anchorManager.added.forEach( item => { + item.measureCharacter = this.measureCharacter; occupancy.register( item ); } ); diff --git a/src/three/plugins/mvt/annotations/PointAnnotation.js b/src/three/plugins/mvt/annotations/PointAnnotation.js index 8b1685e03..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; diff --git a/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js b/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js index 95530d161..3679d9f23 100644 --- a/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js +++ b/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js @@ -71,6 +71,14 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { // screen-space baseline angle per character ( radians ), filled by evaluate() this.characterAngles = []; + // per-character advance width provider ( em units ), assigned by the plugin + this.measureCharacter = () => 1; + + // cached per-character advance widths ( screen px ) and their total, computed lazily in + // evaluate() since the text never changes + this._advances = null; + this._totalWidth = 0; + } // overrides @@ -121,17 +129,42 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { // test every character first so a string that doesn't fit leaves no marks behind. // TODO: also reject foreshortened paths ( tiny screen-space segments ) const length = text.length; - const halfChar = ( length - 1 ) * 0.5; const radius = CHARACTER_SIZE / 2; + // per-character advance widths in screen px ( em fraction × em size ), cached since the + // text never changes + let advances = this._advances; + if ( advances === null ) { + + advances = []; + let total = 0; + for ( let k = 0; k < length; k ++ ) { + + const w = this.measureCharacter( text[ k ] ) * CHARACTER_SIZE; + advances.push( w ); + total += w; + + } + + this._advances = advances; + this._totalWidth = total; + + } + + const totalWidth = this._totalWidth; + let seg = 0; + let cursor = 0; let firstX = 0; let lastX = 0; let prevAngle = 0; let totalTurn = 0; for ( let k = 0; k < length; k ++ ) { - const target = anchorLength + ( k - halfChar ) * CHARACTER_SIZE; + // place each character's center along the arc by its advance, centered on the anchor + const center = cursor + advances[ k ] * 0.5 - totalWidth * 0.5; + cursor += advances[ k ]; + const target = anchorLength + center; // the path is too short on screen to hold the whole string if ( target < 0 || target > totalLength ) { From 3b71814757bc27c836fbcec117f7177bc1b945d1 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Wed, 1 Jul 2026 10:56:01 +0900 Subject: [PATCH 07/18] Update --- .../three/src/plugins/mvt/CharacterPoints.js | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/example/three/src/plugins/mvt/CharacterPoints.js b/example/three/src/plugins/mvt/CharacterPoints.js index 8626db68c..0d535e69a 100644 --- a/example/three/src/plugins/mvt/CharacterPoints.js +++ b/example/three/src/plugins/mvt/CharacterPoints.js @@ -13,8 +13,8 @@ export class CharacterPoints extends Points { constructor( options = {} ) { const { - size = 5, - glyphSize = 32, + size = 14, + glyphSize = 14 * window.devicePixelRatio, slotCount = 256, font = null, strokeStyle = 'black', @@ -61,7 +61,7 @@ export class CharacterPoints extends Points { this._lastUpdateTime = now; // add new anchors, refresh LoD-swapped references, reverse in-progress fade-outs - const { _entryMap, _orderedEntries } = this; + const { _entryMap, _orderedEntries, fadeInDuration, fadeOutDuration } = this; for ( const item of added ) { const existing = _entryMap.get( item.id ); @@ -73,7 +73,8 @@ export class CharacterPoints extends Points { } else { - existing.item = item; // keep reference fresh (LoD swap) + // keep reference fresh (LoD swap) + existing.item = item; if ( existing.state === 'out' ) existing.state = 'in'; } @@ -98,7 +99,7 @@ export class CharacterPoints extends Points { if ( entry.state === 'in' ) { - entry.fade = Math.min( 1, entry.fade + dt / this.fadeInDuration ); + entry.fade = Math.min( 1, entry.fade + dt / fadeInDuration ); if ( entry.fade >= 1 ) { entry.state = 'visible'; @@ -107,7 +108,7 @@ export class CharacterPoints extends Points { } else if ( entry.state === 'out' ) { - entry.fade = Math.max( 0, entry.fade - dt / this.fadeOutDuration ); + entry.fade = Math.max( 0, entry.fade - dt / fadeOutDuration ); if ( entry.fade <= 0 ) { toRemove.push( id ); @@ -157,21 +158,22 @@ export class CharacterPoints extends Points { measureCharacter( char ) { const { _advanceCache } = this; - let advance = _advanceCache.get( char ); - if ( advance === undefined ) { + if ( ! _advanceCache.has( char ) ) { - advance = this._measureCtx.measureText( char ).width / this._measureSize; + const advance = this._measureCtx.measureText( char ).width / this._measureSize; _advanceCache.set( char, advance ); } - return advance + 0.15; + return _advanceCache.get( char ) + 0.15; } // 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 ) ) { @@ -190,8 +192,7 @@ export class CharacterPoints extends Points { _updateGeometry() { - const origin = this.position; - const { _orderedEntries, geometry } = this; + const { _orderedEntries, geometry, position } = this; // total character count across all entries ( including fading ones ) let count = 0; @@ -201,6 +202,7 @@ export class CharacterPoints extends Points { } + // expand the geometry buffers if needed let posAttr = geometry.getAttribute( 'position' ); let glyphUVAttr = geometry.getAttribute( 'glyphUV' ); let alphaAttr = geometry.getAttribute( 'alpha' ); @@ -232,7 +234,7 @@ export class CharacterPoints extends Points { for ( let c = 0, l = positions.length; c < l; c ++ ) { const p = positions[ c ]; - posAttr.setXYZ( i, p.x - origin.x, p.y - origin.y, p.z - origin.z ); + posAttr.setXYZ( i, p.x - position.x, p.y - position.y, p.z - position.z ); const uv = this._glyphUV( text[ c ] ); if ( uv !== null ) { From 20490c7de035fd636b8faab134c7e339813161cd Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Wed, 1 Jul 2026 12:47:24 +0900 Subject: [PATCH 08/18] some simplification --- .../three/src/plugins/mvt/CharacterPoints.js | 6 +- src/three/plugins/mvt/GlyphAtlasTexture.js | 21 ++-- .../plugins/mvt/annotations/LineAnnotation.js | 15 ++- .../mvt/annotations/TextAnchorAnnotation.js | 104 +++++++++++------- 4 files changed, 93 insertions(+), 53 deletions(-) diff --git a/example/three/src/plugins/mvt/CharacterPoints.js b/example/three/src/plugins/mvt/CharacterPoints.js index 0d535e69a..d546628ac 100644 --- a/example/three/src/plugins/mvt/CharacterPoints.js +++ b/example/three/src/plugins/mvt/CharacterPoints.js @@ -40,8 +40,6 @@ export class CharacterPoints extends Points { // canvas context for measuring advance widths, normalized to em units ( width / fontSize ) this._measureSize = fontSize; - this._measureCtx = document.createElement( 'canvas' ).getContext( '2d' ); - this._measureCtx.font = this._font; this._advanceCache = new Map(); // black halo so glyphs read over the imagery @@ -160,7 +158,9 @@ export class CharacterPoints extends Points { const { _advanceCache } = this; if ( ! _advanceCache.has( char ) ) { - const advance = this._measureCtx.measureText( char ).width / this._measureSize; + const info = this.glyphAtlas.measureChar( char, this._font ); + const advance = ( info.actualBoundingBoxRight - info.actualBoundingBoxLeft ) / this._measureSize; + _advanceCache.set( char, advance ); } diff --git a/src/three/plugins/mvt/GlyphAtlasTexture.js b/src/three/plugins/mvt/GlyphAtlasTexture.js index f7b0a018c..e81f06a43 100644 --- a/src/three/plugins/mvt/GlyphAtlasTexture.js +++ b/src/three/plugins/mvt/GlyphAtlasTexture.js @@ -128,19 +128,15 @@ export class GlyphAtlasTexture extends CanvasTexture { const cx = x + w / 2; const cy = y + h / 2; - ctx.font = font; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - - // center the glyph by its ink bounding box, not its advance width, so asymmetric side - // bearings ( e.g. 'j', 'l', punctuation ) don't shift the visible glyph off center - const m = ctx.measureText( char ); + // 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 - ( m.actualBoundingBoxDescent - m.actualBoundingBoxAscent ) / 2; + const drawY = cy + strokeWidth; // 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; @@ -148,6 +144,7 @@ export class GlyphAtlasTexture extends CanvasTexture { } + ctx.font = font; ctx.fillStyle = color; ctx.fillText( char, drawX, drawY ); @@ -155,6 +152,14 @@ export class GlyphAtlasTexture extends CanvasTexture { } + 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/annotations/LineAnnotation.js b/src/three/plugins/mvt/annotations/LineAnnotation.js index 993439dff..732f909fd 100644 --- a/src/three/plugins/mvt/annotations/LineAnnotation.js +++ b/src/three/plugins/mvt/annotations/LineAnnotation.js @@ -37,6 +37,7 @@ export class LineAnnotation extends OccupancyAnnotation { // screen positions and dirty variables this.screenPositions = []; + this.cumulativeLen = []; this.cachedMatrix = new Matrix4(); this.cachedResolution = new Vector2(); @@ -57,7 +58,7 @@ export class LineAnnotation extends OccupancyAnnotation { updateTransform( matrix, resolution, cameraPosition ) { - const { positions, screenPositions, cachedMatrix, cachedResolution } = this; + const { positions, screenPositions, cachedMatrix, cachedResolution, cumulativeLen } = this; if ( cachedMatrix.equals( matrix ) && cachedResolution.equals( resolution ) ) { return; @@ -88,6 +89,18 @@ export class LineAnnotation extends OccupancyAnnotation { } + cumulativeLen.length = screenPositions.length; + cumulativeLen[ 0 ] = 0; + for ( let i = 1; i < screenPositions.length; i ++ ) { + + const a = screenPositions[ i - 1 ]; + const b = screenPositions[ i ]; + const dx = b.x - a.x; + const dy = b.y - a.y; + cumulativeLen[ i ] = cumulativeLen[ i - 1 ] + Math.sqrt( dx * dx + dy * dy ); + + } + } // Place anchors along a path at a fixed "spacing" (geographic, in radians), recording the diff --git a/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js b/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js index 3679d9f23..9e9af840c 100644 --- a/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js +++ b/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js @@ -1,4 +1,4 @@ -import { Vector3 } from 'three'; +import { Vector3, MathUtils } from 'three'; import { OccupancyAnnotation } from '../ScreenOccupationManager.js'; // screen-space spacing / footprint per character, in pixels @@ -9,7 +9,6 @@ const CHARACTER_SIZE = 10; const MAX_LABEL_ANGLE = Math.PI / 2; // scratch reused across evaluate() calls ( synchronous, single pass ) -const _cumulative = []; const _segIndices = []; const _segAlphas = []; @@ -92,14 +91,14 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { } // TODO: update the "active reference" here to avoid iteration every frame - const { line, i0, alpha } = this.getActiveReference(); + const { line, i0, i1, alpha } = this.getActiveReference(); if ( ! line.ready ) { return false; } - const { screenPositions, positions } = line; + const { screenPositions, cumulativeLen, positions } = line; const sampleCount = positions.length; if ( sampleCount < 2 ) { @@ -107,38 +106,34 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { } - // cumulative 2d screen-space arc length per sample - _cumulative.length = sampleCount; - _cumulative[ 0 ] = 0; - for ( let i = 1; i < sampleCount; i ++ ) { + // arc-length position of the anchor along the screen-projected path + const totalLength = cumulativeLen[ cumulativeLen.length - 1 ]; + const anchorOffset = MathUtils.lerp( cumulativeLen[ i0 ], cumulativeLen[ i1 ], alpha ); + + // lay out and test every character; bail if it can't fit or the baseline curves too sharply + const flip = this._layoutCharacters( handle, anchorOffset, totalLength ); + if ( flip === null ) { - const a = screenPositions[ i - 1 ]; - const b = screenPositions[ i ]; - const dx = b.x - a.x; - const dy = b.y - a.y; - _cumulative[ i ] = _cumulative[ i - 1 ] + Math.sqrt( dx * dx + dy * dy ); + return false; } - const totalLength = _cumulative[ sampleCount - 1 ]; + // it fits: commit occupancy marks, world positions, and baseline angles + this._placeCharacters( handle, flip ); + return true; - // arc length of the anchor along the screen-projected path - const anchorLength = _cumulative[ i0 ] + alpha * ( _cumulative[ i0 + 1 ] - _cumulative[ i0 ] ); + } - // march the characters out from the anchor in both directions, centered. measure and - // test every character first so a string that doesn't fit leaves no marks behind. - // TODO: also reject foreshortened paths ( tiny screen-space segments ) - const length = text.length; - const radius = CHARACTER_SIZE / 2; + // per-character advance widths in screen px ( em fraction × em size ), cached since the text + // never changes. also caches their total in `_totalWidth`. + _ensureAdvances() { - // per-character advance widths in screen px ( em fraction × em size ), cached since the - // text never changes - let advances = this._advances; - if ( advances === null ) { + if ( this._advances === null ) { - advances = []; + const { text } = this; + const advances = []; let total = 0; - for ( let k = 0; k < length; k ++ ) { + for ( let k = 0, l = text.length; k < l; k ++ ) { const w = this.measureCharacter( text[ k ] ) * CHARACTER_SIZE; advances.push( w ); @@ -151,7 +146,24 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { } + return this._advances; + + } + + // 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, anchorOffset, totalLength ) { + + const { line } = this.getActiveReference(); + const { cumulativeLen, screenPositions } = line; + const sampleCount = screenPositions.length; + const advances = this._ensureAdvances(); const totalWidth = this._totalWidth; + const length = advances.length; + const radius = CHARACTER_SIZE / 2; let seg = 0; let cursor = 0; @@ -164,24 +176,24 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { // place each character's center along the arc by its advance, centered on the anchor const center = cursor + advances[ k ] * 0.5 - totalWidth * 0.5; cursor += advances[ k ]; - const target = anchorLength + center; + const target = anchorOffset + center; // the path is too short on screen to hold the whole string if ( target < 0 || target > totalLength ) { - return false; + return null; } // advance to the segment containing "target" ( monotonic across k ) - while ( seg < sampleCount - 2 && _cumulative[ seg + 1 ] < target ) { + while ( seg < sampleCount - 2 && cumulativeLen[ seg + 1 ] < target ) { seg ++; } - const segLength = _cumulative[ seg + 1 ] - _cumulative[ seg ]; - const segAlpha = segLength > 0 ? ( target - _cumulative[ seg ] ) / segLength : 0; + const segLength = cumulativeLen[ seg + 1 ] - cumulativeLen[ seg ]; + const segAlpha = segLength > 0 ? ( target - cumulativeLen[ seg ] ) / segLength : 0; const a = screenPositions[ seg ]; const b = screenPositions[ seg + 1 ]; @@ -192,7 +204,7 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { // off-screen in depth, or colliding with an already-placed annotation if ( sz < 0 || sz > 1 || handle.test( sx, sy, radius ) ) { - return false; + return null; } @@ -214,7 +226,7 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { totalTurn += Math.abs( delta ); if ( totalTurn > MAX_LABEL_ANGLE ) { - return false; + return null; } @@ -227,8 +239,23 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { } - // it fits: mark occupancy and record a world-space position per character - const { characterPositions, characterAngles } = this; + // flip the character-to-slot mapping when the path runs right-to-left on screen so the + // label always reads left-to-right + return lastX < firstX; + + } + + // commit a successful layout: mark occupancy and record a world-space position + baseline + // angle per character, applying the reading-direction flip + _placeCharacters( handle, flip ) { + + const { characterPositions, characterAngles, _advances } = this; + const { line } = this.getActiveReference(); + const { screenPositions, positions } = line; + + const length = _advances.length; + const radius = CHARACTER_SIZE / 2; + while ( characterPositions.length < length ) { characterPositions.push( new Vector3() ); @@ -238,9 +265,6 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { characterPositions.length = length; characterAngles.length = length; - // flip the character-to-slot mapping when the path runs right-to-left on screen so the - // label always reads left-to-right - const flip = lastX < firstX; for ( let k = 0; k < length; k ++ ) { const slot = flip ? length - 1 - k : k; @@ -261,8 +285,6 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { } - return true; - } updateTransform( matrix, resolution, cameraPosition ) { From 25aacf1f389659a0e1c97593649ee23f4e407c16 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Wed, 1 Jul 2026 14:14:18 +0900 Subject: [PATCH 09/18] Update --- .../mvt/annotations/TextAnchorAnnotation.js | 123 ++++++++++-------- 1 file changed, 67 insertions(+), 56 deletions(-) diff --git a/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js b/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js index 9e9af840c..b9ae5f78a 100644 --- a/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js +++ b/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js @@ -11,6 +11,7 @@ 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. @@ -75,7 +76,7 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { // cached per-character advance widths ( screen px ) and their total, computed lazily in // evaluate() since the text never changes - this._advances = null; + this._advances = []; this._totalWidth = 0; } @@ -91,62 +92,54 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { } // TODO: update the "active reference" here to avoid iteration every frame - const { line, i0, i1, alpha } = this.getActiveReference(); + const { line } = this.getActiveReference(); + const { cumulativeLen, screenPositions } = line; if ( ! line.ready ) { return false; } - const { screenPositions, cumulativeLen, positions } = line; - const sampleCount = positions.length; - if ( sampleCount < 2 ) { + if ( cumulativeLen.length < 2 ) { return false; } - // arc-length position of the anchor along the screen-projected path - const totalLength = cumulativeLen[ cumulativeLen.length - 1 ]; - const anchorOffset = MathUtils.lerp( cumulativeLen[ i0 ], cumulativeLen[ i1 ], alpha ); + this._updateAdvances(); + _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 - const flip = this._layoutCharacters( handle, anchorOffset, totalLength ); - if ( flip === null ) { + if ( ! this._layoutCharacters( handle, _segIndices, _segAlphas ) ) { return false; } // it fits: commit occupancy marks, world positions, and baseline angles - this._placeCharacters( handle, flip ); + this._placeCharacters( handle, _segIndices, _segAlphas ); return true; } // per-character advance widths in screen px ( em fraction × em size ), cached since the text // never changes. also caches their total in `_totalWidth`. - _ensureAdvances() { + _updateAdvances() { - if ( this._advances === null ) { + const { text, _advances } = this; + _advances.length = text.length; - const { text } = this; - const advances = []; - let total = 0; - for ( let k = 0, l = text.length; k < l; k ++ ) { + let total = 0; + for ( let k = 0, l = text.length; k < l; k ++ ) { - const w = this.measureCharacter( text[ k ] ) * CHARACTER_SIZE; - advances.push( w ); - total += w; - - } - - this._advances = advances; - this._totalWidth = total; + const w = this.measureCharacter( text[ k ] ) * CHARACTER_SIZE; + _advances[ k ] = w; + total += w; } - return this._advances; + this._totalWidth = total; } @@ -155,78 +148,81 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { // 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, anchorOffset, totalLength ) { + _layoutCharacters( handle, outputIndices, outputAlphas ) { - const { line } = this.getActiveReference(); + const { _advances, _totalWidth } = this; + const { line, i0, i1, alpha } = this.getActiveReference(); const { cumulativeLen, screenPositions } = line; - const sampleCount = screenPositions.length; - const advances = this._ensureAdvances(); - const totalWidth = this._totalWidth; - const length = advances.length; + const anchorOffset = MathUtils.lerp( cumulativeLen[ i0 ], cumulativeLen[ i1 ], alpha ); + + const pointCount = screenPositions.length; + const totalLength = cumulativeLen[ cumulativeLen.length - 1 ]; + const length = _advances.length; const radius = CHARACTER_SIZE / 2; let seg = 0; - let cursor = 0; let firstX = 0; let lastX = 0; + let charCursor = 0; let prevAngle = 0; let totalTurn = 0; for ( let k = 0; k < length; k ++ ) { // place each character's center along the arc by its advance, centered on the anchor - const center = cursor + advances[ k ] * 0.5 - totalWidth * 0.5; - cursor += advances[ k ]; - const target = anchorOffset + center; + const charCenter = charCursor + _advances[ k ] * 0.5 - _totalWidth * 0.5; + charCursor += _advances[ k ]; + + // 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 null; + return false; } - // advance to the segment containing "target" ( monotonic across k ) - while ( seg < sampleCount - 2 && cumulativeLen[ seg + 1 ] < target ) { + // advance to the segment containing "target" + while ( seg < pointCount - 2 && cumulativeLen[ seg + 1 ] < target ) { seg ++; } - const segLength = cumulativeLen[ seg + 1 ] - cumulativeLen[ seg ]; + const segNext = seg + 1; + const segLength = cumulativeLen[ segNext ] - cumulativeLen[ seg ]; const segAlpha = segLength > 0 ? ( target - cumulativeLen[ seg ] ) / segLength : 0; - const a = screenPositions[ seg ]; - const b = screenPositions[ seg + 1 ]; - const sx = a.x + ( b.x - a.x ) * segAlpha; - const sy = a.y + ( b.y - a.y ) * segAlpha; - const sz = a.z + ( b.z - a.z ) * segAlpha; + 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 ( sz < 0 || sz > 1 || handle.test( sx, sy, radius ) ) { + if ( _vec.z < 0 || _vec.z > 1 || handle.test( _vec.x, _vec.y, radius ) ) { - return null; + return false; } // track the screen x of the path ends to decide reading direction below if ( k === 0 ) { - firstX = sx; + firstX = _vec.x; } - lastX = sx; + lastX = _vec.x; // 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( b.y - a.y, b.x - a.x ); + const angle = Math.atan2( p1.y - p0.y, p1.x - p0.x ); if ( k > 0 ) { const delta = Math.atan2( Math.sin( angle - prevAngle ), Math.cos( angle - prevAngle ) ); totalTurn += Math.abs( delta ); if ( totalTurn > MAX_LABEL_ANGLE ) { - return null; + return false; } @@ -234,25 +230,40 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { prevAngle = angle; - _segIndices[ k ] = seg; - _segAlphas[ k ] = segAlpha; + outputIndices[ k ] = seg; + outputAlphas[ k ] = segAlpha; } // flip the character-to-slot mapping when the path runs right-to-left on screen so the // label always reads left-to-right + return true; + + } + + // determine the text direction based on the width end points of the string + _getTextDirection( screenPositions, segIndices, segAlphas ) { + + const ss0 = segIndices[ 0 ]; + const ss1 = ss0 + 1; + const se0 = segIndices[ segIndices.length - 1 ]; + const se1 = se0 + 1; + + const firstX = _vec.lerpVectors( screenPositions[ ss0 ], screenPositions[ ss1 ], segAlphas[ 0 ] ).x; + const lastX = _vec.lerpVectors( screenPositions[ se0 ], screenPositions[ se1 ], segAlphas[ segAlphas.length - 1 ] ).x; return lastX < firstX; } // commit a successful layout: mark occupancy and record a world-space position + baseline // angle per character, applying the reading-direction flip - _placeCharacters( handle, flip ) { + _placeCharacters( handle, segIndices, segAlphas ) { const { characterPositions, characterAngles, _advances } = this; const { line } = this.getActiveReference(); const { screenPositions, positions } = line; + const flip = this._getTextDirection( screenPositions, segIndices, segAlphas ); const length = _advances.length; const radius = CHARACTER_SIZE / 2; @@ -268,8 +279,8 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { for ( let k = 0; k < length; k ++ ) { const slot = flip ? length - 1 - k : k; - const index = _segIndices[ slot ]; - const segAlpha = _segAlphas[ slot ]; + const index = segIndices[ slot ]; + const segAlpha = segAlphas[ slot ]; const a = screenPositions[ index ]; const b = screenPositions[ index + 1 ]; From aac09751f404850e47d350ab3e064ad08528fe0f Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Wed, 1 Jul 2026 16:05:31 +0900 Subject: [PATCH 10/18] Cleanup --- .../three/src/plugins/mvt/CharacterPoints.js | 17 +++---- src/three/plugins/mvt/GlyphAtlasTexture.js | 4 +- src/three/plugins/mvt/MVTAnnotationsPlugin.js | 8 ++-- .../plugins/mvt/annotations/LineAnnotation.js | 11 ++--- .../mvt/annotations/TextAnchorAnnotation.js | 44 +++++++------------ 5 files changed, 38 insertions(+), 46 deletions(-) diff --git a/example/three/src/plugins/mvt/CharacterPoints.js b/example/three/src/plugins/mvt/CharacterPoints.js index d546628ac..81c91c4ca 100644 --- a/example/three/src/plugins/mvt/CharacterPoints.js +++ b/example/three/src/plugins/mvt/CharacterPoints.js @@ -13,8 +13,8 @@ export class CharacterPoints extends Points { constructor( options = {} ) { const { - size = 14, - glyphSize = 14 * window.devicePixelRatio, + size = 16, + glyphSize = 16 * window.devicePixelRatio, slotCount = 256, font = null, strokeStyle = 'black', @@ -153,19 +153,20 @@ export class CharacterPoints extends Points { } // advance width of `char` in em units ( fraction of the font size ), cached per character - measureCharacter( char ) { + measureChar( char ) { - const { _advanceCache } = this; + const { _advanceCache, material, glyphAtlas, _font } = this; if ( ! _advanceCache.has( char ) ) { - const info = this.glyphAtlas.measureChar( char, this._font ); - const advance = ( info.actualBoundingBoxRight - info.actualBoundingBoxLeft ) / this._measureSize; + const multiplier = material.size / glyphAtlas.slotSize; + const info = glyphAtlas.measureChar( char, _font ); + const advance = info.width + 2; - _advanceCache.set( char, advance ); + _advanceCache.set( char, advance * multiplier ); } - return _advanceCache.get( char ) + 0.15; + return _advanceCache.get( char ); } diff --git a/src/three/plugins/mvt/GlyphAtlasTexture.js b/src/three/plugins/mvt/GlyphAtlasTexture.js index e81f06a43..7d385b76f 100644 --- a/src/three/plugins/mvt/GlyphAtlasTexture.js +++ b/src/three/plugins/mvt/GlyphAtlasTexture.js @@ -130,8 +130,8 @@ export class GlyphAtlasTexture extends CanvasTexture { // 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 + strokeWidth; + 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 ) { diff --git a/src/three/plugins/mvt/MVTAnnotationsPlugin.js b/src/three/plugins/mvt/MVTAnnotationsPlugin.js index 531880ae1..f194f22d2 100644 --- a/src/three/plugins/mvt/MVTAnnotationsPlugin.js +++ b/src/three/plugins/mvt/MVTAnnotationsPlugin.js @@ -54,7 +54,7 @@ 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.measureCharacter] - Returns a per-character + * @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 { @@ -82,7 +82,7 @@ export class MVTAnnotationsPlugin { }, filterAnnotation = () => false, onAnnotationsUpdate = () => {}, - measureCharacter = () => 1, + measureChar = () => 1, camera = null, } = options; @@ -94,7 +94,7 @@ export class MVTAnnotationsPlugin { this.onAnnotationsUpdate = onAnnotationsUpdate; // TODO: temporary — provides per-character advance widths for text label layout - this.measureCharacter = measureCharacter; + this.measureChar = measureChar; // hierarchy for managing tile loading and visibility this.hierarchy = new MVTHierarchy(); @@ -244,7 +244,7 @@ export class MVTAnnotationsPlugin { anchorManager.update(); anchorManager.added.forEach( item => { - item.measureCharacter = this.measureCharacter; + item.measureChar = this.measureChar; occupancy.register( item ); } ); diff --git a/src/three/plugins/mvt/annotations/LineAnnotation.js b/src/three/plugins/mvt/annotations/LineAnnotation.js index 732f909fd..90a4aebc4 100644 --- a/src/three/plugins/mvt/annotations/LineAnnotation.js +++ b/src/three/plugins/mvt/annotations/LineAnnotation.js @@ -93,11 +93,12 @@ export class LineAnnotation extends OccupancyAnnotation { cumulativeLen[ 0 ] = 0; for ( let i = 1; i < screenPositions.length; i ++ ) { - const a = screenPositions[ i - 1 ]; - const b = screenPositions[ i ]; - const dx = b.x - a.x; - const dy = b.y - a.y; - cumulativeLen[ i ] = cumulativeLen[ i - 1 ] + Math.sqrt( dx * dx + dy * dy ); + 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; } diff --git a/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js b/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js index b9ae5f78a..cfa604e1e 100644 --- a/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js +++ b/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js @@ -2,7 +2,8 @@ import { Vector3, MathUtils } from 'three'; import { OccupancyAnnotation } from '../ScreenOccupationManager.js'; // screen-space spacing / footprint per character, in pixels -const CHARACTER_SIZE = 10; +// TODO: This should come from a user setting +const CHARACTER_SIZE = 16; // reject labels whose projected baseline turns more than this in total ( radians ), since // sharply curving text becomes hard to read @@ -72,7 +73,7 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { this.characterAngles = []; // per-character advance width provider ( em units ), assigned by the plugin - this.measureCharacter = () => 1; + this.measureChar = () => 1; // cached per-character advance widths ( screen px ) and their total, computed lazily in // evaluate() since the text never changes @@ -93,7 +94,7 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { // TODO: update the "active reference" here to avoid iteration every frame const { line } = this.getActiveReference(); - const { cumulativeLen, screenPositions } = line; + const { cumulativeLen } = line; if ( ! line.ready ) { return false; @@ -133,7 +134,7 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { let total = 0; for ( let k = 0, l = text.length; k < l; k ++ ) { - const w = this.measureCharacter( text[ k ] ) * CHARACTER_SIZE; + const w = this.measureChar( text[ k ] ); _advances[ k ] = w; total += w; @@ -161,16 +162,14 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { const radius = CHARACTER_SIZE / 2; let seg = 0; - let firstX = 0; - let lastX = 0; let charCursor = 0; let prevAngle = 0; let totalTurn = 0; - for ( let k = 0; k < length; k ++ ) { + for ( let i = 0; i < length; i ++ ) { // place each character's center along the arc by its advance, centered on the anchor - const charCenter = charCursor + _advances[ k ] * 0.5 - _totalWidth * 0.5; - charCursor += _advances[ k ]; + const charCenter = charCursor + _advances[ i ] * 0.5 - _totalWidth * 0.5; + charCursor += _advances[ i ]; // absolute target position relative to the line const target = anchorOffset + charCenter; @@ -204,19 +203,10 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { } - // track the screen x of the path ends to decide reading direction below - if ( k === 0 ) { - - firstX = _vec.x; - - } - - lastX = _vec.x; - // 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 ( k > 0 ) { + if ( i > 0 ) { const delta = Math.atan2( Math.sin( angle - prevAngle ), Math.cos( angle - prevAngle ) ); totalTurn += Math.abs( delta ); @@ -230,8 +220,8 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { prevAngle = angle; - outputIndices[ k ] = seg; - outputAlphas[ k ] = segAlpha; + outputIndices[ i ] = seg; + outputAlphas[ i ] = segAlpha; } @@ -263,7 +253,7 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { const { line } = this.getActiveReference(); const { screenPositions, positions } = line; - const flip = this._getTextDirection( screenPositions, segIndices, segAlphas ); + const flip = false;//this._getTextDirection( screenPositions, segIndices, segAlphas ); const length = _advances.length; const radius = CHARACTER_SIZE / 2; @@ -282,16 +272,16 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { const index = segIndices[ slot ]; const segAlpha = segAlphas[ slot ]; - const a = screenPositions[ index ]; - const b = screenPositions[ index + 1 ]; - handle.mark( a.x + ( b.x - a.x ) * segAlpha, a.y + ( b.y - a.y ) * segAlpha, radius ); + 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, radius ); characterPositions[ k ].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 = ( b.x - a.x ) * ( flip ? - 1 : 1 ); - const dy = ( b.y - a.y ) * ( flip ? - 1 : 1 ); + const dx = ( p1.x - p0.x ) * ( flip ? - 1 : 1 ); + const dy = ( p1.y - p0.y ) * ( flip ? - 1 : 1 ); characterAngles[ k ] = Math.atan2( dy, dx ); } From 4159d433491bd8abbfca7cf87b9b9b1f842b2091 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Wed, 1 Jul 2026 17:02:47 +0900 Subject: [PATCH 11/18] Fix delayed text, text direction --- src/three/plugins/mvt/SettlingManager.js | 4 + .../plugins/mvt/annotations/LineAnnotation.js | 7 +- .../mvt/annotations/TextAnchorAnnotation.js | 91 +++++++++++++------ 3 files changed, 72 insertions(+), 30 deletions(-) diff --git a/src/three/plugins/mvt/SettlingManager.js b/src/three/plugins/mvt/SettlingManager.js index c82997bc5..2fbccf412 100644 --- a/src/three/plugins/mvt/SettlingManager.js +++ b/src/three/plugins/mvt/SettlingManager.js @@ -372,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/annotations/LineAnnotation.js b/src/three/plugins/mvt/annotations/LineAnnotation.js index 90a4aebc4..7386fd645 100644 --- a/src/three/plugins/mvt/annotations/LineAnnotation.js +++ b/src/three/plugins/mvt/annotations/LineAnnotation.js @@ -41,6 +41,10 @@ export class LineAnnotation extends OccupancyAnnotation { this.cachedMatrix = new Matrix4(); this.cachedResolution = new Vector2(); + // set true when settling updates `positions` so updateTransform recomputes the screen + // projection even when the camera hasn't moved + this.needsUpdate = false; + } hasCoverage( lat, lon ) { @@ -59,12 +63,13 @@ export class LineAnnotation extends OccupancyAnnotation { updateTransform( matrix, resolution, cameraPosition ) { const { positions, screenPositions, cachedMatrix, cachedResolution, cumulativeLen } = this; - if ( cachedMatrix.equals( matrix ) && cachedResolution.equals( resolution ) ) { + if ( ! this.needsUpdate && cachedMatrix.equals( matrix ) && cachedResolution.equals( resolution ) ) { return; } + this.needsUpdate = false; cachedMatrix.copy( matrix ); cachedResolution.copy( resolution ); diff --git a/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js b/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js index cfa604e1e..0df77d375 100644 --- a/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js +++ b/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js @@ -124,7 +124,7 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { } - // per-character advance widths in screen px ( em fraction × em size ), cached since the text + // per-character advance widths in screen px, cached since the text // never changes. also caches their total in `_totalWidth`. _updateAdvances() { @@ -144,17 +144,64 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { } + // 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 ) + // TODO: also reject foreshortened paths (tiny screen-space segments) _layoutCharacters( handle, outputIndices, outputAlphas ) { const { _advances, _totalWidth } = 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 ]; @@ -168,8 +215,9 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { for ( let i = 0; i < length; i ++ ) { // place each character's center along the arc by its advance, centered on the anchor - const charCenter = charCursor + _advances[ i ] * 0.5 - _totalWidth * 0.5; - charCursor += _advances[ i ]; + const slot = flip ? length - 1 - i : i; + const charCenter = charCursor + _advances[ slot ] * 0.5 - _totalWidth * 0.5; + charCursor += _advances[ slot ]; // absolute target position relative to the line const target = anchorOffset + charCenter; @@ -220,31 +268,15 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { prevAngle = angle; - outputIndices[ i ] = seg; - outputAlphas[ i ] = segAlpha; + outputIndices[ slot ] = seg; + outputAlphas[ slot ] = segAlpha; } - // flip the character-to-slot mapping when the path runs right-to-left on screen so the - // label always reads left-to-right return true; } - // determine the text direction based on the width end points of the string - _getTextDirection( screenPositions, segIndices, segAlphas ) { - - const ss0 = segIndices[ 0 ]; - const ss1 = ss0 + 1; - const se0 = segIndices[ segIndices.length - 1 ]; - const se1 = se0 + 1; - - const firstX = _vec.lerpVectors( screenPositions[ ss0 ], screenPositions[ ss1 ], segAlphas[ 0 ] ).x; - const lastX = _vec.lerpVectors( screenPositions[ se0 ], screenPositions[ se1 ], segAlphas[ segAlphas.length - 1 ] ).x; - return lastX < firstX; - - } - // 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 ) { @@ -253,7 +285,7 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { const { line } = this.getActiveReference(); const { screenPositions, positions } = line; - const flip = false;//this._getTextDirection( screenPositions, segIndices, segAlphas ); + const flip = this._getTextDirection(); const length = _advances.length; const radius = CHARACTER_SIZE / 2; @@ -266,23 +298,24 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { characterPositions.length = length; characterAngles.length = length; - for ( let k = 0; k < length; k ++ ) { + for ( let i = 0; i < length; i ++ ) { - const slot = flip ? length - 1 - k : k; - const index = segIndices[ slot ]; - const segAlpha = segAlphas[ slot ]; + // 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, radius ); - characterPositions[ k ].lerpVectors( positions[ index ], positions[ index + 1 ], segAlpha ); + 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[ k ] = Math.atan2( dy, dx ); + characterAngles[ i ] = Math.atan2( dy, dx ); } From bea745500981e1ff3327d2b6c971bd400922afc3 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Wed, 1 Jul 2026 17:48:25 +0900 Subject: [PATCH 12/18] Update reference --- example/three/annotationsExample.js | 4 +-- .../plugins/mvt/annotations/LineAnnotation.js | 2 +- .../mvt/annotations/TextAnchorAnnotation.js | 25 +++++++++++++------ 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/example/three/annotationsExample.js b/example/three/annotationsExample.js index caaf04fe8..969d2fc0a 100644 --- a/example/three/annotationsExample.js +++ b/example/three/annotationsExample.js @@ -186,7 +186,7 @@ function reinstantiateTiles() { }, // deferred: characterPoints is created below, but this only runs at update time - measureCharacter: char => characterPoints.measureCharacter( char ), + measureChar: char => characterPoints.measureChar( char ), } ) ); // use the camera cartographic region plugin to prevent particularly low-lod @@ -232,8 +232,6 @@ function reinstantiateTiles() { tiles.group.add( annotationsPoints ); characterPoints = new CharacterPoints( { - size: 14, - glyphSize: 2 * 14 * renderer.getPixelRatio(), strokeStyle: '#3f3e4c', strokeWidth: 6 * renderer.getPixelRatio(), } ); diff --git a/src/three/plugins/mvt/annotations/LineAnnotation.js b/src/three/plugins/mvt/annotations/LineAnnotation.js index 7386fd645..606f39a6a 100644 --- a/src/three/plugins/mvt/annotations/LineAnnotation.js +++ b/src/three/plugins/mvt/annotations/LineAnnotation.js @@ -230,7 +230,7 @@ export function parseLineAnnotations( vectorTile, x, y, level, tiling, filter, t // 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 - const anchorSpacing = 500000 / 6378137; + const anchorSpacing = 1000000 / 6378137; const subsampleFraction = 1 / 64; const tileBounds = tiling.getTileBounds( x, y, level, true, false ); const [ tMinX, tMinY, tMaxX, tMaxY ] = tileBounds; diff --git a/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js b/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js index 0df77d375..71aa18035 100644 --- a/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js +++ b/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js @@ -64,7 +64,7 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { this.id = `${ id }_${ annotationIndex ++ }`; this.referencePaths = []; - this._lastUsed = null; + this._activeReference = null; // tiles.group local position per character, filled by evaluate() this.characterPositions = []; @@ -324,6 +324,7 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { updateTransform( matrix, resolution, cameraPosition ) { // update the screen positions for shared line + this.updateActiveReference(); this.getActiveReference().line.updateTransform( matrix, resolution, cameraPosition ); } @@ -351,11 +352,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 { @@ -372,15 +379,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; } @@ -412,7 +419,7 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { } ); - return slotIndex; + this.updateActiveReference(); } @@ -430,6 +437,8 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { } + this.updateActiveReference(); + } } From 495ec9729ab85ce72ef290fe48f739a1890858af Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Wed, 1 Jul 2026 17:48:52 +0900 Subject: [PATCH 13/18] Remove unused function --- src/three/plugins/mvt/TextAnchorManager.js | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/three/plugins/mvt/TextAnchorManager.js b/src/three/plugins/mvt/TextAnchorManager.js index c515014bd..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 { From a68c1253a49731ca28448fe8d61e29b24314361e Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Wed, 1 Jul 2026 17:58:23 +0900 Subject: [PATCH 14/18] Update radius calculation --- .../mvt/annotations/TextAnchorAnnotation.js | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js b/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js index 71aa18035..afcdd4b44 100644 --- a/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js +++ b/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js @@ -1,10 +1,6 @@ import { Vector3, MathUtils } from 'three'; import { OccupancyAnnotation } from '../ScreenOccupationManager.js'; -// screen-space spacing / footprint per character, in pixels -// TODO: This should come from a user setting -const CHARACTER_SIZE = 16; - // 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; @@ -79,6 +75,7 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { // evaluate() since the text never changes this._advances = []; this._totalWidth = 0; + this._charRadius = 1; } @@ -107,7 +104,10 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { } + const maxCharWidth = this.measureChar( 'M' ); this._updateAdvances(); + this._charRadius = Math.sqrt( maxCharWidth ** 2 ) / 2; + _segIndices.length = text.length; _segAlphas.length = text.length; @@ -197,7 +197,7 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { // TODO: also reject foreshortened paths (tiny screen-space segments) _layoutCharacters( handle, outputIndices, outputAlphas ) { - const { _advances, _totalWidth } = this; + const { _advances, _totalWidth, _charRadius } = this; const { line, i0, i1, alpha } = this.getActiveReference(); const { cumulativeLen, screenPositions } = line; const anchorOffset = MathUtils.lerp( cumulativeLen[ i0 ], cumulativeLen[ i1 ], alpha ); @@ -206,7 +206,6 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { const pointCount = screenPositions.length; const totalLength = cumulativeLen[ cumulativeLen.length - 1 ]; const length = _advances.length; - const radius = CHARACTER_SIZE / 2; let seg = 0; let charCursor = 0; @@ -245,7 +244,7 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { _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, radius ) ) { + if ( _vec.z < 0 || _vec.z > 1 || handle.test( _vec.x, _vec.y, _charRadius ) ) { return false; @@ -281,13 +280,12 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { // angle per character, applying the reading-direction flip _placeCharacters( handle, segIndices, segAlphas ) { - const { characterPositions, characterAngles, _advances } = this; + const { characterPositions, characterAngles, _advances, _charRadius } = this; const { line } = this.getActiveReference(); const { screenPositions, positions } = line; const flip = this._getTextDirection(); const length = _advances.length; - const radius = CHARACTER_SIZE / 2; while ( characterPositions.length < length ) { @@ -307,7 +305,7 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { 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, radius ); + 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 ); From 76e77a9085f3d1c5216ffac688374d7d87566916 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Wed, 1 Jul 2026 18:28:57 +0900 Subject: [PATCH 15/18] Clean up glyphs --- .../three/src/plugins/mvt/AnnotationPoints.js | 140 +---------------- .../three/src/plugins/mvt/CharacterPoints.js | 134 +--------------- .../three/src/plugins/mvt/GlyphMaterial.js | 29 +++- example/three/src/plugins/mvt/GlyphPoints.js | 145 ++++++++++++++++++ src/three/plugins/mvt/GlyphAtlasTexture.js | 2 +- 5 files changed, 180 insertions(+), 270 deletions(-) create mode 100644 example/three/src/plugins/mvt/GlyphPoints.js diff --git a/example/three/src/plugins/mvt/AnnotationPoints.js b/example/three/src/plugins/mvt/AnnotationPoints.js index 3c48c797b..f41bcf173 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, + glyphSize = 20 * 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 index 81c91c4ca..5aa9b8245 100644 --- a/example/three/src/plugins/mvt/CharacterPoints.js +++ b/example/three/src/plugins/mvt/CharacterPoints.js @@ -1,14 +1,8 @@ -import { BufferAttribute, BufferGeometry, Matrix4, Points } from 'three'; -import { GlyphAtlasTexture } from '3d-tiles-renderer/plugins'; +import { BufferAttribute } from 'three'; import { GlyphMaterial } from './GlyphMaterial.js'; +import { GlyphPoints } from './GlyphPoints.js'; -const _mvMatrix = /* @__PURE__ */ new Matrix4(); - -// Draws one glyph per character for the currently-visible text anchors. Each anchor's -// `characterPositions` (and the characters in its `text`) are recomputed every frame by its -// evaluate(), so the geometry is rebuilt on every update. Glyphs are rasterized lazily into a -// shared atlas the first time a character is seen. ( Orientation / kerning come later. ) -export class CharacterPoints extends Points { +export class CharacterPoints extends GlyphPoints { constructor( options = {} ) { @@ -18,137 +12,23 @@ export class CharacterPoints extends Points { slotCount = 256, font = null, strokeStyle = 'black', - strokeWidth = null, + strokeWidth = 0, } = options; - super( new BufferGeometry(), new GlyphMaterial( { size } ) ); - - this.renderOrder = 1001; - this.frustumCulled = false; - - this.fadeInDuration = 0.3; - this.fadeOutDuration = 0.3; - - // Map keyed by stable id; entry: { item, fade: 0..1, state: 'in'|'visible'|'out' } - this._entryMap = new Map(); - this._orderedEntries = []; - this._lastUpdateTime = - 1; + 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._measureSize = fontSize; this._advanceCache = new Map(); // black halo so glyphs read over the imagery this._strokeStyle = strokeStyle; - this._strokeWidth = strokeWidth ?? Math.max( 1, Math.round( glyphSize * 0.08 ) ); - - this.glyphAtlas = new GlyphAtlasTexture( slotCount, glyphSize ); - this.material.glyphTexture = this.glyphAtlas; - this.glyphAtlas.getSlotSize( this.material.glyphCellSize ); - - } - - 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 anchors, refresh LoD-swapped references, reverse in-progress fade-outs - 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'; - - } - - } - - // start fade-out for removed anchors - 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 anchors for removal - 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._strokeWidth = strokeWidth; - this.position.setFromMatrixPosition( camera.matrixWorld ).applyMatrix4( _mvMatrix ); - this.updateMatrixWorld( true ); + this.glyphAtlas.resize( slotCount, glyphSize ); } diff --git a/example/three/src/plugins/mvt/GlyphMaterial.js b/example/three/src/plugins/mvt/GlyphMaterial.js index ec94fdf56..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; @@ -117,4 +124,10 @@ export class GlyphMaterial extends PointsMaterial { } + 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 7d385b76f..a39f70072 100644 --- a/src/three/plugins/mvt/GlyphAtlasTexture.js +++ b/src/three/plugins/mvt/GlyphAtlasTexture.js @@ -20,7 +20,7 @@ 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 ); From d096416f3b264ea09feb2e1125b17444f0beb1c4 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Wed, 1 Jul 2026 18:44:17 +0900 Subject: [PATCH 16/18] Final cleanup --- example/three/annotationsExample.js | 2 +- src/three/plugins/mvt/MVTAnnotationsPlugin.js | 2 - .../plugins/mvt/annotations/LineAnnotation.js | 57 ++++++++++++------- .../mvt/annotations/TextAnchorAnnotation.js | 41 ++++++------- 4 files changed, 57 insertions(+), 45 deletions(-) diff --git a/example/three/annotationsExample.js b/example/three/annotationsExample.js index 969d2fc0a..77fe69252 100644 --- a/example/three/annotationsExample.js +++ b/example/three/annotationsExample.js @@ -193,7 +193,7 @@ function reinstantiateTiles() { // tiles from loading beneath the camera, causing navigation issues. const cameraRegion = new CameraCartographicRegion( { camera, - radius: 2000, + radius: 1500, errorTarget: 5000, } ); diff --git a/src/three/plugins/mvt/MVTAnnotationsPlugin.js b/src/three/plugins/mvt/MVTAnnotationsPlugin.js index f194f22d2..1132e3874 100644 --- a/src/three/plugins/mvt/MVTAnnotationsPlugin.js +++ b/src/three/plugins/mvt/MVTAnnotationsPlugin.js @@ -92,8 +92,6 @@ export class MVTAnnotationsPlugin { this.sortCallback = sortCallback; this.filterAnnotation = filterAnnotation; this.onAnnotationsUpdate = onAnnotationsUpdate; - - // TODO: temporary — provides per-character advance widths for text label layout this.measureChar = measureChar; // hierarchy for managing tile loading and visibility diff --git a/src/three/plugins/mvt/annotations/LineAnnotation.js b/src/three/plugins/mvt/annotations/LineAnnotation.js index 606f39a6a..c9b65cecd 100644 --- a/src/three/plugins/mvt/annotations/LineAnnotation.js +++ b/src/three/plugins/mvt/annotations/LineAnnotation.js @@ -32,38 +32,46 @@ 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, ref }` + // anchors placed along the path, each { i0, i1, alpha, lat, lon, ref } this.anchorPositions = []; - // screen positions and dirty variables + // screen positions, cumulative length used for calculating text layout this.screenPositions = []; this.cumulativeLen = []; + + // cache variables this.cachedMatrix = new Matrix4(); this.cachedResolution = new Vector2(); - // set true when settling updates `positions` so updateTransform recomputes the screen - // projection even when the camera hasn't moved + // set true when settling updates "positions" so updateTransform recomputes the screen + // projection even when the camera hasn't moved. this.needsUpdate = false; } - hasCoverage( lat, lon ) { - - const [ minLon, minLat, maxLon, maxLat ] = this.range; - return lon >= minLon && lon <= maxLon && lat >= minLat && lat <= maxLat; - - } - + // 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 ) ) { + const { + positions, + screenPositions, + cachedMatrix, + cachedResolution, + cumulativeLen, + } = this; + + if ( + ! this.needsUpdate && + cachedMatrix.equals( matrix ) && + cachedResolution.equals( resolution ) + ) { return; @@ -94,6 +102,7 @@ export class LineAnnotation extends OccupancyAnnotation { } + // roll up the cumulative placement cumulativeLen.length = screenPositions.length; cumulativeLen[ 0 ] = 0; for ( let i = 1; i < screenPositions.length; i ++ ) { @@ -109,6 +118,17 @@ export class LineAnnotation extends OccupancyAnnotation { } + // + + // 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 ) { @@ -127,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 ); @@ -137,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 ) { @@ -223,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 = 1000000 / 6378137; const subsampleFraction = 1 / 64; const tileBounds = tiling.getTileBounds( x, y, level, true, false ); diff --git a/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js b/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js index afcdd4b44..f8e27fc0d 100644 --- a/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js +++ b/src/three/plugins/mvt/annotations/TextAnchorAnnotation.js @@ -29,6 +29,7 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { get text() { + // TODO: this should be derivable from the user specified driver return this.properties.name || ''; } @@ -62,18 +63,15 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { this.referencePaths = []; this._activeReference = null; - // tiles.group local position per character, filled by evaluate() + // local position & angle per character this.characterPositions = []; - - // screen-space baseline angle per character ( radians ), filled by evaluate() this.characterAngles = []; - // per-character advance width provider ( em units ), assigned by the plugin + // per-character advance width provider (pixels) this.measureChar = () => 1; - // cached per-character advance widths ( screen px ) and their total, computed lazily in - // evaluate() since the text never changes - this._advances = []; + // 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; @@ -105,7 +103,7 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { } const maxCharWidth = this.measureChar( 'M' ); - this._updateAdvances(); + this._updateTotalWidth(); this._charRadius = Math.sqrt( maxCharWidth ** 2 ) / 2; _segIndices.length = text.length; @@ -124,19 +122,15 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { } - // per-character advance widths in screen px, cached since the text - // never changes. also caches their total in `_totalWidth`. - _updateAdvances() { - - const { text, _advances } = this; - _advances.length = text.length; + // 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 ++ ) { - const w = this.measureChar( text[ k ] ); - _advances[ k ] = w; - total += w; + total += this.measureChar( text[ k ] ); } @@ -197,7 +191,7 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { // TODO: also reject foreshortened paths (tiny screen-space segments) _layoutCharacters( handle, outputIndices, outputAlphas ) { - const { _advances, _totalWidth, _charRadius } = this; + 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 ); @@ -205,7 +199,7 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { const pointCount = screenPositions.length; const totalLength = cumulativeLen[ cumulativeLen.length - 1 ]; - const length = _advances.length; + const length = text.length; let seg = 0; let charCursor = 0; @@ -215,8 +209,9 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { // place each character's center along the arc by its advance, centered on the anchor const slot = flip ? length - 1 - i : i; - const charCenter = charCursor + _advances[ slot ] * 0.5 - _totalWidth * 0.5; - charCursor += _advances[ slot ]; + 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; @@ -280,12 +275,12 @@ export class TextAnchorAnnotation extends OccupancyAnnotation { // angle per character, applying the reading-direction flip _placeCharacters( handle, segIndices, segAlphas ) { - const { characterPositions, characterAngles, _advances, _charRadius } = this; + const { characterPositions, characterAngles, text, _charRadius } = this; const { line } = this.getActiveReference(); const { screenPositions, positions } = line; const flip = this._getTextDirection(); - const length = _advances.length; + const length = text.length; while ( characterPositions.length < length ) { From ea1a3d158c127c980b7d098ab2b70a46b0f9ef04 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Wed, 1 Jul 2026 18:51:18 +0900 Subject: [PATCH 17/18] Update icons --- example/three/annotationsExample.js | 2 -- example/three/src/plugins/mvt/AnnotationPoints.js | 4 ++-- src/three/plugins/mvt/GlyphAtlasTexture.js | 2 ++ 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/example/three/annotationsExample.js b/example/three/annotationsExample.js index 77fe69252..722a76b13 100644 --- a/example/three/annotationsExample.js +++ b/example/three/annotationsExample.js @@ -202,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; diff --git a/example/three/src/plugins/mvt/AnnotationPoints.js b/example/three/src/plugins/mvt/AnnotationPoints.js index f41bcf173..29ce31e7d 100644 --- a/example/three/src/plugins/mvt/AnnotationPoints.js +++ b/example/three/src/plugins/mvt/AnnotationPoints.js @@ -16,8 +16,8 @@ export class AnnotationPoints extends GlyphPoints { const { getKind = () => null, - size = 20, - glyphSize = 20 * window.devicePixelRatio, + size = 18, + glyphSize = 18 * window.devicePixelRatio, slotCount = 64, } = options; diff --git a/src/three/plugins/mvt/GlyphAtlasTexture.js b/src/three/plugins/mvt/GlyphAtlasTexture.js index a39f70072..375b48f41 100644 --- a/src/three/plugins/mvt/GlyphAtlasTexture.js +++ b/src/three/plugins/mvt/GlyphAtlasTexture.js @@ -24,6 +24,8 @@ export class GlyphAtlasTexture extends CanvasTexture { super( null ); + this.generateMipmaps = false; + this.slotSize = 0; // key -> slot index From 95138e0dac7b940af0b2a23992eac43ecf0cd126 Mon Sep 17 00:00:00 2001 From: Garrett Johnson Date: Wed, 1 Jul 2026 18:52:48 +0900 Subject: [PATCH 18/18] Update --- src/three/plugins/mvt/annotations/LineAnnotation.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/three/plugins/mvt/annotations/LineAnnotation.js b/src/three/plugins/mvt/annotations/LineAnnotation.js index c9b65cecd..d968a0c12 100644 --- a/src/three/plugins/mvt/annotations/LineAnnotation.js +++ b/src/three/plugins/mvt/annotations/LineAnnotation.js @@ -249,7 +249,7 @@ export function parseLineAnnotations( vectorTile, x, y, level, tiling, filter, t // TODO: this needs to scale based on LoD rather than a fixed - this is hackily-scaled below // anchor spacing in radians. Density tracks real-world length, independent of the tile's // zoom / size - const anchorSpacing = 1000000 / 6378137; + const anchorSpacing = 500000 / 6378137; const subsampleFraction = 1 / 64; const tileBounds = tiling.getTileBounds( x, y, level, true, false ); const [ tMinX, tMinY, tMaxX, tMaxY ] = tileBounds;