animouse
Version:
lightweight animation state machine for three js
2,184 lines • 104 kB
JavaScript
import { MathUtils, LoopOnce, Vector2 } from 'three';
/**
* Defines events related to a blend state and its internal AnimationActions.
*
* - ENTER and EXIT describe when the blend state itself becomes active or inactive in the state machine.
* - PLAY, STOP, ITERATE, and FINISH describe the lifecycle of individual AnimationActions inside this blend state.
*/
var AnimationStateEvent;
(function (AnimationStateEvent) {
/**
* Fired when the blend state itself becomes active.
* Indicates that a transition into this state has started.
*/
AnimationStateEvent["ENTER"] = "enter";
/**
* Fired when the blend state itself becomes inactive.
* Indicates that a transition away from this state has started.
*/
AnimationStateEvent["EXIT"] = "exit";
/**
* Fired when an individual AnimationAction inside this blend state starts playing.
* For example, when a walk or run action becomes active due to weights.
*/
AnimationStateEvent["PLAY"] = "play";
/**
* Fired when an individual AnimationAction inside this blend state stops playing.
* For example, when a walk or run action stops as its weights reach zero.
*/
AnimationStateEvent["STOP"] = "stop";
/**
* Fired when an individual AnimationAction inside this blend state completes one full loop.
* Fires every cycle for looping actions (LoopRepeat, LoopPingPong).
*/
AnimationStateEvent["ITERATE"] = "iterate";
/**
* Fired when an individual AnimationAction inside this blend state reaches its natural end.
* This applies to non-looping actions (LoopOnce) that finish playing completely.
*/
AnimationStateEvent["FINISH"] = "finish";
})(AnimationStateEvent || (AnimationStateEvent = {}));
/**
* Small epsilon value used for floating-point comparisons.
*/
const EPSILON = 1e-6;
/**
* Two times PI (2π), commonly used for full circle calculations.
*/
const PI2 = Math.PI * 2;
let lastAnchorIndex = 0;
/**
* Gets the next unique anchor index for animation anchors.
* Each call returns an incrementing integer starting from 0.
* @returns The next available anchor index
*/
function getNextAnchorIndex() {
return lastAnchorIndex++;
}
/**
* Asserts that a number is valid (finite and does not exceed safe integer range).
*
* @param value - The number to validate
* @param message - Custom error message for validation failure
* @throws {Error} When the value is not finite or exceeds MAX_SAFE_INTEGER
*/
function assertValidNumber(value, message) {
if (!Number.isFinite(value)) {
throw new Error(`${message}: value must be a finite number`);
}
if (Math.abs(value) > Number.MAX_SAFE_INTEGER) {
throw new Error(`${message}: value exceeds maximum safe integer range`);
}
}
/**
* Asserts that an azimuth angle is valid (finite, within safe range, and between 0 and 2π).
*
* @param value - The azimuth angle in radians to validate
* @param message - Custom error message for validation failure
* @throws {Error} When the value is not finite, exceeds MAX_SAFE_INTEGER, or is not between 0 and 2π radians
* @see {@link assertValidNumber} for base number validation
*/
function assertValidAzimuth(value, message) {
assertValidNumber(value, message);
if (value < 0 || value > PI2) {
throw new Error(`${message}: azimuth must be between 0 and 2π radians`);
}
}
/**
* Asserts that a number is within the unit range [0, 1].
*
* @param value - The number to validate
* @param message - Custom error message for validation failure
* @throws {Error} When the value is not within the range [0, 1]
* @see {@link assertValidNumber} for base number validation
*/
function assertValidUnitRange(value, message) {
assertValidNumber(value, message);
if (value < 0 || value > 1) {
throw new Error(`${message}: value must be between 0 and 1`);
}
}
/**
* Asserts that a number is positive (greater than or equal to EPSILON).
* Uses EPSILON to account for floating-point precision errors.
*
* @param value - The number to validate
* @param message - Custom error message for validation failure
* @throws {Error} When the value is less than EPSILON
* @see {@link assertValidNumber} for base number validation
*/
function assertValidPositiveNumber(value, message) {
assertValidNumber(value, message);
if (value < EPSILON) {
throw new Error(`${message}: value must be greater than or equal to ${EPSILON}`);
}
}
/**
* Asserts that a number is non-negative (greater than or equal to 0).
*
* @param value - The number to validate
* @param message - Custom error message for validation failure
* @throws {Error} When the value is negative
* @see {@link assertValidNumber} for base number validation
*/
function assertValidNonNegativeNumber(value, message) {
assertValidNumber(value, message);
if (value < 0) {
throw new Error(`${message}: value must be greater than or equal to 0`);
}
}
/**
* Animation state machine for managing complex state transitions and blending.
*
* Provides a comprehensive system for controlling animation states with support for:
* - **Event-based transitions**: Triggered by specific events with optional conditions
* - **Automatic transitions**: Triggered when animations complete (ITERATE/FINISH events)
* - **Data-driven transitions**: Evaluated continuously based on condition functions
*
* The state machine handles smooth blending between states using configurable transition
* durations, automatically managing animation weights and ensuring proper lifecycle events.
* Multiple states can be active simultaneously during transitions, with weights smoothly
* interpolated using linear interpolation.
*/
class AnimationMachine {
/**
* Creates a new animation state machine with the specified initial state.
* Immediately activates the initial state with full influence and enters it.
*
* @param initialState - The starting animation state to activate
* @param mixer - The THREE.js animation mixer for updating animations
*/
constructor(initialState, mixer) {
/** States that are currently fading out during transitions */
this._private_fadingStates = [];
/** Map of event names to their possible transitions */
this._private_eventTransitions = new Map();
/** Map of states to their automatic transitions that occur at animation end */
this._private_automaticTransitions = new Map();
/** Map of states to their data-driven transitions */
this._private_dataTransitions = new Map();
this._private_currentStateInternal = initialState;
this._private_currentStateInternal["onEnterInternal"]();
this._private_currentStateInternal["setInfluenceInternal"](1);
this._private_mixer = mixer;
this._private_eventTransitions = new Map();
}
/**
* Gets the currently active animation state.
* During transitions, this returns the state being transitioned to.
*
* @returns The current active animation state
*/
get currentState() {
return this._private_currentStateInternal;
}
/**
* Adds a new event-triggered transition to the state machine.
* Multiple transitions can be registered for the same event with different source states.
* When an event is handled, the first matching transition will be executed.
*
* @param event - The event name or identifier that triggers this transition
* @param transition - The transition configuration with target state and duration
* @throws {Error} When transition duration is not a finite non-negative number
* @throws {Error} When transition creates a recursive loop (from === to)
* @throws {Error} When transition already exists for the same event and source state
* @see {@link assertValidNonNegativeNumber} for duration validation details
*/
addEventTransition(event, transition) {
assertValidNonNegativeNumber(transition.duration, "Event transition duration");
if (transition.from === transition.to) {
throw new Error("Event animation transition can't create a recursive loop to itself");
}
const transitions = this._private_eventTransitions.get(event) ?? [];
if (!transition.condition) {
for (const someTransition of transitions) {
if (!someTransition.condition &&
someTransition.from === transition.from) {
throw new Error("Event animation transition already exists");
}
}
}
transitions.push(transition);
this._private_eventTransitions.set(event, transitions);
}
/**
* Adds an automatic transition that occurs when an animation completes.
* Listens for ITERATE and FINISH events from the source state to trigger transitions.
* Only one automatic transition can be registered per source state.
*
* @param from - The source state that will trigger the automatic transition
* @param transition - The transition configuration with target state and duration
* @throws {Error} When transition duration is not a finite non-negative number
* @throws {Error} When transition creates a recursive loop (from === to)
* @throws {Error} When an automatic transition already exists for the source state
* @see {@link assertValidNonNegativeNumber} for duration validation details
* @see {@link AnimationStateEvent.ITERATE} for iteration event details
* @see {@link AnimationStateEvent.FINISH} for completion event details
*/
addAutomaticTransition(from, transition) {
assertValidNonNegativeNumber(transition.duration, "Automatic transition duration");
if (from === transition.to) {
throw new Error("Automatic animation transition can't create a recursive loop to itself");
}
if (this._private_automaticTransitions.has(from)) {
throw new Error("Automatic transition already exists");
}
this._private_automaticTransitions.set(from, transition);
from.on(AnimationStateEvent.ITERATE, this._private_onStateIteration, this);
from.on(AnimationStateEvent.FINISH, this._private_onStateIteration, this);
}
/**
* Adds a data-driven transition that is evaluated continuously during updates.
* The condition function is called each frame when the source state is active.
* Multiple data transitions can be registered per state, but only one per target.
*
* @param from - The source state where the transition can originate
* @param transition - The transition configuration with condition and target state
* @throws {Error} When transition duration is not a finite non-negative number
* @throws {Error} When transition creates a recursive loop (from === to)
* @throws {Error} When a data transition to the same target already exists
* @see {@link assertValidNonNegativeNumber} for duration validation details
*/
addDataTransition(from, transition) {
assertValidNonNegativeNumber(transition.duration, "Data transition duration");
if (from === transition.to) {
throw new Error("Automatic animation transition can't create a recursive loop to itself");
}
const transitions = this._private_dataTransitions.get(from) ?? [];
transitions.push(transition);
this._private_dataTransitions.set(from, transitions);
}
/**
* Handles an event by checking and executing the first matching transition.
* Evaluates all registered transitions for the event in registration order,
* executing the first one that matches the current state and passes conditions.
*
* @param event - The event name or identifier to handle
* @param args - Additional arguments passed to transition condition functions
* @returns True if a transition was executed, false if no matching transition found
*/
handleEvent(event, ...args) {
const transitions = this._private_eventTransitions.get(event);
if (transitions === undefined) {
return false;
}
for (const { from, to, duration, condition } of transitions) {
const isValidFromState = !from || from === this._private_currentStateInternal;
const isValidCondition = !condition || condition(from, to, event, ...args);
if (isValidFromState && isValidCondition) {
return this._private_transitionTo(to, duration);
}
}
return false;
}
/**
* Updates the animation state machine and all animations for one frame.
*
* The update process:
* 1. Updates current state and all fading states with frame timing
* 2. Evaluates data-driven transitions for potential state changes
* 3. Progresses active transitions by interpolating state influences
* 4. Cleans up completed transitions and resets influences
* 5. Updates the THREE.js animation mixer
*
* @param deltaTime - Time elapsed since last update in seconds (finite non-negative number)
* @throws {Error} When deltaTime is not a finite non-negative number
* @see {@link assertValidNonNegativeNumber} for deltaTime validation details
*/
update(deltaTime) {
assertValidNonNegativeNumber(deltaTime, "Delta time");
// Update current state
if (this._private_currentStateInternal.influence > 0) {
this._private_currentStateInternal["onTickInternal"](deltaTime);
}
// Update all fading states
for (const state of this._private_fadingStates) {
if (state.influence > 0) {
state["onTickInternal"](deltaTime);
}
}
const transition = this._private_dataTransitions.get(this._private_currentStateInternal)
?.find((transition) => transition.condition(this._private_currentStateInternal, transition.to, ...(transition.data ?? [])));
if (transition) {
this._private_transitionTo(transition.to, transition.duration);
}
if (this._private_transitionElapsedTime !== undefined) {
const t = this._private_transitionElapsedTime === 0
? 1
: Math.min(1, deltaTime / this._private_transitionElapsedTime);
for (const state of this._private_fadingStates) {
state["setInfluenceInternal"](MathUtils.lerp(state.influence, 0, t));
}
this._private_currentStateInternal["setInfluenceInternal"](MathUtils.lerp(this._private_currentStateInternal.influence, 1, t));
this._private_transitionElapsedTime = Math.max(0, this._private_transitionElapsedTime - deltaTime);
if (this._private_transitionElapsedTime === 0) {
for (const state of this._private_fadingStates) {
state["setInfluenceInternal"](0);
}
this._private_fadingStates = [];
this._private_currentStateInternal["setInfluenceInternal"](1);
this._private_transitionElapsedTime = undefined;
}
}
this._private_mixer.update(deltaTime);
}
/**
* Transitions to a new animation state over the specified duration.
*
* @private
* @param {AnimationState} state - The state to transition to
* @param {number} duration - The duration of the transition in seconds
*/
_private_transitionTo(state, duration) {
if (this._private_currentStateInternal === state) {
return false;
}
this._private_fadingStates = this._private_fadingStates.filter((s) => s !== state);
this._private_fadingStates.push(this._private_currentStateInternal);
this._private_currentStateInternal["onExitInternal"]();
this._private_currentStateInternal = state;
this._private_currentStateInternal["onEnterInternal"]();
this._private_transitionElapsedTime = duration;
return true;
}
/**
* Handles animation iteration events for automatic transitions.
*
* @private
* @param {AnimationState} state - The state that completed an iteration
*/
_private_onStateIteration(action, state) {
if (state === this._private_currentStateInternal) {
const transition = this._private_automaticTransitions.get(this._private_currentStateInternal);
if (transition) {
this._private_transitionTo(transition.to, transition.duration);
}
}
}
}
class t{constructor(t,i,r=0){this.t=new WeakMap,this.i=new Map,void 0!==t&&(void 0===i?this.i.set(t,r):this.t.set(i,new Map([[t,r]])));}insert(t,i,r=0){if(void 0===i){if(this.i.has(t))return;return void this.i.set(t,r)}const s=this.t.get(i);void 0===s?this.t.set(i,new Map([[t,r]])):s.has(t)||s.set(t,r);}remove(t,i){if(void 0===i)return void this.i.delete(t);const r=this.t.get(i);void 0!==r&&(r.delete(t),0===r.size&&this.t.delete(i));}has(t,i){if(void 0===i)return this.i.has(t);const r=this.t.get(i);return void 0!==r&&r.has(t)}getPriority(t,i){if(void 0===i)return this.i.get(t);const r=this.t.get(i);return void 0!==r?r.get(t):void 0}}class i{constructor(){this.o=new Map;}on(t,i,r,s=0){return this.h(t,false,s,i,r),this}once(t,i,r,s=0){return this.h(t,true,s,i,r),this}off(t,i,r){const s=this.o.get(t);if(void 0===s)return this;s.u&&(s.u=false,s.v=s.v.slice());const o=s.v;if(0===o.length)return this.o.delete(t),this;if(void 0===i){this.o.delete(t);for(let t=0;t<o.length;t++){const i=o[t];s.l.remove(i._,i.p);}return this}{const e=o.length;if(o[0]._===i&&o[0].p===r)return 1===e?this.o.delete(t):o.shift(),this;const n=e-1;if(o[n]._===i&&o[n].p===r)return o.pop(),this;if(e<10||o[0].M===o[n].M)for(let t=1;t<n;t++){const s=o[t];if(s._===i&&s.p===r)return o.splice(t,1),this}else {const t=s.l.getPriority(i,r);if(void 0===t)return this;let e=0,n=o.length-1;for(;e<=n;){const i=e+n>>>1;o[i].M<t?e=i+1:n=i-1;}const c=e;for(n=o.length-1;e<=n;){const i=e+n>>>1;o[i].M<=t?e=i+1:n=i-1;}const h=n;for(let t=c;t<=h;t++)if(o[t]._===i&&o[t].p===r)return o.splice(t,1),this}}return this}emit(t,...i){const r=this.o.get(t);if(void 0===r)return false;if(0===r.v.length)return false;r.u?r.v=r.v.slice():r.u=true;const s=r.v;let o=false;for(let t=0;t<s.length;t++){const r=s[t];r._.apply(r.p,i),r.k&&(o=true,r.m=true);}const e=this.o.get(t);if(void 0===e)return true;e.v===s&&(e.u=false);const n=e.v;if(o){let i=0;for(let t=0;t<n.length;t++){const r=n[t];r.m?e.l.remove(r._,r.p):n[i++]=r;}0===i?this.o.delete(t):n.length=i;}return true}h(i,r,s,o,e){let n=this.o.get(i);if(void 0===n)return void this.o.set(i,{v:[{_:o,p:e,M:s,k:r,m:false}],u:false,l:new t(o,e,s)});if(n.l.has(o,e))throw Error("Event listener already exists");n.l.insert(o,e,s);const c={_:o,p:e,M:s,k:r,m:false};n.u&&(n.u=false,n.v=n.v.slice());{const t=n.v,i=c.M;if(0===t.length||t[t.length-1].M<=i)return void t.push(c);if(t[0].M>=i)return void t.unshift(c);{let r=0,s=t.length;for(;r<s;){const o=r+s>>>1;t[o].M<i?r=o+1:s=o;}t.splice(r,0,c);}}}}
/**
* Abstract base class for animation states in the animation state machine.
* Manages state influence (weight) and provides lifecycle event handling.
*
* This class is designed to be extended by concrete animation states and
* controlled by the animation state machine.
*/
class AnimationState extends i {
constructor() {
super(...arguments);
/**
* The name identifier for this animation state.
* Used to identify and reference the state within the animation state machine.
*/
this.name = "";
/**
* Internal storage for the state's influence value.
* Represents the weight/contribution of this state in the animation state machine.
*/
this.influenceInternal = 0;
/**
* Map storing time-based event callbacks for each anchor.
* Maps anchors to their respective time events, where each time event
* is identified by a normalized time value and associated event name.
*/
this.timeEvents = new Map();
}
/**
* Gets the current influence (weight) of this animation state.
* The influence determines how much this state contributes to the overall animation.
*
* @returns The current influence value, always in range [0, 1]
*/
get influence() {
return this.influenceInternal;
}
/**
* Internal method called by the animation state machine when entering this state.
* Emits the ENTER event with this state instance as data.
*
* @internal This method is intended to be called only by the animation state machine
*/
["onEnterInternal"]() {
this.emit(AnimationStateEvent.ENTER, this);
}
/**
* Internal method called by the animation state machine when exiting this state.
* Emits the EXIT event with this state instance as data.
*
* @internal This method is intended to be called only by the animation state machine
*/
["onExitInternal"]() {
this.emit(AnimationStateEvent.EXIT, this);
}
/**
* Internal method to register a time-based event callback for an anchor.
* Normalizes the unit time to avoid floating-point precision issues and
* creates a unique event identifier for the time/anchor combination.
*
* @param anchor - The animation anchor to associate the time event with
* @param unitTime - Time in unit range [0, 1] when the event should fire
* @param callback - Function to call when the time event occurs
* @param isOnce - Whether the event should fire only once or repeatedly
* @internal This method is intended to be called only by concrete animation state implementations
*/
onTimeEventInternal(anchor, unitTime, callback, isOnce) {
assertValidUnitRange(unitTime, "Unit time");
const roundedTime = Math.round(unitTime * 100) / 100;
const events = this.timeEvents.get(anchor) ?? new Map();
this.timeEvents.set(anchor, events);
const event = events.get(roundedTime) ?? `${roundedTime}_${anchor.index}`;
events.set(roundedTime, event);
isOnce ? this.once(event, callback) : this.on(event, callback);
}
/**
* Internal method to unregister a time-based event callback for an anchor.
* Removes the callback from the specified time point and cleans up empty
* time event maps to prevent memory leaks.
*
* @param anchor - The animation anchor to remove the time event from
* @param unitTime - Time in unit range [0, 1] where the event was registered
* @param callback - The callback function to remove
* @internal This method is intended to be called only by concrete animation state implementations
*/
offTimeEventInternal(anchor, unitTime, callback) {
assertValidUnitRange(unitTime, "Unit time");
const roundedTime = Math.round(unitTime * 100) / 100;
const events = this.timeEvents.get(anchor);
if (!events) {
return;
}
const event = events.get(roundedTime);
if (!event) {
return;
}
this.off(event, callback);
events.delete(roundedTime);
if (events.size === 0) {
this.timeEvents.delete(anchor);
}
}
/**
* Updates the anchor's time tracking and emits iteration events when appropriate.
* Handles event firing logic based on animation time progression and anchor duration.
* Tracks whether iteration events have been fired to prevent duplicate emissions.
*
* @param anchor - The anchor object containing action, duration, and event tracking state
* @param deltaTime - Time elapsed since the last frame, in milliseconds
* @throws {Error} When the anchor's action is not running
* @internal This method is intended to be called only by concrete animation state implementations
*/
updateAnchorTime(anchor, deltaTime) {
const action = anchor.action;
if (anchor.action.weight === 0) {
throw new Error(`Cannot update anchor time for a non-running action: ${this.name}`);
}
const time = action.time;
const duration = anchor.duration;
if (time < duration && time + deltaTime >= duration) {
this.emit(anchor.iterationEventType, action, this);
}
}
resetFinishedAction(anchor) {
const action = anchor.action;
if (action.loop === LoopOnce && action.time === anchor.duration) {
action.time = 0;
action.paused = false;
action.enabled = true;
}
}
/**
* Processes and fires time-based events for an anchor during animation playback.
* Checks if the animation has crossed any registered time event thresholds
* and emits the corresponding events with the action and state as parameters.
*
* @param anchor - The animation anchor to process time events for
* @param deltaTime - Time elapsed since the last frame, in milliseconds
* @internal This method is intended to be called only by concrete animation state implementations
*/
processTimeEvents(anchor, deltaTime) {
const events = this.timeEvents.get(anchor);
if (events) {
const action = anchor.action;
const invDeltaTime = deltaTime * anchor.invDuration;
for (const [eventTime, eventName] of events) {
const actionTime = action.time * anchor.invDuration;
if (actionTime < eventTime && actionTime + invDeltaTime >= eventTime) {
this.emit(eventName, action, this);
}
}
}
}
}
/**
* Animation state that wraps a single Three.js AnimationAction.
* Manages the lifecycle of a single animation clip, handling playback,
* weight control, and iteration events.
*
* This state automatically detects animation completion and restart events,
* emitting appropriate state events based on the animation's loop type.
*/
class ClipState extends AnimationState {
/**
* Creates a new ClipState from a Three.js AnimationAction.
* Initializes the animation to a stopped state and configures iteration events
* based on the animation's loop type. Sets up anchor with duration tracking
* and event state management.
*
* @param animationAction - The Three.js AnimationAction to wrap (finite duration required)
* @throws {Error} When the animation clip duration is not a positive finite number
* @see {@link assertValidPositiveNumber} for duration validation details
*/
constructor(animationAction) {
super();
const duration = animationAction.getClip().duration;
assertValidPositiveNumber(duration, "Clip duration");
animationAction.stop();
animationAction.time = 0;
animationAction.weight = 0;
animationAction.paused = false;
animationAction.enabled = false;
this._private_anchor = {
index: getNextAnchorIndex(),
action: animationAction,
get weight() {
throw new Error("ClipState weight is not accessible");
},
duration,
invDuration: 1 / duration,
iterationEventType: animationAction.loop === LoopOnce
? AnimationStateEvent.FINISH
: AnimationStateEvent.ITERATE,
};
}
/**
* Registers a callback to be called when the animation reaches a specific time.
* The callback will be invoked every time the animation crosses the specified time threshold.
*
* @param unitTime - Time in unit range [0, 1] when the callback should be invoked
* @param callback - Function to call when the time event occurs, receives the action and state as parameters
*/
onTimeEvent(unitTime, callback) {
this.onTimeEventInternal(this._private_anchor, unitTime, callback, false);
}
/**
* Registers a callback to be called once when the animation reaches a specific time.
* The callback will be invoked only the first time the animation crosses the specified time threshold.
*
* @param unitTime - Time in unit range [0, 1] when the callback should be invoked
* @param callback - Function to call when the time event occurs, receives the action and state as parameters
*/
onceTimeEvent(unitTime, callback) {
this.onTimeEventInternal(this._private_anchor, unitTime, callback, true);
}
/**
* Removes a previously registered time event callback.
* Unregisters the callback from the specified time point and cleans up associated resources.
*
* @param unitTime - Time in unit range [0, 1] where the callback was registered
* @param callback - The callback function to remove
*/
offTimeEvent(unitTime, callback) {
this.offTimeEventInternal(this._private_anchor, unitTime, callback);
}
/**
* Internal method to set the influence of this animation state.
* Controls animation playback: starts animation when influence becomes positive,
* stops when influence becomes zero, and adjusts weight for intermediate values.
* Resets animation time and event tracking state on playback transitions.
*
* @param influence - The new influence value in range [0, 1] (finite number)
* @throws {Error} When influence is not a finite number or is outside the range [0, 1]
* @internal This method is intended to be called only by the animation state machine
* @see {@link AnimationStateEvent.PLAY} for play event details
* @see {@link AnimationStateEvent.STOP} for stop event details
*/
["setInfluenceInternal"](influence) {
assertValidUnitRange(influence, "Influence");
if (this.influenceInternal === influence) {
return;
}
this.influenceInternal = influence;
const anchor = this._private_anchor;
const animationAction = anchor.action;
if (influence > 0 && animationAction.weight === 0) {
animationAction.enabled = true;
animationAction.paused = false;
animationAction.time = 0;
animationAction.play();
this.emit(AnimationStateEvent.PLAY, animationAction, this);
}
else if (influence === 0 && animationAction.weight > 0) {
animationAction.stop();
animationAction.time = 0;
animationAction.paused = false;
animationAction.enabled = false;
this.emit(AnimationStateEvent.STOP, animationAction, this);
}
animationAction.weight = influence;
}
/**
* Internal method called on each frame to update animation state.
* Delegates to the inherited updateAnchorTime method to handle time tracking
* and iteration event emission based on animation progress.
*
* @internal This method is intended to be called only by the animation state machine
* @see {@link updateAnchorTime} for time tracking and event emission details
*/
["onTickInternal"](deltaTime) {
if (this.influence === 0) {
throw new Error(`${this.name}: cannot update anchor time because the animation influence is zero`);
}
this.processTimeEvents(this._private_anchor, deltaTime);
this.updateAnchorTime(this._private_anchor, deltaTime);
}
["onEnterInternal"]() {
super.onEnterInternal();
if (this.influence > 0) {
this.resetFinishedAction(this._private_anchor);
}
}
}
/** The number of vertices in a triangle. */
const TRIANGLE_VERTEX_COUNT = 3;
/**
* Precomputes triangle data for efficient subsequent operations.
* Calculates circumcenter, bounding box, and barycentric coordinate helpers.
*
* @param a - First vertex of the triangle (Vector2Like coordinates)
* @param b - Second vertex of the triangle (Vector2Like coordinates)
* @param c - Third vertex of the triangle (Vector2Like coordinates)
* @returns Precomputed triangle data cache for efficient operations
* @throws {Error} When triangle is degenerate or coordinates are invalid
* @see {@link assertValidNumber} for coordinate validation details
*/
function precomputeTriangle(a, b, c) {
assertValidNumber(a.x, "a.x");
assertValidNumber(a.y, "a.y");
assertValidNumber(b.x, "b.x");
assertValidNumber(b.y, "b.y");
assertValidNumber(c.x, "c.x");
assertValidNumber(c.y, "c.y");
const u = { x: b.x - a.x, y: b.y - a.y };
const v = { x: c.x - a.x, y: c.y - a.y };
const d00 = u.x * u.x + u.y * u.y;
const d01 = u.x * v.x + u.y * v.y;
const d11 = v.x * v.x + v.y * v.y;
const bDet = d00 * d11 - d01 * d01;
if (Math.abs(bDet) < EPSILON) {
throw new Error(`Degenerate triangle detected: determinant is too close to zero (${bDet}). ` +
`Triangle points are a(${a.x}, ${a.y}), b(${b.x}, ${b.y}), c(${c.x}, ${c.y}).`);
}
const cDet = 2 * (u.x * v.y - u.y * v.x);
const circumcenter = {
x: a.x + (v.y * d00 - u.y * d11) / cDet,
y: a.y + (u.x * d11 - v.x * d00) / cDet,
};
return {
origin: a,
circumcenter,
circumradiusSquared: (a.x - circumcenter.x) ** 2 + (a.y - circumcenter.y) ** 2,
u,
v,
d00,
d01,
d11,
invDenom: 1 / bDet,
min: {
x: Math.min(a.x, b.x, c.x),
y: Math.min(a.y, b.y, c.y),
},
max: {
x: Math.max(a.x, b.x, c.x),
y: Math.max(a.y, b.y, c.y),
},
};
}
/**
* Calculates barycentric coordinates for a point relative to a triangle.
* Returns undefined if the point lies outside the triangle boundaries.
*
* @param point - The point to calculate barycentric coordinates for (Vector2Like coordinates)
* @param cache - Precomputed triangle data containing vectors and determinants
* @returns Barycentric weights {aW, bW, cW} if point is inside triangle, undefined otherwise
* @throws {Error} When point coordinates or cache data are invalid
* @see {@link assertValidNumber} for coordinate validation details
*/
function calculateBarycentricWeights(point, cache) {
assertValidNumber(point.x, "point.x");
assertValidNumber(point.y, "point.y");
assertValidNumber(cache.origin.x, "cache.origin.x");
assertValidNumber(cache.origin.y, "cache.origin.y");
assertValidNumber(cache.min.x, "cache.min.x");
assertValidNumber(cache.min.y, "cache.min.y");
assertValidNumber(cache.max.x, "cache.max.x");
assertValidNumber(cache.max.y, "cache.max.y");
assertValidNumber(cache.u.x, "cache.u.x");
assertValidNumber(cache.u.y, "cache.u.y");
assertValidNumber(cache.v.x, "cache.v.x");
assertValidNumber(cache.v.y, "cache.v.y");
assertValidNumber(cache.d00, "cache.d00");
assertValidNumber(cache.d01, "cache.d01");
assertValidNumber(cache.d11, "cache.d11");
assertValidNumber(cache.invDenom, "cache.invDenom");
if (point.x < cache.min.x ||
point.x > cache.max.x ||
point.y < cache.min.y ||
point.y > cache.max.y) {
return undefined;
}
const originToPoint = {
x: point.x - cache.origin.x,
y: point.y - cache.origin.y,
};
const d20 = originToPoint.x * cache.u.x + originToPoint.y * cache.u.y;
const d21 = originToPoint.x * cache.v.x + originToPoint.y * cache.v.y;
const bW = (cache.d11 * d20 - cache.d01 * d21) * cache.invDenom;
if (bW < 0) {
return undefined;
}
const cW = (cache.d00 * d21 - cache.d01 * d20) * cache.invDenom;
if (cW < 0) {
return undefined;
}
const aW = 1 - bW - cW;
if (aW < 0) {
return undefined;
}
return { aW, bW, cW };
}
/**
* Calculates the centroid (geometric center) of a triangle.
* The centroid is the point where all three medians of the triangle intersect.
*
* @param a - First vertex of the triangle (Vector2Like coordinates)
* @param b - Second vertex of the triangle (Vector2Like coordinates)
* @param c - Third vertex of the triangle (Vector2Like coordinates)
* @returns The centroid point of the triangle (Vector2Like coordinates)
* @throws {Error} When any coordinate value is invalid
* @see {@link assertValidNumber} for coordinate validation details
*/
function calculateTriangleCentroid(a, b, c) {
assertValidNumber(a.x, "a.x");
assertValidNumber(a.y, "a.y");
assertValidNumber(b.x, "b.x");
assertValidNumber(b.y, "b.y");
assertValidNumber(c.x, "c.x");
assertValidNumber(c.y, "c.y");
return {
x: (a.x + b.x + c.x) / TRIANGLE_VERTEX_COUNT,
y: (a.y + b.y + c.y) / TRIANGLE_VERTEX_COUNT,
};
}
/**
* Determines if a point is strictly inside a circle (excluding the boundary).
* Uses squared distance comparison to avoid expensive square root operations.
*
* @param origin - The center point of the circle (Vector2Like coordinates)
* @param radiusSquared - The squared radius of the circle (positive number)
* @param point - The point to test (Vector2Like coordinates)
* @returns True if the point is strictly inside the circle, false otherwise
* @throws {Error} When coordinates are invalid or radius is not positive
* @see {@link assertValidNumber} for coordinate validation details
* @see {@link assertValidPositiveNumber} for radius validation details
*/
function isPointInsideCircle(origin, radiusSquared, point) {
assertValidNumber(origin.x, "origin.x");
assertValidNumber(origin.y, "origin.y");
assertValidPositiveNumber(radiusSquared, "radiusSquared");
assertValidNumber(point.x, "point.x");
assertValidNumber(point.y, "point.y");
return ((point.x - origin.x) ** 2 + (point.y - origin.y) ** 2 <
radiusSquared - EPSILON);
}
/**
* Normalizes an azimuth angle to the range [0, 2π).
* Handles negative angles by adding 2π to bring them into the valid range.
*
* @param azimuth - The azimuth angle in radians (any finite number)
* @returns The normalized azimuth in the range [0, 2π) radians
* @throws {Error} When the azimuth value is not a valid number
* @see {@link assertValidNumber} for validation details
*/
function calculateNormalizedAzimuth(azimuth) {
assertValidNumber(azimuth, "Azimuth for normalization");
const result = azimuth % PI2;
return result < 0 ? result + PI2 : result;
}
/**
* Calculates the forward angular distance between two azimuth angles.
* Always measures distance in the positive direction, even if a shorter path exists in the backward direction.
*
* @param from - The starting azimuth angle in radians (normalized to [0, 2π))
* @param to - The target azimuth angle in radians (normalized to [0, 2π))
* @returns The forward angular distance in radians [0, 2π)
* @throws {Error} When either azimuth value is invalid
* @see {@link assertValidAzimuth} for azimuth validation details
*/
function calculateAngularDistanceForward(from, to) {
assertValidAzimuth(from, "Azimuth 'from'");
assertValidAzimuth(to, "Azimuth 'to'");
const delta = to - from;
return delta >= 0 ? delta : delta + PI2;
}
/**
* Determines if an azimuth angle falls within a specified angular range.
* Handles ranges that wrap around the 0/2π boundary correctly.
*
* @param value - The azimuth angle to test in radians (normalized to [0, 2π))
* @param from - The start of the angular range in radians (normalized to [0, 2π))
* @param to - The end of the angular range in radians (normalized to [0, 2π))
* @returns True if the azimuth is within the range (inclusive), false otherwise
* @throws {Error} When any azimuth value is invalid
* @see {@link assertValidAzimuth} for azimuth validation details
*/
function isAzimuthBetween(value, from, to) {
assertValidAzimuth(value, "Azimuth value");
assertValidAzimuth(from, "Azimuth 'from'");
assertValidAzimuth(to, "Azimuth 'to'");
return from <= to
? value >= from && value <= to
: value >= from || value <= to;
}
/**
* Calculates the squared Euclidean distance between two points.
* Using squared distance avoids the expensive square root operation.
*
* @param x1 - X coordinate of the first point (finite number)
* @param y1 - Y coordinate of the first point (finite number)
* @param x2 - X coordinate of the second point (finite number)
* @param y2 - Y coordinate of the second point (finite number)
* @returns The squared distance between the two points (non-negative number)
* @throws {Error} When any coordinate value is invalid
* @see {@link assertValidNumber} for coordinate validation details
*/
function calculateDistanceSquared(x1, y1, x2, y2) {
assertValidNumber(x1, "Coordinate 'x1'");
assertValidNumber(y1, "Coordinate 'y1'");
assertValidNumber(x2, "Coordinate 'x2'");
assertValidNumber(y2, "Coordinate 'y2'");
return (x2 - x1) ** 2 + (y2 - y1) ** 2;
}
/**
* Calculates the squared distance from a point to a line segment (edge).
* Projects the point onto the line segment and clamps to the segment boundaries.
*
* @param edge - A tuple containing the two endpoints of the edge (Vector2Like coordinates)
* @param x - X coordinate of the point (finite number)
* @param y - Y coordinate of the point (finite number)
* @returns The squared distance from the point to the closest point on the edge (non-negative number)
* @throws {Error} When any coordinate value is invalid
* @see {@link assertValidNumber} for coordinate validation details
*/
function calculateDistanceToEdgeSquared([p1, p2], x, y) {
assertValidNumber(p1.x, "Coordinate 'p1.x'");
assertValidNumber(p1.y, "Coordinate 'p1.y'");
assertValidNumber(p2.x, "Coordinate 'p2.x'");
assertValidNumber(p2.y, "Coordinate 'p2.y'");
assertValidNumber(x, "Coordinate 'x'");
assertValidNumber(y, "Coordinate 'y'");
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const t = ((x - p1.x) * dx + (y - p1.y) * dy) / (dx * dx + dy * dy);
const clampedT = t <= 0 ? 0 : t >= 1 ? 1 : t;
const deltaX = x - (p1.x + clampedT * dx);
const deltaY = y - (p1.y + clampedT * dy);
return deltaX ** 2 + deltaY ** 2;
}
/** Scale factor for generating the super triangle that encompasses all input points. */
const SUPER_TRIANGLE_SCALE_FACTOR = 16;
/**
* Implements Bowyer-Watson algorithm for Delaunay triangulation in 2D space.
* Generates a triangulation where no point lies inside the circumcircle of any triangle.
*/
class DelaunayTriangulator {
/**
* Performs Delaunay triangulation on a set of 2D points using the Bowyer-Watson algorithm.
* The resulting triangulation satisfies the Delaunay condition: no point lies inside
* the circumcircle of any triangle.
*
* @template T - Type of vertices that must be Vector2Like objects
* @param points - Array of points to triangulate (minimum 3 points required)
* @returns Triangulation result containing triangles and boundary edge information
* @throws {Error} When fewer than 3 points provided, points contain invalid coordinates,
* duplicate points exist, or all points are collinear
* @see {@link TriangulationResult} for result structure details
* @see {@link Triangle} for triangle structure details
*/
static triangulate(points) {
if (points.length < TRIANGLE_VERTEX_COUNT) {
throw new Error(`At least ${TRIANGLE_VERTEX_COUNT} points are required for triangulation, but got ${points.length}`);
}
for (const point of points) {
assertValidNumber(point.x, "x-coordinate");
assertValidNumber(point.y, "y-coordinate");
}
for (let i = 0; i < points.length - 1; i++) {
const x = points[i].x;
const y = points[i].y;
for (let j = i + 1; j < points.length; j++) {
if (Math.abs(x - points[j].x) < EPSILON &&
Math.abs(y - points[j].y) < EPSILON) {
throw new Error(`Duplicate points found at indices ${i} and ${j}: (${x}, ${y})`);
}
}
}
{
const [a, b, ...rest] = points;
const direction = new Vector2().subVectors(b, a).normalize();
const temp = new Vector2();
if (rest.every((p) => Math.abs(direction.cross(temp.subVectors(p, a))) < EPSILON)) {
throw new Error("All points are collinear - cannot run triangulation");
}
}
const superTriangle = DelaunayTriangulator.buildSuperTriangle(points);
let triangles = [superTriangle];
for (const point of points) {
const badTriangles = DelaunayTriangulator.filterBadTriangles(triangles, point);
for (const edge of DelaunayTriangulator.buildPolygon(badTriangles)) {
triangles.push({
a: edge[0],
b: edge[1],
c: point,
...precomputeTriangle(edge[0], edge[1], point),
});
}
}
const boundaryEdgeMap = DelaunayTriangulator.filterSuperTriangleVertices(triangles, superTriangle);
for (const [key, value] of boundaryEdgeMap) {
// This code is unreachable under all inputs.
// The map represents the outer contour of a closed 2D mesh after Delaunay triangulation.
// Every boundary vertex must have exactly two neighbors on the contour.
// If this branch is ever taken, it implies a violation of mesh invariants and a bug elsewhere in the pipeline —
// not an edge case to be covered by tests.
/* c8 ignore next 5 */
if (value.length !== 2) {
throw new Error(`Invariant violation: outer edge point (${key.x}, ${key.y}) has ${value.length} connections (expected 2)`);
}
}
return { triangles, boundaryEdgeMap: boundaryEdgeMap };
}
/**
* Identifies and removes triangles whose circumcircles contain the given point.
* Such triangles violate the Delaunay condition and must be removed.
* Modifies the input triangles array in-place for efficiency.
*
* @template T - Type of vertices that must be Vector2Like objects
* @param triangles - Array of triangles to filter (modified in-place)
* @param point - Point to test against triangle circumcircles (Vector2Like coordinates)
* @returns Array of triangles that contain the point in their circumcircles
* @see {@link isPointInsideCircle} for circumcircle containment test
*/
static filterBadTriangles(triangles, point) {
const badTriangles = [];
let w = 0;
for (const triangle of triangles) {
const isBadTriangle = isPointInsideCircle(triangle.circumcenter, triangle.circumradiusSquared, point);
if (isBadTriangle) {
badTriangles.push(triangle);
}
else {
triangles[w++] = triangle;
}
}
triangles.length = w;
return badTriangles;
}
/**
* Constructs the polygon boundary formed by removing bad triangles.
* Finds edges that belong to only one triangle (outer edges of the cavity).
* These edges will form the boundary of the polygon hole left by removed triangles.
*
* @template T - Type of vertices that must be Vector2Like objects
* @param triangles - Array of triangles forming the cavity (finite array)
* @returns Array of edges [vertex1, vertex2] that form the polygon boundary
*/
static buildPolygon(triangles) {
const polygonOuterEdges = [];
for (const triangle of triangles) {
const edges = [
[triangle.a, triangle.b],
[triangle.b, triangle.c],
[triangle.c, triangle.a],
];
for (const edge of edges) {
const isOuterEdge = triangles.every((t) => triangle === t ||
[
[t.a, t.b],
[t.b, t.c],
[t.c, t.a],
].every((e) => (edge[0] !== e[0] || edge[1] !== e[1]) &&
(edge[1] !== e[0] || edge[0] !== e[1])));
if (isOuterEdge) {
polygonOuterEdges.push(edge);
}
}
}
return polygonOuterEdges;
}
/**
* Removes triangles containing super triangle vertices and builds boundary edge map.
* Filters out triangles that include any vertex from the initial super triangle,
* keeping only triangles formed entirely from input points. Also constructs
* a map of boundary vertices to their adjacent boundary vertices.
*
* @template T - Type of vertices that must be Vector2Like objects
* @param triangles - Array of triangles to filter (modified in-place)
* @param superTriangle - The super triangle used to initialize triangulation
* @returns Map from boundary vertices to arrays of their adjacent boundary vertices
* @throws {Error} When boundary vertex connectivity invariants are violated
*/
static filterSuperTriangleVertices(triangles, superTriangle) {
const boundaryEdgeMap = new Map();
let w = 0;
for (const t of triangles) {
const boundaryPoints = [];
if (!DelaunayTriangulator.isVertexFromTriangle(t.a, superTriangle)) {
boundaryPoints.push(t.a);
}
if (!DelaunayTriangulator.isVertexFromTriangle(t.b, superTriangle)) {
boundaryPoints.push(t.b);
}
if (!DelaunayTriangulator.isVertexFromTriangle(t.c, superTriangle)) {
boundaryPoints.push(t.c);
}
if (boundaryPoints.length === 2) {
const [lPoint, rPoint] = boundaryPoints;
const lArray = boundaryEdgeMap.get(lPoint) ?? [];
const rArray = boundaryEdgeMap.get(rPoint) ?? [];
lArray.push(rPoint);
rArray.push(lPoint);
boundaryEdgeMap.set(lPoint, lArray);
boundaryEdgeMap.set(rPoint, rArray);
}
if (boundaryPoints.length === TRIANGLE_VERTEX_COUNT) {
triangles[w++] = t;
}
}
triangles.length = w;
return boundaryEdgeMap;
}
/**
* Constructs a large triangle that encompasses all input points.
* The super triangle provides an initial triangulation that contains all points,
* allowing the incremental algorithm to proceed. Vertices are positioned far
* outside the bounding box of input points.
*
* @template T - Type of vertices that must be Vector2Like objects
* @param points - Array of input points to encompass (finite coordinates)
* @returns Triangle that contains all input points with precomputed properties
* @see {@link precomputeTriangle} for triangle property computation
*/
static buildSuperTriangle(points) {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const point of points) {
minX = Math.min(minX, point.x);
minY = Math.min(minY, point.y);
maxX = Math.max(maxX, point.x);
maxY = Math.max(maxY, point.y);
}
const delta = Math.max(maxX - minX, maxY - minY) * SUPER_TRIANGLE_SCALE_FACTOR;
const a = { x: minX - delta, y: minY - delta };
const b = { x: minX + 2 * delta, y: minY - delta };
const c = { x: minX + delta, y: minY + 2 * delta };
return { a, b, c, ...precomputeTriangle(a, b, c) };
}
/**
* Tests whether a vertex belongs to a specific triangle.
* Uses reference equality to determine if the vertex is one of the triangle's vertices.
*
* @template T - Type of vertices that must be Vector2Like objects
* @param vertex - Vertex to test for membership (Vector2Like coordinates)
* @param triangle - Triangle to test against
* @returns True if the vertex is one of the triangle's vertices, false otherwise
*/
static isVertexFromTriangle(vertex, triangle) {
return (vertex === triangle.a || vertex === triangle.b || vertex === triangle.c);
}
}
/**
* Abstract base class for animation trees in the animation state machine.
* Provides foundation for different tree types (linear, polar, freeform, etc.)
* and manages animation anchors with their weights.
*
* Animation trees organize and control multiple animation actions through
* a hierarchical structure, automatically managing playback and weight distribution.
*/
class AnimationTree extends AnimationState {
constructor() {
super(...arguments);
/**
* Map linking animation actions to their corresponding anchors.
* Used to look up anchor information when registering time-based events.
*/
this.actionToAnchor = new Map();
}
/**
* Registers a callback to be called when the specified animation action reaches a specific time.
* The callback will be invoked every time the animation crosses the specified time threshold.
*
* @param action - The animation action to monitor for time events
* @param unitTime - Time in unit range [0, 1] when the callback should be invoked
* @param callback - Function to call when the time event occurs, receives the action and state as parameters
* @throws {Error} When the action is not registered in this animation tree
*/
onTimeEvent(action, unitTime, callback) {
const anchor = this.actionToAnchor.get(action);
if (!anchor) {
throw new Error(`Action is not registered`);
}
this.onTimeEventInternal(anchor, unitTime, callback, false);
}
/**
* Registers a callback to be called once when the specified animation action reaches a specific time.
* The callback will be invoked only the first time the animation crosses the specified time threshold.
*
* @param action - The animation action to monitor for time events
* @param unitTime - Time in unit range [0, 1] when the callback should be invoked
* @param callback - Function to call when the time event occurs, receives the action and state as parameters
* @throws {Error} When the action is not registered in this animation tree
*/
onceTimeEvent(action, unitTime, callback) {
const anchor = this.actionToAnchor.get(action);
if (!anchor) {
throw new Error(`Action is not registered`);
}
this.onTimeEventInternal(anchor, unitTime, callback, true);
}
/**
* Removes a previously registered time event callback for the specified animation action.
* Unregisters the callback from the specified time point and cleans up associated resources.
*
* @param action - The animation action to remove the time event from
* @param unitTime - Time in unit range [0, 1] where the callback was registered
* @param callback - The callback function to remove
* @throws {Error} When the action is not registered in this animation tree
*/
offTimeEvent(action, unitTime, callback) {
const anchor = this.actionToAnchor.get(action);
if (!anchor) {
throw new Error(`Action is not registered`);
}
this.offTimeEventInternal(anchor, unitTime, callback);
}
/**
* Internal method to set the influence of this animation tree.
* When influence changes, triggers an update of anchor influences while
* maintaining their relative weights unchanged.
*
* @param influence - The new influence value in range [0, 1]
* @throws {Error} When influence is not a finite number or is outside the range [0, 1]
* @internal This method is intended to be called only by the animation state machine
*/
["setInfluenceInternal"](influence) {
assertValidUnitRange(influence, "Animation tree influence");
if (influence !== this.influenceInternal) {
this.influenceInternal = influence;
this.updateAnchorsInfluence();
}
}
/**
* Updates the weight of a specific animation anchor (AnimationAction + parameters).
* Handles animation playbook lifecycle: starting, stopping, and weight adjustments.
* Combines the raw weight with the tree's influence to get the final action weight.
*
* When transitioning from zero to non-zero weight: starts playback, resets time to 0,
* resets event tracking state, and emits PLAY event.
* When transitioning from non-zero to zero weight: stops playback, resets time to 0,
* resets event tracking state, and emits STOP event.
* For weight-only changes: updates the animation action weight without lifecycle changes.
*
* @param anchor - The animation anchor containing the action and parameters to update
* @param weight - The raw weight value before applying tree influence (finite number). If not provided, uses the anchor's current weight
* @throws {Error} When weight is not a finite number or is outside the range [0, 1]
* @see {@link AnimationStateEvent.PLAY} for play event details
* @see {@link AnimationStateEvent.STOP} for stop event details
*/
updateAnchorWeight(anchor, weight = anchor.weight) {
assertValidUnitRange(weight, "Anchor weight");
anchor.weight = weight;
const combinedWeight = weight * this.influenceInternal;
const animationAction = anchor.action;
if (combinedWeight === animationAction.weight) {
return;
}
if (combinedWeight > 0 && animationAction.weight === 0) {
animationAction.enabled = true;
animationAction.paused = false;
animationAction.time = 0;
animationAction.play();
this.emit(AnimationStateEvent.PLAY, animationAction, this);
}
else if (combinedWeight === 0 && animationAction.weight > 0) {
animationAction.stop();
animationAction.time = 0;
animationAction.paused = false;
animationAction.enabled = false;
this.emit(AnimationStateEvent.STOP, animationAction, this);
}
animationAction.weight = combinedWeight;
}
}
/** Minimum number of actions required for freeform blend tree triangulation */
const MIN_ACTION_COUNT = 3;
/**
* Freeform blend tree implementation for arbitrary 2D animation blending.
*
* Uses Delaunay triangulation to create a mesh from animation actions positioned
* in arbitrary 2D coordinates, enabling smooth interpolation between animations
* based on barycentric coordinates within triangles. For points outside the mesh,
* falls back to nearest edge interpolation for boundary handling.
*
* The blend tree automatically constructs a triangulated mesh from input actions
* and provides seamless blending across the entire 2D space, making it ideal
* for complex animation systems like movement with multiple speed/direction
* combinations or facial animation with arbitrary control points.
*
* @example Character Movement with Arbitrary Speeds
* ```typescript
* // Define movement animations at various speeds and directions
* const actions = [
* { action: idleAction, x: 0, y: 0 }, // Center: idle
* { action: walkNorthAction, x: 0, y: 1 }, // North: slow
* { action: runNorthAction, x: 0, y: 2 }, // North: fast
* { action: walkEastAction, x: 1, y: 0 }, // East: slow
* { action: runEastAction, x: 2, y: 0 }, // East: fast
* { action: walkNEAction, x: 0.7, y: 0.7 }, // Northeast: diagonal
* { action: sprintAction, x: 1.5, y: 1.5 } // Sprint: very fast diagonal
* ];
*
* const blendTree = new FreeformBlendTree(actions);
*
* // Blend to medium speed northeast
* blendTree.setBlend(0.5, 0.8);
*
* // Blend to maximum speed due east
* blendTree.setBlend(2.0, 0.0);
* ```
*/
class FreeformBlendTree extends AnimationTree {
/**
* Creates a new freeform blend tree from animation actions positioned in 2D space.
* Performs Delaunay triangulation to create a mesh for barycentric interpolation.
* Initializes all actions to stopped state and validates coordinates and durations.
*
* @param freeformActions - Array of freeform actions defining the blend space.
* Must contain at least 3 actions with unique coordinates.
* @throws {Error} When fewer than 3 actions are provided
* @throws {Error} When any action has non-finite coordinates
* @throws {Error} When any action coordinates are outside JavaScript's safe range
* @throws {Error} When multiple actions have the same coordinates
* @throws {Error} When any animation clip duration is not positive
* @throws {Error} When actions form degenerate triangulation (all collinear)
* @see {@link assertValidNumber} for coordinate validation details
* @see {@link DelaunayTriangulator.triangulate} for triangulation details
*/
constructor(freeformActions) {
super();
this._private_tempAnchorMap = new Map();
this._private_trackableAnchors = [];
this._private_triangles = [];
this._private_boundaryEdgeMap = new Map();
this._private_currentX = 0;
this._private_currentY = 0;
if (freeformActions.length < MIN_ACTION_COUNT) {
throw new Error("FreeformBlendTree requires at least 3 actions for triangulation");
}
for (let i = 0; i < freeformActions.length; i++) {
assertValidNumber(freeformActions[i].x, `Freeform action at index ${i} x value`);
assertValidNumber(freeformActions[i].y, `Freeform action at index ${i} y value`);
}
for (let i = 0; i < freeformActions.length - 1; i++) {
const x = freeformActions[i].x;
const y = freeformActions[i].y;
for (let j = i + 1; j < freeformActions.length; j++) {
if (Math.abs(x - freeformActions[j].x) < EPSILON &&
Math.abs(y - freeformActions[j].y) < EPSILON) {
throw new Error(`Duplicate coordinates found, x: ${x}, y: ${y}. All action values must be unique.`);
}
}
}
const anchors = [];
for (const freeformAction of freeformActions) {
const animationAction = freeformAction.action;
animationAction.stop();
animationAction.time = 0;
animationAction.weight = 0;
animationAction.paused = false;
animationAction.enabled = false;
const duration = animationAction.getClip().duration;
if (duration <= 0) {
throw new Error("Action duration must be greater than zero");
}
const anchor = {
index: getNextAnchorIndex(),
action: animationAction,
weight: 0,
duration,
invDuration: 1 / duration,
iterationEventType: animationAction.loop === LoopOnce
? AnimationStateEvent.FINISH
: AnimationStateEvent.ITERATE,
x: freeformAction.x,
y: freeformAction.y,
};
anchors.push(anchor);
this.actionToAnchor.set(animationAction, anchor);
}
const result = DelaunayTriangulator.triangulate(anchors);
this._private_triangles = result.triangles.map((t) => {
// Exclude 'circumcenter' and 'circumradiusSquared' from 't' — we don't need them in the result.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { circumcenter, circumradiusSquared, ...rest } = t;
return {
...rest,
centroid: calculateTriangleCentroid(t.a, t.b, t.c),
};
});
this._private_boundaryEdgeMap = result.boundaryEdgeMap;
this._private_sortTriangles();
this._private_updateAnchors();
}
get blendValue() {
return { x: this._private_currentX, y: this._private_currentY };
}
/**
* Sets the blend position in 2D Cartesian coordinates to determine animation weights.
* When the position changes, animation weights are recalculated using barycentric
* interpolation within triangles or nearest edge interpolation for boundary points.
*
* Points inside triangles use barycentric coordinates for smooth 3-way blending.
* Points outside the mesh use interpolation along the nearest boundary edge.
*
* @param x - Target X coordinate in 2D space (finite number)
* @param y - Target Y coordinate in 2D space (finite number)
* @throws {Error} When x coordinate is not a finite number
* @throws {Error} When y coordinate is not a finite number
* @see {@link assertValidNumber} for coordinate validation details
*
* @example
* ```typescript
* // Blend to position within the mesh
* blendTree.setBlend(1.2, 0.8);
*
* // Blend to position outside mesh (uses boundary interpolation)
* blendTree.setBlend(-0.5, 3.0);
* ```
*/
setBlend(x, y) {
assertValidNumber(x, "Blend x");
assertValidNumber(y, "Blend y");
if (this._private_currentX !== x || this._private_currentY !== y) {
this._private_currentX = x;
this._private_currentY = y;
this._private_sortTriangles();
this._private_updateAnchors();
}
}
/**
* Internal method called by the animation state machine on each frame update.
* Tracks animation progress and emits iteration events for all triangulated actions.
* Monitors timing changes across all anchors in the triangulated mesh.
*
* @internal This method is called exclusively by the animation state machine
* @see {@link updateAnchorTime} for time tracking and event emission details
*/
["onTickInternal"](deltaTime) {
if (this.influence === 0) {
throw new Error(`${this.name}: cannot update anchor time because the animation influence is zero`);
}
for (const anchor of this._private_trackableAnchors) {
this.processTimeEvents(anchor, deltaTime);
this.updateAnchorTime(anchor, deltaTime);
}
}
["onEnterInternal"]() {
super.onEnterInternal();
if (this.influence > 0) {
for (const anchor of this._private_trackableAnchors) {
this.resetFinishedAction(anchor);
}
}
}
/**
* Updates the influence for all anchors in the freeform blend tree.
* Called when the tree's influence changes but relative weights remain the same.
* Applies the current tree influence to all triangulated anchors while maintaining
* their existing weight distribution from the freeform blending calculations.
*/
updateAnchorsInfluence() {
for (const anchor of this._private_trackableAnchors) {
this.updateAnchorWeight(anchor);
}
}
/**
* Recalculates and updates animation weights based on current blend position.
*
* This is the core blending algorithm that:
* 1. Attempts barycentric interpolation if point lies within any triangle
* 2. Falls back to nearest boundary edge interpolation for external points
* 3. Updates the active anchors set with calculated weights
*
* The method prioritizes smooth 3-way barycentric blending when possible,
* providing seamless 2-way edge blending for boundary cases.
*/
_private_updateAnchors() {
this._private_tempAnchorMap.clear();
for (const anchor of this._private_trackableAnchors) {
this._private_tempAnchorMap.set(anchor, 0);
}
if (!this._private_applyBarycentricWeights(this._private_tempAnchorMap)) {
this._private_applyNearestNeighborWeight(this._private_tempAnchorMap);
}
this._private_trackableAnchors.length = 0;
for (const [anchor, weight] of this._private_tempAnchorMap) {
this.updateAnchorWeight(anchor, weight);
if (weight > 0) {
this._private_trackableAnchors.push(anchor);
}
}
}
/**
* Attempts to apply barycentric weights if the blend point lies within any triangle.
* Searches through all triangles to find one containing the current blend position
* and calculates the barycentric coordinates for 3-way interpolation.
*
* @param result - Map to store calculated weights for each anchor
* @returns True if barycentric interpolation was applied, false if point is outside all triangles
* @see {@link calculateBarycentricWeights} for barycentric coordinate calculation
*/
_private_applyBarycentricWeights(result) {
const point = { x: this._private_currentX, y: this._private_currentY };
for (const triangle of this._private_triangles) {
const weights = calculateBarycentricWeights(point, triangle);
if (weights) {
result.set(triangle.a, weights.aW);
result.set(triangle.b, weights.bW);
result.set(triangle.c, weights.cW);
return true;
}
}
return false;
}
/**
* Applies nearest boundary edge interpolation for points outside the triangulated mesh.
* Finds the closest boundary vertex and interpolates along the nearest boundary edge
* to provide smooth 2-way blending for external points.
*
* The algorithm:
* 1. Finds the closest anchor among all boundary vertices
* 2. Identifies the two boundary edges connected to this anchor
* 3. Determines which edge is closer to the blend point
* 4. Projects the point onto the edge and calculates interpolation weights
*
* @param result - Map to store calculated weights for each anchor
* @see {@link calculateDistanceSquared} for distance calculations
* @see {@link calculateDistanceToEdgeSquared} for edge distance calculations
*/
_private_applyNearestNeighborWeight(result) {
const nearestTriangle = this._private_triangles[0];
const closestAnchor = [
nearestTriangle.a,
nearestTriangle.b,
nearestTriangle.c,
].reduce((a, b) => calculateDistanceSquared(a.x, a.y, this._private_currentX, this._private_currentY) <
calculateDistanceSquared(b.x, b.y, this._private_currentX, this._private_currentY)
? a
: b);
const edgeData = this._private_boundaryEdgeMap.get(closestAnchor);
// This code is unreachable under all inputs.
// Every outer contour vertex must have a corresponding entry in boundaryEdgeMap.
// If this branch is ever taken, it implies a violation of mesh construction invariants —
// most likely a logic error in how the boundary map was built or queried.
// This is not an edge case to be tested, but a correctness bug upstream.
/* c8 ignore next 5 */
if (edgeData === undefined) {
throw new Error(`Invariant violation: no edge data found for outer vertex (${closestAnchor.x}, ${closestAnchor.y})`);
}
const edge0 = [
closestAnchor,
edgeData[0],
];
const edge1 = [
closestAnchor,
edgeData[1],
];
const closestEdge = calculateDistanceToEdgeSquared(edge0, this._private_currentX, this._private_currentY) <
calculateDistanceToEdgeSquared(edge1, this._private_currentX, this._private_currentY)
? edge0
: edge1;
const [a, b] = closestEdge;
const dx = b.x - a.x;
const dy = b.y - a.y;
const lengthSquared = dx * dx + dy * dy;
const value = ((this._private_currentX - a.x) * dx + (this._private_currentY - a.y) * dy) / lengthSquared;
const t = Math.max(0, Math.min(1, value));
result.set(a, 1 - t);
result.set(b, t);
}
/**
* Sorts triangles by distance from their centroids to the current blend position.
* This optimization improves performance of barycentric weight calculation by
* checking closer triangles first, reducing average search time.
*
* Called whenever the blend position changes to maintain optimal search order.
*/
_private_sortTriangles() {
this._private_triangles.sort((a, b) => calculateDistanceSquared(a.centroid.x, a.centroid.y, this._private_currentX, this._private_currentY) -
calculateDistanceSquared(b.centroid.x, b.centroid.y, this._private_currentX, this._private_currentY));
}
}
/**
* Linear blend tree implementation for 1D animation blending.
*
* Manages a collection of animation actions positioned along a linear axis,
* automatically blending between adjacent animations based on a blend value.
* The blend tree interpolates weights between the two closest animations
* to create smooth transitions across the linear space.
*
* Note: Action values are not limited to the 0-1 range. You can use any
* numeric values, including negative values, that make sense for your
* application (e.g., speed in m/s, or any custom metric). However, all
* values must be finite numbers within JavaScript's safe range and must
* be unique (no duplicate values allowed).
*
* @example
* ```typescript
* const idleAction = mixer.clipAction(idleClip);
* const walkAction = mixer.clipAction(walkClip);
* const runAction = mixer.clipAction(runClip);
*
* const blendTree = new LinearBlendTree([
* { action: idleAction, value: 0 },
* { action: walkAction, value: 0.5 },
* { action: runAction, value: 1 }
* ]);
*
* // Set blend to 0.3 - results in:
* // idleAction weight: 0.4 (40%)
* // walkAction weight: 0.6 (60%)
* // runAction weight: 0.0 (0%)
* blendTree.setBlend(0.3);
* ```
*/
class LinearBlendTree extends AnimationTree {
/**
* Creates a new linear blend tree with the specified animation actions.
* Actions are automatically sorted by their value along the linear axis.
* Initializes all actions to stopped state and validates clip durations.
*
* @param linearActions - Array of linear actions defining the blend space.
* Must contain at least 2 actions with unique, finite values.
* @throws {Error} When fewer than 2 actions are provided
* @throws {Error} When any action has a non-finite value (NaN, ±Infinity)
* @throws {Error} When any action has a value outside JavaScript's safe integer range
* @throws {Error} When multiple actions have the same value (duplicate values)
* @throws {Error} When any animation clip duration is not a positive finite number
* @see {@link assertValidNumber} for value validation details
* @see {@link assertValidPositiveNumber} for duration validation details
*/
constructor(linearActions) {
super();
this._private_anchors = [];
this._private_currentBlend = 0;
if (linearActions.length < 2) {
throw new Error("Need at least 2 actions");
}
for (let i = 0; i < linearActions.length; i++) {
const value = linearActions[i].value;
assertValidNumber(value, `Linear action at index ${i} value`);
}
for (let i = 0; i < linearActions.length - 1; i++) {
const value = linearActions[i].value;
for (let j = i + 1; j < linearActions.length; j++) {
if (Math.abs(value - linearActions[j].value) < EPSILON) {
throw new Error(`Duplicate value found, value: ${value}. All action values must be unique.`);
}
}
}
for (const linearAction of linearActions) {
const animationAction = linearAction.action;
animationAction.stop();
animationAction.time = 0;
animationAction.weight = 0;
animationAction.paused = false;
animationAction.enabled = false;
const duration = animationAction.getClip().duration;
assertValidPositiveNumber(duration, "Clip duration");
const anchor = {
index: getNextAnchorIndex(),
action: animationAction,
weight: 0,
duration,
invDuration: 1 / duration,
iterationEventType: animationAction.loop === LoopOnce
? AnimationStateEvent.FINISH
: AnimationStateEvent.ITERATE,
value: linearAction.value,
};
this._private_anchors.push(anchor);
this.actionToAnchor.set(animationAction, anchor);
}
this._private_anchors.sort((a, b) => a.value - b.value);
this._private_updateAnchors();
}
get blendValue() {
return this._private_currentBlend;
}
/**
* Sets the blend value to determine animation weights along the linear axis.
* When the blend changes, animation weights are recalculated to interpolate
* between the two closest actions. Values outside the action range are
* handled by giving full weight to the nearest boundary action.
*
* @param value - The target blend value (finite number)
* @throws {Error} When the blend value is not a finite number
* @see {@link assertValidNumber} for value validation details
*/
setBlend(value) {
assertValidNumber(value, "Blend value");
if (value !== this._private_currentBlend) {
this._private_currentBlend = value;
this._private_updateAnchors();
}
}
/**
* Internal method called by the animation state machine on each frame update.
* Tracks animation progress and emits iteration events when animations
* complete or restart. Monitors all active anchors for timing changes.
*
* @internal This method is called exclusively by the animation state machine
* @see {@link updateAnchorTime} for time tracking and event emission details
*/
["onTickInternal"](deltaTime) {
if (this.influence === 0) {
throw new Error(`${this.name}: cannot update anchor time because the animation influence is zero`);
}
if (this._private_lastLeftAnchor?.weight) {
this.processTimeEvents(this._private_lastLeftAnchor, deltaTime);
this.updateAnchorTime(this._private_lastLeftAnchor, deltaTime);
}
if (this._private_lastRightAnchor?.weight) {
this.processTimeEvents(this._private_lastRightAnchor, deltaTime);
this.updateAnchorTime(this._private_lastRightAnchor, deltaTime);
}
}
["onEnterInternal"]() {
super.onEnterInternal();
if (this.influence > 0) {
if (this._private_lastLeftAnchor) {
this.resetFinishedAction(this._private_lastLeftAnchor);
}
if (this._private_lastRightAnchor) {
this.resetFinishedAction(this._private_lastRightAnchor);
}
}
}
/**
* Updates the influence for all anchors in the linear blend tree.
* Called when the tree's influence changes but relative weights remain the same.
* Applies the current tree influence to all anchors while maintaining
* their existing weight distribution from the linear blending.
*/
updateAnchorsInfluence() {
if (this._private_lastLeftAnchor) {
this.updateAnchorWeight(this._private_lastLeftAnchor);
}
if (this._private_lastRightAnchor) {
this.updateAnchorWeight(this._private_lastRightAnchor);
}
}
/**
* Recalculates and updates animation weights based on the current blend value.
* Performs linear interpolation between the two actions closest to the blend point.
* Actions outside the interpolation range receive zero weight.
*
* The interpolation uses the formula:
* - Left weight = 1 - difference
* - Right weight = difference
* Where difference = (blend - leftValue) / (rightValue - leftValue)
*/
_private_updateAnchors() {
const firstAnchor = this._private_anchors[0];
if (this._private_currentBlend <= firstAnchor.value) {
if (this._private_lastLeftAnchor && this._private_lastLeftAnchor !== firstAnchor) {
this.updateAnchorWeight(this._private_lastLeftAnchor, 0);
}
if (this._private_lastRightAnchor) {
this.updateAnchorWeight(this._private_lastRightAnchor, 0);
}
this.updateAnchorWeight(firstAnchor, 1);
this._private_lastLeftAnchor = firstAnchor;
this._private_lastRightAnchor = undefined;
return;
}
const lastAnchor = this._private_anchors[this._private_anchors.length - 1];
if (this._private_currentBlend >= lastAnchor.value) {
if (this._private_lastRightAnchor && this._private_lastRightAnchor !== lastAnchor) {
this.updateAnchorWeight(this._private_lastRightAnchor, 0);
}
if (this._private_lastLeftAnchor) {
this.updateAnchorWeight(this._private_lastLeftAnchor, 0);
}
this.updateAnchorWeight(lastAnchor, 1);
this._private_lastRightAnchor = lastAnchor;
this._private_lastLeftAnchor = undefined;
return;
}
{
let l = 1;
let r = this._private_anchors.length - 1;
while (l < r) {
const m = (l + r) >>> 1;
this._private_anchors[m].value < this._private_currentBlend ? (l = m + 1) : (r = m);
}
const lAnchor = this._private_anchors[l - 1];
const rAnchor = this._private_anchors[l];
if (this._private_lastLeftAnchor &&
this._private_lastLeftAnchor !== lAnchor &&
this._private_lastLeftAnchor !== rAnchor) {
this.updateAnchorWeight(this._private_lastLeftAnchor, 0);
}
if (this._private_lastRightAnchor &&
this._private_lastRightAnchor !== lAnchor &&
this._private_lastRightAnchor !== rAnchor) {
this.updateAnchorWeight(this._private_lastRightAnchor, 0);
}
const difference = (this._private_currentBlend - lAnchor.value) / (rAnchor.value - lAnchor.value);
this.updateAnchorWeight(lAnchor, 1 - difference);
this.updateAnchorWeight(rAnchor, difference);
this._private_lastLeftAnchor = lAnchor;
this._private_lastRightAnchor = rAnchor;
}
}
}
/** Minimum number of actions required for polar blend tree triangulation */
const MIN_POLAR_ACTIONS = 2;
/**
* Polar blend tree implementation for 2D animation blending in polar coordinates.
*
* This class manages a collection of animation actions positioned in polar space (radius, azimuth),
* automatically blending between adjacent animations based on polar coordinates. The blend tree
* organizes actions into rays (constant azimuth) and rings (constant radius) to enable efficient
* bilinear interpolation between the four closest animations, creating smooth transitions across
* the polar space.
*
* ## Architecture
* - **Rays**: Groups of anchors with the same azimuth but different radii
* - **Rings**: Groups of anchors with the same radius but different azimuths
* - **Bilinear Interpolation**: Weight calculation between 4 corner anchors in polar grid
* - **Center Action**: Optional action at origin (0,0) for special handling
*
* ## Coordinate System
* - **Radius**: Distance from origin, must be non-negative
* - **Azimuth**: Angle in radians, automatically normalized to [0, 2π) range
* - **Origin**: Special point (0,0) handled separately if center action provided
*
* ## Input Validation
* - Minimum 2 actions required for basic interpolation
* - All radius values must be finite, positive, within JavaScript safe range
* - All azimuth values must be finite, within JavaScript safe range
* - No duplicate polar coordinates allowed
* - At least 2 rays required (different azimuth values)
* - All rays must have the same number of anchors for grid consistency
*
* ## Blending Algorithm
* 1. Find the two adjacent rays that contain the target azimuth
* 2. Calculate angular interpolation weights between these rays
* 3. For each ray, find the two adjacent rings containing the target radius
* 4. Calculate radial interpolation weights between these rings
* 5. Apply bilinear interpolation to determine final animation weights
* 6. Handle special cases for center action and edge boundaries
*
* @example Walk/Run Directional Movement
* ```typescript
* // Create actions for 4-directional movement at two speeds
* const idle = mixer.clipAction(idleAnimationClip);
*
* // Walk actions (radius = 1)
* const walkForward = mixer.clipAction(walkForwardAnimationClip);
* const walkRight = mixer.clipAction(walkRightAnimationClip);
* const walkBackward = mixer.clipAction(walkBackwardAnimationClip);
* const walkLeft = mixer.clipAction(walkLeftAnimationClip);
*
* // Run actions (radius = 2)
* const runForward = mixer.clipAction(runForwardAnimationClip);
* const runRight = mixer.clipAction(runRightAnimationClip);
* const runBackward = mixer.clipAction(runBackwardAnimationClip);
* const runLeft = mixer.clipAction(runLeftAnimationClip);
*
* // Set up polar blend tree with 4-directional movement
* const blendTree = new PolarBlendTree([
* // Walk speed (radius = 1)
* { action: walkForward, radius: 1, azimuth: MathUtils.degToRad(0) }, // Forward
* { action: walkLeft, radius: 1, azimuth: MathUtils.degToRad(90) }, // Left
* { action: walkBackward, radius: 1, azimuth: MathUtils.degToRad(180) }, // Backward
* { action: walkRight, radius: 1, azimuth: MathUtils.degToRad(270) }, // Right
*
* // Run speed (radius = 2)
* { action: runForward, radius: 2, azimuth: MathUtils.degToRad(0) }, // Fast Forward
* { action: runLeft, radius: 2, azimuth: MathUtils.degToRad(90) }, // Fast Left
* { action: runBackward, radius: 2, azimuth: MathUtils.degToRad(180) }, // Fast Backward
* { action: runRight, radius: 2, azimuth: MathUtils.degToRad(270) }, // Fast Right
* ], idle); // Center action for stationary state
*
* // Blend to medium speed northeast (45° at 1.5x speed)
* blendTree.setBlend(1.5, MathUtils.degToRad(45));
*
* // Blend to slow walk forward
* blendTree.setBlend(0.5, MathUtils.degToRad(0));
* ```
*
* @public
*/
class PolarBlendTree extends AnimationTree {
/**
* Creates a new polar blend tree with the specified animation actions.
* Actions are organized into rays (by azimuth) and rings (by radius) for
* efficient bilinear interpolation. Initializes all actions to stopped state
* and validates clip durations.
*
* @param polarActions - Array of polar actions defining the blend space.
* Must contain at least 2 actions with unique coordinates.
* @param centerAction - Optional center action at origin (0,0)
* @throws {Error} When fewer than 2 actions are provided
* @throws {Error} When any action has non-finite radius or azimuth values
* @throws {Error} When any action has non-positive radius
* @throws {Error} When any action has values outside JavaScript's safe range
* @throws {Error} When multiple actions have the same polar coordinates
* @throws {Error} When fewer than 2 rays are created (insufficient azimuth variety)
* @throws {Error} When rays don't have consistent anchor counts for valid grid
* @throws {Error} When any animation clip duration is not a positive finite number
* @see {@link assertValidNumber} for coordinate validation details
* @see {@link assertValidPositiveNumber} for radius and duration validation details
*/
constructor(polarActions, centerAction) {
super();
this._private_tempAnchorMap = new Map();
this._private_trackableAnchors = [];
/** Array of rays (constant azimuth lines) containing anchors, sorted by azimuth */
this._private_rays = [];
/** Array of rings (constant radius circles) containing anchors, sorted by radius */
this._private_rings = [];
/** Current radial position for blending calculations */
this._private_currentRadius = 0;
/** Current angular position for blending calculations, normalized to [0, 2π) */
this._private_currentAzimuth = 0;
if (polarActions.length < MIN_POLAR_ACTIONS) {
throw new Error(`At least ${MIN_POLAR_ACTIONS} actions are required for polar blend tree`);
}
for (let i = 0; i < polarActions.length; i++) {
const { radius, azimuth } = polarActions[i];
assertValidNumber(radius, `Polar action at index ${i} radius`);
assertValidNumber(azimuth, `Polar action at index ${i} azimuth`);
assertValidPositiveNumber(radius, `Polar action at index ${i} radius`);
}
for (const polarAction of polarActions) {
polarAction.azimuth = calculateNormalizedAzimuth(polarAction.azimuth);
}
for (let i = 0; i < polarActions.length - 1; i++) {
const azimuth = polarActions[i].azimuth;
const radius = polarActions[i].radius;
for (let j = i + 1; j < polarActions.length; j++) {
if (Math.abs(azimuth - polarActions[j].azimuth) < EPSILON &&
Math.abs(radius - polarActions[j].radius) < EPSILON) {
throw new Error(`Duplicate coordinates found, azimuth: ${azimuth}, radius: ${radius}. All action values must be unique.`);
}
}
}
for (const polarAction of polarActions) {
const animationAction = polarAction.action;
const duration = animationAction.getClip().duration;
assertValidPositiveNumber(duration, "Clip duration");
animationAction.stop();
animationAction.time = 0;
animationAction.weight = 0;
animationAction.paused = false;
animationAction.enabled = false;
const anchor = {
index: getNextAnchorIndex(),
action: animationAction,
weight: 0,
duration,
invDuration: 1 / duration,
iterationEventType: animationAction.loop === LoopOnce
? AnimationStateEvent.FINISH
: AnimationStateEvent.ITERATE,
radius: polarAction.radius,
azimuth: polarAction.azimuth,
};
const ray = this._private_rays.find((r) => Math.abs(r.azimuth - polarAction.azimuth) < EPSILON);
const ring = this._private_rings.find((r) => Math.abs(r.radius - polarAction.radius) < EPSILON);
ray
? ray.anchors.push(anchor)
: this._private_rays.push({ azimuth: anchor.azimuth, anchors: [anchor] });
ring
? ring.anchors.push(anchor)
: this._private_rings.push({ radius: anchor.radius, anchors: [anchor] });
this.actionToAnchor.set(animationAction, anchor);
}
if (this._private_rays.length < 2) {
throw new Error("At least two rays are required.");
}
const ringCount = this._private_rings.length;
if (this._private_rays.some((r) => r.anchors.length !== ringCount)) {
throw new Error("The anchors must form a valid grid.");
}
this._private_rays.sort((a, b) => a.azimuth - b.azimuth);
for (const ray of this._private_rays) {
ray.anchors.sort((a, b) => a.radius - b.radius);
}
this._private_rings.sort((a, b) => a.radius - b.radius);
for (const ring of this._private_rings) {
ring.anchors.sort((a, b) => a.azimuth - b.azimuth);
}
if (centerAction) {
const duration = centerAction.getClip().duration;
assertValidPositiveNumber(duration, "Center clip duration");
centerAction.stop();
centerAction.time = 0;
centerAction.weight = 0;
centerAction.paused = false;
centerAction.enabled = false;
this._private_centerAnchor = {
index: getNextAnchorIndex(),
action: centerAction,
weight: 1,
duration,
invDuration: 1 / duration,
iterationEventType: centerAction.loop === LoopOnce
? AnimationStateEvent.FINISH
: AnimationStateEvent.ITERATE,
get radius() {
throw new Error("PolarBlendTree central anchor radius is not accessible");
},
get azimuth() {
throw new Error("PolarBlendTree central anchor azimuth is not accessible");
},
};
this._private_trackableAnchors.push(this._private_centerAnchor);
this.actionToAnchor.set(centerAction, this._private_centerAnchor);
}
else {
this._private_updateAnchors();
}
}
get blendValue() {
return { azimuth: this._private_currentAzimuth, radius: this._private_currentRadius };
}
/**
* Sets the blend position in polar coordinates to determine animation weights.
*
* The azimuth is normalized to [0, 2π) range and radius must be non-negative.
* When the position changes, animation weights are recalculated using bilinear
* interpolation between the closest anchors.
*
* @param azimuth - Target angular position in radians (finite number). Will be normalized to [0, 2π).
* @param radius - Target radial distance from origin (finite non-negative number).
* @throws {Error} When azimuth is not a finite number
* @throws {Error} When radius is not a finite non-negative number
* @see {@link assertValidNumber} for azimuth validation details
* @see {@link assertValidNonNegativeNumber} for radius validation details
*
* @example
* ```typescript
* // Blend to half speed, 45 degrees
* blendTree.setBlend(0.5, MathUtils.degToRad(45));
*
* // Blend to full speed, straight back
* blendTree.setBlend(1.0, MathUtils.degToRad(180));
*
* // Azimuth values are normalized to [0, 2π) range
* blendTree.setBlend(1.0, MathUtils.degToRad(450)); // Becomes (1.0, ~1.57)
* ```
*
* @public
*/
setBlend(azimuth, radius) {
assertValidNumber(azimuth, "Blend azimuth");
assertValidNonNegativeNumber(radius, "Blend radius");
const normalizedAzimuth = calculateNormalizedAzimuth(azimuth);
if (this._private_currentRadius !== radius ||
this._private_currentAzimuth !== normalizedAzimuth) {
this._private_currentRadius = radius;
this._private_currentAzimuth = normalizedAzimuth;
this._private_updateAnchors();
}
}
/**
* Internal frame update method called by the animation state machine.
*
* Monitors animation progress for all active anchors and emits iteration events
* when animations complete, restart, or loop. Tracks timing changes to detect
* animation state transitions and prevent duplicate event emissions.
*
* @internal This method is called exclusively by the animation state machine
* @override
*/
["onTickInternal"](deltaTime) {
if (this.influence === 0) {
throw new Error(`${this.name}: cannot update anchor time because the animation influence is zero`);
}
for (const anchor of this._private_trackableAnchors) {
this.processTimeEvents(anchor, deltaTime);
this.updateAnchorTime(anchor, deltaTime);
}
}
["onEnterInternal"]() {
super.onEnterInternal();
if (this.influence > 0) {
for (const anchor of this._private_trackableAnchors) {
this.resetFinishedAction(anchor);
}
}
}
/**
* Updates the global influence for all active anchors in the polar blend tree.
*
* Called when the tree's overall influence changes but relative weights between
* anchors should remain the same. Applies the current tree influence to all
* active anchors while maintaining their existing weight distribution from
* the polar blending calculations.
*
* @override
* @protected
*/
updateAnchorsInfluence() {
for (const anchor of this._private_trackableAnchors) {
this.updateAnchorWeight(anchor);
}
}
/**
* Recalculates and updates animation weights based on current blend position.
*
* This is the core blending algorithm that:
* 1. Finds the two adjacent rays containing the current azimuth
* 2. Calculates angular interpolation weights between these rays
* 3. Determines if blending occurs in the center region or outer grid
* 4. Applies appropriate interpolation (linear for center, bilinear for grid)
* 5. Updates active anchors set and applies calculated weights
*
* The method handles three distinct cases:
* - **Center Region**: When radius < first ring radius, blends with center action
* - **Grid Region**: When radius >= first ring radius, uses bilinear interpolation
* - **Edge Cases**: Boundary conditions and wraparound azimuth handling
*
* @private
*/
_private_updateAnchors() {
this._private_tempAnchorMap.clear();
for (const anchor of this._private_trackableAnchors) {
this._private_tempAnchorMap.set(anchor, 0);
}
for (let lRayIndex = 0; lRayIndex < this._private_rays.length; lRayIndex++) {
const rRayIndex = (lRayIndex + 1) % this._private_rays.length;
const lRay = this._private_rays[lRayIndex];
const rRay = this._private_rays[rRayIndex];
const lAzimuth = lRay.azimuth;
const rAzimuth = rRay.azimuth;
if (isAzimuthBetween(this._private_currentAzimuth, lAzimuth, rAzimuth)) {
const angularDistance = calculateAngularDistanceForward(lAzimuth, rAzimuth);
const leftDistance = calculateAngularDistanceForward(lAzimuth, this._private_currentAzimuth);
const rRayT = leftDistance / angularDistance;
const lRayT = 1 - rRayT;
if (this._private_currentRadius <= this._private_rings[0].radius) {
let lWeight = lRayT;
let rWeight = rRayT;
if (this._private_centerAnchor) {
const ringWeight = this._private_currentRadius / this._private_rings[0].radius;
this._private_tempAnchorMap.set(this._private_centerAnchor, 1 - ringWeight);
lWeight *= ringWeight;
rWeight *= ringWeight;
}
this._private_tempAnchorMap.set(lRay.anchors[0], lWeight);
this._private_tempAnchorMap.set(rRay.anchors[0], rWeight);
}
else if (this._private_currentRadius >= this._private_rings[this._private_rings.length - 1].radius) {
let lWeight = lRayT;
let rWeight = rRayT;
this._private_tempAnchorMap.set(lRay.anchors[lRay.anchors.length - 1], lWeight);
this._private_tempAnchorMap.set(rRay.anchors[rRay.anchors.length - 1], rWeight);
}
else {
this._private_calculateBilinearWeights(this._private_tempAnchorMap, lRayT, rRayT, lRayIndex, rRayIndex);
}
break;
}
}
this._private_trackableAnchors.length = 0;
for (const [anchor, weight] of this._private_tempAnchorMap) {
this.updateAnchorWeight(anchor, weight);
if (weight > 0) {
this._private_trackableAnchors.push(anchor);
}
}
}
/**
* Calculates bilinear interpolation weights for the four corner anchors in the polar grid.
*
* This method performs standard bilinear interpolation between four points arranged
* in a rectangular grid pattern in polar space. The four corners are defined by:
* - Inner ring vs outer ring (radial dimension)
* - Left ray vs right ray (angular dimension)
*
* The bilinear interpolation formula combines the radial and angular interpolation
* weights to determine how much each of the four corner anchors contributes to
* the final blend result.
*
* @param weights - Map to store calculated weights for each anchor
* @param lRayT - Weight for the left ray (0 = all left, 1 = all right)
* @param rRayT - Weight for the right ray (0 = all left, 1 = all right)
* @param lRayIndex - Index of the left ray in the rays array
* @param rRayIndex - Index of the right ray in the rays array
*
* @throws {Error} When no ring pair contains the current radius (should never happen)
*
* @private
*/
_private_calculateBilinearWeights(weights, lRayT, rRayT, lRayIndex, rRayIndex) {
for (let ringIndex = 0; ringIndex < this._private_rings.length - 1; ringIndex++) {
const innerRing = this._private_rings[ringIndex];
const outerRing = this._private_rings[ringIndex + 1];
const innerRadius = innerRing.radius;
const outerRadius = outerRing.radius;
if (this._private_currentRadius >= innerRadius &&
this._private_currentRadius <= outerRadius) {
const outerRingT = (this._private_currentRadius - innerRadius) / (outerRadius - innerRadius);
const innerRingT = 1 - outerRingT;
weights.set(outerRing.anchors[lRayIndex], outerRingT * lRayT);
weights.set(outerRing.anchors[rRayIndex], outerRingT * rRayT);
weights.set(innerRing.anchors[lRayIndex], innerRingT * lRayT);
weights.set(innerRing.anchors[rRayIndex], innerRingT * rRayT);
return;
}
}
// This code is unreachable under all inputs.
// In a properly functioning system, currentRadius should always fall within the range of some ring pair.
// This method is only called when currentRadius is between the first and last ring radii,
// so there must always be at least one ring interval that contains the current radius.
// If this branch is ever taken, it implies a violation of polar grid invariants and a bug elsewhere in the pipeline —
// not an edge case to be covered by tests.
/* c8 ignore next 3 */
throw new Error(`Invariant violation: currentRadius ${this._private_currentRadius} not found within any ring interval`);
}
}
export { AnimationMachine, AnimationState, AnimationStateEvent, ClipState, EPSILON, FreeformBlendTree, LinearBlendTree, PI2, PolarBlendTree, assertValidAzimuth, assertValidNonNegativeNumber, assertValidNumber, assertValidPositiveNumber, assertValidUnitRange, getNextAnchorIndex };
//# sourceMappingURL=index.js.map