import * as THREE from 'three' ; import * as ethereal from 'ethereal' ; import { WebRenderer , WebLayer } from '../web-renderer' ; import * as domUtils from '../dom-utils' ; export interface WebLayer3DOptions { pixelRatio ? : number ; layerSeparation ? : number ; autoRefresh ? : boolean ; onLayerCreate ? ( layer ) : void ; onAfterRasterize ? ( layer ) : void ; } export type WebLayerHit = ReturnType < typeof WebLayer3D . prototype . hitTest > & { } ; const scratchVector = new THREE . Vector3 ( ) ; const scratchVector2 = new THREE . Vector3 ( ) ; const microtask = Promise . resolve ( ) ; const scratchBounds = new domUtils . Bounds ( ) ; const scratchBounds2 = new domUtils . Bounds ( ) ; export class WebLayer3DBase extends THREE . Object3D { constructor ( public element , public options = { } ) { super ( ) ; this . name = element . id ; this . layout . forceBoundsExclusion = true ; this . transitioner . duration = 1.2 ; this . transitioner . easing = ethereal . easing . easeInOut ; this . content . transitioner . duration = 1.2 ; this . content . transitioner . easing = ethereal . easing . easeInOut ; this . content . transitioner . matrixLocal . scale . start . setScalar ( 0.0001 ) ; this . add ( this . content ) ; this . add ( this . cursor ) ; this . cursor . visible = false ; this . contentMesh . visible = false ; this . contentMesh [ 'customDepthMaterial' ] = this . depthMaterial ; WebLayer3D . layersByElement . set ( this . element , this ) ; WebLayer3D . layersByMesh . set ( this . contentMesh , this ) ; } protected _webLayer = WebRenderer . getClosestLayer ( this . element ) ; textures = new Map < HTMLCanvasElement | HTMLVideoElement , THREE . Texture > ( ) ; get currentTexture ( ) { if ( this . _webLayer . element . tagName === 'VIDEO' ) { const video = this . _webLayer . element as HTMLVideoElement ; let t = this . textures . get ( video ) ; if ( ! t ) { t = new THREE . VideoTexture ( video ) ; t . wrapS = THREE . ClampToEdgeWrapping ; t . wrapT = THREE . ClampToEdgeWrapping ; t . minFilter = THREE . LinearFilter ; this . textures . set ( video , t ) ; } return t ; } const canvas = this . _webLayer . canvas ; let t = this . textures . get ( canvas ) ; if ( ! t ) { t = new THREE . Texture ( canvas ) ; t . wrapS = THREE . ClampToEdgeWrapping ; t . wrapT = THREE . ClampToEdgeWrapping ; t . minFilter = THREE . LinearFilter ; this . textures . set ( canvas , t ) ; } return t ; } content = new THREE . Object3D ( ) ; contentMesh = new THREE . Mesh ( WebLayer3D . GEOMETRY , new THREE . MeshBasicMaterial ( { transparent : true , alphaTest : 0.001 , opacity : 0 } ) ) ; cursor = new THREE . Object3D ( ) ; depthMaterial = new THREE . MeshDepthMaterial ( { depthPacking : THREE . RGBADepthPacking , alphaTest : 0.01 } as any ) ; target = new THREE . Object3D ( ) ; contentTarget = new THREE . Object3D ( ) ; contentOpacity = this . transitioner . add ( new ethereal . Transitionable ( { target : 0 , path : 'contentMesh.material.opacity' } ) ) ; get needsRefresh ( ) { return this . _webLayer . needsRefresh ; } set needsRefresh ( value ) { this . _webLayer . needsRefresh = value ; } /**
     * Get the hover state
     */ get hover ( ) { return WebRenderer . containsHover ( this . element ) ; } /**
     * Get the layer depth (distance from this layer's element and the parent layer's element)
     */ get depth ( ) { return this . _webLayer . depth ; } /**
     *
     */ get index ( ) { return this . parentLayer ? this . parentLayer . childLayers . indexOf ( this ) : 0 ; } /** If true, this layer needs to be removed from the scene */ get needsRemoval ( ) { return this . _webLayer . needsRemoval ; } get bounds ( ) { return this . _webLayer . bounds ; } get parentLayer ( ) : WebLayer3DBase | undefined { return ( this . _webLayer . parentLayer && WebLayer3D . layersByElement . get ( this . _webLayer . parentLayer . element ) ) ; } childLayers : WebLayer3DBase [ ] = [ ] ; /**
     * Specifies whether or not this layer's layout
     * should match the layout stored in the `target` object
     *
     * When set to `true`, the target layout should always be applied.
     * When set to `false`, the target layout should never be applied.
     * When set to `'auto'`, the target layout should only be applied
     * when the `parentLayer` is the same as the `parent` object.
     *
     * It is the responsibiltiy of the update callback
     * to follow these rules.
     *
     * Defaults to `auto`
     */ shouldApplyTargetLayout : true | false | 'auto' = 'auto' ; /**
     * Specifies whether or not the update callback should update
     * the `content` layout to match the layout stored in
     * the `contentTarget` object
     *
     * It is the responsibiltiy of the update callback
     * to follow these rules.
     *
     * Defaults to `true`
     */ shouldApplyContentTargetLayout = true ; private _lastTargetPosition = new THREE . Vector3 ( ) ; private _lastContentTargetScale = new THREE . Vector3 ( 0.01 , 0.01 , 0.01 ) ; refresh ( forceRefresh = false ) { if ( forceRefresh ) this . _webLayer . needsRefresh = true ; this . _webLayer . refresh ( ) ; this . childLayers . length = 0 ; for ( const c of this . _webLayer . childLayers ) { const child = WebLayer3D . getClosestLayerForElement ( c . element ) ; this . childLayers . push ( child ) ; child . refresh ( forceRefresh ) ; } this . _refreshVideoBounds ( ) ; this . _refreshTargetLayout ( ) ; this . _refreshMesh ( ) ; const childMaterial = this . contentMesh . material as THREE . MeshBasicMaterial ; const isHidden = childMaterial . opacity < 0.005 ; if ( isHidden ) this . contentMesh . visible = false ; else this . contentMesh . visible = true ; if ( this . needsRemoval && isHidden ) { if ( this . parent ) this . parent . remove ( this ) ; this . dispose ( ) ; } if ( WebLayer3D . shouldApplyTargetLayout ( this ) ) { this . position . copy ( this . target . position ) ; this . quaternion . copy ( this . target . quaternion ) ; this . scale . copy ( this . target . scale ) ; } if ( this . shouldApplyContentTargetLayout ) { this . content . position . copy ( this . contentTarget . position ) ; this . content . quaternion . copy ( this . contentTarget . quaternion ) ; this . content . scale . copy ( this . contentTarget . scale ) ; } } querySelector ( selector ) { const element = this . element . querySelector ( selector ) ; if ( element ) { return WebLayer3D . layersByElement . get ( element ) ; } return undefined ; } traverseParentLayers < T extends any [ ] > ( each , ... params ) { const parentLayer = this . parentLayer ; if ( parentLayer ) { parentLayer . traverseParentLayers ( each , ... params ) ; each ( parentLayer , ... params ) ; } } traverseLayers < T extends any [ ] > ( each , ... params ) { each ( this , ... params ) ; this . traverseChildLayers ( each , ... params ) ; } traverseChildLayers < T extends any [ ] > ( each , ... params ) { for ( const child of this . childLayers ) { child . traverseLayers ( each , ... params ) ; } return params ; } dispose ( ) { for ( const t of this . textures . values ( ) ) { t . dispose ( ) ; } this . contentMesh . geometry . dispose ( ) ; WebRenderer . disposeLayer ( this . _webLayer ) ; for ( const child of this . childLayers ) child . dispose ( ) ; } private _refreshVideoBounds ( ) { if ( this . element . nodeName === 'VIDEO' ) { const video = this . element as HTMLVideoElement ; const texture = this . currentTexture ; const computedStyle = getComputedStyle ( this . element ) ; const { objectFit } = computedStyle ; const { width : viewWidth , height : viewHeight } = this . bounds ; const { videoWidth , videoHeight } = video ; const videoRatio = videoWidth / videoHeight ; const viewRatio = viewWidth / viewHeight ; texture . center . set ( 0.5 , 0.5 ) ; switch ( objectFit ) { case 'none' : texture . repeat . set ( viewWidth / videoWidth , viewHeight / videoHeight ) . clampScalar ( 0 , 1 ) ; break ; case 'contain' : case 'scale-down' : texture . repeat . set ( 1 , 1 ) ; if ( viewRatio > videoRatio ) { const width = this . bounds . height * videoRatio || 0 ; this . bounds . left += ( this . bounds . width - width ) / 2 ; this . bounds . width = width ; } else { const height = this . bounds . width / videoRatio || 0 ; this . bounds . top += ( this . bounds . height - height ) / 2 ; this . bounds . height = height ; } break ; case 'cover' : texture . repeat . set ( viewWidth / videoWidth , viewHeight / videoHeight ) ; if ( viewRatio < videoRatio ) { const width = this . bounds . height * videoRatio || 0 ; this . bounds . left += ( this . bounds . width - width ) / 2 ; this . bounds . width = width ; } else { const height = this . bounds . width / videoRatio || 0 ; this . bounds . top += ( this . bounds . height - height ) / 2 ; this . bounds . height = height ; } break ; default : case 'fill' : texture . repeat . set ( 1 , 1 ) ; break ; } } } private _refreshTargetLayout ( ) { this . target . position . copy ( this . _lastTargetPosition ) ; this . target . scale . set ( 1 , 1 , 1 ) ; this . target . quaternion . set ( 0 , 0 , 0 , 1 ) ; this . contentTarget . position . set ( 0 , 0 , 0 ) ; this . contentTarget . scale . copy ( this . _lastContentTargetScale ) ; this . contentTarget . quaternion . set ( 0 , 0 , 0 , 1 ) ; if ( this . needsRemoval ) { this . contentOpacity . target = 0 ; return ; } const bounds = this . bounds ; if ( bounds . width === 0 || bounds . height === 0 || ! this . currentTexture . image ) { this . contentOpacity . target = 0 ; return ; } this . contentOpacity . target = 1 ; const width = bounds . width ; const height = bounds . height ; const parentBounds = this . parentLayer instanceof WebLayer3DBase ? this . parentLayer . bounds : domUtils . getViewportBounds ( scratchBounds ) ; const parentWidth = parentBounds . width ; const parentHeight = parentBounds . height ; const leftEdge = - parentWidth / 2 + width / 2 ; const topEdge = parentHeight / 2 - height / 2 ; const pixelSize = 1 / WebLayer3D . DEFAULT_PIXELS_PER_UNIT ; const sep = this . options . layerSeparation || WebLayer3D . DEFAULT_LAYER_SEPARATION ; this . target . position . set ( pixelSize * ( leftEdge + bounds . left ) , pixelSize * ( topEdge - bounds . top ) , this . depth * sep + ( this . parentLayer ? this . parentLayer . index * sep * 0.01 : 0 ) + this . index * sep * 0.001 ) ; this . contentTarget . scale . set ( Math . max ( pixelSize * width , 10e-6 ) , Math . max ( pixelSize * height , 10e-6 ) , 1 ) ; this . _lastTargetPosition . copy ( this . target . position ) ; this . _lastContentTargetScale . copy ( this . contentTarget . scale ) ; } private _refreshMesh ( ) { const mesh = this . contentMesh ; const texture = this . currentTexture ; if ( ! texture . image ) return ; const material = mesh . material as THREE . MeshBasicMaterial ; if ( material . map !== texture ) { material . map = texture ; material . needsUpdate = true ; this . depthMaterial [ 'map' ] = texture ; this . depthMaterial . needsUpdate = true ; } if ( ! mesh . parent ) { this . content . add ( mesh ) ; this . _refreshTargetLayout ( ) ; this . content . position . copy ( this . contentTarget . position ) ; this . content . scale . copy ( this . contentTarget . scale ) ; } mesh . renderOrder = this . depth + this . index * 0.001 ; } } /**
 * Transform a DOM tree into 3D layers.
 *
 * When an instance is created, a `layer` data-attribute is set on the
 * the passed DOM element to match this instance's Object3D id.
 * If the passed DOM element has an `id` attribute, this instance's Object3D name
 * will be set to match the element id.
 *
 * Child WebLayer3D instances can be specified with an empty `layer` data-attribute,
 * which will be set when the child WebLayer3D instance is created automatically.
 * The data-attribute can be specified added in HTML or dynamically:
 *  - `<div data-layer></div>`
 *  - `element.dataset.layer = ''`
 *
 * Additionally, the pixel ratio can be adjusted on each layer, individually:
 *  - `<div data-layer data-layer-pixel-ratio="0.5"></div>`
 *  - `element.dataset.layerPixelRatio = '0.5'`
 *
 * Finally, each layer can prerender multipe states specified as CSS classes delimited by spaces:
 *  - `<div data-layer data-layer-states="near far"></div>`
 *  - `element.dataset.layerStates = 'near far'`
 *
 * Each WebLayer3D will render each of its states with the corresponding CSS class applied to the element.
 * The texture state can be changed by alternating between the specified classes,
 * without requiring the DOM to be re-rendered. Setting a state on a parent layer does
 * not affect the state of a child layer.
 *
 * Every layer has an implicit `hover` state which can be mixed with any other declared state,
 * by using the appropriate CSS selector: `.near.hover` or `.far.hover`. Besides than the
 * `hover` state. The hover state is controlled by interaction rays, which can be provided
 * with the `interactionRays` property.
 *
 * Default dimensions: 1px = 0.001 world dimensions = 1mm (assuming meters)
 *     e.g., 500px width means 0.5meters
 */ export class WebLayer3D extends WebLayer3DBase { static domUtils = domUtils ; static layersByElement = new WeakMap < Element , WebLayer3DBase > ( ) ; static layersByMesh = new WeakMap < THREE . Mesh , WebLayer3DBase > ( ) ; static DEBUG_PERFORMANCE = false ; static LAYER_ATTRIBUTE = 'data-layer' ; static PIXEL_RATIO_ATTRIBUTE = 'data-layer-pixel-ratio' ; static STATES_ATTRIBUTE = 'data-layer-states' ; static HOVER_DEPTH_ATTRIBUTE = 'data-layer-hover-depth' ; private static DISABLE_TRANSFORMS_ATTRIBUTE = 'data-layer-disable-transforms' ; static DEFAULT_LAYER_SEPARATION = 0.001 ; static DEFAULT_PIXELS_PER_UNIT = 1000 ; static GEOMETRY = new THREE . PlaneGeometry ( 1 , 1 , 2 , 2 ) as THREE . Geometry ; static computeNaturalDistance ( projection , renderer ) { let projectionMatrix = projection as THREE . Matrix4 ; if ( ( projection as THREE . Camera ) . isCamera ) { projectionMatrix = ( projection as THREE . Camera ) . projectionMatrix ; } const pixelRatio = renderer . getPixelRatio ( ) ; const widthPixels = renderer . domElement . width / pixelRatio ; const width = WebLayer3D . DEFAULT_PIXELS_PER_UNIT * widthPixels ; const horizontalFOV = getFovs ( projectionMatrix ) . horizontal ; const naturalDistance = width / 2 / Math . tan ( horizontalFOV / 2 ) ; return naturalDistance ; } static UPDATE_DEFAULT = function ( layer , deltaTime = 1 ) { layer . transitioner . active = true ; layer . content . transitioner . active = true ; layer . transitioner . update ( deltaTime , false ) ; layer . content . transitioner . update ( deltaTime , false ) ; } ; static shouldApplyTargetLayout ( layer ) { const should = layer . shouldApplyTargetLayout ; if ( ( should as any ) === 'always' || should === true ) return true ; if ( ( should as any ) === 'never' || should === false ) return false ; if ( should === 'auto' && layer . parentLayer && layer . parent === layer . parentLayer ) return true ; return false ; } private static _updateInteractions ( rootLayer ) { rootLayer . updateWorldMatrix ( true , true ) ; rootLayer . traverseLayers ( WebLayer3D . _hideCursor ) ; WebRenderer . hoverTargetElements . clear ( ) ; for ( const ray of rootLayer . _interactionRays ) { rootLayer . _hitIntersections . length = 0 ; if ( ray instanceof THREE . Ray ) rootLayer . _raycaster . ray . copy ( ray ) ; else rootLayer . _raycaster . ray . set ( ray . getWorldPosition ( scratchVector ) , ray . getWorldDirection ( scratchVector2 ) ) ; rootLayer . _raycaster . intersectObject ( rootLayer , true , rootLayer . _hitIntersections ) ; for ( const intersection of rootLayer . _hitIntersections ) { let layer = WebLayer3D . layersByMesh . get ( intersection . object as any ) ; if ( layer && layer . contentOpacity . current !== 0 ) { layer . cursor . position . copy ( intersection . point ) ; layer . worldToLocal ( layer . cursor . position ) ; layer . cursor . visible = true ; while ( layer instanceof WebLayer3DBase ) { WebRenderer . hoverTargetElements . add ( layer . element ) ; layer = layer . parent as WebLayer3D ; } break ; } } } } private static async _scheduleRefresh ( rootLayer ) { await microtask ; rootLayer . refresh ( ) ; } private static _hideCursor = function ( layer ) { layer . cursor . visible = false ; } ; private _interactionRays = [ ] as Array < THREE . Ray | THREE . Object3D > ; private _raycaster = new THREE . Raycaster ( ) ; private _hitIntersections = this . _raycaster . intersectObjects ( [ ] ) ; constructor ( public element , public options = { } ) { super ( element , options ) ; this . _webLayer = WebRenderer . createLayerTree ( element , ( event , { target } ) => { if ( event === 'layercreated' ) { if ( target === this . element ) return ; const layer = new WebLayer3DBase ( target , this . options ) ; layer . parentLayer . add ( layer ) ; if ( this . options . onLayerCreate ) this . options . onLayerCreate ( layer ) ; } else if ( event === 'layerpainted' ) { const layer = WebRenderer . layers . get ( target ) ; const canvas = layer . canvas ; if ( ! canvas ) throw new Error ( 'missing canvas' ) ; const texture = WebLayer3D . layersByElement . get ( layer . element ) . currentTexture ; texture . image = canvas ; texture . needsUpdate = true ; } else if ( event === 'parentchanged' ) { const layer = WebLayer3D . layersByElement . get ( target ) ; layer . transitioner . parentTarget = layer . parentLayer ; } } ) ; if ( this . options . onLayerCreate ) this . options . onLayerCreate ( this ) ; this . refresh ( true ) ; } param rays set interactionRays ( rays ) { this . _interactionRays = rays ; } get interactionRays ( ) { return this . _interactionRays ; } param lerp param updateCallback update ( lerp = 1 , updateCallback = WebLayer3D . UPDATE_DEFAULT ) { if ( this . options . autoRefresh !== false ) WebLayer3D . _scheduleRefresh ( this ) ; this . updateWorldMatrix ( true , true ) ; this . traverseLayers ( updateCallback , lerp ) ; WebLayer3D . _updateInteractions ( this ) ; } static getLayerForQuery ( selector ) { const element = document . querySelector ( selector ) ; return WebLayer3D . layersByElement . get ( element ) ; } static getClosestLayerForElement ( element ) { const closestLayerElement = element && ( element . closest ( `[${ WebLayer3D . LAYER_ATTRIBUTE }]` ) as HTMLElement ) ; return WebLayer3D . layersByElement . get ( closestLayerElement ) ; } hitTest ( ray ) { const raycaster = this . _raycaster ; const intersections = this . _hitIntersections ; const meshMap = WebLayer3D . layersByMesh ; raycaster . ray . copy ( ray ) ; intersections . length = 0 ; raycaster . intersectObject ( this , true , intersections ) ; for ( const intersection of intersections ) { const layer = meshMap ! . get ( intersection . object as any ) ; if ( ! layer ) continue ; const layerBoundingRect = domUtils . getBounds ( layer . element , scratchBounds ) ; if ( ! layerBoundingRect . width || ! layerBoundingRect . height ) continue ; let target = layer . element as HTMLElement ; const clientX = intersection . uv ! . x * layerBoundingRect . width ; const clientY = ( 1 - intersection . uv ! . y ) * layerBoundingRect . height ; domUtils . traverseChildElements ( layer . element , el => { if ( ! target . contains ( el ) ) return false ; const elementBoundingRect = domUtils . getBounds ( el , scratchBounds2 ) ; const offsetLeft = elementBoundingRect . left - layerBoundingRect . left ; const offsetTop = elementBoundingRect . top - layerBoundingRect . top ; const { width , height } = elementBoundingRect ; const offsetRight = offsetLeft + width ; const offsetBottom = offsetTop + height ; if ( clientX > offsetLeft && clientX < offsetRight && clientY > offsetTop && clientY < offsetBottom ) { target = el as HTMLElement ; return true ; } return false ; } ) ; return { layer , intersection , target } ; } return undefined ; } } function arraySubtract < T > ( a , b ) { const result = [ ] as T [ ] ; for ( const item of a ) { if ( ! b . includes ( item ) ) result . push ( item ) ; } return result ; } class CameraFOVs { top = 0 ; left = 0 ; bottom = 0 ; right = 0 ; horizontal = 0 ; vertical = 0 ; } const _fovs = new CameraFOVs ( ) ; const _getFovsMatrix = new THREE . Matrix4 ( ) ; const _getFovsVector = new THREE . Vector3 ( ) ; const FORWARD = new THREE . Vector3 ( 0 , 0 , - 1 ) ; function getFovs ( projectionMatrix ) { const out = _fovs ; const invProjection = _getFovsMatrix . getInverse ( projectionMatrix , true ) ; const vec = _getFovsVector ; out . left = vec . set ( - 1 , 0 , - 1 ) . applyMatrix4 ( invProjection ) . angleTo ( FORWARD ) ; out . right = vec . set ( 1 , 0 , - 1 ) . applyMatrix4 ( invProjection ) . angleTo ( FORWARD ) ; out . top = vec . set ( 0 , 1 , - 1 ) . applyMatrix4 ( invProjection ) . angleTo ( FORWARD ) ; out . bottom = vec . set ( 0 , - 1 , - 1 ) . applyMatrix4 ( invProjection ) . angleTo ( FORWARD ) ; out . horizontal = out . right + out . left ; out . vertical = out . top + out . bottom ; return out ; }