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[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[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); } 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[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[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[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[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[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[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[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[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[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[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[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[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 }); });