From 5dbac3a8df6827fb9bd9622ec0f2b2f786e7892d Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Thu, 24 Sep 2026 19:39:27 +0200 Subject: [PATCH] Add a Select node that picks one of two values by a condition Choosing between two values meant a Branch with the whole action twice behind it, for example two Play animation nodes that differ only in the animation. Every later change had to be made in both. Select picks the value, so the action stays single. - Select is a data node with the inputs condition, false and true and the output value. It passes on "true" if the condition holds, else "false". An unconnected condition counts as false, and an empty picked input gives null. - The ports are named false and true, not a and b, so nobody has to guess which input is which. The order false, true follows Unreal's Select and Blender's Switch. - false, true and value share one type through `sharedType`, like First not empty. With two Animation literals plugged in, value takes their type and fits Play animation directly. - Both inputs are always evaluated. Data nodes have no side effects, so that does not matter. Tests: - ./check.sh: 160 files, 2363 tests, CHECK EXIT 0 (before 2356). - 5 cases in tests/core/node-catalog.test.ts and 2 in tests/core/shared-port-type.test.ts. - Counter checks: with the inputs swapped all 5 catalog cases fail, without sharedType exactly the type test fails. - No e2e change, the sum stays at 1380. Co-Authored-By: Claude Opus 5.5 --- src/core/graph/nodes/data/Select.ts | 18 +++++++++++++++ src/core/graph/nodes/index.ts | 2 ++ tests/core/node-catalog.test.ts | 23 +++++++++++++++++++ tests/core/shared-port-type.test.ts | 34 +++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+) create mode 100644 src/core/graph/nodes/data/Select.ts diff --git a/src/core/graph/nodes/data/Select.ts b/src/core/graph/nodes/data/Select.ts new file mode 100644 index 00000000..328533b3 --- /dev/null +++ b/src/core/graph/nodes/data/Select.ts @@ -0,0 +1,18 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asBoolean } from '../../values'; +import { any, bool } from '../shared'; + +export const Select: NodeDef = { + type: 'Select', + label: 'Select', + category: 'data', + description: '"true" if the condition holds, else "false". One action, two values – Branch picks actions.', + inputs: [bool('condition'), any('false'), any('true')], + outputs: [any('value')], + // Whatever goes in comes out: with both inputs on the same type, `value` is that type too (#207). + sharedType: { inputs: ['false', 'true'], outputs: ['value'] }, + params: [], + run: (_ctx, inputs) => ({ + outputs: { value: (asBoolean(inputs.condition) ? inputs.true : inputs.false) ?? null }, + }), +}; diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index 0e2c7fb0..57ee7f69 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -74,6 +74,7 @@ import { Properties } from './data/Properties'; import { Parent } from './data/Parent'; import { Random } from './data/Random'; import { Round } from './data/Round'; +import { Select } from './data/Select'; import { Self } from './data/Self'; import { Sqrt } from './data/Sqrt'; import { Substring } from './data/Substring'; @@ -195,6 +196,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [ Equals, Compare, FirstNotEmpty, + Select, And, Or, Not, diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index d3d183e5..a7b7e4de 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -843,6 +843,29 @@ const CASES: Record = { outputs: { value: 5 }, }, ], + Select: [ + { + name: 'true takes the true input', + inputs: { condition: true, false: 'Idle', true: 'Walk' }, + outputs: { value: 'Walk' }, + }, + { + name: 'false takes the false input', + inputs: { condition: false, false: 'Idle', true: 'Walk' }, + outputs: { value: 'Idle' }, + }, + { name: 'an unconnected condition is false', inputs: { false: 1, true: 2 }, outputs: { value: 1 } }, + { + name: 'an empty picked input gives null', + inputs: { condition: true, false: 1 }, + outputs: { value: null }, + }, + { + name: 'a value is passed on as it is, even an empty one', + inputs: { condition: false, false: 0, true: 5 }, + outputs: { value: 0 }, + }, + ], And: [ { name: 'both true', inputs: { a: true, b: true }, outputs: { result: true } }, { name: 'one false', inputs: { a: true, b: false }, outputs: { result: false } }, diff --git a/tests/core/shared-port-type.test.ts b/tests/core/shared-port-type.test.ts index 44ef342d..2657136f 100644 --- a/tests/core/shared-port-type.test.ts +++ b/tests/core/shared-port-type.test.ts @@ -133,6 +133,40 @@ describe('First not empty, whose a, b and value carry the same value', () => { }); }); +describe('Select, whose false, true and value carry the same value', () => { + it('takes the type of its two value inputs, and the condition has no say in it', () => { + const f = fixture(); + const node = f.edit.addNode('Select'); + f.edit.connect(f.edit.addNode('Boolean'), 'value', node, 'condition'); + const idle = f.edit.addNode('Animation'); + f.edit.connect(idle, 'value', node, 'false'); + expect(typeOf(f.behavior, node, 'value', f)).toBe('any'); + + f.edit.connect(f.edit.addNode('Animation'), 'value', node, 'true'); + const animation = typeOf(f.behavior, idle, 'value', f); + expect(animation).not.toBe('any'); + expect(typeOf(f.behavior, node, 'value', f)).toBe(animation); + }); + + it('then fits the animation input of Play animation, as each of its inputs would', () => { + const f = fixture(); + const node = f.edit.addNode('Select'); + f.edit.wire([ + [f.edit.addNode('Animation'), 'value', node, 'false'], + [f.edit.addNode('Animation'), 'value', node, 'true'], + ]); + const play = f.edit.addNode('PlayAnimation'); + const lookup = graphLookup(f.project); + const out = portTypeOf(f.behavior.graph, { nodeId: node.id, port: 'value', isOutput: true }, lookup)!; + const inp = portTypeOf( + f.behavior.graph, + { nodeId: play.id, port: 'animation', isOutput: false }, + lookup, + )!; + expect(portsCompatible(out, inp)).toBe(true); + }); +}); + /** As the canvas draws it: `drawnDef` is the only place a pin colour is decided. */ const inputPin = (behavior: BehaviorAsset, node: GraphNode, key: string, f: ProjectFixture): PortDef => { const lookup = graphLookup(f.project);