From 67f847da82f8ada2b4383ea9593911a915b2969d Mon Sep 17 00:00:00 2001 From: Oscar Lorentzon Date: Fri, 31 Jan 2025 15:29:41 -0700 Subject: [PATCH] feat: add back slider component --- declarations/mapillary.js.flow | 132 +++ examples/debug/index.html | 19 + src/component/ComponentName.ts | 1 + .../interfaces/SliderConfiguration.ts | 108 +++ src/component/slider/SliderComponent.ts | 760 ++++++++++++++++++ src/component/slider/SliderDOMRenderer.ts | 141 ++++ src/component/slider/SliderGLRenderer.ts | 385 +++++++++ src/component/slider/SliderInterfaces.ts | 22 + src/component/slider/SliderMeshFactory.ts | 560 +++++++++++++ src/component/slider/SliderMeshScene.ts | 140 ++++ src/component/slider/SliderShaders.ts | 52 ++ src/component/slider/SliderTransform.ts | 722 +++++++++++++++++ .../SliderBBoxProjectorShaderMaterial.ts | 8 + .../slider/shaders/fisheye.fragment.glsl.ts | 49 ++ .../slider/shaders/fisheye.vertex.glsl.ts | 15 + .../shaders/fisheye_curtain.fragment.glsl.ts | 41 + .../shaders/fisheye_curtain.vertex.glsl.ts | 12 + .../shaders/perspective.fragment.glsl.ts | 43 + .../slider/shaders/perspective.vertex.glsl.ts | 15 + .../perspective_curtain.fragment.glsl.ts | 37 + .../perspective_curtain.vertex.glsl.ts | 12 + .../perspective_distorted.fragment.glsl.ts | 23 + .../perspective_distorted.vertex.glsl.ts | 12 + ...pective_distorted_curtain.fragment.glsl.ts | 24 + ...rspective_distorted_curtain.vertex.glsl.ts | 12 + .../slider/shaders/spherical.fragment.glsl.ts | 26 + .../slider/shaders/spherical.vertex.glsl.ts | 15 + .../spherical_curtain.fragment.glsl.ts | 34 + .../shaders/spherical_curtain.vertex.glsl.ts | 12 + src/external/component.ts | 8 + src/mapillary.ts | 2 + src/viewer/ComponentController.ts | 1 + src/viewer/Viewer.ts | 4 + src/viewer/options/ComponentOptions.ts | 10 + styles/slider.css | 141 ++++ test/viewer/ComponentController.test.ts | 2 + 36 files changed, 3600 insertions(+) create mode 100644 src/component/interfaces/SliderConfiguration.ts create mode 100644 src/component/slider/SliderComponent.ts create mode 100644 src/component/slider/SliderDOMRenderer.ts create mode 100644 src/component/slider/SliderGLRenderer.ts create mode 100644 src/component/slider/SliderInterfaces.ts create mode 100644 src/component/slider/SliderMeshFactory.ts create mode 100644 src/component/slider/SliderMeshScene.ts create mode 100644 src/component/slider/SliderShaders.ts create mode 100644 src/component/slider/SliderTransform.ts create mode 100644 src/component/slider/interfaces/SliderBBoxProjectorShaderMaterial.ts create mode 100644 src/component/slider/shaders/fisheye.fragment.glsl.ts create mode 100644 src/component/slider/shaders/fisheye.vertex.glsl.ts create mode 100644 src/component/slider/shaders/fisheye_curtain.fragment.glsl.ts create mode 100644 src/component/slider/shaders/fisheye_curtain.vertex.glsl.ts create mode 100644 src/component/slider/shaders/perspective.fragment.glsl.ts create mode 100644 src/component/slider/shaders/perspective.vertex.glsl.ts create mode 100644 src/component/slider/shaders/perspective_curtain.fragment.glsl.ts create mode 100644 src/component/slider/shaders/perspective_curtain.vertex.glsl.ts create mode 100644 src/component/slider/shaders/perspective_distorted.fragment.glsl.ts create mode 100644 src/component/slider/shaders/perspective_distorted.vertex.glsl.ts create mode 100644 src/component/slider/shaders/perspective_distorted_curtain.fragment.glsl.ts create mode 100644 src/component/slider/shaders/perspective_distorted_curtain.vertex.glsl.ts create mode 100644 src/component/slider/shaders/spherical.fragment.glsl.ts create mode 100644 src/component/slider/shaders/spherical.vertex.glsl.ts create mode 100644 src/component/slider/shaders/spherical_curtain.fragment.glsl.ts create mode 100644 src/component/slider/shaders/spherical_curtain.vertex.glsl.ts create mode 100644 styles/slider.css diff --git a/declarations/mapillary.js.flow b/declarations/mapillary.js.flow index 2e4ecb1a8..0ede2f357 100644 --- a/declarations/mapillary.js.flow +++ b/declarations/mapillary.js.flow @@ -2431,6 +2431,85 @@ export type SequenceConfiguration = { ... } & ComponentConfiguration; +/** + * Enumeration for slider mode. + * @enum {number} * + * @readonly + * @description Modes for specifying how transitions + * between images are performed in slider mode. Only + * applicable when the slider component determines + * that transitions with motion is possilble. When it + * is not, the stationary mode will be applied. + */ +declare var SliderConfigurationMode: {| + +Motion: 0, // 0 + +Stationary: 1, // 1 +|}; +/** + * Interface for configuration of slider ids. + * @interface + */ +export interface SliderConfigurationIds { + /** + * Id for the image plane in the background. + */ + background: string; + /** + * Id for the image plane in the foreground. + */ + foreground: string; +} +/** + * Interface for configuration of slider component. + * @interface ```js + * var viewer = new Viewer({ + * ... + * component: { + * slider: { + * initialPosition: 0.5, + * ids: { + * background: '', + * foreground: '', + * }, + * sliderVisible: true, + * }, + * }, + * ... + * }); + * ``` + */ +export type SliderConfiguration = { + /** + * Initial position of the slider on the interval [0, 1]. + * @description Configures the initial position of the slider. + * The inital position value will be used when the component + * is activated. + * @default 1 + */ + initialPosition?: number, + /** + * Slider image ids. + * @description Configures the component to show the image + * planes for the supplied image ids in the foreground + * and the background. + */ + ids?: SliderConfigurationIds, + /** + * Value indicating whether the slider should be visible. + * @description Set the value controlling if the + * slider is visible. + * @default true + */ + sliderVisible?: boolean, + /** + * Mode used for image pair transitions. + * @description Configures the mode for transitions between + * image pairs. + */ + mode?: $Values, + ... +} & ComponentConfiguration; + declare var CameraVisualizationMode: {| +Hidden: 0, // 0 +Homogeneous: 1, // 1 @@ -2724,6 +2803,13 @@ export type ComponentOptions = { */ sequence?: boolean | SequenceConfiguration; + /** + * Show a slider for transitioning between image planes. + * @description Requires WebGL support. + * @default false + */ + slider?: boolean | SliderConfiguration; + /** * Enable an interface for showing spatial data in the viewer. * @description Requires WebGL support. @@ -3529,6 +3615,7 @@ export type ComponentName = | "pointer" | "popup" | "sequence" + | "slider" | "spatial" | "tag" | "zoom"; @@ -4031,6 +4118,10 @@ declare class Viewer implements IEventEmitter, IViewer { * how the camera is controlled when the viewer * receives pointer and keyboard input. * + * Changing the camera control mode is not possible + * when the slider component is active and attempts + * to do so will be ignored. + * * @param {$Values< typeof CameraControls>} controls - Camera control mode. @@ -6624,6 +6715,45 @@ declare class SequenceComponent extends Component { _getDefaultConfiguration(): SequenceConfiguration; } +/** + * @class SliderComponent + * @classdesc Component for comparing pairs of images. Renders + * a slider for adjusting the curtain of the first image. + * + * Deactivate the sequence, direction and image plane + * components when activating the slider component to avoid + * interfering UI elements. + * + * To retrive and use the slider component + * @example ```js + * var viewer = new Viewer({ ... }); + * + * viewer.deactivateComponent("image"); + * viewer.deactivateComponent("direction"); + * viewer.deactivateComponent("sequence"); + * + * viewer.activateComponent("slider"); + * + * var sliderComponent = viewer.getComponent("slider"); + * ``` + */ +declare class SliderComponent extends Component { + static componentName: string; + + /** + * @ignore + */ + constructor( + name: string, + container: Container, + navigator: Navigator, + viewportCoords?: ViewportCoords + ): this; + _activate(): void; + _deactivate(): void; + _getDefaultConfiguration(): SliderConfiguration; +} + declare class SpatialComponent extends Component { static componentName: string; @@ -8193,6 +8323,8 @@ declare export { Shader, ShaderChunk, SimpleMarker, + SliderComponent, + SliderConfigurationMode, SpatialComponent, SpotTag, Tag, diff --git a/examples/debug/index.html b/examples/debug/index.html index 0f9a8ca3c..39e120922 100644 --- a/examples/debug/index.html +++ b/examples/debug/index.html @@ -140,6 +140,25 @@ viewer.activateComponent("cache"); sequenceComponent.stop(); }); + + let sliderActive = false; + addButton("Slider", () => { + if (sliderActive) { + viewer.activateComponent("image"); + viewer.activateComponent("direction"); + viewer.activateComponent("sequence"); + viewer.activateComponent("keyboard"); + viewer.deactivateComponent("slider"); + sliderActive = false; + } else { + viewer.activateComponent("slider"); + viewer.deactivateComponent("image"); + viewer.deactivateComponent("direction"); + viewer.deactivateComponent("sequence"); + viewer.deactivateComponent("keyboard"); + sliderActive = true; + } + }); } diff --git a/src/component/ComponentName.ts b/src/component/ComponentName.ts index 2db8ec63e..4dc7b11fa 100644 --- a/src/component/ComponentName.ts +++ b/src/component/ComponentName.ts @@ -10,6 +10,7 @@ export type ComponentName = | "pointer" | "popup" | "sequence" + | "slider" | "spatial" | "tag" | "zoom"; diff --git a/src/component/interfaces/SliderConfiguration.ts b/src/component/interfaces/SliderConfiguration.ts new file mode 100644 index 000000000..2979ecd5b --- /dev/null +++ b/src/component/interfaces/SliderConfiguration.ts @@ -0,0 +1,108 @@ +import { ComponentConfiguration } from "./ComponentConfiguration"; +/** + * Enumeration for slider mode. + * + * @enum {number} + * @readonly + * + * @description Modes for specifying how transitions + * between images are performed in slider mode. Only + * applicable when the slider component determines + * that transitions with motion is possilble. When it + * is not, the stationary mode will be applied. + */ +export enum SliderConfigurationMode { + /** + * Transitions with motion. + * + * @description The slider component moves the + * camera between the image origins. + * + * In this mode it is not possible to zoom or pan. + * + * The slider component falls back to stationary + * mode when it determines that the pair of images + * does not have a strong enough relation. + */ + Motion, + /** + * Stationary transitions. + * + * @description The camera is stationary. + * + * In this mode it is possible to zoom and pan. + */ + Stationary, +} +/** + * Interface for configuration of slider ids. + * + * @interface + */ +export interface SliderConfigurationIds { + /** + * Id for the image plane in the background. + */ + background: string; + /** + * Id for the image plane in the foreground. + */ + foreground: string; +} +/** + * Interface for configuration of slider component. + * + * @interface + * ```js + * var viewer = new Viewer({ + * ... + * component: { + * slider: { + * initialPosition: 0.5, + * ids: { + * background: '', + * foreground: '', + * }, + * sliderVisible: true, + * }, + * }, + * ... + * }); + * ``` + */ +export interface SliderConfiguration extends ComponentConfiguration { + /** + * Initial position of the slider on the interval [0, 1]. + * + * @description Configures the initial position of the slider. + * The inital position value will be used when the component + * is activated. + * + * @default 1 + */ + initialPosition?: number; + /** + * Slider image ids. + * + * @description Configures the component to show the image + * planes for the supplied image ids in the foreground + * and the background. + */ + ids?: SliderConfigurationIds; + /** + * Value indicating whether the slider should be visible. + * + * @description Set the value controlling if the + * slider is visible. + * + * @default true + */ + sliderVisible?: boolean; + /** + * Mode used for image pair transitions. + * + * @description Configures the mode for transitions between + * image pairs. + */ + mode?: SliderConfigurationMode; +} diff --git a/src/component/slider/SliderComponent.ts b/src/component/slider/SliderComponent.ts new file mode 100644 index 000000000..6f6c72db9 --- /dev/null +++ b/src/component/slider/SliderComponent.ts @@ -0,0 +1,760 @@ +import * as THREE from "three"; +import { + combineLatest as observableCombineLatest, + concat as observableConcat, + empty as observableEmpty, + zip as observableZip, + Observable, + Subscription, + Subject, +} from "rxjs"; +import { + catchError, + debounceTime, + distinctUntilChanged, + filter, + first, + map, + pairwise, + publishReplay, + refCount, + scan, + skipWhile, + startWith, + switchMap, + withLatestFrom, +} from "rxjs/operators"; +import { + SliderImages, + SliderCombination, + GLRendererOperation, + PositionLookat, +} from "./SliderInterfaces"; +import { Image } from "../../graph/Image"; +import { Container } from "../../viewer/Container"; +import { Navigator } from "../../viewer/Navigator"; +import { Spatial } from "../../geo/Spatial"; +import { ViewportCoords } from "../../geo/ViewportCoords"; +import { RenderPass } from "../../render/RenderPass"; +import { GLRenderHash } from "../../render/interfaces/IGLRenderHash"; +import { ViewportSize } from "../../render/interfaces/ViewportSize"; +import { VirtualNodeHash } from "../../render/interfaces/VirtualNodeHash"; +import { RenderCamera } from "../../render/RenderCamera"; +import { IAnimationState } from "../../state/interfaces/IAnimationState"; +import { AnimationFrame } from "../../state/interfaces/AnimationFrame"; +import { State } from "../../state/State"; +import { TileLoader } from "../../tile/TileLoader"; +import { TileStore } from "../../tile/TileStore"; +import { TileBoundingBox } from "../../tile/interfaces/TileBoundingBox"; +import { TileRegionOfInterest } + from "../../tile/interfaces/TileRegionOfInterest"; +import { RegionOfInterestCalculator } + from "../../tile/RegionOfInterestCalculator"; +import { TextureProvider } from "../../tile/TextureProvider"; +import { Component } from "../Component"; +import { + SliderConfiguration, + SliderConfigurationMode, +} from "../interfaces/SliderConfiguration"; +import { Transform } from "../../geo/Transform"; +import { SliderDOMRenderer } from "./SliderDOMRenderer"; +import { isSpherical } from "../../geo/Geo"; +import { ComponentName } from "../ComponentName"; +import { SliderGLRenderer } from "./SliderGLRenderer"; +/** + * @class SliderComponent + * + * @classdesc Component for comparing pairs of images. Renders + * a slider for adjusting the curtain of the first image. + * + * Deactivate the sequence, direction and image plane + * components when activating the slider component to avoid + * interfering UI elements. + * + * To retrive and use the slider component + * + * @example + * ```js + * var viewer = new Viewer({ ... }); + * + * viewer.deactivateComponent("image"); + * viewer.deactivateComponent("direction"); + * viewer.deactivateComponent("sequence"); + * + * viewer.activateComponent("slider"); + * + * var sliderComponent = viewer.getComponent("slider"); + * ``` + */ +export class SliderComponent extends Component { + public static componentName: ComponentName = "slider"; + private _viewportCoords: ViewportCoords; + private _domRenderer: SliderDOMRenderer; + private _imageTileLoader: TileLoader; + private _roiCalculator: RegionOfInterestCalculator; + private _spatial: Spatial; + private _glRendererOperation$: Subject; + private _glRenderer$: Observable; + private _glRendererCreator$: Subject; + private _glRendererDisposer$: Subject; + private _waitSubscription: Subscription; + /** @ignore */ + constructor( + name: string, + container: Container, + navigator: Navigator, + viewportCoords?: ViewportCoords) { + super(name, container, navigator); + this._viewportCoords = !!viewportCoords ? viewportCoords : new ViewportCoords(); + this._domRenderer = new SliderDOMRenderer(container); + this._imageTileLoader = new TileLoader(navigator.api); + this._roiCalculator = new RegionOfInterestCalculator(); + this._spatial = new Spatial(); + this._glRendererOperation$ = new Subject(); + this._glRendererCreator$ = new Subject(); + this._glRendererDisposer$ = new Subject(); + this._glRenderer$ = this._glRendererOperation$.pipe( + scan( + (glRenderer: SliderGLRenderer, operation: GLRendererOperation): SliderGLRenderer => { + return operation(glRenderer); + }, + null), + filter( + (glRenderer: SliderGLRenderer): boolean => { + return glRenderer != null; + }), + distinctUntilChanged( + undefined, + (glRenderer: SliderGLRenderer): number => { + return glRenderer.frameId; + })); + this._glRendererCreator$.pipe( + map( + (): GLRendererOperation => { + return (glRenderer: SliderGLRenderer): SliderGLRenderer => { + if (glRenderer != null) { + throw new Error("Multiple slider states can not be created at the same time"); + } + return new SliderGLRenderer(); + }; + })) + .subscribe(this._glRendererOperation$); + this._glRendererDisposer$.pipe( + map( + (): GLRendererOperation => { + return (glRenderer: SliderGLRenderer): SliderGLRenderer => { + glRenderer.dispose(); + return null; + }; + })) + .subscribe(this._glRendererOperation$); + } + protected _activate(): void { + const subs = this._subscriptions; + subs.push(this._domRenderer.mode$ + .subscribe( + (mode: SliderConfigurationMode): void => { + this.configure({ mode }); + })); + subs.push(this._glRenderer$.pipe( + map( + (glRenderer: SliderGLRenderer): GLRenderHash => { + let renderHash: GLRenderHash = { + name: this._name, + renderer: { + frameId: glRenderer.frameId, + needsRender: glRenderer.needsRender, + render: glRenderer.render.bind(glRenderer), + pass: RenderPass.Background, + }, + }; + return renderHash; + })) + .subscribe(this._container.glRenderer.render$)); + const position$ = observableConcat( + this.configuration$.pipe( + map( + (configuration: SliderConfiguration): number => { + return configuration.initialPosition != null ? + configuration.initialPosition : 1; + }), + first()), + this._domRenderer.position$); + const mode$ = this.configuration$.pipe( + map( + (configuration: SliderConfiguration): SliderConfigurationMode => { + return configuration.mode; + }), + distinctUntilChanged()); + const motionless$ = this._navigator.stateService.currentState$.pipe( + map( + (frame: AnimationFrame): boolean => { + return frame.state.motionless; + }), + distinctUntilChanged()); + const spherical$ = this._navigator.stateService.currentState$.pipe( + map( + (frame: AnimationFrame): boolean => { + return isSpherical(frame.state.currentImage.cameraType); + }), + distinctUntilChanged()); + const sliderVisible$ = observableCombineLatest( + this._configuration$.pipe( + map( + (configuration: SliderConfiguration): boolean => { + return configuration.sliderVisible; + })), + this._navigator.stateService.currentState$.pipe( + map( + (frame: AnimationFrame): boolean => { + return !(frame.state.currentImage == null || + frame.state.previousImage == null || + (isSpherical( + frame.state.currentImage.cameraType) && + !isSpherical( + frame.state.previousImage.cameraType))); + }), + distinctUntilChanged())).pipe( + map( + ([sliderVisible, enabledState]: [boolean, boolean]): boolean => { + return sliderVisible && enabledState; + }), + distinctUntilChanged()); + this._waitSubscription = observableCombineLatest( + mode$, + motionless$, + spherical$, + sliderVisible$).pipe( + withLatestFrom(this._navigator.stateService.state$)) + .subscribe( + ([[mode, motionless, spherical, sliderVisible], state]: + [[SliderConfigurationMode, boolean, boolean, boolean], State]): void => { + const interactive: boolean = sliderVisible && + (motionless || + mode === SliderConfigurationMode.Stationary || + spherical); + if (interactive && state !== State.WaitingInteractively) { + this._navigator.stateService.waitInteractively(); + } else if (!interactive && state !== State.Waiting) { + this._navigator.stateService.wait(); + } + }); + subs.push(observableCombineLatest( + position$, + mode$, + motionless$, + spherical$, + sliderVisible$) + .subscribe( + ([position, mode, motionless, spherical]: [number, SliderConfigurationMode, boolean, boolean, boolean]): void => { + if (motionless || mode === SliderConfigurationMode.Stationary || spherical) { + this._navigator.stateService.moveTo(1); + } else { + this._navigator.stateService.moveTo(position); + } + })); + subs.push(observableCombineLatest( + position$, + mode$, + motionless$, + spherical$, + sliderVisible$, + this._container.renderService.size$).pipe( + map( + ([position, mode, motionless, spherical, sliderVisible]: + [number, SliderConfigurationMode, boolean, boolean, boolean, ViewportSize]): VirtualNodeHash => { + return { + name: this._name, + vNode: this._domRenderer.render(position, mode, motionless, spherical, sliderVisible), + }; + })) + .subscribe(this._container.domRenderer.render$)); + this._glRendererCreator$.next(null); + subs.push(observableCombineLatest( + position$, + spherical$, + sliderVisible$, + this._container.renderService.renderCamera$, + this._navigator.stateService.currentTransform$).pipe( + map( + ([position, spherical, visible, render, transform]: [number, boolean, boolean, RenderCamera, Transform]): number => { + if (!spherical) { + return visible ? position : 1; + } + const basicMin: number[] = this._viewportCoords.viewportToBasic(-1.15, 0, transform, render.perspective); + const basicMax: number[] = this._viewportCoords.viewportToBasic(1.15, 0, transform, render.perspective); + const shiftedMax: number = basicMax[0] < basicMin[0] ? basicMax[0] + 1 : basicMax[0]; + const basicPosition: number = basicMin[0] + position * (shiftedMax - basicMin[0]); + return basicPosition > 1 ? basicPosition - 1 : basicPosition; + }), + map( + (position: number): GLRendererOperation => { + return (glRenderer: SliderGLRenderer): SliderGLRenderer => { + glRenderer.updateCurtain(position); + return glRenderer; + }; + })) + .subscribe(this._glRendererOperation$)); + subs.push(observableCombineLatest( + this._navigator.stateService.currentState$, + mode$).pipe( + map( + ([frame, mode]: [AnimationFrame, SliderConfigurationMode]): GLRendererOperation => { + return (glRenderer: SliderGLRenderer): SliderGLRenderer => { + glRenderer.update(frame, mode); + return glRenderer; + }; + })) + .subscribe(this._glRendererOperation$)); + subs.push(this._configuration$.pipe( + filter( + (configuration: SliderConfiguration): boolean => { + return configuration.ids != null; + }), + switchMap( + (configuration: SliderConfiguration): Observable => { + return observableZip( + observableZip( + this._catchCacheImage$( + configuration.ids.background), + this._catchCacheImage$( + configuration.ids.foreground)).pipe( + map( + (images: [Image, Image]) + : SliderImages => { + return { background: images[0], foreground: images[1] }; + })), + this._navigator.stateService.currentState$.pipe(first())).pipe( + map( + (nf: [SliderImages, AnimationFrame]): SliderCombination => { + return { images: nf[0], state: nf[1].state }; + })); + })) + .subscribe( + (co: SliderCombination): void => { + if (co.state.currentImage != null && + co.state.previousImage != null && + co.state.currentImage.id === co.images.foreground.id && + co.state.previousImage.id === co.images.background.id) { + return; + } + if (co.state.currentImage.id === co.images.background.id) { + this._navigator.stateService.setImages([co.images.foreground]); + return; + } + if (co.state.currentImage.id === co.images.foreground.id && + co.state.trajectory.length === 1) { + this._navigator.stateService.prependImages([co.images.background]); + return; + } + this._navigator.stateService.setImages([co.images.background]); + this._navigator.stateService.setImages([co.images.foreground]); + }, + (e: Error): void => { + console.error(e); + })); + const textureProvider$ = + this._container.configurationService.imageTiling$.pipe( + switchMap( + (active): Observable => { + return active ? + this._navigator.stateService.currentState$ : + new Subject(); + }), + distinctUntilChanged( + undefined, + (frame: AnimationFrame): string => { + return frame.state.currentImage.id; + }), + withLatestFrom( + this._container.glRenderer.webGLRenderer$, + this._container.renderService.size$), + map( + ([frame, renderer, size]: [AnimationFrame, THREE.WebGLRenderer, ViewportSize]): TextureProvider => { + const state: IAnimationState = frame.state; + const viewportSize: number = Math.max(size.width, size.height); + const currentImage: Image = state.currentImage; + const currentTransform: Transform = state.currentTransform; + const tileSize: number = viewportSize > 2048 ? 2048 : viewportSize > 1024 ? 1024 : 512; + return new TextureProvider( + currentImage.id, + currentTransform.basicWidth, + currentTransform.basicHeight, + currentImage.image, + this._imageTileLoader, + new TileStore(), + renderer); + }), + publishReplay(1), + refCount()); + subs.push(textureProvider$.subscribe(() => { /*noop*/ })); + subs.push(textureProvider$.pipe( + map( + (provider: TextureProvider): GLRendererOperation => { + return (renderer: SliderGLRenderer): SliderGLRenderer => { + renderer.setTextureProvider(provider.id, provider); + return renderer; + }; + })) + .subscribe(this._glRendererOperation$)); + subs.push(textureProvider$.pipe( + pairwise()) + .subscribe( + (pair: [TextureProvider, TextureProvider]): void => { + let previous: TextureProvider = pair[0]; + previous.abort(); + })); + const roiTrigger$ = + this._container.configurationService.imageTiling$.pipe( + switchMap( + (active): Observable<[RenderCamera, ViewportSize]> => { + return active ? + observableCombineLatest( + this._container.renderService.renderCameraFrame$, + this._container.renderService.size$.pipe(debounceTime(250))) : + new Subject(); + }), + map( + ([camera, size]: [RenderCamera, ViewportSize]): PositionLookat => { + return [ + camera.camera.position.clone(), + camera.camera.lookat.clone(), + camera.zoom.valueOf(), + size.height.valueOf(), + size.width.valueOf()]; + }), + pairwise(), + skipWhile( + (pls: [PositionLookat, PositionLookat]): boolean => { + return pls[1][2] - pls[0][2] < 0 || pls[1][2] === 0; + }), + map( + (pls: [PositionLookat, PositionLookat]): boolean => { + let samePosition: boolean = pls[0][0].equals(pls[1][0]); + let sameLookat: boolean = pls[0][1].equals(pls[1][1]); + let sameZoom: boolean = pls[0][2] === pls[1][2]; + let sameHeight: boolean = pls[0][3] === pls[1][3]; + let sameWidth: boolean = pls[0][4] === pls[1][4]; + return samePosition && sameLookat && sameZoom && sameHeight && sameWidth; + }), + distinctUntilChanged(), + filter( + (stalled: boolean): boolean => { + return stalled; + }), + switchMap( + (): Observable => { + return this._container.renderService.renderCameraFrame$.pipe( + first()); + }), + withLatestFrom( + this._container.renderService.size$, + this._navigator.stateService.currentTransform$)); + subs.push(textureProvider$.pipe( + switchMap( + (provider: TextureProvider): Observable<[TileRegionOfInterest, TextureProvider]> => { + return roiTrigger$.pipe( + map( + ([camera, size, transform]: [RenderCamera, ViewportSize, Transform]): + [TileRegionOfInterest, TextureProvider] => { + return [ + this._roiCalculator.computeRegionOfInterest(camera, size, transform), + provider, + ]; + })); + }), + filter( + (args: [TileRegionOfInterest, TextureProvider]): boolean => { + return !args[1].disposed; + })) + .subscribe( + (args: [TileRegionOfInterest, TextureProvider]): void => { + let roi: TileRegionOfInterest = args[0]; + let provider: TextureProvider = args[1]; + provider.setRegionOfInterest(roi); + })); + const hasTexture$ = textureProvider$.pipe( + switchMap( + (provider: TextureProvider): Observable => { + return provider.hasTexture$; + }), + startWith(false), + publishReplay(1), + refCount()); + subs.push(hasTexture$.subscribe(() => { /*noop*/ })); + const textureProviderPrev$ = + this._container.configurationService.imageTiling$.pipe( + switchMap( + (active): Observable => { + return active ? + this._navigator.stateService.currentState$ : + new Subject(); + }), + filter( + (frame: AnimationFrame): boolean => { + return !!frame.state.previousImage; + }), + distinctUntilChanged( + undefined, + (frame: AnimationFrame): string => { + return frame.state.previousImage.id; + }), + withLatestFrom( + this._container.glRenderer.webGLRenderer$, + this._container.renderService.size$), + map( + ([frame, renderer, size]: [AnimationFrame, THREE.WebGLRenderer, ViewportSize]): TextureProvider => { + const state = frame.state; + const previousImage = state.previousImage; + const previousTransform = state.previousTransform; + return new TextureProvider( + previousImage.id, + previousTransform.basicWidth, + previousTransform.basicHeight, + previousImage.image, + this._imageTileLoader, + new TileStore(), + renderer); + }), + publishReplay(1), + refCount()); + subs.push(textureProviderPrev$.subscribe(() => { /*noop*/ })); + subs.push(textureProviderPrev$.pipe( + map( + (provider: TextureProvider): GLRendererOperation => { + return (renderer: SliderGLRenderer): SliderGLRenderer => { + renderer.setTextureProviderPrev(provider.id, provider); + return renderer; + }; + })) + .subscribe(this._glRendererOperation$)); + subs.push(textureProviderPrev$.pipe( + pairwise()) + .subscribe( + (pair: [TextureProvider, TextureProvider]): void => { + let previous: TextureProvider = pair[0]; + previous.abort(); + })); + const roiTriggerPrev$ = + this._container.configurationService.imageTiling$.pipe( + switchMap( + (active): Observable<[RenderCamera, ViewportSize]> => { + return active ? + observableCombineLatest( + this._container.renderService.renderCameraFrame$, + this._container.renderService.size$.pipe(debounceTime(250))) : + new Subject(); + }), + map( + ([camera, size]: [RenderCamera, ViewportSize]): PositionLookat => { + return [ + camera.camera.position.clone(), + camera.camera.lookat.clone(), + camera.zoom.valueOf(), + size.height.valueOf(), + size.width.valueOf()]; + }), + pairwise(), + skipWhile( + (pls: [PositionLookat, PositionLookat]): boolean => { + return pls[1][2] - pls[0][2] < 0 || pls[1][2] === 0; + }), + map( + (pls: [PositionLookat, PositionLookat]): boolean => { + let samePosition: boolean = pls[0][0].equals(pls[1][0]); + let sameLookat: boolean = pls[0][1].equals(pls[1][1]); + let sameZoom: boolean = pls[0][2] === pls[1][2]; + let sameHeight: boolean = pls[0][3] === pls[1][3]; + let sameWidth: boolean = pls[0][4] === pls[1][4]; + return samePosition && sameLookat && sameZoom && sameHeight && sameWidth; + }), + distinctUntilChanged(), + filter( + (stalled: boolean): boolean => { + return stalled; + }), + switchMap( + (): Observable => { + return this._container.renderService.renderCameraFrame$.pipe( + first()); + }), + withLatestFrom( + this._container.renderService.size$, + this._navigator.stateService.currentTransform$)); + subs.push(textureProviderPrev$.pipe( + switchMap( + (provider: TextureProvider): Observable<[TileRegionOfInterest, TextureProvider]> => { + return roiTriggerPrev$.pipe( + map( + ([camera, size, transform]: [RenderCamera, ViewportSize, Transform]): + [TileRegionOfInterest, TextureProvider] => { + return [ + this._roiCalculator.computeRegionOfInterest(camera, size, transform), + provider, + ]; + })); + }), + filter( + (args: [TileRegionOfInterest, TextureProvider]): boolean => { + return !args[1].disposed; + }), + withLatestFrom(this._navigator.stateService.currentState$)) + .subscribe( + ([[roi, provider], frame]: [[TileRegionOfInterest, TextureProvider], AnimationFrame]): void => { + let shiftedRoi: TileRegionOfInterest = null; + if (isSpherical(frame.state.previousImage.cameraType)) { + if (isSpherical(frame.state.currentImage.cameraType)) { + const currentViewingDirection: THREE.Vector3 = + this._spatial.viewingDirection(frame.state.currentImage.rotation); + const previousViewingDirection: THREE.Vector3 = + this._spatial.viewingDirection(frame.state.previousImage.rotation); + const directionDiff: number = this._spatial.angleBetweenVector2( + currentViewingDirection.x, + currentViewingDirection.y, + previousViewingDirection.x, + previousViewingDirection.y); + const shift: number = directionDiff / (2 * Math.PI); + const bbox: TileBoundingBox = { + maxX: this._spatial.wrap(roi.bbox.maxX + shift, 0, 1), + maxY: roi.bbox.maxY, + minX: this._spatial.wrap(roi.bbox.minX + shift, 0, 1), + minY: roi.bbox.minY, + }; + shiftedRoi = { + bbox: bbox, + pixelHeight: roi.pixelHeight, + pixelWidth: roi.pixelWidth, + }; + } else { + const currentViewingDirection: THREE.Vector3 = + this._spatial.viewingDirection(frame.state.currentImage.rotation); + const previousViewingDirection: THREE.Vector3 = + this._spatial.viewingDirection(frame.state.previousImage.rotation); + const directionDiff: number = this._spatial.angleBetweenVector2( + currentViewingDirection.x, + currentViewingDirection.y, + previousViewingDirection.x, + previousViewingDirection.y); + const shiftX: number = directionDiff / (2 * Math.PI); + const a1: number = this._spatial.angleToPlane(currentViewingDirection.toArray(), [0, 0, 1]); + const a2: number = this._spatial.angleToPlane(previousViewingDirection.toArray(), [0, 0, 1]); + const shiftY: number = (a2 - a1) / (2 * Math.PI); + const currentTransform: Transform = frame.state.currentTransform; + const size: number = Math.max(currentTransform.basicWidth, currentTransform.basicHeight); + const hFov: number = size > 0 ? + 2 * Math.atan(0.5 * currentTransform.basicWidth / (size * currentTransform.focal)) : + Math.PI / 3; + const vFov: number = size > 0 ? + 2 * Math.atan(0.5 * currentTransform.basicHeight / (size * currentTransform.focal)) : + Math.PI / 3; + const spanningWidth: number = hFov / (2 * Math.PI); + const spanningHeight: number = vFov / Math.PI; + const basicWidth: number = (roi.bbox.maxX - roi.bbox.minX) * spanningWidth; + const basicHeight: number = (roi.bbox.maxY - roi.bbox.minY) * spanningHeight; + const pixelWidth: number = roi.pixelWidth * spanningWidth; + const pixelHeight: number = roi.pixelHeight * spanningHeight; + const zoomShiftX: number = (roi.bbox.minX + roi.bbox.maxX) / 2 - 0.5; + const zoomShiftY: number = (roi.bbox.minY + roi.bbox.maxY) / 2 - 0.5; + const minX: number = 0.5 + shiftX + spanningWidth * zoomShiftX - basicWidth / 2; + const maxX: number = 0.5 + shiftX + spanningWidth * zoomShiftX + basicWidth / 2; + const minY: number = 0.5 + shiftY + spanningHeight * zoomShiftY - basicHeight / 2; + const maxY: number = 0.5 + shiftY + spanningHeight * zoomShiftY + basicHeight / 2; + const bbox: TileBoundingBox = { + maxX: this._spatial.wrap(maxX, 0, 1), + maxY: maxY, + minX: this._spatial.wrap(minX, 0, 1), + minY: minY, + }; + shiftedRoi = { + bbox: bbox, + pixelHeight: pixelHeight, + pixelWidth: pixelWidth, + }; + } + } else { + const currentBasicAspect: number = frame.state.currentTransform.basicAspect; + const previousBasicAspect: number = frame.state.previousTransform.basicAspect; + const [[cornerMinX, cornerMinY], [cornerMaxX, cornerMaxY]]: number[][] = + this._getBasicCorners(currentBasicAspect, previousBasicAspect); + const basicWidth: number = cornerMaxX - cornerMinX; + const basicHeight: number = cornerMaxY - cornerMinY; + const pixelWidth: number = roi.pixelWidth / basicWidth; + const pixelHeight: number = roi.pixelHeight / basicHeight; + const minX: number = (basicWidth - 1) / (2 * basicWidth) + roi.bbox.minX / basicWidth; + const maxX: number = (basicWidth - 1) / (2 * basicWidth) + roi.bbox.maxX / basicWidth; + const minY: number = (basicHeight - 1) / (2 * basicHeight) + roi.bbox.minY / basicHeight; + const maxY: number = (basicHeight - 1) / (2 * basicHeight) + roi.bbox.maxY / basicHeight; + const bbox: TileBoundingBox = { + maxX: maxX, + maxY: maxY, + minX: minX, + minY: minY, + }; + this._clipBoundingBox(bbox); + shiftedRoi = { + bbox: bbox, + pixelHeight: pixelHeight, + pixelWidth: pixelWidth, + }; + } + provider.setRegionOfInterest(shiftedRoi); + })); + const hasTexturePrev$ = textureProviderPrev$.pipe( + switchMap( + (provider: TextureProvider): Observable => { + return provider.hasTexture$; + }), + startWith(false), + publishReplay(1), + refCount()); + subs.push(hasTexturePrev$.subscribe(() => { /*noop*/ })); + } + protected _deactivate(): void { + this._waitSubscription.unsubscribe(); + this._navigator.stateService.state$.pipe( + first()) + .subscribe( + (state: State): void => { + if (state !== State.Traversing) { + this._navigator.stateService.traverse(); + } + }); + this._glRendererDisposer$.next(null); + this._domRenderer.deactivate(); + this._subscriptions.unsubscribe(); + this.configure({ ids: null }); + } + protected _getDefaultConfiguration(): SliderConfiguration { + return { + initialPosition: 1, + mode: SliderConfigurationMode.Motion, + sliderVisible: true, + }; + } + private _catchCacheImage$(imageId: string): Observable { + return this._navigator.graphService.cacheImage$(imageId).pipe( + catchError( + (error: Error): Observable => { + console.error(`Failed to cache slider image (${imageId})`, error); + return observableEmpty(); + })); + } + private _getBasicCorners(currentAspect: number, previousAspect: number): number[][] { + let offsetX: number; + let offsetY: number; + if (currentAspect > previousAspect) { + offsetX = 0.5; + offsetY = 0.5 * currentAspect / previousAspect; + } else { + offsetX = 0.5 * previousAspect / currentAspect; + offsetY = 0.5; + } + return [[0.5 - offsetX, 0.5 - offsetY], [0.5 + offsetX, 0.5 + offsetY]]; + } + private _clipBoundingBox(bbox: TileBoundingBox): void { + bbox.minX = Math.max(0, Math.min(1, bbox.minX)); + bbox.maxX = Math.max(0, Math.min(1, bbox.maxX)); + bbox.minY = Math.max(0, Math.min(1, bbox.minY)); + bbox.maxY = Math.max(0, Math.min(1, bbox.maxY)); + } +} diff --git a/src/component/slider/SliderDOMRenderer.ts b/src/component/slider/SliderDOMRenderer.ts new file mode 100644 index 000000000..09e73bf50 --- /dev/null +++ b/src/component/slider/SliderDOMRenderer.ts @@ -0,0 +1,141 @@ +import { merge as observableMerge, Observable, Subject, Subscription } from "rxjs"; +import { filter } from "rxjs/operators"; +import * as vd from "virtual-dom"; +import { Container } from "../../viewer/Container"; +import { SliderConfigurationMode } from "../interfaces/SliderConfiguration"; +export class SliderDOMRenderer { + private _container: Container; + private _interacting: boolean; + private _notifyModeChanged$: Subject; + private _notifyPositionChanged$: Subject; + private _stopInteractionSubscription: Subscription; + constructor(container: Container) { + this._container = container; + this._interacting = false; + this._notifyModeChanged$ = new Subject(); + this._notifyPositionChanged$ = new Subject(); + this._stopInteractionSubscription = null; + } + public get mode$(): Observable { + return this._notifyModeChanged$; + } + public get position$(): Observable { + return this._notifyPositionChanged$; + } + public activate(): void { + if (!!this._stopInteractionSubscription) { + return; + } + this._stopInteractionSubscription = observableMerge( + this._container.mouseService.documentMouseUp$, + this._container.touchService.touchEnd$.pipe( + filter( + (touchEvent: TouchEvent): boolean => { + return touchEvent.touches.length === 0; + }))) + .subscribe( + (event: Event): void => { + if (this._interacting) { + this._interacting = false; + } + }); + } + public deactivate(): void { + if (!this._stopInteractionSubscription) { + return; + } + this._interacting = false; + this._stopInteractionSubscription.unsubscribe(); + this._stopInteractionSubscription = null; + } + public render( + position: number, + mode: SliderConfigurationMode, + motionless: boolean, + spherical: boolean, + visible: boolean): vd.VNode { + const children: vd.VNode[] = []; + if (visible) { + children.push(vd.h("div.mapillary-slider-border", [])); + const modeVisible: boolean = !(motionless || spherical); + if (modeVisible) { + children.push(this._createModeButton(mode)); + children.push(this._createModeButton2d(mode)); + } + children.push(this._createPositionInput(position, modeVisible)); + } + const boundingRect: ClientRect = this._container.domContainer.getBoundingClientRect(); + const width: number = Math.max(215, Math.min(400, boundingRect.width - 100)); + return vd.h("div.mapillary-slider-container", { style: { width: `${width}px` } }, children); + } + private _createModeButton(mode: SliderConfigurationMode): vd.VNode { + const properties: vd.createProperties = { + onclick: (): void => { + if (mode === SliderConfigurationMode.Motion) { + return; + } + this._notifyModeChanged$.next(SliderConfigurationMode.Motion); + }, + }; + const className: string = mode === SliderConfigurationMode.Stationary ? + "mapillary-slider-mode-button-inactive" : + "mapillary-slider-mode-button"; + return vd.h("div." + className, properties, [vd.h("div.mapillary-slider-mode-icon", [])]); + } + private _createModeButton2d(mode: SliderConfigurationMode): vd.VNode { + const properties: vd.createProperties = { + onclick: (): void => { + if (mode === SliderConfigurationMode.Stationary) { + return; + } + this._notifyModeChanged$.next(SliderConfigurationMode.Stationary); + }, + }; + const className: string = mode === SliderConfigurationMode.Motion ? + "mapillary-slider-mode-button-2d-inactive" : + "mapillary-slider-mode-button-2d"; + return vd.h("div." + className, properties, [vd.h("div.mapillary-slider-mode-icon-2d", [])]); + } + private _createPositionInput(position: number, modeVisible: boolean): vd.VNode { + const onChange: (e: Event) => void = (e: Event): void => { + this._notifyPositionChanged$.next(Number((e.target).value) / 1000); + }; + const onStart: (e: Event) => void = (e: Event): void => { + this._interacting = true; + e.stopPropagation(); + }; + const onMove: (e: Event) => void = (e: Event): void => { + if (this._interacting) { + e.stopPropagation(); + } + }; + const onKeyDown: (e: KeyboardEvent) => void = (e: KeyboardEvent): void => { + if (e.key === "ArrowDown" || e.key === "ArrowLeft" || + e.key === "ArrowRight" || e.key === "ArrowUp") { + e.preventDefault(); + } + }; + const boundingRect: ClientRect = this._container.domContainer.getBoundingClientRect(); + const width: number = Math.max(215, Math.min(400, boundingRect.width - 105)) - 84 + (modeVisible ? 0 : 52); + const positionInput: vd.VNode = vd.h( + "input.mapillary-slider-position", + { + max: 1000, + min: 0, + onchange: onChange, + oninput: onChange, + onkeydown: onKeyDown, + onpointerdown: onStart, + onpointermove: onMove, + ontouchmove: onMove, + ontouchstart: onStart, + style: { + width: `${width}px`, + }, + type: "range", + value: 1000 * position, + }, + []); + return vd.h("div.mapillary-slider-position-container", [positionInput]); + } +} diff --git a/src/component/slider/SliderGLRenderer.ts b/src/component/slider/SliderGLRenderer.ts new file mode 100644 index 000000000..ebeca06e0 --- /dev/null +++ b/src/component/slider/SliderGLRenderer.ts @@ -0,0 +1,385 @@ +import * as THREE from "three"; +import { Subscription } from "rxjs"; +import { Spatial } from "../../geo/Spatial"; +import { Image } from "../../graph/Image"; +import { IAnimationState } from "../../state/interfaces/IAnimationState"; +import { AnimationFrame } from "../../state/interfaces/AnimationFrame"; +import { TextureProvider } from "../../tile/TextureProvider"; +import { SliderBBoxProjectorShaderMaterial } from "./interfaces/SliderBBoxProjectorShaderMaterial"; +import { SliderConfigurationMode } from "../interfaces/SliderConfiguration"; +import { SliderMeshFactory } from "./SliderMeshFactory"; +import { isSpherical } from "../../geo/Geo"; +import { CameraType } from "../../geo/interfaces/CameraType"; +import { SliderTransform } from "./SliderTransform"; +import { SliderMeshScene, SliderMeshSceneItem } from "./SliderMeshScene"; +export class SliderGLRenderer { + private _factory: SliderMeshFactory; + private _scene: SliderMeshScene; + private _spatial: Spatial; + private _currentKey: string; + private _previousKey: string; + private _disabled: boolean; + private _curtain: number; + private _frameId: number; + private _needsRender: boolean; + private _mode: SliderConfigurationMode; + private _currentProviderDisposers: { [key: string]: () => void }; + private _previousProviderDisposers: { [key: string]: () => void }; + constructor() { + this._factory = new SliderMeshFactory(); + this._scene = new SliderMeshScene(); + this._spatial = new Spatial(); + this._currentKey = null; + this._previousKey = null; + this._disabled = false; + this._curtain = 1; + this._frameId = 0; + this._needsRender = false; + this._mode = null; + this._currentProviderDisposers = {}; + this._previousProviderDisposers = {}; + } + public get disabled(): boolean { + return this._disabled; + } + public get frameId(): number { + return this._frameId; + } + public get needsRender(): boolean { + return this._needsRender; + } + public setTextureProvider(key: string, provider: TextureProvider): void { + this._setTextureProvider( + key, + this._currentKey, + provider, + this._currentProviderDisposers, + this._updateTexture.bind(this)); + } + public setTextureProviderPrev(key: string, provider: TextureProvider): void { + this._setTextureProvider( + key, + this._previousKey, + provider, + this._previousProviderDisposers, + this._updateTexturePrev.bind(this)); + } + public update(frame: AnimationFrame, mode: SliderConfigurationMode): void { + this._updateFrameId(frame.id); + this._updateImagePlanes(frame.state, mode); + } + public updateCurtain(curtain: number): void { + if (this._curtain === curtain) { + return; + } + this._curtain = curtain; + this._updateCurtain(); + this._needsRender = true; + } + public updateTexture( + imageElement: HTMLImageElement, + image: Image) + : void { + const planes: SliderMeshSceneItem[] = + image.id === this._currentKey ? + this._scene.planes : + image.id === this._previousKey ? + this._scene.planesOld : + []; + if (planes.length) { + return; + } + this._needsRender = true; + for (const plane of planes) { + let material: SliderBBoxProjectorShaderMaterial = plane.mesh.material; + let texture: THREE.Texture = material.uniforms.projectorTex.value; + texture.image = imageElement; + texture.needsUpdate = true; + } + } + public updateTextureImage( + imageElement: HTMLImageElement, + image?: Image) + : void { + if (this._currentKey !== image.id) { + return; + } + this._needsRender = true; + const planes: SliderMeshSceneItem[] = this._scene.planes; + for (const plane of planes) { + let material: SliderBBoxProjectorShaderMaterial = plane.mesh.material; + let texture: THREE.Texture = material.uniforms.projectorTex.value; + texture.image = imageElement; + texture.needsUpdate = true; + } + } + public render( + perspectiveCamera: THREE.PerspectiveCamera, + renderer: THREE.WebGLRenderer): void { + if (!this.disabled) { + renderer.render(this._scene.sceneOld, perspectiveCamera); + } + renderer.render(this._scene.scene, perspectiveCamera); + this._needsRender = false; + } + public dispose(): void { + this._scene.clear(); + for (const key in this._currentProviderDisposers) { + if (!this._currentProviderDisposers.hasOwnProperty(key)) { + continue; + } + this._currentProviderDisposers[key](); + } + for (const key in this._previousProviderDisposers) { + if (!this._previousProviderDisposers.hasOwnProperty(key)) { + continue; + } + this._previousProviderDisposers[key](); + } + this._currentProviderDisposers = {}; + this._previousProviderDisposers = {}; + } + private _getBasicCorners(currentAspect: number, previousAspect: number): number[][] { + let offsetX: number; + let offsetY: number; + if (currentAspect > previousAspect) { + offsetX = 0.5; + offsetY = 0.5 * currentAspect / previousAspect; + } else { + offsetX = 0.5 * previousAspect / currentAspect; + offsetY = 0.5; + } + return [[0.5 - offsetX, 0.5 - offsetY], [0.5 + offsetX, 0.5 + offsetY]]; + } + private _setDisabled(state: IAnimationState): void { + this._disabled = state.currentImage == null || + state.previousImage == null || + (isSpherical(state.currentImage.cameraType) && + !isSpherical(state.previousImage.cameraType)); + } + private _setTextureProvider( + key: string, + originalKey: string, + provider: TextureProvider, + providerDisposers: { [key: string]: () => void }, + updateTexture: (texture: THREE.Texture) => void): void { + if (key !== originalKey) { + return; + } + let createdSubscription: Subscription = provider.textureCreated$ + .subscribe(updateTexture); + let updatedSubscription: Subscription = provider.textureUpdated$ + .subscribe( + (updated: boolean): void => { + this._needsRender = true; + }); + let dispose: () => void = (): void => { + createdSubscription.unsubscribe(); + updatedSubscription.unsubscribe(); + provider.dispose(); + }; + if (key in providerDisposers) { + let disposeProvider: () => void = providerDisposers[key]; + disposeProvider(); + delete providerDisposers[key]; + } + providerDisposers[key] = dispose; + } + private _updateCurtain(): void { + const planes: SliderMeshSceneItem[] = this._scene.planes; + for (const plane of planes) { + let shaderMaterial = plane.mesh.material; + if (!!shaderMaterial.uniforms.curtain) { + shaderMaterial.uniforms.curtain.value = this._curtain; + } + } + } + private _updateFrameId(frameId: number): void { + this._frameId = frameId; + } + private _updateImagePlanes(state: IAnimationState, mode: SliderConfigurationMode): void { + const currentChanged: boolean = state.currentImage != null && this._currentKey !== state.currentImage.id; + const previousChanged: boolean = state.previousImage != null && this._previousKey !== state.previousImage.id; + const modeChanged: boolean = this._mode !== mode; + if (!(currentChanged || previousChanged || modeChanged)) { + return; + } + this._setDisabled(state); + this._needsRender = true; + this._mode = mode; + const motionless = + state.motionless || + mode === SliderConfigurationMode.Stationary || + isSpherical(state.currentImage.cameraType); + if (this.disabled || previousChanged) { + if (this._previousKey in this._previousProviderDisposers) { + this._previousProviderDisposers[this._previousKey](); + delete this._previousProviderDisposers[this._previousKey]; + } + } + if (this.disabled) { + this._scene.setImagePlanesOld([]); + } else { + if (previousChanged || modeChanged) { + const previousNode: Image = state.previousImage; + this._previousKey = previousNode.id; + const elements: number[] = state.currentTransform.rt.elements; + let translation: number[] = [elements[12], elements[13], elements[14]]; + const currentAspect: number = state.currentTransform.basicAspect; + const previousAspect: number = state.previousTransform.basicAspect; + const textureScale: number[] = currentAspect > previousAspect ? + [1, previousAspect / currentAspect] : + [currentAspect / previousAspect, 1]; + let rotation: number[] = state.currentImage.rotation; + let width: number = state.currentImage.width; + let height: number = state.currentImage.height; + if (isSpherical(previousNode.cameraType)) { + rotation = state.previousImage.rotation; + translation = this._spatial + .rotate( + this._spatial + .opticalCenter( + state.currentImage.rotation, + translation) + .toArray(), + rotation) + .multiplyScalar(-1) + .toArray(); + width = state.previousImage.width; + height = state.previousImage.height; + } + const transform: SliderTransform = new SliderTransform( + state.currentImage.exifOrientation, + width, + height, + state.currentImage.scale, + rotation, + translation, + previousNode.image, + textureScale, + Object.values(state.currentImage.camera.parameters), + state.currentImage.camera.type); + + const pElements: number[] = state.previousTransform.rt.elements; + let pTranslation: number[] = [pElements[12], pElements[13], pElements[14]]; + const previousTransform = new SliderTransform( + state.previousImage.exifOrientation, + state.previousImage.width, + state.previousImage.height, + state.previousImage.scale, + state.previousImage.rotation, + pTranslation, + previousNode.image, + undefined, + Object.values(state.previousImage.camera.parameters), + state.previousImage.camera.type); + + const cElements: number[] = state.currentTransform.rt.elements; + let cTranslation: number[] = [cElements[12], cElements[13], cElements[14]]; + const currentTransform = new SliderTransform( + state.currentImage.exifOrientation, + state.currentImage.width, + state.currentImage.height, + state.currentImage.scale, + state.currentImage.rotation, + cTranslation, + state.currentImage.image, + undefined, + Object.values(state.currentImage.camera.parameters), + state.currentImage.camera.type); + + let mesh: THREE.Mesh = undefined; + if (isSpherical(previousNode.cameraType)) { + mesh = this._factory.createMesh( + previousNode, + motionless || + isSpherical(state.currentImage.camera.type) ? + transform : previousTransform); + } else { + if (motionless) { + const [[basicX0, basicY0], [basicX1, basicY1]]: number[][] = this._getBasicCorners(currentAspect, previousAspect); + mesh = this._factory.createFlatMesh( + state.previousImage, + transform, + basicX0, + basicX1, + basicY0, + basicY1); + } else { + mesh = this._factory.createMesh(state.previousImage, previousTransform); + } + } + const previousPlanes: SliderMeshSceneItem[] = [{imageId: previousNode.id, mesh, camera: state.previousImage.camera}]; + this._scene.setImagePlanesOld(previousPlanes); + } + } + if (currentChanged || modeChanged) { + if (this._currentKey in this._currentProviderDisposers) { + this._currentProviderDisposers[this._currentKey](); + delete this._currentProviderDisposers[this._currentKey]; + } + + const cElements: number[] = state.currentTransform.rt.elements; + let cTranslation: number[] = [cElements[12], cElements[13], cElements[14]]; + const currentTransform = new SliderTransform( + state.currentImage.exifOrientation, + state.currentImage.width, + state.currentImage.height, + state.currentImage.scale, + state.currentImage.rotation, + cTranslation, + state.currentImage.image, + undefined, + Object.values(state.currentImage.camera.parameters), + state.currentImage.camera.type); + + this._currentKey = state.currentImage.id; + const planes: SliderMeshSceneItem[] = []; + if (isSpherical(state.currentImage.cameraType)) { + planes.push( + {imageId: state.currentImage.id, mesh: this._factory.createCurtainMesh( + state.currentImage, + currentTransform), camera: state.currentImage.camera} + ); + } else { + if (motionless) { + planes.push( + {imageId: state.currentImage.id, mesh: this._factory.createDistortedCurtainMesh( + state.currentImage, + currentTransform), camera: state.currentImage.camera} + ); + } else { + planes.push( + {imageId: state.currentImage.id, mesh: this._factory.createCurtainMesh( + state.currentImage, + currentTransform), camera: state.currentImage.camera} + ); + } + } + this._scene.setImagePlanes(planes); + this._updateCurtain(); + } + } + private _updateTexture(texture: THREE.Texture): void { + this._needsRender = true; + const planes: SliderMeshSceneItem[] = this._scene.planes; + for (const plane of planes) { + let material: SliderBBoxProjectorShaderMaterial = plane.mesh.material; + let oldTexture: THREE.Texture = material.uniforms.projectorTex.value; + material.uniforms.projectorTex.value = null; + oldTexture.dispose(); + material.uniforms.projectorTex.value = texture; + } + } + private _updateTexturePrev(texture: THREE.Texture): void { + this._needsRender = true; + const planes: SliderMeshSceneItem[] = this._scene.planesOld; + for (const plane of planes) { + let material: SliderBBoxProjectorShaderMaterial = plane.mesh.material; + let oldTexture: THREE.Texture = material.uniforms.projectorTex.value; + material.uniforms.projectorTex.value = null; + oldTexture.dispose(); + material.uniforms.projectorTex.value = texture; + } + } +} diff --git a/src/component/slider/SliderInterfaces.ts b/src/component/slider/SliderInterfaces.ts new file mode 100644 index 000000000..f5683b9a8 --- /dev/null +++ b/src/component/slider/SliderInterfaces.ts @@ -0,0 +1,22 @@ +import * as THREE from "three"; +import { Image } from "../../graph/Image"; +import { IAnimationState } from "../../state/interfaces/IAnimationState"; +import { SliderGLRenderer } from "./SliderGLRenderer"; +export interface SliderImages { + background: Image; + foreground: Image; +} +export interface SliderCombination { + images: SliderImages; + state: IAnimationState; +} +export interface GLRendererOperation { + (glRenderer: SliderGLRenderer): SliderGLRenderer; +} +export type PositionLookat = [ + THREE.Vector3, + THREE.Vector3, + number, + number, + number, +]; diff --git a/src/component/slider/SliderMeshFactory.ts b/src/component/slider/SliderMeshFactory.ts new file mode 100644 index 000000000..ad0c9af06 --- /dev/null +++ b/src/component/slider/SliderMeshFactory.ts @@ -0,0 +1,560 @@ +import * as THREE from "three"; + +import { SliderShaders } from "./SliderShaders"; + +import { Image } from "../../graph/Image"; +import { isFisheye, isSpherical } from "../../geo/Geo"; +import { SliderTransform } from "./SliderTransform"; + +export class SliderMeshFactory { + private _imagePlaneDepth: number; + private _imageSphereRadius: number; + + constructor(imagePlaneDepth?: number, imageSphereRadius?: number) { + this._imagePlaneDepth = imagePlaneDepth != null ? imagePlaneDepth : 200; + this._imageSphereRadius = imageSphereRadius != null ? imageSphereRadius : 200; + } + + public createMesh(image: Image, transform: SliderTransform): THREE.Mesh { + if (isSpherical(transform.cameraType)) { + return this._createImageSphere(image, transform); + } else if (isFisheye(transform.cameraType)) { + return this._createImagePlaneFisheye(image, transform); + } else { + return this._createImagePlane(image, transform); + } + } + + public createFlatMesh( + image: Image, + transform: SliderTransform, + basicX0: number, + basicX1: number, + basicY0: number, + basicY1: number): THREE.Mesh { + + let texture: THREE.Texture = this._createTexture(image.image); + let materialParameters: THREE.ShaderMaterialParameters = + this._createDistortedPlaneMaterialParameters(transform, texture); + let material: THREE.ShaderMaterial = new THREE.ShaderMaterial(materialParameters); + + let geometry: THREE.BufferGeometry = this._getFlatImagePlaneGeoFromBasic(transform, basicX0, basicX1, basicY0, basicY1); + + return new THREE.Mesh(geometry, material); + } + + public createCurtainMesh(image: Image, transform: SliderTransform): THREE.Mesh { + if (isSpherical(transform.cameraType)) { + return this._createSphereCurtainMesh(image, transform); + } else if (isFisheye(transform.cameraType)) { + return this._createCurtainMeshFisheye(image, transform); + } else { + return this._createCurtainMesh(image, transform); + } + } + + public createDistortedCurtainMesh(image: Image, transform: SliderTransform): THREE.Mesh { + return this._createDistortedCurtainMesh(image, transform); + } + + private _createCurtainMesh(image: Image, transform: SliderTransform): THREE.Mesh { + let texture: THREE.Texture = this._createTexture(image.image); + let materialParameters: THREE.ShaderMaterialParameters = + this._createCurtainPlaneMaterialParameters(transform, texture); + let material: THREE.ShaderMaterial = new THREE.ShaderMaterial(materialParameters); + + let geometry: THREE.BufferGeometry = this._useMesh(transform, image) ? + this._getImagePlaneGeo(transform, image) : + this._getRegularFlatImagePlaneGeo(transform); + + return new THREE.Mesh(geometry, material); + } + + private _createCurtainMeshFisheye(image: Image, transform: SliderTransform): THREE.Mesh { + let texture: THREE.Texture = this._createTexture(image.image); + let materialParameters: THREE.ShaderMaterialParameters = + this._createCurtainPlaneMaterialParametersFisheye(transform, texture); + let material: THREE.ShaderMaterial = new THREE.ShaderMaterial(materialParameters); + + let geometry: THREE.BufferGeometry = this._useMesh(transform, image) ? + this._getImagePlaneGeoFisheye(transform, image) : + this._getRegularFlatImagePlaneGeo(transform); + + return new THREE.Mesh(geometry, material); + } + + private _createDistortedCurtainMesh(image: Image, transform: SliderTransform): THREE.Mesh { + let texture: THREE.Texture = this._createTexture(image.image); + let materialParameters: THREE.ShaderMaterialParameters = + this._createDistortedCurtainPlaneMaterialParameters(transform, texture); + let material: THREE.ShaderMaterial = new THREE.ShaderMaterial(materialParameters); + + let geometry: THREE.BufferGeometry = this._getRegularFlatImagePlaneGeo(transform); + + return new THREE.Mesh(geometry, material); + } + + private _createSphereCurtainMesh(image: Image, transform: SliderTransform): THREE.Mesh { + let texture: THREE.Texture = this._createTexture(image.image); + let materialParameters: THREE.ShaderMaterialParameters = + this._createCurtainSphereMaterialParameters(transform, texture); + let material: THREE.ShaderMaterial = new THREE.ShaderMaterial(materialParameters); + + return this._useMesh(transform, image) ? + new THREE.Mesh(this._getImageSphereGeo(transform, image), material) : + new THREE.Mesh(this._getFlatImageSphereGeo(transform), material); + } + + private _createImageSphere(image: Image, transform: SliderTransform): THREE.Mesh { + let texture: THREE.Texture = this._createTexture(image.image); + let materialParameters: THREE.ShaderMaterialParameters = this._createSphereMaterialParameters(transform, texture); + let material: THREE.ShaderMaterial = new THREE.ShaderMaterial(materialParameters); + + let mesh: THREE.Mesh = this._useMesh(transform, image) ? + new THREE.Mesh(this._getImageSphereGeo(transform, image), material) : + new THREE.Mesh(this._getFlatImageSphereGeo(transform), material); + + return mesh; + } + + private _createImagePlane(image: Image, transform: SliderTransform): THREE.Mesh { + let texture: THREE.Texture = this._createTexture(image.image); + let materialParameters: THREE.ShaderMaterialParameters = this._createPlaneMaterialParameters(transform, texture); + let material: THREE.ShaderMaterial = new THREE.ShaderMaterial(materialParameters); + + let geometry: THREE.BufferGeometry = this._useMesh(transform, image) ? + this._getImagePlaneGeo(transform, image) : + this._getRegularFlatImagePlaneGeo(transform); + + return new THREE.Mesh(geometry, material); + } + + private _createImagePlaneFisheye(image: Image, transform: SliderTransform): THREE.Mesh { + let texture: THREE.Texture = this._createTexture(image.image); + let materialParameters: THREE.ShaderMaterialParameters = this._createPlaneMaterialParametersFisheye(transform, texture); + let material: THREE.ShaderMaterial = new THREE.ShaderMaterial(materialParameters); + + let geometry: THREE.BufferGeometry = this._useMesh(transform, image) ? + this._getImagePlaneGeoFisheye(transform, image) : + this._getRegularFlatImagePlaneGeoFisheye(transform); + + return new THREE.Mesh(geometry, material); + } + + private _createSphereMaterialParameters(transform: SliderTransform, texture: THREE.Texture): THREE.ShaderMaterialParameters { + let materialParameters: THREE.ShaderMaterialParameters = { + depthWrite: false, + fragmentShader: SliderShaders.spherical.fragment, + side: THREE.DoubleSide, + transparent: true, + uniforms: { + opacity: { value: 1.0 }, + projectorMat: { value: transform.rt }, + projectorTex: { value: texture }, + }, + vertexShader: SliderShaders.spherical.vertex, + }; + + return materialParameters; + } + + private _createCurtainSphereMaterialParameters(transform: SliderTransform, texture: THREE.Texture): THREE.ShaderMaterialParameters { + let materialParameters: THREE.ShaderMaterialParameters = { + depthWrite: false, + fragmentShader: SliderShaders.sphericalCurtain.fragment, + side: THREE.DoubleSide, + transparent: true, + uniforms: { + curtain: { value: 1.0 }, + opacity: { value: 1.0 }, + projectorMat: { value: transform.rt }, + projectorTex: { value: texture }, + }, + vertexShader: SliderShaders.sphericalCurtain.vertex, + }; + + return materialParameters; + } + + private _createPlaneMaterialParameters( + transform: SliderTransform, + texture: THREE.Texture) + : THREE.ShaderMaterialParameters { + let materialParameters: THREE.ShaderMaterialParameters = { + depthWrite: false, + fragmentShader: SliderShaders.perspective.fragment, + side: THREE.DoubleSide, + transparent: true, + uniforms: { + focal: { value: transform.focal }, + k1: { value: transform.ck1 }, + k2: { value: transform.ck2 }, + opacity: { value: 1.0 }, + projectorMat: { value: transform.basicRt }, + projectorTex: { value: texture }, + radial_peak: { value: !!transform.radialPeak ? transform.radialPeak : 0.0 }, + scale_x: { value: Math.max(transform.basicHeight, transform.basicWidth) / transform.basicWidth }, + scale_y: { value: Math.max(transform.basicWidth, transform.basicHeight) / transform.basicHeight }, + }, + vertexShader: SliderShaders.perspective.vertex, + }; + + return materialParameters; + } + + private _createPlaneMaterialParametersFisheye( + transform: SliderTransform, + texture: THREE.Texture) + : THREE.ShaderMaterialParameters { + let materialParameters: THREE.ShaderMaterialParameters = { + depthWrite: false, + fragmentShader: SliderShaders.fisheye.fragment, + side: THREE.DoubleSide, + transparent: true, + uniforms: { + focal: { value: transform.focal }, + k1: { value: transform.ck1 }, + k2: { value: transform.ck2 }, + opacity: { value: 1.0 }, + projectorMat: { value: transform.basicRt }, + projectorTex: { value: texture }, + radial_peak: { value: !!transform.radialPeak ? transform.radialPeak : 0.0 }, + scale_x: { value: Math.max(transform.basicHeight, transform.basicWidth) / transform.basicWidth }, + scale_y: { value: Math.max(transform.basicWidth, transform.basicHeight) / transform.basicHeight }, + }, + vertexShader: SliderShaders.fisheye.vertex, + }; + + return materialParameters; + } + + private _createCurtainPlaneMaterialParametersFisheye( + transform: SliderTransform, + texture: THREE.Texture) + : THREE.ShaderMaterialParameters { + let materialParameters: THREE.ShaderMaterialParameters = { + depthWrite: false, + fragmentShader: SliderShaders.fisheyeCurtain.fragment, + side: THREE.DoubleSide, + transparent: true, + uniforms: { + curtain: { value: 1.0 }, + focal: { value: transform.focal }, + k1: { value: transform.ck1 }, + k2: { value: transform.ck2 }, + opacity: { value: 1.0 }, + projectorMat: { value: transform.basicRt }, + projectorTex: { value: texture }, + radial_peak: { value: !!transform.radialPeak ? transform.radialPeak : 0.0 }, + scale_x: { value: Math.max(transform.basicHeight, transform.basicWidth) / transform.basicWidth }, + scale_y: { value: Math.max(transform.basicWidth, transform.basicHeight) / transform.basicHeight }, + }, + vertexShader: SliderShaders.fisheyeCurtain.vertex, + }; + + return materialParameters; + } + + private _createCurtainPlaneMaterialParameters( + transform: SliderTransform, + texture: THREE.Texture) + : THREE.ShaderMaterialParameters { + let materialParameters: THREE.ShaderMaterialParameters = { + depthWrite: false, + fragmentShader: SliderShaders.perspectiveCurtain.fragment, + side: THREE.DoubleSide, + transparent: true, + uniforms: { + curtain: { value: 1.0 }, + focal: { value: transform.focal }, + k1: { value: transform.ck1 }, + k2: { value: transform.ck2 }, + opacity: { value: 1.0 }, + projectorMat: { value: transform.basicRt }, + projectorTex: { value: texture }, + radial_peak: { value: !!transform.radialPeak ? transform.radialPeak : 0.0 }, + scale_x: { value: Math.max(transform.basicHeight, transform.basicWidth) / transform.basicWidth }, + scale_y: { value: Math.max(transform.basicWidth, transform.basicHeight) / transform.basicHeight }, + }, + vertexShader: SliderShaders.perspectiveCurtain.vertex, + }; + + return materialParameters; + } + + private _createDistortedCurtainPlaneMaterialParameters( + transform: SliderTransform, + texture: THREE.Texture) + : THREE.ShaderMaterialParameters { + let materialParameters: THREE.ShaderMaterialParameters = { + depthWrite: false, + fragmentShader: SliderShaders.perspectiveDistortedCurtain.fragment, + side: THREE.DoubleSide, + transparent: true, + uniforms: { + curtain: { value: 1.0 }, + opacity: { value: 1.0 }, + projectorMat: { value: transform.projectorMatrix() }, + projectorTex: { value: texture }, + }, + vertexShader: SliderShaders.perspectiveDistortedCurtain.vertex, + }; + + return materialParameters; + } + + private _createDistortedPlaneMaterialParameters( + transform: SliderTransform, + texture: THREE.Texture) + : THREE.ShaderMaterialParameters { + let materialParameters: THREE.ShaderMaterialParameters = { + depthWrite: false, + fragmentShader: SliderShaders.perspectiveDistorted.fragment, + side: THREE.DoubleSide, + transparent: true, + uniforms: { + opacity: { value: 1.0 }, + projectorMat: { value: transform.projectorMatrix() }, + projectorTex: { value: texture }, + }, + vertexShader: SliderShaders.perspectiveDistorted.vertex, + }; + + return materialParameters; + } + + private _createTexture(image: HTMLImageElement): THREE.Texture { + let texture: THREE.Texture = new THREE.Texture(image); + texture.minFilter = THREE.LinearFilter; + texture.needsUpdate = true; + + return texture; + } + + private _useMesh(transform: SliderTransform, image: Image): boolean { + return image.mesh.vertices.length && transform.hasValidScale; + } + + private _getImageSphereGeo(transform: SliderTransform, image: Image): THREE.BufferGeometry { + const t = transform.srtInverse; + + // push everything at least 5 meters in front of the camera + let minZ: number = 5.0 * transform.scale; + let maxZ: number = this._imageSphereRadius * transform.scale; + + let vertices: number[] = image.mesh.vertices; + let numVertices: number = vertices.length / 3; + let positions: Float32Array = new Float32Array(vertices.length); + for (let i: number = 0; i < numVertices; ++i) { + let index: number = 3 * i; + let x: number = vertices[index + 0]; + let y: number = vertices[index + 1]; + let z: number = vertices[index + 2]; + + let l: number = Math.sqrt(x * x + y * y + z * z); + let boundedL: number = Math.max(minZ, Math.min(l, maxZ)); + let factor: number = boundedL / l; + let p: THREE.Vector3 = new THREE.Vector3(x * factor, y * factor, z * factor); + + p.applyMatrix4(t); + + positions[index + 0] = p.x; + positions[index + 1] = p.y; + positions[index + 2] = p.z; + } + + let faces: number[] = image.mesh.faces; + let indices: Uint16Array = new Uint16Array(faces.length); + for (let i: number = 0; i < faces.length; ++i) { + indices[i] = faces[i]; + } + + let geometry: THREE.BufferGeometry = new THREE.BufferGeometry(); + + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + geometry.setIndex(new THREE.BufferAttribute(indices, 1)); + + return geometry; + } + + private _getImagePlaneGeo(transform: SliderTransform, image: Image): THREE.BufferGeometry { + const undistortionMarginFactor: number = 3; + const t = transform.srtInverse; + + // push everything at least 5 meters in front of the camera + let minZ: number = 5.0 * transform.scale; + let maxZ: number = this._imagePlaneDepth * transform.scale; + + let vertices: number[] = image.mesh.vertices; + let numVertices: number = vertices.length / 3; + let positions: Float32Array = new Float32Array(vertices.length); + for (let i: number = 0; i < numVertices; ++i) { + let index: number = 3 * i; + let x: number = vertices[index + 0]; + let y: number = vertices[index + 1]; + let z: number = vertices[index + 2]; + + if (i < 4) { + x *= undistortionMarginFactor; + y *= undistortionMarginFactor; + } + + let boundedZ: number = Math.max(minZ, Math.min(z, maxZ)); + let factor: number = boundedZ / z; + let p: THREE.Vector3 = new THREE.Vector3(x * factor, y * factor, boundedZ); + + p.applyMatrix4(t); + + positions[index + 0] = p.x; + positions[index + 1] = p.y; + positions[index + 2] = p.z; + } + + let faces: number[] = image.mesh.faces; + let indices: Uint16Array = new Uint16Array(faces.length); + for (let i: number = 0; i < faces.length; ++i) { + indices[i] = faces[i]; + } + + let geometry: THREE.BufferGeometry = new THREE.BufferGeometry(); + + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + geometry.setIndex(new THREE.BufferAttribute(indices, 1)); + + return geometry; + } + + private _getImagePlaneGeoFisheye(transform: SliderTransform, image: Image): THREE.BufferGeometry { + const t = transform.srtInverse; + + // push everything at least 5 meters in front of the camera + let minZ: number = 5.0 * transform.scale; + let maxZ: number = this._imagePlaneDepth * transform.scale; + + let vertices: number[] = image.mesh.vertices; + let numVertices: number = vertices.length / 3; + let positions: Float32Array = new Float32Array(vertices.length); + for (let i: number = 0; i < numVertices; ++i) { + let index: number = 3 * i; + let x: number = vertices[index + 0]; + let y: number = vertices[index + 1]; + let z: number = vertices[index + 2]; + + let l: number = Math.sqrt(x * x + y * y + z * z); + let boundedL: number = Math.max(minZ, Math.min(l, maxZ)); + let factor: number = boundedL / l; + let p: THREE.Vector3 = new THREE.Vector3(x * factor, y * factor, z * factor); + + p.applyMatrix4(t); + + positions[index + 0] = p.x; + positions[index + 1] = p.y; + positions[index + 2] = p.z; + } + + let faces: number[] = image.mesh.faces; + let indices: Uint16Array = new Uint16Array(faces.length); + for (let i: number = 0; i < faces.length; ++i) { + indices[i] = faces[i]; + } + + let geometry: THREE.BufferGeometry = new THREE.BufferGeometry(); + + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + geometry.setIndex(new THREE.BufferAttribute(indices, 1)); + + return geometry; + } + + private _getFlatImageSphereGeo(transform: SliderTransform): THREE.BufferGeometry { + const geometry = + new THREE.SphereGeometry(this._imageSphereRadius, 20, 40); + const t = transform.rt + .clone() + .invert(); + geometry.applyMatrix4(t); + return geometry; + } + + private _getRegularFlatImagePlaneGeo(transform: SliderTransform): THREE.BufferGeometry { + let width: number = transform.width; + let height: number = transform.height; + let size: number = Math.max(width, height); + let dx: number = width / 2.0 / size; + let dy: number = height / 2.0 / size; + + return this._getFlatImagePlaneGeo(transform, dx, dy); + } + + private _getFlatImagePlaneGeo(transform: SliderTransform, dx: number, dy: number): THREE.BufferGeometry { + let vertices: number[][] = []; + vertices.push(transform.unprojectSfM([-dx, -dy], this._imagePlaneDepth)); + vertices.push(transform.unprojectSfM([dx, -dy], this._imagePlaneDepth)); + vertices.push(transform.unprojectSfM([dx, dy], this._imagePlaneDepth)); + vertices.push(transform.unprojectSfM([-dx, dy], this._imagePlaneDepth)); + + return this._createFlatGeometry(vertices); + } + + private _getRegularFlatImagePlaneGeoFisheye(transform: SliderTransform): THREE.BufferGeometry { + let width: number = transform.width; + let height: number = transform.height; + let size: number = Math.max(width, height); + let dx: number = width / 2.0 / size; + let dy: number = height / 2.0 / size; + + return this._getFlatImagePlaneGeoFisheye(transform, dx, dy); + } + + private _getFlatImagePlaneGeoFisheye(transform: SliderTransform, dx: number, dy: number): THREE.BufferGeometry { + let vertices: number[][] = []; + vertices.push(transform.unprojectSfM([-dx, -dy], this._imagePlaneDepth)); + vertices.push(transform.unprojectSfM([dx, -dy], this._imagePlaneDepth)); + vertices.push(transform.unprojectSfM([dx, dy], this._imagePlaneDepth)); + vertices.push(transform.unprojectSfM([-dx, dy], this._imagePlaneDepth)); + + return this._createFlatGeometry(vertices); + } + + private _getFlatImagePlaneGeoFromBasic( + transform: SliderTransform, + basicX0: number, + basicX1: number, + basicY0: number, + basicY1: number): THREE.BufferGeometry { + + let vertices: number[][] = []; + + vertices.push(transform.unprojectBasic([basicX0, basicY0], this._imagePlaneDepth)); + vertices.push(transform.unprojectBasic([basicX1, basicY0], this._imagePlaneDepth)); + vertices.push(transform.unprojectBasic([basicX1, basicY1], this._imagePlaneDepth)); + vertices.push(transform.unprojectBasic([basicX0, basicY1], this._imagePlaneDepth)); + + return this._createFlatGeometry(vertices); + } + + private _createFlatGeometry(vertices: number[][]): THREE.BufferGeometry { + let positions: Float32Array = new Float32Array(12); + for (let i: number = 0; i < vertices.length; i++) { + let index: number = 3 * i; + positions[index + 0] = vertices[i][0]; + positions[index + 1] = vertices[i][1]; + positions[index + 2] = vertices[i][2]; + } + + let indices: Uint16Array = new Uint16Array(6); + indices[0] = 0; + indices[1] = 1; + indices[2] = 3; + indices[3] = 1; + indices[4] = 2; + indices[5] = 3; + + let geometry: THREE.BufferGeometry = new THREE.BufferGeometry(); + + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + geometry.setIndex(new THREE.BufferAttribute(indices, 1)); + + return geometry; + } +} diff --git a/src/component/slider/SliderMeshScene.ts b/src/component/slider/SliderMeshScene.ts new file mode 100644 index 000000000..d2908c1b5 --- /dev/null +++ b/src/component/slider/SliderMeshScene.ts @@ -0,0 +1,140 @@ +import { Material, Mesh, Scene, Texture } from "three"; +import { ICamera } from "../../geometry/interfaces/ICamera"; +import { ProjectorShaderMaterial } from "../image/interfaces/ProjectorShaderMaterial"; + +export type SliderMeshSceneItem = { + camera: ICamera; + imageId: string; + mesh: Mesh; +}; + +export class SliderMeshScene { + private _planes: SliderMeshSceneItem[]; + private _planesOld: SliderMeshSceneItem[]; + private _planesPeriphery: SliderMeshSceneItem[]; + + private _scene: Scene; + private _sceneOld: Scene; + private _scenePeriphery: Scene; + + constructor() { + this._planes = []; + this._planesOld = []; + this._planesPeriphery = []; + + this._scene = new Scene(); + this._sceneOld = new Scene(); + this._scenePeriphery = new Scene(); + } + + public get planes(): SliderMeshSceneItem[] { + return this._planes; + } + + public get planesOld(): SliderMeshSceneItem[] { + return this._planesOld; + } + + public get planesPeriphery(): SliderMeshSceneItem[] { + return this._planesPeriphery; + } + + public get scene(): Scene { + return this._scene; + } + + public get sceneOld(): Scene { + return this._sceneOld; + } + + public get scenePeriphery(): Scene { + return this._scenePeriphery; + } + + public updateImagePlanes(planes: SliderMeshSceneItem[]): void { + this._dispose(this._planesOld, this.sceneOld); + + for (const plane of this._planes) { + this._scene.remove(plane.mesh); + this._sceneOld.add(plane.mesh); + } + + for (const plane of planes) { + this._scene.add(plane.mesh); + } + + this._planesOld = this._planes; + this._planes = planes; + } + + public addImagePlanes(planes: SliderMeshSceneItem[]): void { + for (const plane of planes) { + this._scene.add(plane.mesh); + this._planes.push(plane); + } + } + + public addImagePlanesOld(planes: SliderMeshSceneItem[]): void { + for (const plane of planes) { + this._sceneOld.add(plane.mesh); + this._planesOld.push(plane); + } + } + + public setImagePlanes(planes: SliderMeshSceneItem[]): void { + this._clear(); + this.addImagePlanes(planes); + } + + public addPeripheryPlanes(planes: SliderMeshSceneItem[]): void { + for (const plane of planes) { + this._scenePeriphery.add(plane.mesh); + this._planesPeriphery.push(plane); + } + } + + public setPeripheryPlanes(planes: SliderMeshSceneItem[]): void { + this._clearPeriphery(); + this.addPeripheryPlanes(planes); + } + + public setImagePlanesOld(planes: SliderMeshSceneItem[]): void { + this._clearOld(); + this.addImagePlanesOld(planes); + } + + public clear(): void { + this._clear(); + this._clearOld(); + } + + private _clear(): void { + this._dispose(this._planes, this._scene); + this._planes = []; + } + + private _clearOld(): void { + this._dispose(this._planesOld, this._sceneOld); + this._planesOld = []; + } + + private _clearPeriphery(): void { + this._dispose(this._planesPeriphery, this._scenePeriphery); + this._planesPeriphery = []; + } + + private _dispose(planes: SliderMeshSceneItem[], scene: Scene): void { + for (const plane of planes) { + const { mesh } = plane; + scene.remove(mesh); + mesh.geometry.dispose(); + (mesh.material).dispose(); + const texture: Texture = + (mesh.material) + .uniforms.projectorTex.value; + if (texture != null) { + texture.dispose(); + } + } + } +} diff --git a/src/component/slider/SliderShaders.ts b/src/component/slider/SliderShaders.ts new file mode 100644 index 000000000..f82286d80 --- /dev/null +++ b/src/component/slider/SliderShaders.ts @@ -0,0 +1,52 @@ +import { GLShader } from "../../shader/Shader"; +import { fisheyeCurtainFrag } from "./shaders/fisheye_curtain.fragment.glsl"; +import { fisheyeCurtainVert } from "./shaders/fisheye_curtain.vertex.glsl"; +import { fisheyeFrag } from "./shaders/fisheye.fragment.glsl"; +import { fisheyeVert } from "./shaders/fisheye.vertex.glsl"; +import { perspectiveCurtainFrag } from "./shaders/perspective_curtain.fragment.glsl"; +import { perspectiveCurtainVert } from "./shaders/perspective_curtain.vertex.glsl"; +import { perspectiveDistortedCurtainFrag } from "./shaders/perspective_distorted_curtain.fragment.glsl"; +import { perspectiveDistortedCurtainVert } from "./shaders/perspective_distorted_curtain.vertex.glsl"; +import { perspectiveDistortedFrag } from "./shaders/perspective_distorted.fragment.glsl"; +import { perspectiveDistortedVert } from "./shaders/perspective_distorted.vertex.glsl"; +import { perspectiveFrag } from "./shaders/perspective.fragment.glsl"; +import { perspectiveVert } from "./shaders/perspective.vertex.glsl"; +import { sphericalCurtainFrag } from "./shaders/spherical_curtain.fragment.glsl"; +import { sphericalCurtainVert } from "./shaders/spherical_curtain.vertex.glsl"; +import { sphericalFrag } from "./shaders/spherical.fragment.glsl"; +import { sphericalVert } from "./shaders/spherical.vertex.glsl"; + +export class SliderShaders { + public static fisheye: GLShader = { + fragment: fisheyeFrag, + vertex: fisheyeVert, + }; + public static fisheyeCurtain: GLShader = { + fragment: fisheyeCurtainFrag, + vertex: fisheyeCurtainVert, + }; + public static perspective: GLShader = { + fragment: perspectiveFrag, + vertex: perspectiveVert, + }; + public static perspectiveCurtain: GLShader = { + fragment: perspectiveCurtainFrag, + vertex: perspectiveCurtainVert, + }; + public static perspectiveDistorted: GLShader = { + fragment: perspectiveDistortedFrag, + vertex: perspectiveDistortedVert, + }; + public static perspectiveDistortedCurtain: GLShader = { + fragment: perspectiveDistortedCurtainFrag, + vertex: perspectiveDistortedCurtainVert, + }; + public static spherical: GLShader = { + fragment: sphericalFrag, + vertex: sphericalVert, + }; + public static sphericalCurtain: GLShader = { + fragment: sphericalCurtainFrag, + vertex: sphericalCurtainVert, + }; +} diff --git a/src/component/slider/SliderTransform.ts b/src/component/slider/SliderTransform.ts new file mode 100644 index 000000000..5019ef219 --- /dev/null +++ b/src/component/slider/SliderTransform.ts @@ -0,0 +1,722 @@ +import * as THREE from "three"; +import { isFisheye, isSpherical } from "../../geo/Geo"; + +import { CameraType } from "../../geo/interfaces/CameraType" + +const EPSILON = 1e-8; + +/** + * @class Slider transform + * + * @classdesc Class used for calculating coordinate transformations + * and projections. + */ +export class SliderTransform { + private _width: number; + private _height: number; + private _focal: number; + private _orientation: number; + private _scale: number; + private _basicWidth: number; + private _basicHeight: number; + private _basicAspect: number; + + private _worldToCamera: THREE.Matrix4; + private _worldToCameraInverse: THREE.Matrix4; + private _scaledWorldToCamera: THREE.Matrix4; + private _scaledWorldToCameraInverse: THREE.Matrix4; + private _basicWorldToCamera: THREE.Matrix4; + + private _textureScale: number[]; + + private _ck1: number; + private _ck2: number; + private _cameraType: CameraType; + + private _radialPeak: number; + + /** + * Create a new transform instance. + * @param {number} orientation - Image orientation. + * @param {number} width - Image height. + * @param {number} height - Image width. + * @param {number} focal - Focal length. + * @param {number} scale - Atomic scale. + * @param {Array} rotation - Rotation vector in three dimensions. + * @param {Array} translation - Translation vector in three dimensions. + * @param {HTMLImageElement} image - Image for fallback size calculations. + */ + constructor( + orientation: number, + width: number, + height: number, + scale: number, + rotation: number[], + translation: number[], + image: HTMLImageElement, + textureScale?: number[], + cameraParameters?: number[], + cameraType?: CameraType) { + + this._orientation = this._getValue(orientation, 1); + + let imageWidth = image != null ? image.width : 4; + let imageHeight = image != null ? image.height : 3; + let keepOrientation = this._orientation < 5; + + this._width = this._getValue(width, keepOrientation ? imageWidth : imageHeight); + this._height = this._getValue(height, keepOrientation ? imageHeight : imageWidth); + + this._basicAspect = keepOrientation ? + this._width / this._height : + this._height / this._width; + + this._basicWidth = keepOrientation ? width : height; + this._basicHeight = keepOrientation ? height : width; + + const parameters = this._getCameraParameters( + cameraParameters, + cameraType); + const focal = parameters[0]; + const ck1 = parameters[1]; + const ck2 = parameters[2]; + + this._focal = this._getValue(focal, 1); + this._scale = this._getValue(scale, 0); + + this._worldToCamera = this.createWorldToCamera(rotation, translation); + this._worldToCameraInverse = new THREE.Matrix4() + .copy(this._worldToCamera) + .invert() + this._scaledWorldToCamera = + this._createScaledWorldToCamera(this._worldToCamera, this._scale); + this._scaledWorldToCameraInverse = new THREE.Matrix4() + .copy(this._scaledWorldToCamera) + .invert(); + + this._basicWorldToCamera = this._createBasicWorldToCamera( + this._worldToCamera, + orientation); + + this._textureScale = !!textureScale ? textureScale : [1, 1]; + + this._ck1 = !!ck1 ? ck1 : 0; + this._ck2 = !!ck2 ? ck2 : 0; + this._cameraType = !!cameraType ? + cameraType : + "perspective"; + + this._radialPeak = this._getRadialPeak(this._ck1, this._ck2); + } + + public get ck1(): number { + return this._ck1; + } + + public get ck2(): number { + return this._ck2; + } + + public get cameraType(): CameraType { + return this._cameraType; + } + + /** + * Get basic aspect. + * @returns {number} The orientation adjusted aspect ratio. + */ + public get basicAspect(): number { + return this._basicAspect; + } + + /** + * Get basic height. + * + * @description Does not fall back to image image height but + * uses original value from API so can be faulty. + * + * @returns {number} The height of the basic version image + * (adjusted for orientation). + */ + public get basicHeight(): number { + return this._basicHeight; + } + + public get basicRt(): THREE.Matrix4 { + return this._basicWorldToCamera; + } + + /** + * Get basic width. + * + * @description Does not fall back to image image width but + * uses original value from API so can be faulty. + * + * @returns {number} The width of the basic version image + * (adjusted for orientation). + */ + public get basicWidth(): number { + return this._basicWidth; + } + + /** + * Get focal. + * @returns {number} The image focal length. + */ + public get focal(): number { + return this._focal; + } + + /** + * Get height. + * + * @description Falls back to the image image height if + * the API data is faulty. + * + * @returns {number} The orientation adjusted image height. + */ + public get height(): number { + return this._height; + } + + /** + * Get orientation. + * @returns {number} The image orientation. + */ + public get orientation(): number { + return this._orientation; + } + + /** + * Get rt. + * @returns {THREE.Matrix4} The extrinsic camera matrix. + */ + public get rt(): THREE.Matrix4 { + return this._worldToCamera; + } + + /** + * Get srt. + * @returns {THREE.Matrix4} The scaled extrinsic camera matrix. + */ + public get srt(): THREE.Matrix4 { + return this._scaledWorldToCamera; + } + + /** + * Get srtInverse. + * @returns {THREE.Matrix4} The scaled extrinsic camera matrix. + */ + public get srtInverse(): THREE.Matrix4 { + return this._scaledWorldToCameraInverse; + } + + /** + * Get scale. + * @returns {number} The image atomic reconstruction scale. + */ + public get scale(): number { + return this._scale; + } + + /** + * Get has valid scale. + * @returns {boolean} Value indicating if the scale of the transform is valid. + */ + public get hasValidScale(): boolean { + return this._scale > 1e-2 && this._scale < 50; + } + + /** + * Get radial peak. + * @returns {number} Value indicating the radius where the radial + * undistortion function peaks. + */ + public get radialPeak(): number { + return this._radialPeak; + } + + /** + * Get width. + * + * @description Falls back to the image image width if + * the API data is faulty. + * + * @returns {number} The orientation adjusted image width. + */ + public get width(): number { + return this._width; + } + + /** + * Calculate the up vector for the image transform. + * + * @returns {THREE.Vector3} Normalized and orientation adjusted up vector. + */ + public upVector(): THREE.Vector3 { + let rte: number[] = this._worldToCamera.elements; + + switch (this._orientation) { + case 1: + return new THREE.Vector3(-rte[1], -rte[5], -rte[9]); + case 3: + return new THREE.Vector3(rte[1], rte[5], rte[9]); + case 6: + return new THREE.Vector3(-rte[0], -rte[4], -rte[8]); + case 8: + return new THREE.Vector3(rte[0], rte[4], rte[8]); + default: + return new THREE.Vector3(-rte[1], -rte[5], -rte[9]); + } + } + + /** + * Calculate projector matrix for projecting 3D points to texture map + * coordinates (u and v). + * + * @returns {THREE.Matrix4} Projection matrix for 3D point to texture + * map coordinate calculations. + */ + public projectorMatrix(): THREE.Matrix4 { + let projector: THREE.Matrix4 = this._normalizedToTextureMatrix(); + + let f: number = this._focal; + let projection: THREE.Matrix4 = new THREE.Matrix4().set( + f, 0, 0, 0, + 0, f, 0, 0, + 0, 0, 0, 0, + 0, 0, 1, 0); + + projector.multiply(projection); + projector.multiply(this._worldToCamera); + + return projector; + } + + /** + * Project 3D world coordinates to basic coordinates. + * + * @param {Array} point3d - 3D world coordinates. + * @return {Array} 2D basic coordinates. + */ + public projectBasic(point3d: number[]): number[] { + let sfm: number[] = this.projectSfM(point3d); + return this._sfmToBasic(sfm); + } + + /** + * Unproject basic coordinates to 3D world coordinates. + * + * @param {Array} basic - 2D basic coordinates. + * @param {Array} distance - Distance to unproject from camera center. + * @param {boolean} [depth] - Treat the distance value as depth from camera center. + * Only applicable for perspective images. Will be + * ignored for spherical. + * @returns {Array} Unprojected 3D world coordinates. + */ + public unprojectBasic(basic: number[], distance: number, depth?: boolean): number[] { + let sfm: number[] = this._basicToSfm(basic); + return this.unprojectSfM(sfm, distance, depth); + } + + /** + * Project 3D world coordinates to SfM coordinates. + * + * @param {Array} point3d - 3D world coordinates. + * @return {Array} 2D SfM coordinates. + */ + public projectSfM(point3d: number[]): number[] { + let v: THREE.Vector4 = new THREE.Vector4(point3d[0], point3d[1], point3d[2], 1); + v.applyMatrix4(this._worldToCamera); + return this._bearingToSfm([v.x, v.y, v.z]); + } + + /** + * Unproject SfM coordinates to a 3D world coordinates. + * + * @param {Array} sfm - 2D SfM coordinates. + * @param {Array} distance - Distance to unproject + * from camera center. + * @param {boolean} [depth] - Treat the distance value as + * depth from camera center. Only applicable for perspective + * images. Will be ignored for spherical. + * @returns {Array} Unprojected 3D world coordinates. + */ + public unprojectSfM( + sfm: number[], + distance: number, + depth?: boolean): number[] { + const bearing = this._sfmToBearing(sfm); + const unprojectedCamera = depth && !isSpherical(this._cameraType) ? + new THREE.Vector4( + distance * bearing[0] / bearing[2], + distance * bearing[1] / bearing[2], + distance, + 1) : + new THREE.Vector4( + distance * bearing[0], + distance * bearing[1], + distance * bearing[2], + 1); + + const unprojectedWorld = unprojectedCamera + .applyMatrix4(this._worldToCameraInverse); + return [ + unprojectedWorld.x / unprojectedWorld.w, + unprojectedWorld.y / unprojectedWorld.w, + unprojectedWorld.z / unprojectedWorld.w, + ]; + } + + /** + * Transform SfM coordinates to bearing vector (3D cartesian + * coordinates on the unit sphere). + * + * @param {Array} sfm - 2D SfM coordinates. + * @returns {Array} Bearing vector (3D cartesian coordinates + * on the unit sphere). + */ + private _sfmToBearing(sfm: number[]): number[] { + if (isSpherical(this._cameraType)) { + let lng: number = sfm[0] * 2 * Math.PI; + let lat: number = -sfm[1] * 2 * Math.PI; + let x: number = Math.cos(lat) * Math.sin(lng); + let y: number = -Math.sin(lat); + let z: number = Math.cos(lat) * Math.cos(lng); + return [x, y, z]; + } else if (isFisheye(this._cameraType)) { + let [dxn, dyn]: number[] = [sfm[0] / this._focal, sfm[1] / this._focal]; + const dTheta: number = Math.sqrt(dxn * dxn + dyn * dyn); + let d: number = this._distortionFromDistortedRadius(dTheta, this._ck1, this._ck2, this._radialPeak); + let theta: number = dTheta / d; + let z: number = Math.cos(theta); + let r: number = Math.sin(theta); + const denomTheta = dTheta > EPSILON ? 1 / dTheta : 1; + let x: number = r * dxn * denomTheta; + let y: number = r * dyn * denomTheta; + return [x, y, z]; + } else { + let [dxn, dyn]: number[] = [sfm[0] / this._focal, sfm[1] / this._focal]; + const dr: number = Math.sqrt(dxn * dxn + dyn * dyn); + let d: number = this._distortionFromDistortedRadius(dr, this._ck1, this._ck2, this._radialPeak); + + const xn: number = dxn / d; + const yn: number = dyn / d; + + let v: THREE.Vector3 = new THREE.Vector3(xn, yn, 1); + v.normalize(); + return [v.x, v.y, v.z]; + } + } + + /** Compute distortion given the distorted radius. + * + * Solves for d in the equation + * y = d(x, k1, k2) * x + * given the distorted radius, y. + */ + private _distortionFromDistortedRadius(distortedRadius: number, k1: number, k2: number, radialPeak: number): number { + let d: number = 1.0; + for (let i: number = 0; i < 10; i++) { + let radius: number = distortedRadius / d; + if (radius > radialPeak) { + radius = radialPeak; + } + d = 1 + k1 * radius ** 2 + k2 * radius ** 4; + } + return d; + } + + /** + * Transform bearing vector (3D cartesian coordiantes on the unit sphere) to + * SfM coordinates. + * + * @param {Array} bearing - Bearing vector (3D cartesian coordinates on the + * unit sphere). + * @returns {Array} 2D SfM coordinates. + */ + private _bearingToSfm(bearing: number[]): number[] { + if (isSpherical(this._cameraType)) { + let x: number = bearing[0]; + let y: number = bearing[1]; + let z: number = bearing[2]; + let lng: number = Math.atan2(x, z); + let lat: number = Math.atan2(-y, Math.sqrt(x * x + z * z)); + return [lng / (2 * Math.PI), -lat / (2 * Math.PI)]; + } else if (isFisheye(this._cameraType)) { + if (bearing[2] > 0) { + const [x, y, z]: number[] = bearing; + const r: number = Math.sqrt(x * x + y * y); + let theta: number = Math.atan2(r, z); + + if (theta > this._radialPeak) { + theta = this._radialPeak; + } + + const distortion: number = 1.0 + theta ** 2 * (this._ck1 + theta ** 2 * this._ck2); + const s: number = this._focal * distortion * theta / r; + + return [s * x, s * y]; + } else { + return [ + bearing[0] < 0 ? Number.NEGATIVE_INFINITY : Number.POSITIVE_INFINITY, + bearing[1] < 0 ? Number.NEGATIVE_INFINITY : Number.POSITIVE_INFINITY, + ]; + } + } else { + if (bearing[2] > 0) { + let [xn, yn]: number[] = [bearing[0] / bearing[2], bearing[1] / bearing[2]]; + let r2: number = xn * xn + yn * yn; + const rp2: number = this._radialPeak ** 2; + + if (r2 > rp2) { + r2 = rp2; + } + + const d: number = 1 + this._ck1 * r2 + this._ck2 * r2 ** 2; + return [ + this._focal * d * xn, + this._focal * d * yn, + ]; + } else { + return [ + bearing[0] < 0 ? Number.NEGATIVE_INFINITY : Number.POSITIVE_INFINITY, + bearing[1] < 0 ? Number.NEGATIVE_INFINITY : Number.POSITIVE_INFINITY, + ]; + } + } + } + + /** + * Convert basic coordinates to SfM coordinates. + * + * @param {Array} basic - 2D basic coordinates. + * @returns {Array} 2D SfM coordinates. + */ + private _basicToSfm(basic: number[]): number[] { + let rotatedX: number; + let rotatedY: number; + + switch (this._orientation) { + case 1: + rotatedX = basic[0]; + rotatedY = basic[1]; + break; + case 3: + rotatedX = 1 - basic[0]; + rotatedY = 1 - basic[1]; + break; + case 6: + rotatedX = basic[1]; + rotatedY = 1 - basic[0]; + break; + case 8: + rotatedX = 1 - basic[1]; + rotatedY = basic[0]; + break; + default: + rotatedX = basic[0]; + rotatedY = basic[1]; + break; + } + + let w: number = this._width; + let h: number = this._height; + let s: number = Math.max(w, h); + let sfmX: number = rotatedX * w / s - w / s / 2; + let sfmY: number = rotatedY * h / s - h / s / 2; + + return [sfmX, sfmY]; + } + + /** + * Convert SfM coordinates to basic coordinates. + * + * @param {Array} sfm - 2D SfM coordinates. + * @returns {Array} 2D basic coordinates. + */ + private _sfmToBasic(sfm: number[]): number[] { + let w: number = this._width; + let h: number = this._height; + let s: number = Math.max(w, h); + let rotatedX: number = (sfm[0] + w / s / 2) / w * s; + let rotatedY: number = (sfm[1] + h / s / 2) / h * s; + + let basicX: number; + let basicY: number; + + switch (this._orientation) { + case 1: + basicX = rotatedX; + basicY = rotatedY; + break; + case 3: + basicX = 1 - rotatedX; + basicY = 1 - rotatedY; + break; + case 6: + basicX = 1 - rotatedY; + basicY = rotatedX; + break; + case 8: + basicX = rotatedY; + basicY = 1 - rotatedX; + break; + default: + basicX = rotatedX; + basicY = rotatedY; + break; + } + + return [basicX, basicY]; + } + + /** + * Checks a value and returns it if it exists and is larger than 0. + * Fallbacks if it is null. + * + * @param {number} value - Value to check. + * @param {number} fallback - Value to fall back to. + * @returns {number} The value or its fallback value if it is not defined or negative. + */ + private _getValue(value: number, fallback: number): number { + return value != null && value > 0 ? value : fallback; + } + + private _getCameraParameters( + value: number[], + cameraType: string): number[] { + if (isSpherical(cameraType)) { return []; } + if (!value || value.length === 0) { return [1, 0, 0]; } + + const padding = 3 - value.length; + if (padding <= 0) { return value; } + + return value + .concat( + new Array(padding) + .fill(0)); + } + + /** + * Creates the extrinsic camera matrix [ R | t ]. + * + * @param {Array} rotation - Rotation vector in angle axis representation. + * @param {Array} translation - Translation vector. + * @returns {THREE.Matrix4} Extrisic camera matrix. + */ + private createWorldToCamera( + rotation: number[], + translation: number[]): THREE.Matrix4 { + const axis = new THREE.Vector3(rotation[0], rotation[1], rotation[2]); + const angle = axis.length(); + if (angle > 0) { + axis.normalize(); + } + + const worldToCamera = new THREE.Matrix4(); + worldToCamera.makeRotationAxis(axis, angle); + worldToCamera.setPosition( + new THREE.Vector3( + translation[0], + translation[1], + translation[2])); + + return worldToCamera; + } + + /** + * Calculates the scaled extrinsic camera matrix scale * [ R | t ]. + * + * @param {THREE.Matrix4} worldToCamera - Extrisic camera matrix. + * @param {number} scale - Scale factor. + * @returns {THREE.Matrix4} Scaled extrisic camera matrix. + */ + private _createScaledWorldToCamera( + worldToCamera: THREE.Matrix4, + scale: number): THREE.Matrix4 { + const scaledWorldToCamera = worldToCamera.clone(); + const elements = scaledWorldToCamera.elements; + elements[12] = scale * elements[12]; + elements[13] = scale * elements[13]; + elements[14] = scale * elements[14]; + scaledWorldToCamera.scale(new THREE.Vector3(scale, scale, scale)); + return scaledWorldToCamera; + } + + private _createBasicWorldToCamera(rt: THREE.Matrix4, orientation: number): THREE.Matrix4 { + const axis: THREE.Vector3 = new THREE.Vector3(0, 0, 1); + let angle: number = 0; + + switch (orientation) { + case 3: + angle = Math.PI; + break; + case 6: + angle = Math.PI / 2; + break; + case 8: + angle = 3 * Math.PI / 2; + break; + default: + break; + } + + return new THREE.Matrix4() + .makeRotationAxis(axis, angle) + .multiply(rt); + } + + private _getRadialPeak(k1: number, k2: number): number { + const a: number = 5 * k2; + const b: number = 3 * k1; + const c: number = 1; + const d: number = b ** 2 - 4 * a * c; + + if (d < 0) { + return undefined; + } + + const root1: number = (-b - Math.sqrt(d)) / 2 / a; + const root2: number = (-b + Math.sqrt(d)) / 2 / a; + + const minRoot: number = Math.min(root1, root2); + const maxRoot: number = Math.max(root1, root2); + + return minRoot > 0 ? + Math.sqrt(minRoot) : + maxRoot > 0 ? + Math.sqrt(maxRoot) : + undefined; + } + + /** + * Calculate a transformation matrix from normalized coordinates for + * texture map coordinates. + * + * @returns {THREE.Matrix4} Normalized coordinates to texture map + * coordinates transformation matrix. + */ + private _normalizedToTextureMatrix(): THREE.Matrix4 { + const size: number = Math.max(this._width, this._height); + + const scaleX: number = this._orientation < 5 ? this._textureScale[0] : this._textureScale[1]; + const scaleY: number = this._orientation < 5 ? this._textureScale[1] : this._textureScale[0]; + + const w: number = size / this._width * scaleX; + const h: number = size / this._height * scaleY; + + switch (this._orientation) { + case 1: + return new THREE.Matrix4().set(w, 0, 0, 0.5, 0, -h, 0, 0.5, 0, 0, 1, 0, 0, 0, 0, 1); + case 3: + return new THREE.Matrix4().set(-w, 0, 0, 0.5, 0, h, 0, 0.5, 0, 0, 1, 0, 0, 0, 0, 1); + case 6: + return new THREE.Matrix4().set(0, -h, 0, 0.5, -w, 0, 0, 0.5, 0, 0, 1, 0, 0, 0, 0, 1); + case 8: + return new THREE.Matrix4().set(0, h, 0, 0.5, w, 0, 0, 0.5, 0, 0, 1, 0, 0, 0, 0, 1); + default: + return new THREE.Matrix4().set(w, 0, 0, 0.5, 0, -h, 0, 0.5, 0, 0, 1, 0, 0, 0, 0, 1); + } + } +} diff --git a/src/component/slider/interfaces/SliderBBoxProjectorShaderMaterial.ts b/src/component/slider/interfaces/SliderBBoxProjectorShaderMaterial.ts new file mode 100644 index 000000000..68c536434 --- /dev/null +++ b/src/component/slider/interfaces/SliderBBoxProjectorShaderMaterial.ts @@ -0,0 +1,8 @@ +export interface SliderBBoxProjectorShaderMaterial extends THREE.ShaderMaterial { + uniforms: { + [uniform: string]: THREE.IUniform; + opacity: THREE.IUniform; + projectorTex: THREE.IUniform; + bbox: THREE.IUniform; + }; +} diff --git a/src/component/slider/shaders/fisheye.fragment.glsl.ts b/src/component/slider/shaders/fisheye.fragment.glsl.ts new file mode 100644 index 000000000..956d5ad8a --- /dev/null +++ b/src/component/slider/shaders/fisheye.fragment.glsl.ts @@ -0,0 +1,49 @@ +export const fisheyeFrag = ` +#ifdef GL_FRAGMENT_PRECISION_HIGH +precision highp float; +#else +precision mediump float; +#endif + +uniform sampler2D projectorTex; +uniform float opacity; +uniform float focal; +uniform float k1; +uniform float k2; +uniform float scale_x; +uniform float scale_y; +uniform float radial_peak; + +varying vec4 vRstq; + +void main() +{ + float x = vRstq.x; + float y = vRstq.y; + float z = vRstq.z; + + float r = sqrt(x * x + y * y); + float theta = atan(r, z); + + if (radial_peak > 0. && theta > radial_peak) { + theta = radial_peak; + } + + float theta2 = theta * theta; + float theta_d = theta * (1.0 + theta2 * (k1 + theta2 * k2)); + float s = focal * theta_d / r; + + float u = scale_x * s * x + 0.5; + float v = -scale_y * s * y + 0.5; + + vec4 baseColor; + if (u >= 0. && u <= 1. && v >= 0. && v <= 1.) { + baseColor = texture2D(projectorTex, vec2(u, v)); + baseColor.a = opacity; + } else { + baseColor = vec4(0.0, 0.0, 0.0, 0.0); + } + + gl_FragColor = baseColor; +} +` diff --git a/src/component/slider/shaders/fisheye.vertex.glsl.ts b/src/component/slider/shaders/fisheye.vertex.glsl.ts new file mode 100644 index 000000000..7cde2fdae --- /dev/null +++ b/src/component/slider/shaders/fisheye.vertex.glsl.ts @@ -0,0 +1,15 @@ +export const fisheyeVert = ` +#ifdef GL_ES +precision highp float; +#endif + +uniform mat4 projectorMat; + +varying vec4 vRstq; + +void main() +{ + vRstq = projectorMat * vec4(position, 1.0); + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); +} +` diff --git a/src/component/slider/shaders/fisheye_curtain.fragment.glsl.ts b/src/component/slider/shaders/fisheye_curtain.fragment.glsl.ts new file mode 100644 index 000000000..9c9dfe96d --- /dev/null +++ b/src/component/slider/shaders/fisheye_curtain.fragment.glsl.ts @@ -0,0 +1,41 @@ +export const fisheyeCurtainFrag = ` +#ifdef GL_FRAGMENT_PRECISION_HIGH +precision highp float; +#else +precision mediump float; +#endif +uniform sampler2D projectorTex; +uniform float opacity; +uniform float focal; +uniform float k1; +uniform float k2; +uniform float scale_x; +uniform float scale_y; +uniform float radial_peak; +uniform float curtain; +varying vec4 vRstq; +void main() +{ + float x = vRstq.x; + float y = vRstq.y; + float z = vRstq.z; + float r2 = sqrt(x * x + y * y); + float theta = atan(r2, z); + if (radial_peak > 0. && theta > radial_peak) { + theta = radial_peak; + } + float theta2 = theta * theta; + float theta_d = theta * (1.0 + theta2 * (k1 + theta2 * k2)); + float s = focal * theta_d / r2; + float u = scale_x * s * x + 0.5; + float v = -scale_y * s * y + 0.5; + vec4 baseColor; + if ((u < curtain || curtain >= 1.0) && u >= 0. && u <= 1. && v >= 0. && v <= 1.) { + baseColor = texture2D(projectorTex, vec2(u, v)); + baseColor.a = opacity; + } else { + baseColor = vec4(0.0, 0.0, 0.0, 0.0); + } + gl_FragColor = baseColor; +} +` diff --git a/src/component/slider/shaders/fisheye_curtain.vertex.glsl.ts b/src/component/slider/shaders/fisheye_curtain.vertex.glsl.ts new file mode 100644 index 000000000..6570e85d7 --- /dev/null +++ b/src/component/slider/shaders/fisheye_curtain.vertex.glsl.ts @@ -0,0 +1,12 @@ +export const fisheyeCurtainVert = ` +#ifdef GL_ES +precision highp float; +#endif +uniform mat4 projectorMat; +varying vec4 vRstq; +void main() +{ + vRstq = projectorMat * vec4(position, 1.0); + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); +} +` diff --git a/src/component/slider/shaders/perspective.fragment.glsl.ts b/src/component/slider/shaders/perspective.fragment.glsl.ts new file mode 100644 index 000000000..17567d8da --- /dev/null +++ b/src/component/slider/shaders/perspective.fragment.glsl.ts @@ -0,0 +1,43 @@ +export const perspectiveFrag = ` +#ifdef GL_FRAGMENT_PRECISION_HIGH +precision highp float; +#else +precision mediump float; +#endif + +uniform sampler2D projectorTex; +uniform float opacity; +uniform float focal; +uniform float k1; +uniform float k2; +uniform float scale_x; +uniform float scale_y; +uniform float radial_peak; + +varying vec4 vRstq; + +void main() +{ + float x = vRstq.x / vRstq.z; + float y = vRstq.y / vRstq.z; + float r2 = x * x + y * y; + + if (radial_peak > 0. && r2 > radial_peak * sqrt(r2)) { + r2 = radial_peak * radial_peak; + } + + float d = 1.0 + k1 * r2 + k2 * r2 * r2; + float u = scale_x * focal * d * x + 0.5; + float v = - scale_y * focal * d * y + 0.5; + + vec4 baseColor; + if (u >= 0. && u <= 1. && v >= 0. && v <= 1.) { + baseColor = texture2D(projectorTex, vec2(u, v)); + baseColor.a = opacity; + } else { + baseColor = vec4(0.0, 0.0, 0.0, 0.0); + } + + gl_FragColor = baseColor; +} +` diff --git a/src/component/slider/shaders/perspective.vertex.glsl.ts b/src/component/slider/shaders/perspective.vertex.glsl.ts new file mode 100644 index 000000000..7a7420697 --- /dev/null +++ b/src/component/slider/shaders/perspective.vertex.glsl.ts @@ -0,0 +1,15 @@ +export const perspectiveVert = ` +#ifdef GL_ES +precision highp float; +#endif + +uniform mat4 projectorMat; + +varying vec4 vRstq; + +void main() +{ + vRstq = projectorMat * vec4(position, 1.0); + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); +} +` diff --git a/src/component/slider/shaders/perspective_curtain.fragment.glsl.ts b/src/component/slider/shaders/perspective_curtain.fragment.glsl.ts new file mode 100644 index 000000000..b688b81a1 --- /dev/null +++ b/src/component/slider/shaders/perspective_curtain.fragment.glsl.ts @@ -0,0 +1,37 @@ +export const perspectiveCurtainFrag = ` +#ifdef GL_FRAGMENT_PRECISION_HIGH +precision highp float; +#else +precision mediump float; +#endif +uniform sampler2D projectorTex; +uniform float opacity; +uniform float focal; +uniform float k1; +uniform float k2; +uniform float scale_x; +uniform float scale_y; +uniform float radial_peak; +uniform float curtain; +varying vec4 vRstq; +void main() +{ + float x = vRstq.x / vRstq.z; + float y = vRstq.y / vRstq.z; + float r2 = x * x + y * y; + if (radial_peak > 0. && r2 > radial_peak * sqrt(r2)) { + r2 = radial_peak * radial_peak; + } + float d = 1.0 + k1 * r2 + k2 * r2 * r2; + float u = scale_x * focal * d * x + 0.5; + float v = - scale_y * focal * d * y + 0.5; + vec4 baseColor; + if ((u < curtain || curtain >= 1.0) && u >= 0. && u <= 1. && v >= 0. && v <= 1.) { + baseColor = texture2D(projectorTex, vec2(u, v)); + baseColor.a = opacity; + } else { + baseColor = vec4(0.0, 0.0, 0.0, 0.0); + } + gl_FragColor = baseColor; +} +` diff --git a/src/component/slider/shaders/perspective_curtain.vertex.glsl.ts b/src/component/slider/shaders/perspective_curtain.vertex.glsl.ts new file mode 100644 index 000000000..aa0fd703c --- /dev/null +++ b/src/component/slider/shaders/perspective_curtain.vertex.glsl.ts @@ -0,0 +1,12 @@ +export const perspectiveCurtainVert = ` +#ifdef GL_ES +precision highp float; +#endif +uniform mat4 projectorMat; +varying vec4 vRstq; +void main() +{ + vRstq = projectorMat * vec4(position, 1.0); + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); +} +` diff --git a/src/component/slider/shaders/perspective_distorted.fragment.glsl.ts b/src/component/slider/shaders/perspective_distorted.fragment.glsl.ts new file mode 100644 index 000000000..b8caab5e5 --- /dev/null +++ b/src/component/slider/shaders/perspective_distorted.fragment.glsl.ts @@ -0,0 +1,23 @@ +export const perspectiveDistortedFrag = ` +#ifdef GL_FRAGMENT_PRECISION_HIGH +precision highp float; +#else +precision mediump float; +#endif +uniform sampler2D projectorTex; +uniform float opacity; +varying vec4 vRstq; +void main() +{ + float u = vRstq.x / vRstq.w; + float v = vRstq.y / vRstq.w; + vec4 baseColor; + if (u >= 0. && u <= 1. && v >= 0. && v <= 1.) { + baseColor = texture2D(projectorTex, vec2(u, v)); + baseColor.a = opacity; + } else { + baseColor = vec4(0.0, 0.0, 0.0, 0.0); + } + gl_FragColor = baseColor; +} +` diff --git a/src/component/slider/shaders/perspective_distorted.vertex.glsl.ts b/src/component/slider/shaders/perspective_distorted.vertex.glsl.ts new file mode 100644 index 000000000..1f1ea2b9c --- /dev/null +++ b/src/component/slider/shaders/perspective_distorted.vertex.glsl.ts @@ -0,0 +1,12 @@ +export const perspectiveDistortedVert = ` +#ifdef GL_ES +precision highp float; +#endif +uniform mat4 projectorMat; +varying vec4 vRstq; +void main() +{ + vRstq = projectorMat * vec4(position, 1.0); + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); +} +` diff --git a/src/component/slider/shaders/perspective_distorted_curtain.fragment.glsl.ts b/src/component/slider/shaders/perspective_distorted_curtain.fragment.glsl.ts new file mode 100644 index 000000000..5d3d12805 --- /dev/null +++ b/src/component/slider/shaders/perspective_distorted_curtain.fragment.glsl.ts @@ -0,0 +1,24 @@ +export const perspectiveDistortedCurtainFrag = ` +#ifdef GL_FRAGMENT_PRECISION_HIGH +precision highp float; +#else +precision mediump float; +#endif +uniform sampler2D projectorTex; +uniform float opacity; +uniform float curtain; +varying vec4 vRstq; +void main() +{ + float u = vRstq.x / vRstq.w; + float v = vRstq.y / vRstq.w; + vec4 baseColor; + if ((u < curtain || curtain >= 1.0) && u >= 0. && u <= 1. && v >= 0. && v <= 1.) { + baseColor = texture2D(projectorTex, vec2(u, v)); + baseColor.a = opacity; + } else { + baseColor = vec4(0.0, 0.0, 0.0, 0.0); + } + gl_FragColor = baseColor; +} +` diff --git a/src/component/slider/shaders/perspective_distorted_curtain.vertex.glsl.ts b/src/component/slider/shaders/perspective_distorted_curtain.vertex.glsl.ts new file mode 100644 index 000000000..57a47c682 --- /dev/null +++ b/src/component/slider/shaders/perspective_distorted_curtain.vertex.glsl.ts @@ -0,0 +1,12 @@ +export const perspectiveDistortedCurtainVert = ` +#ifdef GL_ES +precision highp float; +#endif +uniform mat4 projectorMat; +varying vec4 vRstq; +void main() +{ + vRstq = projectorMat * vec4(position, 1.0); + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); +} +` diff --git a/src/component/slider/shaders/spherical.fragment.glsl.ts b/src/component/slider/shaders/spherical.fragment.glsl.ts new file mode 100644 index 000000000..dd022335c --- /dev/null +++ b/src/component/slider/shaders/spherical.fragment.glsl.ts @@ -0,0 +1,26 @@ +export const sphericalFrag = ` +#ifdef GL_FRAGMENT_PRECISION_HIGH +precision highp float; +#else +precision mediump float; +#endif + +#define tau 6.28318530718 + +uniform sampler2D projectorTex; +uniform float opacity; + +varying vec4 vRstq; + +void main() +{ + vec3 b = normalize(vRstq.xyz); + float lat = -asin(b.y); + float lng = atan(b.x, b.z); + float x = lng / tau + 0.5; + float y = lat / tau * 2.0 + 0.5; + vec4 baseColor = texture2D(projectorTex, vec2(x, y)); + baseColor.a = opacity; + gl_FragColor = baseColor; +} +` diff --git a/src/component/slider/shaders/spherical.vertex.glsl.ts b/src/component/slider/shaders/spherical.vertex.glsl.ts new file mode 100644 index 000000000..a1f8b9874 --- /dev/null +++ b/src/component/slider/shaders/spherical.vertex.glsl.ts @@ -0,0 +1,15 @@ +export const sphericalVert = ` +#ifdef GL_ES +precision highp float; +#endif + +uniform mat4 projectorMat; + +varying vec4 vRstq; + +void main() +{ + vRstq = projectorMat * vec4(position, 1.0); + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); +} +` diff --git a/src/component/slider/shaders/spherical_curtain.fragment.glsl.ts b/src/component/slider/shaders/spherical_curtain.fragment.glsl.ts new file mode 100644 index 000000000..8486fe21b --- /dev/null +++ b/src/component/slider/shaders/spherical_curtain.fragment.glsl.ts @@ -0,0 +1,34 @@ +export const sphericalCurtainFrag = ` +#ifdef GL_FRAGMENT_PRECISION_HIGH +precision highp float; +#else +precision mediump float; +#endif +#define tau 6.28318530718 +uniform sampler2D projectorTex; +uniform float curtain; +uniform float opacity; +varying vec4 vRstq; +void main() +{ + vec3 b = normalize(vRstq.xyz); + float lat = -asin(b.y); + float lng = atan(b.x, b.z); + float x = lng / tau + 0.5; + float y = lat / tau * 2.0 + 0.5; + bool inverted = curtain < 0.5; + float curtainMin = inverted ? curtain + 0.5 : curtain - 0.5; + float curtainMax = curtain; + bool insideCurtain = inverted ? + x > curtainMin || x < curtainMax : + x > curtainMin && x < curtainMax; + vec4 baseColor; + if (insideCurtain) { + baseColor = texture2D(projectorTex, vec2(x, y)); + baseColor.a = opacity; + } else { + baseColor = vec4(0.0, 0.0, 0.0, 0.0); + } + gl_FragColor = baseColor; +} +` diff --git a/src/component/slider/shaders/spherical_curtain.vertex.glsl.ts b/src/component/slider/shaders/spherical_curtain.vertex.glsl.ts new file mode 100644 index 000000000..e55aa3c59 --- /dev/null +++ b/src/component/slider/shaders/spherical_curtain.vertex.glsl.ts @@ -0,0 +1,12 @@ +export const sphericalCurtainVert = ` +#ifdef GL_ES +precision highp float; +#endif +uniform mat4 projectorMat; +varying vec4 vRstq; +void main() +{ + vRstq = projectorMat * vec4(position, 1.0); + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); +} +` diff --git a/src/external/component.ts b/src/external/component.ts index 0b1c2a2ad..8323cff1a 100644 --- a/src/external/component.ts +++ b/src/external/component.ts @@ -81,6 +81,14 @@ export { SequenceConfiguration } from "../component/interfaces/SequenceConfiguration"; export { SequenceComponent } from "../component/sequence/SequenceComponent"; +// Slider +export { + SliderConfiguration, + SliderConfigurationIds, + SliderConfigurationMode, +} from "../component/interfaces/SliderConfiguration"; +export { SliderComponent } from "../component/slider/SliderComponent"; + // Spatial export { CameraVisualizationMode } from "../component/spatial/enums/CameraVisualizationMode"; diff --git a/src/mapillary.ts b/src/mapillary.ts index a672de1f7..c92eb31d1 100644 --- a/src/mapillary.ts +++ b/src/mapillary.ts @@ -54,6 +54,7 @@ ComponentService.register(MarkerComponent); ComponentService.register(PointerComponent); ComponentService.register(PopupComponent); ComponentService.register(SequenceComponent); +ComponentService.register(SliderComponent); ComponentService.register(SpatialComponent); ComponentService.register(TagComponent); ComponentService.register(ZoomComponent); @@ -63,6 +64,7 @@ import { ImageFallbackComponent } from "./component/fallback/image/ImageFallbackComponent"; import { NavigationFallbackComponent } from "./component/fallback/navigation/NavigationFallbackComponent"; +import { SliderComponent } from "./component/slider/SliderComponent"; ComponentService.register(ImageFallbackComponent); ComponentService.register(NavigationFallbackComponent); diff --git a/src/viewer/ComponentController.ts b/src/viewer/ComponentController.ts index 24790d968..ce1c4fbf0 100644 --- a/src/viewer/ComponentController.ts +++ b/src/viewer/ComponentController.ts @@ -124,6 +124,7 @@ export class ComponentController { this._uFalse(options.marker, "marker"); this._uFalse(options.popup, "popup"); + this._uFalse(options.slider, "slider"); this._uFalse(options.spatial, "spatial"); this._uFalse(options.tag, "tag"); diff --git a/src/viewer/Viewer.ts b/src/viewer/Viewer.ts index 8c35c79ff..b9709eaae 100644 --- a/src/viewer/Viewer.ts +++ b/src/viewer/Viewer.ts @@ -1519,6 +1519,10 @@ export class Viewer extends EventEmitter implements IViewer { * how the camera is controlled when the viewer * receives pointer and keyboard input. * + * Changing the camera control mode is not possible + * when the slider component is active and attempts + * to do so will be ignored. + * * @param {CameraControls} controls - Camera control mode. * * @example diff --git a/src/viewer/options/ComponentOptions.ts b/src/viewer/options/ComponentOptions.ts index 4c8d01f65..066230534 100644 --- a/src/viewer/options/ComponentOptions.ts +++ b/src/viewer/options/ComponentOptions.ts @@ -5,6 +5,7 @@ import { KeyboardConfiguration } from "../../component/interfaces/KeyboardConfig import { MarkerConfiguration } from "../../component/interfaces/MarkerConfiguration"; import { PointerConfiguration } from "../../component/interfaces/PointerConfiguration"; import { SequenceConfiguration } from "../../component/interfaces/SequenceConfiguration"; +import { SliderConfiguration } from "../../component/interfaces/SliderConfiguration"; import { SpatialConfiguration } from "../../component/interfaces/SpatialConfiguration"; import { TagConfiguration } from "../../component/interfaces/TagConfiguration"; import { ZoomConfiguration } from "../../component/interfaces/ZoomConfiguration"; @@ -116,6 +117,15 @@ export interface ComponentOptions { */ sequence?: boolean | SequenceConfiguration; + /** + * Show a slider for transitioning between image planes. + * + * @description Requires WebGL support. + * + * @default false + */ + slider?: boolean | SliderConfiguration; + /** * Enable an interface for showing spatial data in the viewer. * diff --git a/styles/slider.css b/styles/slider.css new file mode 100644 index 000000000..eb7eb7843 --- /dev/null +++ b/styles/slider.css @@ -0,0 +1,141 @@ +.mapillary-slider-container { + position: absolute; + bottom: 40px; + height: 30px; + left: 50%; + padding-left: 5px; + pointer-events: none; + transform: translate(-50%, 0); + z-index: 10; +} +.mapillary-slider-border { + display: inline-block; + width: 8px; + height: 100%; + border-bottom-left-radius: 8px; + border-top-left-radius: 8px; + pointer-events: auto; + cursor: default; + background: rgba(0, 0, 0, 0.5); +} +.mapillary-slider-mode-button, +.mapillary-slider-mode-button-2d, +.mapillary-slider-mode-button-inactive, +.mapillary-slider-mode-button-2d-inactive { + width: 26px; + height: 100%; + display: inline-block; + pointer-events: auto; + background: rgba(0, 0, 0, 0.5); +} +.mapillary-slider-mode-button, +.mapillary-slider-mode-button-2d { + cursor: default; +} +.mapillary-slider-mode-button-inactive, +.mapillary-slider-mode-button-2d-inactive { + cursor: pointer; +} +.mapillary-slider-mode-icon, +.mapillary-slider-mode-icon-2d { + position: relative; + top: 50%; + transform: translate(0, -50%); + background-size: contain; + background-repeat: no-repeat; + opacity: 1; + width: 18px; + height: 18px; + margin-left: 4px; +} +.mapillary-slider-mode-icon { + background-image: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE4IDE3di44NjRjMCAuNTA1IDAgLjk5Ni0uMDM0IDEuNDEyLS4wMzcuNDU4LS4xMjUuOTk3LS40MDIgMS41NGE0IDQgMCAwIDEtMS43NDggMS43NDhjLS41NDMuMjc3LTEuMDgyLjM2NS0xLjU0LjQwMi0uNDE2LjAzNC0uOTA3LjAzNC0xLjQxMi4wMzRINi4xMzZjLS41MDUgMC0uOTk2IDAtMS40MTItLjAzNC0uNDU4LS4wMzctLjk5Ny0uMTI1LTEuNTQtLjQwMmE0IDQgMCAwIDEtMS43NDgtMS43NDhjLS4yNzctLjU0My0uMzY1LTEuMDgyLS40MDItMS41NEExOC4zNiAxOC4zNiAwIDAgMSAxIDE3Ljg2NHYtNi43MjhjMC0uNTA1IDAtLjk5Ni4wMzQtMS40MTIuMDM3LS40NTguMTI1LS45OTcuNDAyLTEuNTRhNCA0IDAgMCAxIDEuNzQ4LTEuNzQ4Yy41NDMtLjI3NyAxLjA4Mi0uMzY1IDEuNTQtLjQwMkM1LjE0IDYgNS42MyA2IDYuMTM2IDZIN3YyaC0uOGMtMS4xMiAwLTEuNjggMC0yLjEwOC4yMThhMiAyIDAgMCAwLS44NzQuODc0QzMgOS41MiAzIDEwLjA4IDMgMTEuMnY2LjZjMCAxLjEyIDAgMS42OC4yMTggMi4xMDhhMiAyIDAgMCAwIC44NzQuODc0QzQuNTIgMjEgNS4wOCAyMSA2LjIgMjFoNi42YzEuMTIgMCAxLjY4IDAgMi4xMDgtLjIxOGEyIDIgMCAwIDAgLjg3NC0uODc0QzE2IDE5LjQ4IDE2IDE4LjEyIDE2IDE3aDJ6TTE2LjUxIDcuMjM2djMuNzFoLjc4YzEuMDk0IDAgMS42Ny0uNTYzIDEuNjctMS44NiAwLTEuMjkzLS41NzYtMS44NS0xLjY2Ny0xLjg1aC0uNzg0eiIgZmlsbD0iI2ZmZiIvPjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNy4zMjcgMi42MzhDNyAzLjI4IDcgNC4xMiA3IDUuOHY2LjRjMCAxLjY4IDAgMi41Mi4zMjcgMy4xNjJhMyAzIDAgMCAwIDEuMzExIDEuMzExQzkuMjggMTcgMTAuMTIgMTcgMTEuOCAxN2g2LjRjMS42OCAwIDIuNTIgMCAzLjE2Mi0uMzI3YTMgMyAwIDAgMCAxLjMxMS0xLjMxMUMyMyAxNC43MiAyMyAxMy44OCAyMyAxMi4yVjUuOGMwLTEuNjggMC0yLjUyLS4zMjctMy4xNjJhMyAzIDAgMCAwLTEuMzExLTEuMzExQzIwLjcyIDEgMTkuODggMSAxOC4yIDFoLTYuNGMtMS42OCAwLTIuNTIgMC0zLjE2Mi4zMjdhMyAzIDAgMCAwLTEuMzExIDEuMzExem03LjAzNCA3LjcyOWMuMDAzLjk5NC0uOTMyIDEuNzEyLTIuMjM5IDEuNzEyLTEuMjc4IDAtMi4xODctLjcwNy0yLjIwNy0xLjcyNGgxLjIzOGMuMDIzLjQyNi40My43MS45NzIuNzEuNTYzIDAgLjk2LS4zMTguOTU3LS43OC4wMDMtLjQ3LS40MDktLjgtMS4wNjgtLjhoLS41NDJ2LS45MDNoLjU0MmMuNTYgMCAuOTUtLjMxLjk0Ni0uNzY3LjAwMy0uNDM3LS4zMy0uNzM4LS44MjctLjczOC0uNTAyIDAtLjkwOS4yODctLjkyMy43M2gtMS4xNzljLjAxNy0xLjAwNi45MTUtMS43MDUgMi4xMDgtMS43MDUgMS4yMDUgMCAyLjAwOS43MjIgMi4wMDYgMS42NC4wMDMuNjUzLS40NDYgMS4xMTMtMS4wOTcgMS4yM3YuMDQ1Yy44NTUuMTA4IDEuMzE2LjYyNSAxLjMxMyAxLjM1em01LjgyNy0xLjI4MmMwIDEuODE4LTEuMDc0IDIuOTE1LTIuODQ2IDIuOTE1aC0yLjA2M1Y2LjE4MmgyLjA4YzEuNzU1IDAgMi44MyAxLjA5IDIuODMgMi45MDN6IiBmaWxsPSIjZmZmIi8+PC9zdmc+'); +} +.mapillary-slider-mode-icon-2d { + background-image: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTEzLjM0NSAxMC4yMzZ2My43MWguNzgxYzEuMDk0IDAgMS42Ny0uNTYzIDEuNjctMS44NiAwLTEuMjkzLS41NzYtMS44NS0xLjY2Ny0xLjg1aC0uNzg0eiIgZmlsbD0iI2ZmZiIvPjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNC4zMjcgNS42MzhDNCA2LjI4IDQgNy4xMiA0IDguOHY2LjRjMCAxLjY4IDAgMi41Mi4zMjcgMy4xNjJhMyAzIDAgMCAwIDEuMzExIDEuMzExQzYuMjggMjAgNy4xMiAyMCA4LjggMjBoNi40YzEuNjggMCAyLjUyIDAgMy4xNjItLjMyN2EzIDMgMCAwIDAgMS4zMTEtMS4zMTFDMjAgMTcuNzIgMjAgMTYuODggMjAgMTUuMlY4LjhjMC0xLjY4IDAtMi41Mi0uMzI3LTMuMTYyYTMgMyAwIDAgMC0xLjMxMS0xLjMxMUMxNy43MiA0IDE2Ljg4IDQgMTUuMiA0SDguOGMtMS42OCAwLTIuNTIgMC0zLjE2Mi4zMjdhMyAzIDAgMCAwLTEuMzExIDEuMzExek0xMS4xODcgMTVINy4wNHYtLjg4NmwyLjA3LTEuOTE4Yy41MTgtLjUuODEtLjgyNy44MTMtMS4yOS0uMDAzLS41MTEtLjM4OS0uODMtLjkwMy0uODMtLjUzNCAwLS44OTguMzM5LS44OTguODk4SDYuOTU1Yy0uMDAzLTEuMTQyLjg1NS0xLjg3MiAyLjA3My0xLjg3MiAxLjI1NiAwIDIuMDQ2Ljc4MiAyLjA1MSAxLjc5LjAwMy43NDQtLjM4NiAxLjI5My0xLjQxMSAyLjIzbC0uODU1LjgzMnYuMDRoMi4zNzRWMTV6bTUuODM3LTIuOTE1YzAgMS44MTgtMS4wNzQgMi45MTUtMi44NDYgMi45MTVoLTIuMDYzVjkuMTgyaDIuMDhjMS43NTUgMCAyLjgzIDEuMDkgMi44MyAyLjkwM3oiIGZpbGw9IiNmZmYiLz48L3N2Zz4gIA=='); +} +.mapillary-slider-mode-button-inactive .mapillary-slider-mode-icon { + opacity: 0.35; +} +.mapillary-slider-mode-button-2d-inactive .mapillary-slider-mode-icon-2d { + opacity: 0.35; +} +.mapillary-slider-position-container { + display: inline-block; + height: 100%; + vertical-align: top; + background: rgba(0, 0, 0, 0.5); + pointer-events: auto; + padding-right: 16px; + border-bottom-right-radius: 8px; + border-top-right-radius: 8px; + padding-left: 8px; +} +.mapillary-slider-position { + pointer-events: auto; + margin: 0; + padding: 0; + margin-top: 7px; + height: 16px; + cursor: pointer; + outline: none; + background: none; + -webkit-appearance: none; +} +.mapillary-slider-position::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + border: 2px solid white; + background: white; + cursor: pointer; + margin-top: -6px; +} +.mapillary-slider-position::-webkit-slider-runnable-track { + height: 4px; + border-radius: 2px; + background-color: white; +} +.mapillary-slider-position::-moz-focus-outer { + border: 0; +} +.mapillary-slider-position::-moz-range-track { + height: 4px; + border-radius: 2px; + background-color: white; +} +.mapillary-slider-position::-moz-range-thumb { + width: 12px; + height: 12px; + border-radius: 50%; + border: 2px solid white; + background: white; + cursor: pointer; + margin-top: -6px; +} +.mapillary-slider-position::-ms-track { + height: 2px; + border-radius: 2px; + background-color: white; +} +.mapillary-slider-position::-ms-fill-lower { + height: 0; + background: none; +} +.mapillary-slider-position::-ms-fill-upper { + height: 0; + background: none; +} +.mapillary-slider-position::-ms-thumb { + width: 12px; + height: 12px; + border-radius: 50%; + border: 2px solid white; + background: white; + cursor: pointer; + margin-top: 0; +} +.mapillary-slider-position::-ms-tooltip { + display: none; +} diff --git a/test/viewer/ComponentController.test.ts b/test/viewer/ComponentController.test.ts index c0fe4f0d5..046ef174d 100644 --- a/test/viewer/ComponentController.test.ts +++ b/test/viewer/ComponentController.test.ts @@ -60,6 +60,7 @@ class MaC extends CMock { protected static _cn: ComponentName = "marker"; } class PtrC extends CMock { protected static _cn: ComponentName = "pointer"; } class PC extends CMock { protected static _cn: ComponentName = "popup"; } class SeC extends CMock { protected static _cn: ComponentName = "sequence"; } +class SlC extends CMock { protected static _cn: ComponentName = "slider"; } class SpC extends CMock { protected static _cn: ComponentName = "spatial"; } class TC extends CMock { protected static _cn: ComponentName = "tag"; } class ZC extends CMock { protected static _cn: ComponentName = "zoom"; } @@ -74,6 +75,7 @@ ComponentService.register(MaC); ComponentService.register(PtrC); ComponentService.register(PC); ComponentService.register(SeC); +ComponentService.register(SlC); ComponentService.register(SpC); ComponentService.register(TC); ComponentService.register(ZC);