Merge branch 'master' into feature/drawing

# Conflicts:
#	package-lock.json
#	src/hub.html
#	src/hub.js
#	src/utils/media-utils.js
This commit is contained in:
Kevin Lee 2018-08-16 15:26:17 -07:00
Родитель fad6c173f2 5236b39e3a
Коммит 59b8cfe9d1
25 изменённых файлов: 3479 добавлений и 2836 удалений

5282
package-lock.json сгенерированный

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Просмотреть файл

@ -44,7 +44,7 @@
"jsonschema": "^1.2.2",
"jszip": "^3.1.5",
"moving-average": "^1.0.0",
"naf-janus-adapter": "^0.10.2",
"naf-janus-adapter": "^0.11.0",
"networked-aframe": "github:mozillareality/networked-aframe#mr-social-client/master",
"nipplejs": "github:mozillareality/nipplejs#mr-social-client/master",
"phoenix": "^1.3.0",
@ -69,6 +69,7 @@
"babel-preset-env": "^1.6.1",
"babel-preset-react": "^6.24.1",
"copy-webpack-plugin": "^4.5.1",
"cors": "^2.8.4",
"css-loader": "^1.0.0",
"dotenv": "^5.0.1",
"eslint": "^5.2.0",

Просмотреть файл

@ -0,0 +1,74 @@
@import 'shared';
:local(.add-media-form) {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
margin: 0;
}
:local(.action-button) {
@extend %bottom-action-button;
margin-left: 6px;
margin-right: 6px;
appearance: none;
width: 128px;
text-align: center;
-moz-appearance: none;
-webkit-appearance: none;
}
:local(.buttons) {
display: flex;
flex-direction: row;
align-items: center;
}
:local(.small-button) {
margin-left: 0.25em;
font-size: 2em;
align-self: center;
}
:local(.cancel-icon) {
color: white;
&:hover {
color: #FF3D7F
}
}
:local(.upload-icon) {
color: white;
&:hover {
color: #2F80ED;
}
}
:local(.input-border) {
display: flex;
border: 0.25em solid white;
border-radius: 1em;
margin: 1em;
padding: 0.5em 0.75em;
width: 100%;
box-sizing: border-box;
@extend %default-font;
}
:local(.left-side-of-input) {
flex-grow: 1;
border: none;
white-space: nowrap;
background: transparent;
color: white;
font-size: 1.2em;
align-self: center;
overflow: hidden;
text-overflow: ellipsis;
}
:local(.hide-file-input) {
visibility: hidden;
position: absolute;
}

Просмотреть файл

