diff --git a/package.json b/package.json
index d724d140eb838e392c7eaf4cfb483b039277c0ba..3212db6bdc3cf7928fbd2ed1269697920f67e258 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#monitor-scale-on-body",
     "aframe-rounded": "^1.0.3",
     "aframe-slice9-component": "^1.0.0",
     "aframe-teleport-controls": "https://github.com/mozillareality/aframe-teleport-controls#hubs/master",
diff --git a/src/components/auto-box-collider.js b/src/components/auto-box-collider.js
new file mode 100644
index 0000000000000000000000000000000000000000..2d3d2f53bbbd4cbe18a3f9fd1f19603badd8f907
--- /dev/null
+++ b/src/components/auto-box-collider.js
@@ -0,0 +1,46 @@
+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);
+  },
+
+  onLoaded() {
+    this.el.removeEventListener("model-loaded", this.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..e3ab60400e00f253e670736c2f94c44e301f4658
--- /dev/null
+++ b/src/components/auto-scale-cannon-physics-body.js
@@ -0,0 +1,27 @@
+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"],
+
+  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(0.001, scale, this.prevScale)) {
+      this.prevScale.copy(scale);
+      this.nextUpdateTime = t + 100;
+    }
+
+    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/offset-relative-to.js b/src/components/offset-relative-to.js
index 3cd45b942ed4573ee9b588a467de456af801f62f..82a90717ba22137ac38e045a893804f60a183d3b 100644
--- a/src/components/offset-relative-to.js
+++ b/src/components/offset-relative-to.js
@@ -12,16 +12,38 @@ 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);
+      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..f85d744a9f532290dccc0dfc799e0959831e4d6c
--- /dev/null
+++ b/src/components/position-at-box-shape-border.js
@@ -0,0 +1,65 @@
+const PI = Math.PI;
+const HALF_PI = PI / 2;
+const THREE_HALF_PI = 3 * PI / 2;
+const rotations = [THREE_HALF_PI, HALF_PI, 0, PI];
+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, right, forward, back];
+
+AFRAME.registerComponent("position-at-box-shape-border", {
+  schema: {
+    target: { type: "string" }
+  },
+
+  init() {
+    this.cam = this.el.sceneEl.camera.el.object3D;
+  },
+
+  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;
+      const halfExtentDirs = ["x", "x", "z", "z"];
+      this.cam.getWorldPosition(camWorldPos);
+
+      let minSquareDistance = Infinity;
+      let targetDir = dirs[0];
+      let targetHalfExtent = halfExtents[halfExtentDirs[0]];
+      let targetRotation = rotations[0];
+
+      for (let i = 0; i < dirs.length; i++) {
+        const dir = dirs[i];
+        const halfExtent = halfExtents[halfExtentDirs[i]];
+        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 = rotations[i];
+        }
+      }
+
+      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/hub.html b/src/hub.html
index 49723f07d5663cc4d03122e21c9477a63f3f6321..3d1eb594544eefa98ad90dbb0ce6b2a4566b1664 100644
--- a/src/hub.html
+++ b/src/hub.html
@@ -162,21 +162,44 @@
                     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
+                    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;"
+                    stretchable="useWorldPosition: true; usePhysics: never"
                     hoverable
                     geometry="primitive: plane"
                     material="shader: flat; side: double; transparent: true;"
diff --git a/src/hub.js b/src/hub.js
index 92fc1fdf833bb20e480cf5a947054df04ff8df0b..6631d12006ca3d4dd7bcc434eda054adedf075e7 100644
--- a/src/hub.js
+++ b/src/hub.js
@@ -64,11 +64,14 @@ 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 ReactDOM from "react-dom";
 import React from "react";
@@ -298,12 +301,29 @@ const onReady = async () => {
       NAF.connection.entities.completeSync(ev.detail.clientId);
     });
 
+    const offset = { x: 0, y: 0, z: -1.5 };
     const addMedia = url => {
-      const image = document.createElement("a-entity");
-      image.id = "interactable-image-" + Date.now();
-      image.setAttribute("image-plus", "src", url);
-      image.setAttribute("networked", { template: "#interactable-image" });
-      scene.appendChild(image);
+      const scene = AFRAME.scenes[0];
+      if (url.endsWith(".gltf") || url.endsWith(".glb")) {
+        const model = document.createElement("a-entity");
+        model.id = "interactable-model-" + Date.now();
+        model.setAttribute("offset-relative-to", {
+          on: "model-loaded",
+          target: "#player-camera",
+          offset: offset,
+          selfDestruct: true
+        });
+        model.setAttribute("gltf-model-plus", "src", url);
+        model.setAttribute("auto-box-collider", { resize: true });
+        model.setAttribute("networked", { template: "#interactable-model" });
+        scene.appendChild(model);
+      } else {
+        const image = document.createElement("a-entity");
+        image.id = "interactable-image-" + Date.now();
+        image.setAttribute("image-plus", "src", url);
+        image.setAttribute("networked", { template: "#interactable-image" });
+        scene.appendChild(image);
+      }
     };
 
     scene.addEventListener("add_media", e => {
diff --git a/src/network-schemas.js b/src/network-schemas.js
index 7addf6702740df5e67d4ebd0dd2f92849e91f07e..11c6e411dac5254b6382231f9f5013a2e78966fd 100644
--- a/src/network-schemas.js
+++ b/src/network-schemas.js
@@ -105,6 +105,11 @@ function registerNetworkSchemas() {
       "image-plus"
     ]
   });
