UNPKG

ayvajs

Version:

A lightweight, behavior-based JavaScript API for controlling Open Source Multi Axis Stroker Robots.

1,236 lines (1,026 loc) 36.3 kB
/* eslint-disable no-await-in-loop */ import MoveBuilder from './util/move-builder.js'; import WorkerTimer from './util/worker-timer.js'; import { clamp, round, has, createConstantProperty, validNumber, isGeneratorFunction } from './util/util.js'; import validator from './util/validator.js'; import OSR_CONFIG from './util/osr-config.js'; import GeneratorBehavior from './behaviors/generator-behavior.js'; // eslint-disable-line import/no-cycle class Ayva { #devices = []; #axes = {}; #frequency = 50; // Hz #movements = new Set(); #nextMovementId = 1; #nextBehaviorId = 1; #currentBehaviorId = null; #performing = false; #timer; #sleepResolves = new Set(); #readyResolves = new Set(); defaultRamp = Ayva.RAMP_COS; static get precision () { // Decimals to round to for internal values. return 10; } static get maxFrequency () { return 250; } get performing () { return this.#performing; } get axes () { const result = {}; Object.keys(this.#axes).forEach((key) => { // Ensure that the result object is immutable by using getAxis() result[key] = this.getAxis(key); }); return result; } get frequency () { return this.#frequency; } set frequency (value) { if (!validNumber(value, 1, Ayva.maxFrequency)) { throw new Error(`Invalid frequency ${value}. Frequency must be a number between 1 and ${Ayva.maxFrequency}.`); } this.#frequency = value; } get period () { return this.#period; } get #period () { return 1 / this.#frequency; } /** * Create a new instance of Ayva with the specified configuration. * * @param {Object} [config] * @param {String} [config.name] - the name of this configuration * @param {String} [config.defaultAxis] - the default axis to command when no axis is specified * @param {Object[]} [config.axes] - an array of axis configurations (see {@link Ayva#configureAxis}) * @class Ayva */ constructor (config) { createConstantProperty(this, '$', {}); if (config) { this.#configure(config); } if (typeof Worker === 'undefined') { this.#timer = { // Default timer is just a basic timeout. sleep (seconds) { return new Promise((resolve) => { setTimeout(resolve, seconds * 1000); }); }, now () { return performance.now() / 1000; }, }; } else { this.#timer = new WorkerTimer(); } } /** * Setup this Ayva instance with the default configuration (a six axis stroker). * * @example * const ayva = new Ayva().defaultConfiguration(); * * @returns the instance of Ayva */ defaultConfiguration () { this.#configure(OSR_CONFIG); return this; } /** * Get the timer that Ayva uses to time movements. * * @returns the timer */ getTimer () { return this.#timer; } /** * Set the timer that Ayva uses to time movements. */ setTimer (timer) { this.#timer = timer; } /** * Perform the specified behavior until it completes or is explicitly stopped. * If another behavior is running, it will be stopped. * * For full details on how to use this method, see the {@tutorial behavior-api} tutorial. * * @param {GeneratorBehavior|Function|Object} behavior - the behavior to perform. */ async do (behavior) { this.#stop(); const behaviorId = this.#nextBehaviorId++; this.#currentBehaviorId = behaviorId; while (this.#performing) { await this.sleep(); } this.#performing = true; const computedBehavior = this.#computeBehavior(behavior); while (this.#currentBehaviorId === behaviorId && !computedBehavior.complete) { try { await computedBehavior.perform(this); // Allow any moves or sleeps that were queued to complete. await this.ready(); } catch (error) { console.error('Error performing behavior:', error?.stack); // eslint-disable-line no-console break; } } this.#performing = false; if (this.#currentBehaviorId !== behaviorId) { // Behavior was stopped before it completed. return false; } this.#currentBehaviorId = null; return true; } /** * Performs movements along one or more axes. This is a powerful method that can synchronize * axis movement while allowing for fine control over position, speed, or move duration. * For full details on how to use this method, see the {@tutorial motion-api} tutorial. * * @example * ayva.move({ * axis: 'stroke', * to: 0, * speed: 1, * },{ * axis: 'twist', * to: 0.5, * duration: 1, * }); * * @param {...Object} movements * @return {Promise} a promise that resolves with the boolean value true when all movements have finished, or false if the move was cancelled. */ move (...movements) { if (!this.#devices || !this.#devices.length) { throw new Error('No output devices have been added.'); } validator.validateMovements(movements, this.#axes, this.defaultAxis); return this.#asyncMove(...movements); } /** * Wait until ayva is not doing anything (neither moving nor sleeping). * * @return {Promise} a promise that resolves when there are no more moves or sleeps queued. */ ready () { if (this.#sleepResolves.size || this.#movements.size) { return new Promise((resolve) => { this.#readyResolves.add(resolve); }); } return this.sleep(); } /** * Creates a MoveBuilder for this instance. * * @returns the new move builder. */ moveBuilder () { return new MoveBuilder(this); } /** * Moves all axes to their default positions. * * @param {Number} [speed = 0.5] - optional speed of the movement. * @return {Promise} A promise that resolves when the movements are finished. */ async home (speed = 0.5) { const movements = this.#getAxesArray() .map((axis) => { const movement = { axis: axis.name, to: axis.defaultValue, }; if (axis.type !== 'boolean') { movement.speed = speed; } return movement; }); if (movements.length) { return this.move(...movements); } console.warn('No linear or rotation axes configured.'); // eslint-disable-line no-console return Promise.resolve(false); } /** * Cancels all running or pending movements, clears the current behavior (if any), and cancels any sleeps. */ stop () { // TODO: Add on stop notification here once event listening is implemented. this.#stop(); } /** * Asynchronously sleep for the specified number of seconds (or until stop() is called). * * @param {Number} seconds * @returns {Promise} a Promise that resolves with the value true if the time elapses. false if the sleep is cancelled. */ sleep (seconds) { let sleepResolve; const sleepCanceller = new Promise((resolve) => { this.#sleepResolves.add(resolve); sleepResolve = resolve; }); return Promise.any([ this.#timer.sleep(seconds).then(() => true), sleepCanceller.then(() => false), ]).finally(() => { this.#sleepResolves.delete(sleepResolve); this.#checkNotifyReady(); }); } /** * Configures a new axis. If an axis with the same name has already been configured, it will be overridden. * * @example * const ayva = new Ayva(); * * ayva.configureAxis({ * name: 'L0', * type: 'linear', * alias: 'stroke', * max: 0.9, * min: 0.3, * }); * * @param {Object} axisConfig - axis configuration object * @param {String} axisConfig.name - the machine name of this axis (such as L0, R0, etc...) * @param {String} axisConfig.type - linear, rotation, or auxiliary * @param {String} [axisConfig.alias] - an alias used to refer to this axis * @param {Number} [axisConfig.max = 1] - specifies maximum value for this axis * @param {Number} [axisConfig.min = 0] - specifies minimum value for this axis */ configureAxis (axisConfig) { // TODO: Disallow 'execute' as an axis name. const resultConfig = validator.validateAxisConfig(axisConfig); const oldConfig = this.#axes[axisConfig.name]; if (oldConfig) { resultConfig.value = oldConfig.value; resultConfig.lastValue = oldConfig.lastValue; delete this.#axes[oldConfig.alias]; delete this.$[oldConfig.alias]; } this.#axes[axisConfig.name] = resultConfig; this.#createAxisMoveBuilder(axisConfig.name); if (axisConfig.alias) { if (this.#axes[axisConfig.alias]) { throw new Error(`Alias already refers to another axis: ${axisConfig.alias}`); } this.#axes[axisConfig.alias] = resultConfig; this.#createAxisMoveBuilder(axisConfig.alias); } } /** * Fetch an immutable object containing the properties for an axis. * * @param {String} name - the name or alias of the axis to get. * @return {Object} axisConfig - an immutable object of axis properties. */ getAxis (name) { const fetchedAxis = this.#axes[name]; if (fetchedAxis) { const axis = {}; Object.keys(fetchedAxis).forEach((key) => { createConstantProperty(axis, key, fetchedAxis[key]); }); return axis; } return undefined; } /** * Fetch an array of the axes. */ getAxes () { return this.#getAxesArray().map((axis) => ({ // Ghetto deep copy, but its the most optimal. name: axis.name, alias: axis.alias, type: axis.type, defaultValue: axis.defaultValue, max: axis.max, min: axis.min, value: axis.value, lastValue: axis.lastValue, resetOnStop: axis.resetOnStop, })); } /** * Update the limits for the specified axis. * * @param {String} axis * @param {Number} from - value between 0 and 1 * @param {Number} to - value between 0 and 1 */ updateLimits (axis, from, to) { const isInvalid = (value) => !Number.isFinite(value) || value < 0 || value > 1; if (isInvalid(from) || isInvalid(to) || from === to) { throw new Error(`Invalid limits: min = ${from}, max = ${to}`); } if (!this.#axes[axis]) { throw new Error(`Invalid axis: ${axis}`); } this.#axes[axis].min = Math.min(from, to); this.#axes[axis].max = Math.max(from, to); } /** * Live update axis values. * * @param {Object} axisValueMap - axis to value map */ setValues (axisValueMap) { // TODO: Validate instead of silently ignoring invalid values? const axisValues = Object.entries(axisValueMap).map(([axis, value]) => ({ axis, value })); this.#writeAxisValues(axisValues); } /** * Registers a new output. Ayva outputs commands to all connected outputs. * More than one output can be specified. * * @param {...Function|Object} output - a function or an object with a write() method. */ addOutput (...output) { this.addOutputDevice(...output); } /** * Return a list of all outputs. */ getOutput () { return this.getOutputDevices(); } /** * Remove the specified output. * * @param {Object} output - the output to remove. */ removeOutput (output) { this.removeOutputDevice(output); } /** * Registers a new output device. Ayva outputs commands to all connected devices. * More than one device can be specified. * * @deprecated since version 0.13.0. Use addOutput() instead. * @param {...Object} device - a function or an object with a write method. */ addOutputDevice (...devices) { const resultDevices = devices.map((device) => { const isWritable = device && device.write && device.write instanceof Function; const isFunction = device instanceof Function; if (!isWritable && !isFunction) { throw new Error(`Invalid device: ${device}`); } return isWritable ? device : { write: device }; }); this.#devices.push(...resultDevices); } /** * Return a list of all output devices. * @deprecated since version 0.13.0. Use getOutput() instead. */ getOutputDevices () { return [...this.#devices]; } /** * Remove the specified device. * * @deprecated since version 0.13.0. Use removeOutput() instead. * @param {Object} device - the device to remove. */ removeOutputDevice (device) { const index = this.#devices.indexOf(device); if (index !== -1) { this.#devices.splice(index, 1); } } async #asyncMove (...movements) { const movementId = this.#nextMovementId++; this.#movements.add(movementId); while (this.#movements.has(movementId) && this.#movements.values().next().value !== movementId) { // Wait until current movements have completed to proceed. await this.sleep(); } if (!this.#movements.has(movementId)) { // This move must have been cancelled. return false; } return this.#performMovements(movementId, movements).finally(() => { this.#movements.delete(movementId); this.#checkNotifyReady(); }); } #stop () { this.#currentBehaviorId = null; this.#movements.clear(); this.#sleepResolves.forEach((resolve) => resolve()); const resetValues = {}; this.#getAxesArray().forEach((axis) => { if (axis.resetOnStop) { resetValues[axis.name] = axis.defaultValue; } }); if (Object.keys(resetValues).length) { this.setValues(resetValues); } } /** * Add the start of a move builder chain to $ for the specified axis. * Also add shortcut properties for value, min, and max to each axis. */ #createAxisMoveBuilder (axis) { Object.defineProperty(this.$, axis, { value: (...args) => this.moveBuilder()[axis](...args), writeable: false, configurable: true, enumerable: true, }); Object.defineProperty(this.$[axis], 'value', { get: () => this.#axes[axis].value, set: (target) => { const { type } = this.#axes[axis]; if (type !== 'boolean' && !validNumber(target, 0, 1)) { // TODO: Move this validation out into a place it can be reused for setValues() method? throw new Error(`Invalid value: ${target}`); } this.#writeAxisValues([{ axis, value: target, }]); }, }); Object.defineProperty(this.$[axis], 'lastValue', { get: () => this.#axes[axis].lastValue, }); Object.defineProperty(this.$[axis], 'defaultValue', { get: () => this.#axes[axis].defaultValue, }); Object.defineProperty(this.$[axis], 'min', { get: () => this.#axes[axis].min, }); Object.defineProperty(this.$[axis], 'max', { get: () => this.#axes[axis].max, }); } /** * Setup the configuration. */ #configure (config) { this.name = config.name; this.defaultAxis = config.defaultAxis; this.#frequency = (config.frequency || this.#frequency); if (config.axes) { config.axes.forEach((axis) => { this.configureAxis(axis); }); } } #computeBehavior (value) { if (typeof value === 'function' && !(value instanceof GeneratorBehavior)) { if (isGeneratorFunction(value)) { return new GeneratorBehavior(value); } return { perform: value, }; } return value; } /** * Writes the specified command out to all connected devices. */ #write (command) { for (const device of this.#devices) { device.write(command); } } async #performMovements (movementId, movements) { const allProviders = this.#createValueProviders(movements); const { duration, stepCount } = this.#computeMaxDurationAndStepCount(allProviders); const immediateProviders = allProviders.filter((provider) => !provider.parameters.stepCount); const stepProviders = allProviders.filter((provider) => !!provider.parameters.stepCount); this.#executeProviders(immediateProviders, 0); let errorCorrection = 0; const startTime = this.#timer.now(); if (stepCount) { for (let index = 0; index < stepCount; index++) { const unfinishedProviders = stepProviders.filter((provider) => index < provider.parameters.stepCount); this.#executeProviders(unfinishedProviders, index); errorCorrection = await this.#stepSleep(index, stepCount, duration, startTime, errorCorrection); if (!this.#movements.has(movementId)) { // This move was cancelled. return false; } } } else { // Always sleep at least a tick even when all providers are immediate. await this.sleep(this.#period); } return true; } /** * Sleep for a single step. Aims to sleep for this.#period seconds on average. This method corrects for * deviations in the underlying timer. * * TODO: Maybe add a threshold for the error. * * @returns the new error correction */ async #stepSleep (index, stepCount, duration, startTime, errorCorrection) { const clampPeriod = (period) => Math.max(period, 1 / Ayva.maxFrequency); if (index === stepCount - 1) { // This shenanigans is to (attempt to) account for the fact that a move is // an integer number of steps but a duration may be fractional. In the final step // we may have time remaining that is less than the period. const currentElapsed = this.#timer.now() - startTime; const remaining = Math.min(Math.max(duration - currentElapsed, 0), this.#period); await this.sleep(clampPeriod(remaining)); } else { await this.sleep(clampPeriod(this.#period - errorCorrection)); } const actualElapsed = this.#timer.now() - startTime; const expectedElapsed = (index + 1) * this.#period; return actualElapsed - expectedElapsed; } #executeProviders (providers, index) { const axisValues = providers.map((provider) => this.#executeProvider(provider, index)); this.#writeAxisValues(axisValues); } #executeProvider (provider, index) { const time = index * this.#period; const { parameters, valueProvider } = provider; const { duration } = parameters; const nextValue = valueProvider({ ...parameters, time, index, period: this.#period, frequency: this.#frequency, currentValue: this.#axes[parameters.axis].value, x: Math.min(1, (index + 1) / (duration * this.#frequency)), }); const notNullOrUndefined = nextValue !== null && nextValue !== undefined; // Allow null or undefined to indicate no movement. if (!this.#isValidAxisValue(nextValue) && notNullOrUndefined) { console.warn(`Invalid value provided: ${nextValue}`); // eslint-disable-line no-console } return { axis: parameters.axis, value: Number.isFinite(nextValue) ? clamp(round(nextValue, Ayva.precision), 0, 1) : nextValue, }; } #writeAxisValues (axisValues) { const filteredAxisValues = axisValues.filter(({ value }) => this.#isValidAxisValue(value)); const tcodes = filteredAxisValues.map(({ axis, value }) => this.#tcode(axis, value)); if (tcodes.length) { this.#write(`${tcodes.join(' ')}\n`); filteredAxisValues.forEach(({ axis, value }) => { this.#axes[axis].lastValue = this.#axes[axis].value; this.#axes[axis].value = value; }); } } #isValidAxisValue (value) { return Number.isFinite(value) || typeof value === 'boolean'; } /** * Converts the value into a standard live command TCode string for the specified axis. (i.e. 0.5 -> L0500) * If the axis is a boolean axis, true values get mapped to 999 and false gets mapped to 000. * * @param {String} axis * @param {Number} value * @returns {String} the TCode string */ #tcode (axis, value) { let valueText; if (typeof value === 'boolean') { valueText = value ? '9999' : '0000'; } else { const { min, max } = this.#axes[axis]; const normalizedValue = round(value * 0.9999, 4); // Convert values from range (0, 1) to (0, 0.9999) const scaledValue = (max - min) * normalizedValue + min; valueText = `${clamp(round(scaledValue * 10000), 0, 9999)}`.padStart(4, '0'); } return `${this.#axes[axis].name}${valueText}`; } /** * Create value providers with initial parameters. * * Precondition: Each movement is a valid movement per the Motion API. * @param {Object[]} movements * @returns {Object[]} - array of value providers with parameters. */ #createValueProviders (movements) { const { parameterObjects, maxDuration } = this.#createParameterObjects(movements); this.#populateDurationAndStepCount(parameterObjects, maxDuration); // Create the actual value providers. return parameterObjects.map((parameters) => { const provider = {}; if (!has(parameters, 'value')) { // Create a value provider from parameters. if (this.#axes[parameters.axis].type === 'boolean') { provider.valueProvider = () => parameters.to; } else if (parameters.to !== parameters.from) { provider.valueProvider = this.defaultRamp; } else { // No movement. provider.valueProvider = () => {}; } } else { // User provided value provider. provider.valueProvider = parameters.value; } delete parameters.sync; delete parameters.value; provider.parameters = parameters; return provider; }); } #createParameterObjects (movements) { let maxDuration = 0; const parameterObjects = movements.map((movement) => { // Initialize all parameters that we can deduce. const axis = movement.axis || this.defaultAxis; const parameters = { ...movement, axis, from: this.#axes[axis].value, period: this.#period, }; if (has(movement, 'to')) { const distance = movement.to - parameters.from; const absoluteDistance = Math.abs(distance); if (has(movement, 'duration')) { // { to: <number>, duration: <number> } parameters.speed = round(absoluteDistance / movement.duration, Ayva.precision); } else if (has(movement, 'speed')) { // { to: <number>, speed: <number> } // Uncomment the below to re-enable speed scaling. // const axisScale = 1 / Math.abs(this.#axes[axis].max - this.#axes[axis].min); // result.speed = movement.speed * axisScale; parameters.duration = round(absoluteDistance / parameters.speed, Ayva.precision); } parameters.direction = distance > 0 ? 1 : distance < 0 ? -1 : 0; // eslint-disable-line no-nested-ternary } if (has(parameters, 'duration')) { maxDuration = Math.max(parameters.duration, maxDuration); } return parameters; }); return { maxDuration, parameterObjects }; } #populateDurationAndStepCount (parameterObjects, maxDuration) { const movementsByAxis = parameterObjects.reduce((map, p) => { map[p.axis] = p; if (this.#axes[p.axis].alias) { map[this.#axes[p.axis].alias] = p; } return map; }, {}); parameterObjects.forEach((parameters) => { // We need to compute the duration for any movements we couldn't in the first pass. // This will be either implicit or explicit sync movements. if (has(parameters, 'sync')) { // Excplicit sync. let syncMovement = parameters; while (has(syncMovement, 'sync')) { syncMovement = movementsByAxis[syncMovement.sync]; } parameters.duration = syncMovement.duration || maxDuration; if (has(parameters, 'to')) { // Now we can compute a speed. parameters.speed = round(Math.abs(parameters.to - parameters.from) / parameters.duration, Ayva.precision); } } else if (!has(parameters, 'duration') && this.#axes[parameters.axis].type !== 'boolean') { // Implicit sync to max duration. parameters.duration = maxDuration; } if (has(parameters, 'duration')) { parameters.stepCount = Math.ceil(parameters.duration * this.#frequency); } // else if (this.#axes[movement.axis].type !== 'boolean') { // By this point, the only movements without a duration should be boolean. // This should literally never happen because of validation. But including here for debugging and clarity. // fail(`Unable to compute duration for movement along axis: ${movement.axis}`); // } }); } #computeMaxDurationAndStepCount (valueProviders) { let stepCount = 0; let duration = 0; valueProviders.forEach((provider) => { const nextStepCount = provider.parameters.stepCount; const nextDuration = provider.parameters.duration; if (nextStepCount) { stepCount = Math.max(nextStepCount, stepCount); } if (nextDuration) { duration = Math.max(nextDuration, duration); } }); return { duration, stepCount }; } #getAxesArray () { const uniqueAxes = {}; Object.values(this.#axes).forEach((axis) => { uniqueAxes[axis.name] = axis; }); function sortByName (a, b) { return a.name > b.name ? 1 : -1; } return Object.values(uniqueAxes).sort(sortByName); } #checkNotifyReady () { if (this.#sleepResolves.size === 0 && this.#movements.size === 0 && this.#readyResolves.size) { for (const resolve of this.#readyResolves) { resolve(); } this.#readyResolves.clear(); } } /** * Convert the function provided into a ramp function. * * @param {Function} fn * @returns the new ramp function */ static ramp (fn) { return (parameters) => { const { from, to } = parameters; return from + ((to - from) * fn(parameters)); }; } /** * Value provider that generates motion towards a target position with constant velocity. */ static RAMP_LINEAR (parameters) { const fn = ({ x }) => x; return Ayva.ramp(fn)(parameters); } /** * Value provider that generates motion towards a target position that resembles part of a cos wave (0 - 180 degrees). */ static RAMP_COS (parameters) { const fn = ({ x }) => (-Math.cos(Math.PI * x) / 2) + 0.5; return Ayva.ramp(fn)(parameters); } /** * Value provider that generates motion towards a target position in the shape of the latter half of a parabola. * This creates the effect of "falling" towards the target position. */ static RAMP_PARABOLIC (parameters) { const fn = ({ x }) => x * x; return Ayva.ramp(fn)(parameters); } /** * Value provider that generates motion towards a target position in the shape of the first half of an upside down parabola. * This creates the effect of "launching" towards the target position. */ static RAMP_NEGATIVE_PARABOLIC (parameters) { const fn = ({ x }) => -((x - 1) ** 2) + 1; return Ayva.ramp(fn)(parameters); } /** * Creates a value provider that generates oscillatory motion. The formula is: * * cos(θ + phase·π/2 + ecc·sin(θ + phase·π/2)) * * The result is translated and scaled to fit the range and beats per minute specified. * This formula was created by [Tempest MAx]{@link https://www.patreon.com/tempestvr}—loosely based * on orbital motion calculations. Hence, tempestMotion. * * See [this graph]{@link https://www.desmos.com/calculator/vnfke1rprt} of the function * where you can adjust the parameters to see how they affect the motion. * * @example * // Note: These examples use Move Builders from the Motion API. * * // Simple up/down stroke for 10 seconds. * ayva.$.stroke(Ayva.tempestMotion(1, 0), 10).execute(); * * // ... out of phase with a little eccentricity. * ayva.$.stroke(Ayva.tempestMotion(1, 0, 1, 0.2), 10).execute(); * * // ... at 30 BPM. * ayva.$.stroke(Ayva.tempestMotion(1, 0, 1, 0.2, 30), 10).execute(); * * @param {Number} from - the start of the range of motion * @param {Number} to - the end of the range of motion * @param {Number} [phase] - the phase of the wave in multiples of π/2 * @param {Number} [ecc] - the eccentricity of the wave * @param {Number} [bpm] - beats per minute * @param {Number} [shift] - additional phase shift of the wave in radians * @returns the value provider. *//** * Creates a value provider that generates oscillatory motion. The formula is: * * cos(θ + phase·π/2 + ecc·sin(θ + phase·π/2)) * * The result is translated and scaled to fit the range and beats per minute specified. * This formula was created by [Tempest MAx]{@link https://www.patreon.com/tempestvr}—loosely based * on orbital motion calculations. Hence, tempestMotion. * * See [this graph]{@link https://www.desmos.com/calculator/vnfke1rprt} of the function * where you can adjust the parameters to see how they affect the motion. * * @example * // Note: These examples use Move Builders from the Motion API. * * // Simple up/down stroke for 10 seconds. * ayva.$.stroke(Ayva.tempestMotion({ from: 1, to: 0}), 10).execute(); * * // ... out of phase with a little eccentricity. * ayva.$.stroke(Ayva.tempestMotion({ from: 1, to: 0, phase: 1, ecc: 0.2 }), 10).execute(); * * // ... at 30 BPM. * ayva.$.stroke(Ayva.tempestMotion({ from: 1, to: 0, phase: 1, ecc: 0.2, bpm: 30 }), 10).execute(); * * @param {Object} params - the parameters of the motion. * @returns the value provider. */ static tempestMotion (from, to, phase = 0, ecc = 0, bpm = 60, shift = 0) { const params = typeof from === 'object' ? from : { from, to, phase, ecc, bpm, shift, }; return Ayva.#tempestMotion(params); } static #tempestMotion (params) { params = { // eslint-disable-line no-param-reassign from: 0, to: 1, phase: 0, ecc: 0, shift: 0, bpm: 60, ...params, }; validator.validateMotionParameters(params); const { from, to, phase, ecc, bpm, shift, } = params; const angularVelocity = (2 * Math.PI * bpm) / 60; const scale = 0.5 * (to - from); const midpoint = 0.5 * (to + from); const provider = ({ index, frequency }) => { const angle = (((index + 1) * angularVelocity) / frequency) + (0.5 * Math.PI * phase) + shift; return midpoint - scale * Math.cos(angle + (ecc * Math.sin(angle))); }; Ayva.#createConstantMotionProperties(provider, from, to, phase, ecc, bpm); return provider; } /** * Eccentric Parametric Oscillatory Parabolic Motion™ * * @param {Number} from - the start of the range of motion * @param {Number} to - the end of the range of motion * @param {Number} [phase] - the phase of the motion in multiples of π/2 * @param {Number} [ecc] - the eccentricity of the motion * @param {Number} [bpm] - beats per minute * @param {Number} [shift] - additional phase shift of the motion in radians * @returns the value provider *//** * Eccentric Parametric Oscillatory Parabolic Motion™ * * @param {Object} params - the parameters of the motion. * @returns the value provider */ static parabolicMotion (from, to, phase = 0, ecc = 0, bpm = 60, shift = 0) { const params = typeof from === 'object' ? from : { from, to, phase, ecc, bpm, shift, }; return Ayva.#parabolicMotion(params); } static #parabolicMotion (params) { // TODO: Thou shalt not repeat thyself. params = { // eslint-disable-line no-param-reassign from: 0, to: 1, phase: 0, ecc: 0, shift: 0, bpm: 60, ...params, }; validator.validateMotionParameters(params); const { from, to, phase, ecc, bpm, shift, } = params; const { sin, PI } = Math; const angularVelocity = (2 * PI * bpm) / 60; const scale = to - from; const offset = to; const mod = (a, b) => ((a % b) + b) % b; // Proper mathematical modulus operator. const provider = ({ index, frequency }) => { const angle = (((index + 1) * angularVelocity) / frequency) + (0.5 * PI * phase) + shift; const x = (mod(angle, (2 * PI)) / PI) - 1 + (ecc / PI) * sin(angle); return offset - scale * x * x; }; Ayva.#createConstantMotionProperties(provider, from, to, phase, ecc, bpm); return provider; } /** * Eccentric Parametric Oscillatory Linear Motion™ * * @param {Number} from - the start of the range of motion * @param {Number} to - the end of the range of motion * @param {Number} [phase] - the phase of the motion in multiples of π/2 * @param {Number} [ecc] - the eccentricity of the motion * @param {Number} [bpm] - beats per minute * @param {Number} [shift] - additional phase shift of the motion in radians * @returns the value provider *//** * Eccentric Parametric Oscillatory Linear Motion™ * * @param {Object} params - the parameters of the motion. * @returns the value provider */ static linearMotion (from, to, phase = 0, ecc = 0, bpm = 60, shift = 0) { const params = typeof from === 'object' ? from : { from, to, phase, ecc, bpm, shift, }; return Ayva.#linearMotion(params); } static #linearMotion (params) { // TODO: Thou shalt not repeat thyself. params = { // eslint-disable-line no-param-reassign from: 0, to: 1, phase: 0, ecc: 0, shift: 0, bpm: 60, ...params, }; validator.validateMotionParameters(params); const { from, to, phase, ecc, bpm, shift, } = params; const { abs, sin, PI } = Math; const angularVelocity = (2 * PI * bpm) / 60; const scale = to - from; const offset = to; const mod = (a, b) => ((a % b) + b) % b; // Proper mathematical modulus operator. const provider = ({ index, frequency }) => { const angle = (((index + 1) * angularVelocity) / frequency) + (0.5 * PI * phase) + shift; const x = (mod(angle, (2 * PI)) / PI) - 1 + (ecc / PI) * sin(angle); return offset - scale * abs(x); }; Ayva.#createConstantMotionProperties(provider, from, to, phase, ecc, bpm); return provider; } static #createConstantMotionProperties (provider, from, to, phase, ecc, bpm) { createConstantProperty(provider, 'from', from); createConstantProperty(provider, 'to', to); createConstantProperty(provider, 'phase', phase); createConstantProperty(provider, 'ecc', ecc); createConstantProperty(provider, 'bpm', bpm); } /** * Creates a value provider that is a blend of the two value providers passed. * The factor is the multiplier for the values generated by the second provider. * The first provider's values will be multiplied by 1 - factor. * * @param {Function} firstProvider * @param {Function} secondProvider * @param {Number} factor - value between 0 and 1 */ static blendMotion (firstProvider, secondProvider, factor) { return (...args) => round((1 - factor) * firstProvider(...args) + factor * secondProvider(...args), Ayva.precision); } /** * Return a copy of the default configuration. */ static get defaultConfiguration () { return { ...OSR_CONFIG, }; } /** * Maps a value from one range to another. The default target range is [0, 1]. * Does not constrain values to within the range. * * This function is analagous to the map() function in the arduino's math library: * * {@link https://www.arduino.cc/reference/en/language/functions/math/map/} * * @param {Number} value * @param {Number} inMin * @param {Number} inMax * @param {Number} [outMin=0] * @param {Number} [outMax=1] */ static map (value, inMin, inMax, outMin = 0, outMax = 1) { return ((value - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin; } } // Separate default export from the class declaration because of jsdoc shenanigans... export default Ayva;