UNPKG

3d-tiles-renderer

Version:

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

578 lines (388 loc) 15.7 kB
import { Vector2, Vector3, MathUtils } from 'three'; import { OccupancyAnnotation } from '../ScreenOccupationManager.js'; // reject labels whose projected baseline curves tighter than this radius in screen px at any // glyph, since sharply curving text becomes hard to read const MIN_LABEL_RADIUS = 40; // reject labels whose baseline turns more than this at a single segment joint. Catches // the sharp kinks in the curve. const MAX_KINK_ANGLE = Math.PI / 2; const MAX_KINK_COS = Math.cos( MAX_KINK_ANGLE ); // If characters are laid out on a zigzagging path then they can wind up closer than their character // advance with implies they should be. If the characters are closer than this ratio of their advances // then reject the label. const MIN_CHAR_SPACING_RATIO = 0.8; const _segIndices = []; const _segAlphas = []; const _vec = /* @__PURE__ */ new Vector3(); // trailing two glyph screen positions and edge vectors, reused for the three-point curvature estimate const _prevPos = /* @__PURE__ */ new Vector2(); const _prevPrevPos = /* @__PURE__ */ new Vector2(); const _dir = /* @__PURE__ */ new Vector2(); const _prevDir = /* @__PURE__ */ new Vector2(); const _ab = /* @__PURE__ */ new Vector2(); const _ac = /* @__PURE__ */ new Vector2(); const _bc = /* @__PURE__ */ new Vector2(); // A text anchor that lays on a give line and stores references to path from different LoDs, // choosing the best one to "snap" to. let annotationIndex = 0; export class TextAnchorAnnotation extends OccupancyAnnotation { get lat() { return this.getActiveReference().lat; } get lon() { return this.getActiveReference().lon; } get ready() { return this.getActiveReference().line.ready; } set ready( value ) {} get properties() { return this.getActiveReference().line.properties; } set properties( value ) {} get enabled() { return this.getActiveReference().line.enabled; } set enabled( value ) {} get text() { return this.getActiveReference().line.text; } constructor( id ) { super(); // ensure a unique id since we are deduping them separately // An id isn't really needed for the text anchor other than for sort stability and to // accommodate the annotation deduping in the screen annotation system. // TODO: consider removing the deduping from the screen occupation manager this.id = `${ id }_${ annotationIndex ++ }`; this.displayed = false; this.referencePaths = []; this._activeReference = null; // transient slot { i0, i1, alpha, lat, lon } the anchor is snapped to on the active line // while it drifts across an LoD swap, overriding its associated anchor slot. Cleared on the // next appearance (see onShown) so it re-derives evenly-spaced, surviving the fade-out first this._snapped = null; this._flippedTextDir = false; // local position & angle per character this.characterPositions = []; this.characterAngles = []; } // overrides // "force" places the characters at the current projection even when they don't fit, // used to keep a fading-out label laid out (see ScreenOccupationManager.refreshLayout) evaluate( handle, force = false ) { const { text } = this; if ( ! text ) { return false; } // TODO: update the "active reference" here to avoid iteration every frame const { line } = this.getActiveReference(); const { cumulativeLen } = line; if ( ! line.ready ) { return false; } if ( cumulativeLen.length < 2 ) { return false; } this._flippedTextDir = this._getTextDirection(); _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 this._layoutCharacters( handle, _segIndices, _segAlphas, force ); if ( ! this.valid && ! force ) { return false; } // it fits: commit occupancy marks, world positions, and baseline angles this._placeCharacters( handle, _segIndices, _segAlphas ); return true; } // determine the reading direction based on the positioning of the end points _getTextDirection() { const { line, i0, i1, alpha } = this.getActiveReference(); const { cumulativeLen, screenPositions, totalTextWidth } = 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 = totalTextWidth * 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. Sets the "valid" field indicating whether the current // characters can be displayed or not. // TODO: also reject foreshortened paths (tiny screen-space segments) _layoutCharacters( handle, outputIndices, outputAlphas, force = false ) { const { line, i0, i1, alpha } = this.getActiveReference(); const { cumulativeLen, screenPositions, totalTextWidth, characterWidths, characterRadius, text } = line; const anchorOffset = MathUtils.lerp( cumulativeLen[ i0 ], cumulativeLen[ i1 ], alpha ); const flip = this._flippedTextDir; this.valid = true; const pointCount = screenPositions.length; const totalLength = cumulativeLen[ cumulativeLen.length - 1 ]; const length = text.length; let seg = 0; let charCursor = 0; let prevAdvance = 0; for ( let i = 0; i < length; i ++ ) { // place each character's center along the arc by its advance, centered on the anchor const slot = flip ? length - 1 - i : i; const advance = characterWidths[ slot ]; const charCenter = charCursor + advance * 0.5 - totalTextWidth * 0.5; charCursor += advance; // absolute target position relative to the line const target = anchorOffset + charCenter; // the path is too short on screen to hold the whole string (forced layout extrapolates // past the ends instead of bailing) if ( target < 0 || target > totalLength ) { this.valid = false; if ( ! force ) break; } // advance to the segment containing "target" while ( seg < pointCount - 2 && cumulativeLen[ seg + 1 ] < target ) { seg ++; } const segNext = seg + 1; const segLength = cumulativeLen[ segNext ] - cumulativeLen[ seg ]; const segAlpha = segLength > 0 ? ( target - cumulativeLen[ seg ] ) / segLength : 0; const p0 = screenPositions[ seg ]; const p1 = screenPositions[ segNext ]; _vec.lerpVectors( p0, p1, segAlpha ); // off-screen in depth, or colliding with an already-placed annotation if ( _vec.z < 0 || _vec.z > 1 || handle.test( _vec.x, _vec.y, characterRadius ) ) { this.valid = false; if ( ! force ) break; } // check if the cursor hasn't moved enough from the old position and reject otherwise. this can // be rejected in cases where the path is zigzagging especially at shallow angles. if ( i > 0 ) { const dx = _vec.x - _prevPos.x; const dy = _vec.y - _prevPos.y; const disToCharSq = dx * dx + dy * dy; const minSpacing = ( advance + prevAdvance ) * 0.5 * MIN_CHAR_SPACING_RATIO; if ( disToCharSq < minSpacing * minSpacing ) { this.valid = false; if ( ! force ) break; } } // estimate local curvature from the last three glyph positions using Menger curvature / circumradius // and reject the label if the baseline bends too tightly if ( i >= 2 ) { // get the segments _ab.subVectors( _prevPos, _prevPrevPos ); _ac.subVectors( _vec, _prevPrevPos ); _bc.subVectors( _vec, _prevPos ); // curvature = 2 * area / ( |AB| * |BC| * |CA| ) = 1 / circumradius const area = Math.abs( _ab.cross( _ac ) ); const denom = _ab.length() * _bc.length() * _ac.length(); const curvature = denom > 0 ? 2 * area / denom : 0; // reject when the baseline curves tighter than the minimum readable radius if ( curvature > 1 / MIN_LABEL_RADIUS ) { this.valid = false; if ( ! force ) break; } } // reject sharp kinks: the turn between this character's segment direction and the // previous character's must stay under MAX_KINK_ANGLE. _dir.subVectors( p1, p0 ).normalize(); if ( i > 0 ) { const cosTurn = _dir.dot( _prevDir ); if ( cosTurn < MAX_KINK_COS ) { this.valid = false; if ( ! force ) break; } } _prevDir.copy( _dir ); prevAdvance = advance; _prevPrevPos.copy( _prevPos ); _prevPos.copy( _vec ); outputIndices[ slot ] = seg; outputAlphas[ slot ] = segAlpha; } } // commit a successful layout: mark occupancy and record a world-space position + baseline // angle per character, applying the reading-direction flip _placeCharacters( handle, segIndices, segAlphas ) { const { characterPositions, characterAngles, text, } = this; const { line } = this.getActiveReference(); const { screenPositions, positions, characterRadius } = line; const flip = this._flippedTextDir; const length = text.length; while ( characterPositions.length < length ) { characterPositions.push( new Vector3() ); } characterPositions.length = length; characterAngles.length = length; for ( let i = 0; i < length; i ++ ) { // the layout already applied the flip when storing per-character data, so index it // straight; flip only affects the baseline angle's sign below const index = segIndices[ i ]; const segAlpha = segAlphas[ i ]; const p0 = screenPositions[ index ]; const p1 = screenPositions[ index + 1 ]; handle.mark( p0.x + ( p1.x - p0.x ) * segAlpha, p0.y + ( p1.y - p0.y ) * segAlpha, characterRadius ); characterPositions[ i ].lerpVectors( positions[ index ], positions[ index + 1 ], segAlpha ); // baseline angle from the segment direction (screen space, y down), pointing in the // reading direction so glyphs stay upright after a flip const dx = ( p1.x - p0.x ) * ( flip ? - 1 : 1 ); const dy = ( p1.y - p0.y ) * ( flip ? - 1 : 1 ); characterAngles[ i ] = Math.atan2( dy, dx ); } } updateTransform( matrix, resolution, cameraPosition ) { // update the screen positions for shared line this.updateActiveReference(); this.getActiveReference().line.updateTransform( matrix, resolution, cameraPosition ); } // anchor functions isEmpty() { return this.referencePaths.length === 0; } hasLoD( lod ) { return this.referencePaths.find( ref => ref.line.lodLevel === lod ); } getPosition( pos ) { const { line, i0, i1, alpha } = this.getActiveReference(); return pos.lerpVectors( line.positions[ i0 ], line.positions[ i1 ], alpha ); } // the highest-LoD entry whose path is settled, used for placement - return the "snapped" or // active line reference getActiveReference() { return this._snapped ?? this._activeReference; } updateActiveReference() { const { referencePaths, _activeReference, displayed } = this; // pick the active line reference. The highest-LoD path is the desired "target" but until it settles // we hold the current LoD rather than hopping through intermediate LoDs as they settle. let result; const target = referencePaths[ 0 ] ?? null; if ( target && target.line.ready ) { // desired path settled result = target; } else if ( _activeReference && _activeReference.line.ready && ( referencePaths.includes( _activeReference ) || this.displayed ) ) { // desired path not settled yet: stay on the current LoD result = _activeReference; } else { result = target ?? _activeReference; } // when the active line changes under a displayed label, snap it onto the new line at the // nearest point so it stays coherent across the LoD swap instead of jumping to the new line's // associated anchor. if ( result && _activeReference && result !== _activeReference ) { if ( displayed ) { // only snap if it's visible const { lat, lon } = this._snapped ?? _activeReference; this._snapped = this._snapToLine( result.line, lat, lon ); } else { // otherwise remove the snapped reference this._snapped = null; } } this._activeReference = result; return result; } // find the nearest point on the given line in cartographic lat / lon to the supplied // position, returning a line-reference used to keep a displayed label coherent // when the active LoD swaps. _snapToLine( line, lat, lon ) { const { lat: lats, lon: lons } = line; if ( lats.length < 2 ) { return null; } let bestDist = Infinity; let bestI0 = 0; let bestI1 = 1; let bestAlpha = 0; let bestLat = lats[ 0 ]; let bestLon = lons[ 0 ]; for ( let i = 0, l = lats.length - 1; i < l; i ++ ) { const lat0 = lats[ i ]; const lon0 = lons[ i ]; const dLat = lats[ i + 1 ] - lat0; const dLon = lons[ i + 1 ] - lon0; // project the point onto the segment, clamped to its ends const lenSq = dLat * dLat + dLon * dLon; const t = lenSq > 0 ? MathUtils.clamp( ( ( lat - lat0 ) * dLat + ( lon - lon0 ) * dLon ) / lenSq, 0, 1 ) : 0; const projLat = lat0 + dLat * t; const projLon = lon0 + dLon * t; // calculate the distance const eLat = lat - projLat; const eLon = lon - projLon; const dist = eLat * eLat + eLon * eLon; if ( dist < bestDist ) { bestDist = dist; bestI0 = i; bestI1 = i + 1; bestAlpha = t; bestLat = projLat; bestLon = projLon; } } return { line, i0: bestI0, i1: bestI1, alpha: bestAlpha, lat: bestLat, lon: bestLon, }; } // clear the transient snapped slot when a fresh display begins so the label re-derives // from its evenly-spaced associated anchor. Deferred to the next appearance (rather than at // hide) so the snap survives the glyph fade-out and doesn't reflow the fading label. onShown() { this.displayed = true; this._snapped = null; } onHidden() { this.displayed = false; } // add a reference to the given line, associating this anchor with the provided anchor // position index addLine( line, slotIndex ) { // store the slot and its cartographic position on this specific path const slot = line.anchorPositions[ slotIndex ]; const { referencePaths } = this; referencePaths.push( { line, i0: slot.i0, i1: slot.i1, alpha: slot.alpha, lat: slot.lat, lon: slot.lon, } ); // sort in order from most important to least referencePaths.sort( ( a, b ) => { return b.line.lodLevel - a.line.lodLevel; } ); this.updateActiveReference(); } removeLine( line ) { const { referencePaths } = this; for ( let i = 0; i < referencePaths.length; i ++ ) { if ( referencePaths[ i ].line === line ) { referencePaths.splice( i, 1 ); i --; } } this.updateActiveReference(); } }