diff --git a/src/core/graph/nodes/data/AddItem.ts b/src/core/graph/nodes/data/AddItem.ts new file mode 100644 index 00000000..427dc5f4 --- /dev/null +++ b/src/core/graph/nodes/data/AddItem.ts @@ -0,0 +1,38 @@ +import type { PropertyValue } from '../../../model/Properties'; +import { itemKindOf } from '../../../properties/listItems'; +import type { NodeDef } from '../../NodeRegistry'; +import { asList, asNumber, asValueItem, type ValueList } from '../../values'; +import { any, int, list } from '../shared'; +import { fitToSlot } from './SetItem'; + +export const AddItem: NodeDef = { + type: 'AddItem', + label: 'Add item', + category: 'data', + description: + 'The same list with one more item, at the end or before index. A struct or list goes in as one item. The list that came in is left alone.', + inputs: [list('list'), any('value'), int('index', -1)], + outputs: [list('result')], + run: (_ctx, inputs) => { + const items = asList(inputs.list); + const i = inputs.index === undefined ? -1 : Math.floor(asNumber(inputs.index)); + // No index, −1 or anything outside the list appends – an index can only ever miss towards the end. + const at = i >= 0 && i <= items.length ? i : items.length; + const next = [...items]; + next.splice(at, 0, fitted(items, asValueItem(inputs.value))); + return { outputs: { result: next } }; + }, + params: [], +}; + +/** + * A list whose items are all one kind gets the new one in that kind too, so a list of pictures + * stays one. A mixed or empty list has no kind to keep, and the value goes in as it is. + */ +function fitted(items: ValueList, value: ValueList[number]): ValueList[number] { + const first = items[0]; + if (first === undefined) return value; + const kind = itemKindOf(first as PropertyValue); + const oneKind = items.every((item) => itemKindOf(item as PropertyValue) === kind); + return oneKind ? fitToSlot(first, value) : value; +} diff --git a/src/core/graph/nodes/data/SetItem.ts b/src/core/graph/nodes/data/SetItem.ts index 0436b0e7..8e783443 100644 --- a/src/core/graph/nodes/data/SetItem.ts +++ b/src/core/graph/nodes/data/SetItem.ts @@ -18,16 +18,19 @@ export const SetItem: NodeDef = { const next = [...items]; // The slot keeps its kind: writing a number into a color item leaves a color behind, the same // way writing into a number variable leaves a number (see `asTypeOf`). - const value = asValueItem(inputs.value); - const slot = items[i]; - // An object has no kind to keep and none to take on, so it is written as it is (#157). - if (isEntity(value) || isEntity(slot)) next[i] = value; - else next[i] = nestedKind(slot) ? sameNested(slot, value) : itemLike(slot, value); + next[i] = fitToSlot(items[i], asValueItem(inputs.value)); return { outputs: { result: next } }; }, params: [], }; +/** `value` as the kind `slot` holds, the way `Set item` and `Add item` write into a list. */ +export function fitToSlot(slot: ValueList[number] | undefined, value: ValueList[number]): ValueList[number] { + // An object has no kind to keep and none to take on, so it is written as it is (#157). + if (isEntity(value) || isEntity(slot)) return value; + return nestedKind(slot) ? sameNested(slot, value) : itemLike(slot, value); +} + /** Whether a slot holds a list or a struct, which may carry objects `itemLike` would not keep. */ const nestedKind = (slot: ValueList[number] | undefined): boolean => { const kind = itemKindOf(slot as PropertyValue); diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index 6c455aff..59fbac3c 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -85,6 +85,7 @@ import { Subtract } from './data/Subtract'; import { Time } from './data/Time'; import { ToNumber } from './data/ToNumber'; import { ToText } from './data/ToText'; +import { AddItem } from './data/AddItem'; import { Concat } from './data/Concat'; import { Count } from './data/Count'; import { CurrentState } from './data/CurrentState'; @@ -213,6 +214,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [ Count, ItemAt, SetItem, + AddItem, SubList, Concat, IndexOf, diff --git a/tests/core/list-of-structs.test.ts b/tests/core/list-of-structs.test.ts index 5fedd00d..8c7611e9 100644 --- a/tests/core/list-of-structs.test.ts +++ b/tests/core/list-of-structs.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { AddItem } from '../../src/core/graph/nodes/data/AddItem'; import { Fill } from '../../src/core/graph/nodes/data/Fill'; import { IndexOf } from '../../src/core/graph/nodes/data/IndexOf'; import { ItemAt } from '../../src/core/graph/nodes/data/ItemAt'; @@ -130,6 +131,12 @@ describe('the list nodes with tagged items', () => { result: [tagged('texture', 'b')], }); }); + + it('adds a plain id to a list of pictures as a picture', () => { + expect(run(AddItem, { list: [tagged('texture', 'a')], value: 'b', index: -1 })).toEqual({ + result: [tagged('texture', 'a'), tagged('texture', 'b')], + }); + }); }); describe('the list nodes with struct items', () => { @@ -148,6 +155,23 @@ describe('the list nodes with struct items', () => { expect(run(IndexOf, { list: cells([1, 2]), value: { x: 9, y: 9 } })).toEqual({ index: -1 }); }); + /** `Join lists` would take the struct apart into its field values. */ + it('adds a struct as one whole item', () => { + expect(run(AddItem, { list: cells([1, 2]), value: { x: 3, y: 4 }, index: -1 })).toEqual({ + result: cells([1, 2], [3, 4]), + }); + }); + + it('adds a struct to an empty list as one whole item', () => { + expect(run(AddItem, { list: [], value: { x: 3, y: 4 }, index: -1 })).toEqual({ result: cells([3, 4]) }); + }); + + it('leaves a number a number beside structs, since a mixed list has no kind to keep', () => { + expect(run(AddItem, { list: [7, { x: 1, y: 2 }], value: 8, index: -1 })).toEqual({ + result: [7, { x: 1, y: 2 }, 8], + }); + }); + it('fills a list with a struct', () => { expect(run(Fill, { value: { x: 1, y: 2 }, count: 2 })).toEqual({ result: cells([1, 2], [1, 2]) }); }); diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index 405f0725..e5dd18a4 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -728,6 +728,35 @@ const CASES: Record = { outputs: { result: [1, 2] }, }, ], + AddItem: [ + { name: 'appends by default', inputs: { list: [1, 2], value: 3 }, outputs: { result: [1, 2, 3] } }, + { + name: 'inserts before index', + inputs: { list: [1, 2, 3], value: 9, index: 1 }, + outputs: { result: [1, 9, 2, 3] }, + }, + { + name: 'index equal to the length appends', + inputs: { list: [1, 2], value: 9, index: 2 }, + outputs: { result: [1, 2, 9] }, + }, + { + name: 'an index outside the list appends instead of dropping the value', + inputs: { list: [1, 2], value: 9, index: 7 }, + outputs: { result: [1, 2, 9] }, + }, + { + name: 'leaves the list it was given untouched – a graph may hold it elsewhere', + inputs: { list: Object.freeze([1, 2]), value: 3 }, + outputs: { result: [1, 2, 3] }, + }, + { + name: 'a list goes in as one item, not joined', + inputs: { list: [[1]], value: [2, 3] }, + outputs: { result: [[1], [2, 3]] }, + }, + { name: 'an unset list starts empty', inputs: { value: 5 }, outputs: { result: [5] } }, + ], SubList: [ { name: 'takes count items from start', diff --git a/tests/core/texture-kind.test.ts b/tests/core/texture-kind.test.ts index 46795aeb..e1701eb7 100644 --- a/tests/core/texture-kind.test.ts +++ b/tests/core/texture-kind.test.ts @@ -123,6 +123,7 @@ describe('the whole-number inputs', () => { const whole = [ ['ItemAt', 'index'], ['SetItem', 'index'], + ['AddItem', 'index'], ['SubList', 'start'], ['SubList', 'count'], ['Fill', 'count'],