Refactoring round one: HttpFontStore/HttpSoundStore/HttpTextureStore collapse into a generic HttpFileStore over a FileStore contract; fontImport/soundImport/textureImport share assetImport with an ImportKind per sort; ImagePicker/FontPicker/SoundPicker sit on FilePicker and describe only their row. The group/comment drag logic leaves GraphCanvas for annotationDrag.ts (with its own unit test), declaredRows.ts replaces the duplicated row build in particles.ts and shaders.ts, and dead code in font/sound/compile/emission/transform/actions is gone. README: asset list, check count 958, the two-job CI, the Scenes wiki page. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
449 lines
18 KiB
TypeScript
449 lines
18 KiB
TypeScript
/**
|
||
* A particle asset is a graph that says what one particle *is*, as a function of its age and of a
|
||
* random number drawn once for it – not what forces act on it. That choice is what these tests
|
||
* pin: the same second always looks the same, and the whole field is a function of the clock.
|
||
*/
|
||
import { describe, expect, it } from 'vitest';
|
||
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||
import { AddNodeCommand, ConnectCommand, MoveNodeCommand } from '../../src/core/commands/graphCommands';
|
||
import { isPlaceableType } from '../../src/core/model/AssetTypes';
|
||
import { findAssetOf } from '../../src/core/model/Project';
|
||
import { graphFingerprint } from '../../src/core/graph/Graph';
|
||
import { compileParticles, emptyParticleOut, type ParticleOut } from '../../src/core/particles/compile';
|
||
import {
|
||
birthTime,
|
||
fieldTime,
|
||
type LiveRange,
|
||
liveRange,
|
||
prewarmOffset,
|
||
} from '../../src/core/particles/emission';
|
||
import {
|
||
hashId,
|
||
mixColor,
|
||
PARTICLE_NODES,
|
||
PARTICLE_OUTPUT,
|
||
PARTICLE_OUTPUTS,
|
||
PARTICLE_RANDOM,
|
||
type ParticleEnv,
|
||
} from '../../src/core/particles/nodes';
|
||
import {
|
||
BURST_COUNT_KEY,
|
||
EMITTING_KEY,
|
||
isValidParticlePropertyName,
|
||
LIFETIME_KEY,
|
||
MAX_PARTICLES_KEY,
|
||
PARTICLE_PROPERTY,
|
||
particleParamDefs,
|
||
PREWARM_KEY,
|
||
RATE_KEY,
|
||
TIME_SCALE_KEY,
|
||
} from '../../src/core/properties/particles';
|
||
import { schemaFor, schemaForAsset } from '../../src/core/properties/PropertySchema';
|
||
import { SHADER_KEY, SHADER_PROPERTY } from '../../src/core/properties/shaders';
|
||
import { paletteEntries } from '../../src/editor/graph/paletteEntries';
|
||
import { makeProject, type ProjectFixture, roundTrip } from '../helpers';
|
||
|
||
/** Command-driven editing of a particle asset's graph, the way the tab does it. */
|
||
function particlesEditor(f: ProjectFixture, name = 'Sparks') {
|
||
const create = new CreateAssetCommand('particles', name);
|
||
f.stack.execute(create);
|
||
const id = create.asset.id;
|
||
const asset = () => findAssetOf(f.project, id, 'particles')!;
|
||
const add = (type: string, params = {}) => {
|
||
const command = new AddNodeCommand(id, type, 0, 0, params);
|
||
f.stack.execute(command);
|
||
return command.node.id;
|
||
};
|
||
const link = (from: string, fromPort: string, to: string, toPort: string) =>
|
||
f.stack.execute(new ConnectCommand(id, { node: from, port: fromPort }, { node: to, port: toPort }));
|
||
return { id, asset, add, link };
|
||
}
|
||
|
||
const env = (over: Partial<ParticleEnv> = {}): ParticleEnv => ({
|
||
age: 0,
|
||
ageSeconds: 0,
|
||
time: 0,
|
||
index: 0,
|
||
seed: 0,
|
||
color: 0xffffff,
|
||
params: [],
|
||
...over,
|
||
});
|
||
|
||
function run(p: ReturnType<typeof particlesEditor>, at: Partial<ParticleEnv>): ParticleOut {
|
||
const build = compileParticles(p.asset().graph);
|
||
expect(build.reason).toBe('');
|
||
const out = emptyParticleOut();
|
||
build.compiled!.evaluate(env(at), out);
|
||
return out;
|
||
}
|
||
|
||
describe('a particle asset', () => {
|
||
it('is placeable, carries a graph, and starts out with nothing to compile', () => {
|
||
const f = makeProject();
|
||
const p = particlesEditor(f);
|
||
expect(isPlaceableType('particles')).toBe(true);
|
||
expect(p.asset().graph).toEqual({ nodes: [], edges: [] });
|
||
expect(compileParticles(p.asset().graph).reason).toBe('No Output node');
|
||
// The source's own rows are there from the start; the graph adds to them.
|
||
expect(schemaFor('particles').map((d) => d.key)).toContain(RATE_KEY);
|
||
expect(schemaFor('particles').map((d) => d.key)).toContain(EMITTING_KEY);
|
||
});
|
||
|
||
it('leaves every unconnected output at what a particle without a graph would be', () => {
|
||
const f = makeProject();
|
||
const p = particlesEditor(f);
|
||
p.add(PARTICLE_OUTPUT);
|
||
const out = run(p, { age: 0.5, color: 0x336699 });
|
||
expect(out).toEqual({
|
||
x: 0,
|
||
y: 0,
|
||
scaleX: 1,
|
||
scaleY: 1,
|
||
rotation: 0,
|
||
// Unconnected, the colour is the source's own row – not a hard-coded white.
|
||
color: 0x336699,
|
||
alpha: 1,
|
||
});
|
||
});
|
||
|
||
it('fades a particle out over its life when Age feeds alpha through Subtract', () => {
|
||
const f = makeProject();
|
||
const p = particlesEditor(f);
|
||
const output = p.add(PARTICLE_OUTPUT);
|
||
const age = p.add('ParticleAge');
|
||
const fade = p.add('ParticleSubtract', { a: 1 });
|
||
p.link(age, 'age', fade, 'b');
|
||
p.link(fade, 'result', output, 'alpha');
|
||
expect(run(p, { age: 0 }).alpha).toBe(1);
|
||
expect(run(p, { age: 0.25 }).alpha).toBe(0.75);
|
||
expect(run(p, { age: 1 }).alpha).toBe(0);
|
||
});
|
||
|
||
it('falls by ½gt², which is the whole point of a formula instead of a force', () => {
|
||
const f = makeProject();
|
||
const p = particlesEditor(f);
|
||
const output = p.add(PARTICLE_OUTPUT);
|
||
const gravity = p.add('ParticleGravity', { strength: 800 });
|
||
p.link(gravity, 'y', output, 'y');
|
||
expect(run(p, { ageSeconds: 0 }).y).toBe(0);
|
||
expect(run(p, { ageSeconds: 0.5 }).y).toBe(100);
|
||
expect(run(p, { ageSeconds: 1 }).y).toBe(400);
|
||
});
|
||
|
||
it('aims Start velocity by degrees, 0 to the right and 90 down', () => {
|
||
const f = makeProject();
|
||
const p = particlesEditor(f);
|
||
const output = p.add(PARTICLE_OUTPUT);
|
||
const v = p.add('ParticleStartVelocity', { angle: 90, speed: 200 });
|
||
p.link(v, 'x', output, 'x');
|
||
p.link(v, 'y', output, 'y');
|
||
const out = run(p, { ageSeconds: 0.5 });
|
||
expect(out.x).toBeCloseTo(0, 10);
|
||
expect(out.y).toBeCloseTo(100, 10);
|
||
});
|
||
|
||
it('draws one random number per particle and a different one per Random node', () => {
|
||
const f = makeProject();
|
||
const p = particlesEditor(f);
|
||
const output = p.add(PARTICLE_OUTPUT);
|
||
const a = p.add(PARTICLE_RANDOM, { min: 0, max: 100 });
|
||
const b = p.add(PARTICLE_RANDOM, { min: 0, max: 100 });
|
||
p.link(a, 'value', output, 'x');
|
||
p.link(b, 'value', output, 'y');
|
||
const first = run(p, { seed: 7, ageSeconds: 0 });
|
||
// The same particle at a later moment: its draw has not moved.
|
||
const later = run(p, { seed: 7, ageSeconds: 9, age: 0.9 });
|
||
expect(later.x).toBe(first.x);
|
||
// Two nodes, two draws – otherwise direction and speed of one particle would be the same number.
|
||
expect(first.y).not.toBe(first.x);
|
||
// A different particle draws differently.
|
||
expect(run(p, { seed: 8 }).x).not.toBe(first.x);
|
||
// And so does the same index at another source: the drawing party's salt goes into the seed,
|
||
// which is what keeps two fountains of one asset from being the same fountain twice.
|
||
expect(run(p, { seed: 7 ^ hashId('source-b') }).x).not.toBe(first.x);
|
||
});
|
||
|
||
it('feeds one node into two without evaluating it twice differently', () => {
|
||
const f = makeProject();
|
||
const p = particlesEditor(f);
|
||
const output = p.add(PARTICLE_OUTPUT);
|
||
const r = p.add(PARTICLE_RANDOM, { min: 0, max: 100 });
|
||
p.link(r, 'value', output, 'x');
|
||
p.link(r, 'value', output, 'y');
|
||
const out = run(p, { seed: 3 });
|
||
expect(out.x).toBe(out.y);
|
||
});
|
||
|
||
it('declares a row per Property node and reads its value from the source', () => {
|
||
const f = makeProject();
|
||
const p = particlesEditor(f);
|
||
const output = p.add(PARTICLE_OUTPUT);
|
||
p.add(PARTICLE_PROPERTY, { name: 'speed', default: 3, min: 0, max: 10 });
|
||
const second = p.add(PARTICLE_PROPERTY, { name: 'spread', default: 1, min: 0, max: 2 });
|
||
p.link(second, 'value', output, 'x');
|
||
const rows = particleParamDefs(p.asset().graph);
|
||
expect(rows.map((r) => r.key)).toEqual(['speed', 'spread']);
|
||
expect(schemaForAsset(f.project, p.asset()).map((d) => d.key)).toEqual([
|
||
...schemaFor('particles').map((d) => d.key),
|
||
'speed',
|
||
'spread',
|
||
]);
|
||
// `spread` is the second row, so it reads slot 1 – not slot 0, and not by name per particle.
|
||
expect(run(p, { params: [99, 42] }).x).toBe(42);
|
||
});
|
||
|
||
it('refuses a parameter named after one of the source’s own rows', () => {
|
||
const f = makeProject();
|
||
const p = particlesEditor(f);
|
||
p.add(PARTICLE_OUTPUT);
|
||
const bad = p.add(PARTICLE_PROPERTY, { name: RATE_KEY });
|
||
const build = compileParticles(p.asset().graph);
|
||
expect(build.compiled).toBeNull();
|
||
expect(build.problems[bad]).toContain('Name must be one word');
|
||
expect(particleParamDefs(p.asset().graph)).toEqual([]);
|
||
});
|
||
|
||
it('names the node that feeds itself instead of hanging', () => {
|
||
const f = makeProject();
|
||
const p = particlesEditor(f);
|
||
const output = p.add(PARTICLE_OUTPUT);
|
||
const a = p.add('ParticleAdd');
|
||
const b = p.add('ParticleAdd');
|
||
p.link(a, 'result', b, 'a');
|
||
p.link(b, 'result', a, 'a');
|
||
p.link(a, 'result', output, 'x');
|
||
const build = compileParticles(p.asset().graph);
|
||
expect(build.compiled).toBeNull();
|
||
expect(Object.values(build.problems)).toContain('Feeds itself');
|
||
});
|
||
|
||
it('keeps the fingerprint when a node only moves, and changes it on a parameter', () => {
|
||
const f = makeProject();
|
||
const p = particlesEditor(f);
|
||
const output = p.add(PARTICLE_OUTPUT);
|
||
const n = p.add('ParticleNumber', { value: 2 });
|
||
p.link(n, 'value', output, 'x');
|
||
const before = graphFingerprint(p.asset().graph);
|
||
f.stack.execute(new MoveNodeCommand(p.id, n, 120, 80));
|
||
expect(graphFingerprint(p.asset().graph)).toBe(before);
|
||
f.stack.execute(new AddNodeCommand(p.id, 'ParticleNumber', 0, 0, { value: 5 }));
|
||
expect(graphFingerprint(p.asset().graph)).not.toBe(before);
|
||
});
|
||
|
||
it('survives a round trip through the project file, graph and rows intact', () => {
|
||
const f = makeProject();
|
||
const p = particlesEditor(f);
|
||
const output = p.add(PARTICLE_OUTPUT);
|
||
p.add(PARTICLE_PROPERTY, { name: 'speed', default: 3, min: 0, max: 10 });
|
||
const g = p.add('ParticleGravity', { strength: 400 });
|
||
p.link(g, 'y', output, 'y');
|
||
const back = roundTrip(f.project);
|
||
const asset = findAssetOf(back, p.id, 'particles')!;
|
||
expect(asset.graph.nodes).toHaveLength(3);
|
||
expect(asset.graph.edges).toHaveLength(1);
|
||
expect(particleParamDefs(asset.graph).map((r) => r.key)).toEqual(['speed']);
|
||
const out = emptyParticleOut();
|
||
compileParticles(asset.graph).compiled!.evaluate(env({ ageSeconds: 1 }), out);
|
||
expect(out.y).toBe(200);
|
||
});
|
||
|
||
it('offers only particle nodes in its palette, and the Output only while there is none', () => {
|
||
const f = makeProject();
|
||
const p = particlesEditor(f);
|
||
const before = paletteEntries(f.project, p.asset());
|
||
expect(before.every((e) => e.def.category === 'particles')).toBe(true);
|
||
expect(before.map((e) => e.id)).toContain(PARTICLE_OUTPUT);
|
||
expect(before).toHaveLength(PARTICLE_NODES.length);
|
||
// A behavior node has no business here, and neither has a shader one.
|
||
expect(before.map((e) => e.id)).not.toContain('Branch');
|
||
p.add(PARTICLE_OUTPUT);
|
||
expect(paletteEntries(f.project, p.asset()).map((e) => e.id)).not.toContain(PARTICLE_OUTPUT);
|
||
});
|
||
|
||
it('numbers the Property nodes it places from the palette', () => {
|
||
const f = makeProject();
|
||
const p = particlesEditor(f);
|
||
const first = paletteEntries(f.project, p.asset()).find((e) => e.id === PARTICLE_PROPERTY)!;
|
||
expect(first.params.name).toBe('value');
|
||
p.add(PARTICLE_PROPERTY, first.params);
|
||
const second = paletteEntries(f.project, p.asset()).find((e) => e.id === PARTICLE_PROPERTY)!;
|
||
expect(second.params.name).toBe('value2');
|
||
});
|
||
});
|
||
|
||
const liveCount = (range: LiveRange): number => Math.max(0, range.to - range.from + 1);
|
||
|
||
describe('when particles are alive', () => {
|
||
const settings = {
|
||
mode: 'continuous' as const,
|
||
rate: 10,
|
||
count: 20,
|
||
lifetime: 1,
|
||
timeScale: 1,
|
||
prewarm: false,
|
||
max: 500,
|
||
};
|
||
|
||
it('puts a continuous source on a fixed grid, so the field is a function of the clock', () => {
|
||
// Particle n is born at n/rate: at 2 s exactly, particles 11..20 are the ones within a lifetime.
|
||
expect(birthTime(settings, 15)).toBe(1.5);
|
||
expect(liveRange(settings, 2)).toEqual({ from: 11, to: 20 });
|
||
expect(liveCount(liveRange(settings, 2))).toBe(10);
|
||
// Before the first one is due there is nothing, and one frame in there is exactly one.
|
||
expect(liveCount(liveRange(settings, 0))).toBe(1);
|
||
expect(liveCount(liveRange(settings, 0.05))).toBe(1);
|
||
});
|
||
|
||
it('drops the oldest when the cap is reached, never the newest', () => {
|
||
expect(liveRange({ ...settings, max: 4 }, 2)).toEqual({ from: 17, to: 20 });
|
||
});
|
||
|
||
it('lets the ones in flight live out their lives after emission stops', () => {
|
||
// Emission stopped at 1 s; at 1.5 s only what was born after 0.5 s is still alive.
|
||
expect(liveRange(settings, 1.5, 1)).toEqual({ from: 6, to: 10 });
|
||
// A whole lifetime after the stop there is nothing left.
|
||
expect(liveCount(liveRange(settings, 2.01, 1))).toBe(0);
|
||
});
|
||
|
||
it('sends a burst out at once and ends it one lifetime later', () => {
|
||
const burst = { ...settings, mode: 'burst' as const, count: 30 };
|
||
expect(birthTime(burst, 29)).toBe(0);
|
||
expect(liveRange(burst, 0.5)).toEqual({ from: 0, to: 29 });
|
||
expect(liveCount(liveRange(burst, 1))).toBe(0);
|
||
// The cap holds for a burst too.
|
||
expect(liveRange({ ...burst, max: 8 }, 0.5)).toEqual({ from: 0, to: 7 });
|
||
});
|
||
|
||
it('runs the source clock at its own speed, which is all a time scale is', () => {
|
||
const fast = { ...settings, timeScale: 2 };
|
||
expect(fieldTime(fast, 1)).toBe(2);
|
||
// Twice the speed is the same picture half the time earlier, particle for particle.
|
||
expect(liveRange(fast, fieldTime(fast, 1))).toEqual(liveRange(settings, fieldTime(settings, 2)));
|
||
// A scale of zero holds the second it starts on rather than emptying the field.
|
||
expect(fieldTime({ ...settings, timeScale: 0 }, 99)).toBe(0);
|
||
});
|
||
|
||
it('starts a prewarmed source one lifetime in, where the field is already full', () => {
|
||
const warm = { ...settings, prewarm: true };
|
||
expect(fieldTime(warm, 0)).toBe(settings.lifetime);
|
||
// Its very first frame carries a whole lifetime of particles; a cold one carries the first.
|
||
expect(liveCount(liveRange(warm, fieldTime(warm, 0)))).toBe(10);
|
||
expect(liveCount(liveRange(settings, fieldTime(settings, 0)))).toBe(1);
|
||
// And it is the steady state, not a spike: a second later there are as many.
|
||
expect(liveCount(liveRange(warm, fieldTime(warm, 1)))).toBe(10);
|
||
});
|
||
|
||
it('leaves a burst alone, which one lifetime in would be over', () => {
|
||
const burst = { ...settings, mode: 'burst' as const, prewarm: true };
|
||
expect(prewarmOffset(burst)).toBe(0);
|
||
expect(liveCount(liveRange(burst, fieldTime(burst, 0)))).toBe(20);
|
||
});
|
||
|
||
it('counts the prewarm in the seconds a lifetime is in, not in the world clock', () => {
|
||
// Scaled first, then offset: at twice the speed one second in is the third second of the field.
|
||
expect(fieldTime({ ...settings, prewarm: true, timeScale: 2 }, 1)).toBe(3);
|
||
});
|
||
|
||
it('emits nothing without a rate, a lifetime or a future', () => {
|
||
expect(liveCount(liveRange({ ...settings, rate: 0 }, 2))).toBe(0);
|
||
expect(liveCount(liveRange({ ...settings, lifetime: 0 }, 2))).toBe(0);
|
||
expect(liveCount(liveRange(settings, -1))).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('a particle colour', () => {
|
||
it('blends channel by channel, which a plain number mix cannot', () => {
|
||
expect(mixColor(0xff0000, 0x0000ff, 0)).toBe(0xff0000);
|
||
expect(mixColor(0xff0000, 0x0000ff, 1)).toBe(0x0000ff);
|
||
// Half way is dark magenta – mixing the packed numbers would have run through green.
|
||
expect(mixColor(0xff0000, 0x0000ff, 0.5)).toBe(0x800080);
|
||
expect(mixColor(0x000000, 0xffffff, 2)).toBe(0xffffff);
|
||
});
|
||
});
|
||
|
||
describe('the source rows', () => {
|
||
it('name every setting the emission rule reads', () => {
|
||
const keys = schemaFor('particles').map((d) => d.key);
|
||
for (const key of [
|
||
RATE_KEY,
|
||
BURST_COUNT_KEY,
|
||
LIFETIME_KEY,
|
||
MAX_PARTICLES_KEY,
|
||
EMITTING_KEY,
|
||
TIME_SCALE_KEY,
|
||
PREWARM_KEY,
|
||
])
|
||
expect(keys).toContain(key);
|
||
// A graph parameter may not take one of those names, or the inspector would show it twice.
|
||
expect(isValidParticlePropertyName(TIME_SCALE_KEY)).toBe(false);
|
||
expect(isValidParticlePropertyName(PREWARM_KEY)).toBe(false);
|
||
});
|
||
|
||
it('has one Output input per drawn value', () => {
|
||
expect([...PARTICLE_OUTPUTS]).toEqual(['x', 'y', 'scaleX', 'scaleY', 'rotation', 'color', 'alpha']);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* A source wears a shader the way a sprite does (#88) – but it is the only asset drawing its rows
|
||
* from two graphs at once, so what a name means when both declare it is pinned here.
|
||
*/
|
||
describe('a source wearing a shader', () => {
|
||
function wearing(uniform = 'glow') {
|
||
const f = makeProject();
|
||
const p = particlesEditor(f);
|
||
const create = new CreateAssetCommand('shader', 'Glow');
|
||
f.stack.execute(create);
|
||
const shaderId = create.asset.id;
|
||
const declared = { name: uniform, default: 3, min: 0, max: 10 };
|
||
f.stack.execute(new AddNodeCommand(shaderId, SHADER_PROPERTY, 0, 0, declared));
|
||
const keys = () => schemaForAsset(f.project, p.asset()).map((d) => d.key);
|
||
const wear = () => f.stack.execute(new SetAssetPropertyCommand(p.id, SHADER_KEY, shaderId));
|
||
return { ...f, p, shaderId, keys, wear };
|
||
}
|
||
|
||
it('has the picker, and the shader’s rows once one is picked', () => {
|
||
const f = wearing();
|
||
expect(schemaFor('particles').find((d) => d.key === SHADER_KEY)).toMatchObject({
|
||
kind: 'asset',
|
||
assetTypes: ['shader'],
|
||
default: '',
|
||
});
|
||
expect(f.keys()).not.toContain('glow');
|
||
f.wear();
|
||
expect(f.keys().at(-1)).toBe('glow');
|
||
expect(schemaForAsset(f.project, f.p.asset()).at(-1)).toMatchObject({ kind: 'number', default: 3 });
|
||
});
|
||
|
||
it('keeps a name it already has: the uniform gets no second row', () => {
|
||
const f = wearing(RATE_KEY);
|
||
f.wear();
|
||
// One row named `rate`, and it is the source's own – the uniform stays on its declared default.
|
||
expect(f.keys().filter((k) => k === RATE_KEY)).toEqual([RATE_KEY]);
|
||
expect(schemaForAsset(f.project, f.p.asset()).find((d) => d.key === RATE_KEY)).toMatchObject({
|
||
label: 'Rate',
|
||
default: 20,
|
||
});
|
||
});
|
||
|
||
it('takes a name the particle graph declares, too', () => {
|
||
const f = wearing('spin');
|
||
f.p.add(PARTICLE_PROPERTY, { name: 'spin', default: 7 });
|
||
f.wear();
|
||
expect(f.keys().filter((k) => k === 'spin')).toEqual(['spin']);
|
||
expect(schemaForAsset(f.project, f.p.asset()).find((d) => d.key === 'spin')).toMatchObject({
|
||
default: 7,
|
||
});
|
||
});
|
||
|
||
it('carries the shader and its values through a save', () => {
|
||
const f = wearing();
|
||
f.wear();
|
||
f.stack.execute(new SetAssetPropertyCommand(f.p.id, 'glow', 5));
|
||
const loaded = findAssetOf(roundTrip(f.project), f.p.id, 'particles')!;
|
||
expect(loaded.props[SHADER_KEY]).toBe(f.shaderId);
|
||
expect(loaded.props.glow).toBe(5);
|
||
});
|
||
});
|