From b22c7cb915eebb12168bd79c63153b2b805c457f Mon Sep 17 00:00:00 2001 From: mistic100 Date: Sat, 14 Oct 2023 16:14:30 +0200 Subject: [PATCH] new overlays plugin --- docs/guide/config.md | 17 +- docs/plugins/gallery.md | 3 +- docs/plugins/overlays.md | 161 ++++++++ examples/index.html | 4 +- examples/misc-cubemap-overlay.html | 111 ----- examples/misc-equirectangular-overlay.html | 75 ---- examples/plugin-overlays-cubemap.html | 85 ++++ examples/plugin-overlays.html | 163 ++++++++ packages/core/src/Viewer.ts | 6 +- packages/core/src/adapters/AbstractAdapter.ts | 5 +- .../src/adapters/EquirectangularAdapter.ts | 9 +- packages/core/src/data/config.ts | 10 +- packages/core/src/events.ts | 14 + packages/core/src/model.ts | 7 +- packages/core/src/services/Renderer.ts | 3 +- .../cubemap-adapter/src/CubemapAdapter.ts | 22 +- packages/cubemap-adapter/src/utils.ts | 2 +- .../src/EquirectangularTilesAdapter.ts | 2 +- packages/overlays-plugin/package.json | 22 + .../overlays-plugin/src/OverlaysPlugin.ts | 387 ++++++++++++++++++ packages/overlays-plugin/src/constants.ts | 1 + packages/overlays-plugin/src/events.ts | 16 + packages/overlays-plugin/src/index.ts | 4 + packages/overlays-plugin/src/model.ts | 74 ++++ packages/overlays-plugin/tsconfig.json | 5 + packages/overlays-plugin/tsup.config.js | 4 + packages/overlays-plugin/typedoc.json | 8 + 27 files changed, 988 insertions(+), 232 deletions(-) create mode 100644 docs/plugins/overlays.md delete mode 100644 examples/misc-cubemap-overlay.html delete mode 100644 examples/misc-equirectangular-overlay.html create mode 100644 examples/plugin-overlays-cubemap.html create mode 100644 examples/plugin-overlays.html create mode 100644 packages/overlays-plugin/package.json create mode 100644 packages/overlays-plugin/src/OverlaysPlugin.ts create mode 100644 packages/overlays-plugin/src/constants.ts create mode 100644 packages/overlays-plugin/src/events.ts create mode 100644 packages/overlays-plugin/src/index.ts create mode 100644 packages/overlays-plugin/src/model.ts create mode 100644 packages/overlays-plugin/tsconfig.json create mode 100644 packages/overlays-plugin/tsup.config.js create mode 100644 packages/overlays-plugin/typedoc.json diff --git a/docs/guide/config.md b/docs/guide/config.md index 22674df2f..2c5733e98 100644 --- a/docs/guide/config.md +++ b/docs/guide/config.md @@ -200,22 +200,9 @@ Requires two fingers to rotate the panorama. This allows standard touch-scroll n ## Advanced options -#### `overlay` +#### ~~`overlay`~~ -- type: `*` - -Path to an additional transparent panorama which will be displayed on top of the main one. The overlay can also be changed with the `setOverlay()` method or within the options of the `setPanorama()` method. - -::: warning -Only the default [equirectangular](./adapters/equirectangular.md) and the [cubemap](./adapters/cubemap.md) adapters support this feature. -::: - -#### `overlayOpacity` - -- type: `number` -- default: `1` - -Opacity of the `overlay`. +This option will be removed in a future version, please migrate to the [Overlays plugin](../plugins/overlays.md). #### `sphereCorrection` diff --git a/docs/plugins/gallery.md b/docs/plugins/gallery.md index 3079108ab..679420b09 100644 --- a/docs/plugins/gallery.md +++ b/docs/plugins/gallery.md @@ -106,8 +106,7 @@ gallery.setItems([ #### `items` -- type: `array` -- default: `GalleryItem[]` +- type: `GalleryItem[]` - updatable: no, use `setItems()` method The list of items, see bellow. diff --git a/docs/plugins/overlays.md b/docs/plugins/overlays.md new file mode 100644 index 000000000..aa0a94a6c --- /dev/null +++ b/docs/plugins/overlays.md @@ -0,0 +1,161 @@ +# OverlaysPlugin + + + +::: module + +Display additional images and videos on top of the panorama. +::: + +[[toc]] + +## Usage + +Overlays are images and videos "glued" to the panorama. Contrary to [markers](./markers.md) they are part of the 3D scene and not drawn on top of the viewer. + +Two kinds of overlays are supported : + +- full size equirectangular/cubemap : will cover the entire panorama +- positionned rectangle : the image/video has a defined position and size (always in radians/degrees) + +```js +const viewer = new PhotoSphereViewer.Viewer({ + plugins: [ + [PhotoSphereViewer.OverlaysPlugin, { + overlays: [ + { + id: 'fullsize', + path: 'path/to/overlay.png', + }, + { + id: 'positionned', + path: 'path/to/image.jpg', + yaw: '-20deg', + pitch: '10deg', + width: '40deg', + height: '20deg', + opacity: 0.5, + }, + { + id: 'video', + type: 'video', + path: 'path/to/video.mp4', + yaw: '20deg', + pitch: '10deg', + width: '40deg', + height: '20deg', + }, + ], + }], + ], +}); +``` + +## Example + +TODO + +### Comparison with markers + +This example show the difference between a positionned overlay and `image` and `imageLayer` markers. + +TODO + +## Configuration + +#### `overlays` + +- type: `OverlayConfig[]` +- updatable: no + +The list of overlays, see bellow. Can be updated with various [methods](#methods). + +#### `autoclear` + +- type: `boolean` +- default: `true` +- updatable: yes + +Automatically remove all overlays when the panorama changes. + +### Overlays + +Overlays can be a single image/video for a spherical gerometry or six images for a cube geometry (no videos). + +#### `id` (recommended) + +- type: `string` +- default: random value + +Used to remove the overlay with `removeOverlay()` method. + +#### `type` + +- type: `image | video` +- default: `image` + +#### `opacity` + +- type: `number` +- default: `1` + +#### `zIndex` + +- type: `number` +- default: `0` + +#### Spherical overlays + +#### `path` (required) + +- type: `string` + +Path to the image or video. + +#### `yaw`, `pitch`, `width`, `height` + +- type: `number | string` + +Definition of the position and size of the overlay, if none of the four properties are configured, the overlay will cover the full sphere, respecting the [panorama data](../guide/adapters/equirectangular.md#cropped-panorama) if applicable. + +#### Cube overlays + +#### `path` (required) + +- type: `CubemapPanorama` + +Check the [cubemap adapter page](../guide/adapters/cubemap.md#panorama-options) for the possible syntaxes. All six faces are required. + +## Methods + +#### `addOverlay(config)` + +Adds a new overlay. + +#### `removeOverlay(id)` + +Removes an overlay. + +#### `clearOverlays()` + +Removes all overlays. + +#### `getVideo(id)` + +Returns the controller of a video overlay (native HTMLVideoElement) in order to change its state, volume, etc. + +```js +overlaysPlugin.getVideo('my-video').muted = false; +``` + +## Events + +#### `overlay-click(overlayId)` + +Triggered when an overlay is clicked. + +```js +overlaysPlugin.addEventListener('overlay-click', ({ overlayId }) => { + console.log(`Clicked on overlay ${overlayId}`); +}); +``` diff --git a/examples/index.html b/examples/index.html index b2db15814..850cfa6f5 100644 --- a/examples/index.html +++ b/examples/index.html @@ -62,6 +62,8 @@
Plugins
Markers Markers layers Markers (cubemap) + Overlays + Overlays (cubemap) Resolution Settings Video @@ -79,8 +81,6 @@
Misc
diff --git a/examples/misc-cubemap-overlay.html b/examples/misc-cubemap-overlay.html deleted file mode 100644 index a4a4a56fd..000000000 --- a/examples/misc-cubemap-overlay.html +++ /dev/null @@ -1,111 +0,0 @@ - - - - - - PhotoSphereViewer - cubemap overlay demo - - - - - -
- - - - - - diff --git a/examples/misc-equirectangular-overlay.html b/examples/misc-equirectangular-overlay.html deleted file mode 100644 index e7ceb96a7..000000000 --- a/examples/misc-equirectangular-overlay.html +++ /dev/null @@ -1,75 +0,0 @@ - - - - - - PhotoSphereViewer - equirectangular overlay demo - - - - - -
- - - - - - diff --git a/examples/plugin-overlays-cubemap.html b/examples/plugin-overlays-cubemap.html new file mode 100644 index 000000000..921ab7432 --- /dev/null +++ b/examples/plugin-overlays-cubemap.html @@ -0,0 +1,85 @@ + + + + + + PhotoSphereViewer - overlays cubemap demo + + + + + +
+ + + + + + diff --git a/examples/plugin-overlays.html b/examples/plugin-overlays.html new file mode 100644 index 000000000..1d0ef30ca --- /dev/null +++ b/examples/plugin-overlays.html @@ -0,0 +1,163 @@ + + + + + + PhotoSphereViewer - overlays demo + + + + + +
+ + + + + + diff --git a/packages/core/src/Viewer.ts b/packages/core/src/Viewer.ts index 236a2af91..bbfa0088f 100644 --- a/packages/core/src/Viewer.ts +++ b/packages/core/src/Viewer.ts @@ -16,6 +16,7 @@ import { ConfigChangedEvent, PanoramaErrorEvent, PanoramaLoadedEvent, + PanoramaLoadEvent, ReadyEvent, SizeUpdatedEvent, StopAllEvent, @@ -400,6 +401,8 @@ export class Viewer extends TypedEventTarget { this.loader.show(); } + this.dispatchEvent(new PanoramaLoadEvent(path)); + const loadingPromise = this.adapter.loadTexture(this.config.panorama, options.panoData).then((textureData) => { // check if another panorama was requested if (textureData.panorama !== this.config.panorama) { @@ -460,7 +463,7 @@ export class Viewer extends TypedEventTarget { } /** - * Loads a new overlay + * @deprecated Use the `overlay` plugin instead */ setOverlay(path: any, opacity = this.config.overlayOpacity): Promise { const supportsOverlay = (this.adapter.constructor as typeof AbstractAdapter).supportsOverlay; @@ -494,6 +497,7 @@ export class Viewer extends TypedEventTarget { }; } }, + false, false ) .then((textureData) => { diff --git a/packages/core/src/adapters/AbstractAdapter.ts b/packages/core/src/adapters/AbstractAdapter.ts index 1ca579308..7ffb81dfb 100644 --- a/packages/core/src/adapters/AbstractAdapter.ts +++ b/packages/core/src/adapters/AbstractAdapter.ts @@ -21,7 +21,7 @@ export abstract class AbstractAdapter { static readonly supportsDownload: boolean = false; /** - * Indicates if the adapter can display an additional transparent image above the panorama + * @deprecated */ static readonly supportsOverlay: boolean = false; @@ -83,6 +83,7 @@ export abstract class AbstractAdapter { abstract loadTexture( panorama: TPanorama, newPanoData?: PanoData | PanoDataProvider, + loader?: boolean, useXmpPanoData?: boolean ): Promise>; @@ -107,7 +108,7 @@ export abstract class AbstractAdapter { abstract disposeTexture(textureData: TextureData): void; /** - * Applies the overlay to the mesh + * @deprecated */ // @ts-ignore unused parameter // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/packages/core/src/adapters/EquirectangularAdapter.ts b/packages/core/src/adapters/EquirectangularAdapter.ts index 93522def4..98a5a76d0 100644 --- a/packages/core/src/adapters/EquirectangularAdapter.ts +++ b/packages/core/src/adapters/EquirectangularAdapter.ts @@ -71,8 +71,8 @@ export class EquirectangularAdapter extends AbstractAdapter { if (typeof panorama !== 'string') { return Promise.reject(new PSVError('Invalid panorama url, are you using the right adapter?')); } - const blob = await this.viewer.textureLoader.loadFile(panorama, (p) => this.viewer.loader.setProgress(p), panorama); + const blob = await this.viewer.textureLoader.loadFile(panorama, loader ? (p) => this.viewer.loader.setProgress(p) : null, panorama); const xmpPanoData = useXmpPanoData ? await this.loadXMP(blob) : null; const img = await this.viewer.textureLoader.blobToImage(blob); diff --git a/packages/core/src/data/config.ts b/packages/core/src/data/config.ts index 60824bdda..c384dcef9 100644 --- a/packages/core/src/data/config.ts +++ b/packages/core/src/data/config.ts @@ -1,10 +1,10 @@ import { MathUtils } from 'three'; +import { PSVError } from '../PSVError'; import { adapterInterop } from '../adapters/AbstractAdapter'; import { EquirectangularAdapter } from '../adapters/EquirectangularAdapter'; import { ParsedViewerConfig, ReadonlyViewerConfig, ViewerConfig } from '../model'; import { pluginInterop } from '../plugins/AbstractPlugin'; -import { PSVError } from '../PSVError'; -import { clone, ConfigParsers, getConfigParser, logWarn, parseAngle } from '../utils'; +import { ConfigParsers, clone, getConfigParser, logWarn, parseAngle } from '../utils'; import { ACTIONS, KEY_CODES } from './constants'; /** @@ -244,6 +244,12 @@ export const CONFIG_PARSERS: ConfigParsers = { } return canvasBackground; }, + overlay: (overlay) => { + if (overlay !== null) { + logWarn(`"overlay" option is deprecated, use "@photo-sphere-viewer/overlay-plugin" instead.`); + } + return overlay; + }, }; /** diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index 0b819dbeb..c427ae6be 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -201,6 +201,19 @@ export class LoadProgressEvent extends ViewerEvent { } } +/** + * @event Triggered when a panorama image starts loading + */ +export class PanoramaLoadEvent extends ViewerEvent { + static override readonly type = 'panorama-load'; + override type: 'panorama-load'; + + /** @internal */ + constructor(public readonly panorama: any) { + super(PanoramaLoadEvent.type); + } +} + /** * @event Triggered when a panorama image has been loaded */ @@ -442,6 +455,7 @@ export type ViewerEvents = | HideTooltipEvent | KeypressEvent | LoadProgressEvent + | PanoramaLoadEvent | PanoramaLoadedEvent | PanoramaErrorEvent | PositionUpdatedEvent diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index b23f38a4a..21402014d 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -151,11 +151,11 @@ export type PanoramaOptions = Partial & { */ panoData?: PanoData | PanoDataProvider; /** - * new overlay to apply to the panorama + * @deprecated Use the `overlay` plugin instead */ overlay?: any; /** - * new overlay opacity + * @deprecated Use the `overlay` plugin instead */ overlayOpacity?: number; }; @@ -293,8 +293,9 @@ export type NavbarCustomButton = { export type ViewerConfig = { container: HTMLElement | string; panorama?: any; + /** @deprecated Use the `overlay` plugin instead */ overlay?: any; - /** @default 1 */ + /** @deprecated Use the `overlay` plugin instead */ overlayOpacity?: number; /** @default equirectangular */ adapter?: AdapterConstructor | [AdapterConstructor, any]; diff --git a/packages/core/src/services/Renderer.ts b/packages/core/src/services/Renderer.ts index a0498097b..f623ceb85 100644 --- a/packages/core/src/services/Renderer.ts +++ b/packages/core/src/services/Renderer.ts @@ -259,8 +259,7 @@ export class Renderer extends AbstractService { } /** - * Applies the overlay to the mesh - * @internal + * @deprecated */ setOverlay(textureData: TextureData, opacity: number) { if (this.state.overlayData) { diff --git a/packages/cubemap-adapter/src/CubemapAdapter.ts b/packages/cubemap-adapter/src/CubemapAdapter.ts index 0effb4ff9..acfd8b1f1 100644 --- a/packages/cubemap-adapter/src/CubemapAdapter.ts +++ b/packages/cubemap-adapter/src/CubemapAdapter.ts @@ -154,7 +154,7 @@ export class CubemapAdapter extends AbstractAdapter { + async loadTexture(panorama: CubemapPanorama, _unused?: null, loader = true): Promise { if (this.viewer.config.fisheye) { utils.logWarn('fisheye effect with cubemap texture can generate distorsion'); } @@ -172,15 +172,15 @@ export class CubemapAdapter extends AbstractAdapter { + .loadImage(paths[i], loader ? (p) => { progress[i] = p; this.viewer.loader.setProgress(utils.sum(progress) / 6); - }, cacheKey) + } : null, cacheKey) .then((img) => this.createCubemapTexture(img)) ); } @@ -256,13 +256,13 @@ export class CubemapAdapter extends AbstractAdapter this.viewer.loader.setProgress(p), cacheKey); + const img = await this.viewer.textureLoader.loadImage(panorama.path, loader ? (p) => this.viewer.loader.setProgress(p) : null, cacheKey); if (img.width !== img.height * 6) { utils.logWarn('Invalid cubemap image, the width should be six times the height'); @@ -302,9 +302,9 @@ export class CubemapAdapter extends AbstractAdapter this.viewer.loader.setProgress(p), cacheKey); + const img = await this.viewer.textureLoader.loadImage(panorama.path, loader ? (p) => this.viewer.loader.setProgress(p) : null, cacheKey); if (img.width / 4 !== img.height / 3) { utils.logWarn('Invalid cubemap image, the width should be 4/3rd of the height'); diff --git a/packages/cubemap-adapter/src/utils.ts b/packages/cubemap-adapter/src/utils.ts index 833b0aa34..9b183e5c7 100644 --- a/packages/cubemap-adapter/src/utils.ts +++ b/packages/cubemap-adapter/src/utils.ts @@ -29,7 +29,7 @@ export function cleanCubemapArray(panorama: T[]): T[] { } /** - * Given an object where keys are faces names, retusn an array in 3JS order + * Given an object where keys are faces names, returns an array in 3JS order */ export function cleanCubemap(cubemap: { [K in CubemapFaces]: T }): T[] { const cleanPanorama: T[] = []; diff --git a/packages/equirectangular-tiles-adapter/src/EquirectangularTilesAdapter.ts b/packages/equirectangular-tiles-adapter/src/EquirectangularTilesAdapter.ts index a4e92def3..049a81229 100644 --- a/packages/equirectangular-tiles-adapter/src/EquirectangularTilesAdapter.ts +++ b/packages/equirectangular-tiles-adapter/src/EquirectangularTilesAdapter.ts @@ -226,7 +226,7 @@ export class EquirectangularTilesAdapter extends AbstractAdapter< if (panorama.baseUrl) { return this.getAdapter() - .loadTexture(panorama.baseUrl, panorama.basePanoData) + .loadTexture(panorama.baseUrl, panorama.basePanoData, true, false) .then((textureData) => ({ panorama, panoData, diff --git a/packages/overlays-plugin/package.json b/packages/overlays-plugin/package.json new file mode 100644 index 000000000..7ff880e22 --- /dev/null +++ b/packages/overlays-plugin/package.json @@ -0,0 +1,22 @@ +{ + "name": "@photo-sphere-viewer/overlays-plugin", + "version": "0.0.0", + "description": "Photo Sphere Viewer plugin to add various overlays over the panorama.", + "homepage": "https://photo-sphere-viewer.js.org/plugins/overlays", + "license": "MIT", + "main": "./src/index.ts", + "types": "./src/index.ts", + "dependencies": { + "@photo-sphere-viewer/core": "0.0.0" + }, + "scripts": { + "build": "tsup", + "watch": "tsup --watch", + "lint": "eslint . --fix", + "publish-dist": "cd dist && npm publish --tag=$NPM_TAG --access=public", + "npm-link": "cd dist && npm link" + }, + "psv": { + "globalName": "PhotoSphereViewer.OverlaysPlugin" + } +} diff --git a/packages/overlays-plugin/src/OverlaysPlugin.ts b/packages/overlays-plugin/src/OverlaysPlugin.ts new file mode 100644 index 000000000..0f9f2b771 --- /dev/null +++ b/packages/overlays-plugin/src/OverlaysPlugin.ts @@ -0,0 +1,387 @@ +import type { AbstractAdapter, PanoData, TextureData, Viewer } from '@photo-sphere-viewer/core'; +import { + AbstractConfigurablePlugin, + CONSTANTS, + EquirectangularAdapter, + PSVError, + events, + utils, +} from '@photo-sphere-viewer/core'; +import type { CubemapAdapter, CubemapData } from '@photo-sphere-viewer/cubemap-adapter'; +import type { CubemapTilesAdapter } from '@photo-sphere-viewer/cubemap-tiles-adapter'; +import type { EquirectangularTilesAdapter } from '@photo-sphere-viewer/equirectangular-tiles-adapter'; +import { BoxGeometry, Mesh, MeshBasicMaterial, SphereGeometry, Texture, Vector2, VideoTexture } from 'three'; +import { OVERLAY_DATA } from './constants'; +import { OverlayClickEvent, OverlaysPluginEvents } from './events'; +import { + CubeOverlayConfig, + OverlayConfig, + OverlaysPluginConfig, + ParsedOverlayConfig, + SphereOverlayConfig, +} from './model'; + +const getConfig = utils.getConfigParser({ + overlays: [], + autoclear: true, + cubemapAdapter: null, +}); + +/** + * Adds various overlays over the panorama + */ +export class OverlaysPlugin extends AbstractConfigurablePlugin< + OverlaysPluginConfig, + OverlaysPluginConfig, + never, + OverlaysPluginEvents +> { + static override readonly id = 'overlays'; + static override configParser = getConfig; + static override: Array = ['overlays', 'cubemapAdapter']; + + private readonly state = { + overlays: {} as Record, + }; + + private cubemapAdapter: CubemapAdapter; + private equirectangularAdapter: EquirectangularAdapter; + + constructor(viewer: Viewer, config?: OverlaysPluginConfig) { + super(viewer, config); + } + + /** + * @internal + */ + override init() { + super.init(); + + this.viewer.addEventListener(events.PanoramaLoadedEvent.type, this, { once: true }); + this.viewer.addEventListener(events.PanoramaLoadEvent.type, this); + this.viewer.addEventListener(events.ClickEvent.type, this); + } + + /** + * @internal + */ + override destroy() { + this.clearOverlays(); + + this.viewer.removeEventListener(events.PanoramaLoadedEvent.type, this); + this.viewer.removeEventListener(events.PanoramaLoadEvent.type, this); + this.viewer.removeEventListener(events.ClickEvent.type, this); + + delete this.cubemapAdapter; + delete this.equirectangularAdapter; + + super.destroy(); + } + + /** + * @internal + */ + handleEvent(e: Event) { + if (e instanceof events.PanoramaLoadedEvent) { + this.config.overlays.forEach((overlay) => { + this.addOverlay(overlay); + }); + delete this.config.overlays; + } else if (e instanceof events.PanoramaLoadEvent) { + if (this.config.autoclear) { + this.clearOverlays(); + } + } else if (e instanceof events.ClickEvent) { + const overlay = e.data.objects + .map((o) => o.userData[OVERLAY_DATA] as ParsedOverlayConfig['id']) + .filter((o) => !!o) + .map((o) => this.state.overlays[o].config) + .sort((a, b) => b.zIndex - a.zIndex)[0]; + + if (overlay) { + this.dispatchEvent(new OverlayClickEvent(overlay.id)); + } + } + } + + /** + * Adds a new overlay + */ + addOverlay(config: OverlayConfig) { + if (!config.path) { + throw new PSVError(`Missing overlay "path"`); + } + + const parsedConfig: ParsedOverlayConfig = { + id: Math.random().toString(36).substring(2), + type: 'image', + mode: typeof config.path === 'string' ? 'sphere' : 'cube', + opacity: 1, + zIndex: 0, + ...config, + }; + + if (this.state.overlays[parsedConfig.id]) { + throw new PSVError(`Overlay "${parsedConfig.id} already exists.`); + } + + if (parsedConfig.type === 'video') { + if (parsedConfig.mode === 'sphere') { + this.__addSphereVideoOverlay(parsedConfig as any); + } else { + throw new PSVError('Video cube overlay are not supported.'); + } + } else { + if (parsedConfig.mode === 'sphere') { + this.__addSphereImageOverlay(parsedConfig as any); + } else { + this.__addCubeImageOverlay(parsedConfig as any); + } + } + } + + /** + * Returns the controller of a video overlay + */ + getVideo(id: string) { + if (!this.state.overlays[id]) { + utils.logWarn(`Overlay "${id}" not found`); + return null; + } + if (this.state.overlays[id].config.type !== 'video') { + utils.logWarn(`Overlay "${id}" is not a video`); + return null; + } + const material = this.state.overlays[id].mesh.material as MeshBasicMaterial; + return material.map.image as HTMLVideoElement; + } + + /** + * Removes an overlay + */ + removeOverlay(id: string) { + if (!this.state.overlays[id]) { + utils.logWarn(`Overlay "${id}" not found`); + return; + } + + const { config, mesh } = this.state.overlays[id]; + + if (config.mode === 'sphere' && config.type === 'video') { + const material = mesh.material as MeshBasicMaterial; + const video = material.map.image as HTMLVideoElement; + video.pause(); + this.viewer.needsContinuousUpdate(false); + } + + this.viewer.renderer.removeObject(mesh); + this.viewer.needsUpdate(); + + delete this.state.overlays[id]; + } + + /** + * Remove all overlays + */ + clearOverlays() { + Object.keys(this.state.overlays).forEach((id) => { + this.removeOverlay(id); + }); + } + + /** + * Create the mesh for a spherical overlay + */ + private __createSphereMesh(config: SphereOverlayConfig & ParsedOverlayConfig, map: Texture) { + const adapter = this.__getEquirectangularAdapter(); + + // if not position provided, it is a full sphere matching the base one + const phi = !utils.isNil(config.yaw) ? utils.parseAngle(config.yaw) : -Math.PI; + const theta = !utils.isNil(config.pitch) ? utils.parseAngle(config.pitch, true) : Math.PI / 2; + const phiLength = !utils.isNil(config.width) ? utils.parseAngle(config.width) : 2 * Math.PI; + const thetaLength = !utils.isNil(config.height) ? utils.parseAngle(config.height) : Math.PI; + + const geometry = new SphereGeometry( + CONSTANTS.SPHERE_RADIUS, + Math.round((adapter.SPHERE_SEGMENTS / (2 * Math.PI)) * phiLength), + Math.round((adapter.SPHERE_HORIZONTAL_SEGMENTS / Math.PI) * thetaLength), + phi + Math.PI / 2, + phiLength, + Math.PI / 2 - theta, + thetaLength + ).scale(-1, 1, 1); + + const material = new MeshBasicMaterial({ + map, + transparent: true, // must always be transparent for renderOrder to be respected + opacity: config.opacity, + depthTest: false, + }); + + const mesh = new Mesh(geometry, material); + mesh.renderOrder = 100 + config.zIndex; + mesh.userData[OVERLAY_DATA] = config.id; + + return mesh; + } + + /** + * Create the mesh for a cubemap overlay + */ + private __createCubeMesh( + config: CubeOverlayConfig & ParsedOverlayConfig, + { texture, panoData }: TextureData + ) { + const cubeSize = CONSTANTS.SPHERE_RADIUS * 2; + const geometry = new BoxGeometry(cubeSize, cubeSize, cubeSize).scale(1, 1, -1); + + const materials = []; + for (let i = 0; i < 6; i++) { + if (panoData.flipTopBottom && (i === 2 || i === 3)) { + texture[i].center = new Vector2(0.5, 0.5); + texture[i].rotation = Math.PI; + } + + materials.push( + new MeshBasicMaterial({ + map: texture[i], + transparent: true, + opacity: config.opacity, + depthTest: false, + }) + ); + } + + const mesh = new Mesh(geometry, materials); + mesh.renderOrder = 100 + config.zIndex; + mesh.userData[OVERLAY_DATA] = config.id; + + return mesh; + } + + /** + * Add a spherical still image + */ + private async __addSphereImageOverlay(config: SphereOverlayConfig & ParsedOverlayConfig) { + const panoData = this.viewer.state.textureData.panoData as PanoData; + + // pano data is only applied if the current texture is equirectangular and if no position is provided + const applyPanoData = + panoData?.isEquirectangular + && utils.isNil(config.yaw) + && utils.isNil(config.pitch) + && utils.isNil(config.width) + && utils.isNil(config.height); + + let texture: Texture; + if (applyPanoData) { + // the adapter can only load standard equirectangular textures + const adapter = this.__getEquirectangularAdapter(); + + texture = ( + await adapter.loadTexture( + config.path, + (image) => { + const r = image.width / panoData.croppedWidth; + return { + isEquirectangular: true, + fullWidth: r * panoData.fullWidth, + fullHeight: r * panoData.fullHeight, + croppedWidth: r * panoData.croppedWidth, + croppedHeight: r * panoData.croppedHeight, + croppedX: r * panoData.croppedX, + croppedY: r * panoData.croppedY, + }; + }, + false, + false + ) + ).texture; + } else { + texture = utils.createTexture(await this.viewer.textureLoader.loadImage(config.path)); + } + + const mesh = this.__createSphereMesh(config, texture); + + this.state.overlays[config.id] = { config, mesh }; + + this.viewer.renderer.addObject(mesh); + this.viewer.needsUpdate(); + } + + /** + * Add a spherical video + */ + private __addSphereVideoOverlay(config: SphereOverlayConfig & ParsedOverlayConfig) { + const video = document.createElement('video'); + video.crossOrigin = this.viewer.config.withCredentials ? 'use-credentials' : 'anonymous'; + video.loop = true; + video.playsInline = true; + video.muted = true; + video.autoplay = true; + video.preload = 'metadata'; + video.src = config.path as string; + + const mesh = this.__createSphereMesh({ ...config, opacity: 0 }, new VideoTexture(video)); + + this.state.overlays[config.id] = { config, mesh }; + + this.viewer.renderer.addObject(mesh); + this.viewer.needsContinuousUpdate(true); + video.play(); + + video.addEventListener('play', () => { + mesh.material.opacity = config.opacity; + }, { once: true }); + } + + /** + * Add a cubemap still image + */ + private async __addCubeImageOverlay(config: CubeOverlayConfig & ParsedOverlayConfig) { + const adapter = this.__getCubemapAdapter(); + + const texture = await adapter.loadTexture(config.path, null, false); + const mesh = this.__createCubeMesh(config, texture); + + this.state.overlays[config.id] = { config, mesh }; + + this.viewer.renderer.addObject(mesh); + this.viewer.needsUpdate(); + } + + private __getEquirectangularAdapter() { + if (!this.equirectangularAdapter) { + const id = (this.viewer.adapter.constructor as typeof AbstractAdapter).id; + if (id === 'equirectangular') { + this.equirectangularAdapter = this.viewer.adapter as EquirectangularAdapter; + } else if (id === 'equirectangular-tiles') { + this.equirectangularAdapter = (this.viewer.adapter as EquirectangularTilesAdapter).getAdapter(); + } else { + this.equirectangularAdapter = new EquirectangularAdapter(this.viewer, { + interpolateBackground: false, + useXmpData: false, + }); + } + } + + return this.equirectangularAdapter; + } + + private __getCubemapAdapter() { + if (!this.cubemapAdapter) { + const id = (this.viewer.adapter.constructor as typeof AbstractAdapter).id; + if (id === 'cubemap') { + this.cubemapAdapter = this.viewer.adapter as CubemapAdapter; + } else if (id === 'cubemap-tiles') { + this.cubemapAdapter = (this.viewer.adapter as CubemapTilesAdapter).getAdapter(); + } else if (this.config.cubemapAdapter) { + this.cubemapAdapter = new this.config.cubemapAdapter(this.viewer) as CubemapAdapter; + } else { + throw new PSVError(`Cubemap overlays are only applicable with cubemap adapters`); + } + } + + return this.cubemapAdapter; + } +} diff --git a/packages/overlays-plugin/src/constants.ts b/packages/overlays-plugin/src/constants.ts new file mode 100644 index 000000000..07f15a8d5 --- /dev/null +++ b/packages/overlays-plugin/src/constants.ts @@ -0,0 +1 @@ +export const OVERLAY_DATA = 'psvOverlay'; diff --git a/packages/overlays-plugin/src/events.ts b/packages/overlays-plugin/src/events.ts new file mode 100644 index 000000000..65f613918 --- /dev/null +++ b/packages/overlays-plugin/src/events.ts @@ -0,0 +1,16 @@ +import { TypedEvent } from '@photo-sphere-viewer/core'; +import type { OverlaysPlugin } from './OverlaysPlugin'; + +/** + * Triggered when an overlay is clicked + */ +export class OverlayClickEvent extends TypedEvent { + static override readonly type = 'overlay-click'; + override type: 'overlay-click'; + + constructor(public readonly overlayId: string) { + super(OverlayClickEvent.type); + } +} + +export type OverlaysPluginEvents = OverlayClickEvent; diff --git a/packages/overlays-plugin/src/index.ts b/packages/overlays-plugin/src/index.ts new file mode 100644 index 000000000..de92071b0 --- /dev/null +++ b/packages/overlays-plugin/src/index.ts @@ -0,0 +1,4 @@ +import * as events from './events'; + +export { OverlaysPlugin } from './OverlaysPlugin'; +export { events }; diff --git a/packages/overlays-plugin/src/model.ts b/packages/overlays-plugin/src/model.ts new file mode 100644 index 000000000..515189efc --- /dev/null +++ b/packages/overlays-plugin/src/model.ts @@ -0,0 +1,74 @@ +import type { AdapterConstructor } from '@photo-sphere-viewer/core'; +import type { CubemapPanorama } from '@photo-sphere-viewer/cubemap-adapter'; + +export type BaseOverlayConfig = { + id?: string; + /** + * @default image + */ + type?: 'image' | 'video'; + /** + * @default 1 + */ + opacity?: number; + /** + * @default 0 + */ + zIndex?: number; +}; + +/** + * Overlay applied on a sphere, complete or partial + */ +export type SphereOverlayConfig = BaseOverlayConfig & { + path: string; + /** + * @default -PI + */ + yaw?: number | string; + /** + * @default PI / 2 + */ + pitch?: number | string; + /** + * @default 2 * PI + */ + width?: number | string; + /** + * @default PI + */ + height?: number | string; +}; + +/** + * Overlay applied on a whole cube (6 images) + */ +export type CubeOverlayConfig = BaseOverlayConfig & { + path: CubemapPanorama; + type?: 'image'; +}; + +export type OverlayConfig = SphereOverlayConfig | CubeOverlayConfig; + +/** + * @internal + */ +export type ParsedOverlayConfig = OverlayConfig & { + mode: 'sphere' | 'cube'; +}; + +export type OverlaysPluginConfig = { + /** + * Initial overlays + */ + overlays?: OverlayConfig[]; + /** + * Automatically remove all overlays when the panorama changes + * @default true + */ + autoclear?: boolean; + /** + * Used to display cubemap overlays on equirectangular panoramas + */ + cubemapAdapter?: AdapterConstructor; +}; diff --git a/packages/overlays-plugin/tsconfig.json b/packages/overlays-plugin/tsconfig.json new file mode 100644 index 000000000..ebd748fe4 --- /dev/null +++ b/packages/overlays-plugin/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*.ts", "../shared/typings.d.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/overlays-plugin/tsup.config.js b/packages/overlays-plugin/tsup.config.js new file mode 100644 index 000000000..a1aaf53f1 --- /dev/null +++ b/packages/overlays-plugin/tsup.config.js @@ -0,0 +1,4 @@ +import createConfig from '../../build/tsup.config'; +import pkg from './package.json' assert { type: 'json' }; + +export default createConfig(pkg); diff --git a/packages/overlays-plugin/typedoc.json b/packages/overlays-plugin/typedoc.json new file mode 100644 index 000000000..24c8d252f --- /dev/null +++ b/packages/overlays-plugin/typedoc.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "extends": ["../../typedoc.json"], + "entryPoints": ["src/index.ts"], + "entryPointStrategy": "resolve", + "name": "OverlaysPlugin", + "readme": "./.typedoc/README.md" +}