From ef2600493fbd9952182a937ffa2438b31af831cb Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Tue, 29 Sep 2026 06:01:27 +0200 Subject: [PATCH] Read the pixel size of a texture in a graph (#262) A graph had no way to know how big a picture is. An icon sprite with a fixed size therefore stretched every icon of another size, an 8x8 pixel-art icon in a 16x16 slot was drawn twice as big. - New data node Texture size. It takes a texture, a frame or a pixel art and hands out width and height in pixels, 0 x 0 for none. Wired into a sprite's width and height, the picture shows pixel for pixel. - GraphLookup gets textureSize. The project answers it with the same pictureSize rule a sprite uses to size itself. Tests: - ./check.sh: 2749 tests, CHECK EXIT 0. - 2 catalog cases (a picture, no picture) and one test against a real project: pixel-art sheet, single frame, missing texture. - No e2e check and no counter-check for this node. Co-Authored-By: Claude Opus 5.5 --- src/core/graph/lookup.ts | 3 +++ src/core/graph/nodes/data/TextureSize.ts | 29 ++++++++++++++++++++++++ src/core/graph/nodes/index.ts | 2 ++ src/core/model/graphLookup.ts | 6 +++++ tests/core/node-catalog.test.ts | 13 +++++++++++ tests/core/pixel-art.test.ts | 18 +++++++++++++++ 6 files changed, 71 insertions(+) create mode 100644 src/core/graph/nodes/data/TextureSize.ts diff --git a/src/core/graph/lookup.ts b/src/core/graph/lookup.ts index da5a19d3..ea395073 100644 --- a/src/core/graph/lookup.ts +++ b/src/core/graph/lookup.ts @@ -46,6 +46,8 @@ export interface GraphLookup { curve(assetId: string): readonly VectorPoint[] | null; /** A Struct asset as a run knows it: a name and field names, nothing else travels (#254). */ structType(assetId: string): StructShape | null; + /** A texture's, frame's or pixel-art frame's own size in pixels; null for no picture. */ + textureSize(pictureId: string): { w: number; h: number } | null; } export interface StructShape { @@ -58,4 +60,5 @@ export const NO_LOOKUP: GraphLookup = { machine: () => null, curve: () => null, structType: () => null, + textureSize: () => null, }; diff --git a/src/core/graph/nodes/data/TextureSize.ts b/src/core/graph/nodes/data/TextureSize.ts new file mode 100644 index 00000000..24a476e0 --- /dev/null +++ b/src/core/graph/nodes/data/TextureSize.ts @@ -0,0 +1,29 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { asString } from '../../values'; +import { num } from '../shared'; + +/** + * A picture's own pixel size: a texture, one of its frames or a pixel-art frame, as a sprite wears + * it. Written into a sprite's `width` and `height`, the picture shows pixel for pixel – an icon slot + * that holds pictures of different sizes stretches none of them. 0 × 0 for no picture. + */ +export const TextureSize: NodeDef = { + type: 'TextureSize', + label: 'Texture size', + category: 'data', + description: "The picture's own size in pixels, 0 × 0 for none – to show a sprite pixel for pixel.", + inputs: [ + { + key: 'texture', + label: 'texture', + type: 'string', + field: { key: 'texture', label: 'Texture', kind: 'texture', default: '' }, + }, + ], + outputs: [num('width'), num('height')], + params: [], + read: (_ctx, _params, input, _def, lookup) => { + const size = lookup.textureSize(asString(input('texture'))); + return { width: size?.w ?? 0, height: size?.h ?? 0 }; + }, +}; diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index d807e5b7..2871ca4f 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -99,6 +99,7 @@ import { IndexOf } from './data/IndexOf'; import { ItemAt } from './data/ItemAt'; import { SetItem } from './data/SetItem'; import { SubList } from './data/SubList'; +import { TextureSize } from './data/TextureSize'; import { OnAnimationFinished } from './events/OnAnimationFinished'; import { OnClick } from './events/OnClick'; import { OnCollisionEnter } from './events/OnCollisionEnter'; @@ -184,6 +185,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [ IsEnabled, Properties, GetPosition, + TextureSize, NumberLiteral, BooleanLiteral, StringLiteral, diff --git a/src/core/model/graphLookup.ts b/src/core/model/graphLookup.ts index b401160f..2f36c5d9 100644 --- a/src/core/model/graphLookup.ts +++ b/src/core/model/graphLookup.ts @@ -1,9 +1,11 @@ import type { GraphLookup } from '../graph/lookup'; import { startStateOf, statesOf, transitionsOf } from '../graph/stateMachine'; +import { pictureSize } from '../properties/aspect'; import { resolveAssetProps } from '../properties/resolve'; import { bagWithoutItemTypes } from './Properties'; import { typeFields } from './structTypes'; import { findAssetOf, type Project } from './Project'; +import { resolvePicture } from './texture'; /** * What a graph may see of the project around it. A behavior's `defaults` are its variable values; @@ -36,5 +38,9 @@ export function graphLookup(project: Project): GraphLookup { const asset = findAssetOf(project, assetId, 'struct'); return asset ? { name: asset.name, fields: typeFields(asset).map((f) => f.key) } : null; }, + textureSize: (pictureId) => { + const ref = resolvePicture(project, pictureId); + return ref ? pictureSize(ref) : null; + }, }; } diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index 3a5ac8a7..8b8a016f 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -265,6 +265,19 @@ const CASES: Record = { after: (ctx) => expect(ctx.states).toEqual({}), }, ], + TextureSize: [ + { + name: "hands out the picture's own pixel size", + inputs: { texture: 'coin' }, + lookup: { ...NO_LOOKUP, textureSize: (id) => (id === 'coin' ? { w: 8, h: 6 } : null) }, + outputs: { width: 8, height: 6 }, + }, + { + name: 'no picture is 0 × 0', + inputs: { texture: '' }, + outputs: { width: 0, height: 0 }, + }, + ], CurrentState: [ { name: 'names the state the machine is in, and hands out its id as well', diff --git a/tests/core/pixel-art.test.ts b/tests/core/pixel-art.test.ts index 39743bdc..38f2f937 100644 --- a/tests/core/pixel-art.test.ts +++ b/tests/core/pixel-art.test.ts @@ -54,6 +54,7 @@ import { withLayerMoved, withLayerRemoved, } from '../../src/core/model/pixelEdit'; +import { graphLookup } from '../../src/core/model/graphLookup'; import { findAsset, type Project } from '../../src/core/model/Project'; import { resolvePicture } from '../../src/core/model/texture'; import { pictureChoices, textureRefLabel } from '../../src/core/properties/textureRef'; @@ -295,6 +296,23 @@ describe('the picture', () => { } }); + it('tells a graph its own pixel size, the sheet for the asset and the cut for a frame', () => { + const remove = registerPictureSource('pixelart', pixelArtPicture); + try { + const { project } = makeProject(); + const asset = art(project); + asset.width = 8; + asset.height = 6; + asset.frames.push({ id: 'second', cels: {} }); + const lookup = graphLookup(project); + expect(lookup.textureSize(asset.id)).toEqual({ w: 16, h: 6 }); + expect(lookup.textureSize('second')).toEqual({ w: 8, h: 6 }); + expect(lookup.textureSize('gone')).toBeNull(); + } finally { + remove(); + } + }); + it('with one frame is offered as the picture alone', () => { const remove = registerPictureSource('pixelart', pixelArtPicture); try {