packages/core/src/features/particles/lib/particle.ts
This is the source snapshot used to build these API details. View this revision on GitHub.
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 version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision 272a94a. ALPHA — NOT FOR PRODUCTION USE.