diff --git a/package.json b/package.json index cae88c47526aef2ed515ceaa4fda82d1a2812622..744b8d81544a7a5fafee47af0f431b6753e7c665 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "aframe-input-mapping-component": "https://github.com/mozillareality/aframe-input-mapping-component#hubs/master", "aframe-motion-capture-components": "https://github.com/mozillareality/aframe-motion-capture-components#1ca616fa67b627e447b23b35a09da175d8387668", "aframe-physics-extras": "^0.1.3", - "aframe-physics-system": "github:donmccurdy/aframe-physics-system", + "aframe-physics-system": "https://github.com/mozillareality/aframe-physics-system#hubs/master", "aframe-rounded": "^1.0.3", "aframe-slice9-component": "^1.0.0", "aframe-teleport-controls": "https://github.com/mozillareality/aframe-teleport-controls#hubs/master", @@ -87,8 +87,10 @@ "selfsigned": "^1.10.2", "shelljs": "^0.8.1", "style-loader": "^0.20.2", + "url-loader": "^1.0.1", "webpack": "^4.0.1", "webpack-cli": "^2.0.9", - "webpack-dev-server": "^3.0.0" + "webpack-dev-server": "^3.0.0", + "worker-loader": "^2.0.0" } } diff --git a/src/assets/images/media-error.gif b/src/assets/images/media-error.gif new file mode 100644 index 0000000000000000000000000000000000000000..825bb624f1770088bf75eaa515d2e612e61adae9 Binary files /dev/null and b/src/assets/images/media-error.gif differ diff --git a/src/assets/stylesheets/2d-hud.scss b/src/assets/stylesheets/2d-hud.scss index 263569b58b04e599868e601cd0c960cee543c843..475e4615923a6e1359984b1c6ea9be72e9242947 100644 --- a/src/assets/stylesheets/2d-hud.scss +++ b/src/assets/stylesheets/2d-hud.scss @@ -1,3 +1,5 @@ +@import 'shared'; + :local(.container) { position: absolute; top: 10px; @@ -37,9 +39,24 @@ width: 40px; height: 40px; background-size: 100%; + border-radius: 50%; + display: flex; + justify-content: center; + align-items: center; cursor: pointer; } +:local(.addMediaButton) { + position: absolute; + top: 90px; + background-color: #404040; +} + +:local(.iconButton.small) { + width: 30px; + height: 30px; +} + :local(.iconButton.large) { width: 80px; height: 80px; diff --git a/src/assets/stylesheets/info-dialog.scss b/src/assets/stylesheets/info-dialog.scss index 690db56c83a0fee301b0bb95952f6a1622ec9140..58b2259fc71f0097d8f2b032b1fcc9bfa5305251 100644 --- a/src/assets/stylesheets/info-dialog.scss +++ b/src/assets/stylesheets/info-dialog.scss @@ -79,7 +79,7 @@ } } -.invite-form { +.invite-form, .add-media-form { display: flex; flex-direction: column; align-items: center; diff --git a/src/components/auto-box-collider.js b/src/components/auto-box-collider.js new file mode 100644 index 0000000000000000000000000000000000000000..220ad92ed83ba506aa63f70dc2ee24e087ce61db --- /dev/null +++ b/src/components/auto-box-collider.js @@ -0,0 +1,49 @@ +AFRAME.registerComponent("auto-box-collider", { + schema: { + resize: { default: false }, + resizeLength: { default: 0.5 } + }, + + init() { + this.onLoaded = this.onLoaded.bind(this); + this.el.addEventListener("model-loaded", this.onLoaded); + }, + + remove() { + this.el.removeEventListener("model-loaded", this.onLoaded); + }, + + onLoaded() { + const rotation = this.el.object3D.rotation.clone(); + this.el.object3D.rotation.set(0, 0, 0); + const { min, max } = new THREE.Box3().setFromObject(this.el.object3DMap.mesh); + const halfExtents = new THREE.Vector3() + .addVectors(min.clone().negate(), max) + .multiplyScalar(0.5 / this.el.object3D.scale.x); + this.el.setAttribute("shape", { + shape: "box", + halfExtents: halfExtents, + offset: new THREE.Vector3(0, halfExtents.y, 0) + }); + if (this.data.resize) { + this.resize(min, max); + } + this.el.object3D.rotation.copy(rotation); + this.el.removeAttribute("auto-box-collider"); + }, + + // Adjust the scale such that the object fits within a box of a specified size. + resize(min, max) { + const dX = Math.abs(max.x - min.x); + const dY = Math.abs(max.y - min.y); + const dZ = Math.abs(max.z - min.z); + const lengthOfLongestComponent = Math.max(dX, dY, dZ); + const correctiveFactor = this.data.resizeLength / lengthOfLongestComponent; + const scale = this.el.object3D.scale; + this.el.setAttribute("scale", { + x: scale.x * correctiveFactor, + y: scale.y * correctiveFactor, + z: scale.z * correctiveFactor + }); + } +}); diff --git a/src/components/auto-scale-cannon-physics-body.js b/src/components/auto-scale-cannon-physics-body.js new file mode 100644 index 0000000000000000000000000000000000000000..1633dffef17c70db520a7e8edb7ef3c819734970 --- /dev/null +++ b/src/components/auto-scale-cannon-physics-body.js @@ -0,0 +1,31 @@ +function almostEquals(epsilon, u, v) { + return Math.abs(u.x - v.x) < epsilon && Math.abs(u.y - v.y) < epsilon && Math.abs(u.z - v.z) < epsilon; +} + +AFRAME.registerComponent("auto-scale-cannon-physics-body", { + dependencies: ["body"], + schema: { + equalityEpsilon: { default: 0.001 }, + debounceDelay: { default: 100 } + }, + + init() { + this.body = this.el.components["body"]; + this.prevScale = this.el.object3D.scale.clone(); + this.nextUpdateTime = -1; + }, + + tick(t) { + const scale = this.el.object3D.scale; + // Note: This only checks if the LOCAL scale of the object3D changes. + if (!almostEquals(this.data.equalityEpsilon, scale, this.prevScale)) { + this.prevScale.copy(scale); + this.nextUpdateTime = t + this.data.debounceDelay; + } + + if (this.nextUpdateTime > 0 && t > this.nextUpdateTime) { + this.nextUpdateTime = -1; + this.body.updateCannonScale(); + } + } +}); diff --git a/src/components/gltf-model-plus.js b/src/components/gltf-model-plus.js index 55d62b10e997a943d39c6e593cb35256c67b0e6c..686245b492555beaf311a7f2f9b94c07dd600d2a 100644 --- a/src/components/gltf-model-plus.js +++ b/src/components/gltf-model-plus.js @@ -203,11 +203,11 @@ function cachedLoadGLTF(src, preferredTechnique, onProgress) { AFRAME.registerComponent("gltf-model-plus", { schema: { src: { type: "string" }, - inflate: { default: false }, - preferredTechnique: { default: AFRAME.utils.device.isMobile() ? "KHR_materials_unlit" : "pbrMetallicRoughness" } + inflate: { default: false } }, init() { + this.preferredTechnique = AFRAME.utils.device.isMobile() ? "KHR_materials_unlit" : "pbrMetallicRoughness"; this.loadTemplates(); }, @@ -245,7 +245,7 @@ AFRAME.registerComponent("gltf-model-plus", { } const gltfPath = THREE.LoaderUtils.extractUrlBase(src); - const model = await cachedLoadGLTF(src, this.data.preferredTechnique); + const model = await cachedLoadGLTF(src, this.preferredTechnique); // If we started loading something else already // TODO: there should be a way to cancel loading instead diff --git a/src/components/image-plus.js b/src/components/image-plus.js new file mode 100644 index 0000000000000000000000000000000000000000..744020534a6d9f5f6924ea12084572d4eb325109 --- /dev/null +++ b/src/components/image-plus.js @@ -0,0 +1,248 @@ +import GIFWorker from "../workers/gifparsing.worker.js"; +import errorImageSrc from "!!url-loader!../assets/images/media-error.gif"; + +class GIFTexture extends THREE.Texture { + constructor(frames, delays, disposals) { + super(document.createElement("canvas")); + this.image.width = frames[0].width; + this.image.height = frames[0].height; + + this._ctx = this.image.getContext("2d"); + + this.generateMipmaps = false; + this.isVideoTexture = true; + this.minFilter = THREE.NearestFilter; + + this.frames = frames; + this.delays = delays; + this.disposals = disposals; + + this.frame = 0; + this.frameStartTime = Date.now(); + } + + update() { + if (!this.frames || !this.delays || !this.disposals) return; + const now = Date.now(); + if (now - this.frameStartTime > this.delays[this.frame]) { + if (this.disposals[this.frame] === 2) { + this._ctx.clearRect(0, 0, this.image.width, this.image.width); + } + this.frame = (this.frame + 1) % this.frames.length; + this.frameStartTime = now; + this._ctx.drawImage(this.frames[this.frame], 0, 0, this.image.width, this.image.height); + this.needsUpdate = true; + } + } +} + +/** + * Create video element to be used as a texture. + * + * @param {string} src - Url to a video file. + * @returns {Element} Video element. + */ +function createVideoEl(src) { + const videoEl = document.createElement("video"); + videoEl.setAttribute("playsinline", ""); + videoEl.setAttribute("webkit-playsinline", ""); + videoEl.autoplay = true; + videoEl.loop = true; + videoEl.crossOrigin = "anonymous"; + videoEl.src = src; + return videoEl; +} + +const textureLoader = new THREE.TextureLoader(); +textureLoader.setCrossOrigin("anonymous"); + +const textureCache = new Map(); + +const errorImage = new Image(); +errorImage.src = errorImageSrc; +const errorTexture = new THREE.Texture(errorImage); +errorTexture.magFilter = THREE.NearestFilter; +errorImage.onload = () => { + errorTexture.needsUpdate = true; +}; + +AFRAME.registerComponent("image-plus", { + dependencies: ["geometry"], + + schema: { + src: { type: "string" }, + contentType: { type: "string" } + }, + + _fit(w, h) { + const ratio = (h || 1.0) / (w || 1.0); + const geo = this.el.geometry; + let width, height; + if (geo && geo.width) { + if (geo.height && ratio > 1) { + width = geo.width / ratio; + } else { + height = geo.height * ratio; + } + } else if (geo && geo.height) { + width = geo.width / ratio; + } else { + width = Math.min(1.0, 1.0 / ratio); + height = Math.min(1.0, ratio); + } + this.el.setAttribute("geometry", { width, height }); + this.el.setAttribute("shape", { + shape: "box", + halfExtents: { + x: width / 2, + y: height / 2, + z: 0.05 + } + }); + }, + + init() { + const material = new THREE.MeshBasicMaterial(); + material.side = THREE.DoubleSide; + material.transparent = true; + this.el.getObject3D("mesh").material = material; + }, + + remove() { + const material = this.el.getObject3D("mesh").material; + const texture = material.map; + + if (texture === errorTexture) return; + + const url = texture.image.src; + const cacheItem = textureCache.get(url); + 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; + video.pause(); + video.src = ""; + video.load(); + } + + 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); + } + }, + + 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); + }); + }, + + 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() { + let texture; + try { + const url = this.data.src; + const contentType = this.data.contentType; + if (!url) { + return; + } + + if (textureCache.has(url)) { + const cacheItem = textureCache.get(url); + texture = cacheItem.texture; + cacheItem.count++; + } else { + if (url === "error") { + texture = errorTexture; + } else if (contentType === "image/gif") { + texture = await this.loadGIF(url); + } else if (contentType.startsWith("image/")) { + texture = await this.loadImage(url); + } else if (contentType.startsWith("video")) { + texture = await this.loadVideo(url); + } else { + throw new Error(`Unknown centent type: ${contentType}`); + } + + textureCache.set(url, { count: 1, texture }); + } + } catch (e) { + console.error("Error loading media", this.data.src, e); + texture = errorTexture; + } + + const material = this.el.getObject3D("mesh").material; + material.map = texture; + material.needsUpdate = true; + this._fit(texture.image.videoWidth || texture.image.width, texture.image.videoHeight || texture.image.height); + } +}); diff --git a/src/components/input-configurator.js b/src/components/input-configurator.js index 1609b20c7b163504d3dcc46390767bf14c91a09e..df36b38d8c4bb81a2f8fd98824cbd3ecf4ef7f15 100644 --- a/src/components/input-configurator.js +++ b/src/components/input-configurator.js @@ -69,7 +69,7 @@ AFRAME.registerComponent("input-configurator", { this.eventHandlers = []; this.actionEventHandler = null; if (this.lookOnMobile) { - this.lookOnMobile.el.removeComponent("look-on-mobile"); + this.lookOnMobile.el.removeAttribute("look-on-mobile"); this.lookOnMobile = null; } this.cursorRequiresManagement = false; diff --git a/src/components/networked-counter.js b/src/components/networked-counter.js index be9725cf6a50efb822370ab5d4b7196a6bcce3c4..6902658b03eef05429e2de1fdcd8cf48debec090 100644 --- a/src/components/networked-counter.js +++ b/src/components/networked-counter.js @@ -6,130 +6,90 @@ AFRAME.registerComponent("networked-counter", { schema: { max: { default: 3 }, - ttl: { default: 120 }, + ttl: { default: 0 }, grab_event: { type: "string", default: "grab-start" }, release_event: { type: "string", default: "grab-end" } }, - init: function() { - this.count = 0; - this.queue = {}; - this.timeouts = {}; + init() { + this.registeredEls = new Map(); }, - remove: function() { - for (const id in this.queue) { - if (this.queue.hasOwnProperty(id)) { - const item = this.queue[id]; - item.el.removeEventListener(this.data.grab_event, item.onGrabHandler); - item.el.removeEventListener(this.data.release_event, item.onReleaseHandler); - } - } - - for (const id in this.timeouts) { - this._removeTimeout(id); - } + remove() { + this.registeredEls.forEach(({ onGrabHandler, onReleaseHandler, timeout }, el) => { + el.removeEventListener(this.data.grab_event, onGrabHandler); + el.removeEventListener(this.data.release_event, onReleaseHandler); + clearTimeout(timeout); + }); + this.registeredEls.clear(); }, - register: function(networkedEl) { - if (this.data.max <= 0) { - return; - } - - const id = NAF.utils.getNetworkId(networkedEl); - if (id && this.queue.hasOwnProperty(id)) { - return; - } + register(el) { + if (this.data.max <= 0 || this.registeredEls.has(el)) return; - const now = Date.now(); - const grabEventListener = this._onGrabbed.bind(this, id); - const releaseEventListener = this._onReleased.bind(this, id); + const grabEventListener = this._onGrabbed.bind(this, el); + const releaseEventListener = this._onReleased.bind(this, el); - this.queue[id] = { - ts: now, - el: networkedEl, + this.registeredEls.set(el, { + ts: Date.now(), onGrabHandler: grabEventListener, onReleaseHandler: releaseEventListener - }; + }); - networkedEl.addEventListener(this.data.grab_event, grabEventListener); - networkedEl.addEventListener(this.data.release_event, releaseEventListener); + el.addEventListener(this.data.grab_event, grabEventListener); + el.addEventListener(this.data.release_event, releaseEventListener); - this.count++; - - if (!this._isCurrentlyGrabbed(id)) { - this._addTimeout(id); + if (!el.is("grabbed")) { + this._startTimer(el); } this._destroyOldest(); }, - deregister: function(networkedEl) { - const id = NAF.utils.getNetworkId(networkedEl); - if (id && this.queue.hasOwnProperty(id)) { - const item = this.queue[id]; - networkedEl.removeEventListener(this.data.grab_event, item.onGrabHandler); - networkedEl.removeEventListener(this.data.release_event, item.onReleaseHandler); - - delete this.queue[id]; - - this._removeTimeout(id); - delete this.timeouts[id]; - - this.count--; + deregister(el) { + if (this.registeredEls.has(el)) { + const { onGrabHandler, onReleaseHandler, timeout } = this.registeredEls.get(el); + el.removeEventListener(this.data.grab_event, onGrabHandler); + el.removeEventListener(this.data.release_event, onReleaseHandler); + clearTimeout(timeout); + this.registeredEls.delete(el); } }, - _onGrabbed: function(id) { - this._removeTimeout(id); + _onGrabbed(el) { + clearTimeout(this.registeredEls.get(el).timeout); }, - _onReleased: function(id) { - this._removeTimeout(id); - this._addTimeout(id); - this.queue[id].ts = Date.now(); + _onReleased(el) { + this._startTimer(el); + this.registeredEls.get(el).ts = Date.now(); }, - _destroyOldest: function() { - if (this.count > this.data.max) { - let oldest = null, - ts = Number.MAX_VALUE; - for (const id in this.queue) { - if (this.queue.hasOwnProperty(id)) { - if (this.queue[id].ts < ts && !this._isCurrentlyGrabbed(id)) { - oldest = this.queue[id]; - ts = this.queue[id].ts; - } + _destroyOldest() { + if (this.registeredEls.size > this.data.max) { + let oldestEl = null, + minTs = Number.MAX_VALUE; + this.registeredEls.forEach(({ ts }, el) => { + if (ts < minTs && !el.is("grabbed")) { + oldestEl = el; + minTs = ts; } - } - if (ts > 0) { - this.deregister(oldest.el); - this._destroy(oldest.el); - } + }); + this._destroy(oldestEl); } }, - _isCurrentlyGrabbed: function(id) { - const networkedEl = this.queue[id].el; - return networkedEl.is("grabbed"); - }, - - _addTimeout: function(id) { - const timeout = this.data.ttl * 1000; - this.timeouts[id] = setTimeout(() => { - const el = this.queue[id].el; - this.deregister(el); + _startTimer(el) { + if (!this.data.ttl) return; + clearTimeout(this.registeredEls.get(el).timeout); + this.registeredEls.get(el).timeout = setTimeout(() => { this._destroy(el); - }, timeout); - }, - - _removeTimeout: function(id) { - if (this.timeouts.hasOwnProperty(id)) { - clearTimeout(this.timeouts[id]); - } + }, this.data.ttl * 1000); }, - _destroy: function(networkedEl) { - networkedEl.parentNode.removeChild(networkedEl); + _destroy(el) { + // networked-interactable's remvoe will also call deregister, but it will happen async so we do it here as well. + this.deregister(el); + el.parentNode.removeChild(el); } }); diff --git a/src/components/offset-relative-to.js b/src/components/offset-relative-to.js index 3cd45b942ed4573ee9b588a467de456af801f62f..23920e099a94fd50373f6804bf8c02d8cae0910f 100644 --- a/src/components/offset-relative-to.js +++ b/src/components/offset-relative-to.js @@ -12,16 +12,41 @@ AFRAME.registerComponent("offset-relative-to", { }, on: { type: "string" + }, + selfDestruct: { + default: false } }, init() { - this.updateOffset(); - this.el.sceneEl.addEventListener(this.data.on, this.updateOffset.bind(this)); + this.updateOffset = this.updateOffset.bind(this); + if (this.data.on) { + this.el.sceneEl.addEventListener(this.data.on, this.updateOffset); + } else { + this.updateOffset(); + } }, - updateOffset() { - const offsetVector = new THREE.Vector3().copy(this.data.offset); - this.data.target.object3D.localToWorld(offsetVector); - this.el.setAttribute("position", offsetVector); - this.data.target.object3D.getWorldQuaternion(this.el.object3D.quaternion); - } + + updateOffset: (function() { + const offsetVector = new THREE.Vector3(); + return function() { + const obj = this.el.object3D; + const target = this.data.target.object3D; + offsetVector.copy(this.data.offset); + target.localToWorld(offsetVector); + if (obj.parent) { + obj.parent.worldToLocal(offsetVector); + } + obj.position.copy(offsetVector); + // 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); + if (this.data.selfDestruct) { + if (this.data.on) { + this.el.sceneEl.removeEventListener(this.data.on, this.updateOffset); + } + this.el.removeAttribute("offset-relative-to"); + } + }; + })() }); diff --git a/src/components/position-at-box-shape-border.js b/src/components/position-at-box-shape-border.js new file mode 100644 index 0000000000000000000000000000000000000000..59776bf6de0c2f12f2b46e40720a4287dee49c9b --- /dev/null +++ b/src/components/position-at-box-shape-border.js @@ -0,0 +1,89 @@ +const PI = Math.PI; +const HALF_PI = PI / 2; +const THREE_HALF_PI = 3 * PI / 2; +const right = new THREE.Vector3(1, 0, 0); +const forward = new THREE.Vector3(0, 0, 1); +const left = new THREE.Vector3(-1, 0, 0); +const back = new THREE.Vector3(0, 0, -1); +const dirs = { + left: { + dir: left, + rotation: THREE_HALF_PI, + halfExtent: "x" + }, + right: { + dir: right, + rotation: HALF_PI, + halfExtent: "x" + }, + forward: { + dir: forward, + rotation: 0, + halfExtent: "z" + }, + back: { + dir: back, + rotation: PI, + halfExtent: "z" + } +}; + +AFRAME.registerComponent("position-at-box-shape-border", { + schema: { + target: { type: "string" }, + dirs: { default: ["left", "right", "forward", "back"] } + }, + + init() { + this.cam = this.el.sceneEl.camera.el.object3D; + }, + + update() { + this.dirs = this.data.dirs.map(d => dirs[d]); + }, + + tick: (function() { + const camWorldPos = new THREE.Vector3(); + const targetPosition = new THREE.Vector3(); + const pointOnBoxFace = new THREE.Vector3(); + return function() { + if (!this.shape) { + this.shape = this.el.components["shape"]; + if (!this.shape) return; + } + if (!this.target) { + this.target = this.el.querySelector(this.data.target).object3D; + if (!this.target) return; + } + const halfExtents = this.shape.data.halfExtents; + this.cam.getWorldPosition(camWorldPos); + + let minSquareDistance = Infinity; + let targetDir = this.dirs[0].dir; + let targetHalfExtent = halfExtents[this.dirs[0].halfExtent]; + let targetRotation = this.dirs[0].rotation; + + for (let i = 0; i < this.dirs.length; i++) { + const dir = this.dirs[i].dir; + const halfExtent = halfExtents[this.dirs[i].halfExtent]; + pointOnBoxFace.copy(dir).multiplyScalar(halfExtent); + this.el.object3D.localToWorld(pointOnBoxFace); + const squareDistance = pointOnBoxFace.distanceToSquared(camWorldPos); + if (squareDistance < minSquareDistance) { + minSquareDistance = squareDistance; + targetDir = dir; + targetHalfExtent = halfExtent; + targetRotation = this.dirs[i].rotation; + } + } + + this.target.position.copy( + targetPosition + .copy(targetDir) + .multiplyScalar(targetHalfExtent) + .add(this.shape.data.offset) + ); + this.target.rotation.set(0, targetRotation, 0); + }; + })() +}); diff --git a/src/components/remove-networked-object-button.js b/src/components/remove-networked-object-button.js new file mode 100644 index 0000000000000000000000000000000000000000..39ec27b754a2dfbb5476c9490ee40e3c30f12f79 --- /dev/null +++ b/src/components/remove-networked-object-button.js @@ -0,0 +1,18 @@ +AFRAME.registerComponent("remove-networked-object-button", { + init() { + this.onClick = () => { + this.targetEl.parentNode.removeChild(this.targetEl); + }; + NAF.utils.getNetworkedEntity(this.el).then(networkedEl => { + this.targetEl = networkedEl; + }); + }, + + play() { + this.el.addEventListener("click", this.onClick); + }, + + pause() { + this.el.removeEventListener("click", this.onClick); + } +}); diff --git a/src/components/sticky-object.js b/src/components/sticky-object.js new file mode 100644 index 0000000000000000000000000000000000000000..d415779078f6a0acb2d6975170cde39cbd80cf47 --- /dev/null +++ b/src/components/sticky-object.js @@ -0,0 +1,130 @@ +/* global THREE, CANNON, AFRAME */ +AFRAME.registerComponent("sticky-object", { + dependencies: ["body", "super-networked-interactable"], + + schema: { + autoLockOnLoad: { default: false }, + autoLockOnRelease: { default: false }, + autoLockSpeedLimit: { default: 0.25 } + }, + + init() { + this._onGrab = this._onGrab.bind(this); + this._onRelease = this._onRelease.bind(this); + this._onBodyLoaded = this._onBodyLoaded.bind(this); + }, + + play() { + this.el.addEventListener("grab-start", this._onGrab); + this.el.addEventListener("grab-end", this._onRelease); + this.el.addEventListener("body-loaded", this._onBodyLoaded); + }, + + pause() { + this.el.removeEventListener("grab-start", this._onGrab); + this.el.removeEventListener("grab-end", this._onRelease); + this.el.removeEventListener("body-loaded", this._onBodyLoaded); + }, + + setLocked(locked) { + if (!NAF.utils.isMine(this.el)) return; + + const mass = this.el.components["super-networked-interactable"].data.mass; + this.locked = locked; + this.el.body.type = locked ? window.CANNON.Body.STATIC : window.CANNON.Body.DYNAMIC; + this.el.setAttribute("body", { + mass: locked ? 0 : mass + }); + }, + + _onBodyLoaded() { + if (this.data.autoLockOnLoad) { + this.setLocked(true); + } + }, + + _onRelease() { + if ( + this.data.autoLockOnRelease && + this.el.body.velocity.lengthSquared() < this.data.autoLockSpeedLimit * this.data.autoLockSpeedLimit + ) { + this.setLocked(true); + } + }, + + _onGrab() { + this.setLocked(false); + }, + + remove() { + if (this.stuckTo) { + const stuckTo = this.stuckTo; + delete this.stuckTo; + stuckTo._unstickObject(); + } + } +}); + +AFRAME.registerComponent("sticky-object-zone", { + dependencies: ["physics"], + init() { + // TODO: position/rotation/impulse need to get updated if the sticky-object-zone moves + this.worldQuaternion = new THREE.Quaternion(); + this.worldPosition = new THREE.Vector3(); + this.el.object3D.getWorldQuaternion(this.worldQuaternion); + this.el.object3D.getWorldPosition(this.worldPosition); + + const dir = new THREE.Vector3(0, 0, 5).applyQuaternion(this.el.object3D.quaternion); + this.bootImpulsePosition = new CANNON.Vec3(0, 0, 0); + this.bootImpulse = new CANNON.Vec3(); + this.bootImpulse.copy(dir); + + this._onCollisions = this._onCollisions.bind(this); + this.el.addEventListener("collisions", this._onCollisions); + }, + + remove() { + this.el.removeEventListener("collisions", this._onCollisions); + }, + + _onCollisions(e) { + e.detail.els.forEach(el => { + const stickyObject = el.components["sticky-object"]; + if (!stickyObject) return; + this._setStuckObject(stickyObject); + }); + if (this.stuckObject) { + e.detail.clearedEls.forEach(el => { + if (this.stuckObject && this.stuckObject.el === el) { + this._unstickObject(); + } + }); + } + }, + + _setStuckObject(stickyObject) { + stickyObject.setLocked(true); + stickyObject.el.object3D.position.copy(this.worldPosition); + stickyObject.el.object3D.quaternion.copy(this.worldQuaternion); + stickyObject.el.body.collisionResponse = false; + stickyObject.stuckTo = this; + + if (this.stuckObject && NAF.utils.isMine(this.stuckObject.el)) { + const el = this.stuckObject.el; + this._unstickObject(); + el.body.applyImpulse(this.bootImpulse, this.bootImpulsePosition); + } + + this.stuckObject = stickyObject; + }, + + _unstickObject() { + // this condition will be false when dragging an object directly from one sticky zone to another + if (this.stuckObject.stuckTo === this) { + this.stuckObject.setLocked(false); + this.stuckObject.el.body.collisionResponse = true; + delete this.stuckObject.stuckTo; + } + delete this.stuckObject; + } +}); diff --git a/src/components/super-networked-interactable.js b/src/components/super-networked-interactable.js index 418b8e50f9b6d5b8f3eb7b89f4547e4dd23aee47..3b84bb7ac3b99421024113d527aa8a1ea54da725 100644 --- a/src/components/super-networked-interactable.js +++ b/src/components/super-networked-interactable.js @@ -24,17 +24,17 @@ AFRAME.registerComponent("super-networked-interactable", { } }); - this.grabStartListener = this._onGrabStart.bind(this); - this.ownershipLostListener = this._onOwnershipLost.bind(this); - this.el.addEventListener("grab-start", this.grabStartListener); - this.el.addEventListener("ownership-lost", this.ownershipLostListener); + this._onGrabStart = this._onGrabStart.bind(this); + this._onOwnershipLost = this._onOwnershipLost.bind(this); + this.el.addEventListener("grab-start", this._onGrabStart); + this.el.addEventListener("ownership-lost", this._onOwnershipLost); this.system.addComponent(this); }, remove: function() { this.counter.deregister(this.el); - this.el.removeEventListener("grab-start", this.grabStartListener); - this.el.removeEventListener("ownership-lost", this.ownershipLostListener); + this.el.removeEventListener("grab-start", this._onGrabStart); + this.el.removeEventListener("ownership-lost", this._onOwnershipLost); this.system.removeComponent(this); }, diff --git a/src/components/text-button.js b/src/components/text-button.js index 67af3653dcc4c9e81b59aec376feab6a00eb7fe4..491b482a248099ae8a277ed5cb04dbb638df6f83 100644 --- a/src/components/text-button.js +++ b/src/components/text-button.js @@ -57,3 +57,12 @@ AFRAME.registerComponent("text-button", { this.textEl.setAttribute("text", "color", hovering ? this.data.textHoverColor : this.data.textColor); } }); + +const noop = function() {}; +// TODO: this should ideally be fixed upstream somehow but its pretty tricky since text is just a geometry not a different type of Object3D, and Object3D is what handles raycast checks. +AFRAME.registerComponent("text-raycast-hack", { + dependencies: ["text"], + init() { + this.el.getObject3D("text").raycast = noop; + } +}); diff --git a/src/gltf-component-mappings.js b/src/gltf-component-mappings.js index d036e0e6c14d308ad07f3458f5f937c3dccdfeda..0552e61c854ad7efe6ed5e97020b512ed5d0feac 100644 --- a/src/gltf-component-mappings.js +++ b/src/gltf-component-mappings.js @@ -23,6 +23,7 @@ AFRAME.GLTFModelPlus.registerComponent("shape", "shape"); AFRAME.GLTFModelPlus.registerComponent("visible", "visible"); AFRAME.GLTFModelPlus.registerComponent("spawn-point", "spawn-point"); AFRAME.GLTFModelPlus.registerComponent("hoverable", "hoverable"); +AFRAME.GLTFModelPlus.registerComponent("sticky-zone", "sticky-zone"); AFRAME.GLTFModelPlus.registerComponent("nav-mesh", "nav-mesh", (el, componentName, componentData, gltfPath) => { if (componentData.src) { componentData.src = resolveURL(componentData.src, gltfPath); diff --git a/src/hub.html b/src/hub.html index da521280d134f2677f41d4013a1e2cf010435d4b..f71396377f6f10cb02d076f08b430242c01522bf 100644 --- a/src/hub.html +++ b/src/hub.html @@ -120,31 +120,8 @@ <a-entity> <a-entity personal-space-invader="radius: 0.2; useMaterial: true;" bone-visibility> </a-entity> <a-entity billboard> - <a-entity - block-button - visible-while-frozen - ui-class-while-frozen - text-button="haptic:#player-right-controller; - textHoverColor: #fff; - textColor: #fff; - backgroundHoverColor: #ea4b54; - backgroundColor: #fff;" - slice9="width: 0.45; - height: 0.2; - left: 53; - top: 53; - right: 10; - bottom: 10; - opacity: 1.3; - src: #tooltip" - position="0 0 .35"> - </a-entity> - <a-entity - visible-while-frozen - text="value:Block; - width:2.5; - align:center;" - position="0 0 0.36"></a-entity> + <a-entity mixin="rounded-text-button" block-button visible-while-frozen ui-class-while-frozen position="0 0 .35"> </a-entity> + <a-entity visible-while-frozen text="value:Block; width:2.5; align:center;" position="0 0 0.36"></a-entity> </a-entity> </a-entity> </template> @@ -183,13 +160,75 @@ class="interactable" super-networked-interactable="counter: #counter; mass: 1;" body="type: dynamic; shape: none; mass: 1;" + auto-scale-cannon-physics-body grabbable - stretchable="useWorldPosition: true;" + stretchable="useWorldPosition: true; usePhysics: never" hoverable duck + sticky-object="autoLockOnRelease: true;" ></a-entity> </template> + <template id="interactable-model"> + <a-entity + gltf-model-plus="inflate: false;" + class="interactable" + super-networked-interactable="counter: #media-counter; mass: 1;" + body="type: dynamic; shape: none; mass: 1;" + grabbable + stretchable="useWorldPosition: true; usePhysics: never" + hoverable + sticky-object="autoLockOnRelease: true; autoLockOnLoad: true;" + auto-box-collider + position-at-box-shape-border="target:.delete-button" + auto-scale-cannon-physics-body + > + <a-entity class="delete-button" visible-while-frozen scale="0.08 0.08 0.08"> + <a-entity mixin="rounded-text-button" remove-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> + </a-entity> + </a-entity> + </template> + + <template id="interactable-image"> + <a-entity + class="interactable" + super-networked-interactable="counter: #media-counter; mass: 1;" + body="type: dynamic; shape: none; mass: 1;" + auto-scale-cannon-physics-body + grabbable + stretchable="useWorldPosition: true; usePhysics: never" + hoverable + geometry="primitive: plane" + image-plus + sticky-object="autoLockOnLoad: true; autoLockOnRelease: true;" + position-at-box-shape-border="target:.delete-button;dirs:forward,back" + > + <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> + </a-entity> + </a-entity> + </template> + + <a-mixin id="rounded-text-button" + text-button=" + haptic:#player-right-controller; + textHoverColor: #fff; + textColor: #fff; + backgroundHoverColor: #ea4b54; + backgroundColor: #fff;" + slice9=" + width: 0.45; + height: 0.2; + left: 53; + top: 53; + right: 10; + bottom: 10; + opacity: 1.3; + src: #tooltip" + ></a-mixin> + <a-mixin id="controller-super-hands" super-hands=" colliderEvent: collisions; colliderEventProperty: els; @@ -204,6 +243,7 @@ <!-- Interactables --> <a-entity id="counter" networked-counter="max: 3; ttl: 120"></a-entity> + <a-entity id="media-counter" networked-counter="max: 10;"></a-entity> <a-entity id="cursor-controller" @@ -368,6 +408,7 @@ nav-mesh-helper static-body="shape: none;" ></a-entity> + </a-scene> <div id="ui-root"></div> diff --git a/src/hub.js b/src/hub.js index 06369fd1d124211ebeace0c553371ea622295af3..fa21149111a673f2390b7acc6b33eaeb3dbfa840 100644 --- a/src/hub.js +++ b/src/hub.js @@ -63,10 +63,16 @@ import "./components/networked-avatar"; import "./components/css-class"; import "./components/scene-shadow"; import "./components/avatar-replay"; +import "./components/image-plus"; +import "./components/auto-box-collider"; import "./components/pinch-to-move"; import "./components/look-on-mobile"; import "./components/pitch-yaw-rotator"; import "./components/input-configurator"; +import "./components/sticky-object"; +import "./components/auto-scale-cannon-physics-body"; +import "./components/position-at-box-shape-border"; +import "./components/remove-networked-object-button"; import ReactDOM from "react-dom"; import React from "react"; @@ -75,6 +81,7 @@ import HubChannel from "./utils/hub-channel"; import LinkChannel from "./utils/link-channel"; import { connectToReticulum } from "./utils/phoenix-utils"; import { disableiOSZoom } from "./utils/disable-ios-zoom"; +import { addMedia } from "./utils/media-utils"; import "./systems/personal-space-bubble"; import "./systems/app-mode"; @@ -296,6 +303,33 @@ const onReady = async () => { NAF.connection.entities.completeSync(ev.detail.clientId); }); + scene.addEventListener("add_media", e => { + addMedia(e.detail); + }); + + if (qsTruthy("mediaTools")) { + document.addEventListener("paste", e => { + if (e.target.nodeName === "INPUT") return; + + const imgUrl = e.clipboardData.getData("text"); + console.log("Pasted: ", imgUrl, e); + addMedia(imgUrl); + }); + + document.addEventListener("dragover", e => { + e.preventDefault(); + }); + + document.addEventListener("drop", e => { + e.preventDefault(); + const imgUrl = e.dataTransfer.getData("url"); + if (imgUrl) { + console.log("Droped: ", imgUrl); + addMedia(imgUrl); + } + }); + } + if (!qsTruthy("offline")) { document.body.addEventListener("connected", () => { if (!isBotMode) { diff --git a/src/input-mappings.js b/src/input-mappings.js index c6ce52501b479fa5749437db3da0882659129cb5..ccf44110bc74e38ab80de143c11f2dcad50d2a5c 100644 --- a/src/input-mappings.js +++ b/src/input-mappings.js @@ -149,8 +149,7 @@ const config = { m_press: "action_mute", q_press: "snap_rotate_left", e_press: "snap_rotate_right", - v_press: "action_share_screen", - b_press: "action_select_hud_item", + b_press: "action_share_screen", // We can't create a keyboard behaviour with AFIM yet, // so these will get captured by wasd-to-analog2d diff --git a/src/network-schemas.js b/src/network-schemas.js index ca4d0f401e3420c75ba6be1f69831ff2006f508c..a67b0d02381f0accc53472818dfdcac56951171a 100644 --- a/src/network-schemas.js +++ b/src/network-schemas.js @@ -95,6 +95,27 @@ function registerNetworkSchemas() { "scale" ] }); + + NAF.schemas.add({ + template: "#interactable-image", + components: [ + { + component: "position", + requiresNetworkUpdate: vectorRequiresUpdate(0.001) + }, + { + component: "rotation", + requiresNetworkUpdate: vectorRequiresUpdate(0.5) + }, + "scale", + "image-plus" + ] + }); + + NAF.schemas.add({ + template: "#interactable-model", + components: ["position", "rotation", "scale", "gltf-model-plus"] + }); } export default registerNetworkSchemas; diff --git a/src/react-components/2d-hud.js b/src/react-components/2d-hud.js index 141606a83d121f0a280ac5ac6b6d997856f7fc1e..e32a02d3eca931e112eb976b3c1cdcafc55f6dff 100644 --- a/src/react-components/2d-hud.js +++ b/src/react-components/2d-hud.js @@ -1,10 +1,30 @@ import React from "react"; import PropTypes from "prop-types"; import cx from "classnames"; +import queryString from "query-string"; import styles from "../assets/stylesheets/2d-hud.scss"; -const TwoDHUD = ({ muted, frozen, spacebubble, onToggleMute, onToggleFreeze, onToggleSpaceBubble }) => ( +import FontAwesomeIcon from "@fortawesome/react-fontawesome"; +import faPlus from "@fortawesome/fontawesome-free-solid/faPlus"; + +const qs = queryString.parse(location.search); +function qsTruthy(param) { + const val = qs[param]; + // if the param exists but is not set (e.g. "?foo&bar"), its value is null. + return val === null || /1|on|true/i.test(val); +} +const enableMediaTools = qsTruthy("mediaTools"); + +const TwoDHUD = ({ + muted, + frozen, + spacebubble, + onToggleMute, + onToggleFreeze, + onToggleSpaceBubble, + onClickAddMedia +}) => ( <div className={styles.container}> <div className={cx("ui-interactive", styles.panel, styles.left)}> <div @@ -25,6 +45,15 @@ const TwoDHUD = ({ muted, frozen, spacebubble, onToggleMute, onToggleFreeze, onT onClick={onToggleSpaceBubble} /> </div> + {enableMediaTools ? ( + <div + className={cx("ui-interactive", styles.iconButton, styles.small, styles.addMediaButton)} + title="Add Media" + onClick={onClickAddMedia} + > + <FontAwesomeIcon icon={faPlus} /> + </div> + ) : null} </div> ); @@ -34,7 +63,8 @@ TwoDHUD.propTypes = { spacebubble: PropTypes.bool, onToggleMute: PropTypes.func, onToggleFreeze: PropTypes.func, - onToggleSpaceBubble: PropTypes.func + onToggleSpaceBubble: PropTypes.func, + onClickAddMedia: PropTypes.func }; export default TwoDHUD; diff --git a/src/react-components/info-dialog.js b/src/react-components/info-dialog.js index a04127ff74ff2b4722d0a2fbe1a99dd07568efc0..83b46b9dfee5569044581b44879566a13ec3c934 100644 --- a/src/react-components/info-dialog.js +++ b/src/react-components/info-dialog.js @@ -8,6 +8,8 @@ import LinkDialog from "./link-dialog.js"; // TODO i18n +let lastAddMediaUrl = ""; + class InfoDialog extends Component { static dialogTypes = { slack: Symbol("slack"), @@ -18,12 +20,14 @@ class InfoDialog extends Component { report: Symbol("report"), help: Symbol("help"), link: Symbol("link"), - webvr_recommend: Symbol("webvr_recommend") + webvr_recommend: Symbol("webvr_recommend"), + add_media: Symbol("add_media") }; static propTypes = { dialogType: PropTypes.oneOf(Object.values(InfoDialog.dialogTypes)), onCloseDialog: PropTypes.func, onSubmittedEmail: PropTypes.func, + onAddMedia: PropTypes.func, linkCode: PropTypes.string }; @@ -34,14 +38,17 @@ class InfoDialog extends Component { this.shareLink = `${loc.protocol}//${loc.host}${loc.pathname}`; this.onKeyDown = this.onKeyDown.bind(this); this.onContainerClicked = this.onContainerClicked.bind(this); + this.onAddMediaClicked = this.onAddMediaClicked.bind(this); } componentDidMount() { window.addEventListener("keydown", this.onKeyDown); + this.setState({ addMediaUrl: lastAddMediaUrl }); } componentWillUnmount() { window.removeEventListener("keydown", this.onKeyDown); + lastAddMediaUrl = this.state.addMediaUrl; } onKeyDown(e) { @@ -56,6 +63,11 @@ class InfoDialog extends Component { } } + onAddMediaClicked() { + this.props.onAddMedia(this.state.addMediaUrl); + this.props.onCloseDialog(); + } + shareLinkClicked = () => { navigator.share({ title: document.title, @@ -71,7 +83,8 @@ class InfoDialog extends Component { state = { mailingListEmail: "", mailingListPrivacy: false, - copyLinkButtonText: "Copy" + copyLinkButtonText: "Copy", + addMediaUrl: "" }; signUpForMailingList = async e => { @@ -184,6 +197,31 @@ class InfoDialog extends Component { </div> ); break; + case InfoDialog.dialogTypes.add_media: + dialogTitle = "Add Media"; + dialogBody = ( + <div> + <div>Tip: You can paste media urls directly into hubs with ctrl+v</div> + <form onSubmit={this.onAddMediaClicked}> + <div className="add-media-form"> + <input + type="url" + placeholder="Image, Video, or GLTF URL" + className="add-media-form__link_field" + value={this.state.addMediaUrl} + onChange={e => this.setState({ addMediaUrl: e.target.value })} + required + /> + <div className="add-media-form__buttons"> + <button className="add-media-form__action-button"> + <span>Add</span> + </button> + </div> + </div> + </form> + </div> + ); + break; case InfoDialog.dialogTypes.updates: dialogTitle = ""; dialogBody = ( diff --git a/src/react-components/ui-root.js b/src/react-components/ui-root.js index 9b6c10ea2149fd02899fe217a2ba89b2c33d2187..c0733064a8fa05a513b040aeeb54401aca12ef1a 100644 --- a/src/react-components/ui-root.js +++ b/src/react-components/ui-root.js @@ -329,7 +329,7 @@ class UIRoot extends Component { mediaSource: "screen", // Work around BMO 1449832 by calculating the width. This will break for multi monitors if you share anything // other than your current monitor that has a different aspect ratio. - width: screen.width / screen.height * 720, + width: 720 * screen.width / screen.height, height: 720, frameRate: 30 } @@ -528,6 +528,10 @@ class UIRoot extends Component { this.setState({ infoDialogType: null, linkCode: null, linkCodeCancel: null }); }; + handleAddMedia = url => { + this.props.scene.emit("add_media", url); + }; + render() { if (this.state.exited || this.props.roomUnavailableReason || this.props.platformUnsupportedReason) { let subtitle = null; @@ -835,6 +839,7 @@ class UIRoot extends Component { linkCode={this.state.linkCode} onSubmittedEmail={() => this.setState({ infoDialogType: InfoDialog.dialogTypes.email_submitted })} onCloseDialog={this.handleCloseDialog} + onAddMedia={this.handleAddMedia} /> {this.state.entryStep === ENTRY_STEPS.finished && ( @@ -872,6 +877,7 @@ class UIRoot extends Component { onToggleMute={this.toggleMute} onToggleFreeze={this.toggleFreeze} onToggleSpaceBubble={this.toggleSpaceBubble} + onClickAddMedia={() => this.setState({ infoDialogType: InfoDialog.dialogTypes.add_media })} /> <Footer hubName={this.props.hubName} diff --git a/src/utils/media-utils.js b/src/utils/media-utils.js new file mode 100644 index 0000000000000000000000000000000000000000..9c17813b926e5541eef96b586f8efb133e696f6c --- /dev/null +++ b/src/utils/media-utils.js @@ -0,0 +1,75 @@ +const whitelistedHosts = [/^.*\.reticulum\.io$/, /^.*hubs\.mozilla\.com$/, /^hubs\.local$/]; +const isHostWhitelisted = hostname => !!whitelistedHosts.filter(r => r.test(hostname)).length; + +let resolveMediaUrl = "/api/v1/media"; +if (process.env.NODE_ENV === "development") { + resolveMediaUrl = `https://${process.env.DEV_RETICULUM_SERVER}${resolveMediaUrl}`; +} + +export const resolveFarsparkUrl = async url => { + const parsedUrl = new URL(url); + if ((parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") || isHostWhitelisted(parsedUrl.hostname)) + return url; + + return (await fetch(resolveMediaUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ media: { url } }) + }).then(r => r.json())).raw; +}; + +const fetchContentType = async url => fetch(url, { method: "HEAD" }).then(r => r.headers.get("content-type")); +let interactableId = 0; + +const offset = { x: 0, y: 0, z: -1.5 }; +export const spawnNetworkedImage = (src, contentType) => { + const scene = AFRAME.scenes[0]; + const image = document.createElement("a-entity"); + image.id = "interactable-image-" + interactableId++; + image.setAttribute("networked", { template: "#interactable-image" }); + image.setAttribute("offset-relative-to", { + target: "#player-camera", + offset: offset, + selfDestruct: true + }); + image.setAttribute("image-plus", { src, contentType }); + scene.appendChild(image); + return image; +}; + +export const spawnNetworkedInteractable = src => { + const scene = AFRAME.scenes[0]; + const model = document.createElement("a-entity"); + model.id = "interactable-model-" + interactableId++; + model.setAttribute("networked", { template: "#interactable-model" }); + model.setAttribute("offset-relative-to", { + on: "model-loaded", + target: "#player-camera", + offset: offset, + selfDestruct: true + }); + model.setAttribute("gltf-model-plus", "src", src); + model.setAttribute("auto-box-collider", { resize: true }); + scene.appendChild(model); + return model; +}; + +export const addMedia = async url => { + try { + const farsparkUrl = await resolveFarsparkUrl(url); + console.log("resolved", url, farsparkUrl); + + const contentType = await fetchContentType(farsparkUrl); + + if (contentType.startsWith("image/") || contentType.startsWith("video/")) { + spawnNetworkedImage(farsparkUrl, contentType); + } else if (contentType.startsWith("model/gltf") || url.endsWith(".gltf") || url.endsWith(".glb")) { + spawnNetworkedInteractable(farsparkUrl); + } else { + throw new Error(`Unsupported content type: ${contentType}`); + } + } catch (e) { + console.error("Error adding media", e); + spawnNetworkedImage("error"); + } +}; diff --git a/src/vendor/GLTFLoader.js b/src/vendor/GLTFLoader.js index 7610044b41b6dd9956b2d84984e46671de46e494..754ae5d9460e80e176a928cc53ea75ab7a6f5212 100644 --- a/src/vendor/GLTFLoader.js +++ b/src/vendor/GLTFLoader.js @@ -7,8 +7,11 @@ * @author Tony Parisi / http://www.tonyparisi.com/ * @author Takahiro / https://github.com/takahirox * @author Don McCurdy / https://www.donmccurdy.com + * @author netpro2k / https://github.com/netpro2k */ + import { resolveFarsparkUrl } from "../utils/media-utils" + THREE.GLTFLoader = ( function () { function GLTFLoader( manager ) { @@ -25,7 +28,7 @@ THREE.GLTFLoader = ( function () { crossOrigin: 'Anonymous', - load: function ( url, onLoad, onProgress, onError ) { + load: async function ( url, onLoad, onProgress, onError ) { var scope = this; @@ -37,7 +40,9 @@ THREE.GLTFLoader = ( function () { loader.setResponseType( 'arraybuffer' ); - loader.load( url, function ( data ) { + var farsparkURL = await resolveFarsparkUrl(url); + + loader.load( farsparkURL, function ( data ) { try { @@ -1598,7 +1603,7 @@ THREE.GLTFLoader = ( function () { * @param {number} bufferIndex * @return {Promise<ArrayBuffer>} */ - GLTFParser.prototype.loadBuffer = function ( bufferIndex ) { + GLTFParser.prototype.loadBuffer = async function ( bufferIndex ) { var bufferDef = this.json.buffers[ bufferIndex ]; var loader = this.fileLoader; @@ -1618,9 +1623,11 @@ THREE.GLTFLoader = ( function () { var options = this.options; + var farsparkURL = await resolveFarsparkUrl(resolveURL(bufferDef.uri, options.path)); + return new Promise( function ( resolve, reject ) { - loader.load( resolveURL( bufferDef.uri, options.path ), resolve, undefined, function () { + loader.load( farsparkURL, resolve, undefined, function () { reject( new Error( 'THREE.GLTFLoader: Failed to load buffer "' + bufferDef.uri + '".' ) ); @@ -1784,7 +1791,7 @@ THREE.GLTFLoader = ( function () { * @param {number} textureIndex * @return {Promise<THREE.Texture>} */ - GLTFParser.prototype.loadTexture = function ( textureIndex ) { + GLTFParser.prototype.loadTexture = async function ( textureIndex ) { var parser = this; var json = this.json; @@ -1798,11 +1805,12 @@ THREE.GLTFLoader = ( function () { var sourceURI = source.uri; var isObjectURL = false; - if ( source.bufferView !== undefined ) { + var hasBufferView = source.bufferView !== undefined; + if ( hasBufferView ) { // Load binary image data from bufferView, if provided. - sourceURI = parser.getDependency( 'bufferView', source.bufferView ).then( function ( bufferView ) { + sourceURI = await parser.getDependency( 'bufferView', source.bufferView ).then( function ( bufferView ) { isObjectURL = true; var blob = new Blob( [ bufferView ], { type: source.mimeType } ); @@ -1813,6 +1821,11 @@ THREE.GLTFLoader = ( function () { } + var urlToLoad = resolveURL(sourceURI, options.path); + if (!hasBufferView){ + urlToLoad = await resolveFarsparkUrl(urlToLoad); + } + return Promise.resolve( sourceURI ).then( function ( sourceURI ) { // Load Texture resource. @@ -1821,7 +1834,7 @@ THREE.GLTFLoader = ( function () { return new Promise( function ( resolve, reject ) { - loader.load( resolveURL( sourceURI, options.path ), resolve, undefined, reject ); + loader.load( urlToLoad, resolve, undefined, reject ); } ); diff --git a/src/workers/gifparsing.worker.js b/src/workers/gifparsing.worker.js new file mode 100644 index 0000000000000000000000000000000000000000..643a95ab98e52c048ef551a6249e3ef937191389 --- /dev/null +++ b/src/workers/gifparsing.worker.js @@ -0,0 +1,72 @@ +/** + * + * Gif parser by @gtk2k + * https://github.com/gtk2k/gtk2k.github.io/tree/master/animation_gif + * + */ + +const parseGIF = function(gif, successCB, errorCB) { + let pos = 0; + const delayTimes = []; + let graphicControl = null; + const frames = []; + const disposals = []; + let loopCnt = 0; + if ( + gif[0] === 0x47 && + gif[1] === 0x49 && + gif[2] === 0x46 && // 'GIF' + gif[3] === 0x38 && + gif[4] === 0x39 && + gif[5] === 0x61 + ) { + // '89a' + pos += 13 + +!!(gif[10] & 0x80) * Math.pow(2, (gif[10] & 0x07) + 1) * 3; + const gifHeader = gif.subarray(0, pos); + while (gif[pos] && gif[pos] !== 0x3b) { + const offset = pos, + blockId = gif[pos]; + if (blockId === 0x21) { + const label = gif[++pos]; + if ([0x01, 0xfe, 0xf9, 0xff].indexOf(label) !== -1) { + label === 0xf9 && delayTimes.push((gif[pos + 3] + (gif[pos + 4] << 8)) * 10); + label === 0xff && (loopCnt = gif[pos + 15] + (gif[pos + 16] << 8)); + while (gif[++pos]) pos += gif[pos]; + if (label === 0xf9) { + graphicControl = gif.subarray(offset, pos + 1); + disposals.push((graphicControl[3] >> 2) & 0x07); + } + } else { + errorCB && errorCB("parseGIF: unknown label"); + break; + } + } else if (blockId === 0x2c) { + pos += 9; + pos += 1 + +!!(gif[pos] & 0x80) * (Math.pow(2, (gif[pos] & 0x07) + 1) * 3); + while (gif[++pos]) pos += gif[pos]; + const imageData = gif.subarray(offset, pos + 1); + frames.push(URL.createObjectURL(new Blob([gifHeader, graphicControl, imageData]))); + } else { + errorCB && errorCB("parseGIF: unknown blockId"); + break; + } + pos++; + } + } else { + errorCB && errorCB("parseGIF: no GIF89a"); + } + successCB && successCB(delayTimes, loopCnt, frames, disposals); +}; + +self.onmessage = e => { + parseGIF( + new Uint8Array(e.data), + (delays, loopcnt, frames, disposals) => { + self.postMessage([true, frames, delays, disposals]); + }, + err => { + console.error("Error in gif parsing worker", err); + self.postMessage([false, err]); + } + ); +}; diff --git a/webpack.config.js b/webpack.config.js index 7f4daa883ea1e19355c8306afb3c5d4a2e5cf39b..e0b082e08f58abdfee4977a7029cec5f613463b2 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -129,6 +129,10 @@ const config = { interpolate: "require" } }, + { + test: /\.worker\.js$/, + use: { loader: "worker-loader" } + }, { test: /\.js$/, include: [path.resolve(__dirname, "src")], diff --git a/yarn.lock b/yarn.lock index 22663266a5572652e4b21a4a89850964cca3ae6a..0dc13a86aa4c0e7a671857cc211ee5934b5b12f0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -182,9 +182,9 @@ aframe-physics-extras@^0.1.3: version "0.1.3" resolved "https://registry.yarnpkg.com/aframe-physics-extras/-/aframe-physics-extras-0.1.3.tgz#803e2164fb96c0a80f2d1a81458f3277f262b130" -"aframe-physics-system@github:donmccurdy/aframe-physics-system": +"aframe-physics-system@https://github.com/mozillareality/aframe-physics-system#hubs/master": version "3.1.2" - resolved "https://codeload.github.com/donmccurdy/aframe-physics-system/tar.gz/c142a301e3ce76f88bab817c89daa5b3d4d97815" + resolved "https://github.com/mozillareality/aframe-physics-system#50f5deb1134eb0d43c0435d287eef7037818d3cc" dependencies: browserify "^14.3.0" budo "^10.0.3" @@ -4956,7 +4956,7 @@ loader-utils@^0.2.16: json5 "^0.5.0" object-assign "^4.0.1" -loader-utils@^1.0.1, loader-utils@^1.0.2, loader-utils@^1.1.0: +loader-utils@^1.0.0, loader-utils@^1.0.1, loader-utils@^1.0.2, loader-utils@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.1.0.tgz#c98aef488bcceda2ffb5e2de646d6a754429f5cd" dependencies: @@ -5266,6 +5266,10 @@ mime@1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/mime/-/mime-1.4.1.tgz#121f9ebc49e3766f311a76e1fa1c8003c4b03aa6" +mime@^2.0.3: + version "2.3.1" + resolved "https://registry.yarnpkg.com/mime/-/mime-2.3.1.tgz#b1621c54d63b97c47d3cfe7f7215f7d64517c369" + mime@^2.1.0: version "2.2.0" resolved "https://registry.yarnpkg.com/mime/-/mime-2.2.0.tgz#161e541965551d3b549fa1114391e3a3d55b923b" @@ -7196,7 +7200,7 @@ sax@~1.2.1: version "1.2.4" resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" -schema-utils@^0.4.2, schema-utils@^0.4.3, schema-utils@^0.4.5: +schema-utils@^0.4.0, schema-utils@^0.4.2, schema-utils@^0.4.3, schema-utils@^0.4.5: version "0.4.5" resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-0.4.5.tgz#21836f0608aac17b78f9e3e24daff14a5ca13a3e" dependencies: @@ -8268,6 +8272,14 @@ url-join@^2.0.2: version "2.0.5" resolved "https://registry.yarnpkg.com/url-join/-/url-join-2.0.5.tgz#5af22f18c052a000a48d7b82c5e9c2e2feeda728" +url-loader@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/url-loader/-/url-loader-1.0.1.tgz#61bc53f1f184d7343da2728a1289ef8722ea45ee" + dependencies: + loader-utils "^1.1.0" + mime "^2.0.3" + schema-utils "^0.4.3" + url-parse-lax@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/url-parse-lax/-/url-parse-lax-1.0.0.tgz#7af8f303645e9bd79a272e7a14ac68bc0609da73" @@ -8673,6 +8685,13 @@ worker-farm@^1.5.2: errno "^0.1.4" xtend "^4.0.1" +worker-loader@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/worker-loader/-/worker-loader-2.0.0.tgz#45fda3ef76aca815771a89107399ee4119b430ac" + dependencies: + loader-utils "^1.0.0" + schema-utils "^0.4.0" + wrap-ansi@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85"