From df56f8df247f6e953103d6ea4dce4064ccdeb299 Mon Sep 17 00:00:00 2001 From: mistic100 Date: Mon, 5 Apr 2021 16:54:27 +0200 Subject: [PATCH] Close #243 Support tiled panorama, externalize cubemap --- docs/.jsdoc/index.md | 2 + docs/.vuepress/config.js | 10 +- docs/README.md | 4 +- docs/guide/README.md | 2 +- docs/guide/adapters/README.md | 32 ++ docs/guide/adapters/cubemap.md | 57 +++ docs/guide/adapters/tiles.md | 77 +++ docs/guide/config.md | 28 +- docs/guide/cubemap.md | 9 - docs/plugins/README.md | 2 +- example/cubemap.html | 2 + example/equirectangular-tiles.html | 139 +++++ rollup.config.js | 15 + src/Viewer.js | 30 +- src/adapters/AbstractAdapter.js | 90 ++++ src/adapters/cubemap/index.js | 155 ++++++ src/adapters/equirectangular-tiles/Queue.js | 67 +++ src/adapters/equirectangular-tiles/Task.js | 46 ++ src/adapters/equirectangular-tiles/index.js | 535 ++++++++++++++++++++ src/adapters/equirectangular/index.js | 204 ++++++++ src/data/config.js | 14 + src/data/constants.js | 34 +- src/index.js | 17 +- src/plugins/gyroscope/index.js | 4 - src/plugins/resolution/index.js | 2 +- src/plugins/stereo/index.js | 5 - src/services/DataHelper.js | 12 +- src/services/EventsHandler.js | 7 +- src/services/Renderer.js | 143 +----- src/services/TextureLoader.js | 288 +---------- src/utils/misc.js | 1 + src/utils/psv.js | 14 + 32 files changed, 1555 insertions(+), 492 deletions(-) create mode 100644 docs/guide/adapters/README.md create mode 100644 docs/guide/adapters/cubemap.md create mode 100644 docs/guide/adapters/tiles.md delete mode 100644 docs/guide/cubemap.md create mode 100644 example/equirectangular-tiles.html create mode 100644 src/adapters/AbstractAdapter.js create mode 100644 src/adapters/cubemap/index.js create mode 100644 src/adapters/equirectangular-tiles/Queue.js create mode 100644 src/adapters/equirectangular-tiles/Task.js create mode 100644 src/adapters/equirectangular-tiles/index.js create mode 100644 src/adapters/equirectangular/index.js diff --git a/docs/.jsdoc/index.md b/docs/.jsdoc/index.md index 52fa09afb..9b2498530 100644 --- a/docs/.jsdoc/index.md +++ b/docs/.jsdoc/index.md @@ -9,11 +9,13 @@ - [Viewer](PSV.Viewer.html) - [Events](PSV.html#.event:autorotate) - [Plugins](PSV.plugins.html) +- [Adapters](PSV.adapters.html) --- ## Exported members +- [AbstractAdapter](PSV.adapters.AbstractAdapter.html) - Base class for render adapters - [AbstractButton](PSV.buttons.AbstractButton.html) - Base class for plugins buttons - [AbstractPlugin](PSV.plugins.AbstractPlugin.html) - Base class for plugins - [Animation](PSV.Animation.html) - Animations manager diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index 6e745ade6..bc1bcf90d 100644 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -40,9 +40,17 @@ module.exports = { 'events', 'navbar', 'markers', - 'cubemap', 'cropped-panorama', 'migration-v3', + { + title: 'Adapters', + path: '/guide/adapters/', + collapsable : false, + children: [ + 'adapters/cubemap', + 'adapters/tiles', + ], + }, { title: 'Reusable components', path: '/guide/components/', diff --git a/docs/README.md b/docs/README.md index 51c1c3b94..4c37eb590 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,8 +20,8 @@ features: footer: Licensed under MIT License, documentation under CC BY 3.0 --- -::: tip New playground -Test Photo Sphere Viewer with you own panorama in the [Playground](playground.md) +::: tip New tiles support +Improve the loading time of big panorams with the the [Tiles adapter](guide/adapters/tiles.md). ::: diff --git a/docs/guide/README.md b/docs/guide/README.md index 3285ee938..0b2f86e4c 100644 --- a/docs/guide/README.md +++ b/docs/guide/README.md @@ -42,7 +42,7 @@ You can also [download the latest release](https://github.com/mistic100/Photo-Sp Include all JS & CSS files in your page manually or with your favorite bundler and init the viewer. -The `panorama` must be an [equirectangular projection](https://en.wikipedia.org/wiki/Equirectangular_projection) of your photo. You can also use [cubemap projection](./cubemap.md) with a special syntax. +The `panorama` must be an [equirectangular projection](https://en.wikipedia.org/wiki/Equirectangular_projection) of your photo. Other modes are supported through [adapters](./adapters/). :::: tabs diff --git a/docs/guide/adapters/README.md b/docs/guide/adapters/README.md new file mode 100644 index 000000000..8cc5194e5 --- /dev/null +++ b/docs/guide/adapters/README.md @@ -0,0 +1,32 @@ +# Adapters + +Adapters are small pieces of code responsible to load the panorama texture(s) in the THREE.js scene. + +The supported adapters are: +- `equirectangular`: the default adapter, used to load full or partial equirectangular panoramas +- [cubemap](cubemap.md): used to load cubemaps projections (six textures) +- [equirectangular tiles](tiles.md): used to load tiled equirectangular panoramas + +## Import an adapter + +Official adapters (listed on the left menu) are available in the the main `photo-sphere-viewer` package inside the `dist/adapters` directory. + +**Example for the Cubemap adapter:** + +:::: tabs + +::: tab Direct import +```html + + + +``` +::: + +::: tab ES import +```js +import CubemapAdapter from 'photo-sphere-viewer/dist/adapters/cubemap'; +``` +::: + +:::: diff --git a/docs/guide/adapters/cubemap.md b/docs/guide/adapters/cubemap.md new file mode 100644 index 000000000..50cb14e27 --- /dev/null +++ b/docs/guide/adapters/cubemap.md @@ -0,0 +1,57 @@ +# Cubemap adapter + +> [Cube mapping](https://en.wikipedia.org/wiki/Cube_mapping) is a kind of projection where the environment is mapped to the six faces of a cube around the viewer. + +This adapter is available in the core `photo-sphere-viewer` package in `dist/adapters/cubemap.js`. + +Photo Sphere Viewer supports cubemaps as six distinct image files. The files can be provided as an object or an array. All features of Photo Sphere Viewer are fully supported when using cubemaps but the `fisheye` option gives funky results. + +```js +new PhotoSphereViewer.Viewer({ + adapter: PhotoSphereViewer.CubemapAdapter, + panorama: { + left: 'path/to/left.jpg', + front: 'path/to/front.jpg', + right: 'path/to/right.jpg', + back: 'path/to/back.jpg', + top: 'path/to/top.jpg', + bottom: 'path/to/bottom.jpg', + }, +}); +``` + +::: warning +This adapter does not use `panoData` option. You can use `sphereCorrection` if the tilt/roll/pan needs to be corrected. +::: + + +## Example + + + + +## Configuration + +When using this adapter the `panorama` option and the `setPanorama()` method accept an array or an object of six URLs. + +```js +// Cubemap as array (order is important) : +panorama: [ + 'path/to/left.jpg', + 'path/to/front.jpg', + 'path/to/right.jpg', + 'path/to/back.jpg', + 'path/to/top.jpg', + 'path/to/bottom.jpg', +] + +// Cubemap as object : +panorama: { + left: 'path/to/left.jpg', + front: 'path/to/front.jpg', + right: 'path/to/right.jpg', + back: 'path/to/back.jpg', + top: 'path/to/top.jpg', + bottom: 'path/to/bottom.jpg', +} +``` diff --git a/docs/guide/adapters/tiles.md b/docs/guide/adapters/tiles.md new file mode 100644 index 000000000..8e38e9c31 --- /dev/null +++ b/docs/guide/adapters/tiles.md @@ -0,0 +1,77 @@ +# Equirectangular tiles adapter + +> Reduce the initial loading time and used bandwidth by slicing big panoramas into many small tiles. + +This adapter is available in the core `photo-sphere-viewer` package in `dist/adapters/equirectangular-tiles.js`. + +```js +new PhotoSphereViewer.Viewer({ + adapter: PhotoSphereViewer.EquirectangularTilesAdapter, + panorama: { + width: 6000, + cols: 16, + rows: 8, + baseUrl: 'low_res.jpg', + tileUrl: (col, row) => { + return `tile_${col}x${row}.jpg`; + }, + }, +}); +``` + +::: warning +This adapter does not use `panoData` option. You can use `sphereCorrection` if the tilt/roll/pan needs to be corrected. +::: + + +## Example + + + + +## Configuration + +When using this adapter the `panorama` option and the `setPanorama()` method accept an object to configure the tiles. + +#### `width` (required) +- type: `number` + +Total width of the panorama, the height is always width / 2. + +#### `cols` (required) +- type: `number` + +Number of columns, must be power of two (4, 6, 16, 32, 64) and the maximum value is 64. + +#### `rows` (required) +- type: `number` + +Number of rows, must be power of two (2, 4, 6, 16, 32) and the maximum value is 32. + +#### `tileUrl` (required) +- type: `function: (col, row) => string` + +Function used to build the URL of a tile. + +#### `baseUrl` (recommended) +- type: `string` + +URL of a low resolution complete panorama image to display while the tiles are loading. + + +## Preparing the panorama + +The tiles can be easily generated using [ImageMagick](https://imagemagick.org) software. + +Let's say you have a 12.000x6.000 pixels panorama you want to split in 32 columns and 16 rows, use the following command: + +``` +magick panorama.jpg -crop 375x375 tile_%04d.jpg +``` + +You can also use this [online tool](https://pinetools.com/split-image). + + +::: tip Performances +It is recommanded to not exceed tiles with a size of 1024x1024 pixels, thus limiting the maximum panorama size to 65.536x32.768 pixels (a little more than 2 Gigapixels). +::: diff --git a/docs/guide/config.md b/docs/guide/config.md index f6988809a..5e5f0ea95 100644 --- a/docs/guide/config.md +++ b/docs/guide/config.md @@ -23,29 +23,15 @@ container: document.querySelector('.viewer') container: 'viewer' // will target [id="viewer"] ``` -#### `panorama` (required) -- type: `string | string[] | object` +#### `adapter` +- default: `equirectangular` -Path to the panorama image(s). It must be a single string for equirectangular panoramas and an array or an object for cubemaps. +Which [adapter](./adapters) used to load the panorama. -```js -// Equirectangular panorama : -panorama: 'path/to/panorama.jpg' - -// Cubemap as array (order is important) : -panorama: [ - 'path/to/left.jpg', 'path/to/front.jpg', - 'path/to/right.jpg', 'path/to/back.jpg', - 'path/to/top.jpg', 'path/to/bottom.jpg', -] - -// Cubemap as object : -panorama: { - left: 'path/to/left.jpg', front: 'path/to/front.jpg', - right: 'path/to/right.jpg', back: 'path/to/back.jpg', - top: 'path/to/top.jpg', bottom: 'path/to/bottom.jpg', -} -``` +#### `panorama` (required) +- type: `*` + +Path to the panorama. Must be a single URL for the default equirectangular adapter. Other adapters support other values. #### `plugins` - type: `array` diff --git a/docs/guide/cubemap.md b/docs/guide/cubemap.md deleted file mode 100644 index 93856de1a..000000000 --- a/docs/guide/cubemap.md +++ /dev/null @@ -1,9 +0,0 @@ -# Cubemap projection - -[Cube mapping](https://en.wikipedia.org/wiki/Cube_mapping) is a kind of projection where the environment is mapped to the six faces of a cube arround the viewer. - -Photo Sphere Viewer supports cubemaps as six distinct image files. The files can be provided as [an object or an array](./config.md#panorama-required). - -All features of Photo Sphere Viewer are fully supported when using cubemaps but the `fisheye` option gives funky results. - - diff --git a/docs/plugins/README.md b/docs/plugins/README.md index 42ab739b9..433c2ba52 100644 --- a/docs/plugins/README.md +++ b/docs/plugins/README.md @@ -6,7 +6,7 @@ Plugins are used to add new functionalities to Photo Sphere Viewer. They can acc Official plugins (listed on the left menu) are available in the the main `photo-sphere-viewer` package inside the `dist/plugins` directory. Some plugins also have an additional CSS file. -**Example for the Markers plugins:** +**Example for the Markers plugin:** :::: tabs diff --git a/example/cubemap.html b/example/cubemap.html index 5092883cc..34efe3644 100644 --- a/example/cubemap.html +++ b/example/cubemap.html @@ -37,6 +37,7 @@ + + + + + + + + + + diff --git a/rollup.config.js b/rollup.config.js index 0190094e4..3071f5a97 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -13,6 +13,10 @@ import pkg from './package.json'; const plugins = fs.readdirSync(path.join(__dirname, 'src/plugins')) .filter(p => p !== 'AbstractPlugin.js'); +const adapters = fs.readdirSync(path.join(__dirname, 'src/adapters')) + .filter(p => p !== 'AbstractAdapter.js') + .filter(p => p !== 'equirectangular'); + const banner = `/*! * Photo Sphere Viewer ${pkg.version} * @copyright 2014-2015 Jérémy Heleine @@ -149,4 +153,15 @@ export default [ }, plugins: secondaryConfig.plugins(), })) +).concat( + adapters.map(p => ({ + ...secondaryConfig, + input : `src/adapters/${p}/index.js`, + output : { + ...secondaryConfig.output, + file: `dist/adapters/${p}.js`, + name: `PhotoSphereViewer.${camelize(p)}Adapter`, + }, + plugins: secondaryConfig.plugins(), + })) ); diff --git a/src/Viewer.js b/src/Viewer.js index 64ca36bb2..7303372f0 100644 --- a/src/Viewer.js +++ b/src/Viewer.js @@ -7,7 +7,7 @@ import { Notification } from './components/Notification'; import { Overlay } from './components/Overlay'; import { Panel } from './components/Panel'; import { CONFIG_PARSERS, DEFAULTS, DEPRECATED_OPTIONS, getConfig, READONLY_OPTIONS } from './data/config'; -import { CHANGE_EVENTS, EVENTS, IDS, VIEWER_DATA } from './data/constants'; +import { CHANGE_EVENTS, EVENTS, IDS, SPHERE_RADIUS, VIEWER_DATA } from './data/constants'; import { SYSTEM } from './data/system'; import errorIcon from './icons/error.svg'; import { PSVError } from './PSVError'; @@ -65,7 +65,6 @@ export class Viewer extends EventEmitter { * @protected * @property {boolean} ready - when all components are loaded * @property {boolean} needsUpdate - if the view needs to be renderer - * @property {boolean} isCubemap - if the panorama is a cubemap * @property {external:THREE.Vector3} direction - direction of the camera * @property {number} vFov - vertical FOV * @property {number} hFov - horizontal FOV @@ -82,8 +81,7 @@ export class Viewer extends EventEmitter { uiRefresh : false, needsUpdate : false, fullscreen : false, - isCubemap : undefined, - direction : new THREE.Vector3(), + direction : new THREE.Vector3(0, 0, SPHERE_RADIUS), vFov : null, hFov : null, aspect : null, @@ -129,6 +127,14 @@ export class Viewer extends EventEmitter { this.container.classList.add('psv-container'); this.parent.appendChild(this.container); + /** + * @summary Render adapter + * @type {PSV.adapters.AbstractAdapter} + * @readonly + * @package + */ + this.adapter = new this.config.adapter[0](this, this.config.adapter[1]); // eslint-disable-line new-cap + /** * @summary All child components * @type {PSV.components.AbstractComponent[]} @@ -226,6 +232,7 @@ export class Viewer extends EventEmitter { longitude: new Dynamic(null), latitude : new Dynamic(null, -Math.PI / 2, Math.PI / 2), }, (position) => { + this.dataHelper.sphericalCoordsToVector3(position, this.prop.direction); this.needsUpdate(); this.trigger(EVENTS.POSITION_UPDATED, position); }), @@ -290,6 +297,7 @@ export class Viewer extends EventEmitter { this.renderer.destroy(); this.textureLoader.destroy(); this.dataHelper.destroy(); + this.adapter.destroy(); this.children.slice().forEach(child => child.destroy()); this.children.length = 0; @@ -424,6 +432,8 @@ export class Viewer extends EventEmitter { this.prop.aspect = this.prop.size.width / this.prop.size.height; this.prop.hFov = this.dataHelper.vFovToHFov(this.prop.vFov); + this.renderer.updateCameraMatrix(); + this.needsUpdate(); this.trigger(EVENTS.SIZE_UPDATED, this.getSize()); this.__resizeRefresh(); @@ -435,7 +445,7 @@ export class Viewer extends EventEmitter { * @description Loads a new panorama file, optionally changing the camera position/zoom and activating the transition animation.
* If the "options" parameter is not defined, the camera will not move and the ongoing animation will continue.
* If another loading is already in progress it will be aborted. - * @param {string|string[]|PSV.Cubemap} path - URL of the new panorama file + * @param {*} path - URL of the new panorama file * @param {PSV.PanoramaOptions} [options] * @returns {Promise} */ @@ -446,10 +456,10 @@ export class Viewer extends EventEmitter { // apply default parameters on first load if (!this.prop.ready) { - if (!('longitude' in options) && !this.prop.isCubemap) { + if (!('longitude' in options)) { options.longitude = this.config.defaultLong; } - if (!('latitude' in options) && !this.prop.isCubemap) { + if (!('latitude' in options)) { options.latitude = this.config.defaultLat; } if (!('zoom' in options)) { @@ -503,12 +513,12 @@ export class Viewer extends EventEmitter { } }; - if (!options.transition || !this.prop.ready) { + if (!options.transition || !this.prop.ready || !this.adapter.constructor.supportsTransition) { if (options.showLoader || !this.prop.ready) { this.loader.show(); } - this.prop.loadingPromise = this.textureLoader.loadTexture(this.config.panorama, options.panoData) + this.prop.loadingPromise = this.adapter.loadTexture(this.config.panorama, options.panoData) .then((textureData) => { this.renderer.setTexture(textureData); this.renderer.setPanoramaPose(textureData.panoData); @@ -528,7 +538,7 @@ export class Viewer extends EventEmitter { this.loader.show(); } - this.prop.loadingPromise = this.textureLoader.loadTexture(this.config.panorama, options.panoData) + this.prop.loadingPromise = this.adapter.loadTexture(this.config.panorama, options.panoData) .then((textureData) => { this.loader.hide(); diff --git a/src/adapters/AbstractAdapter.js b/src/adapters/AbstractAdapter.js new file mode 100644 index 000000000..42f8821bd --- /dev/null +++ b/src/adapters/AbstractAdapter.js @@ -0,0 +1,90 @@ +import { PSVError } from '../PSVError'; + +/** + * @namespace PSV.adapters + */ + +/** + * @summary Base adapters class + * @memberof PSV.adapters + * @abstract + */ +export class AbstractAdapter { + + /** + * @summary Unique identifier of the adapter + * @member {string} + * @readonly + * @static + */ + static id = null; + + /** + * @summary Indicates if the adapter supports transitions between panoramas + * @member {boolean} + * @readonly + * @static + */ + static supportsTransition = false; + + /** + * @param {PSV.Viewer} psv + */ + constructor(psv) { + /** + * @summary Reference to main controller + * @type {PSV.Viewer} + * @readonly + */ + this.psv = psv; + } + + /** + * @summary Destroys the service + */ + destroy() { + delete this.psv; + } + + /** + * @abstract + * @summary Loads the panorama texture(s) + * @param {*} panorama + * @param {PSV.PanoData | PSV.PanoDataProvider} [newPanoData] + * @returns {Promise.} + */ + loadTexture(panorama, newPanoData) { // eslint-disable-line no-unused-vars + throw new PSVError('loadTexture not implemented'); + } + + /** + * @abstract + * @summary Creates the cube mesh + * @param {number} [scale=1] + * @returns {external:THREE.Mesh} + */ + createMesh(scale = 1) { // eslint-disable-line no-unused-vars + throw new PSVError('createMesh not implemented'); + } + + /** + * @abstract + * @summary Applies the texture to the mesh + * @param {external:THREE.Mesh} mesh + * @param {PSV.TextureData} textureData + */ + setTexture(mesh, textureData) { // eslint-disable-line no-unused-vars + throw new PSVError('setTexture not implemented'); + } + + /** + * @abstract + * @summary Changes the opacity of the mesh + * @param {external:THREE.Mesh} mesh + * @param {number} opacity + */ + setTextureOpacity(mesh, opacity) { // eslint-disable-line no-unused-vars + throw new PSVError('setTextureOpacity not implemented'); + } + +} diff --git a/src/adapters/cubemap/index.js b/src/adapters/cubemap/index.js new file mode 100644 index 000000000..ec86aca10 --- /dev/null +++ b/src/adapters/cubemap/index.js @@ -0,0 +1,155 @@ +import { AbstractAdapter, CONSTANTS, PSVError, SYSTEM, utils } from 'photo-sphere-viewer'; +import * as THREE from 'three'; + +/** + * @typedef {Object} PSV.adapters.CubemapAdapter.Cubemap + * @summary Object defining a cubemap + * @property {string} top + * @property {string} right + * @property {string} bottom + * @property {string} left + * @property {string} front + * @property {string} back + */ + +const CUBE_VERTICES = 8; +const CUBE_MAP = [0, 2, 4, 5, 3, 1]; +const CUBE_HASHMAP = ['left', 'right', 'top', 'bottom', 'back', 'front']; + +/** + * @summary Adapter for cubemaps + * @memberof PSV.adapters + */ +export default class CubemapAdapter extends AbstractAdapter { + + static id = 'cubemap'; + static supportsTransition = true; + + /** + * @override + * @param {string[] | PSV.adapters.CubemapAdapter.Cubemap} panorama + * @returns {Promise.} + */ + loadTexture(panorama) { + const cleanPanorama = []; + + if (Array.isArray(panorama)) { + if (panorama.length !== 6) { + return Promise.reject(new PSVError('Must provide exactly 6 image paths when using cubemap.')); + } + + // reorder images + for (let i = 0; i < 6; i++) { + cleanPanorama[i] = panorama[CUBE_MAP[i]]; + } + } + else if (typeof panorama === 'object') { + if (!CUBE_HASHMAP.every(side => !!panorama[side])) { + return Promise.reject(new PSVError('Must provide exactly left, front, right, back, top, bottom when using cubemap.')); + } + + // transform into array + CUBE_HASHMAP.forEach((side, i) => { + cleanPanorama[i] = panorama[side]; + }); + } + else { + return Promise.reject(new PSVError('Invalid cubemap panorama, are you using the right adapter?')); + } + + if (this.psv.config.fisheye) { + utils.logWarn('fisheye effect with cubemap texture can generate distorsion'); + } + + const promises = []; + const progress = [0, 0, 0, 0, 0, 0]; + + for (let i = 0; i < 6; i++) { + promises.push( + this.psv.textureLoader.loadImage(cleanPanorama[i], (p) => { + progress[i] = p; + this.psv.loader.setProgress(utils.sum(progress) / 6); + }) + .then(img => this.__createCubemapTexture(img)) + ); + } + + return Promise.all(promises) + .then(texture => ({ texture })); + } + + /** + * @summary Creates the final texture from image + * @param {HTMLImageElement} img + * @returns {external:THREE.Texture} + * @private + */ + __createCubemapTexture(img) { + let finalImage; + + // resize image + if (img.width > SYSTEM.maxTextureWidth) { + const buffer = document.createElement('canvas'); + const ratio = SYSTEM.getMaxCanvasWidth() / img.width; + + buffer.width = img.width * ratio; + buffer.height = img.height * ratio; + + const ctx = buffer.getContext('2d'); + ctx.drawImage(img, 0, 0, buffer.width, buffer.height); + + finalImage = buffer; + } + else { + finalImage = img; + } + + return utils.createTexture(finalImage); + } + + /** + * @override + */ + createMesh(scale = 1) { + const cubeSize = CONSTANTS.SPHERE_RADIUS * 2 * scale; + const geometry = new THREE.BoxGeometry(cubeSize, cubeSize, cubeSize, CUBE_VERTICES, CUBE_VERTICES, CUBE_VERTICES); + + const materials = []; + for (let i = 0; i < 6; i++) { + materials.push(new THREE.MeshBasicMaterial({ + side: THREE.BackSide, + })); + } + + const mesh = new THREE.Mesh(geometry, materials); + mesh.scale.set(1, 1, -1); + + return mesh; + } + + /** + * @override + */ + setTexture(mesh, textureData) { + const { texture } = textureData; + + for (let i = 0; i < 6; i++) { + if (mesh.material[i].map) { + mesh.material[i].map.dispose(); + } + + mesh.material[i].map = texture[i]; + } + } + + /** + * @override + */ + setTextureOpacity(mesh, opacity) { + for (let i = 0; i < 6; i++) { + mesh.material[i].opacity = opacity; + mesh.material[i].transparent = opacity < 1; + } + } + +} diff --git a/src/adapters/equirectangular-tiles/Queue.js b/src/adapters/equirectangular-tiles/Queue.js new file mode 100644 index 000000000..ee224bd9c --- /dev/null +++ b/src/adapters/equirectangular-tiles/Queue.js @@ -0,0 +1,67 @@ +import { Task } from './Task'; + +/** + * @summary Loading queue + * @memberOf PSV.adapters.EquirectangularTilesAdapter + * @package + */ +export class Queue { + + /** + * @param {int} concurency + */ + constructor(concurency) { + this.concurency = concurency; + this.runningTasks = {}; + this.tasks = {}; + } + + enqueue(task) { + this.tasks[task.id] = task; + } + + clear() { + Object.values(this.tasks).forEach(task => task.cancel()); + this.tasks = {}; + this.runningTasks = {}; + } + + setPriority(taskId, priority) { + if (this.tasks[taskId]) { + this.tasks[taskId].priority = priority; + } + } + + setAllPriorities(priority) { + Object.values(this.tasks).forEach((task) => { + task.priority = priority; + }); + } + + start() { + if (Object.keys(this.runningTasks).length >= this.concurency) { + return; + } + + const nextTask = Object.values(this.tasks) + .filter(task => task.status === Task.STATUS.PENDING && task.priority > 0) + .sort((a, b) => a.priority - b.priority) + .pop(); + + if (nextTask) { + this.runningTasks[nextTask.id] = true; + + nextTask.start() + .then(() => { + if (!nextTask.isCancelled()) { + delete this.tasks[nextTask.id]; + delete this.runningTasks[nextTask.id]; + this.start(); + } + }); + + this.start(); // start tasks until max concurrency is reached + } + } + +} diff --git a/src/adapters/equirectangular-tiles/Task.js b/src/adapters/equirectangular-tiles/Task.js new file mode 100644 index 000000000..715b4b66a --- /dev/null +++ b/src/adapters/equirectangular-tiles/Task.js @@ -0,0 +1,46 @@ +/** + * @summary Loading task + * @memberOf PSV.adapters.EquirectangularTilesAdapter + * @package + */ +export class Task { + + static STATUS = { + PENDING : 0, + RUNNING : 1, + CANCELLED: 2, + DONE : 3, + ERROR : 4, + }; + + /** + * @param {string} id + * @param {number} priority + * @param {function(Task): Promise} fn + */ + constructor(id, priority, fn) { + this.id = id; + this.priority = priority; + this.fn = fn; + this.status = Task.STATUS.PENDING; + } + + start() { + this.status = Task.STATUS.RUNNING; + return this.fn(this) + .then(() => { + this.status = Task.STATUS.DONE; + }, () => { + this.status = Task.STATUS.ERROR; + }); + } + + cancel() { + this.status = Task.STATUS.CANCELLED; + } + + isCancelled() { + return this.status === Task.STATUS.CANCELLED; + } + +} diff --git a/src/adapters/equirectangular-tiles/index.js b/src/adapters/equirectangular-tiles/index.js new file mode 100644 index 000000000..32bd2eeb9 --- /dev/null +++ b/src/adapters/equirectangular-tiles/index.js @@ -0,0 +1,535 @@ +import { AbstractAdapter, CONSTANTS, PSVError, utils } from 'photo-sphere-viewer'; +import * as THREE from 'three'; +import { Queue } from './Queue'; +import { Task } from './Task'; + +/** + * @callback TileUrl + * @summary Function called to build a tile url + * @memberOf PSV.adapters.EquirectangularTilesAdapter + * @param {int} col + * @param {int} row + * @returns {string} + */ + +/** + * @typedef {Object} PSV.adapters.EquirectangularTilesAdapter.Panorama + * @summary Configuration of a tiled panorama + * @property {string} [baseUrl] - low resolution panorama loaded before tiles + * @property {int} width - complete panorama width (height is always width/2) + * @property {int} cols - number of vertical tiles + * @property {int} rows - number of horizontal tiles + * @property {PSV.adapters.EquirectangularTilesAdapter.TileUrl} tileUrl - function to build a tile url + */ + +/** + * @typedef {Object} PSV.adapters.EquirectangularTilesAdapter.Options + * @property {boolean} [showErrorTile=true] - shows a warning sign on tiles that cannot be loaded + */ + +/** + * @typedef {Object} PSV.adapters.EquirectangularTilesAdapter.Tile + * @private + * @property {int} col + * @property {int} row + * @property {int} angle + */ + +const SPHERE_SEGMENTS = 64; +const NB_VERTICES = 3 * (SPHERE_SEGMENTS * 2 + (SPHERE_SEGMENTS / 2 - 2) * SPHERE_SEGMENTS * 2); +const NB_GROUPS = SPHERE_SEGMENTS * 2 + (SPHERE_SEGMENTS / 2 - 2) * SPHERE_SEGMENTS; +const QUEUE_CONCURENCY = 4; + +function tileId(tile) { + return `${tile.col}x${tile.row}`; +} + +function powerOfTwo(x) { + return (Math.log(x) / Math.log(2)) % 1 === 0; +} + + +/** + * @summary Adapter for tiled panoramas + * @memberof PSV.adapters + */ +export default class EquirectangularTilesAdapter extends AbstractAdapter { + + static id = 'equirectangular-tiles'; + static supportsTransition = false; + + /** + * @param {PSV.Viewer} psv + * @param {PSV.adapters.EquirectangularTilesAdapter.Options} options + */ + constructor(psv, options) { + super(psv); + + /** + * @member {PSV.adapters.EquirectangularTilesAdapter.Options} + * @private + */ + this.config = { + showErrorTile: true, + ...options, + }; + + /** + * @member {external:THREE.MeshBasicMaterial[]} + * @private + */ + this.materials = []; + + /** + * @member {PSV.adapters.EquirectangularTilesAdapter.Queue} + * @private + */ + this.queue = new Queue(QUEUE_CONCURENCY); + + /** + * @type {Object} + * @property {int} colSize - size in pixels of a column + * @property {int} rowSize - size in pixels of a row + * @property {int} facesByCol - number of mesh faces by column + * @property {int} facesByRow - number of mesh faces by row + * @property {Record} tiles - loaded tiles + * @property {external:THREE.SphereGeometry} geom + * @property {*} originalUvs + * @property {external:THREE.MeshBasicMaterial} errorMaterial + * @private + */ + this.prop = { + colSize : 0, + rowSize : 0, + facesByCol : 0, + facesByRow : 0, + tiles : {}, + geom : null, + originalUvs : null, + errorMaterial: null, + }; + + /** + * @member {external:THREE.ImageLoader} + * @private + */ + this.loader = new THREE.ImageLoader(); + if (this.psv.config.withCredentials) { + this.loader.setWithCredentials(true); + } + + this.psv.on(CONSTANTS.EVENTS.POSITION_UPDATED, this); + this.psv.on(CONSTANTS.EVENTS.ZOOM_UPDATED, this); + } + + destroy() { + this.psv.off(CONSTANTS.EVENTS.POSITION_UPDATED, this); + this.psv.off(CONSTANTS.EVENTS.ZOOM_UPDATED, this); + + this.__cleanup(); + + this.prop.errorMaterial?.map?.dispose(); + this.prop.errorMaterial?.dispose(); + + delete this.queue; + delete this.loader; + delete this.prop.geom; + delete this.prop.originalUvs; + delete this.prop.errorMaterial; + + super.destroy(); + } + + handleEvent(e) { + /* eslint-disable */ + switch (e.type) { + case CONSTANTS.EVENTS.POSITION_UPDATED: + case CONSTANTS.EVENTS.ZOOM_UPDATED: + this.__refresh(); + break; + } + /* eslint-enable */ + } + + /** + * @summary Clears loading queue, dispose all materials + * @private + */ + __cleanup() { + this.queue.clear(); + this.prop.tiles = {}; + + this.materials.forEach((mat) => { + mat?.map?.dispose(); + mat?.dispose(); + }); + this.materials.length = 0; + } + + /** + * @override + * @param {PSV.adapters.EquirectangularTilesAdapter.Panorama} panorama + * @returns {Promise.} + */ + loadTexture(panorama) { + if (typeof panorama !== 'object' || !panorama.width || !panorama.cols || !panorama.rows || !panorama.tileUrl) { + return Promise.reject(new PSVError('Invalid panorama configuration, are you using the right adapter?')); + } + if (panorama.cols > SPHERE_SEGMENTS) { + return Promise.reject(new PSVError(`Panorama cols must not be greater than ${SPHERE_SEGMENTS}.`)); + } + if (panorama.rows > SPHERE_SEGMENTS / 2) { + return Promise.reject(new PSVError(`Panorama rows must not be greater than ${SPHERE_SEGMENTS / 2}.`)); + } + if (!powerOfTwo(panorama.cols) || !powerOfTwo(panorama.rows)) { + return Promise.reject(new PSVError('Panorama cols and rows must be powers of 2.')); + } + + panorama.height = panorama.width / 2; + + this.prop.colSize = panorama.width / panorama.cols; + this.prop.rowSize = panorama.height / panorama.rows; + this.prop.facesByCol = SPHERE_SEGMENTS / panorama.cols; + this.prop.facesByRow = SPHERE_SEGMENTS / 2 / panorama.rows; + + this.__cleanup(); + + if (this.prop.geom) { + this.prop.geom.setAttribute('uv', this.prop.originalUvs.clone()); + } + + const panoData = { + fullWidth : panorama.width, + fullHeight : panorama.height, + croppedWidth : panorama.width, + croppedHeight: panorama.height, + croppedX : 0, + croppedY : 0, + }; + + if (panorama.baseUrl) { + return this.psv.textureLoader.loadImage(panorama.baseUrl, p => this.psv.loader.setProgress(p)) + .then((img) => { + return { + texture : utils.createTexture(img), + panoData: panoData, + }; + }); + } + else { + return Promise.resolve({ + texture : null, + panoData: panoData, + }); + } + } + + /** + * @override + */ + createMesh(scale = 1) { + const geometry = new THREE.SphereGeometry(CONSTANTS.SPHERE_RADIUS * scale, SPHERE_SEGMENTS, SPHERE_SEGMENTS / 2, -Math.PI / 2) + .toNonIndexed(); + + let i = 0; + let k = 0; + + // first row + for (; i < SPHERE_SEGMENTS * 3; i += 3) { + geometry.addGroup(i, 3, k++); + } + + // second to before last rows + for (; i < NB_VERTICES - SPHERE_SEGMENTS * 3; i += 6) { + geometry.addGroup(i, 6, k++); + } + + // last row + for (; i < NB_VERTICES; i += 3) { + geometry.addGroup(i, 3, k++); + } + + this.prop.geom = geometry; + this.prop.originalUvs = geometry.getAttribute('uv').clone(); + + const mesh = new THREE.Mesh(geometry, this.materials); + mesh.scale.set(-1, 1, 1); + + return mesh; + } + + /** + * @summary Applies the base texture and starts the loading of tiles + * @override + */ + setTexture(mesh, textureData) { + if (textureData.texture) { + const material = new THREE.MeshBasicMaterial({ + side: THREE.BackSide, + map : textureData.texture, + }); + + for (let i = 0; i < NB_GROUPS; i++) { + this.materials.push(material); + } + } + + setTimeout(() => this.__refresh()); + } + + /** + * @summary Compute visible tiles and load them + * @private + */ + __refresh() { + const viewerSize = this.psv.prop.size; + const panorama = this.psv.config.panorama; + + const tilesToLoad = []; + const tilePosition = new THREE.Vector3(); + + for (let col = 0; col <= panorama.cols; col++) { + for (let row = 0; row <= panorama.rows; row++) { + // TODO prefilter with less complex math if possible + const tileTexturePosition = { x: col * this.prop.colSize, y: row * this.prop.rowSize }; + this.psv.dataHelper.sphericalCoordsToVector3(this.psv.dataHelper.textureCoordsToSphericalCoords(tileTexturePosition), tilePosition); + + if (tilePosition.dot(this.psv.prop.direction) > 0) { + const tileViewerPosition = this.psv.dataHelper.vector3ToViewerCoords(tilePosition); + + if (tileViewerPosition.x >= 0 + && tileViewerPosition.x <= viewerSize.width + && tileViewerPosition.y >= 0 + && tileViewerPosition.y <= viewerSize.height) { + const angle = tilePosition.angleTo(this.psv.prop.direction); + + this.__getAdjacentTiles(col, row) + .forEach((tile) => { + const existingTile = tilesToLoad.find(c => c.row === tile.row && c.col === tile.col); + if (existingTile) { + existingTile.angle = Math.min(existingTile.angle, angle); + } + else { + tilesToLoad.push({ ...tile, angle }); + } + }); + } + } + } + } + + this.__loadTiles(tilesToLoad); + } + + /** + * @summary Get the 4 adjacent tiles + * @private + */ + __getAdjacentTiles(col, row) { + const panorama = this.psv.config.panorama; + + return [ + { col: col - 1, row: row - 1 }, + { col: col, row: row - 1 }, + { col: col, row: row }, // eslint-disable-line object-shorthand + { col: col - 1, row: row }, + ] + .map((tile) => { + // examples are for cols=16 and rows=8 + if (tile.row < 0) { + // wrap on top + tile.row = -tile.row - 1; // -1 => 0, -2 => 1 + tile.col += panorama.cols / 2; // change hemisphere + } + else if (tile.row >= panorama.rows) { + // wrap on bottom + tile.row = (panorama.rows - 1) - (tile.row - panorama.rows); // 8 => 7, 9 => 6 + tile.col += panorama.cols / 2; // change hemisphere + } + if (tile.col < 0) { + // wrap on left + tile.col += panorama.cols; // -1 => 15, -2 => 14 + } + else if (tile.col >= panorama.cols) { + // wrap on right + tile.col -= panorama.cols; // 16 => 0, 17 => 1 + } + + return tile; + }); + } + + /** + * @summary Loads tiles and change existing tiles priority + * @param {PSV.adapters.EquirectangularTilesAdapter.Tile[]} tiles + * @private + */ + __loadTiles(tiles) { + this.queue.setAllPriorities(0); + + tiles.forEach((tile) => { + const id = tileId(tile); + const priority = Math.PI / 2 - tile.angle; + + if (this.prop.tiles[id]) { + this.queue.setPriority(id, priority); + } + else { + this.prop.tiles[id] = true; + this.queue.enqueue(new Task(id, priority, task => this.__loadTile(tile, task))); + } + }); + + this.queue.start(); + } + + /** + * @summary Loads and draw a tile + * @param {PSV.adapters.EquirectangularTilesAdapter.Tile} tile + * @param {PSV.adapters.EquirectangularTilesAdapter.Task} task + * @return {Promise} + * @private + */ + __loadTile(tile, task) { + const panorama = this.psv.config.panorama; + const url = panorama.tileUrl(tile.col, tile.row); + + return new Promise((resolve, reject) => this.loader.load(url, resolve, undefined, reject)) + .then((image) => { + if (!task.isCancelled()) { + const material = new THREE.MeshBasicMaterial({ + side: THREE.BackSide, + map : utils.createTexture(image), + }); + this.__swapMaterial(tile.col, tile.row, material); + this.psv.needsUpdate(); + } + }) + .catch(() => { + if (!task.isCancelled() && this.config.showErrorTile) { + const material = this.__getErrorMaterial(); + this.__swapMaterial(tile.col, tile.row, material); + this.psv.needsUpdate(); + } + }); + } + + /** + * @summary Applies a new texture to the faces + * @param {int} col + * @param {int} row + * @param {external:THREE.MeshBasicMaterial} material + * @private + */ + __swapMaterial(col, row, material) { + const uvs = this.prop.geom.getAttribute('uv'); + + for (let c = 0; c < this.prop.facesByCol; c++) { + for (let r = 0; r < this.prop.facesByRow; r++) { + // position of the face (two triangles of the same square) + const faceCol = col * this.prop.facesByCol + c; + const faceRow = row * this.prop.facesByRow + r; + const isFirstRow = faceRow === 0; + const isLastRow = faceRow === SPHERE_SEGMENTS / 2 - 1; + + // first vertex for this face (3 or 6 vertices in total) + let firstVertex; + if (isFirstRow) { + firstVertex = faceCol * 3; + } + else if (isLastRow) { + firstVertex = NB_VERTICES - SPHERE_SEGMENTS * 3 + faceCol * 3; + } + else { + firstVertex = 3 * (SPHERE_SEGMENTS + (faceRow - 1) * SPHERE_SEGMENTS * 2 + faceCol * 2); + } + + // swap material + const matIndex = this.prop.geom.groups.find(g => g.start === firstVertex).materialIndex; + this.materials[matIndex] = material; + + // define new uvs + const top = 1 - r / this.prop.facesByRow; + const bottom = 1 - (r + 1) / this.prop.facesByRow; + const left = c / this.prop.facesByCol; + const right = (c + 1) / this.prop.facesByCol; + + if (isFirstRow) { + uvs.setXY(firstVertex, (left + right) / 2, top); + uvs.setXY(firstVertex + 1, left, bottom); + uvs.setXY(firstVertex + 2, right, bottom); + } + else if (isLastRow) { + uvs.setXY(firstVertex, right, top); + uvs.setXY(firstVertex + 1, left, top); + uvs.setXY(firstVertex + 2, (left + right) / 2, bottom); + } + else { + uvs.setXY(firstVertex, right, top); + uvs.setXY(firstVertex + 1, left, top); + uvs.setXY(firstVertex + 2, right, bottom); + uvs.setXY(firstVertex + 3, left, top); + uvs.setXY(firstVertex + 4, left, bottom); + uvs.setXY(firstVertex + 5, right, bottom); + } + } + } + + uvs.needsUpdate = true; + } + + /** + * @summary Generates an material for errored tiles + * @return {external:THREE.MeshBasicMaterial} + * @private + */ + __getErrorMaterial() { + if (!this.prop.errorMaterial) { + const canvas = document.createElement('canvas'); + canvas.width = this.prop.colSize; + canvas.height = this.prop.rowSize; + + const ctx = canvas.getContext('2d'); + + ctx.fillStyle = '#333'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.font = `${canvas.width / 5}px serif`; + ctx.fillStyle = '#a22'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('âš ', canvas.width / 2, canvas.height / 2); + + const texture = new THREE.CanvasTexture(canvas); + this.prop.errorMaterial = new THREE.MeshBasicMaterial({ + side: THREE.BackSide, + map : texture, + }); + } + + return this.prop.errorMaterial; + } + +} + +/* eslint-disable */ + +/** + * UNUSED : Returns the apparent size of a segment on the screen + * @private + */ +// function getSegmentSize() { +// const p1 = this.psv.prop.direction.clone(); +// const p2 = this.psv.prop.direction.clone(); +// +// const angle = Math.PI * 2 / SPHERE_SEGMENTS / 2; +// const dst = Math.atan(angle) * CONSTANTS.SPHERE_RADIUS; +// const horizontalAxis = new THREE.Vector3(0, 1, 0).cross(this.psv.prop.direction).normalize(); +// +// p1.add(horizontalAxis.clone().multiplyScalar(dst)); +// p2.add(horizontalAxis.clone().multiplyScalar(-dst)); +// +// const p1a = this.psv.dataHelper.vector3ToViewerCoords(p1); +// const p2a = this.psv.dataHelper.vector3ToViewerCoords(p2); +// +// const segmentSize = p2a.x - p1a.x; +// } diff --git a/src/adapters/equirectangular/index.js b/src/adapters/equirectangular/index.js new file mode 100644 index 000000000..e0f260467 --- /dev/null +++ b/src/adapters/equirectangular/index.js @@ -0,0 +1,204 @@ +import * as THREE from 'three'; +import { SPHERE_RADIUS } from '../../data/constants'; +import { SYSTEM } from '../../data/system'; +import { PSVError } from '../../PSVError'; +import { createTexture, firstNonNull, getXMPValue, logWarn } from '../../utils'; +import { AbstractAdapter } from '../AbstractAdapter'; + +const SPHERE_SEGMENTS = 64; + +/** + * @summary Adapter for equirectangular panoramas + * @memberof PSV.adapters + */ +export default class EquirectangularAdapter extends AbstractAdapter { + + static id = 'equirectangular'; + static supportsTransition = true; + + /** + * @override + * @param {string} panorama + * @param {PSV.PanoData | PSV.PanoDataProvider} [newPanoData] + * @returns {Promise.} + */ + loadTexture(panorama, newPanoData) { + if (typeof panorama !== 'string') { + if (Array.isArray(panorama) || typeof panorama === 'object' && !!panorama.left) { + logWarn('Cubemap support now requires an additional adapter, see https://photo-sphere-viewer.js.org/guide/adapters'); + } + return Promise.reject(new PSVError('Invalid panorama url, are you using the right adapter?')); + } + + return ( + !this.psv.config.useXmpData + ? this.psv.textureLoader.loadImage(panorama, p => this.psv.loader.setProgress(p)) + .then(img => ({ img: img, xmpPanoData: null })) + : this.__loadXMP(panorama, p => this.psv.loader.setProgress(p)) + .then(xmpPanoData => this.psv.textureLoader.loadImage(panorama).then(img => ({ img, xmpPanoData }))) + ) + .then(({ img, xmpPanoData }) => { + if (typeof newPanoData === 'function') { + newPanoData = newPanoData(img); + } + + const panoData = { + fullWidth : firstNonNull(newPanoData?.fullWidth, xmpPanoData?.fullWidth, img.width), + fullHeight : firstNonNull(newPanoData?.fullHeight, xmpPanoData?.fullHeight, img.height), + croppedWidth : firstNonNull(newPanoData?.croppedWidth, xmpPanoData?.croppedWidth, img.width), + croppedHeight: firstNonNull(newPanoData?.croppedHeight, xmpPanoData?.croppedHeight, img.height), + croppedX : firstNonNull(newPanoData?.croppedX, xmpPanoData?.croppedX, 0), + croppedY : firstNonNull(newPanoData?.croppedY, xmpPanoData?.croppedY, 0), + poseHeading : firstNonNull(newPanoData?.poseHeading, xmpPanoData?.poseHeading), + posePitch : firstNonNull(newPanoData?.posePitch, xmpPanoData?.posePitch), + poseRoll : firstNonNull(newPanoData?.poseRoll, xmpPanoData?.poseRoll), + }; + + if (panoData.croppedWidth !== img.width || panoData.croppedHeight !== img.height) { + logWarn(`Invalid panoData, croppedWidth and/or croppedHeight is not coherent with loaded image. + panoData: ${panoData.croppedWidth}x${panoData.croppedHeight}, image: ${img.width}x${img.height}`); + } + if (panoData.fullWidth !== panoData.fullHeight * 2) { + logWarn('Invalid panoData, fullWidth should be twice fullHeight'); + } + + const texture = this.__createEquirectangularTexture(img, panoData); + + return { texture, panoData }; + }); + } + + /** + * @summary Loads the XMP data of an image + * @param {string} panorama + * @param {function(number)} [onProgress] + * @returns {Promise} + * @throws {PSV.PSVError} when the image cannot be loaded + * @private + */ + __loadXMP(panorama, onProgress) { + return this.psv.textureLoader.loadFile(panorama, onProgress) + .then(blob => this.__loadBlobAsString(blob)) + .then((binary) => { + const a = binary.indexOf(''); + const data = binary.substring(a, b); + + if (a !== -1 && b !== -1 && data.indexOf('GPano:') !== -1) { + return { + fullWidth : getXMPValue(data, 'FullPanoWidthPixels'), + fullHeight : getXMPValue(data, 'FullPanoHeightPixels'), + croppedWidth : getXMPValue(data, 'CroppedAreaImageWidthPixels'), + croppedHeight: getXMPValue(data, 'CroppedAreaImageHeightPixels'), + croppedX : getXMPValue(data, 'CroppedAreaLeftPixels'), + croppedY : getXMPValue(data, 'CroppedAreaTopPixels'), + poseHeading : getXMPValue(data, 'PoseHeadingDegrees'), + posePitch : getXMPValue(data, 'PosePitchDegrees'), + poseRoll : getXMPValue(data, 'PoseRollDegrees'), + }; + } + + return null; + }); + } + + /** + * @summmary read a Blob as string + * @param {Blob} blob + * @returns {Promise} + * @private + */ + __loadBlobAsString(blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = reject; + reader.readAsText(blob); + }); + } + + /** + * @summary Creates the final texture from image and panorama data + * @param {Image} img + * @param {PSV.PanoData} panoData + * @returns {external:THREE.Texture} + * @private + */ + __createEquirectangularTexture(img, panoData) { + let finalImage; + + // resize image / fill cropped parts with black + if (panoData.fullWidth > SYSTEM.maxTextureWidth + || panoData.croppedWidth !== panoData.fullWidth + || panoData.croppedHeight !== panoData.fullHeight + ) { + const resizedPanoData = { ...panoData }; + + const ratio = SYSTEM.getMaxCanvasWidth() / panoData.fullWidth; + + if (ratio < 1) { + resizedPanoData.fullWidth *= ratio; + resizedPanoData.fullHeight *= ratio; + resizedPanoData.croppedWidth *= ratio; + resizedPanoData.croppedHeight *= ratio; + resizedPanoData.croppedX *= ratio; + resizedPanoData.croppedY *= ratio; + } + + const buffer = document.createElement('canvas'); + buffer.width = resizedPanoData.fullWidth; + buffer.height = resizedPanoData.fullHeight; + + const ctx = buffer.getContext('2d'); + ctx.drawImage(img, + resizedPanoData.croppedX, resizedPanoData.croppedY, + resizedPanoData.croppedWidth, resizedPanoData.croppedHeight); + + finalImage = buffer; + } + else { + finalImage = img; + } + + return createTexture(finalImage); + } + + /** + * @override + */ + createMesh(scale = 1) { + // The middle of the panorama is placed at longitude=0 + const geometry = new THREE.SphereGeometry(SPHERE_RADIUS * scale, SPHERE_SEGMENTS, SPHERE_SEGMENTS / 2, -Math.PI / 2); + + const material = new THREE.MeshBasicMaterial({ + side: THREE.BackSide, + }); + + const mesh = new THREE.Mesh(geometry, material); + mesh.scale.set(-1, 1, 1); + + return mesh; + } + + /** + * @override + */ + setTexture(mesh, textureData) { + const { texture } = textureData; + + if (mesh.material.map) { + mesh.material.map.dispose(); + } + + mesh.material.map = texture; + } + + /** + * @override + */ + setTextureOpacity(mesh, opacity) { + mesh.material.opacity = opacity; + mesh.material.transparent = opacity < 1; + } + +} diff --git a/src/data/config.js b/src/data/config.js index 252594f22..d4ee8501f 100644 --- a/src/data/config.js +++ b/src/data/config.js @@ -1,4 +1,5 @@ import { PSVError } from '../PSVError'; +import EquirectangularAdapter from '../adapters/equirectangular'; import { bound, clone, deepmerge, each, logWarn, parseAngle, parseSpeed } from '../utils'; import { ACTIONS } from './constants'; @@ -11,6 +12,7 @@ import { ACTIONS } from './constants'; export const DEFAULTS = { panorama : null, container : null, + adapter : null, caption : null, loadingImg : null, loadingTxt : 'Loading...', @@ -80,6 +82,7 @@ export const READONLY_OPTIONS = { panorama : 'Use setPanorama method to change the panorama', panoData : 'Use setPanorama method to change the panorama', container: 'Cannot change viewer container', + adapter : 'Cannot change adapter', plugins : 'Cannot change plugins', }; @@ -103,6 +106,17 @@ export const CONFIG_PARSERS = { } return container; }, + adapter : (adapter) => { + if (!adapter) { + return [EquirectangularAdapter]; + } + else if (Array.isArray(adapter)) { + return adapter; + } + else { + return [adapter]; + } + }, defaultLong : (defaultLong) => { // defaultLat is between 0 and PI return parseAngle(defaultLong); diff --git a/src/data/constants.js b/src/data/constants.js index 2fb5660c0..1dccbd2f1 100644 --- a/src/data/constants.js +++ b/src/data/constants.js @@ -56,39 +56,7 @@ export const INERTIA_WINDOW = 300; * @type {number} * @constant */ -export const SPHERE_RADIUS = 100; - -/** - * @summary Number of vertice of the THREE.SphereGeometry - * @memberOf PSV.constants - * @type {number} - * @constant - */ -export const SPHERE_VERTICES = 64; - -/** - * @summary Number of vertices of each side of the THREE.BoxGeometry - * @memberOf PSV.constants - * @type {number} - * @constant - */ -export const CUBE_VERTICES = 8; - -/** - * @summary Order of cube textures for arrays - * @memberOf PSV.constants - * @type {number[]} - * @constant - */ -export const CUBE_MAP = [0, 2, 4, 5, 3, 1]; - -/** - * @summary Order of cube textures for maps - * @memberOf PSV.constants - * @type {string[]} - * @constant - */ -export const CUBE_HASHMAP = ['left', 'right', 'top', 'bottom', 'back', 'front']; +export const SPHERE_RADIUS = 10; /** * @summary Property name added to viewer element diff --git a/src/index.js b/src/index.js index 752ed9647..beb3de183 100644 --- a/src/index.js +++ b/src/index.js @@ -7,6 +7,7 @@ import './data/constants'; // for jsdoc import { SYSTEM } from './data/system'; import { AbstractPlugin } from './plugins/AbstractPlugin'; import { PSVError } from './PSVError'; +import { AbstractAdapter } from './adapters/AbstractAdapter'; import './styles/index.scss'; import * as utils from './utils'; import { Viewer } from './Viewer'; @@ -14,6 +15,7 @@ import { Viewer } from './Viewer'; export { AbstractButton, AbstractPlugin, + AbstractAdapter, Animation, CONSTANTS, DEFAULTS, @@ -81,17 +83,6 @@ export { * @property {number} [zoom] - new zoom level between 0 and 100 */ -/** - * @typedef {Object} PSV.Cubemap - * @summary Object defining a cubemap - * @property {string} top - * @property {string} right - * @property {string} bottom - * @property {string} left - * @property {string} front - * @property {string} back - */ - /** * @typedef {Object} PSV.PanoData * @summary Crop information of the panorama @@ -125,8 +116,8 @@ export { /** * @typedef {Object} PSV.TextureData - * @summary Result of the {@link PSV.TextureLoader#loadTexture} method - * @property {external:THREE.Texture|external:THREE.Texture[]} texture + * @summary Result of the {@link PSV.adapters.AbstractAdapter#loadTexture} method + * @property {external:THREE.Texture|external:THREE.Texture[]|Record} texture * @property {PSV.PanoData} [panoData] */ diff --git a/src/plugins/gyroscope/index.js b/src/plugins/gyroscope/index.js index fdcb2f668..c7981be61 100644 --- a/src/plugins/gyroscope/index.js +++ b/src/plugins/gyroscope/index.js @@ -3,10 +3,6 @@ import * as THREE from 'three'; import { DeviceOrientationControls } from 'three/examples/jsm/controls/DeviceOrientationControls'; import { GyroscopeButton } from './GyroscopeButton'; -/** - * @typedef {Object} external:THREE.DeviceOrientationControls - * @summary {@link https://github.com/mrdoob/three.js/blob/dev/examples/jsm/controls/DeviceOrientationControls.js} - */ /** * @typedef {Object} PSV.plugins.GyroscopePlugin.Options diff --git a/src/plugins/resolution/index.js b/src/plugins/resolution/index.js index af59c8ed2..dbaebfbe9 100644 --- a/src/plugins/resolution/index.js +++ b/src/plugins/resolution/index.js @@ -10,7 +10,7 @@ DEFAULTS.lang.resolution = 'Quality'; * @typedef {Object} PSV.plugins.ResolutionPlugin.Resolution * @property {string} id * @property {string} label - * @property {string|string[]|PSV.Cubemap} panorama + * @property {*} panorama */ /** diff --git a/src/plugins/stereo/index.js b/src/plugins/stereo/index.js index 56958b5f2..bf39b1a61 100644 --- a/src/plugins/stereo/index.js +++ b/src/plugins/stereo/index.js @@ -5,11 +5,6 @@ import mobileRotateIcon from './mobile-rotate.svg'; import { StereoButton } from './StereoButton'; -/** - * @typedef {Object} external:THREE.StereoEffect - * @summary {@link https://github.com/mrdoob/three.js/blob/dev/examples/jsm/effects/StereoEffect.js} - */ - /** * @external NoSleep * @description {@link https://github.com/richtr/NoSleep.js} diff --git a/src/services/DataHelper.js b/src/services/DataHelper.js index 2750aa09d..8dc7deeec 100644 --- a/src/services/DataHelper.js +++ b/src/services/DataHelper.js @@ -73,11 +73,11 @@ export class DataHelper extends AbstractService { * @returns {PSV.Position} */ textureCoordsToSphericalCoords(point) { - if (this.prop.isCubemap) { - throw new PSVError('Unable to use texture coords with cubemap.'); + const panoData = this.prop.panoData; + if (!panoData) { + throw new PSVError('Current adapter does not support texture coordinates.'); } - const panoData = this.prop.panoData; const relativeX = (point.x + panoData.croppedX) / panoData.fullWidth * Math.PI * 2; const relativeY = (point.y + panoData.croppedY) / panoData.fullHeight * Math.PI; @@ -93,11 +93,11 @@ export class DataHelper extends AbstractService { * @returns {PSV.Point} */ sphericalCoordsToTextureCoords(position) { - if (this.prop.isCubemap) { - throw new PSVError('Unable to use texture coords with cubemap.'); + const panoData = this.prop.panoData; + if (!panoData) { + throw new PSVError('Current adapter does not support texture coordinates.'); } - const panoData = this.prop.panoData; const relativeLong = position.longitude / Math.PI / 2 * panoData.fullWidth; const relativeLat = position.latitude / Math.PI * panoData.fullHeight; diff --git a/src/services/EventsHandler.js b/src/services/EventsHandler.js index 9fa22c0f6..f180f3680 100644 --- a/src/services/EventsHandler.js +++ b/src/services/EventsHandler.js @@ -622,12 +622,15 @@ export class EventsHandler extends AbstractService { data.longitude = sphericalCoords.longitude; data.latitude = sphericalCoords.latitude; - // TODO: for cubemap, computes texture's index and coordinates - if (!this.prop.isCubemap) { + try { const textureCoords = this.psv.dataHelper.sphericalCoordsToTextureCoords(data); data.textureX = textureCoords.x; data.textureY = textureCoords.y; } + catch (e) { + data.textureX = NaN; + data.textureY = NaN; + } if (!this.state.dblclickTimeout) { this.psv.trigger(EVENTS.CLICK, data); diff --git a/src/services/Renderer.js b/src/services/Renderer.js index 1b9dad290..fcf121c79 100644 --- a/src/services/Renderer.js +++ b/src/services/Renderer.js @@ -1,6 +1,6 @@ import * as THREE from 'three'; import { Animation } from '../Animation'; -import { CUBE_VERTICES, EVENTS, SPHERE_RADIUS, SPHERE_VERTICES } from '../data/constants'; +import { EVENTS, SPHERE_RADIUS } from '../data/constants'; import { SYSTEM } from '../data/system'; import { each, isExtendedPosition, isNil, logWarn } from '../utils'; import { AbstractService } from './AbstractService'; @@ -166,7 +166,6 @@ export class Renderer extends AbstractService { * @fires PSV.render */ render() { - this.psv.dataHelper.sphericalCoordsToVector3(this.psv.getPosition(), this.prop.direction); this.camera.position.set(0, 0, 0); this.camera.lookAt(this.prop.direction); @@ -174,15 +173,25 @@ export class Renderer extends AbstractService { this.camera.position.copy(this.prop.direction).multiplyScalar(this.config.fisheye / 2).negate(); } - this.camera.aspect = this.prop.aspect; - this.camera.fov = this.prop.vFov; - this.camera.updateProjectionMatrix(); + this.updateCameraMatrix(); this.renderer.render(this.scene, this.camera); this.psv.trigger(EVENTS.RENDER); } + /** + * @summary Updates the camera matrix + * @package + */ + updateCameraMatrix() { + if (this.camera) { + this.camera.aspect = this.prop.aspect; + this.camera.fov = this.prop.vFov; + this.camera.updateProjectionMatrix(); + } + } + /** * @summary Applies the texture to the scene, creates the scene if needed * @param {PSV.TextureData} textureData @@ -190,29 +199,13 @@ export class Renderer extends AbstractService { * @package */ setTexture(textureData) { - const { texture, panoData } = textureData; - this.prop.panoData = panoData; - if (!this.scene) { this.__createScene(); } - if (this.prop.isCubemap) { - for (let i = 0; i < 6; i++) { - if (this.mesh.material[i].map) { - this.mesh.material[i].map.dispose(); - } - - this.mesh.material[i].map = texture[i]; - } - } - else { - if (this.mesh.material.map) { - this.mesh.material.map.dispose(); - } + this.prop.panoData = textureData.panoData; - this.mesh.material.map = texture; - } + this.psv.adapter.setTexture(this.mesh, textureData); this.psv.needsUpdate(); @@ -282,18 +275,13 @@ export class Renderer extends AbstractService { this.renderer.setSize(this.prop.size.width, this.prop.size.height); this.renderer.setPixelRatio(SYSTEM.pixelRatio); - this.camera = new THREE.PerspectiveCamera(this.prop.vFov, this.prop.size.width / this.prop.size.height, 1, 3 * SPHERE_RADIUS); + this.camera = new THREE.PerspectiveCamera(this.prop.vFov, this.prop.size.width / this.prop.size.height, 1, 2 * SPHERE_RADIUS); this.camera.position.set(0, 0, 0); this.scene = new THREE.Scene(); this.scene.add(this.camera); - if (this.prop.isCubemap) { - this.mesh = this.__createCubemap(); - } - else { - this.mesh = this.__createSphere(); - } + this.mesh = this.psv.adapter.createMesh(); this.meshContainer = new THREE.Group(); this.meshContainer.add(this.mesh); @@ -304,49 +292,6 @@ export class Renderer extends AbstractService { this.canvasContainer.appendChild(this.renderer.domElement); } - /** - * @summary Creates the sphere mesh - * @param {number} [scale=1] - * @returns {external:THREE.Mesh} - * @private - */ - __createSphere(scale = 1) { - // The middle of the panorama is placed at longitude=0 - const geometry = new THREE.SphereGeometry(SPHERE_RADIUS * scale, SPHERE_VERTICES, SPHERE_VERTICES, -Math.PI / 2); - - const material = new THREE.MeshBasicMaterial({ - side: THREE.BackSide, - }); - - const mesh = new THREE.Mesh(geometry, material); - mesh.scale.set(-1, 1, 1); - - return mesh; - } - - /** - * @summary Creates the cube mesh - * @param {number} [scale=1] - * @returns {external:THREE.Mesh} - * @private - */ - __createCubemap(scale = 1) { - const cubeSize = SPHERE_RADIUS * 2 * scale; - const geometry = new THREE.BoxGeometry(cubeSize, cubeSize, cubeSize, CUBE_VERTICES, CUBE_VERTICES, CUBE_VERTICES); - - const materials = []; - for (let i = 0; i < 6; i++) { - materials.push(new THREE.MeshBasicMaterial({ - side: THREE.BackSide, - })); - } - - const mesh = new THREE.Mesh(geometry, materials); - mesh.scale.set(1, 1, -1); - - return mesh; - } - /** * @summary Performs transition between the current and a new texture * @param {PSV.TextureData} textureData @@ -355,38 +300,16 @@ export class Renderer extends AbstractService { * @package */ transition(textureData, options) { - const { texture, panoData } = textureData; - - let positionProvided = isExtendedPosition(options); + const positionProvided = isExtendedPosition(options); const zoomProvided = 'zoom' in options; const group = new THREE.Group(); - let mesh; - - if (this.prop.isCubemap) { - if (positionProvided) { - logWarn('cannot perform cubemap transition to different position'); - positionProvided = false; - } - - mesh = this.__createCubemap(0.9); - - mesh.material.forEach((material, i) => { - material.map = texture[i]; - material.transparent = true; - material.opacity = 0; - }); - } - else { - mesh = this.__createSphere(0.9); - mesh.material.map = texture; - mesh.material.transparent = true; - mesh.material.opacity = 0; - - this.setPanoramaPose(panoData, mesh); - this.setSphereCorrection(options.sphereCorrection, group); - } + const mesh = this.psv.adapter.createMesh(0.5); + this.psv.adapter.setTexture(mesh, textureData); + this.psv.adapter.setTextureOpacity(mesh, 0); + this.setPanoramaPose(options.panoData, mesh); + this.setSphereCorrection(options.sphereCorrection, group); // rotate the new sphere to make the target position face the camera if (positionProvided) { @@ -414,14 +337,7 @@ export class Renderer extends AbstractService { duration : options.transition, easing : 'outCubic', onTick : (properties) => { - if (this.prop.isCubemap) { - for (let i = 0; i < 6; i++) { - mesh.material[i].opacity = properties.opacity; - } - } - else { - mesh.material.opacity = properties.opacity; - } + this.psv.adapter.setTextureOpacity(mesh, properties.opacity); if (zoomProvided) { this.psv.zoom(properties.zoom); @@ -433,16 +349,13 @@ export class Renderer extends AbstractService { .then(() => { // remove temp sphere and transfer the texture to the main sphere this.setTexture(textureData); - this.scene.remove(group); + this.setPanoramaPose(options.panoData); + this.setSphereCorrection(options.sphereCorrection); + this.scene.remove(group); mesh.geometry.dispose(); mesh.geometry = null; - if (!this.prop.isCubemap) { - this.setPanoramaPose(panoData); - this.setSphereCorrection(options.sphereCorrection); - } - // actually rotate the camera if (positionProvided) { this.psv.rotate(options); diff --git a/src/services/TextureLoader.js b/src/services/TextureLoader.js index 6316c76c7..57c501a87 100644 --- a/src/services/TextureLoader.js +++ b/src/services/TextureLoader.js @@ -1,8 +1,4 @@ import * as THREE from 'three'; -import { CUBE_HASHMAP, CUBE_MAP } from '../data/constants'; -import { SYSTEM } from '../data/system'; -import { PSVError } from '../PSVError'; -import { firstNonNull, getXMPValue, logWarn, sum } from '../utils'; import { AbstractService } from './AbstractService'; /** @@ -24,6 +20,17 @@ export class TextureLoader extends AbstractService { * @private */ this.requests = []; + + /** + * @summary THREE file loader + * @type {external:THREE:FileLoader} + * @private + */ + this.loader = new THREE.FileLoader(); + this.loader.setResponseType('blob'); + if (this.config.withCredentials) { + this.loader.setWithCredentials(true); + } } /** @@ -36,42 +43,14 @@ export class TextureLoader extends AbstractService { /** * @summary Loads the panorama texture(s) - * @param {string|string[]|PSV.Cubemap} panorama + * @param {*} panorama * @param {PSV.PanoData | PSV.PanoDataProvider} [newPanoData] * @returns {Promise.} * @throws {PSV.PSVError} when the image cannot be loaded * @package */ loadTexture(panorama, newPanoData) { - const tempPanorama = []; - - if (Array.isArray(panorama)) { - if (panorama.length !== 6) { - throw new PSVError('Must provide exactly 6 image paths when using cubemap.'); - } - - // reorder images - for (let i = 0; i < 6; i++) { - tempPanorama[i] = panorama[CUBE_MAP[i]]; - } - - return this.__loadCubemapTexture(tempPanorama); - } - else if (typeof panorama === 'object') { - if (!CUBE_HASHMAP.every(side => !!panorama[side])) { - throw new PSVError('Must provide exactly left, front, right, back, top, bottom when using cubemap.'); - } - - // transform into array - CUBE_HASHMAP.forEach((side, i) => { - tempPanorama[i] = panorama[side]; - }); - - return this.__loadCubemapTexture(tempPanorama); - } - else { - return this.__loadEquirectangularTexture(panorama, newPanoData); - } + return this.psv.adapter.loadTexture(panorama, newPanoData); } /** @@ -87,22 +66,13 @@ export class TextureLoader extends AbstractService { * @param {string} url * @param {function(number)} [onProgress] * @returns {Promise} - * @private */ - __loadFile(url, onProgress) { + loadFile(url, onProgress) { return new Promise((resolve, reject) => { let progress = 0; onProgress && onProgress(progress); - const loader = new THREE.FileLoader(); - - if (this.config.withCredentials) { - loader.setWithCredentials(true); - } - - loader.setResponseType('blob'); - - const request = loader.load( + const request = this.loader.load( url, (result) => { const rIdx = this.requests.indexOf(request); @@ -140,11 +110,10 @@ export class TextureLoader extends AbstractService { * @summary Loads an Image using FileLoader to have progress events * @param {string} url * @param {function(number)} [onProgress] - * @returns {Promise} - * @private + * @returns {Promise} */ - __loadImage(url, onProgress) { - return this.__loadFile(url, onProgress) + loadImage(url, onProgress) { + return this.loadFile(url, onProgress) .then(result => new Promise((resolve, reject) => { const img = document.createElementNS('http://www.w3.org/1999/xhtml', 'img'); img.onload = () => { @@ -156,230 +125,13 @@ export class TextureLoader extends AbstractService { })); } - /** - * @summmary read a Blob as string - * @param {Blob} blob - * @returns {Promise} - * @private - */ - __loadBlobAsString(blob) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result); - reader.onerror = reject; - reader.readAsText(blob); - }); - } - - /** - * @summary Loads the sphere texture - * @param {string} panorama - * @param {PSV.PanoData | PSV.PanoDataProvider} [newPanoData] - * @returns {Promise.} - * @throws {PSV.PSVError} when the image cannot be loaded - * @private - */ - __loadEquirectangularTexture(panorama, newPanoData) { - if (this.prop.isCubemap === true) { - throw new PSVError('The viewer was initialized with an cubemap, cannot switch to equirectangular panorama.'); - } - - this.prop.isCubemap = false; - - return ( - !this.config.useXmpData - ? this.__loadImage(panorama, p => this.psv.loader.setProgress(p)) - .then(img => ({ img: img, xmpPanoData: null })) - : this.__loadXMP(panorama, p => this.psv.loader.setProgress(p)) - .then(xmpPanoData => this.__loadImage(panorama).then(img => ({ img, xmpPanoData }))) - ) - .then(({ img, xmpPanoData }) => { - if (typeof newPanoData === 'function') { - newPanoData = newPanoData(img); - } - - const panoData = { - fullWidth : firstNonNull(newPanoData?.fullWidth, xmpPanoData?.fullWidth, img.width), - fullHeight : firstNonNull(newPanoData?.fullHeight, xmpPanoData?.fullHeight, img.height), - croppedWidth : firstNonNull(newPanoData?.croppedWidth, xmpPanoData?.croppedWidth, img.width), - croppedHeight: firstNonNull(newPanoData?.croppedHeight, xmpPanoData?.croppedHeight, img.height), - croppedX : firstNonNull(newPanoData?.croppedX, xmpPanoData?.croppedX, 0), - croppedY : firstNonNull(newPanoData?.croppedY, xmpPanoData?.croppedY, 0), - poseHeading : firstNonNull(newPanoData?.poseHeading, xmpPanoData?.poseHeading), - posePitch : firstNonNull(newPanoData?.posePitch, xmpPanoData?.posePitch), - poseRoll : firstNonNull(newPanoData?.poseRoll, xmpPanoData?.poseRoll), - }; - - if (panoData.croppedWidth !== img.width || panoData.croppedHeight !== img.height) { - logWarn(`Invalid panoData, croppedWidth and/or croppedHeight is not coherent with loaded image - panoData: ${panoData.croppedWidth}x${panoData.croppedHeight}, image: ${img.width}x${img.height}`); - } - - const texture = this.__createEquirectangularTexture(img, panoData); - - return { texture, panoData }; - }); - } - - /** - * @summary Loads the XMP data of an image - * @param {string} panorama - * @param {function(number)} [onProgress] - * @returns {Promise} - * @throws {PSV.PSVError} when the image cannot be loaded - * @private - */ - __loadXMP(panorama, onProgress) { - return this.__loadFile(panorama, onProgress) - .then(blob => this.__loadBlobAsString(blob)) - .then((binary) => { - const a = binary.indexOf(''); - const data = binary.substring(a, b); - - if (a !== -1 && b !== -1 && data.indexOf('GPano:') !== -1) { - return { - fullWidth : getXMPValue(data, 'FullPanoWidthPixels'), - fullHeight : getXMPValue(data, 'FullPanoHeightPixels'), - croppedWidth : getXMPValue(data, 'CroppedAreaImageWidthPixels'), - croppedHeight: getXMPValue(data, 'CroppedAreaImageHeightPixels'), - croppedX : getXMPValue(data, 'CroppedAreaLeftPixels'), - croppedY : getXMPValue(data, 'CroppedAreaTopPixels'), - poseHeading : getXMPValue(data, 'PoseHeadingDegrees'), - posePitch : getXMPValue(data, 'PosePitchDegrees'), - poseRoll : getXMPValue(data, 'PoseRollDegrees'), - }; - } - - return null; - }); - } - - /** - * @summary Creates the final texture from image and panorama data - * @param {Image} img - * @param {PSV.PanoData} panoData - * @returns {external:THREE.Texture} - * @private - */ - __createEquirectangularTexture(img, panoData) { - let texture; - - // resize image / fill cropped parts with black - if (panoData.fullWidth > SYSTEM.maxTextureWidth - || panoData.croppedWidth !== panoData.fullWidth - || panoData.croppedHeight !== panoData.fullHeight - ) { - const resizedPanoData = { ...panoData }; - - const ratio = SYSTEM.getMaxCanvasWidth() / panoData.fullWidth; - - if (ratio < 1) { - resizedPanoData.fullWidth *= ratio; - resizedPanoData.fullHeight *= ratio; - resizedPanoData.croppedWidth *= ratio; - resizedPanoData.croppedHeight *= ratio; - resizedPanoData.croppedX *= ratio; - resizedPanoData.croppedY *= ratio; - } - - const buffer = document.createElement('canvas'); - buffer.width = resizedPanoData.fullWidth; - buffer.height = resizedPanoData.fullHeight; - - const ctx = buffer.getContext('2d'); - ctx.drawImage(img, - resizedPanoData.croppedX, resizedPanoData.croppedY, - resizedPanoData.croppedWidth, resizedPanoData.croppedHeight); - - texture = new THREE.Texture(buffer); - } - else { - texture = new THREE.Texture(img); - } - - texture.needsUpdate = true; - texture.minFilter = THREE.LinearFilter; - texture.generateMipmaps = false; - - return texture; - } - - /** - * @summary Load the six textures of the cube - * @param {string[]} panorama - * @returns {Promise.} - * @throws {PSV.PSVError} when the image cannot be loaded - * @private - */ - __loadCubemapTexture(panorama) { - if (this.prop.isCubemap === false) { - throw new PSVError('The viewer was initialized with an equirectangular panorama, cannot switch to cubemap.'); - } - - if (this.config.fisheye) { - logWarn('fisheye effect with cubemap texture can generate distorsion'); - } - - this.prop.isCubemap = true; - - const promises = []; - const progress = [0, 0, 0, 0, 0, 0]; - - for (let i = 0; i < 6; i++) { - promises.push( - this.__loadImage(panorama[i], (p) => { - progress[i] = p; - this.psv.loader.setProgress(sum(progress) / 6); - }) - .then(img => this.__createCubemapTexture(img)) - ); - } - - return Promise.all(promises) - .then(texture => ({ texture })); - } - - /** - * @summary Creates the final texture from image - * @param {Image} img - * @returns {external:THREE.Texture} - * @private - */ - __createCubemapTexture(img) { - let texture; - - // resize image - if (img.width > SYSTEM.maxTextureWidth) { - const buffer = document.createElement('canvas'); - const ratio = SYSTEM.getMaxCanvasWidth() / img.width; - - buffer.width = img.width * ratio; - buffer.height = img.height * ratio; - - const ctx = buffer.getContext('2d'); - ctx.drawImage(img, 0, 0, buffer.width, buffer.height); - - texture = new THREE.Texture(buffer); - } - else { - texture = new THREE.Texture(img); - } - - texture.needsUpdate = true; - texture.minFilter = THREE.LinearFilter; - texture.generateMipmaps = false; - - return texture; - } - /** * @summary Preload a panorama file without displaying it - * @param {string|string[]|PSV.Cubemap} panorama + * @param {*} panorama * @returns {Promise} */ preloadPanorama(panorama) { - return this.loadTexture(panorama); + return this.psv.adapter.loadTexture(panorama); } } diff --git a/src/utils/misc.js b/src/utils/misc.js index a20e750f4..9dde650a7 100644 --- a/src/utils/misc.js +++ b/src/utils/misc.js @@ -185,6 +185,7 @@ export function intersect(array1, array2) { /** * @summary Returns if a valu is null or undefined + * @memberOf PSV.utils * @param {*} val * @return {boolean} */ diff --git a/src/utils/psv.js b/src/utils/psv.js index a33ebb083..9cc9ea74f 100644 --- a/src/utils/psv.js +++ b/src/utils/psv.js @@ -225,3 +225,17 @@ export function parseAngle(angle, zeroCenter = false, halfCircle = zeroCenter) { return zeroCenter ? bound(parsed - Math.PI, -Math.PI / (halfCircle ? 2 : 1), Math.PI / (halfCircle ? 2 : 1)) : parsed; } + +/** + * @summary Creates a THREE texture from an image + * @memberOf PSV.utils + * @param {HTMLImageElement | HTMLCanvasElement} img + * @return {external:THREE.Texture} + */ +export function createTexture(img) { + const texture = new THREE.Texture(img); + texture.needsUpdate = true; + texture.minFilter = THREE.LinearFilter; + texture.generateMipmaps = false; + return texture; +}