feat: add render graph driven renderer architecture
Amp-Thread-ID: https://ampcode.com/threads/T-019f9d91-77c1-7206-a60f-ed6554ce92ab Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
Vendored
+132
@@ -0,0 +1,132 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { APPLICATION_COMPILED, APPLICATION_HEADLESS } from "../test/application.js";
|
||||
import { graphId, linkId, socketId, type GraphDocument, type GraphLink } from "@lib/core/types.js";
|
||||
import { nullRecord } from "@lib/core/json.js";
|
||||
|
||||
const { decodeGraphDocument, materializeNode, save } = APPLICATION_HEADLESS;
|
||||
const definitions = [...APPLICATION_COMPILED.nodes.values()];
|
||||
const positions = { common: 0, shader: -280, geometry: -560, compositor: -840 };
|
||||
// This visual fixture intentionally pins authored document sizes. Fresh nodes use calculated dimensions.
|
||||
const authoredSizes: Readonly<Record<string, { readonly x: number; readonly y: number }>> = {
|
||||
"fxnode.common.frame": { x: 300, y: 100 },
|
||||
"fxnode.common.reroute": { x: 140, y: 100 },
|
||||
"fxnode.common.group-input": { x: 180, y: 100 },
|
||||
"fxnode.common.group-output": { x: 180, y: 100 },
|
||||
"fxnode.shader.value": { x: 140, y: 100 },
|
||||
"fxnode.shader.color": { x: 140, y: 100 },
|
||||
"fxnode.shader.math": { x: 180, y: 100 },
|
||||
"fxnode.shader.vector-math": { x: 190, y: 100 },
|
||||
"fxnode.shader.mix": { x: 190, y: 100 },
|
||||
"fxnode.shader.color-ramp": { x: 320, y: 100 },
|
||||
"fxnode.shader.texture-coordinate": { x: 190, y: 100 },
|
||||
"fxnode.shader.noise-texture": { x: 200, y: 100 },
|
||||
"fxnode.shader.image-texture": { x: 280, y: 100 },
|
||||
"fxnode.shader.principled-bsdf": { x: 220, y: 100 },
|
||||
"fxnode.shader.material-output": { x: 190, y: 100 },
|
||||
"fxnode.geometry.position": { x: 140, y: 100 },
|
||||
"fxnode.geometry.mesh-cube": { x: 190, y: 100 },
|
||||
"fxnode.geometry.set-position": { x: 190, y: 100 },
|
||||
"fxnode.geometry.transform-geometry": { x: 210, y: 100 },
|
||||
"fxnode.geometry.join-geometry": { x: 180, y: 100 },
|
||||
"fxnode.compositor.image": { x: 240, y: 100 },
|
||||
"fxnode.compositor.color-balance": { x: 400, y: 100 },
|
||||
};
|
||||
const nodes = definitions.map((definition) => {
|
||||
const family = definition.typeId.split(".")[1] as keyof typeof positions;
|
||||
const peers = definitions.filter((item) => item.typeId.split(".")[1] === family);
|
||||
const materialized = materializeNode(`all-${definition.typeId.replaceAll(".", "-")}`, definition.typeId, {
|
||||
x: 40 + peers.indexOf(definition) * 240,
|
||||
y: positions[family],
|
||||
});
|
||||
const node = { ...materialized, size: authoredSizes[definition.typeId] ?? materialized.size };
|
||||
if (definition.typeId === "fxnode.shader.color-ramp")
|
||||
return {
|
||||
...node,
|
||||
parameters: {
|
||||
ramp: {
|
||||
kind: "json" as const,
|
||||
value: {
|
||||
colorMode: "hsv",
|
||||
interpolation: "ease",
|
||||
hueInterpolation: "far",
|
||||
stops: [
|
||||
{ id: "black", position: 0, color: [0, 0, 0, 1] },
|
||||
{ id: "accent", position: 0.38, color: [0.05, 0.35, 1, 1] },
|
||||
{ id: "white", position: 1, color: [1, 1, 1, 1] },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as typeof node;
|
||||
if (definition.typeId === "fxnode.shader.noise-texture")
|
||||
return {
|
||||
...node,
|
||||
parameters: {
|
||||
...node.parameters,
|
||||
dimensions: { kind: "string" as const, value: "4d" },
|
||||
noiseType: { kind: "string" as const, value: "hybrid-multifractal" },
|
||||
},
|
||||
} as typeof node;
|
||||
return node;
|
||||
});
|
||||
const byType = (type: string) => nodes.find((node) => node.typeId === type)!;
|
||||
const cube = byType("fxnode.geometry.mesh-cube"),
|
||||
position = byType("fxnode.geometry.position"),
|
||||
set = byType("fxnode.geometry.set-position"),
|
||||
transform = byType("fxnode.geometry.transform-geometry"),
|
||||
join = byType("fxnode.geometry.join-geometry");
|
||||
const make = (id: string, from: typeof cube, fromKey: string, to: typeof join, toKey: string): GraphLink => ({
|
||||
id: linkId(id),
|
||||
fromNodeId: from.id,
|
||||
fromSocketId: socketId(`${from.id}:${fromKey}`),
|
||||
toNodeId: to.id,
|
||||
toSocketId: socketId(`${to.id}:${toKey}`),
|
||||
muted: false,
|
||||
extensions: {},
|
||||
});
|
||||
const links = [
|
||||
make("all-link-position", position, "position", set, "position"),
|
||||
make("all-link-cube", cube, "mesh", set, "geometry"),
|
||||
make("all-link-set", set, "result", join, "geometry"),
|
||||
make("all-link-transform", transform, "result", join, "geometry"),
|
||||
];
|
||||
const document: GraphDocument = {
|
||||
schemaVersion: 2,
|
||||
graphId: graphId("all-supported"),
|
||||
catalogVersion: APPLICATION_COMPILED.source.version,
|
||||
nodes: nullRecord(nodes.map((node) => [node.id, node])),
|
||||
links: nullRecord(links.map((link) => [link.id, link])),
|
||||
metadata: nullRecord(),
|
||||
};
|
||||
const saved = save(document);
|
||||
const { schemaVersion: _schemaVersion, ...persistedLayout } = saved;
|
||||
export const initialLayout = {
|
||||
...persistedLayout,
|
||||
nodes: persistedLayout.nodes.map((node) => ({ ...node, known: true })),
|
||||
};
|
||||
const path = new URL("../examples/blender/all-supported/initialLayout.json", import.meta.url),
|
||||
canonical = JSON.stringify(initialLayout, null, 2) + "\n";
|
||||
if (process.argv.includes("--write")) writeFileSync(path, canonical);
|
||||
else {
|
||||
const bytes = readFileSync(path, "utf8"),
|
||||
actual = JSON.parse(bytes),
|
||||
decoded = decodeGraphDocument({
|
||||
...actual,
|
||||
schemaVersion: 2,
|
||||
nodes: actual.nodes.map(({ known: _, ...node }: any) => node),
|
||||
});
|
||||
if (!decoded.ok) throw new Error(`Initial layout decode failed: ${JSON.stringify(decoded.issues)}`);
|
||||
const ids = actual.nodes.map((node: { typeId: string }) => node.typeId),
|
||||
expected = definitions.length;
|
||||
if (
|
||||
ids.length !== expected ||
|
||||
new Set(ids).size !== expected ||
|
||||
definitions.some((item) => !ids.includes(item.typeId))
|
||||
)
|
||||
throw new Error("Fixture must contain each application type exactly once");
|
||||
if (JSON.stringify(actual) !== JSON.stringify(initialLayout) || bytes !== canonical)
|
||||
throw new Error("Initial layout is not canonical; run generate:all-supported");
|
||||
if (actual.links.filter((link: { toNodeId: string }) => link.toNodeId === join.id).length < 2)
|
||||
throw new Error("Join Geometry needs two incoming links");
|
||||
console.log("all-supported fixture verified");
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { APPLICATION_HEADLESS } from "../test/application.js";
|
||||
import { APPLICATION_VERSION } from "../examples/blender/nodes/application.js";
|
||||
import {
|
||||
graphId,
|
||||
linkId,
|
||||
nodeId,
|
||||
socketId,
|
||||
type GraphDocument,
|
||||
type GraphLink,
|
||||
type GraphNode,
|
||||
} from "@lib/core/types.js";
|
||||
import { nullRecord } from "@lib/core/json.js";
|
||||
|
||||
const { materializeNode, save } = APPLICATION_HEADLESS;
|
||||
const node = (
|
||||
id: string,
|
||||
typeId: Parameters<typeof materializeNode>[1],
|
||||
position: { readonly x: number; readonly y: number },
|
||||
size: { readonly x: number; readonly y: number },
|
||||
options: { readonly label?: string; readonly collapsed?: boolean; readonly parentId?: string } = {},
|
||||
): GraphNode => ({
|
||||
...materializeNode(id, typeId, position),
|
||||
size,
|
||||
...(options.label === undefined ? {} : { label: options.label }),
|
||||
...(options.collapsed === undefined ? {} : { collapsed: options.collapsed }),
|
||||
...(options.parentId === undefined ? {} : { parentId: nodeId(options.parentId) }),
|
||||
});
|
||||
|
||||
const nodes = [
|
||||
node("frame", "fxnode.common.frame", { x: -550, y: 250 }, { x: 190, y: 270 }, { label: "Surface Controls" }),
|
||||
node("value-expanded", "fxnode.shader.value", { x: 30, y: -55 }, { x: 140, y: 100 }, { parentId: "frame" }),
|
||||
node(
|
||||
"value-collapsed",
|
||||
"fxnode.shader.value",
|
||||
{ x: 30, y: -175 },
|
||||
{ x: 140, y: 100 },
|
||||
{ collapsed: true, parentId: "frame" },
|
||||
),
|
||||
node("math", "fxnode.shader.math", { x: -300, y: 170 }, { x: 160, y: 110 }),
|
||||
node("noise", "fxnode.shader.noise-texture", { x: -100, y: 190 }, { x: 165, y: 130 }),
|
||||
node("reroute", "fxnode.common.reroute", { x: 105, y: 55 }, { x: 140, y: 100 }),
|
||||
node("principled", "fxnode.shader.principled-bsdf", { x: 165, y: 175 }, { x: 185, y: 125 }),
|
||||
node("output", "fxnode.shader.material-output", { x: 405, y: 115 }, { x: 155, y: 100 }),
|
||||
];
|
||||
const byId = nullRecord(nodes.map((item) => [item.id, item]));
|
||||
const link = (id: string, fromNodeId: string, fromKey: string, toNodeId: string, toKey: string): GraphLink => ({
|
||||
id: linkId(id),
|
||||
fromNodeId: byId[fromNodeId]!.id,
|
||||
fromSocketId: socketId(`${fromNodeId}:${fromKey}`),
|
||||
toNodeId: byId[toNodeId]!.id,
|
||||
toSocketId: socketId(`${toNodeId}:${toKey}`),
|
||||
muted: false,
|
||||
extensions: {},
|
||||
});
|
||||
const links = [
|
||||
link("link-1", "value-expanded", "value", "math", "a"),
|
||||
link("link-2", "value-collapsed", "value", "math", "b"),
|
||||
link("link-3", "math", "value", "noise", "scale"),
|
||||
link("link-4", "noise", "factor", "reroute", "input"),
|
||||
link("link-5", "reroute", "output", "principled", "roughness"),
|
||||
link("link-6", "principled", "bsdf", "output", "surface"),
|
||||
];
|
||||
const document: GraphDocument = {
|
||||
schemaVersion: 2,
|
||||
graphId: graphId("phase-4-example"),
|
||||
catalogVersion: APPLICATION_VERSION,
|
||||
nodes: byId,
|
||||
links: nullRecord(links.map((item) => [item.id, item])),
|
||||
metadata: nullRecord([["description", "Deterministic fxnode browser baseline"]]),
|
||||
};
|
||||
const path = new URL("../examples/blender/initialLayout.json", import.meta.url);
|
||||
const saved = save(document);
|
||||
const { schemaVersion: _schemaVersion, ...layout } = saved;
|
||||
const canonical = `${JSON.stringify({ ...layout, nodes: layout.nodes.map((node) => ({ ...node, known: true })) }, null, 2)}\n`;
|
||||
if (process.argv.includes("--write")) writeFileSync(path, canonical);
|
||||
else if (readFileSync(path, "utf8") !== canonical)
|
||||
throw new Error("Blender fixture is not canonical; run npm run generate:blender-fixture");
|
||||
else console.log("blender fixture verified");
|
||||
@@ -0,0 +1,106 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Create deterministic, project-authored Blender node-editor reference fixtures."""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
|
||||
FIXTURES = (
|
||||
"shader-basic-linked-near", "shader-basic-linked-far",
|
||||
"shader-selected-active-hover-near", "shader-collapsed-near",
|
||||
"common-frame-reroute-near", "shader-widget-rich-near",
|
||||
"shader-socket-gallery-near", "geometry-socket-gallery-near",
|
||||
)
|
||||
|
||||
|
||||
def arguments():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--fixture", choices=FIXTURES, required=True)
|
||||
parser.add_argument("--save", type=Path)
|
||||
return parser.parse_args(sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [])
|
||||
|
||||
|
||||
def add(nodes, node_type, name, x, y):
|
||||
node = nodes.new(node_type)
|
||||
node.name = name
|
||||
node.label = name
|
||||
node.location = (x, y)
|
||||
return node
|
||||
|
||||
|
||||
def shader_tree(fixture):
|
||||
material = bpy.data.materials.new("fxnode-reference")
|
||||
material.use_nodes = True
|
||||
tree = material.node_tree
|
||||
tree.nodes.clear()
|
||||
nodes, links = tree.nodes, tree.links
|
||||
output = add(nodes, "ShaderNodeOutputMaterial", "Material Output", 520, 0)
|
||||
principled = add(nodes, "ShaderNodeBsdfPrincipled", "Principled BSDF", 160, 0)
|
||||
noise = add(nodes, "ShaderNodeTexNoise", "Noise Texture", -440, 160)
|
||||
ramp = add(nodes, "ShaderNodeValToRGB", "Color Ramp", -140, 180)
|
||||
links.new(noise.outputs["Fac"], ramp.inputs["Fac"])
|
||||
links.new(ramp.outputs["Color"], principled.inputs["Base Color"])
|
||||
links.new(principled.outputs["BSDF"], output.inputs["Surface"])
|
||||
if fixture == "shader-collapsed-near":
|
||||
noise.hide = True
|
||||
ramp.hide = True
|
||||
elif fixture == "common-frame-reroute-near":
|
||||
frame = add(nodes, "NodeFrame", "Texture controls", -500, 100)
|
||||
noise.parent = frame
|
||||
reroute = add(nodes, "NodeReroute", "Reroute", 60, 260)
|
||||
links.new(ramp.outputs["Color"], reroute.inputs[0])
|
||||
links.new(reroute.outputs[0], principled.inputs["Base Color"])
|
||||
elif fixture == "shader-widget-rich-near":
|
||||
add(nodes, "ShaderNodeMix", "Mix", -140, -240)
|
||||
add(nodes, "ShaderNodeMath", "Math", 160, -280)
|
||||
elif fixture == "shader-socket-gallery-near":
|
||||
add(nodes, "ShaderNodeTexCoord", "Texture Coordinate", -700, -260)
|
||||
add(nodes, "ShaderNodeVectorMath", "Vector Math", -400, -260)
|
||||
add(nodes, "ShaderNodeValue", "Value", -100, -320)
|
||||
add(nodes, "ShaderNodeRGB", "Color", 160, -320)
|
||||
nodes.active = principled
|
||||
principled.select = True
|
||||
return tree
|
||||
|
||||
|
||||
def geometry_tree():
|
||||
obj = bpy.data.objects.new("fxnode-geometry", bpy.data.meshes.new("fxnode-mesh"))
|
||||
bpy.context.collection.objects.link(obj)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
modifier = obj.modifiers.new("fxnode-reference", "NODES")
|
||||
tree = bpy.data.node_groups.new("fxnode-geometry", "GeometryNodeTree")
|
||||
modifier.node_group = tree
|
||||
nodes = tree.nodes
|
||||
for node_type, name, x, y in (
|
||||
("GeometryNodeInputPosition", "Position", -600, 200),
|
||||
("GeometryNodeMeshCube", "Mesh Cube", -600, -100),
|
||||
("GeometryNodeSetPosition", "Set Position", -200, 80),
|
||||
("GeometryNodeTransform", "Transform Geometry", 160, 80),
|
||||
("GeometryNodeJoinGeometry", "Join Geometry", 500, 80),
|
||||
):
|
||||
add(nodes, node_type, name, x, y)
|
||||
return tree
|
||||
|
||||
|
||||
def main():
|
||||
args = arguments()
|
||||
if bpy.app.version[:2] != (4, 5):
|
||||
raise RuntimeError(f"Blender 4.5.x required, found {bpy.app.version_string}")
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
tree = geometry_tree() if args.fixture == "geometry-socket-gallery-near" else shader_tree(args.fixture)
|
||||
for area in bpy.context.screen.areas:
|
||||
if area.type == "NODE_EDITOR":
|
||||
area.spaces.active.pin = True
|
||||
area.spaces.active.geometry_nodes_type = "MODIFIER" if tree.bl_idname == "GeometryNodeTree" else "MODIFIER"
|
||||
with bpy.context.temp_override(area=area):
|
||||
bpy.ops.node.view_all()
|
||||
if args.fixture == "shader-basic-linked-far":
|
||||
bpy.ops.view2d.zoom_out(zoomfacx=3.0, zoomfacy=3.0)
|
||||
if args.save:
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(args.save.resolve()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { APPLICATION_HEADLESS } from "../test/application.js";
|
||||
import { APPLICATION_VERSION } from "../examples/blender/nodes/application.js";
|
||||
|
||||
const { materializeNode } = APPLICATION_HEADLESS;
|
||||
const plain = (id: string, type: Parameters<typeof materializeNode>[1], x: number, y: number) => {
|
||||
return materializeNode(id, type, { x, y });
|
||||
};
|
||||
const byId = <T extends { readonly id: string }>(items: readonly T[]): T[] =>
|
||||
[...items].sort((left, right) => left.id.localeCompare(right.id));
|
||||
const fixtures: Record<string, unknown> = {
|
||||
"control-test": {
|
||||
graphId: "control-test",
|
||||
catalogVersion: APPLICATION_VERSION,
|
||||
nodes: byId([
|
||||
plain("value", "fxnode.shader.value", -500, 200),
|
||||
plain("math", "fxnode.shader.math", -250, 200),
|
||||
plain("vector", "fxnode.shader.vector-math", 20, 200),
|
||||
plain("color", "fxnode.shader.color", 300, 200),
|
||||
plain("group", "fxnode.common.group-input", -100, -100),
|
||||
]),
|
||||
links: [
|
||||
{
|
||||
id: "value-math",
|
||||
fromNodeId: "value",
|
||||
fromSocketId: "value:value",
|
||||
toNodeId: "math",
|
||||
toSocketId: "math:a",
|
||||
muted: false,
|
||||
extensions: {},
|
||||
},
|
||||
],
|
||||
metadata: {},
|
||||
},
|
||||
"link-tools-test": (() => {
|
||||
const nodes = [
|
||||
plain("source-a", "fxnode.shader.value", -560, 240),
|
||||
plain("math-a", "fxnode.shader.math", 180, 240),
|
||||
plain("source-b", "fxnode.shader.value", -560, 120),
|
||||
plain("math-b", "fxnode.shader.math", 180, 120),
|
||||
plain("source-c", "fxnode.shader.value", -560, 0),
|
||||
plain("math-c", "fxnode.shader.math", 180, 0),
|
||||
plain("chain-source", "fxnode.shader.value", -560, -100),
|
||||
plain("reroute", "fxnode.common.reroute", 0, -100),
|
||||
plain("chain-math", "fxnode.shader.math", 260, -100),
|
||||
plain("transform", "fxnode.geometry.transform-geometry", -300, -230),
|
||||
plain("noise", "fxnode.shader.noise-texture", 180, -230),
|
||||
];
|
||||
const links = [
|
||||
["parallel-a", "source-a", "source-a:value", "math-a", "math-a:a"],
|
||||
["parallel-b", "source-b", "source-b:value", "math-b", "math-b:a"],
|
||||
["parallel-c", "source-c", "source-c:value", "math-c", "math-c:a"],
|
||||
["chain-in", "chain-source", "chain-source:value", "reroute", "reroute:input"],
|
||||
["chain-out", "reroute", "reroute:output", "chain-math", "chain-math:a"],
|
||||
] as const;
|
||||
return {
|
||||
graphId: "link-tools-test",
|
||||
catalogVersion: APPLICATION_VERSION,
|
||||
nodes: byId(nodes),
|
||||
links: byId(
|
||||
links.map(([id, fromNodeId, fromSocketId, toNodeId, toSocketId]) => ({
|
||||
id,
|
||||
fromNodeId,
|
||||
fromSocketId,
|
||||
toNodeId,
|
||||
toSocketId,
|
||||
muted: false,
|
||||
extensions: {},
|
||||
})),
|
||||
),
|
||||
metadata: {},
|
||||
};
|
||||
})(),
|
||||
"ramp-test": (() => {
|
||||
const frame = plain("frame", "fxnode.common.frame", -300, 250),
|
||||
ramp = plain("ramp", "fxnode.shader.color-ramp", 40, -40),
|
||||
value = {
|
||||
colorMode: "rgb",
|
||||
interpolation: "linear",
|
||||
hueInterpolation: "near",
|
||||
stops: [
|
||||
{ id: "red", position: 0.1, color: [1, 0, 0, 1] },
|
||||
{ id: "green", position: 0.35, color: [0, 1, 0, 0.8] },
|
||||
{ id: "blue", position: 0.9, color: [0, 0, 1, 0.6] },
|
||||
],
|
||||
};
|
||||
return {
|
||||
graphId: "ramp-test",
|
||||
catalogVersion: APPLICATION_VERSION,
|
||||
nodes: [
|
||||
{ ...frame, size: { x: 380, y: 310 } },
|
||||
{ ...ramp, parentId: "frame", parameters: { ...ramp.parameters, ramp: { kind: "json", value } } },
|
||||
],
|
||||
links: [],
|
||||
metadata: {},
|
||||
};
|
||||
})(),
|
||||
};
|
||||
for (const [name, fixture] of Object.entries(fixtures)) {
|
||||
const url = new URL(`../examples/blender/${name}/initialLayout.json`, import.meta.url),
|
||||
canonical = `${JSON.stringify(fixture, null, 2)}\n`;
|
||||
if (process.argv.includes("--write")) writeFileSync(url, canonical);
|
||||
else if (readFileSync(url, "utf8") !== canonical)
|
||||
throw new Error(`${name} fixture is not canonical; run npm run generate:browser-fixtures`);
|
||||
}
|
||||
if (!process.argv.includes("--write")) console.log("browser fixtures verified");
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const root = new URL("..", import.meta.url).pathname;
|
||||
const docs = join(root, "docs");
|
||||
const output = join(root, "docs/reference/generated");
|
||||
const files = readdirSync(output, { recursive: true }).map(String);
|
||||
const sidebarFile = files.find((file) => /sidebar.*\.json$/i.test(file));
|
||||
assert(sidebarFile, "VitePress sidebar JSON was not generated");
|
||||
const sidebar: unknown = JSON.parse(readFileSync(join(output, sidebarFile), "utf8"));
|
||||
assert(Array.isArray(sidebar), "sidebar root must be the observed VitePress array structure");
|
||||
|
||||
type SidebarItem = { text?: unknown; link?: unknown; items?: unknown };
|
||||
const sidebarItems: SidebarItem[] = [];
|
||||
const visit = (items: unknown): void => {
|
||||
assert(Array.isArray(items), "sidebar items must be arrays");
|
||||
for (const item of items) {
|
||||
assert(item && typeof item === "object", "sidebar entries must be objects");
|
||||
const typed = item as SidebarItem;
|
||||
sidebarItems.push(typed);
|
||||
if (typed.items !== undefined) visit(typed.items);
|
||||
}
|
||||
};
|
||||
visit(sidebar);
|
||||
for (const item of sidebarItems) {
|
||||
if (item.link === undefined) continue;
|
||||
assert(typeof item.link === "string", "sidebar links must be strings");
|
||||
const link = item.link;
|
||||
const relative = link.replace(/^\/reference\/generated\//, "");
|
||||
assert(relative !== link, `sidebar link is outside generated reference: ${link}`);
|
||||
const target = relative.endsWith("/") ? `${relative}index.md` : relative;
|
||||
assert(existsSync(join(output, target)), `broken sidebar link: ${link}`);
|
||||
}
|
||||
const packageModules = sidebarItems
|
||||
.filter(
|
||||
(item): item is SidebarItem & { text: string; link: string } =>
|
||||
typeof item.text === "string" && typeof item.link === "string" && item.link.endsWith("/"),
|
||||
)
|
||||
.map(({ text, link }) => ({ text, link }));
|
||||
assert.deepEqual(packageModules, [
|
||||
{ text: "fxnode", link: "/reference/generated/fxnode/" },
|
||||
{ text: "headless", link: "/reference/generated/fxnode/headless/" },
|
||||
{ text: "color-ramp", link: "/reference/generated/fxnode/widgets/color-ramp/" },
|
||||
]);
|
||||
|
||||
const text = files
|
||||
.filter((file) => file.endsWith(".md") || file.endsWith(".json"))
|
||||
.map((file) => readFileSync(join(output, file), "utf8"))
|
||||
.join("\n");
|
||||
for (const moduleName of ["fxnode", "fxnode/headless", "fxnode/widgets/color-ramp"])
|
||||
assert(text.includes(moduleName), `missing documented package module: ${moduleName}`);
|
||||
for (const denied of ["BoundEngine", "BoundDocument", "bindEngine", "bindDocument", "@lib/"])
|
||||
assert(!text.includes(denied), `implementation name leaked into docs: ${denied}`);
|
||||
for (const line of text.split("\n").filter((line) => line.startsWith("Defined in:")))
|
||||
assert.match(
|
||||
line,
|
||||
/^Defined in: \[[^\]]+:\d+\]\(https:\/\/github\.com\/Heaust-ops\/fxnode\/blob\/main\/[A-Za-z0-9._/-]+#L\d+\)$/,
|
||||
`invalid source location: ${line}`,
|
||||
);
|
||||
for (const denied of ["/home/", "file:", "node\\_modules/", "node_modules/"])
|
||||
assert(!text.includes(denied), `local or dependency path leaked into docs: ${denied}`);
|
||||
|
||||
// VitePress treats a relative link outside docs as a page link and can fail to
|
||||
// report a missing repository source file. Repository TypeScript links must be
|
||||
// durable GitHub links; relative image links deliberately remain supported.
|
||||
const markdownFiles = readdirSync(docs, { recursive: true })
|
||||
.map(String)
|
||||
.filter((file) => file.endsWith(".md") && !file.startsWith("reference/generated/"));
|
||||
for (const file of markdownFiles) {
|
||||
const markdown = readFileSync(join(docs, file), "utf8");
|
||||
for (const match of markdown.matchAll(/(?<!!)\[[^\]]*\]\(([^)]+\.ts(?:#[^)]*)?)\)/g)) {
|
||||
const target = match[1]!;
|
||||
assert(
|
||||
/^https?:\/\//.test(target),
|
||||
`${file}: repository TypeScript source link must be an absolute URL: ${target}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Docs passed (${files.filter((file) => file.endsWith(".md")).length} generated API pages, ${markdownFiles.length} authored pages; ${sidebarFile})`,
|
||||
);
|
||||
@@ -0,0 +1,90 @@
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { APPLICATION_COMPILED, APPLICATION_HEADLESS } from "../test/application.js";
|
||||
import { APPLICATION_VERSION } from "../examples/blender/nodes/application.js";
|
||||
|
||||
const ids = [...APPLICATION_COMPILED.nodes.keys()];
|
||||
if (ids.length !== 22 || new Set(ids).size !== ids.length)
|
||||
throw new Error(`Application composition must contain 22 unique nodes; found ${ids.length}`);
|
||||
if (APPLICATION_COMPILED.nodes.size !== ids.length)
|
||||
throw new Error("Compiled application composition coverage differs from source");
|
||||
if (APPLICATION_COMPILED.version !== APPLICATION_VERSION)
|
||||
throw new Error("Compiled application version differs from its declared version");
|
||||
for (const [index, id] of ids.entries())
|
||||
if (APPLICATION_HEADLESS.materializeNode(`check-${index}`, id).typeId !== id)
|
||||
throw new Error(`Could not materialize ${id}`);
|
||||
const root = new URL("..", import.meta.url).pathname,
|
||||
src = join(root, "src");
|
||||
if (existsSync(join(src, "catalog"))) throw new Error("Published source must not contain an application catalog");
|
||||
const productionFiles = readdirSync(src, { recursive: true })
|
||||
.map(String)
|
||||
.filter((file) => /\.(?:ts|tsx)$/.test(file));
|
||||
const productionText = productionFiles.map((file) => readFileSync(join(src, file), "utf8")).join("\n");
|
||||
for (const forbidden of [
|
||||
/fxnode\.(?:common|shader|geometry|compositor)\./,
|
||||
/\b(?:LEGACY_COMPOSITION|BUILTIN_DESCRIPTORS|DESCRIPTOR_REGISTRY|CATALOG_NODE_IDS|BLENDER_DARK_THEME|BuiltinNodeTypeId|RenderTheme)\b/,
|
||||
/from\s+["'][^"']*examples\//,
|
||||
])
|
||||
if (forbidden.test(productionText)) throw new Error(`Concrete application authority leaked into src: ${forbidden}`);
|
||||
const exampleRoot = join(root, "examples/blender");
|
||||
const nodeRoot = join(exampleRoot, "nodes");
|
||||
const categoryIndexes = existsSync(nodeRoot)
|
||||
? readdirSync(nodeRoot, { recursive: true })
|
||||
.map(String)
|
||||
.filter((file) => file.endsWith("/index.ts"))
|
||||
: [];
|
||||
if (categoryIndexes.length) throw new Error(`Category helper indexes are forbidden: ${categoryIndexes.join(", ")}`);
|
||||
if (existsSync(join(exampleRoot, "application-composition.ts")))
|
||||
throw new Error("Deleted application aggregate was restored");
|
||||
if (existsSync(join(exampleRoot, "application-runtime.ts")))
|
||||
throw new Error("Application runtime was restored under example");
|
||||
for (const browserFile of [
|
||||
"application-browser.ts",
|
||||
"control-test/main.ts",
|
||||
"link-tools-test/main.ts",
|
||||
"ramp-test/main.ts",
|
||||
]) {
|
||||
const text = readFileSync(join(exampleRoot, browserFile), "utf8");
|
||||
if (/test\/application/.test(text)) throw new Error(`${browserFile} imports the test-only application authority`);
|
||||
}
|
||||
for (const externalRoot of ["examples", "test", "tools"])
|
||||
for (const file of readdirSync(join(root, externalRoot), { recursive: true }).map(String)) {
|
||||
if (!/\.tsx?$/.test(file)) continue;
|
||||
const text = readFileSync(join(root, externalRoot, file), "utf8");
|
||||
if (/(?:from\s*|import\s*\()["'](?:\.\.\/)+src(?:\/|["'])/.test(text))
|
||||
throw new Error(`${externalRoot}/${file} must import library source through @lib/`);
|
||||
}
|
||||
for (const sibling of ["minimal", "color-balance", "live-composition"])
|
||||
for (const file of readdirSync(join(root, "examples", sibling), { recursive: true }).map(String)) {
|
||||
if (!/\.tsx?$/.test(file)) continue;
|
||||
const text = readFileSync(join(root, "examples", sibling, file), "utf8");
|
||||
if (/blender\/(?:application-browser|main)|test\/application/.test(text))
|
||||
throw new Error(`examples/${sibling}/${file} imports Blender or test application bootstrap`);
|
||||
}
|
||||
const authoringFiles = [
|
||||
join(root, "test/application.ts"),
|
||||
join(root, "examples/shared/nodes/color-balance.ts"),
|
||||
...(existsSync(nodeRoot)
|
||||
? readdirSync(nodeRoot, { recursive: true })
|
||||
.map(String)
|
||||
.filter((file) => file.endsWith(".ts"))
|
||||
.map((file) => join(nodeRoot, file))
|
||||
: []),
|
||||
];
|
||||
const source = authoringFiles.map((file) => readFileSync(file, "utf8")).join("\n");
|
||||
for (const forbidden of [
|
||||
/Object\.fromEntries/,
|
||||
/\.reduce\s*\(/,
|
||||
/defaultSize/,
|
||||
/src\/catalog/,
|
||||
/src\/render\/theme/,
|
||||
/BUILTIN_DESCRIPTORS/,
|
||||
/LEGACY_COMPOSITION/,
|
||||
/defineFxNodeComposition/,
|
||||
])
|
||||
if (forbidden.test(source))
|
||||
throw new Error(`Application composition is derived through a forbidden adapter: ${forbidden}`);
|
||||
structuredClone(APPLICATION_COMPILED.source);
|
||||
console.log(
|
||||
`application composition: ${ids.length} unique, materializable nodes (version ${APPLICATION_COMPILED.source.version})`,
|
||||
);
|
||||
Vendored
+509
@@ -0,0 +1,509 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = new URL("..", import.meta.url).pathname;
|
||||
const packageJson = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as {
|
||||
exports: Record<string, unknown>;
|
||||
};
|
||||
assert.deepEqual(
|
||||
Object.keys(packageJson.exports).sort(),
|
||||
[".", "./headless", "./widgets/color-ramp"].sort(),
|
||||
"package exports must be exact",
|
||||
);
|
||||
const run = (command: string, args: string[], cwd: string) =>
|
||||
execFileSync(command, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
||||
run("npm", ["run", "build"], root);
|
||||
const declarationSurfaces = {
|
||||
"dist/index.d.ts": [
|
||||
"AddNodeParams",
|
||||
"AnyNodeParameterId",
|
||||
"AnyNodeSocketId",
|
||||
"BatchCommand",
|
||||
"Command",
|
||||
"CommandId",
|
||||
"CommandIntent",
|
||||
"CommandReceipt",
|
||||
"CompiledFxNodeComposition",
|
||||
"CompiledNode",
|
||||
"CompiledResource",
|
||||
"ComposedNode",
|
||||
"ComposedSocket",
|
||||
"CompositionChange",
|
||||
"CompositionChangeEnvelope",
|
||||
"CompositionReceipt",
|
||||
"CompositionUpdateOptions",
|
||||
"CreateFxNodeOptions",
|
||||
"DEFAULT_FXNODE_THEME",
|
||||
"FXNODE_COMPOSITION_LIMITS",
|
||||
"FXNODE_VIEW_LIMITS",
|
||||
"FxNode",
|
||||
"FxNodeActionOptions",
|
||||
"FxNodeAddNodeMenuRequest",
|
||||
"FxNodeCapabilityError",
|
||||
"FxNodeComposition",
|
||||
"FxNodeCompositionData",
|
||||
"FxNodeCompositionError",
|
||||
"FxNodeCompositionIssue",
|
||||
"FxNodeCompositionSeed",
|
||||
"FxNodeCompositionValidation",
|
||||
"FxNodeDefinition",
|
||||
"FxNodeDestroyedError",
|
||||
"FxNodeCamera",
|
||||
"FxNodeGradingBinding",
|
||||
"FxNodeHexColor",
|
||||
"FxNodeHostRequest",
|
||||
"FxNodeHostSnapshot",
|
||||
"FxNodeImageResourceDefinition",
|
||||
"FxNodeImageResourceDescriptor",
|
||||
"FxNodeInput",
|
||||
"FxNodeIssue",
|
||||
"FxNodeJsonValue",
|
||||
"FxNodeMigration",
|
||||
"FxNodeMigrationStep",
|
||||
"FxNodeModifiers",
|
||||
"FxNodeParameterValue",
|
||||
"FxNodeProtocolError",
|
||||
"FxNodeReadonlyMap",
|
||||
"FxNodeReplayCommand",
|
||||
"FxNodeResourceAuthorization",
|
||||
"FxNodeResourceData",
|
||||
"FxNodeResourceDefinition",
|
||||
"FxNodeResourceOpenRequest",
|
||||
"FxNodeSaveData",
|
||||
"FxNodeSelectionSnapshot",
|
||||
"FxNodeSocketDefinition",
|
||||
"FxNodeSocketTypeDefinition",
|
||||
"FxNodeStyleDefinition",
|
||||
"FxNodeTheme",
|
||||
"FxNodeUiRow",
|
||||
"FxNodeValueSchema",
|
||||
"FxNodeViewport",
|
||||
"FxNodeView",
|
||||
"FxNodeViewOptions",
|
||||
"FxNodeViewDetachedError",
|
||||
"FxNodeVisibility",
|
||||
"FxNodeWorkerError",
|
||||
"GraphDocument",
|
||||
"GraphId",
|
||||
"GraphLayoutNodeV1",
|
||||
"GraphLayoutV1",
|
||||
"GraphLayoutV2",
|
||||
"GraphLink",
|
||||
"GraphLinkV1",
|
||||
"GraphNode",
|
||||
"GraphSnapshot",
|
||||
"GraphState",
|
||||
"HeaderStyled",
|
||||
"JsonValue",
|
||||
"KnownNode",
|
||||
"LinkId",
|
||||
"Mutation",
|
||||
"MutationEnvelope",
|
||||
"NodeBase",
|
||||
"NodeId",
|
||||
"NodeParameterId",
|
||||
"NodeSocketId",
|
||||
"NodeStyleId",
|
||||
"NodeTypeId",
|
||||
"ParameterValue",
|
||||
"RemovedNode",
|
||||
"RemovedSocket",
|
||||
"ResourceId",
|
||||
"SnapshotEnvelope",
|
||||
"Socket",
|
||||
"SocketDataType",
|
||||
"SocketId",
|
||||
"SocketTypeId",
|
||||
"Themed",
|
||||
"UnknownNode",
|
||||
"Vec2",
|
||||
"commandId",
|
||||
"compileFxNodeComposition",
|
||||
"composeNode",
|
||||
"composeSocket",
|
||||
"createFxNode",
|
||||
"createInitialFxNodeComposition",
|
||||
"graphId",
|
||||
"fxNodeDevicePixels",
|
||||
"linkId",
|
||||
"nodeId",
|
||||
"removeNode",
|
||||
"removeSocket",
|
||||
"setHeaderStyles",
|
||||
"setTheme",
|
||||
"socketId",
|
||||
"validateFxNodeComposition",
|
||||
],
|
||||
"dist/headless.d.ts": [
|
||||
"AnyNodeParameterId",
|
||||
"AnyNodeSocketId",
|
||||
"BatchCommand",
|
||||
"Command",
|
||||
"CommandError",
|
||||
"CommandId",
|
||||
"CommandRequest",
|
||||
"CompatibleFxNodeSaveData",
|
||||
"CompiledFxNodeComposition",
|
||||
"CompiledNode",
|
||||
"CompiledResource",
|
||||
"ComposedNode",
|
||||
"ComposedSocket",
|
||||
"DEFAULT_FXNODE_THEME",
|
||||
"DecodeResult",
|
||||
"EngineState",
|
||||
"FXNODE_COMPOSITION_LIMITS",
|
||||
"FXNODE_SAVE_DATA_LIMITS",
|
||||
"FxNodeComposition",
|
||||
"FxNodeCompositionData",
|
||||
"FxNodeCompositionError",
|
||||
"FxNodeCompositionIssue",
|
||||
"FxNodeCompositionSeed",
|
||||
"FxNodeCompositionValidation",
|
||||
"FxNodeDefinition",
|
||||
"FxNodeGradingBinding",
|
||||
"FxNodeHeadless",
|
||||
"FxNodeHexColor",
|
||||
"FxNodeImageResourceDefinition",
|
||||
"FxNodeJsonValue",
|
||||
"FxNodeMigration",
|
||||
"FxNodeMigrationStep",
|
||||
"FxNodeParameterValue",
|
||||
"FxNodeReadonlyMap",
|
||||
"FxNodeReplayCommand",
|
||||
"FxNodeResourceDefinition",
|
||||
"FxNodeSaveData",
|
||||
"FxNodeSocketDefinition",
|
||||
"FxNodeSocketTypeDefinition",
|
||||
"FxNodeStyleDefinition",
|
||||
"FxNodeTheme",
|
||||
"FxNodeUiRow",
|
||||
"FxNodeValueSchema",
|
||||
"FxNodeVisibility",
|
||||
"GraphDocument",
|
||||
"GraphId",
|
||||
"GraphLayoutNodeV1",
|
||||
"GraphLayoutV1",
|
||||
"GraphLayoutV2",
|
||||
"GraphLink",
|
||||
"GraphLinkV1",
|
||||
"GraphNode",
|
||||
"GraphSnapshot",
|
||||
"GraphState",
|
||||
"HeaderStyled",
|
||||
"JsonValue",
|
||||
"KnownNode",
|
||||
"LinkId",
|
||||
"LoadResult",
|
||||
"Mutation",
|
||||
"MutationEnvelope",
|
||||
"NodeBase",
|
||||
"NodeId",
|
||||
"NodeParameterId",
|
||||
"NodeSocketId",
|
||||
"NodeStyleId",
|
||||
"NodeTypeId",
|
||||
"ParameterValue",
|
||||
"RemovedNode",
|
||||
"RemovedSocket",
|
||||
"ReplayResult",
|
||||
"ResourceId",
|
||||
"SnapshotEnvelope",
|
||||
"Socket",
|
||||
"SocketDataType",
|
||||
"SocketId",
|
||||
"SocketTypeId",
|
||||
"StateReplacementRequest",
|
||||
"Themed",
|
||||
"TransitionResult",
|
||||
"UnknownNode",
|
||||
"ValidationIssue",
|
||||
"Vec2",
|
||||
"commandId",
|
||||
"compileFxNodeComposition",
|
||||
"composeNode",
|
||||
"composeSocket",
|
||||
"createFxNodeHeadless",
|
||||
"createInitialFxNodeComposition",
|
||||
"graphId",
|
||||
"linkId",
|
||||
"nodeId",
|
||||
"removeNode",
|
||||
"removeSocket",
|
||||
"setHeaderStyles",
|
||||
"setTheme",
|
||||
"socketId",
|
||||
"validateFxNodeComposition",
|
||||
],
|
||||
"dist/widgets/color-ramp.d.ts": [
|
||||
"ColorRamp",
|
||||
"ColorRampInterpolation",
|
||||
"ColorRampMode",
|
||||
"ColorRampStop",
|
||||
"HueInterpolation",
|
||||
"addRampMidpoint",
|
||||
"addRampStop",
|
||||
"distributeColorRamp",
|
||||
"flipColorRamp",
|
||||
"isColorRamp",
|
||||
"migrateColorRamp",
|
||||
"moveRampStop",
|
||||
"removeRampStop",
|
||||
"sampleColorRamp",
|
||||
"selectRampStop",
|
||||
"setRampColor",
|
||||
],
|
||||
} as const;
|
||||
const declarationProgram = ts.createProgram(
|
||||
Object.keys(declarationSurfaces).map((file) => join(root, file)),
|
||||
{
|
||||
module: ts.ModuleKind.NodeNext,
|
||||
moduleResolution: ts.ModuleResolutionKind.NodeNext,
|
||||
},
|
||||
);
|
||||
const declarationChecker = declarationProgram.getTypeChecker();
|
||||
for (const [file, expected] of Object.entries(declarationSurfaces)) {
|
||||
const source = declarationProgram.getSourceFile(join(root, file));
|
||||
assert(source, `missing declaration entrypoint: ${file}`);
|
||||
const moduleSymbol = declarationChecker.getSymbolAtLocation(source);
|
||||
assert(moduleSymbol, `declaration entrypoint is not a module: ${file}`);
|
||||
const actual = declarationChecker
|
||||
.getExportsOfModule(moduleSymbol)
|
||||
.map((symbol) => symbol.name)
|
||||
.sort();
|
||||
assert.deepEqual(actual, [...expected].sort(), `declaration exports changed for ${file}`);
|
||||
}
|
||||
const builtFiles = readdirSync(join(root, "dist"), { recursive: true })
|
||||
.map(String)
|
||||
.filter((file) => /\.(?:js|d\.ts)$/.test(file));
|
||||
const builtText = builtFiles.map((file) => readFileSync(join(root, "dist", file), "utf8")).join("\n");
|
||||
const runtimeSurfaces = {
|
||||
".": [
|
||||
"DEFAULT_FXNODE_THEME",
|
||||
"FXNODE_COMPOSITION_LIMITS",
|
||||
"FXNODE_VIEW_LIMITS",
|
||||
"FxNodeCapabilityError",
|
||||
"FxNodeCompositionError",
|
||||
"FxNodeDestroyedError",
|
||||
"FxNodeViewDetachedError",
|
||||
"FxNodeProtocolError",
|
||||
"FxNodeWorkerError",
|
||||
"commandId",
|
||||
"compileFxNodeComposition",
|
||||
"composeNode",
|
||||
"composeSocket",
|
||||
"createFxNode",
|
||||
"createInitialFxNodeComposition",
|
||||
"graphId",
|
||||
"fxNodeDevicePixels",
|
||||
"linkId",
|
||||
"nodeId",
|
||||
"removeNode",
|
||||
"removeSocket",
|
||||
"setHeaderStyles",
|
||||
"setTheme",
|
||||
"socketId",
|
||||
"validateFxNodeComposition",
|
||||
],
|
||||
"./headless": [
|
||||
"DEFAULT_FXNODE_THEME",
|
||||
"FXNODE_COMPOSITION_LIMITS",
|
||||
"FXNODE_SAVE_DATA_LIMITS",
|
||||
"FxNodeCompositionError",
|
||||
"commandId",
|
||||
"compileFxNodeComposition",
|
||||
"composeNode",
|
||||
"composeSocket",
|
||||
"createFxNodeHeadless",
|
||||
"createInitialFxNodeComposition",
|
||||
"graphId",
|
||||
"linkId",
|
||||
"nodeId",
|
||||
"removeNode",
|
||||
"removeSocket",
|
||||
"setHeaderStyles",
|
||||
"setTheme",
|
||||
"socketId",
|
||||
"validateFxNodeComposition",
|
||||
],
|
||||
"./widgets/color-ramp": [
|
||||
"addRampMidpoint",
|
||||
"addRampStop",
|
||||
"distributeColorRamp",
|
||||
"flipColorRamp",
|
||||
"isColorRamp",
|
||||
"migrateColorRamp",
|
||||
"moveRampStop",
|
||||
"removeRampStop",
|
||||
"sampleColorRamp",
|
||||
"selectRampStop",
|
||||
"setRampColor",
|
||||
],
|
||||
} as const;
|
||||
for (const [entry, expected] of Object.entries(runtimeSurfaces)) {
|
||||
const target = packageJson.exports[entry] as { import: string };
|
||||
const actual = Object.keys(await import(join(root, target.import))).sort();
|
||||
assert.deepEqual(actual, [...expected].sort(), `runtime exports changed for ${entry}`);
|
||||
}
|
||||
for (const symbol of [
|
||||
"LEGACY_COMPOSITION",
|
||||
"INTERNAL_LEGACY_HEADLESS",
|
||||
"BUILTIN_DESCRIPTORS",
|
||||
"CATALOG_NODE_IDS",
|
||||
"BuiltinNodeTypeId",
|
||||
"DESCRIPTOR_REGISTRY",
|
||||
"BLENDER_DARK_THEME",
|
||||
"getDescriptor",
|
||||
])
|
||||
assert(!builtText.includes(symbol), `obsolete concrete symbol built: ${symbol}`);
|
||||
assert(!builtText.includes("defineFxNodeComposition"), "removed composition identity helper was built");
|
||||
assert(
|
||||
!builtFiles.some((file) => /^composition\/define\.(?:js|d\.ts)$/.test(file)),
|
||||
"stale composition/define output was built",
|
||||
);
|
||||
const temporary = mkdtempSync(join(tmpdir(), "fxnode-package-"));
|
||||
try {
|
||||
const json = JSON.parse(run("npm", ["pack", "--dry-run", "--json", "--ignore-scripts"], root)) as {
|
||||
files: { path: string }[];
|
||||
}[];
|
||||
const files = json[0]?.files.map((item) => item.path) ?? [];
|
||||
assert(
|
||||
files.includes("dist/index.js") &&
|
||||
files.includes("dist/headless.js") &&
|
||||
files.includes("dist/index.d.ts") &&
|
||||
files.includes("LICENSE") &&
|
||||
files.includes("NOTICE.md"),
|
||||
);
|
||||
assert(
|
||||
!files.some((file) => /(^|\/)(\.env|src|test|tools|playwright)(\/|\.|$)/i.test(file)),
|
||||
`forbidden package file: ${files.join(", ")}`,
|
||||
);
|
||||
assert(!files.some((file) => /^docs\//.test(file)), "documentation must not be packed");
|
||||
assert(
|
||||
!files.some((file) => /dist\/(catalog|core\/document|engine\/engine|render\/theme)(\/|\.|$)/.test(file)),
|
||||
"obsolete compatibility module was built",
|
||||
);
|
||||
assert(!files.some((file) => /dist\/browser\/add-node-menu\./.test(file)), "host-owned add-node menu was built");
|
||||
const packed = run("npm", ["pack", "--json", "--ignore-scripts", "--pack-destination", temporary], root);
|
||||
const tarball = join(temporary, (JSON.parse(packed) as { filename: string }[])[0]!.filename);
|
||||
const consumer = join(temporary, "consumer");
|
||||
run("mkdir", [consumer], temporary);
|
||||
writeFileSync(
|
||||
join(consumer, "package.json"),
|
||||
JSON.stringify({
|
||||
private: true,
|
||||
type: "module",
|
||||
dependencies: { fxnode: `file:${tarball}`, typescript: "5.7.2", vite: "6.1.0" },
|
||||
}),
|
||||
);
|
||||
run("npm", ["install", "--ignore-scripts"], consumer);
|
||||
writeFileSync(
|
||||
join(consumer, "ramp.mjs"),
|
||||
"import { isColorRamp } from 'fxnode/widgets/color-ramp'; if (typeof isColorRamp !== 'function') throw new Error('color ramp export missing');\n",
|
||||
);
|
||||
run("node", ["ramp.mjs"], consumer);
|
||||
writeFileSync(
|
||||
join(consumer, "headless.mjs"),
|
||||
"import * as fxnode from 'fxnode/headless'; if (!fxnode.createFxNodeHeadless || !fxnode.compileFxNodeComposition || !fxnode.compileFxNodeComposition || fxnode.bindFxNodeHeadless || fxnode.createEngine || fxnode.materializeNode || fxnode.socketsCompatible) throw new Error('headless export boundary invalid');\n",
|
||||
);
|
||||
run("node", ["headless.mjs"], consumer);
|
||||
writeFileSync(
|
||||
join(consumer, "main.ts"),
|
||||
`import { createFxNode, compileFxNodeComposition, type CompositionChange, type CompositionChangeEnvelope, type CompositionReceipt, type CompositionUpdateOptions, type FxNode, type FxNodeSaveData, type FxNodeView, type FxNodeViewOptions, type NodeTypeId } from 'fxnode';
|
||||
import { createFxNodeHeadless } from 'fxnode/headless';
|
||||
// @ts-expect-error color ramps have a dedicated entrypoint
|
||||
import { isColorRamp } from 'fxnode';
|
||||
// @ts-expect-error protocol-only version expectation is not public
|
||||
import type { VersionExpectation } from 'fxnode';
|
||||
// @ts-expect-error implementation-bound state names are not public
|
||||
import type { BoundEngineState } from 'fxnode/headless';
|
||||
type Equal<A,B> = (<T>()=>T extends A?1:2) extends (<T>()=>T extends B?1:2) ? true : false;
|
||||
type Expect<T extends true> = T;
|
||||
const color='#000000' as const, theme={background:color,grid:color,frame:color,frameHeader:color,body:color,control:color,controlFill:color,controlEditing:color,textSelection:color,outline:color,text:color,muted:color,shadow:color,nodeSelected:color,nodeActive:color,unknownHeader:color,unknownSocket:color,linkMuted:color,knifeMuted:color,emphasis:color,focus:color,editOutline:color,resize:color,muteOverlay:color,boxSelectionFill:color,checkerLight:color,checkerDark:color,widgetBorder:color,rampBorder:color,resourceBackground:color};
|
||||
const composition={schemaVersion:2,id:'consumer',version:1,compatibility:{wildcardInputTypes:[]},theme,socketTypes:{value:{title:'Value',color,acceptsFrom:['value']}},nodeStyles:{basic:{header:color}},resources:{},nodes:{tiny:{version:1,title:'Tiny',behavior:'standard',style:'basic',parameters:{},sockets:{},ui:[],muteBypass:[],migrations:[]}}} as const;
|
||||
type InferredNode = Expect<Equal<NodeTypeId<typeof composition>,'tiny'>>;
|
||||
const compiled=compileFxNodeComposition(composition); compiled.nodes.get('tiny');
|
||||
// @ts-expect-error packed declarations retain exact node IDs
|
||||
compiled.nodes.get('missing');
|
||||
const runtime=createFxNodeHeadless(composition); runtime.materializeNode('n','tiny');
|
||||
// @ts-expect-error complete compilation rejects unknown style references
|
||||
compileFxNodeComposition({...composition,nodes:{tiny:{...composition.nodes.tiny,style:'missing'}}});
|
||||
async function liveCompositionSmoke(api:FxNode,options:CompositionUpdateOptions):Promise<void>{
|
||||
const canvas=null as unknown as HTMLCanvasElement,viewOptions:FxNodeViewOptions={canvas,viewport:{width:1,height:1,dpr:1}};
|
||||
const view:FxNodeView=await api.attachView(viewOptions);view.id;view.feedInput({kind:'focus',phase:'focus'});await view.detach();
|
||||
// @ts-expect-error view handles do not own graph authority
|
||||
view.dispatch({type:'undo'});
|
||||
// @ts-expect-error root authority does not own presentation input
|
||||
api.feedInput({kind:'focus',phase:'focus'});
|
||||
// @ts-expect-error removed copy API
|
||||
api.copyTo(canvas);
|
||||
// @ts-expect-error removed mirror API
|
||||
api.addMirror(canvas);
|
||||
const saveData:FxNodeSaveData=await api.getSaveData();await api.load(saveData);
|
||||
const before=await api.getState(),known=before.nodes.find(node=>node.known);if(known){type MutableOutput=Expect<Equal<typeof known.typeId,string>>;void(null as MutableOutput|null);}
|
||||
const themed=await api.setTheme(theme,{expectedRevision:options.expectedRevision??0});
|
||||
const receipt:CompositionReceipt=themed;
|
||||
if(receipt.status==='committed'){const reset:true=receipt.historyReset;void reset;}else{const reset:false=receipt.historyReset,changed:false=receipt.graphChanged;void reset;void changed;}
|
||||
const socket=await api.composeSocket('dynamic',{title:'Dynamic',color,acceptsFrom:['dynamic']},{expectedRevision:themed.revision});
|
||||
const node=await api.composeNode('dynamic-node',{version:1,title:'Dynamic',behavior:'standard',style:'basic',parameters:{},sockets:{out:{title:'Out',direction:'output',type:'dynamic',maxIncomingLinks:0,visible:true,value:null,showValue:false}},ui:[{kind:'socket',socket:'out'}],muteBypass:[],migrations:[]},{expectedRevision:socket.revision});
|
||||
await api.dispatch({type:'node.add',nodeType:'dynamic-node',position:{x:0,y:0}});
|
||||
const removedNode=await api.removeNode('dynamic-node',{expectedRevision:node.revision});
|
||||
const removedSocket=await api.removeSocket('dynamic',{expectedRevision:removedNode.revision});void removedSocket;
|
||||
await api.loadComposition(composition);
|
||||
api.onCompositionChanges(event=>{const envelope:CompositionChangeEnvelope=event,change:CompositionChange=event.change;envelope.revision;if(change.kind!=='theme.set'&&change.kind!=='header-styles.set'&&change.kind!=='compatibility.set'&&change.kind!=='composition.load')change.id;
|
||||
// @ts-expect-error composition events do not expose definition payloads
|
||||
event.change.definition;});
|
||||
}
|
||||
async function ownershipSmoke():Promise<void>{
|
||||
const api:FxNode=await createFxNode({applicationId:'consumer',applicationVersion:1,resources:composition.resources});
|
||||
const canvas=null as unknown as HTMLCanvasElement,view:FxNodeView=await api.attachView({canvas,viewport:{width:1,height:1,dpr:1}});
|
||||
view.getHostSnapshot();const offHost=view.subscribeHost(()=>{}),offRequests=view.onHostRequests(()=>{});view.feedInput({kind:'focus',phase:'focus'});view.setViewport({width:2,height:2,dpr:1});
|
||||
await view.addNode({typeId:'tiny',viewPosition:{x:0,y:0}});await view.removeSelected();await view.setSelectedMuted(false);await view.provideResource({viewId:view.id,token:'token',graphVersion:0,compositionRevision:0},{name:'x',mime:'image/png',bytes:new ArrayBuffer(1)});await view.whenRendered();offHost();offRequests();await view.detach();
|
||||
await api.getState();api.onSnapshots(()=>{});api.onMutations(()=>{});await api.dispatch({type:'undo'});await api.setTheme(theme);api.onCompositionChanges(()=>{});
|
||||
// @ts-expect-error views do not own graph snapshots
|
||||
view.getState();
|
||||
// @ts-expect-error views do not own composition mutation
|
||||
view.setTheme(theme);
|
||||
// @ts-expect-error roots do not own rendering barriers
|
||||
api.whenRendered();
|
||||
api.destroy();
|
||||
}
|
||||
void runtime; void compiled; void liveCompositionSmoke; void ownershipSmoke; void (null as InferredNode | null);
|
||||
`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(consumer, "tsconfig.json"),
|
||||
JSON.stringify({
|
||||
compilerOptions: {
|
||||
strict: true,
|
||||
noEmit: true,
|
||||
target: "ES2022",
|
||||
module: "NodeNext",
|
||||
moduleResolution: "NodeNext",
|
||||
skipLibCheck: true,
|
||||
},
|
||||
include: ["main.ts"],
|
||||
}),
|
||||
);
|
||||
run("npx", ["tsc"], consumer);
|
||||
writeFileSync(join(consumer, "index.html"), "<script type=module src=/main.ts></script>");
|
||||
run("npx", ["vite", "build", "--base", "./"], consumer);
|
||||
const output = readdirSync(join(consumer, "dist", "assets"));
|
||||
const js = output
|
||||
.filter((file) => file.endsWith(".js"))
|
||||
.map((file) => readFileSync(join(consumer, "dist", "assets", file), "utf8"))
|
||||
.join("\n");
|
||||
assert(!js.includes('new URL("/assets/'), "worker URL is root-absolute");
|
||||
const packageIndex = readFileSync(join(consumer, "node_modules", "fxnode", "dist", "index.js"), "utf8");
|
||||
assert(!packageIndex.includes('new URL("/assets/'), "packed worker URL is root-absolute");
|
||||
const workerMatch = packageIndex.match(/"assets\/([^"?]*worker[^"?]*\.js)"/);
|
||||
assert(workerMatch, "packed package has no package-relative worker URL");
|
||||
const packagedAssets = readdirSync(join(consumer, "node_modules", "fxnode", "dist", "assets"));
|
||||
assert(packagedAssets.includes(workerMatch[1]!), `worker asset ${workerMatch[1]} does not exist`);
|
||||
console.log(`package smoke passed (${files.length} files; worker assets/${workerMatch[1]})`);
|
||||
} finally {
|
||||
rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
Vendored
+45
@@ -0,0 +1,45 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const root = new URL("..", import.meta.url).pathname;
|
||||
const readme = readFileSync(join(root, "README.md"), "utf8");
|
||||
const fixture = readFileSync(join(root, "test/readme-snippets.compile.ts"), "utf8");
|
||||
const links = [...readme.matchAll(/!?\[[^\]]*\]\(([^)]+)\)/g)].map((match) => match[1]!);
|
||||
for (const link of links) {
|
||||
if (/^(?:https:\/\/|LICENSE$|NOTICE\.md$)/.test(link)) continue;
|
||||
assert.fail(`README link is not package-safe: ${link}`);
|
||||
}
|
||||
for (const stale of ["docs/reference/generated", "docs/.vitepress", "](docs/", "](examples/", "](src/"]) {
|
||||
assert(!readme.includes(stale), `README contains generated, VitePress-root, or unpacked path: ${stale}`);
|
||||
}
|
||||
for (const required of [
|
||||
"theme: exampleTheme",
|
||||
"createFxNodeHeadless({",
|
||||
"api.composeSocket(...numberSocket)",
|
||||
"api.composeNode(...valueNode)",
|
||||
'window.addEventListener("pagehide", cleanup)',
|
||||
"if (cleaned) created.destroy()",
|
||||
"api = null",
|
||||
"host.destroy()",
|
||||
"const gradingWheelsRow = {",
|
||||
'satisfies FxNodeDefinition["ui"][number]',
|
||||
"async function installColorBalance(api: FxNode)",
|
||||
]) {
|
||||
assert(readme.includes(required), `README current-API regression: ${required}`);
|
||||
if (
|
||||
["theme: exampleTheme", "const gradingWheelsRow = {", "async function installColorBalance(api: FxNode)"].includes(
|
||||
required,
|
||||
)
|
||||
)
|
||||
assert(
|
||||
fixture.includes(
|
||||
required
|
||||
.replace("const gradingWheelsRow", "export const gradingWheelsRow")
|
||||
.replace("async function", "export async function"),
|
||||
),
|
||||
`compile fixture is not synchronized: ${required}`,
|
||||
);
|
||||
}
|
||||
assert(!readme.includes("Immutable current graph snapshot"), "getState must not claim runtime immutability");
|
||||
console.log(`README checks passed (${readme.split("\n").length - 1} lines, ${links.length} links).`);
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { referenceManifest } from "@lib/research/reference-manifest.js";
|
||||
import { REFERENCE_IDS } from "@lib/research/reference-types.js";
|
||||
|
||||
const strict = process.argv.includes("--strict");
|
||||
const sha256Pattern = /^[a-f0-9]{64}$/u;
|
||||
const ids = referenceManifest.references.map(({ id }) => id);
|
||||
if (
|
||||
ids.length !== REFERENCE_IDS.length ||
|
||||
new Set(ids).size !== REFERENCE_IDS.length ||
|
||||
REFERENCE_IDS.some((id) => !ids.includes(id))
|
||||
) {
|
||||
throw new Error("Reference manifest must contain each of the eight reference IDs exactly once.");
|
||||
}
|
||||
if (!sha256Pattern.test(referenceManifest.baseline.binarySha256)) throw new Error("Invalid baseline binary SHA256.");
|
||||
|
||||
let captured = 0;
|
||||
for (const reference of referenceManifest.references) {
|
||||
if (reference.status === "pending") {
|
||||
if (!reference.reason.trim()) throw new Error(`${reference.id}: pending references require a reason.`);
|
||||
continue;
|
||||
}
|
||||
captured++;
|
||||
if (!sha256Pattern.test(reference.sha256)) throw new Error(`${reference.id}: invalid SHA256.`);
|
||||
const bytes = await readFile(resolve(reference.relativePath));
|
||||
const actual = createHash("sha256").update(bytes).digest("hex");
|
||||
if (actual !== reference.sha256) throw new Error(`${reference.id}: hash mismatch.`);
|
||||
if (bytes.subarray(0, 8).toString("hex") !== "89504e470d0a1a0a") throw new Error(`${reference.id}: not a PNG.`);
|
||||
if (bytes.readUInt32BE(16) !== reference.width || bytes.readUInt32BE(20) !== reference.height)
|
||||
throw new Error(`${reference.id}: dimensions mismatch.`);
|
||||
}
|
||||
if (strict && captured !== REFERENCE_IDS.length)
|
||||
throw new Error(`Strict reference check: ${captured}/8 captured; ${8 - captured} pending.`);
|
||||
console.log(
|
||||
`references: manifest valid; ${captured}/8 captured, ${8 - captured}/8 pending${strict ? " (strict)" : ""}`,
|
||||
);
|
||||
Vendored
+46
@@ -0,0 +1,46 @@
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { APPLICATION_COMPILED } from "../test/application.js";
|
||||
import { APPLICATION_HEADLESS } from "../test/application.js";
|
||||
import type { NodeTypeId } from "@lib/composition/index.js";
|
||||
import { graphId, linkId, socketId, type GraphDocument } from "@lib/core/types.js";
|
||||
import { nullRecord } from "@lib/core/json.js";
|
||||
|
||||
const { materializeNode, save } = APPLICATION_HEADLESS;
|
||||
const specs: readonly [string, NodeTypeId<typeof APPLICATION_COMPILED.source>, number, number][] = [
|
||||
["image-texture", "fxnode.shader.image-texture", -520, 180],
|
||||
["noise-3d", "fxnode.shader.noise-texture", -220, 180],
|
||||
["noise-4d", "fxnode.shader.noise-texture", 60, 180],
|
||||
["color-ramp", "fxnode.shader.color-ramp", 350, 180],
|
||||
["compositor-image", "fxnode.compositor.image", -340, -260],
|
||||
["master", "fxnode.compositor.color-balance", 20, -260],
|
||||
];
|
||||
const nodes = specs.map(([id, type, x, y]) => {
|
||||
const node = materializeNode(id, type, { x, y });
|
||||
if (id === "noise-4d")
|
||||
return { ...node, parameters: { ...node.parameters, dimensions: { kind: "string" as const, value: "4d" } } };
|
||||
if (id === "master") return { ...node, label: "Master Color Grading" };
|
||||
return node;
|
||||
});
|
||||
const link = {
|
||||
id: linkId("compositor-grade"),
|
||||
fromNodeId: nodes[4]!.id,
|
||||
fromSocketId: socketId("compositor-image:image"),
|
||||
toNodeId: nodes[5]!.id,
|
||||
toSocketId: socketId("master:image"),
|
||||
muted: false,
|
||||
extensions: {},
|
||||
};
|
||||
const document: GraphDocument = {
|
||||
schemaVersion: 2,
|
||||
graphId: graphId("parity"),
|
||||
catalogVersion: APPLICATION_COMPILED.source.version,
|
||||
nodes: nullRecord(nodes.map((n) => [n.id, n])),
|
||||
links: nullRecord([[link.id, link]]),
|
||||
metadata: nullRecord(),
|
||||
};
|
||||
const saved = save(document);
|
||||
const { schemaVersion: _schemaVersion, ...layout } = saved;
|
||||
writeFileSync(
|
||||
new URL("../examples/blender/parity/initialLayout.json", import.meta.url),
|
||||
JSON.stringify({ ...layout, nodes: layout.nodes.map((node) => ({ ...node, known: true })) }, null, 2) + "\n",
|
||||
);
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { LooseQuadtree } from "@lib/layout/spatial-index.js";
|
||||
|
||||
const seed = 7,
|
||||
nodes = 5000,
|
||||
links = 10000,
|
||||
viewport = { x: 1200, y: 800, dpr: 2 };
|
||||
const nodeIndex = new LooseQuadtree<number>(),
|
||||
linkIndex = new LooseQuadtree<number>();
|
||||
const start = performance.now();
|
||||
for (let i = 0; i < nodes; i++) {
|
||||
const x = (i % 100) * 400,
|
||||
y = Math.floor(i / 100) * 300;
|
||||
nodeIndex.insert(`n${i}`, i, { minX: x, minY: y - 180, maxX: x + 180, maxY: y });
|
||||
}
|
||||
for (let i = 0; i < links; i++) {
|
||||
const a = i % nodes,
|
||||
row = Math.floor(a / 100),
|
||||
b = row * 100 + (((a % 100) + 1 + (i % 7)) % 100),
|
||||
ax = (a % 100) * 400 + 180,
|
||||
ay = row * 300 - 90,
|
||||
bx = (b % 100) * 400,
|
||||
by = row * 300 - 90;
|
||||
linkIndex.insert(`l${i}`, i, {
|
||||
minX: Math.min(ax, bx),
|
||||
minY: Math.min(ay, by),
|
||||
maxX: Math.max(ax, bx),
|
||||
maxY: Math.max(ay, by),
|
||||
});
|
||||
}
|
||||
const buildMs = performance.now() - start,
|
||||
times: number[] = [],
|
||||
counts: number[] = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const x = (i * 997) % 38000,
|
||||
y = (i * 431) % 14000,
|
||||
s = performance.now();
|
||||
const nc = nodeIndex.query({ minX: x, minY: y - viewport.y, maxX: x + viewport.x, maxY: y }).length,
|
||||
lc = linkIndex.query({ minX: x, minY: y - viewport.y, maxX: x + viewport.x, maxY: y }).length;
|
||||
times.push(performance.now() - s);
|
||||
counts.push(nc + lc);
|
||||
}
|
||||
times.sort((a, b) => a - b);
|
||||
counts.sort((a, b) => a - b);
|
||||
const p = (a: number[], q: number) => a[Math.floor((a.length - 1) * q)]!;
|
||||
const result = {
|
||||
seed,
|
||||
nodes,
|
||||
links,
|
||||
viewport,
|
||||
buildMs: +buildMs.toFixed(3),
|
||||
candidateP50: p(counts, 0.5),
|
||||
candidateP95: p(counts, 0.95),
|
||||
queryMsP50: +p(times, 0.5).toFixed(3),
|
||||
queryMsP95: +p(times, 0.95).toFixed(3),
|
||||
cullingP95: +(1 - p(counts, 0.95) / (nodes + links)).toFixed(4),
|
||||
};
|
||||
console.log(JSON.stringify(result));
|
||||
if (
|
||||
process.argv.includes("--check") &&
|
||||
(result.seed !== 7 ||
|
||||
result.nodes !== 5000 ||
|
||||
result.links !== 10000 ||
|
||||
result.viewport.x !== 1200 ||
|
||||
result.viewport.y !== 800 ||
|
||||
result.viewport.dpr !== 2 ||
|
||||
times.length !== 100 ||
|
||||
result.candidateP50 !== 72 ||
|
||||
result.candidateP95 !== 75 ||
|
||||
result.cullingP95 < 0.9)
|
||||
)
|
||||
throw new Error(`Phase 7 deterministic workload baseline missed: ${JSON.stringify(result)}`);
|
||||
Reference in New Issue
Block a user