A port can take its type from what is plugged into it (#207)
Until now every port of "First not empty" was grey and typeless, so the
editor could not tell you what actually travels through the node. The
output is now typed as soon as both inputs agree, and every any-input is
painted in the colour of its own edge. Nothing changes at runtime.
- A NodeDef can declare which of its ports carry the same value
(`sharedType: { inputs, outputs }`). The rule itself lives as a pure
function `agreedType` in `src/core/graph/portTypes.ts`.
- Only the output inherits. It becomes the shared type when every input
of the group is connected and they all carry the same type, otherwise
it stays `any`.
- The inputs stay `any` on purpose. `canConnect` checks against the port
type and a busy input is replaced by the new edge, so a typed `a`
could never be re-plugged with a text without disconnecting first.
- Chains do not propagate. When looking up what feeds an input, a node
of the same kind reports the `any` it is registered with, so two of
them in a row stay untyped and a data cycle cannot ask itself in a
circle.
- An edge that the new type makes invalid stays where it is and turns
red. `EdgeLayer` already paints a mismatching edge with `danger`,
nothing is disconnected on its own.
- New `PortDef.paintType`: colour only, never truth. It is set in
`drawnDef` and read in `NodeView.redraw`, so `canConnect`, the
interpreter and the compiler keep seeing `any`. Every connected
any-input is painted, not just members of a `sharedType` group; ghost
ports keep their hollow red look.
- The pin now matches the edge that ends in it, because both take the
colour from the resolved output type of the neighbour. Mixed sources
therefore show two colours, which explains why the output stays grey.
- New hooks `__gm.graph.portTypes(id)`, `__gm.graph.edgeColor(id)` and
`__gm.themeColor(name)`. `portTypes` reports type and paint colour
separately so the type checks do not silently become colour checks.
Findings:
- `resolveDef` only appends derived ports to the registered ones, so a
fixed port cannot be retyped that way -- it would appear twice. The
shared type is therefore resolved before the ports are built.
- Answer 5 of the issue (inline field follows the type) has no case left
under variant A: an unconnected input stays `any` and keeps its text
field.
- `Item at`, `Join` and `Equals` do not profit: `Item at` has no
any-input, the other two have fixed outputs. The next candidates are
the shader arithmetic nodes, which were left alone.
Tests:
- ./check.sh -> 143 files, 2127 tests, exit 0 (was 142 / 2113)
- GM_STEPS=1-6 fresh -> ALL PASSED, 245 checks (was 242), 9 of them new
- 14 new unit tests in tests/core/shared-port-type.test.ts
- 9 new e2e checks in block 6z, one of them a pixel check straight on
the pin via pixelAt + themeColor
- Counter-probes all bite: filtering unconnected inputs out of
`agreedType` fails 3 unit tests, ignoring `inherit` fails 1, killing
the derivation in the bundle fails exactly the 2 expected e2e checks,
and never setting `paintType` fails 4 unit tests plus the 3 pin
checks (#bdbdbd instead of #e6a8ff / #6fcf97)
- Screenshot read: `a` pink, `b` green, output `value` grey, edges in
the same colours
- Check total 1302 (1299 + 3), 1293 was measured by CI at 1a3babf
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1a3babf9c5
commit
d7a2521ca7
14 files changed
+499
-19
No files matched your search
@@ -1,9 +1,19 @@
|
||||
import { type PropertyBag, textOf } from '../model/Properties';
|
||||
import { isTaggedKind } from '../properties/kinds';
|
||||
import { flatDef, isValidFieldName, type PropertyDef } from '../properties/PropertySchema';
|
||||
import { type DeclaredPort, declaredPortType, type Graph, type GraphEdge, type GraphNode } from './Graph';
|
||||
import {
|
||||
type DeclaredPort,
|
||||
declaredPortType,
|
||||
edgesInto,
|
||||
findNode,
|
||||
type Graph,
|
||||
type GraphEdge,
|
||||
type GraphNode,
|
||||
type PortType,
|
||||
} from './Graph';
|
||||
import type { GraphLookup } from './lookup';
|
||||
import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry';
|
||||
import { agreedType, type SharedPortGroup } from './portTypes';
|
||||
|
||||
export const BEHAVIOR_START = 'BehaviorStart';
|
||||
export const BEHAVIOR_END = 'BehaviorEnd';
|
||||
@@ -107,15 +117,86 @@ export function declaredPorts(graph: Graph | undefined, type: string): PortDef[]
|
||||
* type declares ports per node (`declaresPorts`) or computes them (`derivePorts`).
|
||||
*/
|
||||
export function resolveDef(node: GraphNode, graph: Graph, lookup: GraphLookup): NodeDef | undefined {
|
||||
return shapedDef(node, graph, lookup, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* `inherit` is off while answering what feeds a shared group: a node of that kind then reports the
|
||||
* `any` it was registered with, so two of them in a row cannot ask each other in circles (#207).
|
||||
*/
|
||||
function shapedDef(
|
||||
node: GraphNode,
|
||||
graph: Graph,
|
||||
lookup: GraphLookup,
|
||||
inherit: boolean,
|
||||
): NodeDef | undefined {
|
||||
const def = nodeDef(node.type);
|
||||
if (!def || (!def.declaresPorts && !def.derivePorts)) return def;
|
||||
if (!def) return undefined;
|
||||
const shared = inherit ? def.sharedType : undefined;
|
||||
if (!def.declaresPorts && !def.derivePorts)
|
||||
return shared ? withSharedType(def, shared, node, graph, lookup) : def;
|
||||
const own = (side: 'inputs' | 'outputs') =>
|
||||
declaredOn(def, node.ports ?? [], side).map((p) =>
|
||||
def.declaredPort ? def.declaredPort(p, node) : portDefOf(p),
|
||||
);
|
||||
const grown = def.derivePorts?.({ node, graph, lookup }) ?? {};
|
||||
const side = (key: 'inputs' | 'outputs') => execFirst([...def[key], ...(grown[key] ?? []), ...own(key)]);
|
||||
return { ...def, label: grown.label ?? def.label, inputs: side('inputs'), outputs: side('outputs') };
|
||||
const resolved = {
|
||||
...def,
|
||||
label: grown.label ?? def.label,
|
||||
inputs: side('inputs'),
|
||||
outputs: side('outputs'),
|
||||
};
|
||||
return shared ? withSharedType(resolved, shared, node, graph, lookup) : resolved;
|
||||
}
|
||||
|
||||
/** The group's outputs, retyped to what its inputs agree on; `any` leaves the definition alone. */
|
||||
function withSharedType(
|
||||
def: NodeDef,
|
||||
group: SharedPortGroup,
|
||||
node: GraphNode,
|
||||
graph: Graph,
|
||||
lookup: GraphLookup,
|
||||
): NodeDef {
|
||||
const type = agreedType(group.inputs.map((key) => feedingType(node, key, graph, lookup, false)));
|
||||
if (type === 'any') return def;
|
||||
return {
|
||||
...def,
|
||||
outputs: def.outputs.map((p) => (group.outputs.includes(p.key) ? { ...p, type } : p)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What the edge into that input carries, or undefined while nothing is connected to it. `inherit`
|
||||
* says whether a shared group on the *feeding* node may have its say: off while a group asks what
|
||||
* feeds it, on when only the drawn colour is wanted, which is what the edge itself is drawn in.
|
||||
*/
|
||||
function feedingType(
|
||||
node: GraphNode,
|
||||
key: string,
|
||||
graph: Graph,
|
||||
lookup: GraphLookup,
|
||||
inherit: boolean,
|
||||
): PortType | undefined {
|
||||
const edge = edgesInto(graph, node.id, key)[0];
|
||||
if (!edge) return undefined;
|
||||
const from = findNode(graph, edge.from.node);
|
||||
const def = from && shapedDef(from, graph, lookup, inherit);
|
||||
return def?.outputs.find((p) => p.key === edge.from.port)?.type;
|
||||
}
|
||||
|
||||
/**
|
||||
* `any` inputs painted as what flows into them: an unconnected one stays grey, a connected one takes
|
||||
* the colour of its own edge, so a group feeding two different types is visible as two colours.
|
||||
* Only `paintType` is set – the port's `type` stays `any`, so `canConnect` keeps taking everything.
|
||||
*/
|
||||
function paintedInputs(node: GraphNode, graph: Graph, def: NodeDef, lookup: GraphLookup): PortDef[] | null {
|
||||
if (!def.inputs.some((port) => port.type === 'any' && !port.ghost)) return null;
|
||||
return def.inputs.map((port) => {
|
||||
if (port.type !== 'any' || port.ghost) return port;
|
||||
const fed = feedingType(node, port.key, graph, lookup, true);
|
||||
return fed === undefined || fed === 'any' || fed === 'exec' ? port : { ...port, paintType: fed };
|
||||
});
|
||||
}
|
||||
|
||||
/** A derived exec pin belongs at the top, above the fixed data ports too. */
|
||||
@@ -150,18 +231,20 @@ function ghostPort(key: string): PortDef {
|
||||
}
|
||||
|
||||
/**
|
||||
* The definition as the canvas draws it: the real ports, then the keys only edges still name. The
|
||||
* ghosts go last and skip `execFirst`, so no real row moves because of one – and they stay out of
|
||||
* `resolveDef`, which the interpreter, the compilers and `ConnectCommand` read as the truth.
|
||||
* The definition as the canvas draws it: the real ports with their pin colours (`paintedInputs`),
|
||||
* then the keys only edges still name. The ghosts go last and skip `execFirst`, so no real row moves
|
||||
* because of one – and none of this reaches `resolveDef`, which the interpreter, the compilers and
|
||||
* `ConnectCommand` read as the truth.
|
||||
*/
|
||||
export function drawnDef(node: GraphNode, graph: Graph, def: NodeDef): NodeDef {
|
||||
export function drawnDef(node: GraphNode, graph: Graph, def: NodeDef, lookup: GraphLookup): NodeDef {
|
||||
const painted = paintedInputs(node, graph, def, lookup);
|
||||
const ends = ghostEnds(node, graph, def);
|
||||
if (ends.length === 0) return def;
|
||||
if (ends.length === 0) return painted ? { ...def, inputs: painted } : def;
|
||||
const side = (isOutput: boolean) =>
|
||||
[...new Set(ends.filter((e) => e.isOutput === isOutput).map((e) => e.key))].map(ghostPort);
|
||||
return {
|
||||
...def,
|
||||
inputs: [...def.inputs, ...side(false)],
|
||||
inputs: [...(painted ?? def.inputs), ...side(false)],
|
||||
outputs: [...def.outputs, ...side(true)],
|
||||
};
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user