Show the type an arithmetic shader node really puts out (#219)

The result of Add, Multiply, Mix and the other arithmetic nodes of
a shader graph was drawn as any, whatever went in. The compiler
already lifts a colour on any side to a colour result, so the pin
said less than the code knew. A colour result could also be wired
into a number input, and the shader then failed to compile.

- liftedType in core/shader/nodes.ts is the one rule. The compiler
  reads it when it lifts two operands, and the editor reads it
  through liftedPortType for the ports it draws.
- A SharedPortGroup may bring its own rule as agree. Without one
  it is agreedType, as before. The thirteen arithmetic nodes share
  their type by liftedPortType.
- Only the any inputs decide. An input nothing feeds counts as the
  number typed into it. t, edge, exponent, min and max have no say.
- The type travels through a chain of such nodes without code of
  its own, because #210 hands a shared type on.
- In a ring the result stays any, unless a colour has settled it.
- A number may feed a colour input. Without that a number result
  no longer fitted Output.color, although the compiler spreads it
  over three channels. The rule sits in portsCompatible, but only
  the shader graph has colour ports.
- A colour result no longer fits a number input. An edge laid
  before the colour came in stays and is drawn in the danger
  colour, and the node at its end names the problem.
- The nine shaders in the stored projects (108 edges) all stay
  valid under the new rule.

Tests:
- Gate on melektaus-atx: 212 files, 3260 tests (3240 + 20), exit 0.
  The gate right before this commit ran on the tree of this and
  the next commit together, with the same numbers.
- e2e 28 on melektaus-atx: 81 checks, all pass.
- e2e 1-31: 1059 pass, 1 fail in module 18 (the editor clock read
  1.97 s after a stop). A rerun of 1-18 passed with 602 checks and
  0.18 s. 1-31 was not run in full again.
- No full run and no smokes. The sum of 1676 (1673 + 3) is reckoned.
- Twenty unit tests in shader-port-types.test.ts, one of them runs
  all thirteen nodes through every mix of number and colour and
  compares what is drawn with the code made.
- Two tests in shaders.test.ts now lay the edge first and bring the
  colour in after, the only order that still wires such a graph.
- Three e2e checks in module 28 with the real mouse: a colour into
  Multiply, the red edge with its message, and the colour result
  refused at alpha and taken at color.
- Fifteen unit counter-probes and two browser counter-probes all
  fail their tests.
- Not checked in the browser: the one-input nodes, Mix and the
  ring, and a number wired straight into a colour input. Nothing
  was looked at after the build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-02 16:50:50 +02:00
1 parent 9ca220a285
commit f02adabcf9
9 files changed
+472 -10

No files matched your search

+323
View File
@@ -0,0 +1,323 @@
/**
* The arithmetic nodes of a shader graph show the type that really comes out (#219): a colour on
* any input makes `result` a colour, numbers alone make it a number. The rule is the compiler's
* (`liftedType`), so what the editor draws and what the fragment declares cannot drift apart.
*/
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { AddNodeCommand, ConnectCommand } from '../../src/core/commands/graphCommands';
import {
canConnect,
drawnDef,
findNode,
portsCompatible,
type PortType,
resolveDef,
} from '../../src/core/graph';
import { graphLookup } from '../../src/core/model/graphLookup';
import { findAssetOf } from '../../src/core/model/Project';
import { compileShader } from '../../src/core/shader/compile';
import {
type Expr,
type GlslType,
liftedPortType,
liftedType,
SHADER_NODES,
SHADER_NUMBER,
SHADER_OUTPUT,
SHADER_TEXTURE_ALPHA,
SHADER_TEXTURE_COLOR,
} from '../../src/core/shader/nodes';
import { makeProject } from '../helpers';
function shader() {
const f = makeProject();
const create = new CreateAssetCommand('shader', 'S');
f.stack.execute(create);
const id = create.asset.id;
const graph = () => findAssetOf(f.project, id, 'shader')!.graph;
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 }));
const def = (node: string) => resolveDef(findNode(graph(), node)!, graph(), graphLookup(f.project))!;
const result = (node: string) => def(node).outputs.find((p) => p.key === 'result')!.type;
const pin = (node: string, key: string) => {
const lookup = graphLookup(f.project);
const real = findNode(graph(), node)!;
return drawnDef(real, graph(), def(node), lookup).inputs.find((p) => p.key === key)!;
};
const fits = (from: string, fromPort: string, to: string, toPort: string) =>
canConnect(
graph(),
{ nodeId: from, port: fromPort, isOutput: true },
{ nodeId: to, port: toPort, isOutput: false },
graphLookup(f.project),
);
return { ...f, graph, add, link, def, result, pin, fits };
}
describe('the type operands come out as', () => {
it('is a colour as soon as one of them is, in the compiler', () => {
expect(liftedType(['float', 'float'])).toBe('float');
expect(liftedType(['vec3', 'float'])).toBe('vec3');
expect(liftedType(['float', 'vec3'])).toBe('vec3');
expect(liftedType(['vec3', 'vec3'])).toBe('vec3');
expect(liftedType(['float'])).toBe('float');
});
it('is the same for the ports, where an unconnected input is its typed number', () => {
expect(liftedPortType(['number', 'number'])).toBe('number');
expect(liftedPortType([undefined, undefined])).toBe('number');
expect(liftedPortType(['number', undefined])).toBe('number');
expect(liftedPortType(['vec3', undefined])).toBe('vec3');
expect(liftedPortType([undefined, 'vec3'])).toBe('vec3');
expect(liftedPortType(['vec3', 'vec3'])).toBe('vec3');
expect(liftedPortType([undefined])).toBe('number');
expect(liftedPortType(['vec3'])).toBe('vec3');
});
it('stays open while an input does not know its type, unless a colour has settled it', () => {
expect(liftedPortType(['any', 'number'])).toBe('any');
expect(liftedPortType(['any', undefined])).toBe('any');
expect(liftedPortType(['any'])).toBe('any');
expect(liftedPortType(['any', 'vec3'])).toBe('vec3');
expect(liftedPortType(['string', 'number'])).toBe('any');
});
});
describe('an arithmetic node of a shader graph', () => {
it('hands out a number while nothing is connected', () => {
const s = shader();
for (const type of ['ShaderAdd', 'ShaderSubtract', 'ShaderMultiply', 'ShaderDivide', 'ShaderMix'])
expect(s.result(s.add(type)), type).toBe('number');
});
it('hands out a colour once a colour feeds either input', () => {
const s = shader();
const texel = s.add(SHADER_TEXTURE_COLOR);
const left = s.add('ShaderMultiply');
const right = s.add('ShaderMultiply');
s.link(texel, 'color', left, 'a');
s.link(texel, 'color', right, 'b');
expect(s.result(left)).toBe('vec3');
expect(s.result(right)).toBe('vec3');
});
it('stays a colour with a number on the other input, and a number with two numbers', () => {
const s = shader();
const times = s.add('ShaderMultiply');
s.link(s.add(SHADER_TEXTURE_ALPHA), 'value', times, 'b');
expect(s.result(times)).toBe('number');
s.link(s.add(SHADER_NUMBER), 'value', times, 'a');
expect(s.result(times)).toBe('number');
s.link(s.add(SHADER_TEXTURE_COLOR), 'color', times, 'a');
expect(s.result(times)).toBe('vec3');
});
it('is a number again once the colour edge is undone', () => {
const s = shader();
const add = s.add('ShaderAdd');
s.link(s.add(SHADER_TEXTURE_COLOR), 'color', add, 'a');
expect(s.result(add)).toBe('vec3');
s.stack.undo();
expect(s.result(add)).toBe('number');
});
it('keeps its inputs any and paints the connected one', () => {
const s = shader();
const add = s.add('ShaderAdd');
s.link(s.add(SHADER_TEXTURE_COLOR), 'color', add, 'a');
expect(s.def(add).inputs.map((p) => p.type)).toEqual(['any', 'any']);
expect(s.pin(add, 'a').paintType).toBe('vec3');
expect(s.pin(add, 'b').paintType).toBeUndefined();
});
it('lets Mix follow a and b only: a colour on t is not what comes out', () => {
const s = shader();
const mix = s.add('ShaderMix');
expect(s.def(mix).inputs.find((p) => p.key === 't')!.type).toBe('number');
s.link(s.add(SHADER_TEXTURE_ALPHA), 'value', mix, 't');
expect(s.result(mix)).toBe('number');
s.link(s.add(SHADER_TEXTURE_COLOR), 'color', mix, 'b');
expect(s.result(mix)).toBe('vec3');
});
});
describe('a node of one value in a shader graph', () => {
const ONE_VALUE = [
'ShaderSin',
'ShaderFloor',
'ShaderFract',
'ShaderAbs',
'ShaderSqrt',
'ShaderStep',
'ShaderPow',
'ShaderClamp',
];
it('hands on what feeds its value, and a number while nothing does', () => {
for (const type of ONE_VALUE) {
const s = shader();
const node = s.add(type);
expect(s.result(node), type).toBe('number');
s.link(s.add(SHADER_TEXTURE_ALPHA), 'value', node, 'value');
expect(s.result(node), type).toBe('number');
s.link(s.add(SHADER_TEXTURE_COLOR), 'color', node, 'value');
expect(s.result(node), type).toBe('vec3');
}
});
it('does not follow its number inputs: edge, exponent, min and max say nothing', () => {
const s = shader();
const alpha = s.add(SHADER_TEXTURE_ALPHA);
const texel = s.add(SHADER_TEXTURE_COLOR);
for (const [type, key] of [
['ShaderStep', 'edge'],
['ShaderPow', 'exponent'],
['ShaderClamp', 'min'],
['ShaderClamp', 'max'],
] as const) {
const node = s.add(type);
s.link(alpha, 'value', node, key);
s.link(texel, 'color', node, 'value');
expect(s.result(node), `${type}.${key}`).toBe('vec3');
}
});
});
describe('every node of a shader graph with an open result', () => {
const OPEN = SHADER_NODES.filter((def) => def.outputs.some((p) => p.type === 'any'));
const expr = (type: GlslType): Expr => ({ code: 'x', type });
const portOf = (type: GlslType): PortType => (type === 'vec3' ? 'vec3' : 'number');
it('declares the group that types it', () => {
expect(OPEN.length).toBe(13);
for (const def of OPEN) {
expect(def.sharedType?.outputs, def.type).toEqual(['result']);
const open = def.inputs.filter((p) => p.type === 'any').map((p) => p.key);
expect(def.sharedType?.inputs, def.type).toEqual(open);
}
});
it('shows the type the compiler emits, for every mix of numbers and colours', () => {
for (const def of OPEN) {
const group = def.sharedType!;
const mixes =
group.inputs.length === 1
? [['float'], ['vec3']]
: [
['float', 'float'],
['vec3', 'float'],
['float', 'vec3'],
['vec3', 'vec3'],
];
for (const mix of mixes as GlslType[][]) {
const inputs: Record<string, Expr> = {};
for (const port of def.inputs) inputs[port.key] = expr('float');
group.inputs.forEach((key, i) => (inputs[key] = expr(mix[i]!)));
const emitted = def.emit(inputs, {}).result!.type;
expect(group.agree!(mix.map(portOf)), `${def.type} ${mix.join('+')}`).toBe(portOf(emitted));
}
}
});
});
describe('an edge out of a typed result', () => {
it('no longer fits a number input once the result is a colour', () => {
const s = shader();
const times = s.add('ShaderMultiply');
const output = s.add(SHADER_OUTPUT);
const mix = s.add('ShaderMix');
expect(s.fits(times, 'result', output, 'alpha')).toBe(true);
expect(s.fits(times, 'result', mix, 't')).toBe(true);
s.link(s.add(SHADER_TEXTURE_COLOR), 'color', times, 'a');
expect(s.fits(times, 'result', output, 'alpha')).toBe(false);
expect(s.fits(times, 'result', mix, 't')).toBe(false);
expect(s.fits(times, 'result', output, 'color')).toBe(true);
expect(() => s.link(times, 'result', output, 'alpha')).toThrow('Cannot connect vec3 to number');
});
it('still fits a colour input while the result is a number: the compiler spreads it', () => {
const s = shader();
const times = s.add('ShaderMultiply');
const output = s.add(SHADER_OUTPUT);
s.link(s.add(SHADER_TEXTURE_ALPHA), 'value', times, 'a');
expect(s.result(times)).toBe('number');
expect(s.fits(times, 'result', output, 'color')).toBe(true);
s.link(times, 'result', output, 'color');
const { compiled } = compileShader(s.graph());
expect(compiled!.fragment).toMatch(
/finalColor = vec4\(clamp\(vec3\(n\d+_result\), 0\.0, 1\.0\) \* alpha, alpha\);/,
);
});
it('lets a plain number feed a colour port, and never a colour a number port', () => {
expect(portsCompatible('number', 'vec3')).toBe(true);
expect(portsCompatible('vec3', 'number')).toBe(false);
expect(portsCompatible('boolean', 'vec3')).toBe(false);
expect(portsCompatible('number', 'string')).toBe(false);
const s = shader();
const output = s.add(SHADER_OUTPUT);
expect(s.fits(s.add(SHADER_NUMBER), 'value', output, 'color')).toBe(true);
expect(s.fits(s.add(SHADER_TEXTURE_COLOR), 'color', output, 'alpha')).toBe(false);
});
it('stays in place and turns invalid when a colour arrives behind it', () => {
const s = shader();
const times = s.add('ShaderMultiply');
const output = s.add(SHADER_OUTPUT);
s.link(times, 'result', output, 'alpha');
s.link(s.add(SHADER_TEXTURE_COLOR), 'color', times, 'a');
expect(s.graph().edges).toHaveLength(2);
const alpha = s.def(output).inputs.find((p) => p.key === 'alpha')!.type;
expect(portsCompatible(s.result(times), alpha)).toBe(false);
expect(compileShader(s.graph()).problems).toEqual({ [output]: '"alpha" needs a number, not a color' });
});
});
describe('a result handed on through several arithmetic nodes (#210)', () => {
it('carries the colour to the end of the chain', () => {
const s = shader();
const times = s.add('ShaderMultiply');
const plus = s.add('ShaderAdd');
const wave = s.add('ShaderSin');
s.link(times, 'result', plus, 'a');
s.link(plus, 'result', wave, 'value');
expect([times, plus, wave].map(s.result)).toEqual(['number', 'number', 'number']);
s.link(s.add(SHADER_TEXTURE_COLOR), 'color', times, 'b');
expect([times, plus, wave].map(s.result)).toEqual(['vec3', 'vec3', 'vec3']);
expect(s.pin(wave, 'value').paintType).toBe('vec3');
});
it('takes a colour joining in the middle: in front of it the chain stays a number', () => {
const s = shader();
const times = s.add('ShaderMultiply');
const plus = s.add('ShaderAdd');
const wave = s.add('ShaderSin');
s.link(times, 'result', plus, 'a');
s.link(plus, 'result', wave, 'value');
s.link(s.add(SHADER_TEXTURE_COLOR), 'color', plus, 'b');
expect([times, plus, wave].map(s.result)).toEqual(['number', 'vec3', 'vec3']);
});
it('ends a ring with an open result instead of asking forever', () => {
const s = shader();
const one = s.add('ShaderAdd');
const two = s.add('ShaderAdd');
s.link(one, 'result', two, 'a');
s.link(two, 'result', one, 'a');
expect(s.result(one)).toBe('any');
expect(s.result(two)).toBe('any');
s.link(s.add(SHADER_TEXTURE_COLOR), 'color', two, 'b');
expect(s.result(two)).toBe('vec3');
expect(s.result(one)).toBe('vec3');
});
});
+6 -4
View File
@@ -320,13 +320,14 @@ describe('compileShader', () => {
expect(fragment).toContain('float alpha = clamp(n2_result, 0.0, 1.0);');
expect(fragment).toContain('finalColor = vec4(clamp(rgb, 0.0, 1.0) * alpha, alpha);');
// A colour reaching the alpha port through an `any` result is the Output's problem.
// A result wired to the alpha port while it was a number, and fed a colour afterwards, is the
// Output's problem.
const wrong = shaderEditor(f, 'Wrong');
const texel = wrong.add(SHADER_TEXTURE_COLOR);
const doubled = wrong.add('ShaderMultiply', { b: 2 });
const out = wrong.add(SHADER_OUTPUT);
wrong.link(texel, 'color', doubled, 'a');
wrong.link(doubled, 'result', out, 'alpha');
wrong.link(texel, 'color', doubled, 'a');
const build = compileShader(wrong.asset().graph);
expect(build.compiled).toBeNull();
expect(build.problems).toEqual({ [out]: '"alpha" needs a number, not a color' });
@@ -335,13 +336,14 @@ describe('compileShader', () => {
it('names the problem on the node, and compiles nothing while there is one', () => {
const f = makeProject();
const s = shaderEditor(f);
// A colour cannot be wired to a number port; a Multiply's `any` result can, and carries one.
// A colour cannot be wired to a number port; a Multiply's result can while it is a number, and
// the edge stays when a colour feeds the Multiply afterwards.
const texel = s.add(SHADER_TEXTURE_COLOR);
const doubled = s.add('ShaderMultiply', { b: 2 });
const mixed = s.add('ShaderMix');
const output = s.add(SHADER_OUTPUT);
s.link(texel, 'color', doubled, 'a');
s.link(doubled, 'result', mixed, 't');
s.link(texel, 'color', doubled, 'a');
s.link(mixed, 'result', output, 'color');
let build = compileShader(s.asset().graph);
expect(build.compiled).toBeNull();