Files
feuerwerk/src/editor/inspector/portSection.ts
T
adminandClaude Opus 5.5 89aa92d2e3
game-maker / gate (push) Successful in 4m5s
game-maker / suite (push) Failing after 11m40s
Put a shader on a text and draw pixel fonts sharp (#169)
A text could not wear a shader, and a pixel font was always smoothed.
Pixi's Text takes no custom shader. It also shares one texture between
texts of the same content and style, and the filter is not part of that
key, so two equal texts with different filters would overwrite each
other.

- TextPicture rasters a text into a texture of its own and draws it
  like a sprite. Shader, time animation and edge mode work without a
  special path. A new text, font or size rasters again and keeps the
  shader.
- The old texture is dropped only after the shader holds the new one.
  Before, Pixi warned about a destroyed texture that was still bound.
- A text has a Shader row with the shader's property rows below, as a
  sprite has. Overrides, saving, animation and the Properties node
  (type Text) know them. A shader parameter named like an own row of
  the text loses to that row, as on the particle source.
- The font asset has a Filter row, Linear or Nearest. The row existed
  three times already (texture, vector art, pixel art) and is now one
  helper filterRow.
- Nearest rasters the text 8 times finer and cuts every 8x8 block to
  one texel: at least half covered is full, else empty. Chrome on
  Windows smooths canvas text sideways through DirectWrite whatever
  filter we set later. The cut removes those half pixels on every
  system, and the texture stays one texel per world unit.

Found on the way:

- On a font that is no pixel font thin strokes break apart under
  Nearest. For a pixel font at its own size this does not matter.

Not done:

- The preview in the Shader tab does not take a selected text as its
  picture, it shows the test picture then.

Tests:

- ./check.sh: 189 files, 2759 tests, CHECK EXIT 0.
- text-shader.test.ts with 10 tests: rows and name precedence, a
  clamped override, save and load, animation, the font filter, and
  sharpCoverage on a smoothed edge and at the texture border.
- e2e 30 has 7 new checks: red glyphs through the shader picked with
  the mouse, the shader survives a new text, red in the game, soft
  edges under Linear, clean 4x4 blocks under Nearest, and half covered
  texels (Linear 238, Nearest 0).
- Full local run before the Nearest cut: all passed, 1543 checks.
  GM_STEPS=1,30 on the final bundle: all passed, 32 checks. The sum
  1544 in the README is counted from these two runs, not measured.
- Counter-checks fail as expected: in the browser no shader on a text
  fails 30h-30j, an always linear sampler fails the Nearest check, the
  old 1:1 raster leaves 107 half covered texels and fails 30k-ii.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 06:01:28 +02:00

317 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
AddPortCommand,
AddPortsCommand,
nextPortName,
RemovePortCommand,
RenamePortCommand,
RepointPortCommand,
SetPortWriteCommand,
} from '../../core/commands/portCommands';
import {
BEHAVIOR_START,
type DeclaredPort,
type GraphNode,
isPortOf,
isTerminalNode,
missingFieldPorts,
OBJECT_BEHAVIOR_PARAM,
OBJECT_BEHAVIOR_SETTING,
OBJECT_TYPE_SETTING,
portKindOf,
VARIABLES_SETTING,
variablesSourceOf,
propertyTargetOf,
structShapeOf,
targetLabel,
targetProperties,
} from '../../core/graph';
import { isPrivateVariable } from '../../core/model/Asset';
import { findAssetOf, findGraphAsset, type Project } from '../../core/model/Project';
import { textOf } from '../../core/model/Properties';
import { DECLARED_PORT_KINDS, VARIABLE_KINDS } from '../../core/properties/kinds';
import { allShaderParamRows, type PropertyDef, schemaForAsset } from '../../core/properties/PropertySchema';
import {
Checkbox,
Dropdown,
HStack,
Label,
Menu,
type MenuItem,
Spacer,
TextField,
Theme,
type Widget,
} from '../../ui';
import { NAME_WIDTH, removeButton } from './fieldFactory';
import { addNote, type InspectorHost } from './host';
import { kindMenu, markLabel } from './variableRows';
/** Whose declarations a Variables node picks its ports from: the asset it names, else the behavior. */
export function variableChoices(project: Project, assetId: string, node: GraphNode): PropertyDef[] {
const source = variablesSourceOf(node.params);
if (source) return findAssetOf(project, source, 'variables')?.variables ?? [];
const asset = findGraphAsset(project, assetId);
// A state machine declares no variables; its one node type has none to name.
return asset?.type === 'behavior' ? asset.variables : [];
}
/**
* The ports of a Variables node: each one *is* a declared variable, so it is picked from a list
* rather than named, and its kind follows the declaration. Set makes it written too.
*/
export function buildVariablePortSection(
host: InspectorHost,
assetId: string,
node: GraphNode,
addSetting: (def: PropertyDef) => void,
): void {
addSetting(VARIABLES_SETTING);
const source = variablesSourceOf(node.params);
const shared = source ? findAssetOf(host.state.project, source, 'variables') : undefined;
buildPickedPortSection(host, assetId, node, {
noun: 'Variable',
choices: variableChoices(host.state.project, assetId, node),
owner: shared ? `"${shared.name}"` : 'this behavior',
empty: source
? shared
? `"${shared.name}" declares no variables yet.`
: 'That Variables asset is gone.'
: 'This behavior has no variables yet.',
});
}
/** The type decides what the ports are picked from; Any names them by hand, with a kind picked. */
export function buildPropertyPortSection(
host: InspectorHost,
assetId: string,
node: GraphNode,
addSetting: (def: PropertyDef) => void,
): void {
const target = propertyTargetOf(node.params);
addSetting(OBJECT_TYPE_SETTING);
if (target === 'any') {
buildPickedPortSection(host, assetId, node, { noun: 'Property', choices: [], owner: '', custom: true });
return;
}
if (target !== 'behavior') {
const owner = `a ${targetLabel(target)}`;
// A sprite's or text's rows go on with the parameters of the shader it wears – any shader, the node
// cannot know which, so each entry says whose it is. Keyed by the def itself: a shader parameter and
// a property of the type may share a name, and only the shader's entry earns the note.
const shaderRows = target === 'sprite' || target === 'text' ? allShaderParamRows(host.state.project) : [];
const from = new Map(shaderRows.map((row) => [row.def, row.shaders.join(', ')]));
buildPickedPortSection(host, assetId, node, {
noun: 'Property',
choices: [...targetProperties(target), ...shaderRows.map((row) => row.def)],
owner,
note: (def) => from.get(def),
});
return;
}
addSetting(OBJECT_BEHAVIOR_SETTING);
const id = textOf(node.params[OBJECT_BEHAVIOR_PARAM] ?? '');
const behavior = id ? findAssetOf(host.state.project, id, 'behavior') : undefined;
buildPickedPortSection(host, assetId, node, {
noun: 'Property',
choices: behavior ? schemaForAsset(host.state.project, behavior) : [],
owner: behavior ? `"${behavior.name}"` : 'a behavior',
empty: id ? 'That behavior is gone.' : 'Pick a behavior first.',
// Not offered, yet a port naming one by hand is one of its properties all the same (#257).
hidden: (def) => behavior !== undefined && isPrivateVariable(behavior, def.key),
});
}
interface PickedPorts {
noun: 'Variable' | 'Property';
choices: readonly PropertyDef[];
/** Whose ports these are, for the warning on one it does not have. */
owner: string;
empty?: string;
/** Named by hand instead of picked. */
custom?: boolean;
/** Where a choice comes from, shown after its key in the menu; nothing for one that speaks for itself. */
note?: (def: PropertyDef) => string | undefined;
/** A choice left out of the menus that is still valid on a port. */
hidden?: (def: PropertyDef) => boolean;
}
function buildPickedPortSection(
host: InspectorHost,
assetId: string,
node: GraphNode,
{ noun, choices, owner, empty, custom = false, note, hidden }: PickedPorts,
): void {
const offered = hidden ? choices.filter((def) => !hidden(def)) : choices;
const plural = noun === 'Property' ? 'properties' : 'variables';
const ports = node.ports ?? [];
const taken = new Set(ports.map((p) => p.key));
const portOf = (def: PropertyDef): DeclaredPort => ({ key: def.key, kind: portKindOf(def) });
const choiceLabel = (def: PropertyDef): string => {
const where = note?.(def);
return where ? `${def.key} (${where})` : def.key;
};
const pick = (own: string, onPick: (port: DeclaredPort) => void): MenuItem[] =>
offered.map((def) => ({
label: choiceLabel(def),
disabled: taken.has(def.key) && def.key !== own,
onSelect: () => onPick(portOf(def)),
}));
const typed = (): MenuItem[] =>
kindMenu(VARIABLE_KINDS, (kind) =>
host.state.execute(
new AddPortCommand(assetId, node.id, { key: nextPortName(node, noun.toLowerCase()), kind }),
),
);
const searchable = noun === 'Property';
const header = new HStack({ gap: Theme.gap, align: 'center' });
header.add(new Label(plural.toUpperCase(), { style: 'title' }), 1);
const add = new Dropdown(`+ ${noun}`, { variant: 'ghost' });
add.onClick = () => {
const items = pick('', (port) => host.state.execute(new AddPortCommand(assetId, node.id, port)));
new Menu(custom ? typed() : items, { searchable }).openBelow(add);
};
add.setEnabled(custom || offered.some((def) => !taken.has(def.key)));
header.add(add);
host.body.add(header);
if (!custom && choices.length === 0 && empty) addNote(host, empty);
else if (ports.length === 0) addNote(host, `No ${plural} picked yet. Each one adds a port.`);
for (const port of ports) {
const toggle = writeToggle(host, assetId, node, port);
if (custom) {
portRow(host, assetId, node, port, portNameField(host, assetId, node, port), toggle);
continue;
}
const def = choices.find((d) => d.key === port.key);
const known = def !== undefined && isPortOf(port, def);
const choose = new Dropdown(port.key, { variant: known ? 'default' : 'danger' });
choose.onClick = () => {
const items = pick(port.key, (next) =>
host.state.execute(new RepointPortCommand(assetId, node.id, port.key, next)),
);
new Menu(items, { searchable }).openBelow(choose);
};
portRow(host, assetId, node, def && known ? { ...port, kind: portKindOf(def) } : port, choose, toggle);
if (!known) addNote(host, `"${port.key}" is not a ${noun.toLowerCase()} of ${owner}.`, 'warning');
}
}
/**
* The ports a node declares for itself: the signature of a behavior seen as a node, or the fields
* of a struct. A start node's are named from the caller's side – what it hands into the graph is
* an *input* of the behavior – which is why the side comes from the node type, not from here.
*/
export function buildPortSection(
host: InspectorHost,
assetId: string,
node: GraphNode,
variables: readonly PropertyDef[] = [],
typeName: string | null = null,
): void {
const onStart = node.type === BEHAVIOR_START;
const noun = isTerminalNode(node) ? (onStart ? 'Input' : 'Output') : 'Field';
const base = noun === 'Field' ? 'field' : onStart ? 'in' : 'out';
const header = new HStack({ gap: Theme.gap, align: 'center' });
header.add(new Label(`${noun.toUpperCase()}S`, { style: 'title' }), 1);
// With a Struct asset as its type the fields are that asset's (#253): changed there, not here.
if (typeName !== null) {
host.body.add(header);
addNote(host, `The fields of ${typeName}. Change them there.`, 'small');
for (const port of node.ports ?? []) {
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
row.add(markLabel(port.kind));
row.add(new Label(port.key, { style: 'muted', width: NAME_WIDTH, padding: Theme.fieldPadding }), 1);
host.body.add(row);
}
return;
}
if (noun === 'Field') addShapePicker(host, assetId, node, variables, header);
const add = new Dropdown(`+ ${noun}`, { variant: 'ghost' });
add.onClick = () => {
const items = kindMenu(DECLARED_PORT_KINDS, (kind) =>
host.state.execute(new AddPortCommand(assetId, node.id, { key: nextPortName(node, base), kind })),
);
new Menu(items).openBelow(add);
};
header.add(add);
host.body.add(header);
const ports = node.ports ?? [];
if (ports.length === 0)
addNote(
host,
noun === 'Field'
? 'No fields yet. Every field added here becomes a port of the struct.'
: onStart
? 'No inputs yet. Every input added here becomes a port on the node that runs this behavior.'
: 'No outputs yet. Every output added here becomes a result of running this behavior.',
);
for (const port of ports) portRow(host, assetId, node, port, portNameField(host, assetId, node, port));
}
function portNameField(host: InspectorHost, assetId: string, node: GraphNode, port: DeclaredPort): TextField {
return new TextField(port.key, {
width: NAME_WIDTH,
onChange: (next) => host.state.execute(new RenamePortCommand(assetId, node.id, port.key, next.trim())),
});
}
function writeToggle(host: InspectorHost, assetId: string, node: GraphNode, port: DeclaredPort): Widget[] {
const box = new Checkbox(port.write === true);
box.onChange = (on) => host.state.execute(new SetPortWriteCommand(assetId, node.id, port.key, on));
return [new Label('Set', { style: 'muted' }), box];
}
function portRow(
host: InspectorHost,
assetId: string,
node: GraphNode,
port: DeclaredPort,
name: Widget,
extras: readonly Widget[] = [],
): void {
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
row.add(markLabel(port.kind));
row.add(name, 1);
for (const extra of extras) row.add(extra);
row.add(new Spacer());
row.add(removeButton(() => host.state.execute(new RemovePortCommand(assetId, node.id, port.key))));
host.body.add(row);
}
/**
* Takes the fields of a Pack/Expand from a variable that already declares the shape, instead of
* typing them again. Only offered where there is a shape to copy; a field the node already has
* stays as it is, so this adds and never rewrites.
*/
function addShapePicker(
host: InspectorHost,
assetId: string,
node: GraphNode,
variables: readonly PropertyDef[],
header: HStack,
): void {
const shaped = variables.filter((v) => structShapeOf(v) !== undefined);
if (shaped.length === 0) return;
const pick = new Dropdown('From variable', { variant: 'ghost' });
pick.onClick = () => {
const items: MenuItem[] = shaped.map((v) => {
const ports = missingFieldPorts(node, structShapeOf(v) ?? []);
return {
label: v.kind === 'list' ? `${v.key} (item)` : v.key,
disabled: ports.length === 0,
onSelect: () => host.state.execute(new AddPortsCommand(assetId, node.id, ports)),
};
});
new Menu(items).openBelow(pick);
};
header.add(pick);
}