Files
yawn/vendor/fxnode/test/layout.test.ts
T

885 lines
32 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import { APPLICATION_COMPILED, APPLICATION_HEADLESS } from "./application.js";
import { commandId, linkId, nodeId } from "@lib/core/types.js";
import { layoutGraph as genericLayoutGraph } from "@lib/layout/layout-graph.js";
import { viewToWorld, worldToView } from "@lib/layout/geometry.js";
const layoutGraph = (document: any, transform: Parameters<typeof genericLayoutGraph>[2]) =>
genericLayoutGraph(APPLICATION_COMPILED, document as never, transform);
const { createEngine, emptyDocument, materializeNode, transition, socketsCompatible } = APPLICATION_HEADLESS;
const BUILTIN_DESCRIPTORS = [...APPLICATION_COMPILED.nodes.values()];
import type { GraphDocument } from "@lib/headless.js";
import { applyNodeOrder } from "@lib/layout/layout-graph.js";
import {
boxNodes,
clampResize,
compatibleTargets,
frameDropCandidate,
groupRoots,
hitRamp,
hitTest,
planLink,
zoomAt,
} from "@lib/worker/interaction.js";
const transform = { center: { x: 0, y: 0 }, zoom: 2, viewport: { x: 800, y: 600 }, dpr: 1 };
test("world/view transforms round trip with +Y up", () => {
const point = { x: 17, y: -23 };
assert.deepEqual(viewToWorld(worldToView(point, transform), transform), point);
assert.ok(worldToView({ x: 0, y: 1 }, transform).y < worldToView({ x: 0, y: 0 }, transform).y);
});
test("all catalog types lay out deterministically", () => {
let state = createEngine(emptyDocument());
for (const [index, descriptor] of BUILTIN_DESCRIPTORS.entries()) {
const result = transition(state, {
commandId: commandId(String(index)),
expectedVersion: state.version,
source: "api",
command: {
type: "node.add",
nodeId: nodeId(`n${index}`),
nodeType: descriptor.typeId,
position: { x: index * 10, y: 0 },
},
});
assert.notEqual(result.status, "rejected");
if (result.status === "committed") state = result.state;
}
const a = layoutGraph(state.document, transform),
b = layoutGraph(state.document, transform);
assert.equal(a.nodes.size, BUILTIN_DESCRIPTORS.length);
assert.deepEqual([...a.drawOrder], [...b.drawOrder]);
const expected = [
["fxnode.common.frame", "frame", "common", [], [100, 100], [300, 100]],
["fxnode.common.reroute", "reroute", "common", ["socket:input:input:1"], [10, 10], [10, 10]],
[
"fxnode.shader.texture-coordinate",
"node",
"shader",
["socket:output:generated:1", "socket:output:normal:1", "socket:output:uv:1", "socket:output:object:1"],
[192, 124],
[192, 124],
],
[
"fxnode.shader.noise-texture",
"node",
"texture",
[
"control:enum:dimensions:1",
"control:enum:noiseType:1",
"control:boolean:normalize:1",
"socket:input:vector:1",
"socket:input:scale:1",
"socket:input:detail:1",
"socket:input:roughness:1",
"socket:input:lacunarity:1",
"socket:input:distortion:1",
"socket:output:factor:1",
"socket:output:color:1",
],
[286, 292],
[286, 292],
],
[
"fxnode.shader.image-texture",
"node",
"input",
[
"control:resource:image:4",
"control:enum:interpolation:1",
"control:enum:projection:1",
"control:enum:extension:1",
"control:enum:colorSpace:1",
"control:enum:alphaMode:1",
"socket:input:vector:1",
"socket:output:color:1",
"socket:output:alpha:1",
],
[257, 316],
[257, 316],
],
[
"fxnode.shader.principled-bsdf",
"node",
"shader",
[
"socket:input:base-color:1",
"socket:input:metallic:1",
"socket:input:roughness:1",
"socket:input:ior:1",
"socket:input:alpha:1",
"socket:input:normal:1",
"socket:output:bsdf:1",
],
[208, 196],
[208, 196],
],
[
"fxnode.shader.material-output",
"node",
"output",
["socket:input:surface:1", "socket:input:volume:1", "socket:input:displacement:1"],
[340, 100],
[340, 100],
],
["fxnode.geometry.position", "node", "geometry", ["socket:output:position:1"], [175, 52], [175, 100]],
[
"fxnode.geometry.mesh-cube",
"node",
"geometry",
[
"socket:input:size:1",
"socket:input:vertices-x:1",
"socket:input:vertices-y:1",
"socket:input:vertices-z:1",
"socket:output:mesh:1",
],
[340, 148],
[340, 148],
],
[
"fxnode.geometry.set-position",
"node",
"geometry",
["socket:input:geometry:1", "socket:input:position:1", "socket:input:offset:1", "socket:output:result:1"],
[340, 124],
[340, 124],
],
[
"fxnode.geometry.transform-geometry",
"node",
"geometry",
[
"socket:input:geometry:1",
"socket:input:translation:1",
"socket:input:rotation:1",
"socket:input:scale:1",
"socket:output:result:1",
],
[340, 148],
[340, 148],
],
[
"fxnode.geometry.join-geometry",
"node",
"geometry",
["socket:input:geometry:2", "socket:output:result:1"],
[175, 100],
[175, 100],
],
[
"fxnode.common.group-input",
"node",
"input",
["control:string:interfaceName:1", "socket:output:output:1"],
[257, 76],
[257, 100],
],
[
"fxnode.compositor.image",
"node",
"compositorInput",
[
"control:resource:image:4",
"control:enum:source:1",
"socket:output:image:1",
"socket:output:alpha:1",
"socket:output:z:1",
],
[176, 220],
[176, 220],
],
[
"fxnode.compositor.color-balance",
"node",
"compositorColor",
[
"control:enum:mode:1",
"grading-wheels:Lift:number:lift:color:liftColor,Gamma:number:gamma:color:gammaColor,Gain:number:gain:color:gainColor:7",
"socket:input:image:1",
"socket:input:factor:1",
"socket:output:result:1",
],
[400, 292],
[400, 292],
],
[
"fxnode.common.group-output",
"node",
"output",
["control:string:interfaceName:1", "socket:input:input:2"],
[257, 100],
[257, 100],
],
[
"fxnode.shader.value",
"node",
"shader",
["control:number:value:1", "socket:output:value:1"],
[151, 76],
[151, 100],
],
[
"fxnode.shader.color",
"node",
"shader",
["control:color:color:1", "socket:output:color:1"],
[151, 76],
[151, 100],
],
[
"fxnode.shader.math",
"node",
"converter",
[
"control:enum:operation:1",
"control:boolean:clamp:1",
"socket:input:a:1",
"socket:input:b:1",
"socket:output:value:1",
],
[192, 148],
[192, 148],
],
[
"fxnode.shader.vector-math",
"node",
"converter",
[
"control:enum:operation:1",
"socket:input:a:1",
"socket:input:b:1",
"socket:output:vector:1",
"socket:output:value:1",
],
[340, 148],
[340, 148],
],
[
"fxnode.shader.mix",
"node",
"shader",
[
"control:enum:blend:1",
"control:boolean:clamp:1",
"socket:input:factor:1",
"socket:input:a:1",
"socket:input:b:1",
"socket:output:result:1",
],
[151, 172],
[151, 172],
],
[
"fxnode.shader.color-ramp",
"node",
"shader",
["socket:output:color:1", "socket:output:alpha:1", "control:color-ramp:ramp:8", "socket:input:factor:1"],
[320, 292],
[320, 292],
],
] as Array<[string, string, string, string[], [number, number], [number, number]]>;
const layoutByType = new Map([...a.nodes.values()].map((node) => [node.typeId, node]));
const aggregate = expected.map(([typeId]) => {
const node = layoutByType.get(typeId)!;
const sourceNode = state.document.nodes[node.id]!;
return {
typeId: node.typeId,
kind: node.kind,
category: node.styleId,
rows: node.rows.map((row) => {
if (row.kind === "control") {
const control = a.controls.get(row.controlId)!;
return `control:${control.kind}:${control.key}:${row.units}`;
}
if (row.kind === "socket") {
const socket = sourceNode.sockets.find((item) => item.id === row.socketId)!;
return `socket:${socket.direction}:${socket.key}:${row.units}`;
}
if (row.kind === "grading-wheels") {
const wheels = row.wheels.map((wheel) => {
const scalar = a.controls.get(wheel.scalarControlId)!;
const color = a.controls.get(wheel.colorControlId)!;
return `${wheel.label}:${scalar.kind}:${scalar.key}:${color.kind}:${color.key}`;
});
return `grading-wheels:${wheels.join(",")}:${row.units}`;
}
return `${row.kind}:${row.label}:${row.units}`;
}),
minimum: node.minimumSize,
effective: { x: node.bounds.width, y: node.bounds.height },
};
});
const expectedLayout = expected.map(
([typeId, kind, category, rows, [minimumX, minimumY], [effectiveX, effectiveY]]) => ({
typeId,
kind,
category,
rows,
minimum: { x: minimumX, y: minimumY },
effective: { x: effectiveX, y: effectiveY },
}),
);
assert.deepEqual(aggregate, expectedLayout);
});
test("socket compatibility and exact theme palettes are stable", () => {
const types = ["float", "vector", "color", "shader", "geometry", "any"] as const;
const matrix = types.map((output) =>
types.map((input) =>
socketsCompatible(
{ direction: "output", dataType: output },
{ direction: "input", dataType: input, accepts: input === "any" ? ["any"] : [input, "any"] },
),
),
);
assert.deepEqual(matrix, [
[true, false, false, false, false, true],
[false, true, false, false, false, true],
[false, false, true, false, false, true],
[false, false, false, true, false, true],
[false, false, false, false, true, true],
[true, true, true, true, true, true],
]);
assert.equal(
socketsCompatible(
{ direction: "input", dataType: "float" },
{ direction: "input", dataType: "float", accepts: ["float"] },
),
false,
);
assert.equal(
socketsCompatible(
{ direction: "output", dataType: "float" },
{ direction: "output", dataType: "float", accepts: [] },
),
false,
);
assert.deepEqual(Object.fromEntries([...APPLICATION_COMPILED.styles].map(([id, style]) => [id, style.header])), {
input: "#8b3f72",
converter: "#4f5964",
texture: "#a36b34",
shader: "#3b7551",
output: "#963d3d",
geometry: "#2c7a75",
common: "#555b64",
compositorInput: "#8b3f72",
compositorColor: "#4f5964",
});
assert.deepEqual(Object.fromEntries([...APPLICATION_COMPILED.socketTypes].map(([id, type]) => [id, type.color])), {
float: "#a8a8a8",
vector: "#6476dc",
color: "#d7ca63",
shader: "#62b34f",
geometry: "#00bfa5",
any: "#999999",
});
});
test("mute bypass map and endpoint anchors are exact", () => {
const expected = [
["fxnode.common.reroute", [["input", "output"]]],
["fxnode.shader.math", [["a", "value"]]],
["fxnode.shader.vector-math", [["a", "vector"]]],
["fxnode.shader.mix", [["a", "result"]]],
["fxnode.geometry.set-position", [["geometry", "result"]]],
["fxnode.geometry.transform-geometry", [["geometry", "result"]]],
["fxnode.compositor.color-balance", [["image", "result"]]],
] as const;
assert.deepEqual(
BUILTIN_DESCRIPTORS.filter((item) => item.muteBypass.length).map((item) => [item.typeId, item.muteBypass]),
expected,
);
for (const [typeId, pairs] of expected) {
const node = { ...materializeNode("muted", typeId, { x: 0, y: 0 }), muted: true };
const document: GraphDocument = { ...emptyDocument("mute"), nodes: { muted: node } };
const layout = layoutGraph(document, transform),
placed = layout.nodes.get(nodeId("muted"))!;
assert.equal(placed.bypasses.length, pairs.length);
for (const [index, [from, to]] of pairs.entries()) {
const fromSocket = node.sockets.find((socket) => socket.key === from)!;
const toSocket = node.sockets.find((socket) => socket.key === to)!;
assert.deepEqual(placed.bypasses[index], {
from: layout.sockets.get(fromSocket.id)!.anchor,
to: layout.sockets.get(toSocket.id)!.anchor,
});
}
}
});
test("expanded, collapsed, reroute and links have pinned geometry", () => {
const value = materializeNode("value", "fxnode.shader.value", { x: -100, y: 20 });
const math = materializeNode("math", "fxnode.shader.math", { x: 100, y: 20 });
const link = {
id: "link",
fromNodeId: "value",
fromSocketId: "value:value",
toNodeId: "math",
toSocketId: "math:a",
extensions: {},
};
const raw = {
schemaVersion: 1,
graphId: "layout",
catalogVersion: 1,
nodes: { value, math },
links: { link },
metadata: {},
} as unknown as Parameters<typeof layoutGraph>[0];
const snapshot = layoutGraph(raw, transform);
assert.equal(snapshot.links.values().next().value?.points.length, 13);
const valueLayout = snapshot.nodes.get(nodeId("value"))!;
assert.equal(valueLayout.bounds.width, valueLayout.minimumSize.x);
assert.ok(valueLayout.bounds.width >= 140);
assert.equal(snapshot.sockets.size, 4);
assert.equal((snapshot.controls.get("value:parameter:value")?.value as { value: number }).value, 0);
assert.equal((snapshot.controls.get("math:parameter:operation")?.value as { value: string }).value, "add");
const mathLayout = snapshot.nodes.get(nodeId("math"))!;
assert.ok(mathLayout.bounds.height >= 126);
assert.equal(mathLayout.collapseHitRect.x, mathLayout.bounds.x);
assert.equal(mathLayout.collapseHitRect.width, 14);
assert.deepEqual(
hitTest(snapshot, worldToView({ x: mathLayout.bounds.x + 10, y: mathLayout.bounds.y - 12 }, snapshot.transform)),
{ kind: "collapse", id: nodeId("math") },
);
assert.deepEqual(
hitTest(snapshot, worldToView({ x: mathLayout.bounds.x + 22, y: mathLayout.bounds.y - 12 }, snapshot.transform)),
{ kind: "node", id: nodeId("math") },
);
assert.equal(snapshot.controls.get("math:socket:math:a")?.linked, true, "linked inputs hide controls");
});
test("multi-input sockets use pill hit bounds and stable per-link anchors", () => {
const join = materializeNode("join", "fxnode.geometry.join-geometry", { x: 160, y: 80 });
const sources = [0, 1, 2].map((index) =>
materializeNode(`cube-${index}`, "fxnode.geometry.mesh-cube", { x: -180, y: 180 - index * 160 }),
);
const linkIds = ["z-random", "a-random", "m-random"] as const;
const links = Object.fromEntries(
sources.map((source, index) => {
const id = linkIds[index]!;
return [
id,
{
id,
fromNodeId: source.id,
fromSocketId: source.sockets.find((socket) => socket.key === "mesh")!.id,
toNodeId: join.id,
toSocketId: join.sockets.find((socket) => socket.key === "geometry")!.id,
muted: false,
extensions: {},
},
];
}),
);
const snapshot = layoutGraph(
{
schemaVersion: 2,
graphId: "multi-input",
catalogVersion: 1,
nodes: Object.fromEntries([join, ...sources].map((node) => [node.id, node])),
links,
metadata: {},
},
transform,
);
const socket = snapshot.sockets.get(join.sockets.find((candidate) => candidate.key === "geometry")!.id)!;
assert.equal(socket.shape, "multi-input");
assert.deepEqual(socket.bounds, {
x: socket.anchor.x - 5,
y: socket.anchor.y + 20,
width: 10,
height: 40,
});
assert.equal(socket.linkAnchors.size, 3);
assert.equal(new Set([...socket.linkAnchors.values()].map((anchor) => anchor.y)).size, 3);
assert.ok(socket.linkAnchors.get(linkId("z-random"))!.y > socket.linkAnchors.get(linkId("a-random"))!.y);
assert.ok(socket.linkAnchors.get(linkId("a-random"))!.y > socket.linkAnchors.get(linkId("m-random"))!.y);
for (const [id, anchor] of socket.linkAnchors) {
assert.deepEqual(snapshot.links.get(id)!.points.at(-1), anchor);
}
const next = planLink(
snapshot,
sources[0]!.sockets.find((candidate) => candidate.key === "mesh")!.id,
socket.id,
linkId("fresh-link"),
);
assert.equal(next?.type, "link.add");
if (next?.type === "link.add") assert.equal(next.link.id, linkId("fresh-link"));
assert.deepEqual(hitTest(snapshot, worldToView({ x: socket.anchor.x, y: socket.bounds!.y - 2 }, transform)), {
kind: "socket",
id: socket.id,
});
const collapsed = layoutGraph(
{
schemaVersion: 2,
graphId: "multi-input-collapsed",
catalogVersion: 1,
nodes: { join: { ...join, collapsed: true } },
links: {},
metadata: {},
},
transform,
).sockets.get(socket.id)!;
assert.equal(collapsed.shape, "circle");
assert.equal(collapsed.bounds, undefined);
});
test("frames have labelled fitted bounds around parent-local children", () => {
const frame = {
...materializeNode("frame", "fxnode.common.frame", { x: -200, y: 200 }),
label: "Surface Controls",
size: { x: 100, y: 100 },
};
const child = { ...materializeNode("child", "fxnode.shader.value", { x: 30, y: -50 }), parentId: "frame" };
const raw = {
schemaVersion: 1,
graphId: "frame-layout",
catalogVersion: 1,
nodes: { frame, child },
links: {},
metadata: {},
} as unknown as Parameters<typeof layoutGraph>[0];
const snapshot = layoutGraph(raw, transform),
frameLayout = snapshot.nodes.get(nodeId("frame"))!,
childLayout = snapshot.nodes.get(nodeId("child"))!;
assert.equal(frameLayout.label, "Surface Controls");
assert.ok(frameLayout.bounds.x <= childLayout.bounds.x - 30);
assert.ok(frameLayout.bounds.x + frameLayout.bounds.width >= childLayout.bounds.x + childLayout.bounds.width + 30);
assert.ok(frameLayout.bounds.y >= childLayout.bounds.y + 30);
assert.ok(frameLayout.bounds.y - frameLayout.bounds.height <= childLayout.bounds.y - childLayout.bounds.height - 30);
});
test("socket compatibility does not treat accepts any as wildcard", () => {
const output = { direction: "output" as const, dataType: "float" as const };
assert.equal(socketsCompatible(output, { direction: "input", dataType: "vector", accepts: ["any"] }), false);
assert.equal(socketsCompatible(output, { direction: "input", dataType: "any", accepts: [] }), true);
});
test("wheel zoom preserves world point and group roots omit selected descendants", () => {
const cursor = { x: 123, y: 234 },
before = viewToWorld(cursor, transform),
next = zoomAt(transform, cursor, 120),
after = viewToWorld(cursor, { ...transform, ...next });
assert.ok(Math.abs(before.x - after.x) < 1e-9);
assert.ok(Math.abs(before.y - after.y) < 1e-9);
const frame = { ...materializeNode("f", "fxnode.common.frame", { x: 0, y: 0 }) },
child = { ...materializeNode("c", "fxnode.shader.value", { x: 10, y: -10 }), parentId: nodeId("f") };
const doc = {
schemaVersion: 1,
graphId: "g",
catalogVersion: 1,
nodes: { f: frame, c: child },
links: {},
metadata: {},
} as unknown as Parameters<typeof layoutGraph>[0];
assert.deepEqual(groupRoots(new Set([nodeId("f"), nodeId("c")]), layoutGraph(doc, transform)), [nodeId("f")]);
});
test("transient node order controls overlapping paint and hit order", () => {
const a = materializeNode("a", "fxnode.shader.value", { x: 0, y: 0 }),
b = materializeNode("b", "fxnode.shader.value", { x: 0, y: 0 }),
doc = {
schemaVersion: 1,
graphId: "z",
catalogVersion: 1,
nodes: { a, b },
links: {},
metadata: {},
} as unknown as Parameters<typeof layoutGraph>[0];
const original = layoutGraph(doc, { ...transform, zoom: 1 }),
raised = applyNodeOrder(original, [nodeId("b"), nodeId("a")]),
point = worldToView({ x: 40, y: -20 }, raised.transform);
assert.deepEqual(raised.drawOrder.slice(-2), [nodeId("b"), nodeId("a")]);
assert.deepEqual(hitTest(raised, point), { kind: "node", id: nodeId("a") });
const shifted = { ...b, position: { x: 30, y: 0 } },
overlap = applyNodeOrder(layoutGraph({ ...doc, nodes: { a, b: shifted } }, { ...transform, zoom: 1 }), [
nodeId("a"),
nodeId("b"),
]);
assert.deepEqual(hitTest(overlap, worldToView({ x: 35, y: -36 }, overlap.transform)), {
kind: "node",
id: nodeId("b"),
});
});
test("reroute core starts links while its outer halo selects the node", () => {
const reroute = materializeNode("r", "fxnode.common.reroute", { x: 0, y: 0 }),
doc = {
schemaVersion: 1,
graphId: "reroute",
catalogVersion: 1,
nodes: { r: reroute },
links: {},
metadata: {},
} as unknown as Parameters<typeof layoutGraph>[0],
layout = layoutGraph(doc, { ...transform, zoom: 1 }),
node = layout.nodes.get(nodeId("r"))!,
center = worldToView({ x: node.bounds.x + 5, y: node.bounds.y - 5 }, layout.transform);
assert.equal(hitTest(layout, center, "output").kind, "socket");
assert.deepEqual(hitTest(layout, { x: center.x + 11, y: center.y }), { kind: "node", id: nodeId("r") });
});
test("interaction helpers hit sampled links, box nodes, plan replacement and clamp resize", () => {
const value = materializeNode("v", "fxnode.shader.value", { x: -100, y: 50 }),
math = materializeNode("m", "fxnode.shader.math", { x: 100, y: 50 });
const old = { id: "old", fromNodeId: "v", fromSocketId: "v:value", toNodeId: "m", toSocketId: "m:a", extensions: {} };
const doc = {
schemaVersion: 1,
graphId: "i",
catalogVersion: 1,
nodes: { v: value, m: math },
links: { old },
metadata: {},
} as unknown as Parameters<typeof layoutGraph>[0],
layout = layoutGraph(doc, { ...transform, zoom: 1 });
const link = layout.links.values().next().value!,
mid = worldToView(link.points[6]!, layout.transform);
assert.deepEqual(hitTest(layout, mid), { kind: "link", id: "old" });
const v = layout.nodes.get(nodeId("v"))!;
assert.deepEqual(
boxNodes(
layout,
worldToView({ x: v.bounds.x - 1, y: v.bounds.y + 1 }, layout.transform),
worldToView({ x: v.bounds.x + v.bounds.width + 1, y: v.bounds.y - v.bounds.height - 1 }, layout.transform),
),
[nodeId("v")],
);
assert.equal(
compatibleTargets(layout, "v:value" as never).some((s) => s.id === "m:a"),
true,
);
assert.equal(planLink(layout, "v:value" as never, "m:a" as never)?.type, "link.replace");
assert.deepEqual(clampResize(layout, nodeId("m"), { x: 10000, y: 10000 }), {
x: 700,
y: layout.nodes.get(nodeId("m"))!.minimumSize.y,
});
});
test("frame drop picks containing frame and rejects self", () => {
const frame = materializeNode("f", "fxnode.common.frame", { x: -200, y: 200 }),
node = materializeNode("n", "fxnode.shader.value", { x: 0, y: 0 });
const doc = {
schemaVersion: 1,
graphId: "d",
catalogVersion: 1,
nodes: { f: frame, n: node },
links: {},
metadata: {},
} as unknown as Parameters<typeof layoutGraph>[0],
layout = layoutGraph(doc, transform);
assert.equal(frameDropCandidate(layout, nodeId("n"), { x: -100, y: 100 }), nodeId("f"));
assert.equal(frameDropCandidate(layout, nodeId("f"), { x: -100, y: 100 }), undefined);
});
test("Color Ramp authoritative bounds resolve every interaction region", () => {
const node = materializeNode("r", "fxnode.shader.color-ramp", { x: 0, y: 0 }),
doc = {
schemaVersion: 1,
graphId: "r",
catalogVersion: 1,
nodes: { r: node },
links: {},
metadata: {},
} as unknown as Parameters<typeof layoutGraph>[0],
layout = layoutGraph(doc, { ...transform, zoom: 1 }),
control = layout.controls.get("r:parameter:ramp")!,
b = control.rampBounds!,
center = (r: typeof b.toolbar) => ({ x: r.x + r.width / 2, y: r.y - r.height / 2 });
assert.equal(hitRamp(control, { x: b.toolbar.x + 1, y: b.toolbar.y - 10 })?.target, "add");
assert.equal(hitRamp(control, { x: b.toolbar.x + b.toolbar.width * 0.18, y: b.toolbar.y - 10 })?.target, "remove");
assert.equal(hitRamp(control, { x: b.toolbar.x + b.toolbar.width * 0.4, y: b.toolbar.y - 10 })?.target, "flip");
assert.equal(hitRamp(control, { x: b.toolbar.x + b.toolbar.width * 0.8, y: b.toolbar.y - 10 })?.target, "distribute");
assert.equal(hitRamp(control, center(b.mode))?.target, "mode");
assert.equal(hitRamp(control, center(b.interpolation))?.target, "interpolation");
assert.equal(hitRamp(control, center(b.hue))?.target, "hue");
assert.equal(hitRamp(control, center(b.gradient))?.target, "gradient");
assert.equal(hitRamp(control, center(b.selector))?.target, "selector");
assert.equal(hitRamp(control, center(b.position))?.target, "position");
for (const x of [0.05, 0.5, 0.95])
assert.equal(hitRamp(control, { x: b.color.x + b.color.width * x, y: b.color.y - 10 })?.target, "swatch");
});
test("Color Balance owns three disjoint Blender-style grading wheels", () => {
const node = materializeNode("balance", "fxnode.compositor.color-balance", { x: 0, y: 0 }),
doc = {
schemaVersion: 1,
graphId: "balance",
catalogVersion: 1,
nodes: { balance: node },
links: {},
metadata: {},
} as unknown as Parameters<typeof layoutGraph>[0],
layout = layoutGraph(doc, { ...transform, zoom: 1 }),
placed = layout.nodes.get(nodeId("balance"))!,
row = placed.rows.find((item) => item.kind === "grading-wheels");
assert.ok(row && row.kind === "grading-wheels");
assert.deepEqual(
row.wheels.map((wheel) => wheel.label),
["Lift", "Gamma", "Gain"],
);
assert.ok(placed.minimumSize.x >= 400);
assert.equal(row.units, 7);
for (const wheel of row.wheels) {
const color = layout.controls.get(wheel.colorControlId)!,
scalar = layout.controls.get(wheel.scalarControlId)!,
bounds = color.colorWheelBounds!;
assert.equal(bounds.plane.width, bounds.plane.height);
assert.ok(bounds.lightness.x >= bounds.plane.x + bounds.plane.width);
for (const [region, rect] of Object.entries(bounds) as ["plane" | "lightness", typeof bounds.plane][])
assert.deepEqual(
hitTest(layout, worldToView({ x: rect.x + rect.width / 2, y: rect.y - rect.height / 2 }, layout.transform)),
{ kind: "color-wheel", id: color.id, region },
);
assert.equal(
hitTest(
layout,
worldToView(
{ x: scalar.bounds.x + scalar.bounds.width / 2, y: scalar.bounds.y - scalar.bounds.height / 2 },
layout.transform,
),
).kind,
"control",
);
}
for (let i = 1; i < row.wheels.length; i++) {
const prior = layout.controls.get(row.wheels[i - 1]!.colorControlId)!.bounds,
next = layout.controls.get(row.wheels[i]!.colorControlId)!.bounds;
assert.ok(next.x >= prior.x + prior.width);
}
const collapsed = layoutGraph(
{ ...doc, nodes: { balance: { ...node, collapsed: true } } },
{ ...transform, zoom: 1 },
).nodes.get(nodeId("balance"))!;
assert.equal(collapsed.bounds.width, placed.bounds.width);
assert.equal(collapsed.bounds.height, 24);
});
test("compound and component controls have bounded, disjoint layout cells", () => {
const types = [
["r", "fxnode.shader.color-ramp"],
["i", "fxnode.shader.image-texture"],
["g", "fxnode.compositor.color-balance"],
] as const;
const nodes = Object.fromEntries(
types.map(([id, type], index) => [id, materializeNode(id, type, { x: index * 500, y: 0 })]),
);
const doc = {
schemaVersion: 1,
graphId: "cells",
catalogVersion: 1,
nodes,
links: {},
metadata: {},
} as unknown as Parameters<typeof layoutGraph>[0],
layout = layoutGraph(doc, transform);
const inside = (
outer: { x: number; y: number; width: number; height: number },
inner: { x: number; y: number; width: number; height: number },
) =>
inner.x >= outer.x &&
inner.x + inner.width <= outer.x + outer.width &&
inner.y <= outer.y &&
inner.y - inner.height >= outer.y - outer.height;
for (const control of layout.controls.values()) {
const node = layout.nodes.get(control.nodeId)!;
assert.ok(inside(node.bounds, control.bounds), `${control.id} is inside its node`);
for (let index = 1; index < control.subfields.length; index++)
assert.ok(
control.subfields[index]!.bounds.x -
(control.subfields[index - 1]!.bounds.x + control.subfields[index - 1]!.bounds.width) >=
2,
"component gutter",
);
}
const ramp = layout.controls.get("r:parameter:ramp")!,
rampRow = layout.nodes.get(nodeId("r"))!.rows.find((row) => row.kind === "control")!;
for (const row of layout.nodes.get(nodeId("r"))!.rows.filter((row) => row.kind === "socket"))
assert.ok(
row.bounds.y - row.bounds.height >= rampRow.bounds.y || rampRow.bounds.y - rampRow.bounds.height >= row.bounds.y,
"ramp and sockets disjoint",
);
const ordinary = [layout.controls.get("i:parameter:interpolation")!, layout.controls.get("i:parameter:projection")!],
factor = layout.controls.get("g:socket:g:factor")!;
assert.ok([...ordinary, factor].every((control) => control.bounds.height === 18));
assert.ok(
ordinary.every(
(control) =>
Math.abs(
(control.bounds.x - layout.nodes.get(control.nodeId)!.bounds.x) /
layout.nodes.get(control.nodeId)!.bounds.width -
0.42,
) < 1e-9,
),
);
assert.equal(factor.bounds.x - layout.nodes.get(factor.nodeId)!.bounds.x, 12);
});
test("numeric fields expose Blender-style range fill and step geometry", () => {
const principled = materializeNode("p", "fxnode.shader.principled-bsdf", { x: 0, y: 0 }),
valueNode = materializeNode("v", "fxnode.shader.value", { x: 500, y: 0 }),
doc = {
schemaVersion: 1,
graphId: "numeric-fields",
catalogVersion: 1,
nodes: { p: principled, v: valueNode },
links: {},
metadata: {},
} as unknown as Parameters<typeof layoutGraph>[0],
layout = layoutGraph(doc, transform),
roughness = layout.controls.get("p:socket:p:roughness")!.numericFields[0]!,
unbounded = layout.controls.get("v:parameter:value")!.numericFields[0]!;
assert.deepEqual(roughness.range, { minimum: 0, maximum: 1 });
assert.equal(unbounded.range, undefined);
assert.equal(roughness.decrement.width, 7);
assert.equal(roughness.increment.width, 7);
assert.ok(roughness.value.x >= roughness.decrement.x + roughness.decrement.width);
assert.ok(roughness.increment.x >= roughness.value.x + roughness.value.width);
});
test("color controls are compact swatches rather than RGBA fields", () => {
const colorNode = materializeNode("c", "fxnode.shader.color", { x: 0, y: 0 }),
principled = materializeNode("p", "fxnode.shader.principled-bsdf", { x: 300, y: 0 }),
doc = {
schemaVersion: 1,
graphId: "swatches",
catalogVersion: 1,
nodes: { c: colorNode, p: principled },
links: {},
metadata: {},
} as unknown as Parameters<typeof layoutGraph>[0],
layout = layoutGraph(doc, transform),
parameter = layout.controls.get("c:parameter:color")!,
socket = layout.controls.get("p:socket:p:base-color")!;
for (const control of [parameter, socket]) {
assert.equal(control.kind, "color");
assert.equal(control.subfields.length, 0);
assert.equal(control.numericFields.length, 0);
assert.equal(
hitTest(
layout,
worldToView(
{ x: control.bounds.x + control.bounds.width / 2, y: control.bounds.y - control.bounds.height / 2 },
layout.transform,
),
).kind,
"control",
);
}
assert.equal(layout.nodes.get(nodeId("c"))!.minimumSize.x, 151);
assert.equal(layout.nodes.get(nodeId("p"))!.bounds.width, 208);
});
test("resource previews and open buttons are authoritative worker hit targets", () => {
const image = materializeNode("i", "fxnode.shader.image-texture", { x: 0, y: 0 }),
document = {
schemaVersion: 1,
graphId: "resource-hits",
catalogVersion: 1,
nodes: { i: image },
links: {},
metadata: {},
} as unknown as Parameters<typeof layoutGraph>[0],
layout = layoutGraph(document, transform),
control = layout.controls.get("i:parameter:image")!,
bounds = control.resourceBounds!,
center = (rect: typeof bounds.preview) =>
worldToView({ x: rect.x + rect.width / 2, y: rect.y - rect.height / 2 }, layout.transform);
assert.equal(control.kind, "resource");
assert.deepEqual(hitTest(layout, center(bounds.preview)), { kind: "resource", id: control.id });
assert.deepEqual(hitTest(layout, center(bounds.open)), { kind: "resource", id: control.id });
});