UNPKG

3d-tiles-renderer

Version:

https://github.com/AnalyticalGraphicsInc/3d-tiles/tree/master/specification

493 lines (361 loc) 11.2 kB
import { CanvasTexture, SRGBColorSpace, Vector2 } from 'three'; /** * A canvas texture that manages a grid of fixed-size slots, each holding a rendered glyph or icon. * Slots are addressed by string key and can be drawn with text, images, or paths. * @extends CanvasTexture */ export class MVTGlyphAtlasTexture extends CanvasTexture { /** * Returns true when all slots are allocated. * @type {boolean} **/ get isFull() { return this._freeList.length === 0 && this._nextIndex >= this._capacity; } /** * Returns the total number of icons that can be added to the atlas. * @type {number} **/ get capacity() { return this._capacity; } /** * Returns the number of icons currently used. * @type {number} **/ get count() { return this._slots.size; } /** * @param {number} slotCount - Maximum number of slots in the atlas. * @param {number} slotSize - Width and height of each slot in pixels. */ constructor( slotCount = 32, slotSize = 64 ) { super( null ); this.generateMipmaps = false; this.slotSize = 0; this._columns = - 1; this._capacity = - 1; // key -> slot index this._slots = new Map(); this._freeList = []; this._nextIndex = 0; this._capacity = 0; this._columns = 0; this._uvs = new Map(); this.resize( slotCount, slotSize ); this.colorSpace = SRGBColorSpace; } /** * Returns the keys associated with all glyphs. */ keys() { return this._slots.keys(); } /** * Returns true if key has an allocated slot. * @param {string} key * @returns {boolean} */ has( key ) { return this._slots.has( key ); } /** * Returns the slot bounds `{ x, y, w, h }` for key, or null if not allocated. * @param {string} key * @returns {{ x: number, y: number, w: number, h: number } | null} */ get( key ) { const { _slots } = this; if ( ! _slots.has( key ) ) { return null; } return this._indexToSlot( _slots.get( key ) ); } /** * Returns the UV bounds of a slot for key in GPU texture space (flipY applied), * or null if not allocated. * @param {Vector2} target * @returns {Vector2} */ getSlotSize( target ) { const { slotSize, image } = this; return target.set( slotSize / image.width, slotSize / image.height ); } /** * Returns the UV bounds of the slot for key in GPU texture space (flipY applied), * or null if not allocated. x/y is the top-left corner; w/h is the slot size in UV units. * @param {string} key * @returns {{ x: number, y: number, w: number, h: number } | null} */ getUV( key ) { const { _slots, _uvs } = this; const index = _slots.get( key ); return _uvs.get( index ); } /** * Renders a single character in the slot, centered on its text metrics bounding box. * @param {string} key * @param {string} char - The character to draw. * @param {Object} [styles={}] * @param {string} [styles.font=''] CSS font string (e.g. `'bold 48px sans-serif'`). * @param {string} [styles.color='white'] CSS fill color. * @param {string|null} [styles.strokeStyle=null] CSS stroke color drawn under the fill, or * null to skip the stroke. * @param {number} [styles.strokeWidth=1] Stroke width in atlas pixels. * @returns {{ x: number, y: number, w: number, h: number }} The allocated slot. * @throws If the atlas is full. */ drawChar( key, char, styles = {} ) { const { font = '', color = 'white', strokeStyle = null, strokeWidth = 1, } = styles; return this._draw( key, ( ctx, x, y, w, h ) => { const cx = x + w / 2; const cy = y + h / 2; // center the glyph by its ink bounding box width const m = this.measureChar( char ); const drawX = cx - ( m.actualBoundingBoxRight + m.actualBoundingBoxLeft ) / 2; const drawY = cy + h / 4; // stroke first so the fill sits on top of the halo if ( strokeStyle !== null ) { ctx.font = font; ctx.lineJoin = 'round'; ctx.lineWidth = strokeWidth * 2; ctx.strokeStyle = strokeStyle; ctx.strokeText( char, drawX, drawY ); } ctx.font = font; ctx.fillStyle = color; ctx.fillText( char, drawX, drawY ); } ); } /** * Function that returns a text metrics object for the given character rendered with * the provided set of styles. * @param {string} char * @param {string} font * @returns {TextMetrics} */ 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 * @param {HTMLImageElement|HTMLCanvasElement|ImageBitmap} image * @returns {{ x: number, y: number, w: number, h: number }} The allocated slot. * @throws If the atlas is full. */ drawImage( key, image ) { return this._draw( key, ( ctx, x, y, w, h ) => { ctx.drawImage( image, x, y, w, h ); } ); } /** * Renders a `Path2D` into the slot. Path coordinates are slot-local (origin at top-left). * @param {string} key * @param {Path2D} path2D * @param {Object} [styles={}] * @param {string|null} [styles.fillStyle=null] CSS fill color, or null to skip fill. * @param {string|null} [styles.strokeStyle=null] CSS stroke color, or null to skip stroke. * @param {number} [styles.lineWidth=1] Stroke width in pixels. * @returns {{ x: number, y: number, w: number, h: number }} The allocated slot. * @throws If the atlas is full. */ drawPath( key, path2D, styles = {} ) { const { fillStyle = null, strokeStyle = null, lineWidth = 1, } = styles; return this._draw( key, ( ctx, x, y ) => { ctx.save(); ctx.translate( x, y ); if ( fillStyle !== null ) { ctx.fillStyle = fillStyle; ctx.fill( path2D ); } if ( strokeStyle !== null ) { ctx.strokeStyle = strokeStyle; ctx.lineWidth = lineWidth; ctx.stroke( path2D ); } ctx.restore(); } ); } /** * Parses an SVG string and renders its paths into a slot, scaled to fit. * @param {string} key * @param {string} svgText * @param {Object} [styles={}] * @param {string|null} [styles.fillStyle='white'] CSS fill color, or null to skip fill. * @param {string|null} [styles.strokeStyle=null] CSS stroke color, or null to skip stroke. * @param {number} [styles.strokeWidth=1] Stroke width in SVG user units before scaling. * @param {number} [styles.iconScale=1] Fraction of the slot size the icon occupies (0–1). * @returns {{ x: number, y: number, w: number, h: number }} The allocated slot. * @throws If the atlas is full. */ drawSVG( key, svgText, styles = {} ) { const { fillStyle = 'white', strokeStyle = null, strokeWidth = 1, iconScale = 1, } = styles; // parse the svg doc so we can extract the content const doc = new DOMParser().parseFromString( svgText, 'image/svg+xml' ); const svg = doc.documentElement; const vbParts = ( svg.getAttribute( 'viewBox' ) ?? '0 0 15 15' ).trim().split( /[\s,]+/ ); const vbW = parseFloat( vbParts[ 2 ] ); const vbH = parseFloat( vbParts[ 3 ] ); // parse all paths const paths = [ ...svg.querySelectorAll( 'path' ) ] .map( el => el.getAttribute( 'd' ) ) .filter( Boolean ) .map( d => new Path2D( d ) ); return this._draw( key, ( ctx, x, y, w, h ) => { // TODO: this draw is resulting in alpha blending white outline // We might be able to use "source-over" and "destination-in" with "globalCompositeOperation" // to fix this. const iw = w * iconScale; const ih = h * iconScale; const scale = Math.min( iw / vbW, ih / vbH ); const originX = x + ( w - vbW * scale ) / 2; const originY = y + ( h - vbH * scale ) / 2; ctx.save(); ctx.translate( originX, originY ); ctx.scale( scale, scale ); ctx.lineJoin = 'round'; ctx.lineCap = 'round'; if ( strokeStyle !== null ) { ctx.lineWidth = strokeWidth / scale; ctx.strokeStyle = strokeStyle; for ( const path of paths ) { ctx.stroke( path ); } } if ( fillStyle !== null ) { ctx.fillStyle = fillStyle; for ( const path of paths ) { ctx.fill( path ); } } ctx.restore(); } ); } /** * Frees the slot for key, returning it to the pool for reuse. * @param {string} key */ release( key ) { const { _slots, _freeList } = this; if ( ! _slots.has( key ) ) { return; } const index = _slots.get( key ); _freeList.push( index ); _slots.delete( key ); } /** * Resizes the atlas, copying existing slot content to their new positions. * @param {number} slotCount - New maximum slot count. * @param {number} [slotSize] - New slot size in pixels. Defaults to current size. */ resize( slotCount, slotSize = this.slotSize ) { const oldCanvas = this.image; const oldColumns = this._columns; const oldSlotSize = this.slotSize; const columns = Math.ceil( Math.sqrt( slotCount ) ); const canvas = document.createElement( 'canvas' ); canvas.width = columns * slotSize; canvas.height = columns * slotSize; const ctx = canvas.getContext( '2d' ); // copy each allocated slot from its old grid position to its new one for ( const i of this._slots.values() ) { const srcX = ( i % oldColumns ) * oldSlotSize; const srcY = Math.floor( i / oldColumns ) * oldSlotSize; const dstX = ( i % columns ) * slotSize; const dstY = Math.floor( i / columns ) * slotSize; ctx.drawImage( oldCanvas, srcX, srcY, oldSlotSize, oldSlotSize, dstX, dstY, slotSize, slotSize, ); } this.dispose(); this.image = canvas; this.ctx = ctx; this.slotSize = slotSize; this._columns = columns; this._capacity = slotCount; this.needsUpdate = true; } /** * Clears all slots and resets the atlas to empty. */ clear() { this._slots.clear(); this._freeList.length = 0; this._nextIndex = 0; this.ctx.clearRect( 0, 0, this.image.width, this.image.height ); this.needsUpdate = true; } // calls the callback to draw into the calculated slot for the given key _draw( key, callback ) { const { ctx, image, _freeList, _capacity, _slots, _uvs, } = this; let index; if ( _slots.has( key ) ) { index = _slots.get( key ); } else { if ( _freeList.length > 0 ) { index = _freeList.pop(); } else if ( this._nextIndex < _capacity ) { index = this._nextIndex ++; } else { throw new Error( 'MVTGlyphAtlasTexture: atlas is full. Call resize() to increase capacity.' ); } _slots.set( key, index ); } // prepare the drawing clip const slot = this._indexToSlot( index ); ctx.save(); ctx.beginPath(); ctx.rect( slot.x, slot.y, slot.w, slot.h ); ctx.clip(); ctx.clearRect( slot.x, slot.y, slot.w, slot.h ); callback( ctx, slot.x, slot.y, slot.w, slot.h ); ctx.restore(); const { width, height, slotSize } = image; _uvs.set( index, { x: slot.x / width, y: ( height - slot.y ) / height, w: slotSize / width, h: slotSize / height, } ); this.needsUpdate = true; return slot; } // calculates the section of the canvas for the given index _indexToSlot( index ) { const { _columns, slotSize } = this; return { x: ( index % _columns ) * slotSize, y: Math.floor( index / _columns ) * slotSize, w: slotSize, h: slotSize, }; } }