+
+  NAF.schemas.add({
+    template: "#interactable-model",
+    components: ["position", "rotation", "scale", "gltf-model-plus"]
+  });
 }
 
 export default registerNetworkSchemas;
diff --git a/src/react-components/info-dialog.js b/src/react-components/info-dialog.js
index fe7c0e8df2419f751414bc05edcb003df64ce1b0..83b46b9dfee5569044581b44879566a13ec3c934 100644
--- a/src/react-components/info-dialog.js
+++ b/src/react-components/info-dialog.js
@@ -206,7 +206,7 @@ class InfoDialog extends Component {
               <div className="add-media-form">
                 <input
                   type="url"
-                  placeholder="Image or Video 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 })}
diff --git a/src/react-components/ui-root.js b/src/react-components/ui-root.js
index e5c56aa01147f9680c3ee6aa7fcf6e44658f4523..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
         }
diff --git a/src/vendor/GLTFLoader.js b/src/vendor/GLTFLoader.js
index 7610044b41b6dd9956b2d84984e46671de46e494..1df68673f4d773200f36899bba60be7b7c308a21 100644
--- a/src/vendor/GLTFLoader.js
+++ b/src/vendor/GLTFLoader.js
@@ -9,6 +9,19 @@
  * @author Don McCurdy / https://www.donmccurdy.com
  */
 
+async function toFarsparkURL(url){
+	var mediaJson = await fetch("https://smoke-dev.reticulum.io/api/v1/media", {
+		method: "POST",
+		headers: {
+			"Content-Type": "application/json"
+		},
+		body: JSON.stringify({
+			media: {url}
+		})
+	}).then(r => r.json());
+	return mediaJson.images.raw;
+}
+
 THREE.GLTFLoader = ( function () {
 
 	function GLTFLoader( manager ) {
@@ -25,7 +38,7 @@ THREE.GLTFLoader = ( function () {
 
 		crossOrigin: 'Anonymous',
 
-		load: function ( url, onLoad, onProgress, onError ) {
+		load: async function ( url, onLoad, onProgress, onError ) {
 
 			var scope = this;
 
@@ -37,7 +50,9 @@ THREE.GLTFLoader = ( function () {
 
 			loader.setResponseType( 'arraybuffer' );
 
-			loader.load( url, function ( data ) {
+			var farsparkURL = await toFarsparkURL(url);
+
+			loader.load( farsparkURL, function ( data ) {
 
 				try {
 
@@ -1598,7 +1613,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 +1633,11 @@ THREE.GLTFLoader = ( function () {
 
 		var options = this.options;
 
+		var farsparkURL = await toFarsparkURL(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 +1801,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 +1815,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 +1831,11 @@ THREE.GLTFLoader = ( function () {
 
 		}
 
+    var urlToLoad = resolveURL(sourceURI, options.path);
+    if (!hasBufferView){
+      urlToLoad = await toFarsparkURL(urlToLoad);
+    }
+
 		return Promise.resolve( sourceURI ).then( function ( sourceURI ) {
 
 			// Load Texture resource.
@@ -1821,7 +1844,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/yarn.lock b/yarn.lock
index 589589dc64406716bd1084f40581f5ad2b410af0..de13d49c2eec6911c15c7962adbb5776dc27a3da 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#monitor-scale-on-body":
   version "3.1.2"
-  resolved "https://codeload.github.com/donmccurdy/aframe-physics-system/tar.gz/c142a301e3ce76f88bab817c89daa5b3d4d97815"
+  resolved "https://github.com/mozillareality/aframe-physics-system#7cd2bf83d125194cc76aa48bce700884de113215"
   dependencies:
     browserify "^14.3.0"
     budo "^10.0.3"
@@ -3637,6 +3637,10 @@ gh-got@^6.0.0:
     got "^7.0.0"
     is-plain-obj "^1.1.0"
 
+gif-engine-js@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/gif-engine-js/-/gif-engine-js-1.0.1.tgz#438dedd0bb1788e8b03cba4452524b015ec0158c"
+
 github-username@^4.0.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/github-username/-/github-username-4.1.0.tgz#cbe280041883206da4212ae9e4b5f169c30bf417"
@@ -4952,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:
@@ -7191,7 +7195,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:
@@ -8668,6 +8672,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"