diff --git a/.env.test b/.env.test index fd2127b84..95f32c750 100644 --- a/.env.test +++ b/.env.test @@ -2,6 +2,6 @@ HUBS_SERVER="dev.reticulum.io" RETICULUM_SERVER="dev.reticulum.io" FARSPARK_SERVER="farspark-dev.reticulum.io" NON_CORS_PROXY_DOMAINS="hubs.local,localhost" -CORS_PROXY_SERVER="hubs-proxy.com" +CORS_PROXY_SERVER="cors-proxy-dev.reticulum.io" HOST_PORT="9090" IS_MOZ="false" diff --git a/src/api/Api.js b/src/api/Api.js index d4edd443c..2fade539e 100644 --- a/src/api/Api.js +++ b/src/api/Api.js @@ -228,6 +228,25 @@ export default class Project extends EventEmitter { return json; } + async getProjectlessScenes() { + const token = this.getToken(); + + const headers = { + "content-type": "application/json", + authorization: `Bearer ${token}` + }; + + const response = await this.fetch(`https://${RETICULUM_SERVER}/api/v1/scenes/projectless`, { headers }); + + const json = await response.json(); + + if (!Array.isArray(json.scenes)) { + throw new Error(`Error fetching scenes: ${json.error || "Unknown error."}`); + } + + return json.scenes; + } + async resolveUrl(url, index) { if (!shouldCorsProxy(url)) { return { origin: url }; @@ -287,11 +306,12 @@ export default class Project extends EventEmitter { if (resolveMediaCache.has(cacheKey)) return resolveMediaCache.get(cacheKey); const request = (async () => { - let contentType, canonicalUrl, accessibleUrl; + let contentType, canonicalUrl, accessibleUrl, meta; try { const result = await this.resolveUrl(absoluteUrl); canonicalUrl = result.origin; + meta = result.meta; accessibleUrl = proxiedUrlFor(canonicalUrl, index); contentType = @@ -315,7 +335,7 @@ export default class Project extends EventEmitter { throw new RethrownError(`Error loading Sketchfab model "${accessibleUrl}"`, error); } - return { canonicalUrl, accessibleUrl, contentType }; + return { canonicalUrl, accessibleUrl, contentType, meta }; })(); resolveMediaCache.set(cacheKey, request); @@ -890,7 +910,7 @@ export default class Project extends EventEmitter { allow_promotion: publishParams.allowPromotion, name: publishParams.name, attributions: { - creator: publishParams.creatorAttribution, + creator: publishParams.creatorAttribution && publishParams.creatorAttribution.trim(), content: publishParams.contentAttributions } }; @@ -954,6 +974,51 @@ export default class Project extends EventEmitter { return project; } + async publishGLBScene(screenshotFile, glbFile, params, signal, sceneId) { + let screenshotId, screenshotToken; + if (screenshotFile) { + const { + file_id, + meta: { access_token } + } = await this.upload(screenshotFile, null, signal); + screenshotId = file_id; + screenshotToken = access_token; + } + + let glbId, glbToken; + if (glbFile) { + const { + file_id, + meta: { access_token } + } = await this.upload(glbFile, null, signal); + glbId = file_id; + glbToken = access_token; + } + + const headers = { + "content-type": "application/json", + authorization: `Bearer ${this.getToken()}` + }; + + const sceneParams = { + screenshot_file_id: screenshotId, + screenshot_file_token: screenshotToken, + model_file_id: glbId, + model_file_token: glbToken, + ...params + }; + + const body = JSON.stringify({ scene: sceneParams }); + + const resp = await this.fetch(`https://${RETICULUM_SERVER}/api/v1/scenes${sceneId ? "/" + sceneId : ""}`, { + method: sceneId ? "PUT" : "POST", + headers, + body + }); + + return resp.json(); + } + async upload(blob, onUploadProgress, signal) { // Use direct upload API, see: https://github.com/mozilla/reticulum/pull/319 const { phx_host: uploadHost } = await (await this.fetch(`https://${RETICULUM_SERVER}/api/v1/meta`)).json(); diff --git a/src/api/PublishDialog.js b/src/api/PublishDialog.js index 7f834cf9f..a90c858cd 100644 --- a/src/api/PublishDialog.js +++ b/src/api/PublishDialog.js @@ -107,7 +107,18 @@ export default class PublishDialog extends Component { {contentAttributions && ( -

{contentAttributions.map(a => `${a.name} by ${a.author}\n`)}

+
)} diff --git a/src/editor/Editor.js b/src/editor/Editor.js index 6dc576f19..c24521c3a 100644 --- a/src/editor/Editor.js +++ b/src/editor/Editor.js @@ -350,7 +350,7 @@ export default class Editor extends EventEmitter { const scene = this.scene; - const floorPlanNode = scene.findNodeByType(FloorPlanNode); + const floorPlanNode = scene.findNodeByType(FloorPlanNode, false); if (floorPlanNode) { await floorPlanNode.generate(signal); diff --git a/src/editor/gltf/GLTFExporter.js b/src/editor/gltf/GLTFExporter.js index b20158e5c..e250ba6b2 100644 --- a/src/editor/gltf/GLTFExporter.js +++ b/src/editor/gltf/GLTFExporter.js @@ -837,14 +837,11 @@ class GLTFExporter { } // alphaMode - if (material.transparent || material.alphaTest > 0.0) { - // Write alphaCutoff if it's non-zero and different from the default (0.5). - if (material.alphaTest > 0.0 && material.alphaTest !== 0.5) { - gltfMaterial.alphaMode = "MASK"; - gltfMaterial.alphaCutoff = material.alphaTest; - } else { - gltfMaterial.alphaMode = "BLEND"; - } + if (material.transparent) { + gltfMaterial.alphaMode = "BLEND"; + } else if (material.alphaTest > 0.0) { + gltfMaterial.alphaMode = "MASK"; + gltfMaterial.alphaCutoff = material.alphaTest; } // doubleSided diff --git a/src/editor/nodes/EditorNodeMixin.js b/src/editor/nodes/EditorNodeMixin.js index b6c5c7b83..cf55c22fa 100644 --- a/src/editor/nodes/EditorNodeMixin.js +++ b/src/editor/nodes/EditorNodeMixin.js @@ -10,6 +10,7 @@ import { Color, Object3D } from "three"; import serializeColor from "../utils/serializeColor"; import LoadingCube from "../objects/LoadingCube"; import ErrorIcon from "../objects/ErrorIcon"; +import traverseFilteredSubtrees from "../utils/traverseFilteredSubtrees"; export default function EditorNodeMixin(Object3DClass) { return class extends Object3DClass { @@ -56,7 +57,13 @@ export default function EditorNodeMixin(Object3DClass) { const visibleComponent = json.components.find(c => c.name === "visible"); if (visibleComponent) { - node.visible = visibleComponent.props.visible; + node._visible = visibleComponent.props.visible; + } + + const editorSettingsComponent = json.components.find(c => c.name === "editor-settings"); + + if (editorSettingsComponent) { + node.enabled = editorSettingsComponent.props.enabled; } } @@ -70,13 +77,14 @@ export default function EditorNodeMixin(Object3DClass) { this.nodeName = this.constructor.nodeName; this.name = this.constructor.nodeName; this.isNode = true; - this.isCollapsed = false; this.disableTransform = this.constructor.disableTransform; this.useMultiplePlacementMode = this.constructor.useMultiplePlacementMode; this.ignoreRaycast = this.constructor.ignoreRaycast; this.staticMode = StaticModes.Inherits; this.originalStaticMode = null; + this.enabled = true; + this._visible = true; this.saveParent = false; this.loadingCube = null; this.errorIcon = null; @@ -110,10 +118,20 @@ export default function EditorNodeMixin(Object3DClass) { } this.issues = source.issues.slice(); + this._visible = source._visible; + this.enabled = source.enabled; return this; } + get visible() { + return this.enabled && this._visible; + } + + set visible(value) { + this._visible = value; + } + onPlay() {} onUpdate(dt) { @@ -163,7 +181,13 @@ export default function EditorNodeMixin(Object3DClass) { { name: "visible", props: { - visible: this.visible + visible: this._visible + } + }, + { + name: "editor-settings", + props: { + enabled: this.enabled } } ] @@ -345,40 +369,46 @@ export default function EditorNodeMixin(Object3DClass) { return isDynamic(this); } - findNodeByType(nodeType) { - if (this.constructor === nodeType) { - return this; - } + findNodeByType(nodeType, includeDisabled = true) { + let node = null; - for (const child of this.children) { - if (child.isNode) { - const result = child.findNodeByType(nodeType); + traverseFilteredSubtrees(this, child => { + if (node) { + return false; + } - if (result) { - return result; - } + if (!child.isNode) { + return; } - } - return null; + if (!child.enabled && !includeDisabled) { + return false; + } + + if (child.constructor === nodeType) { + node = child; + } + }); + + return node; } - getNodesByType(nodeType) { + getNodesByType(nodeType, includeDisabled = true) { const nodes = []; - if (this.constructor === nodeType) { - nodes.push(this); - } + traverseFilteredSubtrees(this, child => { + if (!child.isNode) { + return; + } - for (const child of this.children) { - if (child.isNode) { - const results = child.getNodesByType(nodeType); + if (!child.enabled && !includeDisabled) { + return false; + } - for (const result of results) { - nodes.push(result); - } + if (child.constructor === nodeType) { + nodes.push(this); } - } + }); return nodes; } @@ -387,5 +417,45 @@ export default function EditorNodeMixin(Object3DClass) { getRuntimeResourcesForStats() { // return { textures: [], materials: [], meshes: [], lights: [] }; } + + // Returns the node's attribution information by default just the name + // This should be overriding by nodes that can provide a more specific info (ie. models based on GLTF) + getAttribution() { + return { + title: this.name.replace(/\.[^/.]+$/, "") + }; + } + + // Updates attribution information. The meta information from the API is consider the most authoritative source + // That info then would be updated with node's source information if exists. + updateAttribution() { + const attribution = this.getAttribution(); + this.attribution = this.attribution || {}; + if (this.meta) { + Object.assign( + this.attribution, + this.meta.author ? { author: this.meta.author ? this.meta.author.replace(/ \(http.+\)/, "") : "" } : null, + this.meta.name ? { title: this.meta.name } : this.name ? { title: this.name.replace(/\.[^/.]+$/, "") } : null, + this.meta.author && this.meta.name && this._canonicalUrl ? { url: this._canonicalUrl } : null + ); + } + // Replace the attribute keys only if they don't exist otherwise + // we give preference to the info coming from the API source over the GLTF asset + Object.keys(this.attribution).forEach(key => { + if (!this.attribution[key] || this.attribution[key] == null) { + this.attribution[key] = attribution[key]; + } + }); + // If the GLTF attribution info has keys that are missing form the API source, we add them + for (const key in attribution) { + if (!Object.prototype.hasOwnProperty.call(this.attribution, key)) { + if (key === "author") { + this.attribution[key] = attribution[key] ? attribution[key].replace(/ \(http.+\)/, "") : ""; + } else { + this.attribution[key] = attribution[key]; + } + } + } + } }; } diff --git a/src/editor/nodes/FloorPlanNode.js b/src/editor/nodes/FloorPlanNode.js index 848e6e774..663b4167f 100644 --- a/src/editor/nodes/FloorPlanNode.js +++ b/src/editor/nodes/FloorPlanNode.js @@ -8,11 +8,18 @@ import mergeMeshGeometries from "../utils/mergeMeshGeometries"; import RecastClient from "../recast/RecastClient"; import HeightfieldClient from "../heightfield/HeightfieldClient"; import SpawnPointNode from "../nodes/SpawnPointNode"; +import { RethrownError } from "../utils/errors"; import * as recastWasmUrl from "recast-wasm/dist/recast.wasm"; +import traverseFilteredSubtrees from "../utils/traverseFilteredSubtrees"; const recastClient = new RecastClient(); const heightfieldClient = new HeightfieldClient(); +export const NavMeshMode = { + Automatic: "automatic", + Custom: "custom" +}; + export default class FloorPlanNode extends EditorNodeMixin(FloorPlan) { static nodeName = "Floor Plan"; @@ -24,7 +31,7 @@ export default class FloorPlanNode extends EditorNodeMixin(FloorPlan) { return editor.scene.findNodeByType(FloorPlanNode) === null; } - static async deserialize(editor, json) { + static async deserialize(editor, json, loadAsync, onError) { const node = await super.deserialize(editor, json); const { @@ -37,7 +44,9 @@ export default class FloorPlanNode extends EditorNodeMixin(FloorPlan) { agentMaxSlope, regionMinSize, maxTriangles, - forceTrimesh + forceTrimesh, + navMeshMode, + navMeshSrc } = json.components.find(c => c.name === "floor-plan").props; node.autoCellSize = autoCellSize; @@ -51,6 +60,14 @@ export default class FloorPlanNode extends EditorNodeMixin(FloorPlan) { node.maxTriangles = maxTriangles || 1000; node.forceTrimesh = forceTrimesh || false; + node._navMeshMode = navMeshMode || NavMeshMode.Automatic; + + if (navMeshMode === NavMeshMode.Custom) { + loadAsync(node.load(navMeshSrc, onError)); + } else { + node._navMeshSrc = navMeshSrc || ""; + } + return node; } @@ -67,6 +84,88 @@ export default class FloorPlanNode extends EditorNodeMixin(FloorPlan) { this.maxTriangles = 1000; this.forceTrimesh = false; this.heightfieldMesh = null; + this._navMeshMode = NavMeshMode.Automatic; + this._navMeshSrc = ""; + this.navMesh = null; + this.trimesh = null; + this.heightfieldMesh = null; + } + + get navMeshMode() { + return this._navMeshMode; + } + + set navMeshMode(value) { + if (value === NavMeshMode.Custom) { + // Force reloading nav mesh since it was removed and this._navMeshSrc didn't change + this.load(this._navMeshSrc, undefined, true).catch(console.error); + } else if (this.navMesh) { + this.remove(this.navMesh); + this.navMesh = null; + } + + this._navMeshMode = value; + } + + get navMeshSrc() { + return this._navMeshSrc; + } + + set navMeshSrc(value) { + this.load(value).catch(console.error); + } + + async load(src, onError, force = false) { + const nextSrc = src || ""; + + if (nextSrc === this._navMeshSrc && nextSrc !== "" && !force) { + return; + } + + this._navMeshSrc = nextSrc; + this.issues = []; + + if (this.navMesh) { + this.remove(this.navMesh); + this.navMesh = null; + } + + try { + const { accessibleUrl } = await this.editor.api.resolveMedia(nextSrc); + + const loader = this.editor.gltfCache.getLoader(accessibleUrl); + + const { scene } = await loader.getDependency("gltf"); + + const mesh = scene.getObjectByProperty("type", "Mesh"); + + if (!mesh) { + throw new Error("No mesh available."); + } + + const geometry = mesh.geometry.clone(); // Clone in case the user reuses a mesh for the navmesh. + mesh.updateMatrixWorld(); + geometry.applyMatrix(mesh.matrixWorld); + + this.setNavMesh(new Mesh(geometry, new MeshBasicMaterial({ color: 0x0000ff, transparent: true, opacity: 0.2 }))); + + if (this.navMesh) { + this.navMesh.visible = this.editor.selected.indexOf(this) !== -1; + } + } catch (error) { + const modelError = new RethrownError(`Error loading custom navmesh "${this._navMeshSrc}"`, error); + + if (onError) { + onError(this, modelError); + } + + console.error(modelError); + + this.issues.push({ severity: "error", message: "Error loading custom navmesh." }); + } + + this.editor.emit("objectsChanged", [this]); + this.editor.emit("selectionChanged"); } onSelect() { @@ -98,17 +197,20 @@ export default class FloorPlanNode extends EditorNodeMixin(FloorPlan) { } async generate(signal) { - window.scene = this; const collidableMeshes = []; const walkableMeshes = []; - const groundPlaneNode = this.editor.scene.findNodeByType(GroundPlaneNode); + const groundPlaneNode = this.editor.scene.findNodeByType(GroundPlaneNode, false); if (groundPlaneNode && groundPlaneNode.walkable) { walkableMeshes.push(groundPlaneNode.walkableMesh); } - this.editor.scene.traverse(object => { + traverseFilteredSubtrees(this.editor.scene, object => { + if (!object.enabled) { + return false; + } + if (object.isNode && object.model && (object.collidable || object.walkable)) { object.model.traverse(child => { if (child.isMesh) { @@ -124,62 +226,69 @@ export default class FloorPlanNode extends EditorNodeMixin(FloorPlan) { } }); - const boxColliderNodes = this.editor.scene.getNodesByType(BoxColliderNode); + if (this.navMeshMode === NavMeshMode.Automatic) { + const boxColliderNodes = this.editor.scene.getNodesByType(BoxColliderNode, false); - for (const node of boxColliderNodes) { - if (node.walkable) { - const helperMesh = node.helper.object; - const boxColliderMesh = new Mesh(helperMesh.geometry, new MeshBasicMaterial()); - boxColliderMesh.applyMatrix(node.matrixWorld); - boxColliderMesh.updateMatrixWorld(); - walkableMeshes.push(boxColliderMesh); + for (const node of boxColliderNodes) { + if (node.walkable) { + const helperMesh = node.helper.object; + const boxColliderMesh = new Mesh(helperMesh.geometry, new MeshBasicMaterial()); + boxColliderMesh.applyMatrix(node.matrixWorld); + boxColliderMesh.updateMatrixWorld(); + walkableMeshes.push(boxColliderMesh); + } } - } - const walkableGeometry = mergeMeshGeometries(walkableMeshes); + const walkableGeometry = mergeMeshGeometries(walkableMeshes); + + const box = new Box3().setFromBufferAttribute(walkableGeometry.attributes.position); + const size = new Vector3(); + box.getSize(size); + if (Math.max(size.x, size.y, size.z) > 2000) { + throw new Error( + `Scene is too large (${size.x.toFixed(3)} x ${size.y.toFixed(3)} x ${size.z.toFixed(3)}) ` + + `to generate a floor plan.\n` + + `You can un-check the "walkable" checkbox on models to exclude them from the floor plan.` + ); + } - const box = new Box3().setFromBufferAttribute(walkableGeometry.attributes.position); - const size = new Vector3(); - box.getSize(size); - if (Math.max(size.x, size.y, size.z) > 2000) { - throw new Error( - `Scene is too large (${size.x.toFixed(3)} x ${size.y.toFixed(3)} x ${size.z.toFixed(3)}) ` + - `to generate a floor plan.\n` + - `You can un-check the "walkable" checkbox on models to exclude them from the floor plan.` + const area = size.x * size.z; + + // Tuned to produce cell sizes from ~0.5 to ~1.5 for areas from ~200 to ~350,000. + const cellSize = this.autoCellSize ? Math.pow(area, 1 / 3) / 50 : this.cellSize; + + const navGeometry = await recastClient.buildNavMesh( + walkableGeometry, + { + cellSize, + cellHeight: this.cellHeight, + agentHeight: this.agentHeight, + agentRadius: this.agentRadius, + agentMaxClimb: this.agentMaxClimb, + agentMaxSlope: this.agentMaxSlope, + regionMinSize: this.regionMinSize, + wasmUrl: new URL(recastWasmUrl, configs.BASE_ASSETS_PATH || window.location).href + }, + signal ); - } - - const area = size.x * size.z; - // Tuned to produce cell sizes from ~0.5 to ~1.5 for areas from ~200 to ~350,000. - const cellSize = this.autoCellSize ? Math.pow(area, 1 / 3) / 50 : this.cellSize; - - const navGeometry = await recastClient.buildNavMesh( - walkableGeometry, - { - cellSize, - cellHeight: this.cellHeight, - agentHeight: this.agentHeight, - agentRadius: this.agentRadius, - agentMaxClimb: this.agentMaxClimb, - agentMaxSlope: this.agentMaxSlope, - regionMinSize: this.regionMinSize, - wasmUrl: new URL(recastWasmUrl, configs.BASE_ASSETS_PATH || window.location).href - }, - signal - ); - - const navMesh = new Mesh(navGeometry, new MeshBasicMaterial({ color: 0x0000ff, transparent: true, opacity: 0.2 })); + const navMesh = new Mesh( + navGeometry, + new MeshBasicMaterial({ color: 0x0000ff, transparent: true, opacity: 0.2 }) + ); - this.setNavMesh(navMesh); + this.setNavMesh(navMesh); + } - navMesh.visible = this.editor.selected.indexOf(this) !== -1; + if (this.navMesh) { + this.navMesh.visible = this.editor.selected.indexOf(this) !== -1; + } const collidableGeometry = mergeMeshGeometries(collidableMeshes); let heightfield = null; if (!this.forceTrimesh) { - const spawnPoints = this.editor.scene.getNodesByType(SpawnPointNode); + const spawnPoints = this.editor.scene.getNodesByType(SpawnPointNode, false); let minY = Number.POSITIVE_INFINITY; for (let j = 0; j < spawnPoints.length; j++) { @@ -247,6 +356,7 @@ export default class FloorPlanNode extends EditorNodeMixin(FloorPlan) { copy(source, recursive = true) { if (recursive) { this.remove(this.heightfieldMesh); + this.remove(this.navMesh); } super.copy(source, recursive); @@ -257,6 +367,12 @@ export default class FloorPlanNode extends EditorNodeMixin(FloorPlan) { if (heightfieldMeshIndex !== -1) { this.heightfieldMesh = this.children[heightfieldMeshIndex]; } + + const navMeshIndex = source.children.findIndex(child => child === source.navMesh); + + if (navMeshIndex !== -1) { + this.navMesh = this.children[navMeshIndex]; + } } this.autoCellSize = source.autoCellSize; @@ -269,6 +385,9 @@ export default class FloorPlanNode extends EditorNodeMixin(FloorPlan) { this.regionMinSize = source.regionMinSize; this.maxTriangles = source.maxTriangles; this.forceTrimesh = source.forceTrimesh; + this._navMeshMode = source._navMeshMode; + this._navMeshSrc = source._navMeshSrc; + return this; } @@ -284,7 +403,9 @@ export default class FloorPlanNode extends EditorNodeMixin(FloorPlan) { agentMaxSlope: this.agentMaxSlope, regionMinSize: this.regionMinSize, maxTriangles: this.maxTriangles, - forceTrimesh: this.forceTrimesh + forceTrimesh: this.forceTrimesh, + navMeshMode: this.navMeshMode, + navMeshSrc: this.navMeshSrc } }); } @@ -296,9 +417,14 @@ export default class FloorPlanNode extends EditorNodeMixin(FloorPlan) { this.remove(this.heightfieldMesh); } - const navMeshMaterial = this.navMesh.material; + if (!this.navMesh && this.navMeshMode === NavMeshMode.Custom) { + throw new Error("The FloorPlan Node is set to use a custom navigation mesh but none was provided."); + } + + const navMeshMaterial = this.navMesh.material.clone(); navMeshMaterial.transparent = true; navMeshMaterial.opacity = 0; + this.navMesh.material = navMeshMaterial; this.navMesh.name = "navMesh"; this.navMesh.userData.gltfExtensions = { @@ -310,10 +436,11 @@ export default class FloorPlanNode extends EditorNodeMixin(FloorPlan) { if (this.trimesh) { this.trimesh.name = "trimesh"; - const trimeshMaterial = this.trimesh.material; + const trimeshMaterial = this.trimesh.material.clone(); trimeshMaterial.transparent = true; trimeshMaterial.opacity = 0; trimeshMaterial.wireframe = false; + this.trimesh.material = trimeshMaterial; this.trimesh.userData.gltfExtensions = { MOZ_hubs_components: { diff --git a/src/editor/nodes/ImageNode.js b/src/editor/nodes/ImageNode.js index 8286d65fb..ad1fee3ae 100644 --- a/src/editor/nodes/ImageNode.js +++ b/src/editor/nodes/ImageNode.js @@ -18,6 +18,10 @@ export default class ImageNode extends EditorNodeMixin(Image) { const { src, projection, controls, alphaMode, alphaCutoff } = json.components.find(c => c.name === "image").props; + if (json.components.find(c => c.name === "billboard")) { + node.billboard = true; + } + loadAsync( (async () => { await node.load(src, onError); @@ -28,6 +32,12 @@ export default class ImageNode extends EditorNodeMixin(Image) { })() ); + const linkComponent = json.components.find(c => c.name === "link"); + + if (linkComponent) { + node.href = linkComponent.props.href; + } + return node; } @@ -35,7 +45,9 @@ export default class ImageNode extends EditorNodeMixin(Image) { super(editor); this._canonicalUrl = ""; + this.href = ""; this.controls = true; + this.billboard = false; } get src() { @@ -70,7 +82,12 @@ export default class ImageNode extends EditorNodeMixin(Image) { this.showLoadingCube(); try { - const { accessibleUrl } = await this.editor.api.resolveMedia(src); + const { accessibleUrl, meta } = await this.editor.api.resolveMedia(src); + + this.meta = meta; + + this.updateAttribution(); + await super.load(accessibleUrl); this.issues = getObjectPerfIssues(this._mesh, false); @@ -105,15 +122,17 @@ export default class ImageNode extends EditorNodeMixin(Image) { super.copy(source, recursive); this.controls = source.controls; + this.billboard = source.billboard; this.alphaMode = source.alphaMode; this.alphaCutoff = source.alphaCutoff; this._canonicalUrl = source._canonicalUrl; + this.href = source.href; return this; } serialize() { - return super.serialize({ + const components = { image: { src: this._canonicalUrl, controls: this.controls, @@ -121,7 +140,17 @@ export default class ImageNode extends EditorNodeMixin(Image) { alphaCutoff: this.alphaCutoff, projection: this.projection } - }); + }; + + if (this.billboard) { + components.billboard = {}; + } + + if (this.href) { + components.link = { href: this.href }; + } + + return super.serialize(components); } prepareForExport() { @@ -133,14 +162,25 @@ export default class ImageNode extends EditorNodeMixin(Image) { alphaMode: this.alphaMode, projection: this.projection }; + if (this.alphaMode === ImageAlphaMode.Mask) { imageData.alphaCutoff = this.alphaCutoff; } this.addGLTFComponent("image", imageData); + this.addGLTFComponent("networked", { id: this.uuid }); + + if (this.billboard && this.projection === "flat") { + this.addGLTFComponent("billboard", {}); + } + + if (this.href && this.projection === "flat") { + this.addGLTFComponent("link", { href: this.href }); + } + this.replaceObject(); } diff --git a/src/editor/nodes/ModelNode.js b/src/editor/nodes/ModelNode.js index 692f0908c..e0623f916 100644 --- a/src/editor/nodes/ModelNode.js +++ b/src/editor/nodes/ModelNode.js @@ -42,7 +42,8 @@ export default class ModelNode extends EditorNodeMixin(Model) { // Legacy, might be a raw string left over before switch to JSON. if (attribution && typeof attribution === "string") { const [name, author] = attribution.split(" by "); - node.attribution = { name, author }; + node.attribution = node.attribution || {}; + Object.assign(node.attribution, author ? { author: author } : null, name ? { title: name } : null); } else { node.attribution = attribution; } @@ -74,6 +75,10 @@ export default class ModelNode extends EditorNodeMixin(Model) { node.castShadow = shadowComponent.props.cast; node.receiveShadow = shadowComponent.props.receive; } + + if (json.components.find(c => c.name === "billboard")) { + node.billboard = true; + } })() ); @@ -92,6 +97,7 @@ export default class ModelNode extends EditorNodeMixin(Model) { this.boundingSphere = new Sphere(); this.stats = defaultStats; this.gltfJson = null; + this._billboard = false; } // Overrides Model's src property and stores the original (non-resolved) url. @@ -104,6 +110,15 @@ export default class ModelNode extends EditorNodeMixin(Model) { this.load(value).catch(console.error); } + get billboard() { + return this._billboard; + } + + set billboard(value) { + this._billboard = value; + this.updateStaticModes(); + } + // Overrides Model's loadGLTF method and uses the Editor's gltf cache. async loadGLTF(src) { const loader = this.editor.gltfCache.getLoader(src); @@ -115,15 +130,7 @@ export default class ModelNode extends EditorNodeMixin(Model) { const clonedScene = cloneObject3D(scene); - const sketchfabExtras = json.asset && json.asset.extras; - - if (sketchfabExtras) { - const name = sketchfabExtras.title; - const author = sketchfabExtras.author ? sketchfabExtras.author.replace(/ \(http.+\)/, "") : ""; - const url = sketchfabExtras.source || this._canonicalUrl; - clonedScene.name = name; - this.attribution = { name, author, url }; - } + this.updateAttribution(); return clonedScene; } @@ -152,7 +159,9 @@ export default class ModelNode extends EditorNodeMixin(Model) { this.showLoadingCube(); try { - const { accessibleUrl, files } = await this.editor.api.resolveMedia(src); + const { accessibleUrl, files, meta } = await this.editor.api.resolveMedia(src); + + this.meta = meta; if (this.model) { this.editor.renderer.removeBatchedObject(this.model); @@ -259,6 +268,23 @@ export default class ModelNode extends EditorNodeMixin(Model) { return this; } + getAttribution() { + // Sketchfab models use an extra object inside the asset object + // Blender & Google Poly exporters add a copyright property to the asset object + const name = this.name.replace(/\.[^/.]+$/, ""); + const assetDef = this.gltfJson.asset; + const attributions = {}; + Object.assign( + attributions, + assetDef.extras && assetDef.extras.author + ? { author: assetDef.extras.author } + : (assetDef.copyright && { author: assetDef.copyright }) || null, + assetDef.extras && assetDef.extras.source ? { url: assetDef.extras.source } : null, + assetDef.extras && assetDef.extras.title ? { title: assetDef.extras.title } : this.name ? { title: name } : null + ); + return attributions; + } + onAdd() { if (this.model) { this.editor.renderer.addBatchedObject(this.model); @@ -308,6 +334,10 @@ export default class ModelNode extends EditorNodeMixin(Model) { } } } + + if (this.billboard) { + setStaticMode(this.model, StaticModes.Dynamic); + } } serialize() { @@ -340,6 +370,10 @@ export default class ModelNode extends EditorNodeMixin(Model) { components.combine = {}; } + if (this.billboard) { + components.billboard = {}; + } + return super.serialize(components); } @@ -350,7 +384,6 @@ export default class ModelNode extends EditorNodeMixin(Model) { this.initialScale = source.initialScale; this.load(source.src); } else { - this.updateStaticModes(); this.stats = JSON.parse(JSON.stringify(source.stats)); this.gltfJson = source.gltfJson; this._canonicalUrl = source._canonicalUrl; @@ -360,11 +393,16 @@ export default class ModelNode extends EditorNodeMixin(Model) { this.collidable = source.collidable; this.walkable = source.walkable; this.combine = source.combine; + this._billboard = source._billboard; + + this.updateStaticModes(); + return this; } prepareForExport(ctx) { super.prepareForExport(); + this.addGLTFComponent("shadow", { cast: this.castShadow, receive: this.receiveShadow @@ -387,5 +425,9 @@ export default class ModelNode extends EditorNodeMixin(Model) { activeClipIndices: clipIndices }); } + + if (this.billboard) { + this.addGLTFComponent("billboard", {}); + } } } diff --git a/src/editor/nodes/SceneNode.js b/src/editor/nodes/SceneNode.js index 2a3c3b5c5..54bbf2b4d 100644 --- a/src/editor/nodes/SceneNode.js +++ b/src/editor/nodes/SceneNode.js @@ -7,6 +7,7 @@ import GroupNode from "./GroupNode"; import getNodeWithUUID from "../utils/getNodeWithUUID"; import serializeColor from "../utils/serializeColor"; import { DistanceModelType } from "../objects/AudioSource"; +import traverseFilteredSubtrees from "../utils/traverseFilteredSubtrees"; // Migrate v1 spoke scene to v2 function migrateV1ToV2(json) { @@ -528,7 +529,11 @@ export default class SceneNode extends EditorNodeMixin(Scene) { }); for (const node of nodeList) { - node.prepareForExport(ctx); + if (node.enabled) { + node.prepareForExport(ctx); + } else { + node.parent.remove(node); + } } this.addGLTFComponent("background", { @@ -617,8 +622,16 @@ export default class SceneNode extends EditorNodeMixin(Scene) { getAnimationClips() { const animations = []; - this.traverse(child => { - if (child.isNode && child.type === "Model") { + traverseFilteredSubtrees(this, child => { + if (!child.isNode) { + return; + } + + if (!child.enabled) { + return false; + } + + if (child.type === "Model") { animations.push(...child.clips); } }); @@ -630,18 +643,29 @@ export default class SceneNode extends EditorNodeMixin(Scene) { const contentAttributions = []; const seenAttributions = new Set(); - this.traverse(obj => { - if (!(obj.isNode && obj.type === "Model")) return; + traverseFilteredSubtrees(this, obj => { + if (!obj.isNode) { + return; + } + + if (!obj.enabled) { + return false; + } + const attribution = obj.attribution; if (!attribution) return; - if (attribution) { - const attributionKey = attribution.url || `${attribution.name}_${attribution.author}`; - if (seenAttributions.has(attributionKey)) return; - seenAttributions.add(attributionKey); - contentAttributions.push(attribution); - } + const url = attribution.url && attribution.url.trim(); + const title = attribution.title && attribution.title.trim(); + const author = attribution.author && attribution.author.trim(); + + if (!url && !title && !author) return; + + const attributionKey = url || `${title}_${author}`; + if (seenAttributions.has(attributionKey)) return; + seenAttributions.add(attributionKey); + contentAttributions.push(attribution); }); return contentAttributions; diff --git a/src/editor/nodes/SpawnerNode.js b/src/editor/nodes/SpawnerNode.js index 66b418c69..d0a7fe9f6 100644 --- a/src/editor/nodes/SpawnerNode.js +++ b/src/editor/nodes/SpawnerNode.js @@ -71,6 +71,8 @@ export default class SpawnerNode extends EditorNodeMixin(Model) { this.stats = stats; this.gltfJson = json; + this.updateAttribution(); + return cloneObject3D(scene); } @@ -98,7 +100,9 @@ export default class SpawnerNode extends EditorNodeMixin(Model) { this.showLoadingCube(); try { - const { accessibleUrl, files } = await this.editor.api.resolveMedia(src); + const { accessibleUrl, files, meta } = await this.editor.api.resolveMedia(src); + + this.meta = meta; if (this.model) { this.editor.renderer.removeBatchedObject(this.model); diff --git a/src/editor/nodes/VideoNode.js b/src/editor/nodes/VideoNode.js index 74789beac..11dcc4351 100644 --- a/src/editor/nodes/VideoNode.js +++ b/src/editor/nodes/VideoNode.js @@ -35,6 +35,16 @@ export default class VideoNode extends EditorNodeMixin(Video) { projection } = json.components.find(c => c.name === "video").props; + if (json.components.find(c => c.name === "billboard")) { + node.billboard = true; + } + + const linkComponent = json.components.find(c => c.name === "link"); + + if (linkComponent) { + node.href = linkComponent.props.href; + } + loadAsync( (async () => { await node.load(src, onError); @@ -64,6 +74,8 @@ export default class VideoNode extends EditorNodeMixin(Video) { this._autoPlay = true; this.volume = 0.5; this.controls = true; + this.billboard = false; + this.href = ""; } get src() { @@ -102,7 +114,11 @@ export default class VideoNode extends EditorNodeMixin(Video) { } try { - const { accessibleUrl, contentType } = await this.editor.api.resolveMedia(src); + const { accessibleUrl, contentType, meta } = await this.editor.api.resolveMedia(src); + + this.meta = meta; + + this.updateAttribution(); const isHls = isHLS(src, contentType); @@ -171,13 +187,15 @@ export default class VideoNode extends EditorNodeMixin(Video) { super.copy(source, recursive); this.controls = source.controls; + this.billboard = source.billboard; this._canonicalUrl = source._canonicalUrl; + this.href = source.href; return this; } serialize() { - return super.serialize({ + const components = { video: { src: this._canonicalUrl, controls: this.controls, @@ -194,11 +212,22 @@ export default class VideoNode extends EditorNodeMixin(Video) { coneOuterGain: this.coneOuterGain, projection: this.projection } - }); + }; + + if (this.billboard) { + components.billboard = {}; + } + + if (this.href) { + components.link = { href: this.href }; + } + + return super.serialize(components); } prepareForExport() { super.prepareForExport(); + this.addGLTFComponent("video", { src: this._canonicalUrl, controls: this.controls, @@ -215,9 +244,19 @@ export default class VideoNode extends EditorNodeMixin(Video) { coneOuterGain: this.coneOuterGain, projection: this.projection }); + this.addGLTFComponent("networked", { id: this.uuid }); + + if (this.billboard && this.projection === "flat") { + this.addGLTFComponent("billboard", {}); + } + + if (this.href && this.projection === "flat") { + this.addGLTFComponent("link", { href: this.href }); + } + this.replaceObject(); } diff --git a/src/editor/objects/AudioSource.js b/src/editor/objects/AudioSource.js index 78f7dc77f..eac07d68b 100644 --- a/src/editor/objects/AudioSource.js +++ b/src/editor/objects/AudioSource.js @@ -231,8 +231,12 @@ export default class AudioSource extends Object3D { async load(src) { await this.loadMedia(src); - this.audioSource = this.audioListener.context.createMediaElementSource(this.el); - this.audio.setNodeSource(this.audioSource); + + if (this.audioSource === undefined) { + this.audioSource = this.audioListener.context.createMediaElementSource(this.el); + this.audio.setNodeSource(this.audioSource); + } + return this; } diff --git a/src/editor/objects/Model.js b/src/editor/objects/Model.js index 53f8659f4..ead6e5e85 100644 --- a/src/editor/objects/Model.js +++ b/src/editor/objects/Model.js @@ -15,6 +15,7 @@ export default class Model extends Object3D { this.activeClipItems = []; this.animationMixer = null; this.currentActions = []; + this.meta = null; } get src() { diff --git a/src/editor/objects/Video.js b/src/editor/objects/Video.js index 903cb08a9..7b6f45b6d 100644 --- a/src/editor/objects/Video.js +++ b/src/editor/objects/Video.js @@ -133,8 +133,10 @@ export default class Video extends AudioSource { this.onResize(); - this.audioSource = this.audioListener.context.createMediaElementSource(this.el); - this.audio.setNodeSource(this.audioSource); + if (this.audioSource === undefined) { + this.audioSource = this.audioListener.context.createMediaElementSource(this.el); + this.audio.setNodeSource(this.audioSource); + } if (this._texture.format === RGBAFormat) { this._mesh.material.transparent = true; diff --git a/src/editor/utils/traverseFilteredSubtrees.js b/src/editor/utils/traverseFilteredSubtrees.js new file mode 100644 index 000000000..1fc2969f0 --- /dev/null +++ b/src/editor/utils/traverseFilteredSubtrees.js @@ -0,0 +1,11 @@ +export default function traverseFilteredSubtrees(object, cb) { + if (cb(object) === false) { + return; + } + + const children = object.children; + + for (let i = 0; i < children.length; i++) { + traverseFilteredSubtrees(children[i], cb); + } +} diff --git a/src/telemetry.js b/src/telemetry.js index 493404ea3..d6fb749de 100644 --- a/src/telemetry.js +++ b/src/telemetry.js @@ -33,7 +33,7 @@ export function Telemetry({ overridePage, overrideTitle }) { overrideTitle = "Editor"; } - const page = "/spoke" + (overridePage || location.pathname); + const page = `/spoke${overridePage || location.pathname}`; const title = overrideTitle ? "Spoke by Mozilla | " + overrideTitle : document.title; console.info(`Telemetry ${telemetryEnabled ? "enabled" : "disabled"} | Navigated to: ${page}`); diff --git a/src/ui/App.js b/src/ui/App.js index dfb0ff6b0..04b5b6b09 100644 --- a/src/ui/App.js +++ b/src/ui/App.js @@ -20,6 +20,7 @@ import LoginPage from "./auth/LoginPage"; import LogoutPage from "./auth/LogoutPage"; import ProjectsPage from "./projects/ProjectsPage"; import CreateProjectPage from "./projects/CreateProjectPage"; +import CreateScenePage from "./projects/CreateScenePage"; import { ThemeProvider } from "styled-components"; @@ -82,6 +83,7 @@ export default class App extends Component { + } /> diff --git a/src/ui/hierarchy/HierarchyPanelContainer.js b/src/ui/hierarchy/HierarchyPanelContainer.js index ca79eaaf4..ed2ff5656 100644 --- a/src/ui/hierarchy/HierarchyPanelContainer.js +++ b/src/ui/hierarchy/HierarchyPanelContainer.js @@ -132,6 +132,7 @@ const TreeNodeLabel = styled.div` color: ${props => (props.isOver && props.canDrop ? props.theme.text : "inherit")}; border-radius: 4px; padding: 0 2px; + text-decoration: ${props => (props.enabled ? "none" : "line-through")}; `; function borderStyle({ isOver, canDrop, position }) { @@ -173,7 +174,7 @@ function TreeNode({ style }) { const node = nodes[index]; - const { isLeaf, object, depth, selected, active, iconComponent, isCollapsed, childIndex, lastChild } = node; + const { isLeaf, object, depth, selected, active, iconComponent, isExpanded, childIndex, lastChild, enabled } = node; const editor = useContext(EditorContext); @@ -432,8 +433,8 @@ function TreeNode({ {isLeaf ? ( ) : ( - - {isCollapsed ? : } + + {isExpanded ? : } )} @@ -452,7 +453,7 @@ function TreeNode({ /> ) : ( - + {object.name} )} @@ -485,9 +486,10 @@ TreeNode.propTypes = { selected: PropTypes.bool, active: PropTypes.bool, iconComponent: PropTypes.object, - isCollapsed: PropTypes.bool, + isExpanded: PropTypes.bool, childIndex: PropTypes.number.isRequired, - lastChild: PropTypes.bool.isRequired + lastChild: PropTypes.bool.isRequired, + enabled: PropTypes.bool.isRequired }) ), renamingNode: PropTypes.object, @@ -506,38 +508,41 @@ TreeNode.propTypes = { const MemoTreeNode = memo(TreeNode, areEqual); -function* treeWalker(editor, collapsedNodes) { +function* treeWalker(editor, expandedNodes) { const stack = []; stack.push({ depth: 0, object: editor.scene, childIndex: 0, - lastChild: true + lastChild: true, + parentEnabled: true }); while (stack.length !== 0) { - const { depth, object, childIndex, lastChild } = stack.pop(); + const { depth, object, childIndex, lastChild, parentEnabled } = stack.pop(); const NodeEditor = editor.getNodeEditor(object) || DefaultNodeEditor; const iconComponent = NodeEditor.iconComponent || DefaultNodeEditor.iconComponent; - const isCollapsed = collapsedNodes[object.id]; + const isExpanded = expandedNodes[object.id] || object === editor.scene; + const enabled = parentEnabled && object.enabled; yield { id: object.id, isLeaf: object.children.filter(c => c.isNode).length === 0, - isCollapsed, + isExpanded, depth, object, iconComponent, selected: editor.selected.indexOf(object) !== -1, active: editor.selected.length > 0 && object === editor.selected[editor.selected.length - 1], + enabled, childIndex, lastChild }; - if (object.children.length !== 0 && !isCollapsed) { + if (object.children.length !== 0 && isExpanded) { for (let i = object.children.length - 1; i >= 0; i--) { const child = object.children[i]; @@ -546,7 +551,8 @@ function* treeWalker(editor, collapsedNodes) { depth: depth + 1, object: child, childIndex: i, - lastChild: i === 0 + lastChild: i === 0, + parentEnabled: enabled }); } } @@ -558,68 +564,68 @@ export default function HierarchyPanel() { const editor = useContext(EditorContext); const onUpload = useUpload(uploadOptions); const [renamingNode, setRenamingNode] = useState(null); - const [collapsedNodes, setCollapsedNodes] = useState({}); + const [expandedNodes, setExpandedNodes] = useState({}); const [nodes, setNodes] = useState([]); const updateNodeHierarchy = useCallback(() => { - setNodes(Array.from(treeWalker(editor, collapsedNodes))); - }, [editor, collapsedNodes]); + setNodes(Array.from(treeWalker(editor, expandedNodes))); + }, [editor, expandedNodes]); const expandNode = useCallback( node => { - delete collapsedNodes[node.id]; - setCollapsedNodes({ ...collapsedNodes }); + setExpandedNodes({ ...expandedNodes, [node.id]: true }); }, - [collapsedNodes] + [expandedNodes] ); const collapseNode = useCallback( node => { - setCollapsedNodes({ ...collapsedNodes, [node.id]: true }); + delete expandedNodes[node.id]; + setExpandedNodes({ ...expandedNodes }); }, - [setCollapsedNodes, collapsedNodes] + [setExpandedNodes, expandedNodes] ); const expandChildren = useCallback( node => { node.object.traverse(child => { if (child.isNode) { - delete collapsedNodes[child.id]; + expandedNodes[child.id] = true; } }); - setCollapsedNodes({ ...collapsedNodes }); + setExpandedNodes({ ...expandedNodes }); }, - [setCollapsedNodes, collapsedNodes] + [setExpandedNodes, expandedNodes] ); const collapseChildren = useCallback( node => { node.object.traverse(child => { if (child.isNode) { - collapsedNodes[child.id] = true; + delete expandedNodes[child.id]; } }); - setCollapsedNodes({ ...collapsedNodes }); + setExpandedNodes({ ...expandedNodes }); }, - [setCollapsedNodes, collapsedNodes] + [setExpandedNodes, expandedNodes] ); const onExpandAllNodes = useCallback(() => { - setCollapsedNodes({}); - }, [setCollapsedNodes]); - - const onCollapseAllNodes = useCallback(() => { - const newCollapsedNodes = {}; + const newExpandedNodes = {}; editor.scene.traverse(child => { if (child.isNode) { - newCollapsedNodes[child.id] = true; + newExpandedNodes[child.id] = true; } }); - setCollapsedNodes(newCollapsedNodes); - }, [editor, setCollapsedNodes]); + setExpandedNodes(newExpandedNodes); + }, [editor, setExpandedNodes]); + + const onCollapseAllNodes = useCallback(() => { + setExpandedNodes({}); + }, [setExpandedNodes]); const onObjectChanged = useCallback( (objects, propertyName) => { - if (propertyName === "name" || !propertyName) { + if (propertyName === "name" || propertyName === "enabled" || !propertyName) { updateNodeHierarchy(); } }, @@ -664,13 +670,13 @@ export default function HierarchyPanel() { const onToggle = useCallback( (_e, node) => { - if (collapsedNodes[node.id]) { - expandNode(node); - } else { + if (expandedNodes[node.id]) { collapseNode(node); + } else { + expandNode(node); } }, - [collapsedNodes, expandNode, collapseNode] + [expandedNodes, expandNode, collapseNode] ); const onKeyDown = useCallback( @@ -838,7 +844,7 @@ export default function HierarchyPanel() { useEffect(() => { updateNodeHierarchy(); - }, [collapsedNodes, updateNodeHierarchy]); + }, [expandedNodes, updateNodeHierarchy]); return ( diff --git a/src/ui/inputs/FileInput.js b/src/ui/inputs/FileInput.js index 3b8907c61..8b35e6636 100644 --- a/src/ui/inputs/FileInput.js +++ b/src/ui/inputs/FileInput.js @@ -1,18 +1,33 @@ import React, { Component } from "react"; import PropTypes from "prop-types"; +import styled from "styled-components"; + import { Button } from "./Button"; -import Hidden from "../layout/Hidden"; let nextId = 0; +export const FileInputContainer = styled.div` + display: flex; + flex-direction: row; + align-items: center; +`; + +// We do this instead of actually hiding it so that form validation can still display tooltips correctly +export const StyledInput = styled.input` + opacity: 0; + position: absolute; +`; + export default class FileInput extends Component { static propTypes = { label: PropTypes.string.isRequired, - onChange: PropTypes.func.isRequired + onChange: PropTypes.func.isRequired, + showSelectedFile: PropTypes.bool }; static defaultProps = { - label: "Upload..." + label: "Upload...", + showSelectedFile: false }; constructor(props) { @@ -24,6 +39,7 @@ export default class FileInput extends Component { } onChange = e => { + this.setState({ filename: e.target.files[0].name }); this.props.onChange(e.target.files, e); }; @@ -31,12 +47,13 @@ export default class FileInput extends Component { const { label, onChange, ...rest } = this.props; return ( -
+ - -
+ + {this.props.showSelectedFile && {this.state.filename ? this.state.filename : "No File chosen"}} + ); } } diff --git a/src/ui/projects/CreateProjectPage.js b/src/ui/projects/CreateProjectPage.js index d35dca192..c61746d18 100644 --- a/src/ui/projects/CreateProjectPage.js +++ b/src/ui/projects/CreateProjectPage.js @@ -122,6 +122,9 @@ export default function CreateProjectPage({ history, location }) { + diff --git a/src/ui/projects/CreateScenePage.js b/src/ui/projects/CreateScenePage.js new file mode 100644 index 000000000..1effff3fa --- /dev/null +++ b/src/ui/projects/CreateScenePage.js @@ -0,0 +1,392 @@ +import PropTypes from "prop-types"; +import configs from "../../configs"; +import { withApi } from "../contexts/ApiContext"; +import NavBar from "../navigation/NavBar"; +import Footer from "../navigation/Footer"; +import styled from "styled-components"; +import { Redirect } from "react-router-dom"; + +import React, { useState, useCallback, useEffect } from "react"; +import { useHistory } from "react-router-dom"; + +import StringInput from "../inputs/StringInput"; +import BooleanInput from "../inputs/BooleanInput"; +import FileInput from "../inputs/FileInput"; +import FormField from "../inputs/FormField"; +import { Button } from "../inputs/Button"; +import ProgressBar from "../inputs/ProgressBar"; + +const SceneUploadFormContainer = styled.div` + display: flex; + flex: 1; + flex-direction: row; + background-color: ${props => props.theme.panel2}; + border-radius: 3px; +`; + +const InfoBox = styled.div` + background-color: ${props => props.theme.panel2}; + margin-top: 10px; + padding: 10px; + border-radius: 3px; + text-align: center; +`; + +const ErrorMessage = styled.div` + color: ${props => props.theme.red}; + margin-top: 8px; +`; + +export const UploadSceneSection = styled.form` + padding-bottom: 100px; + display: flex; + + &:first-child { + padding-top: 100px; + } + + h1 { + font-size: 36px; + } + + h2 { + font-size: 16px; + } +`; + +export const UploadSceneContainer = styled.div` + display: flex; + flex: 1; + flex-direction: column; + margin: 0 auto; + max-width: 800px; + min-width: 400px; + padding: 0 20px; +`; + +export const SceneUploadHeader = styled.div` + margin-bottom: 36px; + display: flex; + justify-content: space-between; + align-items: center; +`; + +const LeftContent = styled.div` + display: flex; + width: 360px; + border-top-left-radius: inherit; + align-items: flex-start; + padding: 30px; + position: relative; + + img, + div { + width: 300px; + height: 168px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + background-color: ${props => props.theme.panel}; + border-radius: 6px; + } + input { + opacity: 0; + position: absolute; + } +`; + +const RightContent = styled.div` + display: flex; + flex-direction: column; + flex: 1; + padding: 30px 30px; + + label[type="button"] { + display: flex; + margin-bottom: 0; + margin-right: 5px; + } +`; + +function CreateScenePage({ match, api }) { + const [isUploading, setIsUploading] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + const [sceneId, setSceneId] = useState(null); + const [error, setError] = useState(null); + + const [sceneInfo, setSceneInfo] = useState({ + name: "", + creatorAttribution: "", + allowRemixing: false, + allowPromotion: false + }); + + const [glbFile, setGlbFile] = useState(null); + const [thumbnailFile, setThumbnailFile] = useState(null); + + const [thumbnailUrl, setThumbnailUrl] = useState(null); + const [sceneUrl, setSceneUrl] = useState(null); + + const history = useHistory(); + + const openScene = useCallback(() => { + window.open(sceneUrl); + }, [sceneUrl]); + + useEffect(() => { + async function doInitialLoad() { + const sceneId = match.params.sceneId; + const isNew = sceneId === "new"; + + const scene = isNew ? {} : await api.getScene(sceneId); + setSceneInfo({ + name: scene.name || "", + creatorAttribution: (scene.attributions && scene.attributions.creator) || "", + allowRemixing: scene.allow_remixing, + allowPromotion: scene.allow_promotion + }); + setThumbnailUrl(scene.screenshot_url); + setSceneId(scene.scene_id); + setSceneUrl(scene.url); + setIsLoading(false); + } + doInitialLoad().catch(e => { + console.error(e); + setError(e.message); + }); + }, [match, api, setSceneInfo, setThumbnailUrl, setSceneId, setSceneUrl, setIsLoading]); + + const onChangeName = useCallback( + name => { + setSceneInfo(sceneInfo => ({ ...sceneInfo, name })); + }, + [setSceneInfo] + ); + + const onChangeCreatorAttribution = useCallback( + creatorAttribution => { + setSceneInfo({ ...sceneInfo, creatorAttribution }); + }, + [sceneInfo, setSceneInfo] + ); + + const onChangeAllowRemixing = useCallback( + allowRemixing => { + setSceneInfo({ ...sceneInfo, allowRemixing }); + }, + [sceneInfo, setSceneInfo] + ); + + const onChangeAllowPromotion = useCallback( + allowPromotion => { + setSceneInfo({ ...sceneInfo, allowPromotion }); + }, + [sceneInfo, setSceneInfo] + ); + + const onChangeGlbFile = useCallback( + ([file]) => { + setGlbFile(file); + }, + [setGlbFile] + ); + + const onChangeThumbnailFile = useCallback( + ([file]) => { + setThumbnailFile(file); + }, + [setThumbnailFile] + ); + + // For preview + useEffect(() => { + if (!thumbnailFile) return; + const reader = new FileReader(); + reader.onload = e => { + setThumbnailUrl(prevUrl => { + if (prevUrl && prevUrl.indexOf("data:") === 0) { + URL.revokeObjectURL(prevUrl); + } + return e.target.result; + }); + }; + reader.readAsDataURL(thumbnailFile); + + return () => { + reader.abort(); + }; + }, [thumbnailFile, setThumbnailUrl]); + + const onPublish = useCallback( + e => { + e.preventDefault(); + + setError(null); + setIsUploading(true); + + const abortController = new AbortController(); + + api + .publishGLBScene( + thumbnailFile, + glbFile, + { + name: sceneInfo.name, + allow_remixing: sceneInfo.allowRemixing, + allow_promotion: sceneInfo.allowPromotion, + attributions: { + creator: sceneInfo.creatorAttribution, + content: [] + } + }, + abortController.signal, + sceneId + ) + .then(() => history.push("/projects")) + .catch(e => { + setIsUploading(false); + setError(e.message); + }); + }, + [thumbnailFile, glbFile, sceneInfo, sceneId, setIsUploading, api, history, setError] + ); + + if (!api.isAuthenticated()) { + return ; + } + + const isNew = !sceneId; + + const content = isLoading ? ( + error ? ( + {error} + ) : ( + + ) + ) : ( + <> + +

{isNew ? "Publish Scene From Blender" : "Update Blender Scene"}

+ + {sceneUrl && ( + + )} +
+ + + + onChangeThumbnailFile(e.target.files)} + /> + + + + + + + + + + + + + + + + + + + + + + + + + + {isUploading ? : } + {error && {error}} + + + + For more info on creating scenes in Blender, check out the{" "} + Hubs Blender Exporter + + + ); + + return ( + <> + +
+ + {content} + +
+