@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.
305 lines • 11.7 kB
JavaScript
import { AnimationClip, BooleanKeyframeTrack, Color, ColorKeyframeTrack, Euler, InterpolateDiscrete, InterpolateLinear, InterpolateSmooth, NumberKeyframeTrack, Object3D, Quaternion, QuaternionKeyframeTrack, Vector3, VectorKeyframeTrack } from "three";
import { isDevEnvironment } from "../engine/debug/index.js";
// --- Implementation ---
/**
* Creates an animation track descriptor targeting a property on the given object.
*
* The `target` is used for **TypeScript type inference** — it determines which property names
* are offered and what value types the keyframes accept. By default, the resulting track
* targets "self" (the mixer root). Pass `{ root }` to target a named child instead.
*
* @param target - The object whose type determines valid properties and value types
* @param property - The property to animate (e.g. `"position"`, `"opacity"`, `"intensity"`)
* @param keyframes - An array of {@link AnimationKeyframe} objects, or a {@link Tween} shorthand
* @param options - Optional {@link TrackOptions} with a `root` for named targeting
* @returns A {@link TrackDescriptor} that can be passed to {@link createAnimation}, or inline
* to `AnimatorControllerBuilder.state()` or `TimelineBuilder.clip()`
*
* @example Keyframe array
* ```ts
* track(door, "position", [
* { time: 0, value: [0, 0, 0] },
* { time: 1, value: [2, 0, 0] },
* ])
* ```
*
* @example Tween shorthand
* ```ts
* track(door, "position", { from: [0, 0, 0], to: [2, 0, 0], duration: 1 })
* ```
*
* @example Named targeting (track targets a child of root)
* ```ts
* track(door, "position", keyframes, { root: room })
* ```
*
* @category Animation and Sequencing
* @group Utilities
*/
export function track(target, property, keyframes, options) {
const kf = isTween(keyframes) ? tweenToKeyframes(keyframes) : keyframes;
return {
__isTrackDescriptor: true,
_target: target,
_property: property,
_keyframes: kf.map(k => ({ ...k, value: snapshotValue(k.value) })),
_root: options?.root,
};
}
/** @internal alias so the AnimationBuilder class method can call the standalone function */
const trackFn = track;
// ============================================================
// AnimationBuilder
// ============================================================
/**
* A fluent builder for creating `AnimationClip` instances from code.
*
* Use {@link AnimationBuilder.create} to start a new builder, chain `.track()` calls
* to add animation tracks, and call `.build()` to produce the clip.
*
* @example Single track
* ```ts
* const clip = AnimationBuilder.create()
* .track(door, "position", { from: [0,0,0], to: [2,0,0], duration: 1 })
* .build();
* ```
*
* @example Multiple tracks
* ```ts
* const clip = AnimationBuilder.create("DoorOpen")
* .track(door, "position", { from: [0,0,0], to: [2,0,0], duration: 1 })
* .track(light, "intensity", { from: 0, to: 5, duration: 1 })
* .build(room);
* ```
*
* @category Animation and Sequencing
* @group Utilities
*/
export class AnimationBuilder {
_name;
_tracks = [];
/** Creates a new AnimationBuilder instance */
static create(name) {
return new AnimationBuilder(name);
}
constructor(name) {
this._name = name;
}
track(target, property, keyframes, options) {
this._tracks.push(trackFn(target, property, keyframes, options));
return this;
}
/**
* Builds and returns the `AnimationClip`.
* @param root - Optional root Object3D for resolving track paths.
* When provided, tracks targeting a different object use `target.name` for named resolution.
*/
build(root) {
return resolveToClip(this._tracks, root, this._name);
}
}
export function createAnimation(...args) {
let options;
let descriptors;
if (args.length > 0 && !isTrackDescriptor(args[0])) {
options = args[0];
descriptors = args.slice(1);
}
else {
descriptors = args;
}
return resolveToClip(descriptors, options?.root, options?.name);
}
// ============================================================
// Resolution helpers (exported for use by AnimatorControllerBuilder / TimelineBuilder)
// ============================================================
/**
* Resolves a clip source (AnimationClip, TrackDescriptor, or TrackDescriptor[]) into an AnimationClip.
* Used internally by AnimatorControllerBuilder and TimelineBuilder.
* @internal
*/
export function resolveClipSource(clip, root) {
if (clip instanceof AnimationClip)
return clip;
if (Array.isArray(clip))
return resolveToClip(clip, root);
if (isTrackDescriptor(clip))
return resolveToClip([clip], root);
return clip; // should not reach
}
/** Type guard for {@link TrackDescriptor} */
export function isTrackDescriptor(obj) {
return obj != null && typeof obj === "object" && obj.__isTrackDescriptor === true;
}
/** Resolves an array of TrackDescriptors into an AnimationClip. @internal */
export function resolveToClip(descriptors, buildRoot, name) {
const keyframeTracks = [];
for (const desc of descriptors) {
keyframeTracks.push(buildKeyframeTrack(desc, buildRoot));
}
let duration = 0;
for (const t of keyframeTracks) {
const last = t.times[t.times.length - 1];
if (last !== undefined && last > duration)
duration = last;
}
return new AnimationClip(name ?? `clip_${_clipCounter++}`, duration, keyframeTracks);
}
// ============================================================
// Internal helpers
// ============================================================
let _clipCounter = 0;
function buildKeyframeTrack(desc, buildRoot) {
const property = resolvePropertyName(desc._property);
const trackName = resolveTrackName(desc, buildRoot, property);
const times = [];
const values = [];
for (const kf of desc._keyframes) {
times.push(kf.time);
const flat = flattenValue(kf.value, desc._property);
for (let i = 0; i < flat.length; i++)
values.push(flat[i]);
}
const interpolation = resolveInterpolation(desc._keyframes[0]?.interpolation);
const TrackClass = resolveTrackClass(desc._property, desc._keyframes[0]?.value);
return new TrackClass(trackName, times, values, interpolation);
}
function resolveTrackName(desc, buildRoot, resolvedProperty) {
const root = desc._root ?? buildRoot;
const property = resolvedProperty ?? resolvePropertyName(desc._property);
// Material target → always self-targeting with .material. prefix
if (isMaterial(desc._target)) {
return `.material.${property}`;
}
// No root → self-targeting
if (!root)
return `.${property}`;
// Root === target → self-targeting
if (root === desc._target)
return `.${property}`;
// Root !== target → named targeting
const target = desc._target;
const nodeName = target.name;
if (!nodeName) {
if (isDevEnvironment()) {
console.warn(`AnimationBuilder: target has no name, falling back to self-targeting. Set target.name for named targeting.`);
}
return `.${property}`;
}
// Dev mode: validate that target is actually a descendant of root
if (isDevEnvironment() && root instanceof Object3D && target instanceof Object3D) {
let found = false;
root.traverse(child => {
if (child === target)
found = true;
});
if (!found) {
console.warn(`AnimationBuilder: target "${nodeName}" is not a descendant of the provided root "${root.name}". The track may not resolve at play time.`);
}
}
return `${nodeName}.${property}`;
}
function resolvePropertyName(property) {
if (property === "rotation")
return "quaternion";
return property;
}
function resolveTrackClass(property, sampleValue) {
// Check property name first (most reliable)
if (property === "quaternion" || property === "rotation")
return QuaternionKeyframeTrack;
if (property === "visible")
return BooleanKeyframeTrack;
if (property === "position" || property === "scale")
return VectorKeyframeTrack;
if (property === "color" || property === "emissive")
return ColorKeyframeTrack;
// Check value type
if (sampleValue instanceof Vector3)
return VectorKeyframeTrack;
if (sampleValue instanceof Quaternion)
return QuaternionKeyframeTrack;
if (sampleValue instanceof Color)
return ColorKeyframeTrack;
if (sampleValue instanceof Euler)
return QuaternionKeyframeTrack;
if (typeof sampleValue === "boolean")
return BooleanKeyframeTrack;
if (typeof sampleValue === "number")
return NumberKeyframeTrack;
// Array → infer from length + property context
if (Array.isArray(sampleValue)) {
if (sampleValue.length === 4)
return QuaternionKeyframeTrack;
if (sampleValue.length === 3)
return VectorKeyframeTrack;
if (sampleValue.length === 2)
return VectorKeyframeTrack;
return NumberKeyframeTrack;
}
return NumberKeyframeTrack;
}
function flattenValue(value, property) {
// Tuple arrays — already flat
if (Array.isArray(value)) {
// Special case: Euler array [x,y,z] for "rotation" → convert to quaternion
if (property === "rotation" && value.length === 3) {
const q = new Quaternion().setFromEuler(new Euler(value[0], value[1], value[2]));
return [q.x, q.y, q.z, q.w];
}
return value;
}
if (typeof value === "number")
return [value];
if (typeof value === "boolean")
return [value ? 1 : 0];
if (value instanceof Vector3)
return [value.x, value.y, value.z];
if (value instanceof Quaternion)
return [value.x, value.y, value.z, value.w];
if (value instanceof Color)
return [value.r, value.g, value.b];
if (value instanceof Euler) {
const q = new Quaternion().setFromEuler(value);
return [q.x, q.y, q.z, q.w];
}
// duck-type Vector2/Vector3-like
if (typeof value === "object" && value !== null && "x" in value && "y" in value) {
if ("w" in value)
return [value.x, value.y, value.z, value.w];
if ("z" in value)
return [value.x, value.y, value.z];
return [value.x, value.y];
}
return [Number(value)];
}
function resolveInterpolation(mode) {
switch (mode) {
case "smooth": return InterpolateSmooth;
case "step": return InterpolateDiscrete;
default: return InterpolateLinear;
}
}
function isTween(kf) {
return kf != null && !Array.isArray(kf) && typeof kf === "object" && "from" in kf && "to" in kf;
}
function tweenToKeyframes(tween) {
return [
{ time: 0, value: tween.from, interpolation: tween.interpolation },
{ time: tween.duration ?? 1, value: tween.to, interpolation: tween.interpolation },
];
}
/** Snapshot a keyframe value so live references (e.g. `obj.position`) are captured at definition time */
function snapshotValue(value) {
if (value == null || typeof value !== "object")
return value; // primitives are fine
if (typeof value.clone === "function")
return value.clone(); // Vector3, Quaternion, Color, Euler, etc.
if (Array.isArray(value))
return value.slice(); // tuple arrays
return value;
}
function isMaterial(obj) {
return obj != null && typeof obj === "object" && obj.isMaterial === true;
}
//# sourceMappingURL=AnimationBuilder.js.map