@@ -6,3 +6,6 @@ build/** | |||
# Ignore generated docs | |||
utils/doc/generated_docs | |||
# Ignore IntelliJ Files | |||
.idea |
@@ -1,4 +1,4 @@ | |||
Copyright (c) 2011-2017 Matthew Petroff | |||
Copyright (c) 2011-2018 Matthew Petroff | |||
Permission is hereby granted, free of charge, to any person obtaining a copy of | |||
this software and associated documentation files (the "Software"), to deal in | |||
@@ -1 +1 @@ | |||
2.3.2 | |||
2.4.1 |
@@ -2,6 +2,81 @@ Changelog | |||
========= | |||
Changes in Pannellum 2.4.1 | |||
-------------------------- | |||
Bugfixes: | |||
- Fix touch input issue in Chrome | |||
- The API's `loadScene` method now works when no scenes have been loaded yet | |||
Changes in Pannellum 2.4.0 | |||
-------------------------- | |||
New Features: | |||
- Translation support | |||
- Event for when scene change fade completes (`scenechangefadedone`) | |||
- Events for touch starts and ends (`touchstart` and `touchend`) | |||
- Added ability to set custom animation timing | |||
function (`animationTimingFunction` parameter) | |||
- Added option for only enable mouse wheel zoom while in | |||
fullscreen (`mouseZoom` parameter) | |||
- Added option to set title and author displayed while the load button | |||
is displayed (`previewTitle` and `previewAuthor` parameters) | |||
- Mouse and touch dragging can now be disabled (`draggable` parameter) | |||
- Added option to disable keyboard controls (`disableKeyboardCtrl` parameter) | |||
- CORS setting can now be configured | |||
New API functions: | |||
- Check if image is loaded (`isLoaded`) | |||
- Method to update viewer after it is resized (`resize`) | |||
- Set horizon pitch and roll (`setPose`) | |||
- Turn device orientation control on and off, check if it is supported, and | |||
check if it is activated (`startOrientation`, `stopOrientation`, | |||
`isOrientationSupported`, and `isOrientationActive`) | |||
- Method to retrieve viewer's container element (`getContainer`) | |||
Improvements: | |||
- Double-clicking now causes the viewer to zoom in (and back out when | |||
double-clicking while zoomed in) | |||
- New lines are now allowed in hot spot text | |||
- Support for HTML in configuration strings can be enabled when using | |||
the API (`escapeHTML` parameter) | |||
- Fallback cursor is provided for browsers that don't support SVG data URIs | |||
- Image type configuration parameter is now validated | |||
- Optional callbacks added to `lookAt`, `setPitch`, `setYaw`, and `setHfov` | |||
API functions | |||
- Scroll events are now only captured when they're being used | |||
- Viewer object is now assigned to a variable in the standalone viewer | |||
- Hot spots can now be added with API before panorama is loaded | |||
- Viewer UI is now created in a container element | |||
Bugfixes: | |||
- Fixed race condition when scene change hot spot is double-clicked | |||
- Fixed bug with preview image absolute URLs | |||
- Removed redundant constraints on yaw in API | |||
- Tabbing now works, and only events for keys that are used are captured | |||
- Fixed bug in HTML escaping | |||
- Fixed bug that sometimes occurred when `orientationOnByDefault` was `true` | |||
- Yaw no longer changes when device orientation mode is activated | |||
- Fixed iOS 10 canvas size too big issue | |||
- Fixed iOS 10 NPOT cube map issue | |||
- Hot spots added via API are now permanent between scene changes | |||
- Fixed multiple bugs with removing event listeners | |||
- Fixed bug with multiresolution tile loading | |||
- Fixed `sameAzimuth` target yaw not working when `northOffset` wasn't set | |||
- Fixed bug yaw out of bounds in `mouseEventToCoords` | |||
- Fixed bug with `animateMove` function | |||
- Fixed bug with scene change fade | |||
- Yaw animation is now always in the shortest direction | |||
- Fixed bug related to removing hot spots | |||
Changes in Pannellum 2.3.2 | |||
-------------------------- | |||
@@ -11,12 +11,28 @@ Fired when a scene change is initiated. A `load` event will be fired when the | |||
new scene finishes loading. Passes scene ID string to handler. | |||
## `fullscreenchange` | |||
Fired when browser fullscreen status changed. Passes status boolean to handler. | |||
## `zoomchange` | |||
Fired when scene hfov update. Passes new HFOV value to handler. | |||
## `scenechangefadedone` | |||
If a scene transition fade interval is specified, this event is fired when the | |||
fading is completed after changing scenes. | |||
## `animatefinished` | |||
Fired when any movements / animations finish, i.e. when the renderer stops | |||
rendering new frames. Passes final pitch, yaw, and HFOV values to handler. | |||
## `error` | |||
Fired when an error occured. The error message string is passed to the | |||
@@ -46,3 +62,4 @@ Fired when a touch starts. Passes `TouchEvent` to handler. | |||
## `touchend` | |||
Fired when a touch ends. Passes `TouchEvent` to handler. | |||
@@ -98,6 +98,13 @@ viewer is fullscreen. | |||
If set to `false`, mouse and touch dragging is disabled. Defaults to `true`. | |||
### `friction` (number) | |||
Controls the "friction" that slows down the viewer motion after it is dragged | |||
and released. Higher values mean the motion stops faster. Should be set | |||
(0.0, 1.0]; defaults to 0.15. | |||
### `disableKeyboardCtrl` (boolean) | |||
If set to `true`, keyboard controls are disabled. Defaults to `false`. | |||
@@ -115,6 +122,11 @@ the fullscreen API. | |||
If set to `false`, no controls are displayed. Defaults to `true`. | |||
### `touchPanSpeedCoeffFactor` (number) | |||
Adjusts panning speed from touch inputs. Defaults to `1`. | |||
### `yaw` (number) | |||
Sets the panorama's starting yaw position in degrees. Defaults to `0`. | |||
@@ -146,7 +158,16 @@ Defaults to `undefined`, so the viewer center can reach `-90` / `90`. | |||
### `minHfov` and `maxHfov` (number) | |||
Sets the minimum / maximum horizontal field of view, in degrees, that the | |||
viewer can be set to. Defaults to `50` / `120`. | |||
viewer can be set to. Defaults to `50` / `120`. Unless the `multiResMinHfov` | |||
parameter is set to `true`, the `minHfov` parameter is ignored for | |||
`multires` panoramas. | |||
### `multiResMinHfov` (boolean) | |||
When set to `false`, the `minHfov` parameter is ignored for `multires` | |||
panoramas; an automatically calculated minimum horizontal field of view is used | |||
instead. Defaults to `false`. | |||
### `compass` (boolean) | |||
@@ -198,9 +219,15 @@ the configuration is provided via the URL; it defaults to `false` but can be | |||
set to `true` when using the API. | |||
### `hotSpots` (array) | |||
### `crossOrigin` (string) | |||
This specifies the type of CORS request used and can be set to either | |||
`anonymous` or `use-credentials`. Defaults to `anonymous`. | |||
### `hotSpots` (object) | |||
This specifies an array of hot spots that can be links to other scenes, | |||
This specifies a dictionary of hot spots that can be links to other scenes, | |||
information, or external links. Each array element has the following properties. | |||
@@ -230,6 +257,11 @@ spot. | |||
If specified for an `info` hot spot, the hot spot links to the specified URL. | |||
Not applicable for `scene` hot spots. | |||
#### `attributes` (dict) | |||
Specifies URL's link attributes. If not set, the `target` attribute is set to | |||
`_blank`, to open link in new tab to avoid opening in viewer frame / page. | |||
#### `sceneId` (string) | |||
Specifies the ID of the scene to link to for `scene` hot spots. Not applicable | |||
@@ -251,7 +283,9 @@ maintain the same direction with regard to north. | |||
#### `targetHfov` (number) | |||
Specifies the HFOV of the target scene, in degrees. | |||
Specifies the HFOV of the target scene, in degrees. Can also be set to `same`, | |||
which uses the current HFOV of the current scene as the initial HFOV of the | |||
target scene. | |||
#### `id` | |||
@@ -285,6 +319,27 @@ Specifies the fade duration, in milliseconds, when transitioning between | |||
scenes. Not defined by default. Only applicable for tours. Only works with | |||
WebGL renderer. | |||
### `capturedKeyNumbers` (array) | |||
Specifies the key numbers that are captured in key events. Defaults to the | |||
standard keys that are used by the viewer. | |||
### `backgroundColor` ([number, number, number]) | |||
Specifies an array containing RGB values [0, 1] that sets the background color | |||
for areas where no image data is available. Defaults to `[0, 0, 0]` (black). | |||
For partial `equirectangular` panoramas this applies to areas past the edges of | |||
the defined rectangle. For `multires` and `cubemap` (including fallback) panoramas | |||
this applies to areas corresponding to missing tiles or faces. | |||
### `avoidShowingBackground` (boolean) | |||
If set to `true`, prevent displaying out-of-range areas of a partial panorama | |||
by constraining the yaw and the field-of-view. Even at the corners and edges | |||
of the canvas only areas actually belonging to the image | |||
(i.e., within [`minYaw`, `maxYaw`] and [`minPitch`, `maxPitch`]) are shown, | |||
thus setting the `backgroundColor` option is not needed if this option is set. | |||
Defaults to `false`. | |||
## `equirectangular` specific options | |||
@@ -318,11 +373,6 @@ and the equirectangular image is not cropped symmetrically. | |||
If set to `true`, any embedded Photo Sphere XMP data will be ignored; else, | |||
said data will override any existing settings. Defaults to `false`. | |||
### `backgroundColor` ([number, number, number]) | |||
Specifies an array containing RGB values [0, 1] that sets the background color | |||
shown past the edges of a partial panorama. Defaults to `[0, 0, 0]` (black). | |||
## `cubemap` specific options | |||
@@ -332,8 +382,7 @@ shown past the edges of a partial panorama. Defaults to `[0, 0, 0]` (black). | |||
This is an array of URLs for the six cube faces in the order front, right, | |||
back, left, up, down. These are relative to `basePath` if it is set, else they | |||
are relative to the location of `pannellum.htm`. Absolute URLs can also be | |||
used. | |||
used. Partial cubemap images may be specified by giving `null` instead of a URL. | |||
## `multires` specific options | |||
@@ -397,6 +446,12 @@ Currently, only equirectangular dynamic content is supported. | |||
The panorama source is considered dynamic when this is set to `true`. Defaults | |||
to `false`. This should be set to `true` for video. | |||
### `dynamicUpdate` (boolean) | |||
For dynamic content, viewer will start automatically updating when set to | |||
`true`. Defaults to `false`. If the updates are controlled via the `setUpdate` | |||
method, as with the Video.js plugin, this should be set to `false`. | |||
## Additional information for tour configuration files | |||
@@ -1,7 +1,7 @@ | |||
{ | |||
"name": "pannellum", | |||
"description": "Pannellum is a lightweight, free, and open source panorama viewer for the web.", | |||
"version": "2.3.2", | |||
"version": "2.4.1", | |||
"bugs": { | |||
"url": "https://github.com/mpetroff/pannellum/issues" | |||
}, | |||
@@ -45,6 +45,10 @@ Since Pannellum is built with recent web standards, it requires a modern browser | |||
#### No support: | |||
Internet Explorer 10 and previous | |||
#### Not officially supported: | |||
Mobile / app frameworks are not officially supported. They may work, but they're not tested and are not the targeted platform. | |||
## Translations | |||
All user-facing strings can be changed using the `strings` configuration parameter. There exists a [third-party respository of user-contributed translations](https://github.com/DanielBiegler/pannellum-translation) that can be used with this configuration option. | |||
@@ -17,6 +17,7 @@ | |||
outline: 0; | |||
line-height: 1.4; | |||
contain: content; | |||
touch-action: none; | |||
} | |||
.pnlm-container * { | |||
@@ -437,3 +438,7 @@ div.pnlm-tooltip:hover span:after { | |||
top: 0; | |||
left: 0; | |||
} | |||
.pnlm-pointer { | |||
cursor: pointer; | |||
} |
@@ -1,6 +1,6 @@ | |||
/* | |||
* libpannellum - A WebGL and CSS 3D transform based Panorama Renderer | |||
* Copyright (c) 2012-2017 Matthew Petroff | |||
* Copyright (c) 2012-2018 Matthew Petroff | |||
* | |||
* Permission is hereby granted, free of charge, to any person obtaining a copy | |||
* of this software and associated documentation files (the "Software"), to deal | |||
@@ -42,6 +42,7 @@ function Renderer(container) { | |||
var pose; | |||
var image, imageType, dynamic; | |||
var texCoordBuffer, cubeVertBuf, cubeVertTexCoordBuf, cubeVertIndBuf; | |||
var globalParams; | |||
/** | |||
* Initialize renderer. | |||
@@ -63,7 +64,7 @@ function Renderer(container) { | |||
*/ | |||
this.init = function(_image, _imageType, _dynamic, haov, vaov, voffset, callback, params) { | |||
// Default argument for image type | |||
if (typeof _imageType === undefined) | |||
if (_imageType === undefined) | |||
_imageType = 'equirectangular'; | |||
if (_imageType != 'equirectangular' && _imageType != 'cubemap' && | |||
@@ -75,6 +76,7 @@ function Renderer(container) { | |||
imageType = _imageType; | |||
image = _image; | |||
dynamic = _dynamic; | |||
globalParams = params || {}; | |||
// Clear old data | |||
if (program) { | |||
@@ -99,6 +101,40 @@ function Renderer(container) { | |||
pose = undefined; | |||
var s; | |||
var faceMissing = false; | |||
var cubeImgWidth; | |||
if (imageType == 'cubemap') { | |||
for (s = 0; s < 6; s++) { | |||
if (image[s].width > 0) { | |||
if (cubeImgWidth === undefined) | |||
cubeImgWidth = image[s].width; | |||
if (cubeImgWidth != image[s].width) | |||
console.log('Cube faces have inconsistent widths: ' + cubeImgWidth + ' vs. ' + image[s].width); | |||
} else | |||
faceMissing = true; | |||
} | |||
} | |||
function fillMissingFaces(imgSize) { | |||
if (faceMissing) { // Fill any missing fallback/cubemap faces with background | |||
var nbytes = imgSize * imgSize * 4; // RGB, plus non-functional alpha | |||
var imageArray = new Uint8ClampedArray(nbytes); | |||
var rgb = params.backgroundColor ? params.backgroundColor : [0, 0, 0]; | |||
rgb[0] *= 255; | |||
rgb[1] *= 255; | |||
rgb[2] *= 255; | |||
// Maybe filling could be done faster, see e.g. https://stackoverflow.com/questions/1295584/most-efficient-way-to-create-a-zero-filled-javascript-array | |||
for (var i = 0; i < nbytes; i++) { | |||
imageArray[i++] = rgb[0]; | |||
imageArray[i++] = rgb[1]; | |||
imageArray[i++] = rgb[2]; | |||
} | |||
var backgroundSquare = new ImageData(imageArray, imgSize, imgSize); | |||
for (s = 0; s < 6; s++) { | |||
if (image[s].width == 0) | |||
image[s] = backgroundSquare; | |||
} | |||
} | |||
} | |||
// This awful browser specific test exists because iOS 8/9 and IE 11 | |||
// don't display non-power-of-two cubemap textures but also don't | |||
@@ -108,7 +144,7 @@ function Renderer(container) { | |||
// NPOT cubemaps, and the CSS 3D transform fallback renderer is used | |||
// instead. | |||
if (!(imageType == 'cubemap' && | |||
(image[0].width & (image[0].width - 1)) !== 0 && | |||
(cubeImgWidth & (cubeImgWidth - 1)) !== 0 && | |||
(navigator.userAgent.toLowerCase().match(/(iphone|ipod|ipad).* os 8_/) || | |||
navigator.userAgent.toLowerCase().match(/(iphone|ipod|ipad).* os 9_/) || | |||
navigator.userAgent.toLowerCase().match(/(iphone|ipod|ipad).* os 10_/) || | |||
@@ -121,6 +157,7 @@ function Renderer(container) { | |||
} | |||
// If there is no WebGL, fall back to CSS 3D transform renderer. | |||
// This will discard the image loaded so far and load the fallback image. | |||
// While browser specific tests are usually frowned upon, the | |||
// fallback viewer only really works with WebKit/Blink and IE 10/11 | |||
// (it doesn't work properly in Firefox). | |||
@@ -204,6 +241,16 @@ function Renderer(container) { | |||
// Draw image width duplicated edge pixels on canvas | |||
faceContext.putImageData(imgData, 0, 0); | |||
incLoaded.call(this); | |||
}; | |||
var incLoaded = function() { | |||
if (this.width > 0) { | |||
if (fallbackImgSize === undefined) | |||
fallbackImgSize = this.width; | |||
if (fallbackImgSize != this.width) | |||
console.log('Fallback faces have inconsistent widths: ' + fallbackImgSize + ' vs. ' + this.width); | |||
} else | |||
faceMissing = true; | |||
loaded++; | |||
if (loaded == 6) { | |||
fallbackImgSize = this.width; | |||
@@ -211,23 +258,27 @@ function Renderer(container) { | |||
callback(); | |||
} | |||
}; | |||
faceMissing = false; | |||
for (s = 0; s < 6; s++) { | |||
var faceImg = new Image(); | |||
faceImg.crossOrigin = 'anonymous'; | |||
faceImg.crossOrigin = globalParams.crossOrigin ? globalParams.crossOrigin : 'anonymous'; | |||
faceImg.side = s; | |||
faceImg.onload = onLoad; | |||
faceImg.onerror = incLoaded; // ignore missing face to support partial fallback image | |||
if (imageType == 'multires') { | |||
faceImg.src = encodeURI(path.replace('%s', sides[s]) + '.' + image.extension); | |||
} else { | |||
faceImg.src = encodeURI(image[s].src); | |||
} | |||
} | |||
fillMissingFaces(fallbackImgSize); | |||
return; | |||
} else if (!gl) { | |||
console.log('Error: no WebGL support detected!'); | |||
throw {type: 'no webgl'}; | |||
} | |||
if (imageType == 'cubemap') | |||
fillMissingFaces(cubeImgWidth); | |||
if (image.basePath) { | |||
image.fullpath = image.basePath + image.path; | |||
} else { | |||
@@ -243,19 +294,18 @@ function Renderer(container) { | |||
} | |||
// Make sure image isn't too big | |||
var width, maxWidth; | |||
var maxWidth = 0; | |||
if (imageType == 'equirectangular') { | |||
width = Math.max(image.width, image.height); | |||
maxWidth = gl.getParameter(gl.MAX_TEXTURE_SIZE); | |||
if (width > maxWidth) { | |||
console.log('Error: The image is too big; it\'s ' + width + 'px wide, but this device\'s maximum supported width is ' + maxWidth + 'px.'); | |||
throw {type: 'webgl size error', width: width, maxWidth: maxWidth}; | |||
if (Math.max(image.width / 2, image.height) > maxWidth) { | |||
console.log('Error: The image is too big; it\'s ' + image.width + 'px wide, '+ | |||
'but this device\'s maximum supported size is ' + (maxWidth * 2) + 'px.'); | |||
throw {type: 'webgl size error', width: image.width, maxWidth: maxWidth * 2}; | |||
} | |||
} else if (imageType == 'cubemap') { | |||
width = image[0].width; | |||
maxWidth = gl.getParameter(gl.MAX_CUBE_MAP_TEXTURE_SIZE); | |||
if (width > maxWidth) { | |||
console.log('Error: The cube face image is too big; it\'s ' + width + 'px wide, but this device\'s maximum supported width is ' + maxWidth + 'px.'); | |||
if (cubeImgWidth > gl.getParameter(gl.MAX_CUBE_MAP_TEXTURE_SIZE)) { | |||
console.log('Error: The image is too big; it\'s ' + width + 'px wide, '+ | |||
'but this device\'s maximum supported size is ' + maxWidth + 'px.'); | |||
throw {type: 'webgl size error', width: width, maxWidth: maxWidth}; | |||
} | |||
} | |||
@@ -311,6 +361,11 @@ function Renderer(container) { | |||
program.drawInProgress = false; | |||
// Set background clear color (does not apply to cubemap/fallback image) | |||
var color = params.backgroundColor ? params.backgroundColor : [0, 0, 0]; | |||
gl.clearColor(color[0], color[1], color[2], 1.0); | |||
gl.clear(gl.COLOR_BUFFER_BIT); | |||
// Look up texture coordinates location | |||
program.texCoordLocation = gl.getAttribLocation(program, 'a_texCoord'); | |||
gl.enableVertexAttribArray(program.texCoordLocation); | |||
@@ -325,7 +380,7 @@ function Renderer(container) { | |||
// Pass aspect ratio | |||
program.aspectRatio = gl.getUniformLocation(program, 'u_aspectRatio'); | |||
gl.uniform1f(program.aspectRatio, canvas.clientWidth / canvas.clientHeight); | |||
gl.uniform1f(program.aspectRatio, gl.drawingBufferWidth / gl.drawingBufferHeight); | |||
// Locate psi, theta, focal length, horizontal extent, vertical extent, and vertical offset | |||
program.psi = gl.getUniformLocation(program, 'u_psi'); | |||
@@ -344,7 +399,6 @@ function Renderer(container) { | |||
// Set background color | |||
if (imageType == 'equirectangular') { | |||
program.backgroundColor = gl.getUniformLocation(program, 'u_backgroundColor'); | |||
var color = params.backgroundColor ? params.backgroundColor : [0, 0, 0]; | |||
gl.uniform4fv(program.backgroundColor, color.concat([1])); | |||
} | |||
@@ -362,8 +416,44 @@ function Renderer(container) { | |||
gl.texImage2D(gl.TEXTURE_CUBE_MAP_POSITIVE_Z, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, image[0]); | |||
gl.texImage2D(gl.TEXTURE_CUBE_MAP_NEGATIVE_Z, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, image[2]); | |||
} else { | |||
// Upload image to the texture | |||
gl.texImage2D(glBindType, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, image); | |||
if (image.width <= maxWidth) { | |||
gl.uniform1i(gl.getUniformLocation(program, 'u_splitImage'), 0); | |||
// Upload image to the texture | |||
gl.texImage2D(glBindType, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, image); | |||
} else { | |||
// Image needs to be split into two parts due to texture size limits | |||
gl.uniform1i(gl.getUniformLocation(program, 'u_splitImage'), 1); | |||
// Draw image on canvas | |||
var cropCanvas = document.createElement('canvas'); | |||
cropCanvas.width = image.width; | |||
cropCanvas.height = image.height; | |||
var cropContext = cropCanvas.getContext('2d'); | |||
cropContext.drawImage(image, 0, 0); | |||
// Upload first half of image to the texture | |||
var cropImage = cropContext.getImageData(0, 0, image.width / 2, image.height); | |||
gl.texImage2D(glBindType, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, cropImage); | |||
// Create and bind texture for second half of image | |||
program.texture2 = gl.createTexture(); | |||
gl.activeTexture(gl.TEXTURE1); | |||
gl.bindTexture(glBindType, program.texture2); | |||
gl.uniform1i(gl.getUniformLocation(program, 'u_image1'), 1); | |||
// Upload second half of image to the texture | |||
cropImage = cropContext.getImageData(image.width / 2, 0, image.width / 2, image.height); | |||
gl.texImage2D(glBindType, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, cropImage); | |||
// Set parameters for rendering any size | |||
gl.texParameteri(glBindType, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); | |||
gl.texParameteri(glBindType, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); | |||
gl.texParameteri(glBindType, gl.TEXTURE_MIN_FILTER, gl.LINEAR); | |||
gl.texParameteri(glBindType, gl.TEXTURE_MAG_FILTER, gl.LINEAR); | |||
// Reactive first texture unit | |||
gl.activeTexture(gl.TEXTURE0); | |||
} | |||
} | |||
// Set parameters for rendering any size | |||
@@ -540,16 +630,18 @@ function Renderer(container) { | |||
// Apply face transforms | |||
var faces = Object.keys(transforms); | |||
for (i = 0; i < 6; i++) { | |||
var face = world.querySelector('.pnlm-' + faces[i] + 'face').style; | |||
face.webkitTransform = transform + transforms[faces[i]]; | |||
face.transform = transform + transforms[faces[i]]; | |||
var face = world.querySelector('.pnlm-' + faces[i] + 'face'); | |||
if (!face) | |||
continue; // ignore missing face to support partial cubemap/fallback image | |||
face.style.webkitTransform = transform + transforms[faces[i]]; | |||
face.style.transform = transform + transforms[faces[i]]; | |||
} | |||
return; | |||
} | |||
if (imageType != 'multires') { | |||
// Calculate focal length from vertical field of view | |||
var vfov = 2 * Math.atan(Math.tan(hfov * 0.5) / (canvas.clientWidth / canvas.clientHeight)); | |||
var vfov = 2 * Math.atan(Math.tan(hfov * 0.5) / (gl.drawingBufferWidth / gl.drawingBufferHeight)); | |||
focal = 1 / Math.tan(vfov * 0.5); | |||
// Pass psi, theta, roll, and focal length | |||
@@ -571,7 +663,7 @@ function Renderer(container) { | |||
} else { | |||
// Create perspective matrix | |||
var perspMatrix = makePersp(hfov, canvas.clientWidth / canvas.clientHeight, 0.1, 100.0); | |||
var perspMatrix = makePersp(hfov, gl.drawingBufferWidth / gl.drawingBufferHeight, 0.1, 100.0); | |||
// Find correct zoom level | |||
checkZoom(hfov); | |||
@@ -606,12 +698,29 @@ function Renderer(container) { | |||
var ntmp = new MultiresNode(vtmps[s], sides[s], 1, 0, 0, image.fullpath); | |||
testMultiresNode(rotPersp, ntmp, pitch, yaw, hfov); | |||
} | |||
program.currentNodes.sort(multiresNodeRenderSort); | |||
// Only process one tile per frame to improve responsiveness | |||
for (i = 0; i < program.currentNodes.length; i++) { | |||
if (!program.currentNodes[i].texture) { | |||
setTimeout(processNextTile, 0, program.currentNodes[i]); | |||
break; | |||
// Unqueue any pending requests for nodes that are no longer visible | |||
for (i = pendingTextureRequests.length - 1; i >= 0; i--) { | |||
if (program.currentNodes.indexOf(pendingTextureRequests[i].node) === -1) { | |||
pendingTextureRequests[i].node.textureLoad = false; | |||
pendingTextureRequests.splice(i, 1); | |||
} | |||
} | |||
// Allow one request to be pending, so that we can create a texture buffer for that in advance of loading actually beginning | |||
if (pendingTextureRequests.length === 0) { | |||
for (i = 0; i < program.currentNodes.length; i++) { | |||
var node = program.currentNodes[i]; | |||
if (!node.texture && !node.textureLoad) { | |||
node.textureLoad = true; | |||
setTimeout(processNextTile, 0, node); | |||
// Only process one tile per frame to improve responsiveness | |||
break; | |||
} | |||
} | |||
} | |||
@@ -695,8 +804,9 @@ function Renderer(container) { | |||
function multiresDraw() { | |||
if (!program.drawInProgress) { | |||
program.drawInProgress = true; | |||
gl.clear(gl.COLOR_BUFFER_BIT); | |||
for ( var i = 0; i < program.currentNodes.length; i++ ) { | |||
if (program.currentNodes[i].textureLoaded) { | |||
if (program.currentNodes[i].textureLoaded > 1) { | |||
//var color = program.currentNodes[i].color; | |||
//gl.uniform4f(program.colorUniform, color[0], color[1], color[2], 1.0); | |||
@@ -984,7 +1094,7 @@ function Renderer(container) { | |||
* @returns {number[]} Generated perspective matrix. | |||
*/ | |||
function makePersp(hfov, aspect, znear, zfar) { | |||
var fovy = 2 * Math.atan(Math.tan(hfov/2) * canvas.clientHeight / canvas.clientWidth); | |||
var fovy = 2 * Math.atan(Math.tan(hfov/2) * gl.drawingBufferHeight / gl.drawingBufferWidth); | |||
var f = 1 / Math.tan(fovy/2); | |||
return [ | |||
f/aspect, 0, 0, 0, | |||
@@ -1009,23 +1119,31 @@ function Renderer(container) { | |||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); | |||
gl.bindTexture(gl.TEXTURE_2D, null); | |||
} | |||
var pendingTextureRequests = []; | |||
// Based on http://blog.tojicode.com/2012/03/javascript-memory-optimization-and.html | |||
var loadTexture = (function() { | |||
var cacheTop = 4; // Maximum number of concurrents loads | |||
var textureImageCache = {}; | |||
var pendingTextureRequests = []; | |||
var crossOrigin; | |||
function TextureImageLoader() { | |||
var self = this; | |||
this.texture = this.callback = null; | |||
this.image = new Image(); | |||
this.image.crossOrigin = 'anonymous'; | |||
this.image.addEventListener('load', function() { | |||
processLoadedTexture(self.image, self.texture); | |||
self.callback(self.texture); | |||
this.image.crossOrigin = crossOrigin ? crossOrigin : 'anonymous'; | |||
var loadFn = (function() { | |||
if (self.image.width > 0 && self.image.height > 0) { // ignore missing tile to supporting partial image | |||
processLoadedTexture(self.image, self.texture); | |||
self.callback(self.texture, true); | |||
} else { | |||
self.callback(self.texture, false); | |||
} | |||
releaseTextureImageLoader(self); | |||
}); | |||
this.image.addEventListener('load', loadFn); | |||
this.image.addEventListener('error', loadFn); // ignore missing tile file to support partial image, otherwise retry loop causes high CPU load | |||
}; | |||
TextureImageLoader.prototype.loadTexture = function(src, texture, callback) { | |||
@@ -1034,7 +1152,8 @@ function Renderer(container) { | |||
this.image.src = src; | |||
}; | |||
function PendingTextureRequest(src, texture, callback) { | |||
function PendingTextureRequest(node, src, texture, callback) { | |||
this.node = node; | |||
this.src = src; | |||
this.texture = texture; | |||
this.callback = callback; | |||
@@ -1051,12 +1170,13 @@ function Renderer(container) { | |||
for (var i = 0; i < cacheTop; i++) | |||
textureImageCache[i] = new TextureImageLoader(); | |||
return function(src, callback) { | |||
return function(node, src, callback, _crossOrigin) { | |||
crossOrigin = _crossOrigin; | |||
var texture = gl.createTexture(); | |||
if (cacheTop) | |||
textureImageCache[--cacheTop].loadTexture(src, texture, callback); | |||
else | |||
pendingTextureRequests.push(new PendingTextureRequest(src, texture, callback)); | |||
pendingTextureRequests.push(new PendingTextureRequest(node, src, texture, callback)); | |||
return texture; | |||
}; | |||
})(); | |||
@@ -1067,13 +1187,10 @@ function Renderer(container) { | |||
* @param {MultiresNode} node - Input node. | |||
*/ | |||
function processNextTile(node) { | |||
if (!node.textureLoad) { | |||
node.textureLoad = true; | |||
loadTexture(encodeURI(node.path + '.' + image.extension), function(texture) { | |||
node.texture = texture; | |||
node.textureLoaded = true; | |||
}); | |||
} | |||
loadTexture(node, encodeURI(node.path + '.' + image.extension), function(texture, loaded) { | |||
node.texture = texture; | |||
node.textureLoaded = loaded ? 2 : 1; | |||
}, globalParams.crossOrigin); | |||
} | |||
/** | |||
@@ -1085,7 +1202,7 @@ function Renderer(container) { | |||
// Find optimal level | |||
var newLevel = 1; | |||
while ( newLevel < image.maxLevel && | |||
canvas.width > image.tileResolution * | |||
gl.drawingBufferWidth > image.tileResolution * | |||
Math.pow(2, newLevel - 1) * Math.tan(hfov / 2) * 0.707 ) { | |||
newLevel++; | |||
} | |||
@@ -1241,7 +1358,9 @@ var fragEquiCubeBase = [ | |||
'const float PI = 3.14159265358979323846264;', | |||
// Texture | |||
'uniform sampler2D u_image;', | |||
'uniform sampler2D u_image0;', | |||
'uniform sampler2D u_image1;', | |||
'uniform bool u_splitImage;', | |||
'uniform samplerCube u_imageCube;', | |||
// Coordinates passed in from vertex shader | |||
@@ -1286,8 +1405,17 @@ var fragEquirectangular = fragEquiCubeBase + [ | |||
// Map from [-1,1] to [0,1] and flip y-axis | |||
'if(coord.x < -u_h || coord.x > u_h || coord.y < -u_v + u_vo || coord.y > u_v + u_vo)', | |||
'gl_FragColor = u_backgroundColor;', | |||
'else', | |||
'gl_FragColor = texture2D(u_image, vec2((coord.x + u_h) / (u_h * 2.0), (-coord.y + u_v + u_vo) / (u_v * 2.0)));', | |||
'else {', | |||
'if(u_splitImage) {', | |||
// Image was split into two textures to work around texture size limits | |||
'if(coord.x < 0.0)', | |||
'gl_FragColor = texture2D(u_image0, vec2((coord.x + u_h) / u_h, (-coord.y + u_v + u_vo) / (u_v * 2.0)));', | |||
'else', | |||
'gl_FragColor = texture2D(u_image1, vec2((coord.x + u_h) / u_h - 1.0, (-coord.y + u_v + u_vo) / (u_v * 2.0)));', | |||
'} else {', | |||
'gl_FragColor = texture2D(u_image0, vec2((coord.x + u_h) / (u_h * 2.0), (-coord.y + u_v + u_vo) / (u_v * 2.0)));', | |||
'}', | |||
'}', | |||
'}' | |||
].join('\n'); | |||
@@ -1,6 +1,6 @@ | |||
/* | |||
* Pannellum - An HTML5 based Panorama Viewer | |||
* Copyright (c) 2011-2017 Matthew Petroff | |||
* Copyright (c) 2011-2018 Matthew Petroff | |||
* | |||
* Permission is hereby granted, free of charge, to any person obtaining a copy | |||
* of this software and associated documentation files (the "Software"), to deal | |||
@@ -49,7 +49,7 @@ var config, | |||
onPointerDownPitch = 0, | |||
keysDown = new Array(10), | |||
fullscreenActive = false, | |||
loaded = false, | |||
loaded, | |||
error = false, | |||
isTimedOut = false, | |||
listenersAdded = false, | |||
@@ -67,11 +67,13 @@ var config, | |||
externalEventListeners = {}, | |||
specifiedPhotoSphereExcludes = [], | |||
update = false, // Should we update when still to render dynamic content | |||
eps = 1e-6, | |||
hotspotsCreated = false; | |||
var defaultConfig = { | |||
hfov: 100, | |||
minHfov: 50, | |||
multiResMinHfov: false, | |||
maxHfov: 120, | |||
pitch: 0, | |||
minPitch: undefined, | |||
@@ -90,6 +92,7 @@ var defaultConfig = { | |||
northOffset: 0, | |||
showFullscreenCtrl: true, | |||
dynamic: false, | |||
dynamicUpdate: false, | |||
doubleClickZoom: true, | |||
keyboardZoom: true, | |||
mouseZoom: true, | |||
@@ -99,9 +102,14 @@ var defaultConfig = { | |||
orientationOnByDefault: false, | |||
hotSpotDebug: false, | |||
backgroundColor: [0, 0, 0], | |||
avoidShowingBackground: false, | |||
animationTimingFunction: timingFunction, | |||
draggable: true, | |||
disableKeyboardCtrl: false, | |||
crossOrigin: 'anonymous', | |||
touchPanSpeedCoeffFactor: 1, | |||
capturedKeyNumbers: [16, 17, 27, 37, 38, 39, 40, 61, 65, 68, 83, 87, 107, 109, 173, 187, 189], | |||
friction: 0.15 | |||
}; | |||
// Translatable / configurable strings | |||
@@ -128,8 +136,6 @@ defaultConfig.strings = { | |||
unknownError: 'Unknown error. Check developer console.', | |||
} | |||
var usedKeyNumbers = [16, 17, 27, 37, 38, 39, 40, 61, 65, 68, 83, 87, 107, 109, 173, 187, 189]; | |||
// Initialize container | |||
container = typeof container === 'string' ? document.getElementById(container) : container; | |||
container.classList.add('pnlm-container'); | |||
@@ -306,7 +312,7 @@ function init() { | |||
panoImage = []; | |||
for (i = 0; i < 6; i++) { | |||
panoImage.push(new Image()); | |||
panoImage[i].crossOrigin = 'anonymous'; | |||
panoImage[i].crossOrigin = config.crossOrigin; | |||
} | |||
infoDisplay.load.lbox.style.display = 'block'; | |||
infoDisplay.load.lbar.style.display = 'none'; | |||
@@ -350,18 +356,23 @@ function init() { | |||
var onError = function(e) { | |||
var a = document.createElement('a'); | |||
a.href = e.target.src; | |||
a.innerHTML = a.href; | |||
a.textContent = a.href; | |||
anError(config.strings.fileAccessError.replace('%s', a.outerHTML)); | |||
}; | |||
for (i = 0; i < panoImage.length; i++) { | |||
panoImage[i].onload = onLoad; | |||
panoImage[i].onerror = onError; | |||
p = config.cubeMap[i]; | |||
if (config.basePath && !absoluteURL(p)) { | |||
p = config.basePath + p; | |||
if (p == "null") { // support partial cubemap image with explicitly empty faces | |||
console.log('Will use background instead of missing cubemap face ' + i); | |||
onLoad(); | |||
} else { | |||
if (config.basePath && !absoluteURL(p)) { | |||
p = config.basePath + p; | |||
} | |||
panoImage[i].onload = onLoad; | |||
panoImage[i].onerror = onError; | |||
panoImage[i].src = sanitizeURL(p); | |||
} | |||
panoImage[i].src = encodeURI(p); | |||
} | |||
} else if (config.type == 'multires') { | |||
onImageLoad(); | |||
@@ -385,8 +396,8 @@ function init() { | |||
if (xhr.status != 200) { | |||
// Display error if image can't be loaded | |||
var a = document.createElement('a'); | |||
a.href = encodeURI(p); | |||
a.innerHTML = a.href; | |||
a.href = p; | |||
a.textContent = a.href; | |||
anError(config.strings.fileAccessError.replace('%s', a.outerHTML)); | |||
} | |||
var img = this.response; | |||
@@ -427,6 +438,7 @@ function init() { | |||
} | |||
xhr.responseType = 'blob'; | |||
xhr.setRequestHeader('Accept', 'image/*,*/*;q=0.9'); | |||
xhr.withCredentials = config.crossOrigin === 'use-credentials'; | |||
xhr.send(); | |||
} | |||
} | |||
@@ -468,10 +480,10 @@ function onImageLoad() { | |||
if (config.doubleClickZoom) { | |||
dragFix.addEventListener('dblclick', onDocumentDoubleClick, false); | |||
} | |||
uiContainer.addEventListener('mozfullscreenchange', onFullScreenChange, false); | |||
uiContainer.addEventListener('webkitfullscreenchange', onFullScreenChange, false); | |||
uiContainer.addEventListener('msfullscreenchange', onFullScreenChange, false); | |||
uiContainer.addEventListener('fullscreenchange', onFullScreenChange, false); | |||
container.addEventListener('mozfullscreenchange', onFullScreenChange, false); | |||
container.addEventListener('webkitfullscreenchange', onFullScreenChange, false); | |||
container.addEventListener('msfullscreenchange', onFullScreenChange, false); | |||
container.addEventListener('fullscreenchange', onFullScreenChange, false); | |||
window.addEventListener('resize', onDocumentResize, false); | |||
window.addEventListener('orientationchange', onDocumentResize, false); | |||
if (!config.disableKeyboardCtrl) { | |||
@@ -480,13 +492,17 @@ function onImageLoad() { | |||
container.addEventListener('blur', clearKeys, false); | |||
} | |||
document.addEventListener('mouseleave', onDocumentMouseUp, false); | |||
dragFix.addEventListener('touchstart', onDocumentTouchStart, false); | |||
dragFix.addEventListener('touchmove', onDocumentTouchMove, false); | |||
dragFix.addEventListener('touchend', onDocumentTouchEnd, false); | |||
dragFix.addEventListener('pointerdown', onDocumentPointerDown, false); | |||
dragFix.addEventListener('pointermove', onDocumentPointerMove, false); | |||
dragFix.addEventListener('pointerup', onDocumentPointerUp, false); | |||
dragFix.addEventListener('pointerleave', onDocumentPointerUp, false); | |||
if (document.documentElement.style.pointerAction === '' && | |||
document.documentElement.style.touchAction === '') { | |||
dragFix.addEventListener('pointerdown', onDocumentPointerDown, false); | |||
dragFix.addEventListener('pointermove', onDocumentPointerMove, false); | |||
dragFix.addEventListener('pointerup', onDocumentPointerUp, false); | |||
dragFix.addEventListener('pointerleave', onDocumentPointerUp, false); | |||
} else { | |||
dragFix.addEventListener('touchstart', onDocumentTouchStart, false); | |||
dragFix.addEventListener('touchmove', onDocumentTouchMove, false); | |||
dragFix.addEventListener('touchend', onDocumentTouchEnd, false); | |||
} | |||
// Deal with MS pointer events | |||
if (window.navigator.pointerEnabled) | |||
@@ -494,6 +510,7 @@ function onImageLoad() { | |||
} | |||
renderInit(); | |||
setHfov(config.hfov); // possibly adapt hfov after configuration and canvas is complete; prevents empty space on top or bottom by zomming out too much | |||
setTimeout(function(){isTimedOut = true;}, 500); | |||
} | |||
@@ -644,8 +661,9 @@ function aboutMessage(event) { | |||
function mousePosition(event) { | |||
var bounds = container.getBoundingClientRect(); | |||
var pos = {}; | |||
pos.x = event.clientX - bounds.left; | |||
pos.y = event.clientY - bounds.top; | |||
// pageX / pageY needed for iOS | |||
pos.x = (event.clientX || event.pageX) - bounds.left; | |||
pos.y = (event.clientY || event.pageY) - bounds.top; | |||
return pos; | |||
} | |||
@@ -869,7 +887,7 @@ function onDocumentTouchMove(event) { | |||
// | |||
// Currently this seems to *roughly* keep initial drag/pan start position close to | |||
// the user's finger while panning regardless of zoom level / config.hfov value. | |||
var touchmovePanSpeedCoeff = config.hfov / 360; | |||
var touchmovePanSpeedCoeff = (config.hfov / 360) * config.touchPanSpeedCoeffFactor; | |||
var yaw = (onPointerDownPointerX - clientX) * touchmovePanSpeedCoeff + onPointerDownYaw; | |||
speed.yaw = (yaw - config.yaw) % 360 * 0.2; | |||
@@ -926,7 +944,7 @@ function onDocumentPointerMove(event) { | |||
pointerCoordinates[i].clientY = event.clientY; | |||
event.targetTouches = pointerCoordinates; | |||
onDocumentTouchMove(event); | |||
//event.preventDefault(); | |||
event.preventDefault(); | |||
return; | |||
} | |||
} | |||
@@ -986,7 +1004,6 @@ function onDocumentMouseWheel(event) { | |||
setHfov(config.hfov + event.detail * 1.5); | |||
speed.hfov = event.detail > 0 ? 1 : -1; | |||
} | |||
animateInit(); | |||
} | |||
@@ -1007,8 +1024,8 @@ function onDocumentKeyPress(event) { | |||
var keynumber = event.which || event.keycode; | |||
// Override default action for keys that are used | |||
if (usedKeyNumbers.indexOf(keynumber) < 0) | |||
return | |||
if (config.capturedKeyNumbers.indexOf(keynumber) < 0) | |||
return; | |||
event.preventDefault(); | |||
// If escape key is pressed | |||
@@ -1043,8 +1060,8 @@ function onDocumentKeyUp(event) { | |||
var keynumber = event.which || event.keycode; | |||
// Override default action for keys that are used | |||
if (usedKeyNumbers.indexOf(keynumber) < 0) | |||
return | |||
if (config.capturedKeyNumbers.indexOf(keynumber) < 0) | |||
return; | |||
event.preventDefault(); | |||
// Change key | |||
@@ -1232,19 +1249,19 @@ function keyRepeat() { | |||
// "Inertia" | |||
if (diff > 0 && !config.autoRotate) { | |||
// "Friction" | |||
var friction = 0.85; | |||
var slowDownFactor = 1 - config.friction; | |||
// Yaw | |||
if (!keysDown[4] && !keysDown[5] && !keysDown[8] && !keysDown[9] && !animatedMove.yaw) { | |||
config.yaw += speed.yaw * diff * friction; | |||
config.yaw += speed.yaw * diff * slowDownFactor; | |||
} | |||
// Pitch | |||
if (!keysDown[2] && !keysDown[3] && !keysDown[6] && !keysDown[7] && !animatedMove.pitch) { | |||
config.pitch += speed.pitch * diff * friction; | |||
config.pitch += speed.pitch * diff * slowDownFactor; | |||
} | |||
// Zoom | |||
if (!keysDown[0] && !keysDown[1] && !animatedMove.hfov) { | |||
setHfov(config.hfov + speed.hfov * diff * friction); | |||
setHfov(config.hfov + speed.hfov * diff * slowDownFactor); | |||
} | |||
} | |||
@@ -1262,7 +1279,7 @@ function keyRepeat() { | |||
} | |||
// Stop movement if opposite controls are pressed | |||
if (keysDown[0] && keysDown[0]) { | |||
if (keysDown[0] && keysDown[1]) { | |||
speed.hfov = 0; | |||
} | |||
if ((keysDown[2] || keysDown[6]) && (keysDown[3] || keysDown[7])) { | |||
@@ -1287,11 +1304,7 @@ function animateMove(axis) { | |||
t.endPosition === t.startPosition) { | |||
result = t.endPosition; | |||
speed[axis] = 0; | |||
var callback = animatedMove[axis].callback, | |||
callbackArgs = animatedMove[axis].callbackArgs; | |||
delete animatedMove[axis]; | |||
if (typeof callback == 'function') | |||
callback(callbackArgs); | |||
} | |||
config[axis] = result; | |||
} | |||
@@ -1316,7 +1329,7 @@ function onDocumentResize() { | |||
//animateInit(); | |||
// Kludge to deal with WebKit regression: https://bugs.webkit.org/show_bug.cgi?id=93525 | |||
onFullScreenChange(); | |||
onFullScreenChange('resize'); | |||
} | |||
/** | |||
@@ -1359,6 +1372,7 @@ function animate() { | |||
} else if (renderer && (renderer.isLoading() || (config.dynamic === true && update))) { | |||
requestAnimationFrame(animate); | |||
} else { | |||
fireEvent('animatefinished', {pitch: _this.getPitch(), yaw: _this.getYaw(), hfov: _this.getHfov()}); | |||
animating = false; | |||
prevTime = undefined; | |||
var autoRotateStartTime = config.autoRotateInactivityDelay - | |||
@@ -1385,32 +1399,50 @@ function render() { | |||
var tmpyaw; | |||
if (loaded) { | |||
if (config.yaw > 180) { | |||
config.yaw -= 360; | |||
} else if (config.yaw < -180) { | |||
config.yaw += 360; | |||
} | |||
// Keep a tmp value of yaw for autoRotate comparison later | |||
tmpyaw = config.yaw; | |||
// Optionally avoid showing background (empty space) on left or right by adapting min/max yaw | |||
var hoffcut = 0, | |||
voffcut = 0; | |||
if (config.avoidShowingBackground) { | |||
var canvas = renderer.getCanvas(), | |||
hfov2 = config.hfov / 2, | |||
vfov2 = Math.atan2(Math.tan(hfov2 / 180 * Math.PI), (canvas.width / canvas.height)) * 180 / Math.PI, | |||
transposed = config.vaov > config.haov; | |||
if (transposed) { | |||
voffcut = vfov2 * (1 - Math.min(Math.cos((config.pitch - hfov2) / 180 * Math.PI), | |||
Math.cos((config.pitch + hfov2) / 180 * Math.PI))); | |||
} else { | |||
hoffcut = hfov2 * (1 - Math.min(Math.cos((config.pitch - vfov2) / 180 * Math.PI), | |||
Math.cos((config.pitch + vfov2) / 180 * Math.PI))); | |||
} | |||
} | |||
// Ensure the yaw is within min and max allowed | |||
var yawRange = config.maxYaw - config.minYaw, | |||
minYaw = -180, | |||
maxYaw = 180; | |||
if (yawRange < 360) { | |||
minYaw = config.minYaw + config.hfov / 2; | |||
maxYaw = config.maxYaw - config.hfov / 2; | |||
minYaw = config.minYaw + config.hfov / 2 + hoffcut; | |||
maxYaw = config.maxYaw - config.hfov / 2 - hoffcut; | |||
if (yawRange < config.hfov) { | |||
// Lock yaw to average of min and max yaw when both can be seen at once | |||
minYaw = maxYaw = (minYaw + maxYaw) / 2; | |||
} | |||
config.yaw = Math.max(minYaw, Math.min(maxYaw, config.yaw)); | |||
} | |||
config.yaw = Math.max(minYaw, Math.min(maxYaw, config.yaw)); | |||
if (config.yaw > 180) { | |||
config.yaw -= 360; | |||
} else if (config.yaw < -180) { | |||
config.yaw += 360; | |||
} | |||
// Check if we autoRotate in a limited by min and max yaw | |||
// If so reverse direction | |||
if (config.autoRotate !== false && tmpyaw != config.yaw) { | |||
if (config.autoRotate !== false && tmpyaw != config.yaw && | |||
prevTime !== undefined) { // this condition prevents changing the direction initially | |||
config.autoRotate *= -1; | |||
} | |||
@@ -1653,7 +1685,7 @@ function createHotSpot(hs) { | |||
p = hs.video; | |||
if (config.basePath && !absoluteURL(p)) | |||
p = config.basePath + p; | |||
video.src = encodeURI(p); | |||
video.src = sanitizeURL(p); | |||
video.controls = true; | |||
video.style.width = hs.width + 'px'; | |||
renderContainer.appendChild(div); | |||
@@ -1663,11 +1695,11 @@ function createHotSpot(hs) { | |||
if (config.basePath && !absoluteURL(p)) | |||
p = config.basePath + p; | |||
a = document.createElement('a'); | |||
a.href = encodeURI(hs.URL ? hs.URL : p); | |||
a.href = sanitizeURL(hs.URL ? hs.URL : p); | |||
a.target = '_blank'; | |||
span.appendChild(a); | |||
var image = document.createElement('img'); | |||
image.src = encodeURI(p); | |||
image.src = sanitizeURL(p); | |||
image.style.width = hs.width + 'px'; | |||
image.style.paddingTop = '5px'; | |||
renderContainer.appendChild(div); | |||
@@ -1675,11 +1707,17 @@ function createHotSpot(hs) { | |||
span.style.maxWidth = 'initial'; | |||
} else if (hs.URL) { | |||
a = document.createElement('a'); | |||
a.href = encodeURI(hs.URL); | |||
a.target = '_blank'; | |||
a.href = sanitizeURL(hs.URL); | |||
if (hs.attributes) { | |||
for (var key in hs.attributes) { | |||
a.setAttribute(key, hs.attributes[key]); | |||
} | |||
} else { | |||
a.target = '_blank'; | |||
} | |||
renderContainer.appendChild(a); | |||
div.style.cursor = 'pointer'; | |||
span.style.cursor = 'pointer'; | |||
div.className += ' pnlm-pointer'; | |||
span.className += ' pnlm-pointer'; | |||
a.appendChild(div); | |||
} else { | |||
if (hs.sceneId) { | |||
@@ -1690,8 +1728,8 @@ function createHotSpot(hs) { | |||
} | |||
return false; | |||
}; | |||
div.style.cursor = 'pointer'; | |||
span.style.cursor = 'pointer'; | |||
div.className += ' pnlm-pointer'; | |||
span.className += ' pnlm-pointer'; | |||
} | |||
renderContainer.appendChild(div); | |||
} | |||
@@ -1709,8 +1747,8 @@ function createHotSpot(hs) { | |||
div.addEventListener('click', function(e) { | |||
hs.clickHandlerFunc(e, hs.clickHandlerArgs); | |||
}, 'false'); | |||
div.style.cursor = 'pointer'; | |||
span.style.cursor = 'pointer'; | |||
div.className += ' pnlm-pointer'; | |||
span.className += ' pnlm-pointer'; | |||
} | |||
hs.div = div; | |||
}; | |||
@@ -1746,10 +1784,12 @@ function destroyHotSpots() { | |||
if (hs) { | |||
for (var i = 0; i < hs.length; i++) { | |||
var current = hs[i].div; | |||
while(current.parentNode != renderContainer) { | |||
current = current.parentNode; | |||
if (current) { | |||
while (current.parentNode && current.parentNode != renderContainer) { | |||
current = current.parentNode; | |||
} | |||
renderContainer.removeChild(current); | |||
} | |||
renderContainer.removeChild(current); | |||
delete hs[i].div; | |||
} | |||
} | |||
@@ -1899,7 +1939,7 @@ function processOptions(isPreview) { | |||
p = config.basePath + p; | |||
preview = document.createElement('div'); | |||
preview.className = 'pnlm-preview-img'; | |||
preview.style.backgroundImage = "url('" + encodeURI(p) + "')"; | |||
preview.style.backgroundImage = "url('" + sanitizeURLForCss(p) + "')"; | |||
renderContainer.appendChild(preview); | |||
} | |||
@@ -1940,7 +1980,16 @@ function processOptions(isPreview) { | |||
break; | |||
case 'fallback': | |||
infoDisplay.errorMsg.innerHTML = '<p>Your browser does not support WebGL.<br><a href="' + encodeURI(config[key]) + '" target="_blank">Click here to view this panorama in an alternative viewer.</a></p>'; | |||
var link = document.createElement('a'); | |||
link.href = sanitizeURL(config[key]); | |||
link.target = '_blank'; | |||
link.textContent = 'Click here to view this panorama in an alternative viewer.'; | |||
var message = document.createElement('p'); | |||
message.textContent = 'Your browser does not support WebGL.' | |||
message.appendChild(document.createElement('br')); | |||
message.appendChild(link); | |||
infoDisplay.errorMsg.innerHTML = ''; // Removes all children nodes | |||
infoDisplay.errorMsg.appendChild(message); | |||
break; | |||
case 'hfov': | |||
@@ -2058,15 +2107,16 @@ function toggleFullscreen() { | |||
* Event handler for fullscreen changes. | |||
* @private | |||
*/ | |||
function onFullScreenChange() { | |||
if (document.fullscreen || document.mozFullScreen || document.webkitIsFullScreen || document.msFullscreenElement) { | |||
function onFullScreenChange(resize) { | |||
if (document.fullscreenElement || document.fullscreen || document.mozFullScreen || document.webkitIsFullScreen || document.msFullscreenElement) { | |||
controls.fullscreen.classList.add('pnlm-fullscreen-toggle-button-active'); | |||
fullscreenActive = true; | |||
} else { | |||
controls.fullscreen.classList.remove('pnlm-fullscreen-toggle-button-active'); | |||
fullscreenActive = false; | |||
} | |||
if (resize !== 'resize') | |||
fireEvent('fullscreenchange', fullscreenActive); | |||
// Resize renderer (deal with browser quirks and fixes #155) | |||
renderer.resize(); | |||
setHfov(config.hfov); | |||
@@ -2104,20 +2154,31 @@ function zoomOut() { | |||
function constrainHfov(hfov) { | |||
// Keep field of view within bounds | |||
var minHfov = config.minHfov; | |||
if (config.type == 'multires' && renderer) { | |||
if (config.type == 'multires' && renderer && config.multiResMinHfov) { | |||
minHfov = Math.min(minHfov, renderer.getCanvas().width / (config.multiRes.cubeResolution / 90 * 0.9)); | |||
} | |||
if (minHfov > config.maxHfov) { | |||
// Don't change view if bounds don't make sense | |||
console.log('HFOV bounds do not make sense (minHfov > maxHfov).') | |||
return config.hfov; | |||
} if (hfov < minHfov) { | |||
return minHfov; | |||
} | |||
var newHfov = config.hfov; | |||
if (hfov < minHfov) { | |||
newHfov = minHfov; | |||
} else if (hfov > config.maxHfov) { | |||
return config.maxHfov; | |||
newHfov = config.maxHfov; | |||
} else { | |||
return hfov; | |||
newHfov = hfov; | |||
} | |||
// Optionally avoid showing background (empty space) on top or bottom by adapting newHfov | |||
if (config.avoidShowingBackground && renderer) { | |||
var canvas = renderer.getCanvas(); | |||
newHfov = Math.min(newHfov, | |||
Math.atan(Math.tan((config.maxPitch - config.minPitch) / 360 * Math.PI) / | |||
canvas.height * canvas.width) | |||
* 360 / Math.PI); | |||
} | |||
return newHfov; | |||
} | |||
/** | |||
@@ -2127,6 +2188,7 @@ function constrainHfov(hfov) { | |||
*/ | |||
function setHfov(hfov) { | |||
config.hfov = constrainHfov(hfov); | |||
fireEvent('zoomchange', config.hfov); | |||
} | |||
/** | |||
@@ -2148,6 +2210,7 @@ function load() { | |||
// since it is a new scene and the error from previous maybe because of lacking | |||
// memory etc and not because of a lack of WebGL support etc | |||
clearError(); | |||
loaded = false; | |||
controls.load.style.display = 'none'; | |||
infoDisplay.load.box.style.display = 'inline'; | |||
@@ -2228,6 +2291,13 @@ function loadScene(sceneId, targetPitch, targetYaw, targetHfov, fadeDone) { | |||
} | |||
fireEvent('scenechange', sceneId); | |||
load(); | |||
// Properly handle switching to dynamic scenes | |||
update = config.dynamicUpdate === true; | |||
if (config.dynamic) { | |||
panoImage = config.panorama; | |||
onImageLoad(); | |||
} | |||
} | |||
/** | |||
@@ -2269,13 +2339,41 @@ function escapeHTML(s) { | |||
} | |||
/** | |||
* Removes possibility of XSS attacks with URLs. | |||
* The URL cannot be of protocol 'javascript'. | |||
* @private | |||
* @param {string} url - URL to sanitize | |||
* @returns {string} Sanitized URL | |||
*/ | |||
function sanitizeURL(url) { | |||
if (url.trim().toLowerCase().indexOf('javascript:') === 0) { | |||
return 'about:blank'; | |||
} | |||
return url; | |||
} | |||
/** | |||
* Removes possibility of XSS atacks with URLs for CSS. | |||
* The URL will be sanitized with `sanitizeURL()` and single quotes | |||
* and double quotes escaped. | |||
* @private | |||
* @param {string} url - URL to sanitize | |||
* @returns {string} Sanitized URL | |||
*/ | |||
function sanitizeURLForCss(url) { | |||
return sanitizeURL(url) | |||
.replace(/"/g, '%22') | |||
.replace(/'/g, '%27'); | |||
} | |||
/** | |||
* Checks whether or not a panorama is loaded. | |||
* @memberof Viewer | |||
* @instance | |||
* @returns {boolean} `true` if a panorama is loaded, else `false` | |||
*/ | |||
this.isLoaded = function() { | |||
return loaded; | |||
return Boolean(loaded); | |||
}; | |||
/** | |||
@@ -2299,16 +2397,22 @@ this.getPitch = function() { | |||
* @returns {Viewer} `this` | |||
*/ | |||
this.setPitch = function(pitch, animated, callback, callbackArgs) { | |||
latestInteraction = Date.now(); | |||
if (Math.abs(pitch - config.pitch) <= eps) { | |||
if (typeof callback == 'function') | |||
callback(callbackArgs); | |||
return this; | |||
} | |||
animated = animated == undefined ? 1000: Number(animated); | |||
if (animated) { | |||
animatedMove.pitch = { | |||
'startTime': Date.now(), | |||
'startPosition': config.pitch, | |||
'endPosition': pitch, | |||
'duration': animated, | |||
'callback': callback, | |||
'callbackArgs': callbackArgs | |||
'duration': animated | |||
} | |||
if (typeof callback == 'function') | |||
setTimeout(function(){callback(callbackArgs)}, animated); | |||
} else { | |||
config.pitch = pitch; | |||
} | |||
@@ -2360,6 +2464,12 @@ this.getYaw = function() { | |||
* @returns {Viewer} `this` | |||
*/ | |||
this.setYaw = function(yaw, animated, callback, callbackArgs) { | |||
latestInteraction = Date.now(); | |||
if (Math.abs(yaw - config.yaw) <= eps) { | |||
if (typeof callback == 'function') | |||
callback(callbackArgs); | |||
return this; | |||
} | |||
animated = animated == undefined ? 1000: Number(animated); | |||
yaw = ((yaw + 180) % 360) - 180 // Keep in bounds | |||
if (animated) { | |||
@@ -2373,10 +2483,10 @@ this.setYaw = function(yaw, animated, callback, callbackArgs) { | |||
'startTime': Date.now(), | |||
'startPosition': config.yaw, | |||
'endPosition': yaw, | |||
'duration': animated, | |||
'callback': callback, | |||
'callbackArgs': callbackArgs | |||
'duration': animated | |||
} | |||
if (typeof callback == 'function') | |||
setTimeout(function(){callback(callbackArgs)}, animated); | |||
} else { | |||
config.yaw = yaw; | |||
} | |||
@@ -2428,16 +2538,22 @@ this.getHfov = function() { | |||
* @returns {Viewer} `this` | |||
*/ | |||
this.setHfov = function(hfov, animated, callback, callbackArgs) { | |||
latestInteraction = Date.now(); | |||
if (Math.abs(hfov - config.hfov) <= eps) { | |||
if (typeof callback == 'function') | |||
callback(callbackArgs); | |||
return this; | |||
} | |||
animated = animated == undefined ? 1000: Number(animated); | |||
if (animated) { | |||
animatedMove.hfov = { | |||
'startTime': Date.now(), | |||
'startPosition': config.hfov, | |||
'endPosition': constrainHfov(hfov), | |||
'duration': animated, | |||
'callback': callback, | |||
'callbackArgs': callbackArgs | |||
'duration': animated | |||
} | |||
if (typeof callback == 'function') | |||
setTimeout(function(){callback(callbackArgs)}, animated); | |||
} else { | |||
setHfov(hfov); | |||
} | |||
@@ -2483,16 +2599,20 @@ this.setHfovBounds = function(bounds) { | |||
*/ | |||
this.lookAt = function(pitch, yaw, hfov, animated, callback, callbackArgs) { | |||
animated = animated == undefined ? 1000: Number(animated); | |||
if (pitch !== undefined) { | |||
if (pitch !== undefined && Math.abs(pitch - config.pitch) > eps) { | |||
this.setPitch(pitch, animated, callback, callbackArgs); | |||
callback = undefined; | |||
} | |||
if (yaw !== undefined) { | |||
if (yaw !== undefined && Math.abs(yaw - config.yaw) > eps) { | |||
this.setYaw(yaw, animated, callback, callbackArgs); | |||
callback = undefined; | |||
} | |||
if (hfov !== undefined) | |||
if (hfov !== undefined && Math.abs(hfov - config.hfov) > eps) { | |||
this.setHfov(hfov, animated, callback, callbackArgs); | |||
callback = undefined; | |||
} | |||
if (typeof callback == 'function') | |||
callback(callbackArgs); | |||
return this; | |||
} | |||
@@ -2596,6 +2716,16 @@ this.stopAutoRotate = function() { | |||
}; | |||
/** | |||
* Stops all movement. | |||
* @memberof Viewer | |||
* @instance | |||
*/ | |||
this.stopMovement = function() { | |||
stopAnimation(); | |||
speed = {'yaw': 0, 'pitch': 0, 'hfov': 0}; | |||
} | |||
/** | |||
* Returns the panorama renderer. | |||
* @memberof Viewer | |||
* @instance | |||
@@ -2643,7 +2773,7 @@ this.mouseEventToCoords = function(event) { | |||
* @returns {Viewer} `this` | |||
*/ | |||
this.loadScene = function(sceneId, pitch, yaw, hfov) { | |||
if (loaded) | |||
if (loaded !== false) | |||
loadScene(sceneId, pitch, yaw, hfov); | |||
return this; | |||
} | |||
@@ -2707,6 +2837,16 @@ this.getConfig = function() { | |||
} | |||
/** | |||
* Get viewer's container element. | |||
* @memberof Viewer | |||
* @instance | |||
* @returns {HTMLElement} Container `div` element | |||
*/ | |||
this.getContainer = function() { | |||
return container; | |||
} | |||
/** | |||
* Add a new hot spot. | |||
* @memberof Viewer | |||
* @instance | |||
@@ -2747,26 +2887,43 @@ this.addHotSpot = function(hs, sceneId) { | |||
* @memberof Viewer | |||
* @instance | |||
* @param {string} hotSpotId - The ID of the hot spot | |||
* @param {string} [sceneId] - Removes hot spot from specified scene if provided, else from current scene | |||
* @returns {boolean} True if deletion is successful, else false | |||
*/ | |||
this.removeHotSpot = function(hotSpotId) { | |||
if (!config.hotSpots) | |||
return false; | |||
for (var i = 0; i < config.hotSpots.length; i++) { | |||
if (config.hotSpots[i].hasOwnProperty('id') && | |||
config.hotSpots[i].id === hotSpotId) { | |||
// Delete hot spot DOM elements | |||
var current = config.hotSpots[i].div; | |||
while (current.parentNode != renderContainer) | |||
current = current.parentNode; | |||
renderContainer.removeChild(current); | |||
delete config.hotSpots[i].div; | |||
// Remove hot spot from configuration | |||
config.hotSpots.splice(i, 1); | |||
return true; | |||
this.removeHotSpot = function(hotSpotId, sceneId) { | |||
if (sceneId === undefined || config.scene == sceneId) { | |||
if (!config.hotSpots) | |||
return false; | |||
for (var i = 0; i < config.hotSpots.length; i++) { | |||
if (config.hotSpots[i].hasOwnProperty('id') && | |||
config.hotSpots[i].id === hotSpotId) { | |||
// Delete hot spot DOM elements | |||
var current = config.hotSpots[i].div; | |||
while (current.parentNode != renderContainer) | |||
current = current.parentNode; | |||
renderContainer.removeChild(current); | |||
delete config.hotSpots[i].div; | |||
// Remove hot spot from configuration | |||
config.hotSpots.splice(i, 1); | |||
return true; | |||
} | |||
} | |||
} else { | |||
if (initialConfig.scenes.hasOwnProperty(sceneId)) { | |||
if (!initialConfig.scenes[sceneId].hasOwnProperty('hotSpots')) | |||
return false; | |||
for (var i = 0; i < initialConfig.scenes[sceneId].hotSpots.length; i++) { | |||
if (initialConfig.scenes[sceneId].hotSpots[i].hasOwnProperty('id') && | |||
initialConfig.scenes[sceneId].hotSpots[i].id === hotSpotId) { | |||
// Remove hot spot from configuration | |||
initialConfig.scenes[sceneId].hotSpots.splice(i, 1); | |||
return true; | |||
} | |||
} | |||
} else { | |||
return false; | |||
} | |||
} | |||
return false; | |||
} | |||
/** | |||
@@ -2775,7 +2932,8 @@ this.removeHotSpot = function(hotSpotId) { | |||
* @instance | |||
*/ | |||
this.resize = function() { | |||
onDocumentResize(); | |||
if (renderer) | |||
onDocumentResize(); | |||
} | |||
/** | |||
@@ -2818,6 +2976,16 @@ this.startOrientation = function() { | |||
} | |||
/** | |||
* Check if device orientation control is currently activated. | |||
* @memberof Viewer | |||
* @instance | |||
* @returns {boolean} True if active, else false | |||
*/ | |||
this.isOrientationActive = function() { | |||
return Boolean(orientation); | |||
} | |||
/** | |||
* Subscribe listener to specified event. | |||
* @memberof Viewer | |||
* @instance | |||
@@ -2882,14 +3050,10 @@ function fireEvent(type) { | |||
*/ | |||
this.destroy = function() { | |||
if (renderer) | |||
renderer.destroy() | |||
renderer.destroy(); | |||
if (listenersAdded) { | |||
dragFix.removeEventListener('mousedown', onDocumentMouseDown, false); | |||
dragFix.removeEventListener('dblclick', onDocumentDoubleClick, false); | |||
document.removeEventListener('mousemove', onDocumentMouseMove, false); | |||
document.removeEventListener('mouseup', onDocumentMouseUp, false); | |||
container.removeEventListener('mousewheel', onDocumentMouseWheel, false); | |||
container.removeEventListener('DOMMouseScroll', onDocumentMouseWheel, false); | |||
container.removeEventListener('mozfullscreenchange', onFullScreenChange, false); | |||
container.removeEventListener('webkitfullscreenchange', onFullScreenChange, false); | |||
container.removeEventListener('msfullscreenchange', onFullScreenChange, false); | |||
@@ -2900,18 +3064,9 @@ this.destroy = function() { | |||
container.removeEventListener('keyup', onDocumentKeyUp, false); | |||
container.removeEventListener('blur', clearKeys, false); | |||
document.removeEventListener('mouseleave', onDocumentMouseUp, false); | |||
dragFix.removeEventListener('touchstart', onDocumentTouchStart, false); | |||
dragFix.removeEventListener('touchmove', onDocumentTouchMove, false); | |||
dragFix.removeEventListener('touchend', onDocumentTouchEnd, false); | |||
dragFix.removeEventListener('pointerdown', onDocumentPointerDown, false); | |||
dragFix.removeEventListener('pointermove', onDocumentPointerMove, false); | |||
dragFix.removeEventListener('pointerup', onDocumentPointerUp, false); | |||
dragFix.removeEventListener('pointerleave', onDocumentPointerUp, false); | |||
} | |||
container.innerHTML = ''; | |||
container.classList.remove('pnlm-container'); | |||
uiContainer.classList.remove('pnlm-grab'); | |||
uiContainer.classList.remove('pnlm-grabbing'); | |||
} | |||
} | |||
@@ -1,7 +1,9 @@ | |||
function anError(error) { | |||
var errorMsg = document.createElement('div'); | |||
errorMsg.className = 'pnlm-info-box'; | |||
errorMsg.innerHTML = '<p>' + error + '</p>'; | |||
var p = document.createElement('p'); | |||
p.textContent = error; | |||
errorMsg.appendChild(p); | |||
document.getElementById('container').appendChild(errorMsg); | |||
} | |||
@@ -10,17 +12,16 @@ function parseURLParameters() { | |||
var URL; | |||
if (window.location.hash.length > 0) { | |||
// Prefered method since parameters aren't sent to server | |||
URL = [window.location.hash.slice(1)]; | |||
URL = window.location.hash.slice(1); | |||
} else { | |||
URL = decodeURI(window.location.href).split('?'); | |||
URL.shift(); | |||
URL = window.location.search.slice(1); | |||
} | |||
if (URL.length < 1) { | |||
if (!URL) { | |||
// Display error if no configuration parameters are specified | |||
anError('No configuration options were specified.'); | |||
return; | |||
} | |||
URL = URL[0].split('&'); | |||
URL = URL.split('&'); | |||
var configFromURL = {}; | |||
for (var i = 0; i < URL.length; i++) { | |||
var option = URL[i].split('=')[0]; | |||
@@ -57,7 +58,7 @@ function parseURLParameters() { | |||
// Display error if JSON can't be loaded | |||
var a = document.createElement('a'); | |||
a.href = configFromURL.config; | |||
a.innerHTML = a.href; | |||
a.textContent = a.href; | |||
anError('The file ' + a.outerHTML + ' could not be accessed.'); | |||
return; | |||
} | |||
@@ -4,7 +4,8 @@ | |||
# and nona (from Hugin) | |||
# generate.py - A multires tile set generator for Pannellum | |||
# Copyright (c) 2014-2017 Matthew Petroff | |||
# Extensions to cylindrical input and partial panoramas by David von Oheimb | |||
# Copyright (c) 2014-2018 Matthew Petroff | |||
# | |||
# Permission is hereby granted, free of charge, to any person obtaining a copy | |||
# of this software and associated documentation files (the "Software"), to deal | |||
@@ -31,9 +32,14 @@ from PIL import Image | |||
import os | |||
import sys | |||
import math | |||
import ast | |||
from distutils.spawn import find_executable | |||
import subprocess | |||
# Allow large images (this could lead to a denial of service attach if you're | |||
# running this script on user-submitted images.) | |||
Image.MAX_IMAGE_PIXELS = None | |||
# Find external programs | |||
try: | |||
nona = find_executable('nona') | |||
@@ -42,16 +48,36 @@ except KeyError: | |||
nona = None | |||
# Parse input | |||
parser = argparse.ArgumentParser(description='Generate a Pannellum multires tile set from an full equirectangular panorama.', | |||
parser = argparse.ArgumentParser(description='Generate a Pannellum multires tile set from a full or partial equirectangular or cylindrical panorama.', | |||
formatter_class=argparse.ArgumentDefaultsHelpFormatter) | |||
parser.add_argument('inputFile', metavar='INPUT', | |||
help='full equirectangular panorama to be processed') | |||
help='panorama to be processed') | |||
parser.add_argument('-C', '--cylindrical', action='store_true', | |||
help='input projection is cylindrical (default is equirectangular)') | |||
parser.add_argument('-H', '--haov', dest='haov', default=-1, type=float, | |||
help='horizontal angle of view (defaults to 360.0 for full panorama)') | |||
parser.add_argument('-F', '--hfov', dest='hfov', default=100.0, type=float, | |||
help='starting horizontal field of view (defaults to 100.0)') | |||
parser.add_argument('-V', '--vaov', dest='vaov', default=-1, type=float, | |||
help='vertical angle of view (defaults to 180.0 for full panorama)') | |||
parser.add_argument('-O', '--voffset', dest='vOffset', default=0.0, type=float, | |||
help='starting pitch position (defaults to 0.0)') | |||
parser.add_argument('-e', '--horizon', dest='horizon', default=0.0, type=int, | |||
help='offset of the horizon in pixels (negative if above middle, defaults to 0)') | |||
parser.add_argument('-o', '--output', dest='output', default='./output', | |||
help='output directory') | |||
help='output directory, optionally to be used as basePath (defaults to "./output")') | |||
parser.add_argument('-s', '--tilesize', dest='tileSize', default=512, type=int, | |||
help='tile size in pixels') | |||
parser.add_argument('-f', '--fallbacksize', dest='fallbackSize', default=1024, type=int, | |||
help='fallback tile size in pixels (defaults to 1024)') | |||
parser.add_argument('-c', '--cubesize', dest='cubeSize', default=0, type=int, | |||
help='cube size in pixels, or 0 to retain all details') | |||
parser.add_argument('-b', '--backgroundcolor', dest='backgroundColor', default="[0.0, 0.0, 0.0]", type=str, | |||
help='RGB triple of values [0, 1] defining background color shown past the edges of a partial panorama (defaults to "[0.0, 0.0, 0.0]")') | |||
parser.add_argument('-B', '--avoidbackground', action='store_true', | |||
help='viewer should limit view to avoid showing background, so using --backgroundcolor is not needed') | |||
parser.add_argument('-a', '--autoload', action='store_true', | |||
help='automatically load panorama in viewer') | |||
parser.add_argument('-q', '--quality', dest='quality', default=75, type=int, | |||
help='output JPEG quality 0-100') | |||
parser.add_argument('--png', action='store_true', | |||
@@ -59,42 +85,75 @@ parser.add_argument('--png', action='store_true', | |||
parser.add_argument('-n', '--nona', default=nona, required=nona is None, | |||
metavar='EXECUTABLE', | |||
help='location of the nona executable to use') | |||
parser.add_argument('-G', '--gpu', action='store_true', | |||
help='perform image remapping by nona on the GPU') | |||
parser.add_argument('-d', '--debug', action='store_true', | |||
help='debug mode (print status info and keep intermediate files)') | |||
args = parser.parse_args() | |||
# Create output directory | |||
if os.path.exists(args.output): | |||
print('Output directory "' + args.output + '" already exists') | |||
if not args.debug: | |||
sys.exit(1) | |||
else: | |||
os.makedirs(args.output) | |||
# Process input image information | |||
print('Processing input image information...') | |||
origWidth, origHeight = Image.open(args.inputFile).size | |||
if float(origWidth) / origHeight != 2: | |||
print('Error: the image width is not twice the image height.') | |||
print('Input image must be a full, not partial, equirectangular panorama!') | |||
sys.exit(1) | |||
haov = args.haov | |||
if haov == -1: | |||
if args.cylindrical or float(origWidth) / origHeight == 2: | |||
print('Assuming --haov 360.0') | |||
haov = 360.0 | |||
else: | |||
print('Unless given the --haov option, equirectangular input image must be a full (not partial) panorama!') | |||
sys.exit(1) | |||
vaov = args.vaov | |||
if vaov == -1: | |||
if args.cylindrical or float(origWidth) / origHeight == 2: | |||
print('Assuming --vaov 180.0') | |||
vaov = 180.0 | |||
else: | |||
print('Unless given the --vaov option, equirectangular input image must be a full (not partial) panorama!') | |||
sys.exit(1) | |||
if args.cubeSize != 0: | |||
cubeSize = args.cubeSize | |||
else: | |||
cubeSize = 8 * int(origWidth / math.pi / 8) | |||
levels = int(math.ceil(math.log(float(cubeSize) / args.tileSize, 2))) + 1 | |||
cubeSize = 8 * int((360 / haov) * origWidth / math.pi / 8) | |||
tileSize = min(args.tileSize, cubeSize) | |||
levels = int(math.ceil(math.log(float(cubeSize) / tileSize, 2))) + 1 | |||
origHeight = str(origHeight) | |||
origWidth = str(origWidth) | |||
origFilename = os.path.join(os.getcwd(), args.inputFile) | |||
extension = '.jpg' | |||
if args.png: | |||
extension = '.png' | |||
partialPano = True if args.haov != -1 and args.vaov != -1 else False | |||
colorList = ast.literal_eval(args.backgroundColor) | |||
colorTuple = (int(colorList[0]*255), int(colorList[1]*255), int(colorList[2]*255)) | |||
# Create output directory | |||
os.makedirs(args.output) | |||
if args.debug: | |||
print('maxLevel: '+ str(levels)) | |||
print('tileResolution: '+ str(tileSize)) | |||
print('cubeResolution: '+ str(cubeSize)) | |||
# Generate PTO file for nona to generate cube faces | |||
# Face order: front, back, up, down, left, right | |||
faceLetters = ['f', 'b', 'u', 'd', 'l', 'r'] | |||
projection = "f1" if args.cylindrical else "f4" | |||
pitch = 0 | |||
text = [] | |||
text.append('p E0 R0 f0 h' + str(cubeSize) + ' n"TIFF_m" u0 v90 w' + str(cubeSize)) | |||
facestr = 'i a0 b0 c0 d0 e'+ str(args.horizon) +' '+ projection + ' h' + origHeight +' w'+ origWidth +' n"'+ origFilename +'" r0 v' + str(haov) | |||
text.append('p E0 R0 f0 h' + str(cubeSize) + ' w' + str(cubeSize) + ' n"TIFF_m" u0 v90') | |||
text.append('m g1 i0 m2 p0.00784314') | |||
text.append('i a0 b0 c0 d0 e0 f4 h' + origHeight + ' n"' + origFilename + '" p0 r0 v360 w' + origWidth + ' y0') | |||
text.append('i a0 b0 c0 d0 e0 f4 h' + origHeight + ' n"' + origFilename + '" p0 r0 v360 w' + origWidth + ' y180') | |||
text.append('i a0 b0 c0 d0 e0 f4 h' + origHeight + ' n"' + origFilename + '" p-90 r0 v360 w' + origWidth + ' y0') | |||
text.append('i a0 b0 c0 d0 e0 f4 h' + origHeight + ' n"' + origFilename + '" p90 r0 v360 w' + origWidth + ' y0') | |||
text.append('i a0 b0 c0 d0 e0 f4 h' + origHeight + ' n"' + origFilename + '" p0 r0 v360 w' + origWidth + ' y90') | |||
text.append('i a0 b0 c0 d0 e0 f4 h' + origHeight + ' n"' + origFilename + '" p0 r0 v360 w' + origWidth + ' y-90') | |||
text.append(facestr +' p' + str(pitch+ 0) +' y0' ) | |||
text.append(facestr +' p' + str(pitch+ 0) +' y180') | |||
text.append(facestr +' p' + str(pitch-90) +' y0' ) | |||
text.append(facestr +' p' + str(pitch+90) +' y0' ) | |||
text.append(facestr +' p' + str(pitch+ 0) +' y90' ) | |||
text.append(facestr +' p' + str(pitch+ 0) +' y-90') | |||
text.append('v') | |||
text.append('*') | |||
text = '\n'.join(text) | |||
@@ -103,65 +162,90 @@ with open(os.path.join(args.output, 'cubic.pto'), 'w') as f: | |||
# Create cube faces | |||
print('Generating cube faces...') | |||
subprocess.check_call([args.nona, '-o', os.path.join(args.output, 'face'), os.path.join(args.output, 'cubic.pto')]) | |||
subprocess.check_call([args.nona, ('-g' if args.gpu else '-d') , '-o', os.path.join(args.output, 'face'), os.path.join(args.output, 'cubic.pto')]) | |||
faces = ['face0000.tif', 'face0001.tif', 'face0002.tif', 'face0003.tif', 'face0004.tif', 'face0005.tif'] | |||
# Generate tiles | |||
print('Generating tiles...') | |||
for f in range(0, 6): | |||
size = cubeSize | |||
face = Image.open(os.path.join(args.output, faces[f])) | |||
if 'A' in face.mode: | |||
if face.mode == 'RGBA': | |||
face = face.convert('RGB') | |||
elif face.mode == 'LA': | |||
face = face.convert('L') | |||
for level in range(levels, 0, -1): | |||
if not os.path.exists(os.path.join(args.output, str(level))): | |||
os.makedirs(os.path.join(args.output, str(level))) | |||
tiles = int(math.ceil(float(size) / args.tileSize)) | |||
if (level < levels): | |||
face = face.resize([size, size], Image.ANTIALIAS) | |||
for i in range(0, tiles): | |||
for j in range(0, tiles): | |||
left = j * args.tileSize | |||
upper = i * args.tileSize | |||
right = min(j * args.tileSize + args.tileSize, size) | |||
lower = min(i * args.tileSize + args.tileSize, size) | |||
tile = face.crop([left, upper, right, lower]) | |||
tile.load() | |||
tile.save(os.path.join(args.output, str(level), faceLetters[f] + str(i) + '_' + str(j) + extension), quality = args.quality) | |||
size = int(size / 2) | |||
faceExists = os.path.exists(os.path.join(args.output, faces[f])) | |||
if faceExists: | |||
face = Image.open(os.path.join(args.output, faces[f])) | |||
for level in range(levels, 0, -1): | |||
if not os.path.exists(os.path.join(args.output, str(level))): | |||
os.makedirs(os.path.join(args.output, str(level))) | |||
tiles = int(math.ceil(float(size) / tileSize)) | |||
if (level < levels): | |||
face = face.resize([size, size], Image.ANTIALIAS) | |||
for i in range(0, tiles): | |||
for j in range(0, tiles): | |||
left = j * tileSize | |||
upper = i * tileSize | |||
right = min(j * args.tileSize + args.tileSize, size) # min(...) not really needed | |||
lower = min(i * args.tileSize + args.tileSize, size) # min(...) not really needed | |||
tile = face.crop([left, upper, right, lower]) | |||
if args.debug: | |||
print('level: '+ str(level) + ' tiles: '+ str(tiles) + ' tileSize: ' + str(tileSize) + ' size: '+ str(size)) | |||
print('left: '+ str(left) + ' upper: '+ str(upper) + ' right: '+ str(right) + ' lower: '+ str(lower)) | |||
colors = tile.getcolors(1) | |||
if not partialPano or colors == None or colors[0][1] != colorTuple: | |||
# More than just one color (the background), i.e., non-empty tile | |||
if tile.mode in ('RGBA', 'LA'): | |||
background = Image.new(tile.mode[:-1], tile.size, colorTuple) | |||
background.paste(tile, tile.split()[-1]) | |||
tile = background | |||
tile.save(os.path.join(args.output, str(level), faceLetters[f] + str(i) + '_' + str(j) + extension), quality=args.quality) | |||
size = int(size / 2) | |||
# Generate fallback tiles | |||
print('Generating fallback tiles...') | |||
for f in range(0, 6): | |||
if not os.path.exists(os.path.join(args.output, 'fallback')): | |||
os.makedirs(os.path.join(args.output, 'fallback')) | |||
face = Image.open(os.path.join(args.output, faces[f])) | |||
if 'A' in face.mode: | |||
if face.mode == 'RGBA': | |||
face = face.convert('RGB') | |||
elif face.mode == 'LA': | |||
face = face.convert('L') | |||
face = face.resize([1024, 1024], Image.ANTIALIAS) | |||
face.save(os.path.join(args.output, 'fallback', faceLetters[f] + extension), quality = args.quality) | |||
if os.path.exists(os.path.join(args.output, faces[f])): | |||
face = Image.open(os.path.join(args.output, faces[f])) | |||
if face.mode in ('RGBA', 'LA'): | |||
background = Image.new(face.mode[:-1], face.size, colorTuple) | |||
background.paste(face, face.split()[-1]) | |||
face = background | |||
face = face.resize([args.fallbackSize, args.fallbackSize], Image.ANTIALIAS) | |||
face.save(os.path.join(args.output, 'fallback', faceLetters[f] + extension), quality = args.quality) | |||
# Clean up temporary files | |||
os.remove(os.path.join(args.output, 'cubic.pto')) | |||
for face in faces: | |||
os.remove(os.path.join(args.output, face)) | |||
if not args.debug: | |||
os.remove(os.path.join(args.output, 'cubic.pto')) | |||
for face in faces: | |||
if os.path.exists(os.path.join(args.output, face)): | |||
os.remove(os.path.join(args.output, face)) | |||
# Generate config file | |||
text = [] | |||
text.append('{') | |||
text.append(' "hfov": ' + str(args.hfov)+ ',') | |||
if haov < 360: | |||
text.append(' "haov": ' + str(haov)+ ',') | |||
text.append(' "minYaw": ' + str(-haov/2+0)+ ',') | |||
text.append(' "yaw": ' + str(-haov/2+args.hfov/2)+ ',') | |||
text.append(' "maxYaw": ' + str(+haov/2+0)+ ',') | |||
if vaov < 180: | |||
text.append(' "vaov": ' + str(vaov)+ ',') | |||
text.append(' "vOffset": ' + str(args.vOffset)+ ',') | |||
text.append(' "minPitch": ' + str(-vaov/2+args.vOffset)+ ',') | |||
text.append(' "pitch": ' + str( args.vOffset)+ ',') | |||
text.append(' "maxPitch": ' + str(+vaov/2+args.vOffset)+ ',') | |||
if colorTuple != (0, 0, 0): | |||
text.append(' "backgroundColor": "' + args.backgroundColor+ '",') | |||
if args.avoidbackground: | |||
text.append(' "avoidShowingBackground": true,') | |||
if args.autoload: | |||
text.append(' "autoLoad": true,') | |||
text.append(' "type": "multires",') | |||
text.append(' ') | |||
text.append(' "multiRes": {') | |||
text.append(' "path": "/%l/%s%y_%x",') | |||
text.append(' "fallbackPath": "/fallback/%s",') | |||
text.append(' "extension": "' + extension[1:] + '",') | |||
text.append(' "tileResolution": ' + str(args.tileSize) + ',') | |||
text.append(' "tileResolution": ' + str(tileSize) + ',') | |||
text.append(' "maxLevel": ' + str(levels) + ',') | |||
text.append(' "cubeResolution": ' + str(cubeSize)) | |||
text.append(' }') | |||
@@ -1,18 +1,20 @@ | |||
/* | |||
* Video.js plugin for Pannellum | |||
* Copyright (c) 2015-2017 Matthew Petroff | |||
* Copyright (c) 2015-2018 Matthew Petroff | |||
* MIT License | |||
*/ | |||
(function(document, videojs, pannellum) { | |||
'use strict'; | |||
videojs.plugin('pannellum', function(config) { | |||
var registerPlugin = videojs.registerPlugin || videojs.plugin; // Use registerPlugin for Video.js >= 6 | |||
registerPlugin('pannellum', function(config) { | |||
// Create Pannellum instance | |||
var player = this; | |||
var container = player.el(); | |||
var vid = container.getElementsByTagName('video')[0], | |||
pnlmContainer = document.createElement('div'); | |||
pnlmContainer.style.zIndex = '0'; | |||
config = config || {}; | |||
config.type = 'equirectangular'; | |||
config.dynamic = true; | |||