import { edgesInto, findNode, type Graph, type GraphNode } from '../graph/Graph'; import { nodeDef, type PortDef } from '../graph/NodeRegistry'; import type { FlatDef } from '../properties/PropertySchema'; import { isParticlePropertyNode, isValidParticlePropertyName, particleParamDefs, propertyNameOf, } from '../properties/particles'; import { isParticleNodeDef, PARTICLE_OUTPUT, PARTICLE_OUTPUTS, PARTICLE_TIME, type ParticleEnv, type ParticleExpr, type ParticleOutputKey, } from './nodes'; /** What one particle is drawn with, everything relative to its source. */ export type ParticleOut = Record; export interface CompiledParticles { /** The rows the graph adds to every source placed from this asset, in node order. */ params: FlatDef<'number'>[]; /** A Time node reaches the Output: the whole field moves, not just the particles in it. */ time: boolean; /** Fills `out` with the seven values for this particle. */ evaluate(env: ParticleEnv, out: ParticleOut): void; } export interface ParticlesBuild { /** Null while the graph has a problem – the source then emits nothing. */ compiled: CompiledParticles | null; /** Per node id, what keeps the graph from compiling. */ problems: Record; /** One line for the tab: empty once compiled. */ reason: string; } /** * A particle graph to one function per output: every node reachable from the Output becomes one * local per output, in dependency order, so a node feeding two others is evaluated once per * particle. There is no interpreter and no eval – the compiler composes closures. */ export function compileParticles(graph: Graph): ParticlesBuild { const problems: Record = {}; const problem = (node: GraphNode, text: string) => { if (!(node.id in problems)) problems[node.id] = text; }; const names = new Set(); for (const node of graph.nodes) { if (!isParticlePropertyNode(node)) continue; const name = propertyNameOf(node); if (!isValidParticlePropertyName(name)) problem(node, 'Name must be one word: letters, digits, _ – and not one of the source’s own rows'); else if (names.has(name)) problem(node, `Another property is named "${name}"`); names.add(name); } const params = particleParamDefs(graph); const paramIndex = (name: string) => params.findIndex((row) => row.key === name); const ops: ParticleExpr[] = []; const emitted = new Map>(); const visiting = new Set(); const emitNode = (node: GraphNode): Record | null => { const done = emitted.get(node.id); if (done) return done; if (visiting.has(node.id)) { problem(node, 'Feeds itself'); return null; } const def = nodeDef(node.type); if (!def || !isParticleNodeDef(def)) { problem(node, 'Not a particle node'); return null; } visiting.add(node.id); const inputs: Record = {}; let complete = true; for (const port of def.inputs) { const expr = input(node, port); if (expr) inputs[port.key] = expr; else complete = false; } visiting.delete(node.id); if (!complete) return null; const outputs: Record = {}; for (const [key, expr] of Object.entries( def.emit(inputs, node.params, { nodeId: node.id, paramIndex }), )) { const slot = ops.length; ops.push(expr); outputs[key] = (_env, locals) => locals[slot]!; } emitted.set(node.id, outputs); return outputs; }; /** What feeds an input: the edge's source, the typed-in value, or what the node says (`unfed`). */ const input = (node: GraphNode, port: PortDef): ParticleExpr | null => { const edge = edgesInto(graph, node.id, port.key)[0]; if (edge) { const source = findNode(graph, edge.from.node); const outputs = source && emitNode(source); return outputs?.[edge.from.port] ?? null; } if (port.field) { const typed = Number(node.params[port.key] ?? port.default ?? 0); const value = Number.isFinite(typed) ? typed : 0; return () => value; } const def = nodeDef(node.type); const unfed = def && isParticleNodeDef(def) ? def.unfed?.[port.key] : undefined; return unfed ?? (() => 0); }; const output = graph.nodes.find((n) => n.type === PARTICLE_OUTPUT); if (!output) return { compiled: null, problems, reason: 'No Output node' }; const outputDef = nodeDef(PARTICLE_OUTPUT)!; const reads: ParticleExpr[] = []; for (const key of PARTICLE_OUTPUTS) { const port = outputDef.inputs.find((p) => p.key === key)!; const expr = input(output, port); if (!expr) break; reads.push(expr); } const first = graph.nodes.find((n) => n.id in problems); if (first || reads.length !== PARTICLE_OUTPUTS.length) return { compiled: null, problems, reason: first ? problems[first.id]! : 'Cannot compile' }; const locals = new Float64Array(ops.length); const time = graph.nodes.some((n) => n.type === PARTICLE_TIME && emitted.has(n.id)); const compiled: CompiledParticles = { params, time, evaluate(env, out) { // In dependency order, so every local a later op reads is already filled. for (let i = 0; i < ops.length; i++) locals[i] = ops[i]!(env, locals); for (let i = 0; i < PARTICLE_OUTPUTS.length; i++) out[PARTICLE_OUTPUTS[i]!] = reads[i]!(env, locals); }, }; return { compiled, problems, reason: '' }; } /** A particle whose graph says nothing: at the source, full size, the source's own colour. */ export function emptyParticleOut(): ParticleOut { return { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, color: 0xffffff, alpha: 1 }; }