animouse
Version:
lightweight animation state machine for three js
1,951 lines • 88.7 kB
JavaScript
import { MathUtils, LoopOnce, Vector2 } from 'three';
/**
* Events related to animation states and their internal AnimationActions.
*
* ENTER and EXIT describe when the state becomes active or inactive.
* PLAY, STOP, ITERATE, and FINISH describe the lifecycle of individual AnimationActions.
*/
var AnimationStateEvent;
(function (AnimationStateEvent) {
/**
* Fired when the state becomes active.
*/
AnimationStateEvent["ENTER"] = "enter";
/**
* Fired when the state becomes inactive.
*/
AnimationStateEvent["EXIT"] = "exit";
/**
* Fired when an AnimationAction starts playing.
*/
AnimationStateEvent["PLAY"] = "play";
/**
* Fired when an AnimationAction stops playing.
*/
AnimationStateEvent["STOP"] = "stop";
/**
* Fired when an AnimationAction completes one full loop.
* Fires every cycle for looping actions.
*/
AnimationStateEvent["ITERATE"] = "iterate";
/**
* Fired when an AnimationAction reaches its natural end.
* Applies to non-looping actions 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.
* @returns The next available anchor index
*/
function getNextAnchorIndex() {
return lastAnchorIndex++;
}
/**
* Asserts that a number is valid (finite and within safe integer range).
*
* @param value - The number to validate
* @param subject - Custom error message for validation failure
* @throws {Error} When the value is not finite or exceeds MAX_SAFE_INTEGER
*/
function assertValidNumber(value, subject) {
if (!Number.isFinite(value)) {
throw new Error(`${subject}: value must be a finite number`);
}
if (Math.abs(value) > Number.MAX_SAFE_INTEGER) {
throw new Error(`${subject}: value exceeds maximum safe integer range`);
}
}
/**
* Asserts that an azimuth angle is valid (finite and between 0 and 2π).
*
* @param value - The azimuth angle in radians to validate
* @param subject - Custom error message for validation failure
* @throws {Error} When the value is not finite or is not between 0 and 2π radians
*/
function assertValidAzimuth(value, subject) {
assertValidNumber(value, subject);
if (value < 0 || value > PI2) {
throw new Error(`${subject}: 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 subject - Custom error message for validation failure
* @throws {Error} When the value is not within the range [0, 1]
*/
function assertValidUnitRange(value, subject) {
assertValidNumber(value, subject);
if (value < 0 || value > 1) {
throw new Error(`${subject}: value must be between 0 and 1`);
}
}
/**
* Asserts that a number is positive (greater than EPSILON).
* Uses EPSILON to account for floating-point precision errors.
*
* @param value - The number to validate
* @param subject - Custom error message for validation failure
* @throws {Error} When the value is less than EPSILON
*/
function assertValidPositiveNumber(value, subject) {
assertValidNumber(value, subject);
if (value < EPSILON) {
throw new Error(`${subject}: 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 subject - Custom error message for validation failure
* @throws {Error} When the value is negative
*/
function assertValidNonNegativeNumber(value, subject) {
assertValidNumber(value, subject);
if (value < 0) {
throw new Error(`${subject}: value must be greater than or equal to 0`);
}
}
/**
* Animation state machine for managing state transitions and blending.
*
* Controls 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
*
* Handles blending between states using configurable transition durations.
* Multiple states can be active during transitions, with weights interpolated linearly.
*/
class AnimationMachine {
/**
* Creates a new animation state machine with the specified initial state.
* Activates the initial state with full influence.
*
* @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 an 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 is 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
*/
addEventTransition(event, transition) {
var _a;
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 = (_a = this._private_eventTransitions.get(event)) !== null && _a !== void 0 ? _a : [];
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
*/
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 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
*/
addDataTransition(from, transition) {
var _a;
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 = (_a = this._private_dataTransitions.get(from)) !== null && _a !== void 0 ? _a : [];
transitions.push(transition);
this._private_dataTransitions.set(from, transitions);
}
/**
* Handles an event by executing the first matching transition.
* Evaluates registered transitions for the event in registration order.
*
* @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.
*
* Updates current state and fading states, evaluates data-driven transitions,
* progresses active transitions by interpolating state influences, and updates
* the animation mixer.
*
* @param deltaTime - Time elapsed since last update in seconds
* @throws {Error} When deltaTime is not a finite non-negative number
*/
update(deltaTime) {
var _a;
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 = (_a = this._private_dataTransitions.get(this._private_currentStateInternal)) === null || _a === void 0 ? void 0 : _a.find((transition) => {
var _a;
return transition.condition(this._private_currentStateInternal, transition.to, ...((_a = transition.data) !== null && _a !== void 0 ? _a : []));
});
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;o.length>t;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(10>e||o[0].M===o[n].M)for(let t=1;n>t;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(;n>=e;){const i=e+n>>>1;t>o[i].M?e=i+1:n=i-1;}const c=e;for(n=o.length-1;n>=e;){const i=e+n>>>1;o[i].M>t?n=i-1:e=i+1;}const h=n;for(let t=c;h>=t;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;s.length>t;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;n.length>t;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||i>=t[t.length-1].M)return void t.push(c);if(t[0].M>=i)return void t.unshift(c);{let r=0,s=t.length;for(;s>r;){const o=r+s>>>1;i>t[o].M?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.
*
* 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;
}
/**
* Called by the animation state machine when entering this state.
* Emits the ENTER event with this state instance as data.
*
* @internal Called only by the animation state machine
*/
["onEnterInternal"]() {
this.emit(AnimationStateEvent.ENTER, this);
}
/**
* Called by the animation state machine when exiting this state.
* Emits the EXIT event with this state instance as data.
*
* @internal Called only by the animation state machine
*/
["onExitInternal"]() {
this.emit(AnimationStateEvent.EXIT, this);
}
/**
* Registers 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 Called only by concrete animation state implementations
*/
onTimeEventInternal(anchor, unitTime, callback, isOnce) {
var _a, _b;
assertValidUnitRange(unitTime, "Unit time");
const roundedTime = Math.round(unitTime * 100) / 100;
const events = (_a = this.timeEvents.get(anchor)) !== null && _a !== void 0 ? _a : new Map();
this.timeEvents.set(anchor, events);
const event = (_b = events.get(roundedTime)) !== null && _b !== void 0 ? _b : `${roundedTime}_${anchor.index}`;
events.set(roundedTime, event);
isOnce ? this.once(event, callback) : this.on(event, callback);
}
/**
* Unregisters 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 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 === undefined) {
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 playback, weight control, and iteration events for a single animation clip.
*
* 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.
*
* @param animationAction - The Three.js AnimationAction to wrap
* @throws {Error} When the animation clip duration is not a positive finite number
*/
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);
}
/**
* Sets the influence of this animation state.
* Starts animation when influence becomes positive, stops when zero.
* Resets animation time on playback transitions.
*
* @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 Called only by the animation state machine
*/
["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;
}
/**
* Called on each frame to update animation state.
* Handles time tracking and iteration event emission.
*
* @internal Called only by the animation state machine
*/
["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);
}
}
}
/******************************************************************************
Copyright (c) Microsoft Corporation.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.
***************************************************************************** */
/* global Reflect, Promise, SuppressedError, Symbol, Iterator */
function __rest(s, e) {
var t = {};
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
t[p] = s[p];
if (s != null && typeof Object.getOwnPropertySymbols === "function")
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
t[p[i]] = s[p[i]];
}
return t;
}
typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
var e = new Error(message);
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
};
/** The number of vertices in a triangle. */
const TRIANGLE_VERTEX_COUNT = 3;
/**
* Precomputes triangle data for efficient operations.
* Calculates circumcenter, bounding box, and barycentric coordinate helpers.
*
* @param a - First vertex of the triangle
* @param b - Second vertex of the triangle
* @param c - Third vertex of the triangle
* @returns Precomputed triangle data cache
* @throws {Error} When triangle is degenerate or coordinates are invalid
*/
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: Math.pow((a.x - circumcenter.x), 2) + Math.pow((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.
*
* @param point - The point to calculate barycentric coordinates for
* @param cache - Precomputed triangle data
* @returns Barycentric weights {aW, bW, cW} if point is inside triangle, undefined otherwise
* @throws {Error} When point coordinates or cache data are invalid
*/
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.
*
* @param a - First vertex of the triangle
* @param b - Second vertex of the triangle
* @param c - Third vertex of the triangle
* @returns The centroid point of the triangle
* @throws {Error} When any coordinate value is invalid
*/
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 square root operations.
*
* @param origin - The center point of the circle
* @param radiusSquared - The squared radius of the circle
* @param point - The point to test
* @returns True if the point is strictly inside the circle, false otherwise
* @throws {Error} When coordinates are invalid or radius is not positive
*/
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 (Math.pow((point.x - origin.x), 2) + Math.pow((point.y - origin.y), 2) <
radiusSquared - EPSILON);
}
/**
* Normalizes an azimuth angle to the range [0, 2π).
*
* @param azimuth - The azimuth angle in radians
* @returns The normalized azimuth in the range [0, 2π) radians
* @throws {Error} When the azimuth value is not a valid number
*/
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.
*
* @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
*/
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.
*
* @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
*/
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.
* Uses squared distance to avoid square root operation.
*
* @param x1 - X coordinate of the first point
* @param y1 - Y coordinate of the first point
* @param x2 - X coordinate of the second point
* @param y2 - Y coordinate of the second point
* @returns The squared distance between the two points
* @throws {Error} When any coordinate value is invalid
*/
function calculateDistanceSquared(x1, y1, x2, y2) {
assertValidNumber(x1, "Coordinate 'x1'");
assertValidNumber(y1, "Coordinate 'y1'");
assertValidNumber(x2, "Coordinate 'x2'");
assertValidNumber(y2, "Coordinate 'y2'");
return Math.pow((x2 - x1), 2) + Math.pow((y2 - y1), 2);
}
/**
* Calculates the squared distance from a point to a line segment.
* Projects the point onto the line segment and clamps to segment boundaries.
*
* @param edge - A tuple containing the two endpoints of the edge
* @param x - X coordinate of the point
* @param y - Y coordinate of the point
* @returns The squared distance from the point to the closest point on the edge
* @throws {Error} When any coordinate value is invalid
*/
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 Math.pow(deltaX, 2) + Math.pow(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.
*/
class DelaunayTriangulator {
/**
* Performs Delaunay triangulation on a set of 2D points.
*
* @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
*/
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(b.x - a.x, b.y - a.y).normalize();
const temp = new Vector2();
if (rest.every((p) => Math.abs(direction.cross(temp.set(p.x - a.x, p.y - a.y))) < 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(Object.assign({ 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.
* 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
* @returns Array of triangles that contain the point in their circumcircles
*/
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.
*
* @template T - Type of vertices that must be Vector2Like objects
* @param triangles - Array of triangles forming the cavity
* @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.
*
* @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
*/
static filterSuperTriangleVertices(triangles, superTriangle) {
var _a, _b;
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 = (_a = boundaryEdgeMap.get(lPoint)) !== null && _a !== void 0 ? _a : [];
const rArray = (_b = boundaryEdgeMap.get(rPoint)) !== null && _b !== void 0 ? _b : [];
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.
* Vertices are positioned 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
* @returns Triangle that contains all input points with precomputed properties
*/
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 Object.assign({ a, b, c }, precomputeTriangle(a, b, c));
}
/**
* Tests whether a vertex belongs to a specific triangle.
*
* @template T - Type of vertices that must be Vector2Like objects
* @param vertex - Vertex to test for membership
* @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.
* Manages multiple animation actions with their weights.
*
* Animation trees organize animation actions and handle 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);
}
/**
* Sets the influence of this animation tree.
* Updates anchor influences while maintaining their relative weights.
*
* @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 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.
* Handles animation lifecycle: starting, stopping, and weight adjustments.
* Combines the raw weight with the tree's influence to get the final action weight.
*
* Transitions from zero to non-zero weight start playback and emit PLAY event.
* Transitions from non-zero to zero weight stop playback and emit STOP event.
*
* @param anchor - The animation anchor containing the action and parameters to update
* @param weight - The raw weight value before applying tree influence. 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]
*/
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 2D animation blending.
*
* Uses Delaunay triangulation to create a mesh from animation actions positioned
* in 2D coordinates. Interpolates between animations using barycentric coordinates
* within triangles. For points outside the mesh, uses nearest edge interpolation.
*
* @example
* ```typescript
* const actions = [
* { action: idleAction, x: 0, y: 0 },
* { action: walkAction, x: 0, y: 1 },
* { action: runAction, x: 1, y: 0 },
* { action: sprintAction, x: 1, y: 1 }
* ];
*
* const blendTree = new FreeformBlendTree(actions);
* blendTree.setBlend(0.5, 0.8);
* ```
*/
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 interpolation.
*
* @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 multiple actions have the same coordinates
* @throws {Error} When any animation clip duration is not positive
*/
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 } = t, rest = __rest(t, ["circumcenter", "circumradiusSquared"]);
return Object.assign(Object.assign({}, 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 coordinates to determine animation weights.
* Recalculates weights using barycentric interpolation within triangles
* or nearest edge interpolation for boundary points.
*
* @param x - Target X coordinate in 2D space
* @param y - Target Y coordinate in 2D space
* @throws {Error} When x or y coordinate is not a finite number
*/
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();
}
}
/**
* Called by the animation state machine on each frame update.
* Tracks animation progress and emits iteration events for active actions.
*
* @internal Called only by the animation state machine
*/
["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.
*/
updateAnchorsInfluence() {
for (const anchor of this._private_trackableAnchors) {
this.updateAnchorWeight(anchor);
}
}
/**
* Recalculates and updates animation weights based on current blend position.
*
* Attempts barycentric interpolation if point lies within any triangle.
* Falls back to nearest boundary edge interpolation for external points.
*/
_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);
}
}
}
/**
* Applies barycentric weights if the blend point lies within any triangle.
*
* @param result - Map to store calculated weights for each anchor
* @returns True if barycentric interpolation was applied, false if point is outside all triangles
*/
_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 mesh.
* Finds the closest boundary vertex and interpolates along the nearest edge.
*
* @param result - Map to store calculated weights for each anchor
*/
_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.
* Improves performance by checking closer triangles first.
*/
_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 animation actions positioned along a linear axis, blending
* between adjacent animations based on a blend value. Interpolates weights
* between the two closest animations.
*
* Action values can be any finite numbers and must be unique.
*
* @example
* ```typescript
* const blendTree = new LinearBlendTree([
* { action: idleAction, value: 0 },
* { action: walkAction, value: 0.5 },
* { action: runAction, value: 1 }
* ]);
*
* blendTree.setBlend(0.3);
* ```
*/
class LinearBlendTree extends AnimationTree {
/**
* Creates a new linear blend tree with the specified animation actions.
* Actions are sorted by their value along the linear axis.
*
* @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
* @throws {Error} When multiple actions have the same value
* @throws {Error} When any animation clip duration is not positive
*/
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.
* Recalculates weights to interpolate between the two closest actions.
* Values outside the action range give full weight to the nearest boundary action.
*
* @param value - The target blend value
* @throws {Error} When the blend value is not a finite number
*/
setBlend(value) {
assertValidNumber(value, "Blend value");
if (value !== this._private_currentBlend) {
this._private_currentBlend = value;
this._private_updateAnchors();
}
}
/**
* Called by the animation state machine on each frame update.
* Tracks animation progress and emits iteration events when animations
* complete or restart.
*
* @internal Called only by the animation state machine
*/
["onTickInternal"](deltaTime) {
var _a, _b;
if (this.influence === 0) {
throw new Error(`${this.name}: cannot update anchor time because the animation influence is zero`);
}
if (((_a = this._private_lastLeftAnchor) === null || _a === void 0 ? void 0 : _a.weight) !== undefined) {
this.processTimeEvents(this._private_lastLeftAnchor, deltaTime);
this.updateAnchorTime(this._private_lastLeftAnchor, deltaTime);
}
if (((_b = this._private_lastRightAnchor) === null || _b === void 0 ? void 0 : _b.weight) !== undefined) {
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.
*/
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.
*/
_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.
*
* Manages animation actions positioned in polar space (radius, azimuth), blending
* between adjacent animations using bilinear interpolation. Organizes actions into
* rays (constant azimuth) and rings (constant radius).
*
* @example
* ```typescript
* const blendTree = new PolarBlendTree([
* { action: walkForward, radius: 1, azimuth: 0 },
* { action: walkLeft, radius: 1, azimuth: Math.PI / 2 },
* { action: runForward, radius: 2, azimuth: 0 },
* { action: runLeft, radius: 2, azimuth: Math.PI / 2 }
* ], idleAction);
*
* blendTree.setBlend(Math.PI / 4, 1.5);
* ```
*/
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).
*
* @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 multiple actions have the same polar coordinates
* @throws {Error} When fewer than 2 rays are created
* @throws {Error} When rays don't have consistent anchor counts
* @throws {Error} When any animation clip duration is not positive
*/
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.
* Azimuth is normalized to [0, 2π) range and radius must be non-negative.
* Recalculates weights using bilinear interpolation between the closest anchors.
*
* @param azimuth - Target angular position in radians. Will be normalized to [0, 2π).
* @param radius - Target radial distance from origin.
* @throws {Error} When azimuth is not a finite number
* @throws {Error} When radius is not a finite non-negative number
*/
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();
}
}
/**
* Called by the animation state machine on each frame update.
* Monitors animation progress and emits iteration events for active anchors.
*
* @internal Called only by the animation state machine
*/
["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 active anchors in the polar blend tree.
* Called when the tree's influence changes but relative weights remain the same.
*/
updateAnchorsInfluence() {
for (const anchor of this._private_trackableAnchors) {
this.updateAnchorWeight(anchor);
}
}
/**
* Recalculates and updates animation weights based on current blend position.
* Finds the two adjacent rays containing the current azimuth and applies
* appropriate interpolation (linear for center, bilinear for grid).
*/
_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.
* Performs interpolation between four points in a rectangular grid pattern.
*
* @param weights - Map to store calculated weights for each anchor
* @param lRayT - Weight for the left ray
* @param rRayT - Weight for the right ray
* @param lRayIndex - Index of the left ray in the rays array
* @param rRayIndex - Index of the right ray in the rays array
*/
_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