@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.
549 lines • 21.1 kB
JavaScript
import { getParam } from "../../../../../engine/engine_utils.js";
import { makeNameSafeForUSD, USDObject } from "../../ThreeUSDZExporter.js";
const debug = getParam("debugusdz");
export class BehaviorModel {
static global_id = 0;
id;
trigger;
action;
exclusive = false;
makeExclusive(exclusive) {
this.exclusive = exclusive;
return this;
}
constructor(id, trigger, action) {
this.id = "Behavior_" + makeNameSafeForUSD(id) + "_" + BehaviorModel.global_id++;
this.trigger = trigger;
this.action = action;
// Special case: SceneTransition enter triggers should never run multiple times, even if the stage loops.
// There seems to have been a change in behavior in iOS, where SceneTransition enter runs even
// when the stage length is reached...
// BUT unfortunately it looks like child groups are also ignored then, and their own loops stop working
// once any parent is set to "ignore", which is different than what's in the documentation.
/*
if (trigger instanceof TriggerModel) {
if (trigger.tokenId === "SceneTransition" && trigger.type === "enter") {
const wrapper = ActionBuilder.sequence(action);
wrapper.multiplePerformOperation = "ignore";
this.action = wrapper;
}
}
*/
// Another idea: we let actions run forever by appending a waitAction to them
// Also doesn't seem to work... the scene start action still runs again
/*
if (trigger instanceof TriggerModel) {
if (trigger.tokenId === "SceneTransition" && trigger.type === "enter") {
const wrapper = ActionBuilder.sequence(action, ActionBuilder.waitAction(9999999999));
this.action = wrapper;
}
}
*/
}
writeTo(_ext, document, writer) {
if (!this.trigger || !this.action)
return;
writer.beginBlock(`def Preliminary_Behavior "${this.id}"`);
let triggerString = "";
if (Array.isArray(this.trigger)) {
triggerString = "[";
for (let i = 0; i < this.trigger.length; i++) {
const tr = this.trigger[i];
triggerString += "<" + tr.id + ">";
if (i + 1 < this.trigger.length)
triggerString += ", ";
}
triggerString += "]";
}
else
triggerString = `<${this.trigger.id}>`;
writer.appendLine(`rel triggers = ${triggerString}`);
writer.appendLine(`rel actions = <${this.action.id}>`);
writer.appendLine(`uniform bool exclusive = ${this.exclusive ? 1 : 0}`); // Apple uses 0 and 1 for bools
writer.appendLine();
if (Array.isArray(this.trigger)) {
for (const trigger of this.trigger) {
trigger.writeTo(document, writer);
writer.appendLine();
}
}
else
this.trigger.writeTo(document, writer);
writer.appendLine();
this.action.writeTo(document, writer);
writer.closeBlock();
}
}
const addedStrings = new Set();
/** called to resolve target objects to usdz paths */
function resolve(targetObject, document) {
let result = "";
if (Array.isArray(targetObject)) {
addedStrings.clear();
let str = "[ ";
for (let i = 0; i < targetObject.length; i++) {
let obj = targetObject[i];
if (!obj) {
console.warn("Invalid target object in behavior", targetObject + ". Is the object exported?");
continue;
}
if (typeof obj === "string") {
if (addedStrings.has(obj))
continue;
str += obj;
addedStrings.add(obj);
}
else if (typeof obj === "object") {
//@ts-ignore
if (obj.isObject3D) {
//@ts-ignore
obj = document.findById(obj.uuid);
if (!obj) {
console.warn("Invalid target object in behavior", targetObject + ". Is the object exported?");
continue;
}
}
const res = obj.getPath?.call(obj);
if (addedStrings.has(res))
continue;
// console.log(str, res, addedStrings)
str += res;
addedStrings.add(res);
}
if (i + 1 < targetObject.length)
str += ", ";
}
str += " ]";
result = str;
addedStrings.clear();
}
else if (typeof targetObject === "object") {
const sourceObject = targetObject;
//@ts-ignore
if (sourceObject.isObject3D) {
//@ts-ignore
targetObject = document.findById(sourceObject.uuid);
}
if (!targetObject) {
console.error("Invalid target object in behavior, the target object is likely missing from USDZ export. Is the object exported?", sourceObject);
throw new Error(`Invalid target object in behavior, the target object is likely missing from USDZ export. Please report a bug. uuid: ${sourceObject.uuid}.`);
}
result = targetObject.getPath?.call(targetObject);
}
return result;
}
export class TriggerModel {
static global_id = 0;
id;
targetId;
tokenId;
type;
distance;
constructor(targetId, id) {
if (targetId)
this.targetId = targetId;
if (id)
this.id = id;
else
this.id = "Trigger_" + TriggerModel.global_id++;
}
writeTo(document, writer) {
writer.beginBlock(`def Preliminary_Trigger "${this.id}"`);
if (this.targetId) {
if (typeof this.targetId !== "string")
this.targetId = resolve(this.targetId, document);
writer.appendLine(`rel affectedObjects = ` + this.targetId);
}
if (this.tokenId)
writer.appendLine(`token info:id = "${this.tokenId}"`);
if (this.type)
writer.appendLine(`token type = "${this.type}"`);
if (typeof this.distance === "number")
writer.appendLine(`double distance = ${this.distance}`);
writer.closeBlock();
}
}
/** Adds `RealityKit.InputTarget` child prim to enable Vision OS direct/indirect interactions
(by default, only indirect interactions are allowed) */
function addInputTargetComponent(model, options = { direct: true, indirect: true }) {
const empty = USDObject.createEmpty();
empty.name = "InputTarget_" + empty.name;
empty.displayName = undefined;
empty.type = "RealityKitComponent";
empty.onSerialize = (writer) => {
writer.appendLine("bool allowsDirectInput = " + (options.direct ? 1 : 0));
writer.appendLine("bool allowsIndirectInput = " + (options.indirect ? 1 : 0));
writer.appendLine('uniform token info:id = "RealityKit.InputTarget"');
};
model.add(empty);
}
export class TriggerBuilder {
static __sceneStartTrigger;
static sceneStartTrigger() {
if (this.__sceneStartTrigger !== undefined)
return this.__sceneStartTrigger;
const trigger = new TriggerModel(undefined, "SceneStart");
trigger.tokenId = "SceneTransition";
trigger.type = "enter";
this.__sceneStartTrigger = trigger;
return trigger;
}
/** Trigger that fires when an object has been tapped/clicked.
* @param targetObject The object or list of objects that can be interacted with.
* @param inputMode Input Mode (direct and/or indirect). Only available for USDObject targets. Only supported on Vision OS at the moment. */
static tapTrigger(targetObject, inputMode = { direct: true, indirect: true }) {
const trigger = new TriggerModel(targetObject);
if (Array.isArray(targetObject) && targetObject.length > 1) {
for (const obj of targetObject) {
if (!(obj instanceof USDObject))
continue;
addInputTargetComponent(obj, inputMode);
}
}
else {
if (targetObject instanceof USDObject) {
addInputTargetComponent(targetObject, inputMode);
}
}
trigger.tokenId = "TapGesture";
return trigger;
}
static isTapTrigger(trigger) {
return trigger?.tokenId === "TapGesture";
}
static proximityToCameraTrigger(targetObject, distance) {
const trigger = new TriggerModel(targetObject);
trigger.tokenId = "ProximityToCamera";
trigger.distance = distance;
return trigger;
}
}
export class GroupActionModel {
static global_id = 0;
static getId() {
return this.global_id++;
}
id;
actions;
loops = 0;
performCount = 1;
type = "serial";
multiplePerformOperation = undefined;
constructor(id, actions) {
this.id = id;
this.actions = actions;
}
addAction(el) {
this.actions.push(el);
return this;
}
makeParallel() {
this.type = "parallel";
return this;
}
makeSequence() {
this.type = "serial";
return this;
}
makeLooping() {
this.loops = 1;
this.performCount = 0;
return this;
}
makeRepeat(count) {
this.performCount = count;
return this;
}
writeTo(document, writer) {
writer.beginBlock(`def Preliminary_Action "${this.id}"`);
writer.beginArray("rel actions");
for (const act of this.actions) {
if (!act)
continue;
const isLast = act === this.actions[this.actions.length - 1];
writer.appendLine("<" + act.id + ">" + (isLast ? "" : ", "));
}
writer.closeArray();
writer.appendLine();
writer.appendLine(`token info:id = "Group"`);
writer.appendLine(`bool loops = ${this.loops}`);
writer.appendLine(`int performCount = ${this.loops > 0 ? 0 : Math.max(0, this.performCount)}`);
writer.appendLine(`token type = "${this.type}"`);
if (typeof this.multiplePerformOperation === "string") {
writer.appendLine(`token multiplePerformOperation = "${this.multiplePerformOperation}"`);
}
writer.appendLine();
for (const act of this.actions) {
if (!act)
continue;
act.writeTo(document, writer);
writer.appendLine();
}
writer.closeBlock();
}
}
export class ActionModel {
static global_id = 0;
id;
tokenId;
affectedObjects;
easeType;
;
motionType = undefined;
duration;
moveDistance;
style;
type; // combined types of different actions
front;
up;
start;
animationSpeed;
reversed;
pingPong;
xFormTarget;
audio;
gain;
auralMode;
multiplePerformOperation;
velocity;
// extra info written as comment at the beginning of the action
comment;
animationName;
clone() {
const copy = new ActionModel();
const id = copy.id;
Object.assign(copy, this);
copy.id = id;
return copy;
}
constructor(affectedObjects, id) {
if (affectedObjects)
this.affectedObjects = affectedObjects;
if (id)
this.id = id;
else
this.id = "Action";
this.id += "_" + ActionModel.global_id++;
}
writeTo(document, writer) {
writer.beginBlock(`def Preliminary_Action "${this.id}"`);
if (this.comment)
writer.appendLine(`# ${this.comment}`);
if (this.affectedObjects) {
if (typeof this.affectedObjects !== "string")
this.affectedObjects = resolve(this.affectedObjects, document);
writer.appendLine('rel affectedObjects = ' + this.affectedObjects);
}
if (typeof this.duration === "number") {
if (typeof this.animationSpeed === "number" && this.animationSpeed !== 1) {
writer.appendLine(`double duration = ${this.duration / this.animationSpeed} `);
}
else {
writer.appendLine(`double duration = ${this.duration} `);
}
}
if (this.easeType)
writer.appendLine(`token easeType = "${this.easeType}"`);
if (this.tokenId)
writer.appendLine(`token info:id = "${this.tokenId}"`);
if (this.tokenId === "ChangeScene")
writer.appendLine(`rel scene = </StageRoot/Scenes/Scene>`);
if (this.motionType !== undefined)
writer.appendLine(`token motionType = "${this.motionType}"`);
if (typeof this.moveDistance === "number")
writer.appendLine(`double moveDistance = ${this.moveDistance} `);
if (this.style)
writer.appendLine(`token style = "${this.style}"`);
if (this.type)
writer.appendLine(`token type = "${this.type}"`);
if (this.front)
writer.appendLine(`vector3d front = (${this.front.x}, ${this.front.y}, ${this.front.z})`);
if (this.up)
writer.appendLine(`vector3d upVector = (${this.up.x}, ${this.up.y}, ${this.up.z})`);
if (typeof this.start === "number") {
writer.appendLine(`double start = ${this.start} `);
}
if (typeof this.animationSpeed === "number") {
writer.appendLine(`double animationSpeed = ${this.animationSpeed.toFixed(2)} `);
}
if (typeof this.reversed === "boolean") {
writer.appendLine(`bool reversed = ${this.reversed}`);
}
if (typeof this.pingPong === "boolean") {
writer.appendLine(`bool reverses = ${this.pingPong}`);
}
if (this.xFormTarget) {
if (typeof this.xFormTarget !== "string")
this.xFormTarget = resolve(this.xFormTarget, document);
writer.appendLine(`rel xformTarget = ${this.xFormTarget}`);
}
if (typeof this.audio === "string") {
writer.appendLine(`asset audio = @${this.audio}@`);
}
if (typeof this.gain === "number") {
writer.appendLine(`double gain = ${this.gain}`);
}
if (typeof this.auralMode === "string") {
writer.appendLine(`token auralMode = "${this.auralMode}"`);
}
if (typeof this.multiplePerformOperation === "string") {
writer.appendLine(`token multiplePerformOperation = "${this.multiplePerformOperation}"`);
}
if (typeof this.velocity === "object") {
writer.appendLine(`vector3d velocity = (${this.velocity.x}, ${this.velocity.y}, ${this.velocity.z})`);
}
writer.closeBlock();
}
}
class Vec3 {
x = 0;
y = 0;
z = 0;
constructor(x, y, z) {
this.x = x;
this.y = y;
this.z = z;
}
static get up() {
return new Vec3(0, 1, 0);
}
static get right() {
return new Vec3(1, 0, 0);
}
static get forward() {
return new Vec3(0, 0, 1);
}
static get back() {
return new Vec3(0, 0, -1);
}
static get zero() {
return new Vec3(0, 0, 0);
}
}
export class ActionBuilder {
static sequence(...params) {
const group = new GroupActionModel("Group_" + GroupActionModel.getId(), params);
return group.makeSequence();
}
static parallel(...params) {
const group = new GroupActionModel("Group_" + GroupActionModel.getId(), params);
return group.makeParallel();
}
static fadeAction(targetObject, duration, show) {
const act = new ActionModel(targetObject);
act.tokenId = "Visibility";
act.type = show ? "show" : "hide";
act.duration = duration;
act.style = "basic";
act.motionType = "none"; // only VisibilityActionMotionType allowed
act.moveDistance = 0;
act.easeType = "none";
return act;
}
/**
* creates an action that plays an animation
* @param start offset in seconds!
* @param duration in seconds! 0 means play to end
*/
static startAnimationAction(targetObject, anim, reversed = false, pingPong = false) {
const act = new ActionModel(targetObject);
act.tokenId = "StartAnimation";
/*
if (targetObject instanceof USDObject) {
act.cachedTargetObject = targetObject;
// try to retarget the animation – this improves animation playback with overlapping roots.
act.affectedObjects = anim.nearestAnimatedRoot;
}
*/
const start = anim.start;
const duration = anim.duration;
const animationSpeed = anim.speed;
const animationName = anim.clipName;
act.comment = `Animation: ${animationName}, start=${start * 60}, length=${duration * 60}, end=${(start + duration) * 60}`;
act.animationName = animationName;
// start is time in seconds, the documentation is not right here
act.start = start;
// duration of 0 is play to end
act.duration = duration;
act.animationSpeed = animationSpeed;
act.reversed = reversed;
act.pingPong = pingPong;
act.multiplePerformOperation = "allow";
if (reversed) {
act.start -= duration;
//console.warn("Reversed animation does currently not work. The resulting file will most likely not playback.", act.id, targetObject);
}
if (pingPong) {
act.pingPong = false;
const back = act.clone();
back.reversed = !reversed;
back.start = act.start;
if (back.reversed) {
back.start -= duration;
}
const group = ActionBuilder.sequence(act, back);
return group;
}
// if (debug) console.log("Start Animation Action", act);
return act;
}
static waitAction(duration) {
const act = new ActionModel();
act.tokenId = "Wait";
act.duration = duration;
act.motionType = undefined;
return act;
}
static lookAtCameraAction(targets, duration, front, up) {
const act = new ActionModel(targets);
act.tokenId = "LookAtCamera";
act.duration = duration === undefined ? 9999999999999 : duration;
act.front = front ?? Vec3.forward;
// 0,0,0 is a special case for "free look"
// 0,1,0 is for "y-locked look-at"
act.up = up ?? Vec3.up;
return act;
}
static emphasize(targets, duration, motionType = "bounce", moveDistance = 1, style = "basic") {
const act = new ActionModel(targets);
act.tokenId = "Emphasize";
act.duration = duration;
act.style = style ?? "basic";
act.motionType = motionType;
act.moveDistance = moveDistance;
return act;
}
static transformAction(targets, transformTarget, duration, transformType, easeType = "inout") {
const act = new ActionModel(targets);
act.tokenId = "Transform";
act.duration = duration;
// Workaround for a bug in QuickLook: if duration is 0, loops stop somewhat randomly. FB13759712
act.duration = Math.max(0.000001, duration);
act.type = transformType;
act.easeType = duration > 0 ? easeType : "none";
if (Array.isArray(transformTarget)) {
console.error("Transform target must not be an array", transformTarget);
}
act.xFormTarget = transformTarget;
return act;
}
static playAudioAction(targets, audio, type = "play", gain = 1, auralMode = "spatial") {
const act = new ActionModel(targets);
act.tokenId = "Audio";
act.type = type;
act.audio = audio;
act.gain = gain;
act.auralMode = auralMode;
act.multiplePerformOperation = "allow";
return act;
}
// Supported only on VisionOS, Preliminary Behaviours can affect RealityKit physics as well
static impulseAction(targets, velocity) {
const act = new ActionModel(targets);
act.tokenId = "Impulse";
act.velocity = velocity;
return act;
}
}
export { Vec3 as USDVec3 };
//# sourceMappingURL=BehavioursBuilder.js.map