/**
* @summary Main event loop, calls {@link PhotoSphereViewer._render} if `prop.needsUpdate` is true
* @param {int} timestamp
* @fires PhotoSphereViewer.filter:before-render
* @private
*/
PhotoSphereViewer.prototype._run = function(timestamp) {
/**
* @event before-render
* @memberof PhotoSphereViewer
* @summary Triggered before a render, used to modify the view
* @param {int} timestamp - time provided by requestAnimationFrame
*/
this.trigger('before-render', timestamp || +new Date());
if (this.prop.needsUpdate) {
this._render();
this.prop.needsUpdate = false;
}
this.prop.main_reqid = window.requestAnimationFrame(this._run.bind(this));
};
/**
* @summary Performs a render
* @fires PhotoSphereViewer.render
* @private
*/
PhotoSphereViewer.prototype._render = function() {
this.prop.direction = this.sphericalCoordsToVector3(this.prop.position);
this.camera.position.set(0, 0, 0);
this.camera.lookAt(this.prop.direction);
if (this.config.fisheye) {
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.stereoEffect || this.renderer).render(this.scene, this.camera);
/**
* @event render
* @memberof PhotoSphereViewer
* @summary Triggered on each viewer render, **this event is triggered very often**
*/
this.trigger('render');
};
/**
* @summary Loads the XMP data with AJAX
* @param {string} panorama
* @returns {Promise.<PhotoSphereViewer.PanoData>}
* @throws {PSVError} when the image cannot be loaded
* @private
*/
PhotoSphereViewer.prototype._loadXMP = function(panorama) {
if (!this.config.usexmpdata) {
return Promise.resolve(null);
}
return new Promise(function(resolve) {
var progress = 0;
var xhr = new XMLHttpRequest();
if (this.config.with_credentials) {
xhr.withCredentials = true;
}
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
if (xhr.status === 200 || xhr.status === 201 || xhr.status === 202 || xhr.status === 0) {
this.loader.setProgress(100);
var binary = xhr.responseText;
var a = binary.indexOf('<x:xmpmeta'), b = binary.indexOf('</x:xmpmeta>');
var data = binary.substring(a, b);
var pano_data = null;
if (a !== -1 && b !== -1 && data.indexOf('GPano:') !== -1) {
pano_data = {
full_width: parseInt(PSVUtils.getXMPValue(data, 'FullPanoWidthPixels')),
full_height: parseInt(PSVUtils.getXMPValue(data, 'FullPanoHeightPixels')),
cropped_width: parseInt(PSVUtils.getXMPValue(data, 'CroppedAreaImageWidthPixels')),
cropped_height: parseInt(PSVUtils.getXMPValue(data, 'CroppedAreaImageHeightPixels')),
cropped_x: parseInt(PSVUtils.getXMPValue(data, 'CroppedAreaLeftPixels')),
cropped_y: parseInt(PSVUtils.getXMPValue(data, 'CroppedAreaTopPixels'))
};
if (!pano_data.full_width || !pano_data.full_height || !pano_data.cropped_width || !pano_data.cropped_height) {
console.warn('PhotoSphereViewer: invalid XMP data');
pano_data = null;
}
}
resolve(pano_data);
}
else {
this.container.textContent = 'Cannot load image';
throw new PSVError('Cannot load image');
}
}
else if (xhr.readyState === 3) {
this.loader.setProgress(progress += 10);
}
}.bind(this);
xhr.onprogress = function(e) {
if (e.lengthComputable) {
var new_progress = parseInt(e.loaded / e.total * 100);
if (new_progress > progress) {
progress = new_progress;
this.loader.setProgress(progress);
}
}
}.bind(this);
xhr.onerror = function(e) {
this.container.textContent = 'Cannot load image';
reject(e);
throw new PSVError('Cannot load image');
}.bind(this);
xhr.open('GET', panorama, true);
xhr.send(null);
}.bind(this));
};
/**
* @summary Loads the panorama texture(s)
* @param {string|string[]} panorama
* @returns {Promise.<THREE.Texture|THREE.Texture[]>}
* @fires PhotoSphereViewer.panorama-load-progress
* @throws {PSVError} when the image cannot be loaded
* @private
*/
PhotoSphereViewer.prototype._loadTexture = function(panorama) {
var tempPanorama = [];
if (Array.isArray(panorama)) {
if (panorama.length !== 6) {
throw new PSVError('Must provide exactly 6 image paths when using cubemap.');
}
// reorder images
for (var i = 0; i < 6; i++) {
tempPanorama[i] = panorama[PhotoSphereViewer.CUBE_MAP[i]];
}
panorama = tempPanorama;
}
else if (typeof panorama === 'object') {
if (!PhotoSphereViewer.CUBE_HASHMAP.every(function(side) {
return !!panorama[side];
})) {
throw new PSVError('Must provide exactly left, front, right, back, top, bottom when using cubemap.');
}
// transform into array
PhotoSphereViewer.CUBE_HASHMAP.forEach(function(side, i) {
tempPanorama[i] = panorama[side];
});
panorama = tempPanorama;
}
if (Array.isArray(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) {
console.warn('PhotoSphereViewer: fisheye effect with cubemap texture can generate distorsions.');
}
if (this.config.cache_texture === PhotoSphereViewer.DEFAULTS.cache_texture) {
this.config.cache_texture *= 6;
}
this.prop.isCubemap = true;
return this._loadCubemapTexture(panorama);
}
else {
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._loadEquirectangularTexture(panorama);
}
};
/**
* @summary Loads the sphere texture
* @param {string} panorama
* @returns {Promise.<THREE.Texture>}
* @fires PhotoSphereViewer.panorama-load-progress
* @throws {PSVError} when the image cannot be loaded
* @private
*/
PhotoSphereViewer.prototype._loadEquirectangularTexture = function(panorama) {
if (this.config.cache_texture) {
var cache = this.getPanoramaCache(panorama);
if (cache) {
this.prop.pano_data = cache.pano_data;
return Promise.resolve(cache.image);
}
}
return this._loadXMP(panorama).then(function(pano_data) {
return new Promise(function(resolve, reject) {
var loader = new THREE.ImageLoader();
var progress = pano_data ? 100 : 0;
if (this.config.with_credentials) {
loader.setCrossOrigin('use-credentials');
}
else {
loader.setCrossOrigin('anonymous');
}
var onload = function(img) {
progress = 100;
this.loader.setProgress(progress);
/**
* @event panorama-load-progress
* @memberof PhotoSphereViewer
* @summary Triggered while a panorama image is loading
* @param {string} panorama
* @param {int} progress
*/
this.trigger('panorama-load-progress', panorama, progress);
// Config XMP data
if (!pano_data && this.config.pano_data) {
pano_data = PSVUtils.clone(this.config.pano_data);
}
// Default XMP data
if (!pano_data) {
pano_data = {
full_width: img.width,
full_height: img.height,
cropped_width: img.width,
cropped_height: img.height,
cropped_x: 0,
cropped_y: 0
};
}
this.prop.pano_data = pano_data;
var texture;
var ratio = Math.min(pano_data.full_width, PhotoSphereViewer.SYSTEM.maxTextureWidth) / pano_data.full_width;
// resize image / fill cropped parts with black
if (ratio !== 1 || pano_data.cropped_width !== pano_data.full_width || pano_data.cropped_height !== pano_data.full_height) {
var resized_pano_data = PSVUtils.clone(pano_data);
resized_pano_data.full_width *= ratio;
resized_pano_data.full_height *= ratio;
resized_pano_data.cropped_width *= ratio;
resized_pano_data.cropped_height *= ratio;
resized_pano_data.cropped_x *= ratio;
resized_pano_data.cropped_y *= ratio;
img.width = resized_pano_data.cropped_width;
img.height = resized_pano_data.cropped_height;
var buffer = document.createElement('canvas');
buffer.width = resized_pano_data.full_width;
buffer.height = resized_pano_data.full_height;
var ctx = buffer.getContext('2d');
ctx.drawImage(img, resized_pano_data.cropped_x, resized_pano_data.cropped_y, resized_pano_data.cropped_width, resized_pano_data.cropped_height);
texture = new THREE.Texture(buffer);
}
else {
texture = new THREE.Texture(img);
}
texture.needsUpdate = true;
texture.minFilter = THREE.LinearFilter;
texture.generateMipmaps = false;
if (this.config.cache_texture) {
this._putPanoramaCache({
panorama: panorama,
image: texture,
pano_data: pano_data
});
}
resolve(texture);
};
var onprogress = function(e) {
if (e.lengthComputable) {
var new_progress = parseInt(e.loaded / e.total * 100);
if (new_progress > progress) {
progress = new_progress;
this.loader.setProgress(progress);
this.trigger('panorama-load-progress', panorama, progress);
}
}
};
var onerror = function(e) {
this.container.textContent = 'Cannot load image';
reject(e);
throw new PSVError('Cannot load image');
};
loader.load(panorama, onload.bind(this), onprogress.bind(this), onerror.bind(this));
}.bind(this));
}.bind(this));
};
/**
* @summary Load the six textures of the cube
* @param {string[]} panorama
* @returns {Promise.<THREE.Texture[]>}
* @fires PhotoSphereViewer.panorama-load-progress
* @throws {PSVError} when the image cannot be loaded
* @private
*/
PhotoSphereViewer.prototype._loadCubemapTexture = function(panorama) {
return new Promise(function(resolve, reject) {
var loader = new THREE.ImageLoader();
var progress = [0, 0, 0, 0, 0, 0];
var loaded = [];
var done = 0;
if (this.config.with_credentials) {
loader.setCrossOrigin('use-credentials');
}
else {
loader.setCrossOrigin('anonymous');
}
var onend = function() {
loaded.forEach(function(img) {
img.needsUpdate = true;
img.minFilter = THREE.LinearFilter;
img.generateMipmaps = false;
});
resolve(loaded);
};
var onload = function(i, img) {
done++;
progress[i] = 100;
this.loader.setProgress(PSVUtils.sum(progress) / 6);
this.trigger('panorama-load-progress', panorama[i], progress[i]);
var ratio = Math.min(img.width, PhotoSphereViewer.SYSTEM.maxTextureWidth / 2) / img.width;
// resize image
if (ratio !== 1) {
var buffer = document.createElement('canvas');
buffer.width = img.width * ratio;
buffer.height = img.height * ratio;
var ctx = buffer.getContext('2d');
ctx.drawImage(img, 0, 0, buffer.width, buffer.height);
loaded[i] = new THREE.Texture(buffer);
}
else {
loaded[i] = new THREE.Texture(img);
}
if (this.config.cache_texture) {
this._putPanoramaCache({
panorama: panorama[i],
image: loaded[i]
});
}
if (done === 6) {
onend();
}
};
var onprogress = function(i, e) {
if (e.lengthComputable) {
var new_progress = parseInt(e.loaded / e.total * 100);
if (new_progress > progress[i]) {
progress[i] = new_progress;
this.loader.setProgress(PSVUtils.sum(progress) / 6);
this.trigger('panorama-load-progress', panorama[i], progress[i]);
}
}
};
var onerror = function(i, e) {
this.container.textContent = 'Cannot load image';
reject(e);
throw new PSVError('Cannot load image ' + i);
};
for (var i = 0; i < 6; i++) {
if (this.config.cache_texture) {
var cache = this.getPanoramaCache(panorama[i]);
if (cache) {
done++;
progress[i] = 100;
loaded[i] = cache.image;
continue;
}
}
loader.load(panorama[i], onload.bind(this, i), onprogress.bind(this, i), onerror.bind(this, i));
}
if (done === 6) {
resolve(loaded);
}
}.bind(this));
};
/**
* @summary Applies the texture to the scene, creates the scene if needed
* @param {THREE.Texture|THREE.Texture[]} texture
* @fires PhotoSphereViewer.panorama-loaded
* @private
*/
PhotoSphereViewer.prototype._setTexture = function(texture) {
if (!this.scene) {
this._createScene();
}
if (this.prop.isCubemap) {
for (var 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.mesh.material.map = texture;
}
/**
* @event panorama-loaded
* @memberof PhotoSphereViewer
* @summary Triggered when a panorama image has been loaded
*/
this.trigger('panorama-loaded');
this._render();
};
/**
* @summary Creates the 3D scene and GUI components
* @private
*/
PhotoSphereViewer.prototype._createScene = function() {
this.raycaster = new THREE.Raycaster();
this.renderer = new THREE.WebGLRenderer();
this.renderer.setSize(this.prop.size.width, this.prop.size.height);
this.renderer.setPixelRatio(PhotoSphereViewer.SYSTEM.pixelRatio);
this.camera = new THREE.PerspectiveCamera(this.config.default_fov, this.prop.size.width / this.prop.size.height, 1, 3 * PhotoSphereViewer.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.scene.add(this.mesh);
// create canvas container
this.canvas_container = document.createElement('div');
this.canvas_container.className = 'psv-canvas-container';
this.renderer.domElement.className = 'psv-canvas';
this.container.appendChild(this.canvas_container);
this.canvas_container.appendChild(this.renderer.domElement);
};
/**
* @summary Creates the sphere mesh
* @param {number} [scale=1]
* @returns {THREE.Mesh}
* @private
*/
PhotoSphereViewer.prototype._createSphere = function(scale) {
scale = scale || 1;
// The middle of the panorama is placed at longitude=0
var geometry = new THREE.SphereGeometry(
PhotoSphereViewer.SPHERE_RADIUS * scale,
PhotoSphereViewer.SPHERE_VERTICES,
PhotoSphereViewer.SPHERE_VERTICES,
-PSVUtils.HalfPI
);
var material = new THREE.MeshBasicMaterial({
side: THREE.DoubleSide, // needs to be DoubleSide for CanvasRenderer
});
var mesh = new THREE.Mesh(geometry, material);
mesh.scale.x = -1;
return mesh;
};
/**
* @summary Applies a SphereCorrection to a Mesh
* @param {THREE.Mesh} mesh
* @param {PhotoSphereViewer.SphereCorrection} sphere_correction
* @private
*/
PhotoSphereViewer.prototype._setSphereCorrection = function(mesh, sphere_correction) {
this.cleanSphereCorrection(sphere_correction);
mesh.rotation.set(
sphere_correction.tilt,
sphere_correction.pan,
sphere_correction.roll
);
};
/**
* @summary Creates the cube mesh
* @param {number} [scale=1]
* @returns {THREE.Mesh}
* @private
*/
PhotoSphereViewer.prototype._createCubemap = function(scale) {
scale = scale || 1;
var geometry = new THREE.BoxGeometry(
PhotoSphereViewer.SPHERE_RADIUS * 2 * scale, PhotoSphereViewer.SPHERE_RADIUS * 2 * scale, PhotoSphereViewer.SPHERE_RADIUS * 2 * scale,
PhotoSphereViewer.CUBE_VERTICES, PhotoSphereViewer.CUBE_VERTICES, PhotoSphereViewer.CUBE_VERTICES
);
var materials = [];
for (var i = 0; i < 6; i++) {
materials.push(new THREE.MeshBasicMaterial({
side: THREE.BackSide,
}));
}
var 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 {THREE.Texture} texture
* @param {PhotoSphereViewer.PanoramaOptions} options
* @returns {Promise}
* @private
* @throws {PSVError} if the panorama is a cubemap
*/
PhotoSphereViewer.prototype._transition = function(texture, options) {
var mesh;
var positionProvided = this.isExtendedPosition(options);
var zoomProvided = options.zoom !== undefined;
if (this.prop.isCubemap) {
if (positionProvided) {
console.warn('PhotoSphereViewer: cannot perform cubemap transition to different position.');
positionProvided = false;
}
mesh = this._createCubemap(0.9);
mesh.material.forEach(function(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;
if (options.sphere_correction) {
this._setSphereCorrection(mesh, options.sphere_correction);
}
}
// rotate the new sphere to make the target position face the camera
if (positionProvided) {
this.cleanPosition(options);
// Longitude rotation along the vertical axis
var verticalAxis = new THREE.Vector3(0, 1, 0);
mesh.rotateOnWorldAxis(verticalAxis, options.longitude - this.prop.position.longitude);
// Latitude rotation along the camera horizontal axis
var horizontalAxis = new THREE.Vector3(0, 1, 0).cross(this.camera.getWorldDirection()).normalize();
mesh.rotateOnWorldAxis(horizontalAxis, options.latitude - this.prop.position.latitude);
// FIXME: find a better way to handle ranges
if (this.config.latitude_range || this.config.longitude_range) {
this.config.longitude_range = this.config.latitude_range = null;
console.warn('PhotoSphereViewer: trying to perform transition with longitude_range and/or latitude_range, ranges cleared.');
}
}
this.scene.add(mesh);
this.needsUpdate();
return new PSVAnimation({
properties: {
opacity: { start: 0.0, end: 1.0 },
zoom: zoomProvided ? { start: this.prop.zoom_lvl, end: options.zoom } : undefined
},
duration: this.config.transition.duration,
easing: 'outCubic',
onTick: function(properties) {
if (this.prop.isCubemap) {
for (var i = 0; i < 6; i++) {
mesh.material[i].opacity = properties.opacity;
}
}
else {
mesh.material.opacity = properties.opacity;
}
if (zoomProvided) {
this.zoom(properties.zoom);
}
this.needsUpdate();
}.bind(this)
})
.then(function() {
// remove temp sphere and transfer the texture to the main sphere
this._setTexture(texture);
this.scene.remove(mesh);
mesh.geometry.dispose();
mesh.geometry = null;
// actually rotate the camera
if (positionProvided) {
this.rotate(options);
}
if (options.sphere_correction) {
this._setSphereCorrection(this.mesh, options.sphere_correction);
}
else {
this._setSphereCorrection(this.mesh, {});
}
}.bind(this));
};
/**
* @summary Reverses autorotate direction with smooth transition
* @private
*/
PhotoSphereViewer.prototype._reverseAutorotate = function() {
var self = this;
var newSpeed = -this.config.anim_speed;
var range = this.config.longitude_range;
this.config.longitude_range = null;
new PSVAnimation({
properties: {
speed: { start: this.config.anim_speed, end: 0 }
},
duration: 300,
easing: 'inSine',
onTick: function(properties) {
self.config.anim_speed = properties.speed;
}
})
.then(function() {
return new PSVAnimation({
properties: {
speed: { start: 0, end: newSpeed }
},
duration: 300,
easing: 'outSine',
onTick: function(properties) {
self.config.anim_speed = properties.speed;
}
});
})
.then(function() {
self.config.longitude_range = range;
self.config.anim_speed = newSpeed;
});
};
/**
* @summary Adds a panorama to the cache
* @param {PhotoSphereViewer.CacheItem} cache
* @fires PhotoSphereViewer.panorama-cached
* @throws {PSVError} when the cache is disabled
* @private
*/
PhotoSphereViewer.prototype._putPanoramaCache = function(cache) {
if (!this.config.cache_texture) {
throw new PSVError('Cannot add panorama to cache, cache_texture is disabled');
}
var existingCache = this.getPanoramaCache(cache.panorama);
if (existingCache) {
existingCache.image = cache.image;
existingCache.pano_data = cache.pano_data;
}
else {
this.prop.cache = this.prop.cache.slice(0, this.config.cache_texture - 1); // remove most ancient elements
this.prop.cache.unshift(cache);
}
/**
* @event panorama-cached
* @memberof PhotoSphereViewer
* @summary Triggered when a panorama is stored in the cache
* @param {string} panorama
*/
this.trigger('panorama-cached', cache.panorama);
};
/**
* @summary Stops all current animations
* @private
*/
PhotoSphereViewer.prototype._stopAll = function() {
this.stopAutorotate();
this.stopAnimation();
this.stopGyroscopeControl();
this.stopStereoView();
};