diff --git a/src/core/commands/Command.ts b/src/core/commands/Command.ts index ecf118c3..420c6233 100644 --- a/src/core/commands/Command.ts +++ b/src/core/commands/Command.ts @@ -11,6 +11,25 @@ export interface Command { undo(project: Project): void; } +/** A rename that is undone by renaming back. */ +export abstract class RenameCommand implements Command { + constructor( + readonly label: string, + private readonly from: string, + private readonly to: string, + ) {} + + execute(project: Project): void { + this.rename(project, this.from, this.to); + } + + undo(project: Project): void { + this.rename(project, this.to, this.from); + } + + protected abstract rename(project: Project, from: string, to: string): void; +} + /** A rejected edit with a message fit for the user; the editor shows it as a notice. */ export class CommandError extends Error { constructor(message: string) { diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index 9b400f2d..970454ae 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -110,22 +110,24 @@ export function buildTextureAsset(name: string, image: TextureImage, id = newId( } /** - * The image is uploaded before the command runs; this only records the result, so undo and redo + * The file is uploaded before the command runs; this only records the result, so undo and redo * never touch the network. */ -export class CreateTextureCommand implements Command { +abstract class ImportFileCommand implements Command { readonly label: string; - readonly asset: TextureAsset; - constructor(name: string, image: TextureImage) { - this.asset = buildTextureAsset(name, image); - this.label = `Import texture "${name}"`; + constructor( + readonly asset: A, + private readonly fileNoun: string, + private readonly isStored: (file: string) => boolean, + ) { + this.label = `Import ${asset.type} "${asset.name}"`; } execute(project: Project): void { // The name is the hash the server derived from the bytes; anything else was never uploaded. - if (!isTextureFile(this.asset.file)) - throw new CommandError(`"${this.asset.file}" is not a stored image file`); + if (!this.isStored(this.asset.file)) + throw new CommandError(`"${this.asset.file}" is not a stored ${this.fileNoun} file`); project.assets.push(this.asset); } @@ -134,28 +136,19 @@ export class CreateTextureCommand implements Command { } } +export class CreateTextureCommand extends ImportFileCommand { + constructor(name: string, image: TextureImage) { + super(buildTextureAsset(name, image), 'image', isTextureFile); + } +} + export function buildFontAsset(name: string, file: string, id = newId()): FontAsset { return { id, name, props: defaultsFor('font'), type: 'font', file }; } -/** The typeface is uploaded before the command runs, as an image is for `CreateTextureCommand`. */ -export class CreateFontCommand implements Command { - readonly label: string; - readonly asset: FontAsset; - +export class CreateFontCommand extends ImportFileCommand { constructor(name: string, file: string) { - this.asset = buildFontAsset(name, file); - this.label = `Import font "${name}"`; - } - - execute(project: Project): void { - if (!isFontFile(this.asset.file)) - throw new CommandError(`"${this.asset.file}" is not a stored font file`); - project.assets.push(this.asset); - } - - undo(project: Project): void { - project.assets = removeById(project.assets, this.asset.id); + super(buildFontAsset(name, file), 'font', isFontFile); } } @@ -163,24 +156,9 @@ export function buildSoundAsset(name: string, file: string, id = newId()): Sound return { id, name, props: defaultsFor('sound'), type: 'sound', file }; } -/** The sound is uploaded before the command runs, as a typeface is for `CreateFontCommand`. */ -export class CreateSoundCommand implements Command { - readonly label: string; - readonly asset: SoundAsset; - +export class CreateSoundCommand extends ImportFileCommand { constructor(name: string, file: string) { - this.asset = buildSoundAsset(name, file); - this.label = `Import sound "${name}"`; - } - - execute(project: Project): void { - if (!isSoundFile(this.asset.file)) - throw new CommandError(`"${this.asset.file}" is not a stored sound file`); - project.assets.push(this.asset); - } - - undo(project: Project): void { - project.assets = removeById(project.assets, this.asset.id); + super(buildSoundAsset(name, file), 'sound', isSoundFile); } } diff --git a/src/core/commands/portCommands.ts b/src/core/commands/portCommands.ts index 86cd43a5..60b7d446 100644 --- a/src/core/commands/portCommands.ts +++ b/src/core/commands/portCommands.ts @@ -19,7 +19,7 @@ import { import type { PropertyValue } from '../model/Properties'; import type { Project } from '../model/Project'; import { type Removed, removeFirst, removeWhere, restoreAt } from '../util/list'; -import { type Command, CommandError } from './Command'; +import { type Command, CommandError, RenameCommand } from './Command'; import { nextFieldName } from './naming'; import { requireBehavior, requireNode, requireNodeDef } from './require'; @@ -260,27 +260,17 @@ export class SetPortWriteCommand implements Command { } /** Rewrites the edges and inline values pointing at the port too, in every graph that has any. */ -export class RenamePortCommand implements Command { - readonly label: string; - +export class RenamePortCommand extends RenameCommand { constructor( private readonly assetId: string, private readonly nodeId: string, - private readonly from: string, - private readonly to: string, + from: string, + to: string, ) { - this.label = `Rename ${from}`; + super(`Rename ${from}`, from, to); } - execute(project: Project): void { - this.rename(project, this.from, this.to); - } - - undo(project: Project): void { - this.rename(project, this.to, this.from); - } - - private rename(project: Project, from: string, to: string): void { + protected rename(project: Project, from: string, to: string): void { if (from === to) return; const node = requireNode(project, this.assetId, this.nodeId); const sites = portSites(project, this.assetId, node); diff --git a/src/core/commands/variableCommands.ts b/src/core/commands/variableCommands.ts index 1a804547..d213d037 100644 --- a/src/core/commands/variableCommands.ts +++ b/src/core/commands/variableCommands.ts @@ -17,7 +17,7 @@ import { variableDef, } from '../properties/PropertySchema'; import { type Removed, removeFirst, restoreAt } from '../util/list'; -import { type Command, CommandError } from './Command'; +import { type Command, CommandError, RenameCommand } from './Command'; import { renameVariablePorts } from './portCommands'; import { requireVariableOwner } from './require'; @@ -117,26 +117,16 @@ export class RemoveVariableCommand implements Command { } /** Renames everywhere: definition, default, instance overrides and the graph nodes referring to it. */ -export class RenameVariableCommand implements Command { - readonly label: string; - +export class RenameVariableCommand extends RenameCommand { constructor( private readonly assetId: string, - private readonly from: string, - private readonly to: string, + from: string, + to: string, ) { - this.label = `Rename variable ${from}`; + super(`Rename variable ${from}`, from, to); } - execute(project: Project): void { - this.rename(project, this.from, this.to); - } - - undo(project: Project): void { - this.rename(project, this.to, this.from); - } - - private rename(project: Project, from: string, to: string): void { + protected rename(project: Project, from: string, to: string): void { const asset = requireVariableOwner(project, this.assetId); if (from === to) return; if (!isValidVariableName(to)) throw new CommandError(INVALID_NAME); @@ -316,27 +306,17 @@ export class RemoveStructFieldCommand implements Command { } } -export class RenameStructFieldCommand implements Command { - readonly label: string; - +export class RenameStructFieldCommand extends RenameCommand { constructor( private readonly assetId: string, private readonly key: string, - private readonly from: string, - private readonly to: string, + from: string, + to: string, ) { - this.label = `Rename field ${from}`; + super(`Rename field ${from}`, from, to); } - execute(project: Project): void { - this.rename(project, this.from, this.to); - } - - undo(project: Project): void { - this.rename(project, this.to, this.from); - } - - private rename(project: Project, from: string, to: string): void { + protected rename(project: Project, from: string, to: string): void { const owner = requireFieldOwner(project, this.assetId, this.key); if (from === to) return; if (!isValidFieldName(to)) throw new CommandError(INVALID_FIELD); diff --git a/src/core/graph/GraphInterpreter.ts b/src/core/graph/GraphInterpreter.ts index 80cda921..56836aea 100644 --- a/src/core/graph/GraphInterpreter.ts +++ b/src/core/graph/GraphInterpreter.ts @@ -19,8 +19,8 @@ const MAX_STEPS_PER_FIRE = 100_000; /** * How far exec edges may nest. The interpreter recurses along them, so this is what keeps a flow - * feeding back into itself off the call stack – a job the step budget can no longer do now that a - * legitimate pulse may run tens of thousands of steps. + * feeding back into itself off the call stack – the step budget cannot, since a legitimate pulse + * may run tens of thousands of steps. */ const MAX_DEPTH = 1000; diff --git a/src/core/graph/emitGraph.ts b/src/core/graph/emitGraph.ts new file mode 100644 index 00000000..f719a1ec --- /dev/null +++ b/src/core/graph/emitGraph.ts @@ -0,0 +1,106 @@ +import { edgesInto, findNode, type Graph, type GraphNode } from './Graph'; +import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry'; + +export interface EmitRules { + isDef: (def: NodeDef) => def is D; + /** Names the node kind in "Not a … node". */ + noun: string; + /** A node's outputs from its inputs; called once per node, in dependency order. */ + emit: (def: D, inputs: Record, node: GraphNode) => Record; + /** An input without an edge: the typed-in value or what the node says it means unfed. */ + unfed: (node: GraphNode, port: PortDef, def: D | undefined) => T | null; + /** An edge's value on its way into `port`; null (after reporting a problem) when it cannot go in. */ + conform?: (node: GraphNode, port: PortDef, value: T) => T | null; +} + +export interface GraphEmitter { + /** Per node id, the first thing that keeps the graph from compiling. */ + readonly problems: Record; + problem(node: GraphNode, text: string): void; + /** What feeds an input, emitting whatever it depends on first. */ + input(node: GraphNode, port: PortDef): T | null; + /** Whether the walk reached the node. */ + reached(node: GraphNode): boolean; + /** Reports property names that are invalid (`invalid` returns the text) or taken twice. */ + checkNames( + nodes: GraphNode[], + nameOf: (node: GraphNode) => string, + invalid: (name: string) => string, + ): void; + /** The problem of the first node in graph order, or null when there is none. */ + firstProblem(): string | null; +} + +/** + * The walk shared by the graph compilers: every node reachable from where `input` is first asked + * is emitted once, before anything that reads it, and a node feeding itself is a problem. + */ +export function graphEmitter(graph: Graph, rules: EmitRules): GraphEmitter { + const problems: Record = {}; + const emitted = new Map>(); + const visiting = new Set(); + + const problem = (node: GraphNode, text: string) => { + if (!(node.id in problems)) problems[node.id] = text; + }; + + 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 || !rules.isDef(def)) { + problem(node, `Not a ${rules.noun} node`); + return null; + } + visiting.add(node.id); + const inputs: Record = {}; + let complete = true; + for (const port of def.inputs) { + const value = input(node, port); + if (value) inputs[port.key] = value; + else complete = false; + } + visiting.delete(node.id); + if (!complete) return null; + const outputs = rules.emit(def, inputs, node); + emitted.set(node.id, outputs); + return outputs; + }; + + const input = (node: GraphNode, port: PortDef): T | null => { + const edge = edgesInto(graph, node.id, port.key)[0]; + if (edge) { + const source = findNode(graph, edge.from.node); + const value = (source && emitNode(source))?.[edge.from.port]; + if (!value) return null; + return rules.conform ? rules.conform(node, port, value) : value; + } + const def = nodeDef(node.type); + return rules.unfed(node, port, def && rules.isDef(def) ? def : undefined); + }; + + return { + problems, + problem, + input, + reached: (node) => emitted.has(node.id), + checkNames(nodes, nameOf, invalid) { + const names = new Set(); + for (const node of nodes) { + const name = nameOf(node); + const text = invalid(name); + if (text) problem(node, text); + else if (names.has(name)) problem(node, `Another property is named "${name}"`); + names.add(name); + } + }, + firstProblem() { + const first = graph.nodes.find((n) => n.id in problems); + return first ? problems[first.id]! : null; + }, + }; +} diff --git a/src/core/particles/compile.ts b/src/core/particles/compile.ts index 2b0ce4cc..e2df539c 100644 --- a/src/core/particles/compile.ts +++ b/src/core/particles/compile.ts @@ -1,5 +1,6 @@ -import { edgesInto, findNode, type Graph, type GraphNode } from '../graph/Graph'; -import { nodeDef, type PortDef } from '../graph/NodeRegistry'; +import { graphEmitter } from '../graph/emitGraph'; +import type { Graph } from '../graph/Graph'; +import { nodeDef } from '../graph/NodeRegistry'; import type { FlatDef } from '../properties/PropertySchema'; import { isParticlePropertyNode, @@ -13,6 +14,7 @@ import { PARTICLE_OUTPUTS, PARTICLE_TIME, type ParticleEnv, + type ParticleNodeDef, type ParticleExpr, type ParticleOutputKey, } from './nodes'; @@ -44,79 +46,40 @@ export interface ParticlesBuild { * 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; - }; + const walk = graphEmitter(graph, { + isDef: isParticleNodeDef, + noun: 'particle', + emit: (def, inputs, node) => { + 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]!; + } + return outputs; + }, + unfed: (node, port, def) => { + if (port.field) { + const typed = Number(node.params[port.key] ?? port.default ?? 0); + const value = Number.isFinite(typed) ? typed : 0; + return () => value; + } + return def?.unfed?.[port.key] ?? (() => 0); + }, + }); + const { problems } = walk; - /** 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); - }; + walk.checkNames(graph.nodes.filter(isParticlePropertyNode), propertyNameOf, (name) => + isValidParticlePropertyName(name) + ? '' + : 'Name must be one word: letters, digits, _ – and not one of the source’s own rows', + ); const output = graph.nodes.find((n) => n.type === PARTICLE_OUTPUT); if (!output) return { compiled: null, problems, reason: 'No Output node' }; @@ -124,17 +87,17 @@ export function compileParticles(graph: Graph): ParticlesBuild { const reads: ParticleExpr[] = []; for (const key of PARTICLE_OUTPUTS) { const port = outputDef.inputs.find((p) => p.key === key)!; - const expr = input(output, port); + const expr = walk.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 failed = walk.firstProblem(); + if (failed !== null || reads.length !== PARTICLE_OUTPUTS.length) + return { compiled: null, problems, reason: failed ?? 'Cannot compile' }; const locals = new Float64Array(ops.length); - const time = graph.nodes.some((n) => n.type === PARTICLE_TIME && emitted.has(n.id)); + const time = graph.nodes.some((n) => n.type === PARTICLE_TIME && walk.reached(n)); const compiled: CompiledParticles = { params, time, diff --git a/src/core/shader/compile.ts b/src/core/shader/compile.ts index 83bd25f8..cb49c13a 100644 --- a/src/core/shader/compile.ts +++ b/src/core/shader/compile.ts @@ -1,5 +1,6 @@ -import { edgesInto, findNode, type Graph, type GraphNode } from '../graph/Graph'; -import { nodeDef, type PortDef } from '../graph/NodeRegistry'; +import { graphEmitter } from '../graph/emitGraph'; +import { edgesInto, type Graph } from '../graph/Graph'; +import { nodeDef } from '../graph/NodeRegistry'; import { colorOf } from '../model/Properties'; import { isSpriteProp, isValidFieldName } from '../properties/PropertySchema'; import { isPropertyNode, propertyName, shaderParamDefs } from '../properties/shaders'; @@ -14,6 +15,7 @@ import { SHADER_OUTPUT, SHADER_SAMPLE, SHADER_TIME, + type ShaderNodeDef, SHEET_EDGE, STRAIGHT_FN, TEXEL_ALPHA, @@ -55,101 +57,61 @@ export interface ShaderBuild { * arrives premultiplied, so the prelude unpremultiplies and the epilogue multiplies back. */ export function compileShader(graph: Graph): ShaderBuild { - 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 (!isPropertyNode(node)) continue; - const name = propertyName(node); - if (!isValidFieldName(name)) problem(node, 'Name must be one word: letters, digits, _'); - else if (isSpriteProp(name)) problem(node, `"${name}" is a sprite property`); - else if (names.has(name)) problem(node, `Another property is named "${name}"`); - names.add(name); - } - const lines: string[] = []; - const emitted = new Map>(); - const visiting = new Set(); let count = 0; - 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 || !isShaderNodeDef(def)) { - problem(node, 'Not a shader 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))) { - const local = `n${count++}_${key}`; - lines.push(`${expr.type} ${local} = ${expr.code};`); - outputs[key] = { code: local, type: expr.type }; - } - emitted.set(node.id, outputs); - return outputs; - }; + const walk = graphEmitter(graph, { + isDef: isShaderNodeDef, + noun: 'shader', + emit: (def, inputs, node) => { + const outputs: Record = {}; + for (const [key, expr] of Object.entries(def.emit(inputs, node.params))) { + const local = `n${count++}_${key}`; + lines.push(`${expr.type} ${local} = ${expr.code};`); + outputs[key] = { code: local, type: expr.type }; + } + return outputs; + }, + // With nothing to type and nothing the node says, an input is the texel: its colour for a + // colour port, its alpha for a number. + unfed: (node, port, def) => { + const typed = node.params[port.key] ?? port.default; + if (port.field?.kind === 'color') + return { code: vec3Literal(colorOf(typed ?? 0xffffff, 0xffffff)), type: 'vec3' }; + if (port.field) return { code: floatLiteral(Number(typed ?? 0)), type: 'float' }; + return def?.unfed?.[port.key] ?? (port.type === 'number' ? TEXEL_ALPHA : TEXEL_COLOR); + }, + conform: (node, port, expr) => { + if (port.type === 'number' && expr.type === 'vec3') { + walk.problem(node, `"${port.key}" needs a number, not a colour`); + return null; + } + if (port.type === 'vec3' && expr.type === 'float') return { code: asVec3(expr), type: 'vec3' }; + return expr; + }, + }); + const { problems } = walk; - /** - * What feeds an input: the edge's source, the typed-in value, or – with nothing to type – what - * the node says (`unfed`), else the texel: its colour for a colour port, its alpha for a number. - */ - const input = (node: GraphNode, port: PortDef): Expr | null => { - const edge = edgesInto(graph, node.id, port.key)[0]; - if (edge) { - const source = findNode(graph, edge.from.node); - const outputs = source && emitNode(source); - const expr = outputs?.[edge.from.port]; - if (!expr) return null; - return conform(node, port, expr); - } - const typed = node.params[port.key] ?? port.default; - if (port.field?.kind === 'color') - return { code: vec3Literal(colorOf(typed ?? 0xffffff, 0xffffff)), type: 'vec3' }; - if (port.field) return { code: floatLiteral(Number(typed ?? 0)), type: 'float' }; - const def = nodeDef(node.type); - const unfed = def && isShaderNodeDef(def) ? def.unfed?.[port.key] : undefined; - return unfed ?? (port.type === 'number' ? TEXEL_ALPHA : TEXEL_COLOR); - }; - - const conform = (node: GraphNode, port: PortDef, expr: Expr): Expr | null => { - if (port.type === 'number' && expr.type === 'vec3') { - problem(node, `"${port.key}" needs a number, not a colour`); - return null; - } - if (port.type === 'vec3' && expr.type === 'float') return { code: asVec3(expr), type: 'vec3' }; - return expr; - }; + walk.checkNames(graph.nodes.filter(isPropertyNode), propertyName, (name) => + !isValidFieldName(name) + ? 'Name must be one word: letters, digits, _' + : isSpriteProp(name) + ? `"${name}" is a sprite property` + : '', + ); const output = graph.nodes.find((n) => n.type === SHADER_OUTPUT); if (!output) return { compiled: null, problems, reason: 'No Output node' }; const outputDef = nodeDef(SHADER_OUTPUT)!; - const color = input(output, outputDef.inputs[0]!); - const alpha = input(output, outputDef.inputs[1]!); + const color = walk.input(output, outputDef.inputs[0]!); + const alpha = walk.input(output, outputDef.inputs[1]!); - const first = graph.nodes.find((n) => n.id in problems); - if (first || !color || !alpha) - return { compiled: null, problems, reason: first ? problems[first.id]! : 'Cannot compile' }; + const failed = walk.firstProblem(); + if (failed !== null || !color || !alpha) + return { compiled: null, problems, reason: failed ?? 'Cannot compile' }; const uniforms = shaderParamDefs(graph).map((row) => ({ name: row.key, default: row.default })); - const time = graph.nodes.some((n) => n.type === SHADER_TIME && emitted.has(n.id)); + const time = graph.nodes.some((n) => n.type === SHADER_TIME && walk.reached(n)); // The fragment runs in the sprite's own draw (#77). `vColor` is the tint, premultiplied like the // texel; it goes in before the graph, so `Texture color` is the sprite as it draws without a shader. // The version line is what makes Pixi compile the program as GLSL ES 3.00 (`GlProgram` looks for @@ -179,7 +141,7 @@ export function compileShader(graph: Graph): ShaderBuild { const shifted = graph.nodes.some( (n) => n.type === SHADER_SAMPLE && - emitted.has(n.id) && + walk.reached(n) && (edgesInto(graph, n.id, 'u').length > 0 || edgesInto(graph, n.id, 'v').length > 0), ); const edge = shifted ? edgeOf(output.params) : SHEET_EDGE; diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 4ba88e7c..44f0db02 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -361,7 +361,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E canvasRect: () => editor.stateMachineEditor.canvas.canvasRect(), center: () => centreOf(editor.stateMachineEditor.canvas), snapping: () => editor.stateMachineEditor.canvas.snapEnabled, - /** Where a state's hidden pin sits. It is no longer grabbable – `portGrabbable` is the check. */ + /** Where a state's hidden pin sits; it cannot be grabbed – `portGrabbable` is the check. */ portAt: (nodeId: string, port: string, isOutput: boolean) => editor.stateMachineEditor.canvas.portGlobal(nodeId, port, isOutput), portGrabbable: (nodeId: string, port: string, isOutput: boolean) => diff --git a/src/editor/framing.ts b/src/editor/framing.ts index 2a2977a4..2f3493dd 100644 --- a/src/editor/framing.ts +++ b/src/editor/framing.ts @@ -68,16 +68,22 @@ export interface Framing { panY: number; } +function bandBounds(band: Band): { minX: number; maxX: number; minY: number; maxY: number } { + return { + minX: Math.min(band.x1, band.x2), + maxX: Math.max(band.x1, band.x2), + minY: Math.min(band.y1, band.y2), + maxY: Math.max(band.y1, band.y2), + }; +} + /** * Ids of every box the band overlaps. Nodes are large rectangles, so – unlike the scene view, * which tests an object's position – touching counts: a band drawn across a node that visibly * caught it would otherwise leave it out. */ export function boxesInBand(boxes: readonly Box[], band: Band): string[] { - const minX = Math.min(band.x1, band.x2); - const maxX = Math.max(band.x1, band.x2); - const minY = Math.min(band.y1, band.y2); - const maxY = Math.max(band.y1, band.y2); + const { minX, maxX, minY, maxY } = bandBounds(band); return boxes .filter((b) => b.x <= maxX && b.x + b.w >= minX && b.y <= maxY && b.y + b.h >= minY) .map((b) => b.id); @@ -90,10 +96,7 @@ export function boxesInBand(boxes: readonly Box[], band: Band): string[] { * Enclosing it is a deliberate gesture, and it is the one the eye reads as "this box, too". */ export function boxesWithinBand(boxes: readonly Box[], band: Band): string[] { - const minX = Math.min(band.x1, band.x2); - const maxX = Math.max(band.x1, band.x2); - const minY = Math.min(band.y1, band.y2); - const maxY = Math.max(band.y1, band.y2); + const { minX, maxX, minY, maxY } = bandBounds(band); return boxes .filter((b) => b.x >= minX && b.x + b.w <= maxX && b.y >= minY && b.y + b.h <= maxY) .map((b) => b.id); diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index b1cb01d7..faead646 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -1143,6 +1143,21 @@ export class GraphCanvas extends Widget { } } + private launchBeads(assetId: string, trace: Trace | null): void { + if (!trace || assetId !== this.beadAsset) { + this.beadTimes.clear(); + this.beadFired.clear(); + this.beadAsset = assetId; + } + for (const [id, time] of trace?.edges ?? []) { + if (this.beadFired.get(id) === time) continue; + this.beadFired.set(id, time); + const times = this.beadTimes.get(id) ?? []; + this.beadTimes.set(id, times); + launchBead(times, time); + } + } + /** A port's centre in graph coordinates. */ private anchorPoint(side: PortSide | null): { x: number; y: number } | null { if (!side) return null; @@ -1170,9 +1185,7 @@ export class GraphCanvas extends Widget { portGlobal(nodeId: string, port: string, isOutput: boolean): { x: number; y: number } | null { const view = this.views.get(nodeId); const p = view?.portPosition(port, isOutput); - if (!view || !p) return null; - const g = view.toGlobal({ x: p.x, y: p.y }); - return { x: g.x, y: g.y }; + return view && p ? globalPoint(view, p) : null; } /** The port a running edge drag would connect to, or null when the drop would do nothing. */ @@ -1222,8 +1235,7 @@ export class GraphCanvas extends Widget { .drawnMarks() .find((m) => m.edgeIds.includes(edgeId) && m.isOutput === isOutput); if (!mark) return null; - const g = this.canvas.toGlobal({ x: mark.x, y: mark.y }); - return { x: g.x, y: g.y, links: mark.edgeIds.length, label: mark.text }; + return { ...globalPoint(this.canvas, mark), links: mark.edgeIds.length, label: mark.text }; } nodeRect(nodeId: string): Rect | null { @@ -1272,21 +1284,6 @@ export class GraphCanvas extends Widget { return this.views.get(nodeId)?.inputLines ?? null; } - private launchBeads(assetId: string, trace: Trace | null): void { - if (!trace || assetId !== this.beadAsset) { - this.beadTimes.clear(); - this.beadFired.clear(); - this.beadAsset = assetId; - } - for (const [id, time] of trace?.edges ?? []) { - if (this.beadFired.get(id) === time) continue; - this.beadFired.set(id, time); - const times = this.beadTimes.get(id) ?? []; - this.beadTimes.set(id, times); - launchBead(times, time); - } - } - beadsDrawn(): readonly { edgeId: string; progress: number; x: number; y: number }[] { return this.edgeLayer.drawnBeads(); } @@ -1298,9 +1295,7 @@ export class GraphCanvas extends Widget { /** Global screen position of a node's title bar centre. */ nodeGlobal(nodeId: string): { x: number; y: number } | null { const view = this.views.get(nodeId); - if (!view) return null; - const g = view.toGlobal({ x: view.w / 2, y: TITLE_HEIGHT / 2 }); - return { x: g.x, y: g.y }; + return view ? globalPoint(view, { x: view.w / 2, y: TITLE_HEIGHT / 2 }) : null; } /** Every group and comment as it is drawn: the box, the text on it, and both its colours. */ @@ -1334,8 +1329,7 @@ export class GraphCanvas extends Widget { const local = corner ? { x: view.w - RESIZE_HANDLE / 2, y: view.h - RESIZE_HANDLE / 2 } : { x: view.w / 2, y: ANNOTATION_TITLE_HEIGHT / 2 }; - const g = view.toGlobal(local); - return { x: g.x, y: g.y }; + return globalPoint(view, local); } /** The rectangle a box is drawn as, on screen – what a fit has to bring into view. */ @@ -1352,6 +1346,11 @@ export class GraphCanvas extends Widget { type Rect = { x: number; y: number; w: number; h: number }; +function globalPoint(target: Container, local: { x: number; y: number }): { x: number; y: number } { + const g = target.toGlobal({ x: local.x, y: local.y }); + return { x: g.x, y: g.y }; +} + function globalRect(target: Container, w: number, h: number): Rect { const topLeft = target.toGlobal({ x: 0, y: 0 }); const bottomRight = target.toGlobal({ x: w, y: h }); diff --git a/src/editor/inspector/portSection.ts b/src/editor/inspector/portSection.ts index 36412b00..8ca73b8b 100644 --- a/src/editor/inspector/portSection.ts +++ b/src/editor/inspector/portSection.ts @@ -175,12 +175,7 @@ function buildPickedPortSection( for (const port of ports) { const toggle = writeToggle(host, assetId, node, port); if (custom) { - const name = new TextField(port.key, { - width: NAME_WIDTH, - onChange: (next) => - host.state.execute(new RenamePortCommand(assetId, node.id, port.key, next.trim())), - }); - portRow(host, assetId, node, port, name, toggle); + portRow(host, assetId, node, port, portNameField(host, assetId, node, port), toggle); continue; } const def = choices.find((d) => d.key === port.key); @@ -236,13 +231,14 @@ export function buildPortSection( : 'No outputs yet. Every output added here becomes a result of running this behavior.', ); - for (const port of ports) { - const name = new TextField(port.key, { - width: NAME_WIDTH, - onChange: (next) => host.state.execute(new RenamePortCommand(assetId, node.id, port.key, next.trim())), - }); - portRow(host, assetId, node, port, name); - } + for (const port of ports) portRow(host, assetId, node, port, portNameField(host, assetId, node, port)); +} + +function portNameField(host: InspectorHost, assetId: string, node: GraphNode, port: DeclaredPort): TextField { + return new TextField(port.key, { + width: NAME_WIDTH, + onChange: (next) => host.state.execute(new RenamePortCommand(assetId, node.id, port.key, next.trim())), + }); } function writeToggle(host: InspectorHost, assetId: string, node: GraphNode, port: DeclaredPort): Widget[] { diff --git a/src/ui/widgets/Popup.ts b/src/ui/widgets/Popup.ts index 92e003c3..c8248c6f 100644 --- a/src/ui/widgets/Popup.ts +++ b/src/ui/widgets/Popup.ts @@ -28,8 +28,8 @@ export function screenMargin(screenHeight: number): number { /** * Where a popup of `height` ends up when it was asked to open at `wanted`. The margin has to be * enforced here, not only in `roomFor`: a popup anchored low is *shifted* up to fit, and shifting - * it against the raw canvas height parks it on the bottom edge – which is why the gap used to be - * visible above and missing below. + * it against the raw canvas height would park it on the bottom edge, with the gap above it and none + * below. * * Downwards only, though. Above the popup the margin is what the height cap spends, not a distance * to keep: a menu hanging off a button near the top of the screen belongs under its button, and diff --git a/src/viewport/FileLibrary.ts b/src/viewport/FileLibrary.ts new file mode 100644 index 00000000..7c9fc01d --- /dev/null +++ b/src/viewport/FileLibrary.ts @@ -0,0 +1,52 @@ +/** + * Uploaded files the view draws with, keyed by file name. Nothing is evicted: file names are + * content addressed, so a loaded file can never go stale. A miss is not an error – the subclass + * starts the load and reports "not yet", and `onChange` fires when the file lands. + */ +export abstract class FileLibrary { + private base: string; + private readonly loading = new Set(); + /** Never retried, so one broken reference cannot spam the network. */ + private readonly failed = new Set(); + private readonly listeners = new Set<() => void>(); + + constructor( + base: string, + private readonly tag: string, + ) { + this.base = base; + } + + /** Where the files are served from. Set once at start-up, before anything draws. */ + configure(base: string): void { + this.base = base.replace(/\/+$/, ''); + } + + /** Runs `fn` whenever a file has arrived; returns the unsubscribe. */ + onChange(fn: () => void): () => void { + this.listeners.add(fn); + return () => this.listeners.delete(fn); + } + + protected abstract fetchFile(url: string, file: string): Promise; + + /** Keeps what arrived; the listeners run right after. */ + protected abstract keep(file: string, loaded: T): void; + + protected startLoading(file: string): void { + if (this.loading.has(file) || this.failed.has(file)) return; + this.loading.add(file); + void this.fetchFile(`${this.base}/${file}`, file).then( + (loaded) => { + this.loading.delete(file); + this.keep(file, loaded); + for (const listener of this.listeners) listener(); + }, + (error: unknown) => { + this.loading.delete(file); + this.failed.add(file); + console.error(`[${this.tag}] cannot load ${file}`, error); + }, + ); + } +} diff --git a/src/viewport/FontLibrary.ts b/src/viewport/FontLibrary.ts index c1225d58..ea1a3159 100644 --- a/src/viewport/FontLibrary.ts +++ b/src/viewport/FontLibrary.ts @@ -1,24 +1,15 @@ import { fontFamilyOf } from '../core/model/font'; - -const DEFAULT_BASE = '/game-maker/data/fonts'; +import { FileLibrary } from './FileLibrary'; /** - * Registers uploaded typefaces with the browser, keyed by file name – the image library's twin. - * Nothing is evicted: file names are content addressed, so a face can never go stale. A miss is - * not an error; it starts the load and reports the fallback, and the redraw comes when the face - * lands. Text therefore appears in the default typeface for a frame or two and then reflows. + * Registers uploaded typefaces with the browser. Text appears in the default typeface for a frame + * or two and then reflows when the face lands. */ -export class FontLibrary { - private base = DEFAULT_BASE; +export class FontLibrary extends FileLibrary { private readonly ready = new Set(); - private readonly loading = new Set(); - /** Never retried, so one broken reference cannot spam the network. */ - private readonly failed = new Set(); - private readonly listeners = new Set<() => void>(); - /** Where the files are served from. Set once at start-up, before anything draws. */ - configure(base: string): void { - this.base = base.replace(/\/+$/, ''); + constructor() { + super('/game-maker/data/fonts', 'fonts'); } /** @@ -32,31 +23,15 @@ export class FontLibrary { return ''; } - /** Runs `fn` whenever a typeface has arrived; returns the unsubscribe. */ - onChange(fn: () => void): () => void { - this.listeners.add(fn); - return () => this.listeners.delete(fn); + // `FontFace` rather than a stylesheet rule: it reports when the bytes are actually usable, + // and a family name is asked for by the same content-addressed key that fetched them. + protected fetchFile(url: string, file: string): Promise { + return new FontFace(fontFamilyOf(file), `url("${url}")`).load(); } - private startLoading(file: string): void { - if (this.loading.has(file) || this.failed.has(file)) return; - this.loading.add(file); - // `FontFace` rather than a stylesheet rule: it reports when the bytes are actually usable, - // and a family name is asked for by the same content-addressed key that fetched them. - const face = new FontFace(fontFamilyOf(file), `url("${this.base}/${file}")`); - void face.load().then( - (loaded) => { - this.loading.delete(file); - document.fonts.add(loaded); - this.ready.add(file); - for (const listener of this.listeners) listener(); - }, - (error: unknown) => { - this.loading.delete(file); - this.failed.add(file); - console.error(`[fonts] cannot load ${file}`, error); - }, - ); + protected keep(file: string, loaded: FontFace): void { + document.fonts.add(loaded); + this.ready.add(file); } } diff --git a/src/viewport/TextureLibrary.ts b/src/viewport/TextureLibrary.ts index afa31dad..a2509222 100644 --- a/src/viewport/TextureLibrary.ts +++ b/src/viewport/TextureLibrary.ts @@ -1,8 +1,7 @@ import { Assets, CanvasSource, Texture } from 'pixi.js'; import type { FrameRect, TextureRef } from '../core/model/texture'; import { type EdgeMode, SHEET_EDGE } from '../core/shader/nodes'; - -const DEFAULT_BASE = '/game-maker/data/textures'; +import { FileLibrary } from './FileLibrary'; /** * Pixi turns `clamp-to-edge` into `repeat` on every texture a Graphics fill draws with @@ -22,24 +21,14 @@ const ADDRESS_MODE: Record { private readonly loaded = new Map(); /** Frames cut from their sheets, keyed by file and rectangle; kept like the files. */ private readonly crops = new Map(); - private readonly loading = new Set(); - /** Never retried, so one broken reference cannot spam the network. */ - private readonly failed = new Set(); - private readonly listeners = new Set<() => void>(); - /** Where the files are served from. Set once at start-up, before anything draws. */ - configure(base: string): void { - this.base = base.replace(/\/+$/, ''); + constructor() { + super('/game-maker/data/textures', 'textures'); } /** @@ -83,12 +72,6 @@ export class TextureLibrary { return this.loaded.get(file) ?? null; } - /** Runs `fn` whenever a picture has arrived; returns the unsubscribe. */ - onChange(fn: () => void): () => void { - this.listeners.add(fn); - return () => this.listeners.delete(fn); - } - /** * A frame as a texture of its own, cut the first time it is asked for. Drawn from the sheet * through a fill matrix it showed a row of the neighbouring cell along any edge that fell @@ -110,23 +93,14 @@ export class TextureLibrary { return texture; } - private startLoading(file: string): void { - if (this.loading.has(file) || this.failed.has(file)) return; - this.loading.add(file); - void Assets.load(`${this.base}/${file}`).then( - (texture) => { - this.loading.delete(file); - texture.source.style.addressMode = EDGE_MODE; - texture.source.style.update(); - this.loaded.set(file, texture); - for (const listener of this.listeners) listener(); - }, - (error: unknown) => { - this.loading.delete(file); - this.failed.add(file); - console.error(`[textures] cannot load ${file}`, error); - }, - ); + protected fetchFile(url: string): Promise { + return Assets.load(url); + } + + protected keep(file: string, texture: Texture): void { + texture.source.style.addressMode = EDGE_MODE; + texture.source.style.update(); + this.loaded.set(file, texture); } }