UNPKG

@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
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