@ -75,6 +75,10 @@
}
}
.dialog__box__contents__body p:first-child {
margin-top: 0;
}
.invite-form, .add-media-form, .custom-scene-form {
display: flex;
flex-direction: column;

Просмотреть файл

@ -72,7 +72,7 @@
pointer-events: auto;
}
:local(.invite-nag-button) {
:local(.nag-button) {
position: absolute;
top: 110px;
left: 0;

Просмотреть файл

@ -22,6 +22,7 @@
"entry.invite-others": "invite others",
"entry.invite-others-nag": "invite others to join",
"entry.enable-screen-sharing": "Share my desktop",
"entry.return-to-vr": "Enter in VR",
"profile.save": "save",
"profile.display_name.validation_warning": "Alphanumerics and hyphens. At least 3 characters, no more than 32",
"profile.header": "Your display name:",

Просмотреть файл

@ -0,0 +1,27 @@
AFRAME.registerComponent("ambient-light", {
schema: {
color: { type: "color" },
intensity: { default: 1.0 }
},
init() {
const el = this.el;
this.light = new THREE.AmbientLight();
this.el.setObject3D("ambient-light", this.light);
this.el.sceneEl.systems.light.registerLight(el);
},
update(prevData) {
if (this.data.color !== prevData.color) {
this.light.color.set(this.data.color);
}
if (this.data.intensity !== prevData.intensity) {
this.light.intensity = this.data.intensity;
}
},
remove: function() {
this.el.removeObject3D("ambient-light");
}
});

Просмотреть файл

@ -0,0 +1,35 @@
AFRAME.registerComponent("directional-light", {
schema: {
color: { type: "color" },
intensity: { default: 1.0 },
castShadow: { default: true }
},
init() {
const el = this.el;
this.light = new THREE.DirectionalLight();
this.light.position.set(0, 0, 0);
this.light.target.position.set(0, 0, 1);
this.light.add(this.light.target);
this.el.setObject3D("directional-light", this.light);
this.el.sceneEl.systems.light.registerLight(el);
},
update(prevData) {
if (this.data.color !== prevData.color) {
this.light.color.set(this.data.color);
}
if (this.data.intensity !== prevData.intensity) {
this.light.intensity = this.data.intensity;
}
if (this.data.castShadow !== prevData.castShadow) {
this.light.castShadow = this.data.castShadow;
}
},
remove: function() {
this.el.removeObject3D("directional-light");
}
});

Просмотреть файл

@ -215,19 +215,22 @@ async function resolveGLTFUri(gltfProperty, basePath) {
if (url.protocol === "blob:") {
gltfProperty.uri = url.href;
} else {
const { raw } = await resolveMedia(url.href);
const { raw } = await resolveMedia(url.href, true);
gltfProperty.uri = raw;
}
}
async function loadGLTF(src, preferredTechnique, onProgress) {
const { raw, origin, contentType } = await resolveMedia(src);
async function loadGLTF(src, contentType, preferredTechnique, onProgress) {
const resolved = await resolveMedia(src);
const raw = resolved.raw;
const origin = resolved.origin;
contentType = contentType || resolved.contentType;
const basePath = THREE.LoaderUtils.extractUrlBase(origin);
let gltfUrl = raw;
let fileMap;
if (contentType === "model/gltf+zip") {
if (contentType.includes("model/gltf+zip") || contentType.includes("application/x-zip-compressed")) {
fileMap = await getFilesFromSketchfabZip(gltfUrl);
gltfUrl = fileMap["scene.gtlf"];
}
@ -247,13 +250,17 @@ async function loadGLTF(src, preferredTechnique, onProgress) {
if (images) {
for (const image of images) {
pendingFarsparkPromises.push(resolveGLTFUri(image, parser.options.path));
if (image.uri) {
pendingFarsparkPromises.push(resolveGLTFUri(image, parser.options.path));
}
}
}
if (buffers) {
for (const buffer of buffers) {
pendingFarsparkPromises.push(resolveGLTFUri(buffer, parser.options.path));
if (buffer.uri) {
pendingFarsparkPromises.push(resolveGLTFUri(buffer, parser.options.path));
}
}
}
@ -278,8 +285,6 @@ async function loadGLTF(src, preferredTechnique, onProgress) {
await Promise.all(pendingFarsparkPromises);
console.log(parser.json);
const gltf = await new Promise((resolve, reject) =>
parser.parse(
(scene, scenes, cameras, animations, json) => {
@ -317,6 +322,7 @@ async function loadGLTF(src, preferredTechnique, onProgress) {
AFRAME.registerComponent("gltf-model-plus", {
schema: {
src: { type: "string" },
contentType: { type: "string" },
inflate: { default: false }
},
@ -327,7 +333,7 @@ AFRAME.registerComponent("gltf-model-plus", {
},
update() {
this.applySrc(this.data.src);
this.applySrc(this.data.src, this.data.contentType);
},
loadTemplates() {
@ -338,7 +344,7 @@ AFRAME.registerComponent("gltf-model-plus", {
});
},
async applySrc(src) {
async applySrc(src, contentType) {
try {
// If the src attribute is a selector, get the url from the asset item.
if (src && src.charAt(0) === "#") {
@ -360,7 +366,7 @@ AFRAME.registerComponent("gltf-model-plus", {
const gltfPath = THREE.LoaderUtils.extractUrlBase(src);
if (!GLTFCache[src]) {
GLTFCache[src] = loadGLTF(src, this.preferredTechnique);
GLTFCache[src] = loadGLTF(src, contentType, this.preferredTechnique);
}
const model = cloneGltf(await GLTFCache[src]);

Просмотреть файл

@ -0,0 +1,33 @@
AFRAME.registerComponent("hemisphere-light", {
schema: {
skyColor: { type: "color" },
groundColor: { type: "color" },
intensity: { default: 1.0 }
},
init() {
const el = this.el;
this.light = new THREE.HemisphereLight();
this.light.position.set(0, 0, 0);
this.el.setObject3D("hemisphere-light", this.light);
this.el.sceneEl.systems.light.registerLight(el);
},
update(prevData) {
if (this.data.skyColor !== prevData.skyColor) {
this.light.color.set(this.data.skyColor);
}
if (this.data.groundColor !== prevData.groundColor) {
this.light.groundColor.set(this.data.groundColor);
}
if (this.data.intensity !== prevData.intensity) {
this.light.intensity = this.data.intensity;
}
},
remove: function() {
this.el.removeObject3D("hemisphere-light");
}
});

Просмотреть файл

@ -36,6 +36,41 @@ class GIFTexture extends THREE.Texture {
}
}
async function createGIFTexture(url) {
return new Promise((resolve, reject) => {
// TODO: pool workers
const worker = new GIFWorker();
worker.onmessage = e => {
const [success, frames, delays, disposals] = e.data;
if (!success) {
reject(`error loading gif: ${e.data[1]}`);
return;
}
let loadCnt = 0;
for (let i = 0; i < frames.length; i++) {
const img = new Image();
img.onload = e => {
loadCnt++;
frames[i] = e.target;
if (loadCnt === frames.length) {
const texture = new GIFTexture(frames, delays, disposals);
texture.image.src = url;
resolve(texture);
}
};
img.src = frames[i];
}
};
fetch(url, { mode: "cors" })
.then(r => r.arrayBuffer())
.then(rawImageData => {
worker.postMessage(rawImageData, [rawImageData]);
})
.catch(reject);
});
}
/**
* Create video element to be used as a texture.
*
@ -53,8 +88,42 @@ function createVideoEl(src) {
return videoEl;
}
function createVideoTexture(url) {
return new Promise((resolve, reject) => {
const videoEl = createVideoEl(url);
const texture = new THREE.VideoTexture(videoEl);
texture.minFilter = THREE.LinearFilter;
videoEl.addEventListener("loadedmetadata", () => resolve(texture), { once: true });
videoEl.onerror = reject;
// If iOS and video is HLS, do some hacks.
if (
AFRAME.utils.device.isIOS() &&
AFRAME.utils.material.isHLS(
videoEl.src || videoEl.getAttribute("src"),
videoEl.type || videoEl.getAttribute("type")
)
) {
// Actually BGRA. Tell shader to correct later.
texture.format = THREE.RGBAFormat;
texture.needsCorrectionBGRA = true;
// Apparently needed for HLS. Tell shader to correct later.
texture.flipY = false;
texture.needsCorrectionFlipY = true;
}
});
}
const textureLoader = new THREE.TextureLoader();
textureLoader.setCrossOrigin("anonymous");
function createImageTexture(url) {
return new Promise((resolve, reject) => {
textureLoader.load(url, resolve, null, function(xhr) {
reject(`'${url}' could not be fetched (Error code: ${xhr.status}; Response: ${xhr.statusText})`);
});
});
}
const textureCache = new Map();
@ -74,138 +143,76 @@ AFRAME.registerComponent("image-plus", {
depth: { default: 0.05 }
},
remove() {
const material = this.el.getObject3D("mesh").material;
const texture = material.map;
releaseTexture(src) {
if (this.mesh && this.mesh.material.map !== errorTexture) {
this.mesh.material.map = null;
this.mesh.material.needsUpdate = true;
}
if (texture === errorTexture) return;
if (!textureCache.has(src)) return;
const url = texture.image.src;
const cacheItem = textureCache.get(url);
const cacheItem = textureCache.get(src);
cacheItem.count--;
if (cacheItem.count <= 0) {
// Unload the video element to prevent it from continuing to play in the background
if (texture.image instanceof HTMLVideoElement) {
const video = texture.image;
if (cacheItem.texture.image instanceof HTMLVideoElement) {
const video = cacheItem.texture.image;
video.pause();
video.src = "";
video.load();
}
texture.dispose();
cacheItem.texture.dispose();
// THREE never lets go of material refs, long running PR HERE https://github.com/mrdoob/three.js/pull/12464
// Mitigate the damage a bit by at least breaking the image ref so Image/Video elements can be freed
// TODO: If/when THREE gets fixed, we should be able to safely remove this
delete texture.image;
textureCache.delete(url);
textureCache.delete(src);
}
},
async loadGIF(url) {
return new Promise((resolve, reject) => {
// TODO: pool workers
const worker = new GIFWorker();
worker.onmessage = e => {
const [success, frames, delays, disposals] = e.data;
if (!success) {
reject(`error loading gif: ${e.data[1]}`);
return;
}
let loadCnt = 0;
for (let i = 0; i < frames.length; i++) {
const img = new Image();
img.onload = e => {
loadCnt++;
frames[i] = e.target;
if (loadCnt === frames.length) {
const texture = new GIFTexture(frames, delays, disposals);
texture.image.src = url;
resolve(texture);
}
};
img.src = frames[i];
}
};
fetch(url, { mode: "cors" })
.then(r => r.arrayBuffer())
.then(rawImageData => {
worker.postMessage(rawImageData, [rawImageData]);
})
.catch(reject);
});
remove() {
this.releaseTexture(this.data.src);
},
loadVideo(url) {
return new Promise((resolve, reject) => {
const videoEl = createVideoEl(url);
const texture = new THREE.VideoTexture(videoEl);
texture.minFilter = THREE.LinearFilter;
videoEl.addEventListener("loadedmetadata", () => resolve(texture), { once: true });
videoEl.onerror = reject;
// If iOS and video is HLS, do some hacks.
if (
this.el.sceneEl.isIOS &&
AFRAME.utils.material.isHLS(
videoEl.src || videoEl.getAttribute("src"),
videoEl.type || videoEl.getAttribute("type")
)
) {
// Actually BGRA. Tell shader to correct later.
texture.format = THREE.RGBAFormat;
texture.needsCorrectionBGRA = true;
// Apparently needed for HLS. Tell shader to correct later.
texture.flipY = false;
texture.needsCorrectionFlipY = true;
}
});
},
loadImage(url) {
return new Promise((resolve, reject) => {
textureLoader.load(url, resolve, null, function(xhr) {
reject(`'${url}' could not be fetched (Error code: ${xhr.status}; Response: ${xhr.statusText})`);
});
});
},
async update() {
async update(oldData) {
let texture;
try {
const url = this.data.src;
const contentType = this.data.contentType;
if (!url) {
return;
const { src, contentType } = this.data;
if (!src) return;
if (this.mesh) {
this.releaseTexture(oldData.src);
}
let cacheItem;
if (textureCache.has(url)) {
cacheItem = textureCache.get(url);
if (textureCache.has(src)) {
cacheItem = textureCache.get(src);
texture = cacheItem.texture;
cacheItem.count++;
} else {
cacheItem = { count: 1 };
if (url === "error") {
if (src === "error") {
texture = errorTexture;
} else if (contentType === "image/gif") {
texture = await this.loadGIF(url);
} else if (contentType.includes("image/gif")) {
texture = await createGIFTexture(src);
} else if (contentType.startsWith("image/")) {
texture = await this.loadImage(url);
texture = await createImageTexture(src);
} else if (contentType.startsWith("video/") || contentType.startsWith("audio/")) {
texture = await this.loadVideo(url);
texture = await createVideoTexture(src);
cacheItem.audioSource = this.el.sceneEl.audioListener.context.createMediaElementSource(texture.image);
} else {
throw new Error(`Unknown content type: ${contentType}`);
}
texture.encoding = THREE.sRGBEncoding;
texture.minFilter = THREE.LinearFilter;
cacheItem.texture = texture;
textureCache.set(url, cacheItem);
textureCache.set(src, cacheItem);
// No way to cancel promises, so if src has changed while we were creating the texture just throw it away.
if (this.data.src !== src) {
this.releaseTexture(src);
return;
}
}
if (cacheItem.audioSource) {
@ -218,22 +225,31 @@ AFRAME.registerComponent("image-plus", {
texture = errorTexture;
}
const material = new THREE.MeshBasicMaterial();
material.side = THREE.DoubleSide;
material.transparent = true;
material.map = texture;
material.needsUpdate = true;
material.map.needsUpdate = true;
const ratio =
(texture.image.videoHeight || texture.image.height || 1.0) /
(texture.image.videoWidth || texture.image.width || 1.0);
const width = Math.min(1.0, 1.0 / ratio);
const height = Math.min(1.0, ratio);
const geometry = new THREE.PlaneGeometry(width, height, 1, 1);
this.mesh = new THREE.Mesh(geometry, material);
if (!this.mesh) {
const material = new THREE.MeshBasicMaterial();
material.side = THREE.DoubleSide;
material.transparent = true;
material.map = texture;
material.needsUpdate = true;
const geometry = new THREE.PlaneGeometry();
this.mesh = new THREE.Mesh(geometry, material);
} else {
const { material } = this.mesh;
material.map = texture;
material.needsUpdate = true;
this.mesh.needsUpdate = true;
}
this.el.setObject3D("mesh", this.mesh);
this.mesh.scale.set(width, height, 1);
this.el.setAttribute("shape", {
shape: "box",
halfExtents: { x: width / 2, y: height / 2, z: this.data.depth }

Просмотреть файл

@ -1,14 +1,16 @@
import { getBox, getScaleCoefficient } from "../utils/auto-box-collider";
import { resolveMedia } from "../utils/media-utils";
import { resolveMedia, fetchMaxContentIndex } from "../utils/media-utils";
AFRAME.registerComponent("media-loader", {
schema: {
src: { type: "string" },
index: { type: "number" },
resize: { default: false }
},
init() {
this.onError = this.onError.bind(this);
this.showLoader = this.showLoader.bind(this);
},
setShapeAndScale(resize) {
@ -36,45 +38,83 @@ AFRAME.registerComponent("media-loader", {
},
onError() {
this.el.removeAttribute("gltf-model-plus");
this.el.removeAttribute("media-pager");
this.el.setAttribute("image-plus", { src: "error" });
clearTimeout(this.showLoaderTimeout);
delete this.showLoaderTimeout;
},
// TODO: correctly handle case where src changes
async update() {
showLoader() {
const loadingObj = new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshBasicMaterial());
this.el.setObject3D("mesh", loadingObj);
this.setShapeAndScale(true);
delete this.showLoaderTimeout;
},
async update(oldData) {
try {
const url = this.data.src;
const { src, index } = this.data;
this.showLoaderTimeout = setTimeout(() => {
const loadingObj = new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshBasicMaterial());
this.el.setObject3D("mesh", loadingObj);
this.setShapeAndScale(true);
}, 100);
if (src !== oldData.src && !this.showLoaderTimeout) {
this.showLoaderTimeout = setTimeout(this.showLoader, 100);
}
const { raw, contentType } = await resolveMedia(url);
if (!src) return;
if (contentType.startsWith("image/") || contentType.startsWith("video/") || contentType.startsWith("audio/")) {
const { raw, images, contentType } = await resolveMedia(src, false, index);
const isPDF = contentType.startsWith("application/pdf");
if (
contentType.startsWith("image/") ||
contentType.startsWith("video/") ||
contentType.startsWith("audio/") ||
isPDF
) {
this.el.removeAttribute("gltf-model-plus");
this.el.addEventListener(
"image-loaded",
() => {
async () => {
clearTimeout(this.showLoaderTimeout);
delete this.showLoaderTimeout;
if (isPDF) {
const maxIndex = await fetchMaxContentIndex(src, images.png);
this.el.setAttribute("media-pager", { index, maxIndex });
}
},
{ once: true }
);
this.el.setAttribute("image-plus", { src: raw, contentType });
this.el.setAttribute("position-at-box-shape-border", { target: ".delete-button", dirs: ["forward", "back"] });
} else if (contentType.startsWith("model/gltf") || url.endsWith(".gltf") || url.endsWith(".glb")) {
const imageSrc = isPDF ? images.png : raw;
const imageContentType = isPDF ? "image/png" : contentType;
if (!isPDF) {
this.el.removeAttribute("media-pager");
}
this.el.setAttribute("image-plus", { src: imageSrc, contentType: imageContentType });
this.el.setAttribute("position-at-box-shape-border", { dirs: ["forward", "back"] });
} else if (
contentType.includes("application/octet-stream") ||
contentType.includes("x-zip-compressed") ||
contentType.startsWith("model/gltf") ||
src.endsWith(".gltf") ||
src.endsWith(".glb")
) {
this.el.removeAttribute("image-plus");
this.el.removeAttribute("media-pager");
this.el.addEventListener(
"model-loaded",
() => {
clearTimeout(this.showLoaderTimeout);
delete this.showLoaderTimeout;
this.setShapeAndScale(this.data.resize);
},
{ once: true }
);
this.el.addEventListener("model-error", this.onError, { once: true });
this.el.setAttribute("gltf-model-plus", {
src: url, // gltf-model-plus expects the unresolved gltf url. The resolved farspark URL will be retrieved from the cache.
src,
contentType,
inflate: true
});
} else {
@ -86,3 +126,54 @@ AFRAME.registerComponent("media-loader", {
}
}
});
AFRAME.registerComponent("media-pager", {
schema: {
index: { type: "string" },
maxIndex: { type: "string" }
},
init() {
this.onNext = this.onNext.bind(this);
this.onPrev = this.onPrev.bind(this);
const template = document.getElementById("paging-toolbar");
this.el.appendChild(document.importNode(template.content, true));
this.toolbar = this.el.querySelector(".paging-toolbar");
// we have to wait a tick for the attach callbacks to get fired for the elements in a template
setTimeout(() => {
this.nextButton = this.el.querySelector(".next-button [text-button]");
this.prevButton = this.el.querySelector(".prev-button [text-button]");
this.pageLabel = this.el.querySelector(".page-label");
this.nextButton.addEventListener("click", this.onNext);
this.prevButton.addEventListener("click", this.onPrev);
this.update();
}, 0);
},
update() {
if (!this.pageLabel) return;
this.pageLabel.setAttribute("text", "value", `${this.data.index + 1}/${this.data.maxIndex + 1}`);
this.repositionToolbar();
},
remove() {
this.nextButton.removeEventListener("click", this.onNext);
this.prevButton.removeEventListener("click", this.onPrev);
this.el.removeChild(this.toolbar);
},
onNext() {
this.el.setAttribute("media-loader", "index", Math.min(this.data.index + 1, this.data.maxIndex));
},
onPrev() {
this.el.setAttribute("media-loader", "index", Math.max(this.data.index - 1, 0));
},
repositionToolbar() {
this.toolbar.object3D.position.y = -this.el.getAttribute("shape").halfExtents.y - 0.2;
}
});

Просмотреть файл

@ -38,9 +38,6 @@ AFRAME.registerComponent("offset-relative-to", {
}
obj.position.copy(offsetVector);
this.el.body && this.el.body.position.copy(obj.position);
// TODO: Hack here to deal with the fact that the rotation component mutates ordering, and we network rotation without sending ordering information
// See https://github.com/networked-aframe/networked-aframe/issues/134
obj.rotation.order = "YXZ";
target.getWorldQuaternion(obj.quaternion);
this.el.body && this.el.body.quaternion.copy(obj.quaternion);
if (this.data.selfDestruct) {

Просмотреть файл

@ -0,0 +1,38 @@
AFRAME.registerComponent("point-light", {
schema: {
color: { type: "color" },
intensity: { default: 1.0 },
range: { default: 0 },
castShadow: { default: true }
},
init() {
const el = this.el;
this.light = new THREE.PointLight();
this.light.decay = 2;
this.el.setObject3D("point-light", this.light);
this.el.sceneEl.systems.light.registerLight(el);
},
update(prevData) {
if (this.data.color !== prevData.color) {
this.light.color.set(this.data.color);
}
if (this.data.intensity !== prevData.intensity) {
this.light.intensity = this.data.intensity;
}
if (this.data.range !== prevData.range) {
this.light.distance = this.data.range;
}
if (this.data.castShadow !== prevData.castShadow) {
this.light.castShadow = this.data.castShadow;
}
},
remove: function() {
this.el.removeObject3D("point-light");
}
});

Просмотреть файл

@ -0,0 +1,47 @@
AFRAME.registerComponent("spot-light", {
schema: {
color: { type: "color" },
intensity: { default: 1.0 },
range: { default: 0 },
innerConeAngle: { default: 0 },
outerConeAngle: { default: Math.PI / 4.0 },
castShadow: { default: true }
},
init() {
const el = this.el;
this.light = new THREE.SpotLight();
this.light.target.position.set(0, 0, 1);
this.light.add(this.light.target);
this.light.decay = 2;
this.el.setObject3D("spot-light", this.light);
this.el.sceneEl.systems.light.registerLight(el);
},
update(prevData) {
if (this.data.color !== prevData.color) {
this.light.color.set(this.data.color);
}
if (this.data.intensity !== prevData.intensity) {
this.light.intensity = this.data.intensity;
}
if (this.data.range !== prevData.range) {
this.light.distance = this.data.range;
}
if (this.data.innerConeAngle !== prevData.innerConeAngle || this.data.outerConeAngle !== prevData.outerConeAngle) {
this.light.angle = this.data.outerConeAngle;
this.light.penumbra = 1.0 - this.data.innerConeAngle / this.data.outerConeAngle;
}
if (this.data.castShadow !== prevData.castShadow) {
this.light.castShadow = this.data.castShadow;
}
},
remove: function() {
this.el.removeObject3D("spot-light");
}
});

Просмотреть файл

@ -0,0 +1,16 @@
/**
* Toggles the visibility of this entity based on networked ownership
* @namespace ui
* @component visible-while-frozen
*/
AFRAME.registerComponent("visible-to-owner", {
init() {
this.onStateChange = e => {
this.el.setAttribute("visible", e.detail.newOwner === NAF.clientId);
};
NAF.utils.getNetworkedEntity(this.el).then(el => {
el.addEventListener("ownership-changed", this.onStateChange);
this.el.setAttribute("visible", NAF.utils.isMine(el));
});
}
});

Просмотреть файл

@ -11,9 +11,11 @@ AFRAME.GLTFModelPlus.registerComponent("gltf-model-plus", "gltf-model-plus");
AFRAME.GLTFModelPlus.registerComponent("body", "body");
AFRAME.GLTFModelPlus.registerComponent("hide-when-quality", "hide-when-quality");
AFRAME.GLTFModelPlus.registerComponent("light", "light");
AFRAME.GLTFModelPlus.registerComponent("directional-light", "light");
AFRAME.GLTFModelPlus.registerComponent("ambient-light", "light");
AFRAME.GLTFModelPlus.registerComponent("point-light", "light");
AFRAME.GLTFModelPlus.registerComponent("ambient-light", "ambient-light");
AFRAME.GLTFModelPlus.registerComponent("directional-light", "directional-light");
AFRAME.GLTFModelPlus.registerComponent("hemisphere-light", "hemisphere-light");
AFRAME.GLTFModelPlus.registerComponent("point-light", "point-light");
AFRAME.GLTFModelPlus.registerComponent("spot-light", "spot-light");
AFRAME.GLTFModelPlus.registerComponent("skybox", "skybox");
AFRAME.GLTFModelPlus.registerComponent("layers", "layers");
AFRAME.GLTFModelPlus.registerComponent("shadow", "shadow");

Просмотреть файл

@ -176,7 +176,9 @@
sticky-object="autoLockOnRelease: true; autoLockOnLoad: true;"
position-at-box-shape-border="target:.delete-button"
destroy-at-extreme-distances
rotation
>
<!-- HACK: rotation component above is required for its side effect of setting YXZ order -->
<a-entity class="delete-button" visible-while-frozen>
<a-entity mixin="rounded-text-button" remove-networked-object-button position="0 0 0"> </a-entity>
<a-entity text=" value:Delete; width:2.5; align:center;" text-raycast-hack position="0 0 0.01"></a-entity>
@ -216,6 +218,19 @@
></a-entity>
</template>
<template id="paging-toolbar">
<a-entity class="paging-toolbar" visible-to-owner>
<a-entity class="prev-button" position="-0.3 0 0">
<a-entity mixin="rounded-text-button" slice9="width: 0.2"> </a-entity>
<a-entity text=" value:<; width:2; align:center;" text-raycast-hack position="0 0 0.01"></a-entity>
</a-entity>
<a-entity class="page-label" text="width:2; align:center;" text-raycast-hack></a-entity>
<a-entity class="next-button" position="0.3 0 0">
<a-entity mixin="rounded-text-button" slice9="width: 0.2"> </a-entity>
<a-entity text=" value:>; width:2; align:center;" text-raycast-hack position="0 0 0.01"></a-entity>
</a-entity>
</a-entity>
</template>
<a-mixin id="rounded-text-button"
text-button="

Просмотреть файл

@ -75,6 +75,12 @@ import "./components/remove-networked-object-button";
import "./components/destroy-at-extreme-distances";
import "./components/media-loader";
import "./components/gamma-factor";
import "./components/ambient-light";
import "./components/directional-light";
import "./components/hemisphere-light";
import "./components/point-light";
import "./components/spot-light";
import "./components/visible-to-owner";
import ReactDOM from "react-dom";
import React from "react";
@ -303,8 +309,9 @@ const onReady = async () => {
});
const offset = { x: 0, y: 0, z: -1.5 };
const spawnMediaInfrontOfPlayer = url => {
const entity = addMedia(url, "#interactable-media", true);
const spawnMediaInfrontOfPlayer = src => {
const entity = addMedia(src, "#interactable-media", true);
entity.setAttribute("offset-relative-to", {
target: "#player-camera",
offset
@ -315,28 +322,36 @@ const onReady = async () => {
spawnMediaInfrontOfPlayer(e.detail);
});
if (qsTruthy("mediaTools")) {
document.addEventListener("paste", e => {
if (e.target.nodeName === "INPUT") return;
document.addEventListener("paste", e => {
if (e.target.nodeName === "INPUT") return;
const imgUrl = e.clipboardData.getData("text");
console.log("Pasted: ", imgUrl, e);
spawnMediaInfrontOfPlayer(imgUrl);
});
document.addEventListener("dragover", e => {
e.preventDefault();
});
document.addEventListener("drop", e => {
e.preventDefault();
const imgUrl = e.dataTransfer.getData("url");
if (imgUrl) {
console.log("Dropped: ", imgUrl);
spawnMediaInfrontOfPlayer(imgUrl);
const url = e.clipboardData.getData("text");
const files = e.clipboardData.files && e.clipboardData.files;
if (url) {
spawnMediaInfrontOfPlayer(url);
} else {
for (const file of files) {
spawnMediaInfrontOfPlayer(file);
}
});
}
}
});
document.addEventListener("dragover", e => {
e.preventDefault();
});
document.addEventListener("drop", e => {
e.preventDefault();
const url = e.dataTransfer.getData("url");
const files = e.dataTransfer.files;
if (url) {
spawnMediaInfrontOfPlayer(url);
} else {
for (const file of files) {
spawnMediaInfrontOfPlayer(file);
}
}
});
if (!qsTruthy("offline")) {
document.body.addEventListener("connected", () => {

13
src/react-components/2d-hud.js поставляемый
Просмотреть файл

@ -3,7 +3,6 @@ import PropTypes from "prop-types";
import cx from "classnames";
import styles from "../assets/stylesheets/2d-hud.scss";
import qsTruthy from "../utils/qs_truthy";
const TopHUD = ({ muted, frozen, spacebubble, onToggleMute, onToggleFreeze, onToggleSpaceBubble }) => (
<div className={cx(styles.container, styles.top)}>
@ -40,13 +39,11 @@ TopHUD.propTypes = {
const BottomHUD = ({ onCreateObject }) => (
<div className={cx(styles.container, styles.bottom)}>
{qsTruthy("mediaTools") && (
<div
className={cx("ui-interactive", styles.iconButton, styles.large, styles.createObject)}
title={"Create Object"}
onClick={onCreateObject}
/>
)}
<div
className={cx("ui-interactive", styles.iconButton, styles.large, styles.createObject)}
title={"Create Object"}
onClick={onCreateObject}
/>
</div>
);

115
src/react-components/create-object-dialog.js поставляемый
Просмотреть файл

@ -1,19 +1,37 @@
import React, { Component } from "react";
import "aframe";
import PropTypes from "prop-types";
import giphyLogo from "../assets/images/giphy_logo.png";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faPaperclip, faTimes } from "@fortawesome/free-solid-svg-icons";
import styles from "../assets/stylesheets/create-object-dialog.scss";
import cx from "classnames";
const attributionHostnames = {
"giphy.com": giphyLogo
"giphy.com": giphyLogo,
"media.giphy.com": giphyLogo
};
const DEFAULT_OBJECT_URL = "https://asset-bundles-prod.reticulum.io/interactables/Ducky/DuckyMesh-438ff8e022.gltf";
const isMobile = AFRAME.utils.device.isMobile();
const instructions = "Paste a URL or upload a file.";
const desktopTips = "Tip: You can paste links directly into Hubs with Ctrl+V";
const mobileInstructions = <div>{instructions}</div>;
const desktopInstructions = (
<div>
<p>{instructions}</p>
<p>{desktopTips}</p>
</div>
);
let lastUrl = "";
const fileInputId = "file-input";
export default class CreateObjectDialog extends Component {
state = {
url: ""
url: "",
file: null,
fileName: ""
};
static propTypes = {
@ -22,9 +40,7 @@ export default class CreateObjectDialog extends Component {
};
componentDidMount() {
this.setState({ url: lastUrl }, () => {
this.onUrlChange({ target: this.input });
});
this.setState({ url: lastUrl });
}
componentWillUnmount() {
@ -32,44 +48,79 @@ export default class CreateObjectDialog extends Component {
}
onUrlChange = e => {
if (e && e.target.value && e.target.value !== "") {
this.setState({
url: e.target.value,
attributionImage: e.target.validity.valid && attributionHostnames[new URL(e.target.value).hostname]
});
let attributionImage = this.state.attributionImage;
if (e.target && e.target.value && e.target.validity.valid) {
attributionImage = attributionHostnames[new URL(e.target.value).hostname];
}
this.setState({
url: e.target && e.target.value,
attributionImage: attributionImage
});
};
onCreateClicked = () => {
this.props.onCreateObject(this.state.url || DEFAULT_OBJECT_URL);
onFileChange = e => {
this.setState({
file: e.target.files[0],
fileName: e.target.files[0].name
});
};
onCreateClicked = e => {
e.preventDefault();
this.props.onCreateObject(this.state.file || this.state.url || DEFAULT_OBJECT_URL);
this.props.onCloseDialog();
};
reset = e => {
e.preventDefault();
this.setState({
url: "",
file: null,
fileName: ""
});
this.fileInput.value = null;
};
render() {
const cancelButton = (
<label className={cx(styles.smallButton, styles.cancelIcon)} onClick={this.reset}>
<FontAwesomeIcon icon={faTimes} />
</label>
);
const uploadButton = (
<label htmlFor={fileInputId} className={cx(styles.smallButton, styles.uploadIcon)}>
<FontAwesomeIcon icon={faPaperclip} />
</label>
);
const filenameLabel = <label className={cx(styles.leftSideOfInput)}>{this.state.fileName}</label>;
const urlInput = (
<input
className={cx(styles.leftSideOfInput)}
placeholder="Image/Video/glTF URL"
onChange={this.onUrlChange}
type="url"
value={this.state.url}
/>
);
return (
<div>
{!AFRAME.utils.device.isMobile() ? (
<div>
Paste a URL from the web to create an object in the room.
<br />
Tip: You can paste directly into Hubs using Ctrl+V
</div>
) : (
<div>Paste a URL from the web to create an object in the room.</div>
)}
{isMobile ? mobileInstructions : desktopInstructions}
<form onSubmit={this.onCreateClicked}>
<div className="add-media-form">
<div className={styles.addMediaForm}>
<input
ref={el => (this.input = el)}
type="url"
placeholder="Image, Video, or GLTF URL"
className="add-media-form__link_field"
value={this.state.url}
onChange={this.onUrlChange}
id={fileInputId}
ref={f => (this.fileInput = f)}
className={styles.hideFileInput}
type="file"
onChange={this.onFileChange}
/>
<div className="add-media-form__buttons">
<button className="add-media-form__action-button">
<div className={styles.inputBorder}>
{this.state.file ? filenameLabel : urlInput}
{this.state.url || this.state.fileName ? cancelButton : uploadButton}
</div>
<div className={styles.buttons}>
<button className={styles.actionButton}>
<span>create</span>
</button>
</div>

74
src/react-components/info-dialog.js поставляемый
Просмотреть файл

@ -123,18 +123,26 @@ class InfoDialog extends Component {
dialogTitle = "Get in Touch";
dialogBody = (
<span>
Want to join the conversation?
<p />
Join us on the{" "}
<a href="https://webvr-slack.herokuapp.com/" target="_blank" rel="noopener noreferrer">
WebVR Slack
</a>{" "}
in the #social channel.<br />VR meetups every Friday at noon PST!
<p /> Or, tweet at{" "}
<a href="https://twitter.com/mozillareality" target="_blank" rel="noopener noreferrer">
@mozillareality
</a>{" "}
on Twitter.
<p>Want to join the conversation?</p>
<p>
Join us on the{" "}
<a href="https://webvr-slack.herokuapp.com/" target="_blank" rel="noopener noreferrer">
WebVR Slack
</a>{" "}
in the{" "}
<a href="https://webvr.slack.com/messages/social" target="_blank" rel="noopener noreferrer">
#social
</a>{" "}
channel.<br />
VR meetups every Friday at noon PDT!
</p>
<p>
Or, tweet at{" "}
<a href="https://twitter.com/mozillareality" target="_blank" rel="noopener noreferrer">
@mozillareality
</a>{" "}
on Twitter.
</p>
</span>
);
break;
@ -241,8 +249,7 @@ class InfoDialog extends Component {
dialogTitle = "";
dialogBody = (
<span>
Sign up to get updates about new features in Hubs.
<p />
<p>Sign up to get updates about new features in Hubs.</p>
<form onSubmit={this.signUpForMailingList}>
<div className="mailing-list-form">
<input
@ -278,18 +285,24 @@ class InfoDialog extends Component {
dialogTitle = "Report an Issue";
dialogBody = (
<span>
Need to report a problem?
<p />
You can file a{" "}
<a href="https://github.com/mozilla/hubs/issues" target="_blank" rel="noopener noreferrer">
Github Issue
</a>{" "}
or e-mail us for support at <a href="mailto:hubs@mozilla.com">hubs@mozilla.com</a>.
<p />
You can also find us in #social on the{" "}
<a href="http://webvr-slack.herokuapp.com/" target="_blank" rel="noopener noreferrer">
WebVR Slack
</a>.
<p>Need to report a problem?</p>
<p>
You can file a{" "}
<a href="https://github.com/mozilla/hubs/issues" target="_blank" rel="noopener noreferrer">
GitHub Issue
</a>{" "}
or e-mail us for support at <a href="mailto:hubs@mozilla.com">hubs@mozilla.com</a>.
</p>
<p>
You can also find us in{" "}
<a href="https://webvr.slack.com/messages/social" target="_blank" rel="noopener noreferrer">
#social
</a>{" "}
on the{" "}
<a href="https://webvr-slack.herokuapp.com/" target="_blank" rel="noopener noreferrer">
WebVR Slack
</a>.
</p>
</span>
);
break;
@ -297,10 +310,11 @@ class InfoDialog extends Component {
dialogTitle = "Getting Started";
dialogBody = (
<div className="info-dialog__help">
When in a room, other avatars can see and hear you.
<p />
Use your controller&apos;s action button to teleport from place to place. If it has a trigger, use it to
pick up objects.
<p>When in a room, other avatars can see and hear you.</p>
<p>
Use your controller&apos;s action button to teleport from place to place. If it has a trigger, use it to
pick up objects.
</p>
<p style={{ textAlign: "center" }}>
In VR, <b>look up</b> to find your menu:
<img

16
src/react-components/ui-root.js поставляемый
Просмотреть файл

@ -893,10 +893,18 @@ class UIRoot extends Component {
onToggleFreeze={this.toggleFreeze}
onToggleSpaceBubble={this.toggleSpaceBubble}
/>
{this.props.occupantCount <= 1 && (
<div className={styles.inviteNagButton}>
<button onClick={() => this.setState({ infoDialogType: InfoDialog.dialogTypes.invite })}>
<FormattedMessage id="entry.invite-others-nag" />
{!this.props.availableVREntryTypes.isInHMD &&
this.props.occupantCount <= 1 && (
<div className={styles.nagButton}>
<button onClick={() => this.setState({ infoDialogType: InfoDialog.dialogTypes.invite })}>
<FormattedMessage id="entry.invite-others-nag" />
</button>
</div>
)}
{this.props.availableVREntryTypes.isInHMD && (
<div className={styles.nagButton}>
<button onClick={() => this.props.scene.enterVR()}>
<FormattedMessage id="entry.return-to-vr" />
</button>
</div>
)}

Просмотреть файл

@ -1,34 +1,55 @@
const whitelistedHosts = [/^.*\.reticulum\.io$/, /^.*hubs\.mozilla\.com$/, /^hubs\.local$/];
const isHostWhitelisted = hostname => !!whitelistedHosts.filter(r => r.test(hostname)).length;
let mediaAPIEndpoint = "/api/v1/media";
if (process.env.RETICULUM_SERVER) {
mediaAPIEndpoint = `https://${process.env.RETICULUM_SERVER}${mediaAPIEndpoint}`;
}
const fetchContentType = async url => fetch(url, { method: "HEAD" }).then(r => r.headers.get("content-type"));
const fetchContentType = async url => {
return fetch(url, { method: "HEAD" }).then(r => r.headers.get("content-type"));
};
const contentIndexCache = new Map();
export const fetchMaxContentIndex = async (documentUrl, pageUrl) => {
if (contentIndexCache.has(documentUrl)) return contentIndexCache.get(documentUrl);
const maxIndex = await fetch(pageUrl).then(r => parseInt(r.headers.get("x-max-content-index")));
contentIndexCache.set(documentUrl, maxIndex);
return maxIndex;
};
const resolveMediaCache = new Map();
export const resolveMedia = async url => {
export const resolveMedia = async (url, skipContentType, index) => {
const parsedUrl = new URL(url);
if (resolveMediaCache.has(url)) return resolveMediaCache.get(url);
const cacheKey = `${url}|${index}`;
if (resolveMediaCache.has(cacheKey)) return resolveMediaCache.get(cacheKey);
const resolved =
(parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") || isHostWhitelisted(parsedUrl.hostname)
? { raw: url, origin: url }
: await fetch(mediaAPIEndpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ media: { url } })
}).then(r => r.json());
const isHttpOrHttps = parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:";
const resolved = !isHttpOrHttps
? { raw: url, origin: url }
: await fetch(mediaAPIEndpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ media: { url, index } })
}).then(r => r.json());
const contentType = (resolved.meta && resolved.meta.expected_content_type) || (await fetchContentType(resolved.raw));
resolved.contentType = contentType;
if (isHttpOrHttps && !skipContentType) {
const contentType =
(resolved.meta && resolved.meta.expected_content_type) || (await fetchContentType(resolved.raw));
resolved.contentType = contentType;
}
resolveMediaCache.set(url, resolved);
resolveMediaCache.set(cacheKey, resolved);
return resolved;
};
export const upload = file => {
const formData = new FormData();
formData.append("media", file);
return fetch(mediaAPIEndpoint, {
method: "POST",
body: formData
}).then(r => r.json());
};
let interactableId = 0;
export const addMedia = (src, template, resize = false) => {
const scene = AFRAME.scenes[0];
@ -36,7 +57,19 @@ export const addMedia = (src, template, resize = false) => {
const entity = document.createElement("a-entity");
entity.id = "interactable-media-" + interactableId++;
entity.setAttribute("networked", { template: template });
entity.setAttribute("media-loader", { src, resize });
entity.setAttribute("media-loader", { resize, src: typeof src === "string" ? src : "" });
scene.appendChild(entity);
if (src instanceof File) {
upload(src)
.then(response => {
const srcUrl = new URL(response.raw);
srcUrl.searchParams.set("token", response.meta.access_token);
entity.setAttribute("media-loader", { src: srcUrl.href });
})
.catch(() => {
entity.setAttribute("media-loader", { src: "error" });
});
}
return entity;
};

Просмотреть файл

@ -7,6 +7,7 @@ const fs = require("fs");
const path = require("path");
const selfsigned = require("selfsigned");
const webpack = require("webpack");
const cors = require("cors");
const HTMLWebpackPlugin = require("html-webpack-plugin");
const ExtractTextPlugin = require("extract-text-webpack-plugin");
const CopyWebpackPlugin = require("copy-webpack-plugin");
@ -76,8 +77,9 @@ module.exports = (env, argv) => ({
host: "0.0.0.0",
useLocalIp: true,
allowedHosts: ["hubs.local"],
headers: { "Access-Control-Allow-Origin": "hubs.local" },
before: function(app) {
// be flexible with people accessing via a local reticulum on another port
app.use(cors({ origin: /hubs\.local(:\d*)?$/ }));
// networked-aframe makes HEAD requests to the server for time syncing. Respond with an empty body.
app.head("*", function(req, res, next) {
if (req.method === "HEAD") {
@ -178,7 +180,7 @@ module.exports = (env, argv) => ({
new HTMLWebpackPlugin({
filename: "index.html",
template: path.join(__dirname, "src", "index.html"),
chunks: ["vendor", "index"]
chunks: ["vendor", "engine", "index"]
}),
new HTMLWebpackPlugin({
filename: "hub.html",