Skip to content

packages/core/src/features/particles/lib/particle.ts

Read as Markdown

This is the source snapshot used to build these API details. View this revision on GitHub.

Back to reference

1 import type {
2   PibblParticleChoice,
3   PibblParticleCurve,
4   PibblParticleCurveOptions,
5   PibblParticleCurveValue,
6   PibblParticleGradient,
7   PibblParticleIntegerRange,
8   PibblParticleParameterReference,
9   PibblParticleRange,
10 } from './types.js';
11 
12 export type ParticleDescriptorRecord =
13   | Readonly<{ type: 'between'; min: number; max: number }>
14   | Readonly<{ type: 'integer'; min: number; max: number }>
15   | Readonly<{ type: 'choice'; choices: readonly Readonly<{ value: unknown; weight: number }>[] }>
16   | Readonly<{ type: 'parameter'; name: string }>
17   | Readonly<{
18       type: 'curve';
19       keys: readonly (readonly [number, PibblParticleCurveValue])[];
20       options: PibblParticleCurveOptions | undefined;
21     }>
22   | Readonly<{
23       type: 'gradient';
24       keys: readonly (readonly [number, string])[];
25       options: PibblParticleCurveOptions | undefined;
26     }>;
27 
28 const descriptorRecords = new WeakMap<object, ParticleDescriptorRecord>();
29 
30 function descriptor<T extends object>(value: T, record: ParticleDescriptorRecord): T {
31   const frozen = Object.freeze(value);
32   descriptorRecords.set(frozen, Object.freeze(record));
33   return frozen;
34 }
35 
36 function assertFinite(value: number, label: string): void {
37   if (!Number.isFinite(value)) {
38     throw new RangeError(`${label} must be finite; received ${String(value)}.`);
39   }
40 }
41 
42 function cloneFrozenValue<T>(value: T, seen = new WeakMap<object, unknown>()): T {
43   if (value === null || typeof value !== 'object') return value;
44   const existing = seen.get(value);
45   if (existing !== undefined) return existing as T;
46   if (Array.isArray(value)) {
47     const result: unknown[] = [];
48     seen.set(value, result);
49     for (const entry of value) result.push(cloneFrozenValue(entry, seen));
50     return Object.freeze(result) as T;
51   }
52   const prototype = Object.getPrototypeOf(value);
53   if (prototype !== Object.prototype && prototype !== null) return value;
54   const result: Record<string, unknown> = Object.create(prototype);
55   seen.set(value, result);
56   for (const [key, entry] of Object.entries(value)) {
57     result[key] = cloneFrozenValue(entry, seen);
58   }
59   return Object.freeze(result) as T;
60 }
61 
62 function validateRange(min: number, max: number, label: string): void {
63   assertFinite(min, `${label} min`);
64   assertFinite(max, `${label} max`);
65   if (min > max) {
66     throw new RangeError(
67       `${label} bounds require min to be at most max; received ${String(min)} through ${String(max)}.`,
68     );
69   }
70 }
71 
72 function curveValueDimension(value: PibblParticleCurveValue): 1 | 2 {
73   if (typeof value === 'number') {
74     assertFinite(value, 'particleCurve value');
75     return 1;
76   }
77   if (!Array.isArray(value) || value.length !== 2) {
78     throw new TypeError('particleCurve values must be finite numbers or two-dimensional vectors.');
79   }
80   assertFinite(value[0], 'particleCurve vector value[0]');
81   assertFinite(value[1], 'particleCurve vector value[1]');
82   return 2;
83 }
84 
85 function validateKeyTimes(
86   keys: readonly (readonly [time: number, value: unknown])[],
87   label: string,
88 ): void {
89   if (keys.length === 0) throw new TypeError(`${label} keys must be nonempty.`);
90   if (keys[0]?.[0] !== 0 || keys.at(-1)?.[0] !== 1) {
91     throw new RangeError(`${label} keys must cover normalized time 0 through 1.`);
92   }
93   let previous = Number.NEGATIVE_INFINITY;
94   for (let index = 0; index < keys.length; index += 1) {
95     const time = keys[index]?.[0];
96     assertFinite(time as number, `${label} keys[${index}] time`);
97     if ((time as number) <= previous) {
98       throw new RangeError(`${label} key times must be strictly increasing.`);
99     }
100     previous = time as number;
101   }
102 }
103 
104 function normalizeOptions(
105   options: PibblParticleCurveOptions | undefined,
106   label: string,
107 ): PibblParticleCurveOptions | undefined {
108   if (options === undefined) return undefined;
109   if (options.easing !== undefined && typeof options.easing !== 'function') {
110     throw new TypeError(`${label} options.easing must be a Pibbl easing function.`);
111   }
112   return Object.freeze({ easing: options.easing });
113 }
114 
115 function between(min: number, max: number): PibblParticleRange {
116   validateRange(min, max, 'particleRange');
117   if (!Number.isFinite(max - min)) {
118     throw new RangeError('particleRange span must be finite.');
119   }
120   const value = { type: 'between' as const, min, max };
121   return descriptor(value, value);
122 }
123 
124 function integer(min: number, max: number): PibblParticleIntegerRange {
125   validateRange(min, max, 'particleInteger');
126   if (!Number.isSafeInteger(min) || !Number.isSafeInteger(max)) {
127     throw new RangeError('particleInteger min and max must be safe integers.');
128   }
129   if (!Number.isSafeInteger(max - min + 1)) {
130     throw new RangeError('particleInteger inclusive span must be a safe integer.');
131   }
132   const value = { type: 'integer' as const, min, max };
133   return descriptor(value, value);
134 }
135 
136 function choice<const T>(
137   choices: readonly Readonly<{ value: T; weight: number }>[],
138 ): PibblParticleChoice<T> {
139   if (choices.length === 0) {
140     throw new TypeError('particleChoice choices must be nonempty.');
141   }
142   let totalWeight = 0;
143   const copied = Object.freeze(choices.map(({ value, weight }, index) => {
144     if (!Number.isFinite(weight) || weight <= 0) {
145       throw new RangeError(
146         `particleChoice choices[${index}].weight must be positive and finite; received ${String(weight)}.`,
147       );
148     }
149     totalWeight += weight;
150     if (!Number.isFinite(totalWeight)) {
151       throw new RangeError('particleChoice total weight must be finite.');
152     }
153     return Object.freeze({ value: cloneFrozenValue(value), weight });
154   }));
155   const value = { type: 'choice' as const, choices: copied };
156   return descriptor(value, value as ParticleDescriptorRecord) as PibblParticleChoice<T>;
157 }
158 
159 function parameter<const Name extends string>(
160   name: Name,
161 ): PibblParticleParameterReference<Name> {
162   if (typeof name !== 'string' || name.trim().length === 0) {
163     throw new TypeError('particleParameter name must be a nonempty string.');
164   }
165   const value = { type: 'parameter' as const, name };
166   return descriptor(value, value);
167 }
168 
169 function curve<const T extends PibblParticleCurveValue>(
170   keys: readonly (readonly [time: number, value: T])[],
171   options?: PibblParticleCurveOptions,
172 ): PibblParticleCurve<T> {
173   validateKeyTimes(keys, 'particleCurve');
174   let dimension: 1 | 2 | undefined;
175   const copied = Object.freeze(keys.map(([time, value], index) => {
176     const currentDimension = curveValueDimension(value);
177     if (dimension !== undefined && dimension !== currentDimension) {
178       throw new TypeError(
179         `particleCurve keys[${index}] value dimension must match earlier keys.`,
180       );
181     }
182     dimension = currentDimension;
183     return Object.freeze([time, cloneFrozenValue(value)] as const);
184   }));
185   const normalizedOptions = normalizeOptions(options, 'particleCurve');
186   const value = {
187     type: 'curve' as const,
188     keys: copied,
189     ...(normalizedOptions === undefined ? {} : { options: normalizedOptions }),
190   };
191   return descriptor(value, {
192     type: 'curve', keys: copied, options: normalizedOptions,
193   }) as PibblParticleCurve<T>;
194 }
195 
196 function gradient(
197   keys: readonly (readonly [time: number, color: string])[],
198   options?: PibblParticleCurveOptions,
199 ): PibblParticleGradient {
200   validateKeyTimes(keys, 'particleGradient');
201   const copied = Object.freeze(keys.map(([time, color], index) => {
202     if (typeof color !== 'string' || color.trim().length === 0) {
203       throw new TypeError(`particleGradient keys[${index}] color must be a nonempty string.`);
204     }
205     return Object.freeze([time, color] as const);
206   }));
207   const normalizedOptions = normalizeOptions(options, 'particleGradient');
208   const value = {
209     type: 'gradient' as const,
210     keys: copied,
211     ...(normalizedOptions === undefined ? {} : { options: normalizedOptions }),
212   };
213   return descriptor(value, {
214     type: 'gradient', keys: copied, options: normalizedOptions,
215   });
216 }
217 
218 /** @internal Returns metadata only for descriptors created by this package instance. */
219 export function getParticleDescriptorRecord(value: unknown): ParticleDescriptorRecord | undefined {
220   return typeof value === 'object' && value !== null ? descriptorRecords.get(value) : undefined;
221 }
222 
223 /**
224  * Describes a continuous random range for particle sampling.
225  *
226  * @param min - Finite lower bound, no greater than max.
227  * @param max - Finite upper bound; the span must also be finite.
228  * @returns An immutable continuous sampling descriptor. See {@link PibblParticleRange}.
229  *
230  * @see {@link PibblParticleRange}
231  */
232 export const particleRange = between;
233 /**
234  * Describes a random integer range for particle sampling.
235  *
236  * @param min - Inclusive lower bound, a safe integer.
237  * @param max - Inclusive upper bound; the inclusive span must be a safe integer.
238  * @returns An immutable integer sampling descriptor. See {@link PibblParticleIntegerRange}.
239  *
240  * @see {@link PibblParticleIntegerRange}
241  */
242 export const particleInteger = integer;
243 /**
244  * Describes weighted choices sampled by a particle effect.
245  *
246  * @param choices - Nonempty weighted values; every weight and their total must be positive and
247  * finite.
248  * @returns An immutable descriptor containing copied choices. See {@link PibblParticleChoice}.
249  *
250  * @see {@link PibblParticleChoice}
251  */
252 export const particleChoice = choice;
253 /**
254  * References a named parameter declared by a particle effect.
255  *
256  * @param name - Nonempty name of a parameter declared by the effect.
257  * @returns A typed parameter reference. See {@link PibblParticleParameterReference}.
258  *
259  * @see {@link PibblParticleParameterReference}
260  */
261 export const particleParameter = parameter;
262 /**
263  * Describes keyframed scalar or vector values over normalized particle lifetime.
264  *
265  * @param keys - Scalar or 2D-vector keys with strictly increasing normalized times, starting at 0
266  * and ending at 1.
267  * @param options - Optional segment easing. See {@link PibblParticleCurveOptions}.
268  * @returns An immutable lifetime curve containing copied keys. See {@link PibblParticleCurve}.
269  *
270  * @see {@link PibblParticleCurveOptions}
271  * @see {@link PibblParticleCurve}
272  * @see {@link PibblParticleCurveValue}
273  */
274 export const particleCurve = curve;
275 /**
276  * Describes keyframed colors over normalized particle lifetime.
277  *
278  * @param keys - Color keys with strictly increasing normalized times, starting at 0 and ending at
279  * 1.
280  * @param options - Optional segment easing. See {@link PibblParticleCurveOptions}.
281  * @returns An immutable lifetime color gradient containing copied keys. See
282  * {@link PibblParticleGradient} .
283  *
284  * @see {@link PibblParticleCurveOptions}
285  * @see {@link PibblParticleGradient}
286  */
287 export const particleGradient = gradient;
288 
289 /** @internal Legacy test convenience; not exported from the public package entry. */
290 export const particle = Object.freeze({
291   between,
292   integer,
293   choice,
294   parameter,
295   curve,
296   gradient,
297 });
298 

Documentation built with @pibbl/core 0.0.2, revision 2dccb19. ALPHA — NOT FOR PRODUCTION USE.