- {contentAttributions.map(a => `${a.name} by ${a.author}\n`)}
+
+ {contentAttributions.map(
+ (a, i) =>
+ a.author &&
+ a.title && (
+ -
+ {`${a.title}`}
+ {(a.author && ` (by ${a.author})`) || ` (by Unknown)`}
+
+ )
+ )}
+
)}
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 {