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>
154 lines
5.7 KiB
TypeScript
154 lines
5.7 KiB
TypeScript
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<ParticleOutputKey, number>;
|
||
|
||
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<string, string>;
|
||
/** 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<string, string> = {};
|
||
const problem = (node: GraphNode, text: string) => {
|
||
if (!(node.id in problems)) problems[node.id] = text;
|
||
};
|
||
|
||
const names = new Set<string>();
|
||
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<string, Record<string, ParticleExpr>>();
|
||
const visiting = new Set<string>();
|
||
|
||
const emitNode = (node: GraphNode): Record<string, ParticleExpr> | 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<string, ParticleExpr> = {};
|
||
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<string, ParticleExpr> = {};
|
||
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 };
|
||
}
|