@needle-tools/engine
Version:
Needle Engine is a web-based runtime for 3D apps. It runs on your machine for development with great integrations into editors like Unity or Blender - and can be deployed onto any device! It is flexible, extensible and networking and XR are built-in.
291 lines • 14.5 kB
JavaScript
import { getParam } from "../../../../../engine/engine_utils.js";
import { GameObject } from "../../../../Component.js";
import { AudioExtension } from "./AudioExtension.js";
import { ActionModel, GroupActionModel, TriggerModel } from "./BehavioursBuilder.js";
const debug = getParam("debugusdzbehaviours");
/** internal USDZ behaviours extension */
export class BehaviorExtension {
get extensionName() {
return "Behaviour";
}
behaviours = [];
addBehavior(beh) {
this.behaviours.push(beh);
}
/** Register audio clip for USDZ export. The clip will be embedded in the resulting file. */
addAudioClip(clipUrl) {
if (!clipUrl)
return "";
if (typeof clipUrl !== "string")
return "";
const clipName = AudioExtension.getName(clipUrl);
const filesKey = "audio/" + clipName;
this.audioClips.push({ clipUrl, filesKey });
return filesKey;
}
behaviourComponents = [];
behaviourComponentsCopy = [];
audioClips = [];
audioClipsCopy = [];
targetUuids = new Set();
getAllTargetUuids() {
return this.targetUuids;
}
onBeforeBuildDocument(context) {
if (!context.root)
return Promise.resolve();
const beforeCreateDocumentPromises = [];
context.root.traverse(e => {
GameObject.foreachComponent(e, (comp) => {
const c = comp;
// Test if the components has any of the behaviour type methods
if (typeof c.createBehaviours === "function" ||
typeof c.beforeCreateDocument === "function" ||
typeof c.afterCreateDocument === "function" ||
typeof c.afterSerialize === "function") {
this.behaviourComponents.push(c);
// run beforeCreateDocument. We run them in parallel if any of them is async because the order in which this is invoked on the components is not guaranteed anyways
// (or at least no behaviour component should rely on another to have finished this method)
const res = c.beforeCreateDocument?.call(c, this, context);
if (res instanceof Promise) {
beforeCreateDocumentPromises.push(res);
}
}
}, false);
});
if (debug)
console.log("onBeforeBuildDocument: all components", this.behaviourComponents);
return Promise.all(beforeCreateDocumentPromises);
}
onExportObject(_object, model, context) {
for (const beh of this.behaviourComponents) {
// if (debug) console.log("onExportObject: createBehaviours", beh);
beh.createBehaviours?.call(beh, this, model, context);
}
}
onAfterBuildDocument(context) {
for (const beh of this.behaviourComponents) {
if (typeof beh.afterCreateDocument === "function")
beh.afterCreateDocument(this, context);
}
this.behaviourComponentsCopy = this.behaviourComponents.slice();
this.behaviourComponents.length = 0;
this.audioClipsCopy = this.audioClips.slice();
this.audioClips.length = 0;
// We want to know all trigger sources and action targets.
// These can be nested in Group Actions.
const triggerSources = new Set();
const actionTargets = new Set();
const targetUuids = new Set();
const playAnimationActions = new Set();
// We're assembling a mermaid graph on the go, for easier debugging
const createMermaidGraphForDebugging = debug;
let mermaidGraph = "graph LR\n";
let mermaidGraphTopLevel = "";
function collectAction(actionModel) {
if (actionModel instanceof GroupActionModel) {
if (createMermaidGraphForDebugging)
mermaidGraph += `subgraph Group_${actionModel.id}\n`;
for (const action of actionModel.actions) {
if (createMermaidGraphForDebugging)
mermaidGraph += `${actionModel.id}[${actionModel.id}] -- ${actionModel.type},loops:${actionModel.loops} --> ${action.id}[${action.id}]\n`;
collectAction(action);
}
if (createMermaidGraphForDebugging)
mermaidGraph += `end\n`;
}
else if (actionModel instanceof ActionModel) {
if (actionModel.tokenId === "StartAnimation") {
playAnimationActions.add(actionModel);
}
let actionType = actionModel.tokenId;
if (actionModel.type !== undefined)
actionType += ":" + actionModel.type;
const affected = actionModel.affectedObjects;
if (affected) {
if (Array.isArray(affected)) {
for (const a of affected) {
actionTargets.add(a);
//@ts-ignore
if (createMermaidGraphForDebugging)
mermaidGraphTopLevel += `${actionModel.id}[${actionModel.id}\n${actionType}] -- ${actionType} --> ${a.uuid}(("${a.displayName || a.name || a.uuid}"))\n`;
}
}
else if (typeof affected === "object") {
actionTargets.add(affected);
//@ts-ignore
if (createMermaidGraphForDebugging)
mermaidGraphTopLevel += `${actionModel.id}[${actionModel.id}\n${actionType}] -- ${actionType} --> ${affected.uuid}(("${affected.displayName || affected.name || affected.uuid}"))\n`;
}
else if (typeof affected === "string") {
actionTargets.add({ uuid: affected });
}
}
const xform = actionModel.xFormTarget;
if (xform) {
if (typeof xform === "object") {
actionTargets.add(xform);
//@ts-ignore
if (createMermaidGraphForDebugging)
mermaidGraphTopLevel += `${actionModel.id}[${actionModel.id}\n${actionType}] -- ${actionType} --> ${xform.uuid}(("${xform.displayName || xform.name || xform.uuid}"))\n`;
}
else if (typeof xform === "string") {
actionTargets.add({ uuid: xform });
}
}
}
}
function collectTrigger(trigger, action) {
if (Array.isArray(trigger)) {
for (const t of trigger)
collectTrigger(t, action);
}
else if (trigger instanceof TriggerModel) {
let triggerType = trigger.tokenId;
if (trigger.type !== undefined)
triggerType += ":" + trigger.type;
if (typeof trigger.targetId === "object") {
triggerSources.add(trigger.targetId);
//@ts-ignore
if (createMermaidGraphForDebugging)
mermaidGraphTopLevel += `${trigger.targetId.uuid}(("${trigger.targetId.displayName}")) --> ${trigger.id}[${trigger.id}\n${triggerType}]\n`;
}
//@ts-ignore
if (createMermaidGraphForDebugging)
mermaidGraph += `${trigger.id}((${trigger.id})) -- ${triggerType} --> ${action.id}[${action.tokenId || action.id}]\n`;
}
}
// collect all targets of all triggers and actions
for (const beh of this.behaviours) {
if (createMermaidGraphForDebugging)
mermaidGraph += `subgraph ${beh.id}\n`;
collectAction(beh.action);
collectTrigger(beh.trigger, beh.action);
if (createMermaidGraphForDebugging)
mermaidGraph += `end\n`;
}
if (createMermaidGraphForDebugging)
mermaidGraph += "\n" + mermaidGraphTopLevel;
if (createMermaidGraphForDebugging) {
console.log("All USDZ behaviours", this.behaviours);
if (this.behaviours.length) {
console.warn("The Mermaid graph can be pasted into https://massive-mermaid.glitch.me/ or https://mermaid.live/edit. It should be in your clipboard already!");
console.log(mermaidGraph);
// copy to clipboard, can be pasted into https://massive-mermaid.glitch.me/ or https://mermaid.live/edit
navigator.clipboard.writeText(mermaidGraph);
}
}
{
// Validation: Check if any PlayAnimation actions are overlapping.
// That means: the target of one of the actions is a child of any other target of another action.
// This leads to undefined behaviour in the runtime.
// See FB15122057 for more information.
let animationsGraph = "gantt\ntitle Animations\ndateFormat X\naxisFormat %s\n";
const arr = Array.from(playAnimationActions);
const animationTargetObjects = new Set();
for (const a of arr) {
if (!a.affectedObjects)
continue;
if (typeof a.affectedObjects === "string")
continue;
if (Array.isArray(a.affectedObjects)) {
for (const o of a.affectedObjects) {
animationTargetObjects.add(o);
}
}
else {
animationTargetObjects.add(a.affectedObjects);
}
if (createMermaidGraphForDebugging) {
animationsGraph += `section ${a.animationName} (${a.id})\n`;
animationsGraph += `${a.id} : ${a.start}, ${a.duration}s\n`;
}
}
if (createMermaidGraphForDebugging && playAnimationActions.size) {
console.log(animationsGraph);
}
const animationTargetPaths = new Set();
for (const o of animationTargetObjects) {
if (!o.getPath) {
console.error("USDZExporter: Animation target object has no getPath method. This is likely a bug", o);
}
let path = o.getPath();
// remove < and >, these are part of USD paths
if (path.startsWith("<"))
path = path.substring(1);
if (path.endsWith(">"))
path = path.substring(0, path.length - 1);
animationTargetPaths.add({ path, obj: o });
}
// order by length
const sortedPaths = Array.from(animationTargetPaths).sort((a, b) => a.path.length - b.path.length);
const overlappingTargets = new Array();
for (let i = 0; i < sortedPaths.length; i++) {
for (let j = i + 1; j < sortedPaths.length; j++) {
if (sortedPaths[j].path.startsWith(sortedPaths[i].path)) {
const c = sortedPaths[j];
const p = sortedPaths[i];
overlappingTargets.push({ child: c.obj.displayName + " (" + c.path + ")", parent: p.obj.displayName + " (" + p.path + ")" });
}
}
}
// There's some overlapping animation targets – we should warn here, so that this
// can be resolved in the scene.
if (overlappingTargets.length) {
console.warn("USDZExporter: There are overlapping PlayAnimation actions. This can lead to undefined runtime behaviour when playing multiple animations. Please restructure the hierarchy so that animations don't overlap.", {
overlappingTargets,
playAnimationActions,
});
}
}
for (const source of new Set([...triggerSources, ...actionTargets])) {
// shouldn't happen but strictly speaking a trigger source could be set to an array
if (Array.isArray(source)) {
for (const s of source)
targetUuids.add(s.uuid);
}
else
targetUuids.add(source.uuid);
}
if (debug)
console.log("All Behavior trigger sources and action targets", triggerSources, actionTargets, targetUuids);
this.targetUuids = new Set(targetUuids);
}
onAfterHierarchy(context, writer) {
if (this.behaviours?.length) {
writer.beginBlock('def Scope "Behaviors"');
for (const beh of this.behaviours)
beh.writeTo(this, context.document, writer);
writer.closeBlock();
}
}
async onAfterSerialize(context) {
if (debug)
console.log("onAfterSerialize behaviours", this.behaviourComponentsCopy);
for (const beh of this.behaviourComponentsCopy) {
if (typeof beh.afterSerialize === "function") {
const isAsync = beh.afterSerialize.constructor.name === "AsyncFunction";
if (isAsync) {
await beh.afterSerialize(this, context);
}
else {
beh.afterSerialize(this, context);
}
}
}
for (const { clipUrl, filesKey } of this.audioClipsCopy) {
// if the clip was already added, don't add it again
if (context.files[filesKey])
return;
const audio = await fetch(clipUrl);
const audioBlob = await audio.blob();
const arrayBuffer = await audioBlob.arrayBuffer();
const audioData = new Uint8Array(arrayBuffer);
context.files[filesKey] = audioData;
}
// cleanup
this.behaviourComponentsCopy.length = 0;
this.audioClipsCopy.length = 0;
}
}
//# sourceMappingURL=Behaviour.js.map