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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-29 06:01:27 +02:00
1 parent d7d0a6e7d4
commit ef2600493f
6 files changed
+71

No files matched your search

+3
View File
@@ -46,6 +46,8 @@ export interface GraphLookup {
curve(assetId: string): readonly VectorPoint[] | null; curve(assetId: string): readonly VectorPoint[] | null;
/** A Struct asset as a run knows it: a name and field names, nothing else travels (#254). */ /** A Struct asset as a run knows it: a name and field names, nothing else travels (#254). */
structType(assetId: string): StructShape | null; 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 { export interface StructShape {
@@ -58,4 +60,5 @@ export const NO_LOOKUP: GraphLookup = {
machine: () => null, machine: () => null,
curve: () => null, curve: () => null,
structType: () => null, structType: () => null,
textureSize: () => null,
}; };
+29
View File
@@ -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 };
},
};
+2
View File
@@ -99,6 +99,7 @@ import { IndexOf } from './data/IndexOf';
import { ItemAt } from './data/ItemAt'; import { ItemAt } from './data/ItemAt';
import { SetItem } from './data/SetItem'; import { SetItem } from './data/SetItem';
import { SubList } from './data/SubList'; import { SubList } from './data/SubList';
import { TextureSize } from './data/TextureSize';
import { OnAnimationFinished } from './events/OnAnimationFinished'; import { OnAnimationFinished } from './events/OnAnimationFinished';
import { OnClick } from './events/OnClick'; import { OnClick } from './events/OnClick';
import { OnCollisionEnter } from './events/OnCollisionEnter'; import { OnCollisionEnter } from './events/OnCollisionEnter';
@@ -184,6 +185,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
IsEnabled, IsEnabled,
Properties, Properties,
GetPosition, GetPosition,
TextureSize,
NumberLiteral, NumberLiteral,
BooleanLiteral, BooleanLiteral,
StringLiteral, StringLiteral,
+6
View File
@@ -1,9 +1,11 @@
import type { GraphLookup } from '../graph/lookup'; import type { GraphLookup } from '../graph/lookup';
import { startStateOf, statesOf, transitionsOf } from '../graph/stateMachine'; import { startStateOf, statesOf, transitionsOf } from '../graph/stateMachine';
import { pictureSize } from '../properties/aspect';
import { resolveAssetProps } from '../properties/resolve'; import { resolveAssetProps } from '../properties/resolve';
import { bagWithoutItemTypes } from './Properties'; import { bagWithoutItemTypes } from './Properties';
import { typeFields } from './structTypes'; import { typeFields } from './structTypes';
import { findAssetOf, type Project } from './Project'; 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; * 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'); const asset = findAssetOf(project, assetId, 'struct');
return asset ? { name: asset.name, fields: typeFields(asset).map((f) => f.key) } : null; 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;
},
}; };
} }
+13
View File
@@ -265,6 +265,19 @@ const CASES: Record<string, Case[]> = {
after: (ctx) => expect(ctx.states).toEqual({}), 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: [ CurrentState: [
{ {
name: 'names the state the machine is in, and hands out its id as well', name: 'names the state the machine is in, and hands out its id as well',
+18
View File
@@ -54,6 +54,7 @@ import {
withLayerMoved, withLayerMoved,
withLayerRemoved, withLayerRemoved,
} from '../../src/core/model/pixelEdit'; } from '../../src/core/model/pixelEdit';
import { graphLookup } from '../../src/core/model/graphLookup';
import { findAsset, type Project } from '../../src/core/model/Project'; import { findAsset, type Project } from '../../src/core/model/Project';
import { resolvePicture } from '../../src/core/model/texture'; import { resolvePicture } from '../../src/core/model/texture';
import { pictureChoices, textureRefLabel } from '../../src/core/properties/textureRef'; 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', () => { it('with one frame is offered as the picture alone', () => {
const remove = registerPictureSource('pixelart', pixelArtPicture); const remove = registerPictureSource('pixelart', pixelArtPicture);
try { try {