feat: add variadic render graph logic nodes

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:
Amp
2026-07-29 10:03:06 +00:00
co-authored by heaust
parent 0e866596c0
commit 90406ae18f
34 changed files with 1031 additions and 273 deletions
+124 -56
View File
@@ -306,10 +306,10 @@ pub fn parse_and_compile(bytes: &[u8]) -> Result<CompiledGraph, GraphError> {
.map_err(|_| GraphError::new("GRAPH_ENCODING_INVALID", "graph payload is not UTF-8"))?;
let probe: serde_json::Value = serde_json::from_str(text)
.map_err(|e| GraphError::new("GRAPH_JSON_INVALID", e.to_string()))?;
if probe.get("schemaVersion").and_then(|v| v.as_u64()) != Some(2) {
if probe.get("schemaVersion").and_then(|v| v.as_u64()) != Some(3) {
return Err(GraphError::new(
"GRAPH_SCHEMA_UNSUPPORTED",
"schemaVersion must be 2",
"schemaVersion must be 3",
));
}
let graph = serde_json::from_str(text)
@@ -790,15 +790,20 @@ fn decode(node: &Node, i: usize) -> Result<NormalizedParameters, GraphError> {
base.clone(),
)
})?;
if object.len() != contract.inputs.len() {
let default_inputs: Vec<_> = contract
.inputs
.iter()
.filter(|input| input.cardinality.max == 1)
.collect();
if object.len() != default_inputs.len() {
return Err(error(
"GRAPH_PARAMETERS_INVALID",
"expression defaults must exactly match inputs",
base,
));
}
let mut defaults = Vec::with_capacity(contract.inputs.len());
for input in contract.inputs {
let mut defaults = Vec::with_capacity(default_inputs.len());
for input in default_inputs {
let key = format!("{}Default", input.name);
let value = object.get(&key).ok_or_else(|| {
error(
@@ -841,7 +846,7 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
}
let mut input_count = 0usize;
for (i, node) in graph.nodes.iter().enumerate() {
input_count = input_count.saturating_add(node.inputs.len());
input_count = input_count.saturating_add(node.inputs.values().map(Vec::len).sum::<usize>());
if input_count > 8192 {
return Err(error(
"GRAPH_LIMIT_EXCEEDED",
@@ -850,10 +855,10 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
));
}
}
if graph.schema_version != 2 {
if graph.schema_version != 3 {
return Err(GraphError::new(
"GRAPH_SCHEMA_UNSUPPORTED",
"schemaVersion must be 2",
"schemaVersion must be 3",
));
}
validate_name_length(&graph.graph_id, "graphId")?;
@@ -864,13 +869,14 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
] {
validate_name_length(value, path)?;
}
for (socket, r) in &n.inputs {
for (value, path) in [
(socket, format!("nodes[{i}].inputs.{socket}")),
(&r.node, format!("nodes[{i}].inputs.{socket}.node")),
(&r.socket, format!("nodes[{i}].inputs.{socket}.socket")),
] {
validate_name_length(value, path)?;
for (socket, refs) in &n.inputs {
validate_name_length(socket, format!("nodes[{i}].inputs.{socket}"))?;
for (index, r) in refs.iter().enumerate() {
validate_name_length(&r.node, format!("nodes[{i}].inputs.{socket}[{index}].node"))?;
validate_name_length(
&r.socket,
format!("nodes[{i}].inputs.{socket}[{index}].socket"),
)?;
}
}
}
@@ -891,27 +897,33 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
format!("nodes[{i}].id"),
));
}
for (socket, r) in &n.inputs {
for (value, path) in [
(socket, format!("nodes[{i}].inputs.{socket}")),
(&r.node, format!("nodes[{i}].inputs.{socket}.node")),
(&r.socket, format!("nodes[{i}].inputs.{socket}.socket")),
] {
validate_name_grammar(value, path)?;
for (socket, refs) in &n.inputs {
validate_name_grammar(socket, format!("nodes[{i}].inputs.{socket}"))?;
for (index, r) in refs.iter().enumerate() {
validate_name_grammar(
&r.node,
format!("nodes[{i}].inputs.{socket}[{index}].node"),
)?;
validate_name_grammar(
&r.socket,
format!("nodes[{i}].inputs.{socket}[{index}].socket"),
)?;
}
}
}
for (i, n) in graph.nodes.iter().enumerate() {
for (s, r) in &n.inputs {
for (s, refs) in &n.inputs {
for (index, r) in refs.iter().enumerate() {
if !ids.contains_key(r.node.as_str()) {
return Err(error(
"GRAPH_UNKNOWN_NODE",
"unknown input node",
format!("nodes[{i}].inputs.{s}.node"),
format!("nodes[{i}].inputs.{s}[{index}].node"),
));
}
}
}
}
let contracts: Vec<_> = graph
.nodes
.iter()
@@ -978,27 +990,36 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
}
}
for (i, n) in graph.nodes.iter().enumerate() {
for (name, r) in &n.inputs {
for (name, refs) in &n.inputs {
for (index, r) in refs.iter().enumerate() {
let pn = ids[r.node.as_str()];
if !contracts[pn].outputs.iter().any(|out| out.name == r.socket) {
return Err(error(
"GRAPH_UNKNOWN_SOCKET",
"unknown output socket",
format!("nodes[{i}].inputs.{name}.socket"),
format!("nodes[{i}].inputs.{name}[{index}].socket"),
));
}
}
}
}
for (i, n) in graph.nodes.iter().enumerate() {
for input in contracts[i].inputs {
if !n.inputs.contains_key(input.name) {
if input.cardinality == InputCardinality::RequiredOne {
let sources = n.inputs.get(input.name);
if sources.is_some_and(Vec::is_empty) {
return Err(error(
"GRAPH_SOCKET_CARDINALITY",
"required input is missing",
"present input bindings must not be empty",
format!("nodes[{i}].inputs.{}", input.name),
));
}
let count = sources.map_or(0, Vec::len);
if count < input.cardinality.min as usize || count > input.cardinality.max as usize {
return Err(error(
"GRAPH_SOCKET_CARDINALITY",
"input binding count is outside the accepted range",
format!("nodes[{i}].inputs.{}", input.name),
));
}
}
}
@@ -1006,9 +1027,10 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
let mut bound: Vec<BTreeMap<&str, BoundInput>> = vec![BTreeMap::new(); graph.nodes.len()];
for (i, n) in graph.nodes.iter().enumerate() {
for input in contracts[i].inputs {
let Some(r) = n.inputs.get(input.name) else {
let Some(refs) = n.inputs.get(input.name) else {
continue;
};
for (source_index, r) in refs.iter().enumerate() {
let pn = ids[r.node.as_str()];
let (ordinal, out) = contracts[pn]
.outputs
@@ -1020,9 +1042,10 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
return Err(error(
"GRAPH_SOCKET_TYPE_MISMATCH",
"socket type mismatch",
format!("nodes[{i}].inputs.{}", input.name),
format!("nodes[{i}].inputs.{}[{source_index}]", input.name),
));
}
if source_index == 0 {
bound[i].insert(
input.name,
BoundInput {
@@ -1031,10 +1054,32 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
);
}
}
}
}
let mut edges = Vec::new();
for i in 0..graph.nodes.len() {
for (input_ordinal, input) in contracts[i].inputs.iter().enumerate() {
if let Some(b) = bound[i].get(input.name) {
for (source_index, b) in graph.nodes[i]
.inputs
.get(input.name)
.into_iter()
.flatten()
.enumerate()
.map(|(source_index, r)| {
let pn = ids[r.node.as_str()];
let ordinal = contracts[pn]
.outputs
.iter()
.position(|o| o.name == r.socket)
.unwrap();
(
source_index,
BoundInput {
producer: OutputKey(pn, ordinal as u16),
},
)
})
{
edges.push(DependencyEdge {
from_node: b.producer.0,
from_socket: contracts[b.producer.0].outputs[b.producer.1 as usize]
@@ -1044,7 +1089,7 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
to_node: i,
to_socket: input.name.into(),
consumer_input_ordinal: input_ordinal as u16,
resource: graph.nodes[i].inputs[input.name].clone(),
resource: graph.nodes[i].inputs[input.name][source_index].clone(),
});
}
}
@@ -2346,7 +2391,40 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
let mut operands = Vec::new();
let mut operand_provenance = Vec::new();
for (ordinal, input) in contracts[i].inputs.iter().enumerate() {
if let Some(binding) = bound[i].get(input.name) {
let bindings: Vec<_> = graph.nodes[i]
.inputs
.get(input.name)
.into_iter()
.flatten()
.map(|r| {
let producer = ids[r.node.as_str()];
let output = contracts[producer]
.outputs
.iter()
.position(|o| o.name == r.socket)
.unwrap();
BoundInput {
producer: OutputKey(producer, output as u16),
}
})
.collect();
if bindings.is_empty() {
if input.cardinality.max > 1 {
continue;
}
let literal = defaults[ordinal].clone();
operands.push(intern(
literal.semantic_type(),
ExpressionOp::Literal { literal },
NodeOutputRef {
node: graph.nodes[i].id.clone(),
socket: input.name.into(),
},
None,
));
operand_provenance.push(None);
} else {
for binding in bindings {
let key = binding.producer;
let producer_type = contracts[key.0].outputs[key.1 as usize].semantic_type;
let operand_mesh = if contracts[key.0].key == "mesh" {
@@ -2376,10 +2454,12 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
graph.nodes[key.0]
.inputs
.get("")
.cloned()
.and_then(|refs| refs.first().cloned())
.unwrap_or(NodeOutputRef {
node: graph.nodes[key.0].id.clone(),
socket: contracts[key.0].outputs[key.1 as usize].name.into(),
socket: contracts[key.0].outputs[key.1 as usize]
.name
.into(),
}),
Some(mesh),
)
@@ -2391,18 +2471,7 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
};
operands.push(id);
operand_provenance.push(operand_mesh);
} else {
let literal = defaults[ordinal].clone();
operands.push(intern(
literal.semantic_type(),
ExpressionOp::Literal { literal },
NodeOutputRef {
node: graph.nodes[i].id.clone(),
socket: input.name.into(),
},
None,
));
operand_provenance.push(None);
}
}
}
let mut provenances = operand_provenance.into_iter().flatten();
@@ -2418,15 +2487,14 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
let key = contracts[i].key;
let op = match key {
"not" => ExpressionOp::Not { value: operands[0] },
"and" | "or" | "xor" | "xnor" => ExpressionOp::BooleanBinary {
"and" | "or" | "xor" | "xnor" => ExpressionOp::Boolean {
operation: match key {
"and" => BooleanBinaryOp::And,
"or" => BooleanBinaryOp::Or,
"xor" => BooleanBinaryOp::Xor,
_ => BooleanBinaryOp::Xnor,
"and" => BooleanOp::And,
"or" => BooleanOp::Or,
"xor" => BooleanOp::Xor,
_ => BooleanOp::Xnor,
},
left: operands[0],
right: operands[1],
operands: operands.clone(),
},
"greater_than_f32" | "less_than_f32" | "equals_f32" => ExpressionOp::CompareF32 {
operation: if key.starts_with("greater") {
@@ -2636,7 +2704,7 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
));
}
Ok(CompiledGraph {
schema_version: 2,
schema_version: 3,
graph_id: graph.graph_id,
revision: graph.revision,
node_count: graph.nodes.len() as u32,
+47 -12
View File
@@ -39,9 +39,9 @@ pub enum FullscreenPolicy {
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum InputCardinality {
RequiredOne,
OptionalOne,
pub struct InputCardinality {
pub min: u8,
pub max: u8,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
@@ -97,17 +97,19 @@ pub struct Contract {
}
use SemanticType::*;
const R: InputCardinality = InputCardinality::RequiredOne;
const O: InputCardinality = InputCardinality::OptionalOne;
const R: InputCardinality = InputCardinality { min: 1, max: 1 };
const O: InputCardinality = InputCardinality { min: 0, max: 1 };
const V: InputCardinality = InputCardinality { min: 0, max: 8 };
const fn i(
name: &'static str,
ty: SemanticType,
cardinality: InputCardinality,
role: InputRole,
) -> InputSocketContract {
let default_policy = match cardinality {
InputCardinality::RequiredOne => InputDefaultPolicy::None,
InputCardinality::OptionalOne => match role {
let default_policy = match (cardinality.min, cardinality.max) {
(_, 2..) => InputDefaultPolicy::None,
(1, _) => InputDefaultPolicy::None,
_ => match role {
InputRole::ColorTarget { .. } | InputRole::DepthTarget => {
InputDefaultPolicy::CompilerTexture
}
@@ -194,17 +196,50 @@ macro_rules! ex {
c!($k, 1, Expression, $ins, $outs, false, None)
};
}
const BOOL_VARIADIC_I: &[InputSocketContract] = &[i("inputs", Bool, V, InputRole::Expression)];
pub static CONTRACTS: &[Contract] = &[
c!("mesh", 2, Source, NONE_I, MESH_O, false, None),
c!("texture", 2, Source, NONE_I, TEXTURE_O, false, None),
c!("frustum_cull", 2, Expression, CULL_I, CULL_O, false, None),
c!("pipeline", 4, Render, PIPE_I, PIPE_O, false, None),
ex!("and", ins!("left":Bool,"right":Bool), outs!("value":Bool)),
ex!("or", ins!("left":Bool,"right":Bool), outs!("value":Bool)),
c!(
"and",
2,
Expression,
BOOL_VARIADIC_I,
outs!("value":Bool),
false,
None
),
c!(
"or",
2,
Expression,
BOOL_VARIADIC_I,
outs!("value":Bool),
false,
None
),
ex!("not", ins!("operand":Bool), outs!("value":Bool)),
ex!("xor", ins!("left":Bool,"right":Bool), outs!("value":Bool)),
ex!("xnor", ins!("left":Bool,"right":Bool), outs!("value":Bool)),
c!(
"xor",
2,
Expression,
BOOL_VARIADIC_I,
outs!("value":Bool),
false,
None
),
c!(
"xnor",
2,
Expression,
BOOL_VARIADIC_I,
outs!("value":Bool),
false,
None
),
ex!(
"greater_than_f32",
ins!("left":F32,"right":F32),
+4 -5
View File
@@ -70,7 +70,7 @@ pub enum CompareOp {
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum BooleanBinaryOp {
pub enum BooleanOp {
And,
Or,
Xor,
@@ -93,10 +93,9 @@ pub enum ExpressionOp {
Not {
value: ExprId,
},
BooleanBinary {
operation: BooleanBinaryOp,
left: ExprId,
right: ExprId,
Boolean {
operation: BooleanOp,
operands: Vec<ExprId>,
},
CompareF32 {
operation: CompareOp,
+10 -8
View File
@@ -802,9 +802,9 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> {
));
}
}
if graph.schema_version != 2 {
if graph.schema_version != 3 {
return Err(invalid(
"compiled graph schema version must be 2",
"compiled graph schema version must be 3",
"schemaVersion",
));
}
@@ -1303,14 +1303,16 @@ fn validate_instance_traversal(graph: &CompiledGraph) -> Result<(), GraphError>
}
vec![*value]
}
ExpressionOp::BooleanBinary { left, right, .. } => {
ExpressionOp::Boolean { operands, .. } => {
if expression.semantic_type != SemanticType::Bool
|| ty(*left) != Some(SemanticType::Bool)
|| ty(*right) != Some(SemanticType::Bool)
|| operands.len() > 8
|| operands
.iter()
.any(|operand| ty(*operand) != Some(SemanticType::Bool))
{
return Err(invalid("boolean signature is invalid", &path));
}
vec![*left, *right]
operands.clone()
}
ExpressionOp::CompareF32 { left, right, .. } => {
if expression.semantic_type != SemanticType::Bool
@@ -1511,8 +1513,8 @@ fn expression_operands(op: &ExpressionOp) -> Vec<ExprId> {
| ExpressionOp::U32Bit { value, .. } => vec![*value],
ExpressionOp::AabbMin { aabb } | ExpressionOp::AabbMax { aabb } => vec![*aabb],
ExpressionOp::FrustumCulled { local_aabb, .. } => vec![*local_aabb],
ExpressionOp::BooleanBinary { left, right, .. }
| ExpressionOp::CompareF32 { left, right, .. }
ExpressionOp::Boolean { operands, .. } => operands.clone(),
ExpressionOp::CompareF32 { left, right, .. }
| ExpressionOp::CompareU32 { left, right, .. } => vec![*left, *right],
ExpressionOp::VectorConstruct { components } => components.clone(),
ExpressionOp::MatrixConstruct { columns } => columns.clone(),
+1 -1
View File
@@ -18,7 +18,7 @@ pub struct Node {
pub state: NodeState,
pub executor: ExecutorRef,
pub parameters: serde_json::Value,
pub inputs: BTreeMap<String, NodeOutputRef>,
pub inputs: BTreeMap<String, Vec<NodeOutputRef>>,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
+33 -12
View File
@@ -7,7 +7,7 @@ fn compile_value(value: Value) -> Result<CompiledGraph, GraphError> {
}
fn input(node: &str, socket: &str) -> Value {
json!({ "node": node, "socket": socket })
json!([{ "node": node, "socket": socket }])
}
fn texture(id: &str, format: &str) -> Value {
@@ -38,7 +38,7 @@ fn node(id: &str, key: &str, version: u32, parameters: Value, inputs: Value) ->
}
pub(crate) fn full_cull_graph() -> Value {
json!({ "schemaVersion": 2, "graphId": "typed", "revision": 1, "nodes": [
json!({ "schemaVersion": 3, "graphId": "typed", "revision": 1, "nodes": [
texture("color", "rgba16_float"), texture("depth", "depth32_float"),
node("mesh", "mesh", 2, json!({}), json!({})),
node("words", "separate_u32x16", 1, json!({"valueDefault":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}),
@@ -47,8 +47,8 @@ pub(crate) fn full_cull_graph() -> Value {
node("cull", "frustum_cull", 2, json!({"camera":"active"}),
json!({"mesh":input("mesh","mesh"),"localAabb":input("mesh","localAabb")})),
node("visible", "not", 1, json!({"operandDefault":false}), json!({"operand":input("cull","isFrustumCulled")})),
node("class", "and", 1, json!({"leftDefault":true,"rightDefault":true}),
json!({"left":input("bits","bit0"),"right":input("visible","value")})),
node("class", "and", 2, json!({}),
json!({"inputs":[input("bits","bit0")[0].clone(),input("visible","value")[0].clone()]})),
node("pipeline", "pipeline", 4,
json!({"pipeline":"gltf_standard","depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}),
json!({"mesh":input("mesh","mesh"),"predicate":input("class","value"),"colorTarget":input("color","texture"),"depthTarget":input("depth","texture")})),
@@ -82,7 +82,7 @@ fn catalog_exposes_final_mesh_pipeline_and_generic_expression_contracts() {
.iter()
.find(|i| i.name == "predicate")
.unwrap();
assert_eq!(predicate.cardinality, InputCardinality::OptionalOne);
assert_eq!(predicate.cardinality, InputCardinality { min: 0, max: 1 });
for key in [
"and",
"xnor",
@@ -98,6 +98,23 @@ fn catalog_exposes_final_mesh_pipeline_and_generic_expression_contracts() {
}
}
#[test]
fn variadic_boolean_inputs_reject_empty_and_over_capacity_bindings() {
let mut empty = full_cull_graph();
empty["nodes"][7]["inputs"]["inputs"] = json!([]);
let error = compile_value(empty).unwrap_err();
assert_eq!(error.code, "GRAPH_SOCKET_CARDINALITY");
assert_eq!(error.details["path"], "nodes[7].inputs.inputs");
let mut over_capacity = full_cull_graph();
over_capacity["nodes"][7]["inputs"]["inputs"] = json!((0..9)
.map(|_| json!({ "node": "bits", "socket": "bit0" }))
.collect::<Vec<_>>());
let error = compile_value(over_capacity).unwrap_err();
assert_eq!(error.code, "GRAPH_SOCKET_CARDINALITY");
assert_eq!(error.details["path"], "nodes[7].inputs.inputs");
}
#[test]
fn typed_graph_builds_one_dense_deterministic_traversal() {
let first = compile_value(full_cull_graph()).unwrap();
@@ -356,7 +373,7 @@ fn expression_provenance_rejects_cross_mesh_values() {
}
fn implicit_pipeline_graph() -> Value {
json!({ "schemaVersion": 2, "graphId": "implicit", "revision": 1, "nodes": [
json!({ "schemaVersion": 3, "graphId": "implicit", "revision": 1, "nodes": [
node("mesh", "mesh", 2, json!({}), json!({})),
node("first", "pipeline", 4,
json!({"pipeline":"ground_plane","depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}),
@@ -392,14 +409,18 @@ fn contract_v4_declares_strict_default_policies() {
.flat_map(|c| c.inputs)
.all(|input| matches!(
(input.cardinality, input.default_policy),
(InputCardinality::RequiredOne, InputDefaultPolicy::None)
| (
InputCardinality::OptionalOne,
(
InputCardinality { min: 1, max: 1 },
InputDefaultPolicy::None
) | (
InputCardinality { min: 0, max: 1 },
InputDefaultPolicy::ParameterLiteral
)
| (
InputCardinality::OptionalOne,
) | (
InputCardinality { min: 0, max: 1 },
InputDefaultPolicy::CompilerTexture
) | (
InputCardinality { min: 0, max: 8 },
InputDefaultPolicy::None
)
)));
}
+72 -15
View File
@@ -1,7 +1,7 @@
//! Graph-owned instance predicate compute support.
use crate::render_graph::{
BooleanBinaryOp, CompareOp, ExpressionOp, InstanceTraversalPlan, SemanticType, TypedLiteral,
BooleanOp, CompareOp, ExpressionOp, InstanceTraversalPlan, SemanticType, TypedLiteral,
};
use super::gpu_scene::{DrawIndexedIndirect, GpuSceneCache};
@@ -58,21 +58,29 @@ pub fn generate_wgsl(plan: &InstanceTraversalPlan) -> Result<String, String> {
ExpressionOp::InstanceType { .. } => "types[i]".into(),
ExpressionOp::LocalAabb { .. } => "LocalAabb(aabbs[i].min.xyz,aabbs[i].max.xyz)".into(),
ExpressionOp::Not { value } => format!("!{}", x(*value)),
ExpressionOp::BooleanBinary {
ExpressionOp::Boolean {
operation,
left,
right,
} => format!(
"({} {} {})",
x(*left),
match operation {
BooleanBinaryOp::And => "&&",
BooleanBinaryOp::Or => "||",
BooleanBinaryOp::Xor => "!=",
BooleanBinaryOp::Xnor => "==",
},
x(*right)
),
operands,
} => {
let identity = matches!(operation, BooleanOp::And | BooleanOp::Xnor);
let operator = match operation {
BooleanOp::And => "&&",
BooleanOp::Or => "||",
BooleanOp::Xor | BooleanOp::Xnor => "!=",
};
let folded = operands
.iter()
.map(|operand| x(*operand))
.reduce(|left, right| format!("({left} {operator} {right})"))
.unwrap_or_else(|| identity.to_string());
if matches!(operation, BooleanOp::Xnor) && operands.len() > 1 {
format!("!{folded}")
} else if matches!(operation, BooleanOp::Xnor) && !operands.is_empty() {
format!("!({folded})")
} else {
folded
}
}
ExpressionOp::CompareF32 {
operation,
left,
@@ -432,6 +440,55 @@ mod tests {
assert_eq!(wgsl_type(&SemanticType::Mat4).unwrap(), "mat4x4<f32>");
}
#[test]
fn variadic_boolean_wgsl_uses_identities_and_ordered_parity() {
let expressions = vec![
expression(
SemanticType::Bool,
ExpressionOp::Boolean {
operation: BooleanOp::And,
operands: vec![],
},
),
expression(
SemanticType::Bool,
ExpressionOp::Boolean {
operation: BooleanOp::Xor,
operands: vec![],
},
),
expression(
SemanticType::Bool,
ExpressionOp::Boolean {
operation: BooleanOp::Xnor,
operands: vec![crate::render_graph::ExprId(0)],
},
),
expression(
SemanticType::Bool,
ExpressionOp::Boolean {
operation: BooleanOp::Xnor,
operands: vec![
crate::render_graph::ExprId(0),
crate::render_graph::ExprId(1),
crate::render_graph::ExprId(2),
],
},
),
];
let wgsl = generate_wgsl(&InstanceTraversalPlan {
mesh: 0,
expressions: ExpressionPlan { expressions },
pipelines: vec![],
requires_camera: false,
})
.unwrap();
assert!(wgsl.contains("let e0=true;"));
assert!(wgsl.contains("let e1=false;"));
assert!(wgsl.contains("let e2=!(e0);"));
assert!(wgsl.contains("let e3=!((e0 != e1) != e2);"));
}
#[test]
fn u32_construct_wgsl_is_parenthesized() {
let plan = InstanceTraversalPlan {
+1 -1
View File
@@ -1155,7 +1155,7 @@ mod switch_request_tests {
assert_eq!(pending, None);
let invalid_replacement =
br#"{"schemaVersion":2,"graphId":"switch","revision":2,"nodes":[],"unexpected":true}"#;
br#"{"schemaVersion":3,"graphId":"switch","revision":2,"nodes":[],"unexpected":true}"#;
assert_eq!(
registry.compile(invalid_replacement).unwrap_err().code,
"GRAPH_JSON_INVALID"
+10 -8
View File
@@ -446,10 +446,10 @@ export function adaptFxNodeSnapshot(raw, revision = 1) {
linkSources.set(link.id, linkSource);
if (!link.muted) {
incoming.set(link.toSocketId, (incoming.get(link.toSocketId) ?? 0) + 1);
nodes.get(to.node).value.inputs[to.key] = {
(nodes.get(to.node).value.inputs[to.key] ??= []).push({
node: from.node,
socket: from.key,
};
});
}
}
const ordered = [...nodes.values()].sort((a, b) =>
@@ -548,13 +548,13 @@ export function adaptFxNodeSnapshot(raw, revision = 1) {
for (const key of Object.keys(
descriptors[item.value.executor.key].inputs,
)) {
const link = raw.links.find(
const links = raw.links.filter(
(x) =>
!x.muted &&
x.toNodeId === item.value.id &&
sockets.get(x.toSocketId)?.key === key,
);
const source = linkSources.get(link?.id) ?? {
const source = linkSources.get(links[0]?.id) ?? {
kind: "input",
nodeId: item.value.id,
input: key,
@@ -562,9 +562,11 @@ export function adaptFxNodeSnapshot(raw, revision = 1) {
unconnected: true,
};
paths[`${base}.inputs.${key}`] = source;
if (link) {
paths[`${base}.inputs.${key}.node`] = source;
paths[`${base}.inputs.${key}.socket`] = {
for (const [index, link] of links.entries()) {
const linkSource = linkSources.get(link.id);
paths[`${base}.inputs.${key}[${index}]`] = linkSource;
paths[`${base}.inputs.${key}[${index}].node`] = linkSource;
paths[`${base}.inputs.${key}[${index}].socket`] = {
kind: "socket",
nodeId: link.fromNodeId,
socketId: link.fromSocketId,
@@ -576,7 +578,7 @@ export function adaptFxNodeSnapshot(raw, revision = 1) {
paths[`${base}.inputs`] = nodeSource;
}
const ir = {
schemaVersion: 2,
schemaVersion: 3,
graphId: GRAPH_ID,
revision,
nodes: ordered.map((item) => item.value),
+14 -13
View File
@@ -1,9 +1,9 @@
export const GRAPH_ID = "authored_gpu_culling";
export const CATALOG_VERSION = 10;
export const CATALOG_VERSION = 11;
const exact = (type) => ({ kind: "exact", types: [type] });
const i = (type, required = true, authoringType, defaultPolicy = required ? "none" : "parameter_literal") => ({
const i = (type, minimum = 1, authoringType, defaultPolicy = minimum ? "none" : "parameter_literal", maximum = 1) => ({
accepted: typeof type === "string" ? exact(type) : type,
required,
cardinality: { minimum, maximum },
...(authoringType ? { authoringType } : {}),
defaultPolicy,
});
@@ -11,18 +11,18 @@ const o = (type) => ({ type });
const expression = (inputs, outputs) => ({
version: 1,
execution: "expression",
inputs: Object.fromEntries(Object.entries(inputs).map(([name, type]) => [name, i(type, false)])),
inputs: Object.fromEntries(Object.entries(inputs).map(([name, type]) => [name, i(type, 0)])),
outputs: Object.fromEntries(Object.entries(outputs).map(([name, type]) => [name, o(type)])),
parameters: {},
});
const numbered = (prefix, count, type) =>
Object.fromEntries(Array.from({ length: count }, (_, index) => [`${prefix}${index}`, type]));
const expressionCatalog = {
and: expression({ left: "bool", right: "bool" }, { value: "bool" }),
or: expression({ left: "bool", right: "bool" }, { value: "bool" }),
and: { ...expression({ inputs: "bool" }, { value: "bool" }), version: 2, inputs: { inputs: i("bool", 0, undefined, "none", 8) } },
or: { ...expression({ inputs: "bool" }, { value: "bool" }), version: 2, inputs: { inputs: i("bool", 0, undefined, "none", 8) } },
not: expression({ operand: "bool" }, { value: "bool" }),
xor: expression({ left: "bool", right: "bool" }, { value: "bool" }),
xnor: expression({ left: "bool", right: "bool" }, { value: "bool" }),
xor: { ...expression({ inputs: "bool" }, { value: "bool" }), version: 2, inputs: { inputs: i("bool", 0, undefined, "none", 8) } },
xnor: { ...expression({ inputs: "bool" }, { value: "bool" }), version: 2, inputs: { inputs: i("bool", 0, undefined, "none", 8) } },
greater_than_f32: expression({ left: "f32", right: "f32" }, { value: "bool" }),
less_than_f32: expression({ left: "f32", right: "f32" }, { value: "bool" }),
equals_f32: expression({ left: "f32", right: "f32" }, { value: "bool" }),
@@ -112,9 +112,9 @@ export const semanticCatalog = Object.freeze({
execution: "render",
inputs: {
mesh: i("mesh_data"),
predicate: i("bool", false),
colorTarget: i("texture", false, undefined, "compiler_texture"),
depthTarget: i("texture", false, undefined, "compiler_texture"),
predicate: i("bool", 0),
colorTarget: i("texture", 0, undefined, "compiler_texture"),
depthTarget: i("texture", 0, undefined, "compiler_texture"),
},
outputs: { color: o("texture"), depth: o("texture") },
parameters: { pipeline: "gltf_standard", depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor: [0.015, 0.02, 0.03, 1] },
@@ -271,11 +271,11 @@ export const styles = {
render: { header: "#426b43" },
frame: { header: "#a75d37" },
};
const socket = (title, direction, type, value = null) => ({
const socket = (title, direction, type, value = null, capacity = 1) => ({
title,
direction,
type,
maxIncomingLinks: direction === "input" ? 1 : 0,
maxIncomingLinks: direction === "input" ? capacity : 0,
visible: true,
value,
showValue: value !== null,
@@ -429,6 +429,7 @@ export const nodeDefinitions = Object.fromEntries(
"input",
v.authoringType ?? v.accepted.types[0],
v.defaultPolicy === "parameter_literal" ? socketDefault(v.accepted.types[0], defaultForInput(key, n, v.accepted.types[0])) : null,
v.cardinality.maximum,
),
]),
),
+4 -4
View File
@@ -16,10 +16,10 @@ async function seed(root) {
for (const [index, item] of culling.nodes.entries())
await root.dispatch({ type: "node.add", nodeId: item.id, nodeType: item.executor.key,
position: { x: 40 + (index % 6) * 280, y: 120 + Math.floor(index / 6) * 260 } });
const links = culling.nodes.flatMap((item) => Object.entries(item.inputs).map(([socket, from]) =>
[from.node, from.socket, item.id, socket]));
for (const [a, as, b, bs] of links) {
const id = `${a}_${as}_${b}_${bs}`;
const links = culling.nodes.flatMap((item) => Object.entries(item.inputs).flatMap(([socket, sources]) =>
sources.map((from, index) => [from.node, from.socket, item.id, socket, index])));
for (const [a, as, b, bs, index] of links) {
const id = `${a}_${as}_${b}_${bs}_${index}`;
await root.dispatch({
type: "link.add",
link: {
+8 -9
View File
@@ -1,6 +1,6 @@
import { descriptors } from "./catalog.js";
const input = (node, socket) => ({ node, socket });
const input = (node, socket) => [{ node, socket }];
const node = (id, key, parameters = {}, inputs = {}) => ({
id, state: "enabled", executor: { key, version: descriptors[key].version }, parameters, inputs,
});
@@ -17,20 +17,19 @@ const predicates = (withCulling = false) => {
const result = [
node("type_words", "separate_u32x16", { valueDefault: Array(16).fill(0) }, { value: input("mesh", "type") }),
node("type_bits", "separate_u32_bits", { valueDefault: 0 }, { value: input("type_words", "word0") }),
node("ground_class", "and", { leftDefault: true, rightDefault: true }, { left: input("type_bits", "bit0"), right: input("type_bits", "bit1") }),
node("visible_pbr", "and", { leftDefault: true, rightDefault: true }, { left: input("type_bits", "bit0"), right: input("type_bits", "bit2") }),
node("ground_class", "and", {}, { inputs: [...input("type_bits", "bit0"), ...input("type_bits", "bit1")] }),
node("not_double", "not", { operandDefault: false }, { operand: input("type_bits", "bit3") }),
node("standard_class", "and", { leftDefault: true, rightDefault: true }, { left: input("visible_pbr", "value"), right: input("not_double", "value") }),
node("double_class", "and", { leftDefault: true, rightDefault: true }, { left: input("type_bits", "bit0"), right: input("type_bits", "bit3") }),
node("standard_class", "and", {}, { inputs: [...input("type_bits", "bit0"), ...input("type_bits", "bit2"), ...input("not_double", "value")] }),
node("double_class", "and", {}, { inputs: [...input("type_bits", "bit0"), ...input("type_bits", "bit3")] }),
];
if (!withCulling) return { nodes: result, classes: { ground: "ground_class", pbr: "standard_class", pbr_double: "double_class" } };
result.push(
node("cull", "frustum_cull", { camera: "active" }, { mesh: input("mesh", "mesh"), localAabb: input("mesh", "localAabb") }),
node("not_culled", "not", { operandDefault: false }, { operand: input("cull", "isFrustumCulled") }),
...[["ground", "ground_class"], ["pbr", "standard_class"], ["pbr_double", "double_class"]].map(([name, classification]) =>
node(`${name}_final`, "and", { leftDefault: true, rightDefault: true }, { left: input(classification, "value"), right: input("not_culled", "value") })),
);
return { nodes: result, classes: { ground: "ground_final", pbr: "pbr_final", pbr_double: "pbr_double_final" } };
for (const id of ["ground_class", "standard_class", "double_class"])
result.find((item) => item.id === id).inputs.inputs.push(...input("not_culled", "value"));
return { nodes: result, classes: { ground: "ground_class", pbr: "standard_class", pbr_double: "double_class" } };
};
const scene = (colorTarget, clearColor = [0.015, 0.02, 0.03, 1], heightScale = 1, withCulling = false) => {
const classification = predicates(withCulling);
@@ -45,7 +44,7 @@ const scene = (colorTarget, clearColor = [0.015, 0.02, 0.03, 1], heightScale = 1
node("pbr_double", "pipeline", { pipeline: "gltf_standard_double_sided", depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor, predicateDefault: true }, { mesh: input("mesh", "mesh"), predicate: input(classification.classes.pbr_double, "value"), colorTarget: input("pbr", "color"), depthTarget: input("pbr", "depth") }),
];
};
const graph = (graphId, nodes) => Object.freeze({ schemaVersion: 2, graphId, revision: 1, nodes });
const graph = (graphId, nodes) => Object.freeze({ schemaVersion: 3, graphId, revision: 2, nodes });
const direct = (graphId, clearColor) => graph(graphId, [
node("ldr", "texture", texture("rgba8_unorm")),
...scene("ldr", clearColor),
+1 -1
View File
@@ -36,7 +36,7 @@ test("production render graph composition passes fxnode's public validator", asy
result.ok ? undefined : JSON.stringify(result.issues, null, 2),
);
assert.equal(fxNodeComposition.schemaVersion, 2);
assert.equal(fxNodeComposition.version, 10);
assert.equal(fxNodeComposition.version, 11);
assert.equal(Object.keys(fxNodeComposition.nodes).length, 42);
assert.ok(
Object.values(fxNodeComposition.nodes).every(
+61 -7
View File
@@ -2,18 +2,46 @@ import test from "node:test";
import assert from "node:assert/strict";
import { culling } from "../static/render-graph/presets.js";
import {
CATALOG_VERSION, semanticCatalog, nodeDefinitions, descriptors,
CATALOG_VERSION, GRAPH_ID, semanticCatalog, nodeDefinitions, descriptors, socketTypes,
} from "../static/render-graph/catalog.js";
import { adaptFxNodeSnapshot, mapAuthoringDiagnostic } from "../static/render-graph/adapter.js";
test("catalog v10 exposes the final mesh, pipeline, and typed-expression contracts", () => {
assert.equal(CATALOG_VERSION, 10);
const authoredNode = (id, typeId) => {
const definition = nodeDefinitions[typeId];
return {
id, typeId, typeVersion: definition.version,
position: { x: 0, y: 0 }, size: { x: 200, y: 120 }, label: id,
parameters: Object.fromEntries(Object.entries(definition.parameters)
.map(([key, schema]) => [key, structuredClone(schema.default)])),
sockets: Object.entries(definition.sockets).map(([key, socket]) => ({
id: `${id}:${key}`, key, label: socket.title, direction: socket.direction,
dataType: socket.type,
accepts: socket.direction === "input" ? [...socketTypes[socket.type].acceptsFrom] : [],
maxIncomingLinks: socket.maxIncomingLinks,
...(socket.value ? { defaultValue: structuredClone(socket.value.default) } : {}),
visible: socket.visible,
})),
muted: false, collapsed: false, extensions: {}, known: true,
};
};
const authoredLink = (id, from, to = "target", muted = false) => ({
id, fromNodeId: from, fromSocketId: `${from}:value`,
toNodeId: to, toSocketId: `${to}:inputs`, muted, extensions: {},
});
test("catalog v11 exposes the final mesh, pipeline, and typed-expression contracts", () => {
assert.equal(CATALOG_VERSION, 11);
assert.deepEqual(semanticCatalog.mesh.outputs, {
mesh: { type: "mesh_data" }, type: { type: "u32x16" }, localAabb: { type: "local_aabb" },
});
assert.equal(semanticCatalog.mesh.version, 2);
assert.equal(nodeDefinitions.mesh.sockets.localAabb.title, "Local AABB");
assert.equal(semanticCatalog.pipeline.version, 4);
assert.equal(semanticCatalog.pipeline.inputs.predicate.required, false);
assert.deepEqual(semanticCatalog.pipeline.inputs.predicate.cardinality, { minimum: 0, maximum: 1 });
assert.deepEqual(semanticCatalog.and.inputs.inputs.cardinality, { minimum: 0, maximum: 8 });
assert.equal(nodeDefinitions.and.sockets.inputs.maxIncomingLinks, 8);
assert.equal(nodeDefinitions.and.sockets.inputs.value, null);
assert.deepEqual(nodeDefinitions.texture.parameters.sampleCount.enum, ["1", "4"]);
for (const key of ["and", "xnor", "equals_f32", "greater_than_u32", "combine_vec4",
"separate_mat4", "combine_u32_bits", "separate_u32x16", "separate_local_aabb"])
@@ -26,10 +54,10 @@ test("catalog v10 exposes the final mesh, pipeline, and typed-expression contrac
test("current culling fixture uses type-bit predicates and final socket versions", () => {
const byId = Object.fromEntries(culling.nodes.map((node) => [node.id, node]));
assert.deepEqual(byId.cull.inputs.localAabb, { node: "mesh", socket: "localAabb" });
assert.deepEqual(byId.type_words.inputs.value, { node: "mesh", socket: "type" });
assert.deepEqual(byId.cull.inputs.localAabb, [{ node: "mesh", socket: "localAabb" }]);
assert.deepEqual(byId.type_words.inputs.value, [{ node: "mesh", socket: "type" }]);
assert.equal(byId.ground.executor.version, 4);
assert.equal(byId.ground.inputs.predicate.node, "ground_final");
assert.equal(byId.ground.inputs.predicate[0].node, "ground_class");
});
test("compiler texture sockets expose policy metadata without literal widgets", () => {
@@ -48,3 +76,29 @@ test("removed architecture is absent from the authoring catalog", () => {
for (const removedSocket of ["isVisible", "localAabbs", "activation"])
assert.equal(serialized.includes(`\"${removedSocket}\"`), false);
});
test("adapter preserves ordered multisocket links and indexed diagnostics", () => {
const raw = {
graphId: GRAPH_ID, catalogVersion: CATALOG_VERSION,
nodes: ["source_a", "source_b", "source_c"].map((id) => authoredNode(id, "not"))
.concat(authoredNode("target", "and")),
links: [
authoredLink("link_a", "source_a"),
authoredLink("link_muted", "source_c", "target", true),
authoredLink("link_b", "source_b"),
],
metadata: {}, version: 1,
};
const graph = adaptFxNodeSnapshot(raw, 2);
const targetIndex = graph.nodes.findIndex((node) => node.id === "target");
assert.equal(graph.schemaVersion, 3);
assert.deepEqual(graph.nodes[targetIndex].inputs.inputs, [
{ node: "source_a", socket: "value" },
{ node: "source_b", socket: "value" },
]);
const diagnostic = mapAuthoringDiagnostic(graph, {
code: "GRAPH_SOCKET_TYPE_MISMATCH",
details: { path: `nodes[${targetIndex}].inputs.inputs[1].node` },
});
assert.equal(diagnostic.source.linkId, "link_b");
});
+14 -15
View File
@@ -6,7 +6,7 @@ import { descriptors } from "../static/render-graph/catalog.js";
test("all presets use current schemas, versions, and one frame output", () => {
assert.equal(Object.keys(presets.renderGraphPresets).length, 13);
for (const [name, graph] of Object.entries(presets.renderGraphPresets)) {
assert.deepEqual([graph.schemaVersion, graph.revision], [2, 1], name);
assert.deepEqual([graph.schemaVersion, graph.revision], [3, 2], name);
assert.equal(new Set(graph.nodes.map((node) => node.id)).size, graph.nodes.length, name);
assert.equal(graph.nodes.filter((node) => node.executor.key === "frame_out").length, 1, name);
for (const node of graph.nodes)
@@ -23,14 +23,13 @@ test("all presets use current schemas, versions, and one frame output", () => {
test("presets classify demo-owned enable and material bits through type.words[0] predicates", () => {
for (const [name, graph] of Object.entries(presets.renderGraphPresets)) {
const byId = Object.fromEntries(graph.nodes.map((node) => [node.id, node]));
assert.deepEqual(byId.type_words.inputs.value, { node: "mesh", socket: "type" }, name);
assert.deepEqual(byId.type_bits.inputs.value, { node: "type_words", socket: "word0" }, name);
const suffix = name === "culling" ? "_final" : "_class";
assert.deepEqual(byId.ground.inputs.predicate, { node: `ground${suffix}`, socket: "value" }, name);
assert.deepEqual(byId.pbr.inputs.predicate, { node: name === "culling" ? "pbr_final" : "standard_class", socket: "value" }, name);
assert.deepEqual(byId.pbr_double.inputs.predicate, { node: name === "culling" ? "pbr_double_final" : "double_class", socket: "value" }, name);
assert.deepEqual(byId.type_words.inputs.value, [{ node: "mesh", socket: "type" }], name);
assert.deepEqual(byId.type_bits.inputs.value, [{ node: "type_words", socket: "word0" }], name);
assert.deepEqual(byId.ground.inputs.predicate, [{ node: "ground_class", socket: "value" }], name);
assert.deepEqual(byId.pbr.inputs.predicate, [{ node: "standard_class", socket: "value" }], name);
assert.deepEqual(byId.pbr_double.inputs.predicate, [{ node: "double_class", socket: "value" }], name);
for (const pipeline of [byId.ground, byId.pbr, byId.pbr_double]) {
assert.deepEqual(pipeline.inputs.mesh, { node: "mesh", socket: "mesh" });
assert.deepEqual(pipeline.inputs.mesh, [{ node: "mesh", socket: "mesh" }]);
assert.equal(pipeline.executor.version, 4);
}
}
@@ -39,11 +38,11 @@ test("presets classify demo-owned enable and material bits through type.words[0]
test("culling adds a local-AABB expression to each material predicate", () => {
const byId = Object.fromEntries(presets.culling.nodes.map((node) => [node.id, node]));
assert.deepEqual(byId.cull.inputs, {
mesh: { node: "mesh", socket: "mesh" }, localAabb: { node: "mesh", socket: "localAabb" },
mesh: [{ node: "mesh", socket: "mesh" }], localAabb: [{ node: "mesh", socket: "localAabb" }],
});
assert.deepEqual(byId.not_culled.inputs.operand, { node: "cull", socket: "isFrustumCulled" });
for (const id of ["ground", "pbr", "pbr_double"])
assert.equal(byId[id].inputs.predicate.node.endsWith("_final"), true);
assert.deepEqual(byId.not_culled.inputs.operand, [{ node: "cull", socket: "isFrustumCulled" }]);
for (const id of ["ground_class", "standard_class", "double_class"])
assert.equal(byId[id].inputs.inputs.at(-1).node, "not_culled");
});
test("implicit presets start disconnected and then chain both attachments", () => {
@@ -51,9 +50,9 @@ test("implicit presets start disconnected and then chain both attachments", () =
const byId = Object.fromEntries(presets[name].nodes.map((node) => [node.id, node]));
assert.equal("colorTarget" in byId.ground.inputs, false, name);
assert.equal("depthTarget" in byId.ground.inputs, false, name);
assert.deepEqual(byId.pbr.inputs.colorTarget, { node: "ground", socket: "color" }, name);
assert.deepEqual(byId.pbr.inputs.depthTarget, { node: "ground", socket: "depth" }, name);
assert.deepEqual(byId.pbr.inputs.colorTarget, [{ node: "ground", socket: "color" }], name);
assert.deepEqual(byId.pbr.inputs.depthTarget, [{ node: "ground", socket: "depth" }], name);
}
const midnight = Object.fromEntries(presets.midnight.nodes.map((node) => [node.id, node]));
assert.deepEqual(midnight.ground.inputs.colorTarget, { node: "ldr", socket: "texture" });
assert.deepEqual(midnight.ground.inputs.colorTarget, [{ node: "ldr", socket: "texture" }]);
});
+53
View File
@@ -130,6 +130,59 @@ try {
Complete sources: [definition](https://github.com/Heaust-ops/fxnode/blob/main/examples/minimal/definition.ts), [bootstrap](https://github.com/Heaust-ops/fxnode/blob/main/examples/minimal/main.ts), and
[first-node tutorial](https://github.com/Heaust-ops/fxnode/blob/main/docs/learn/tutorials/first-node.md).
## Multi-input sockets and logic gates
An input socket becomes a vertical multi-input pill when `maxIncomingLinks` is greater than `1`. The worker enforces
the capacity, lays each link onto a stable point along the pill, and keeps the whole pill selectable for link gestures.
Outputs must continue to use `0`; ordinary single-link inputs use `1`.
```ts
const andNode = [
"example.logic.and",
{
version: 1,
title: "AND",
behavior: "standard",
style: "logic",
parameters: {},
sockets: {
inputs: {
title: "Inputs (up to 5)",
direction: "input",
type: "boolean",
maxIncomingLinks: 5,
visible: true,
value: null,
showValue: false,
},
result: {
title: "Result",
direction: "output",
type: "boolean",
maxIncomingLinks: 0,
visible: true,
value: null,
showValue: false,
},
},
ui: [
{ kind: "socket", socket: "inputs" },
{ kind: "socket", socket: "result" },
],
muteBypass: [["inputs", "result"]],
migrations: [],
},
] as const satisfies readonly [string, FxNodeDefinition];
await api.composeNode(...andNode);
```
![AND, OR, NOT, XOR, and XNOR nodes using multi-input socket pills](https://raw.githubusercontent.com/Heaust-ops/fxnode/main/examples/assets/logic-nodes.png)
fxnode presents and edits the graph; it does not prescribe graph execution semantics. The
[logic-node example](https://github.com/Heaust-ops/fxnode/tree/main/examples/logic-nodes) evaluates Boolean operations
in application code by subscribing to versioned snapshots.
## One graph, zero or many views
`createFxNode()` creates the shared graph authority and starts one worker, but creates no canvas. This is valid for
+5 -5
View File
@@ -1,9 +1,9 @@
# fxnode upstream provenance
- Source: https://github.com/Heaust-ops/fxnode
- Commit: `3f8745717bf4574577be72e9769373475cc300c9`
- Tree: `fb8bf407854b68dad8c0249c9ba63c7fd0bd9332`
- Imported: 2026-07-26
- Commit: `4e96585e99742959660d4107b0078c27ff13e708`
- Tree: `4fcafa60557bcbd760d7aa8d8898c0adbe0bb2c8`
- Imported: 2026-07-29
- License: MIT (see `LICENSE` and `NOTICE.md`)
- Local patches: none
@@ -14,8 +14,8 @@ This directory is a committed source snapshot. Application adaptations belong ou
```sh
git clone --filter=blob:none https://github.com/Heaust-ops/fxnode /tmp/fxnode
git -C /tmp/fxnode fetch --depth=1 origin 3f8745717bf4574577be72e9769373475cc300c9
git -C /tmp/fxnode checkout --detach 3f8745717bf4574577be72e9769373475cc300c9
git -C /tmp/fxnode fetch --depth=1 origin 4e96585e99742959660d4107b0078c27ff13e708
git -C /tmp/fxnode checkout --detach 4e96585e99742959660d4107b0078c27ff13e708
rm -rf vendor/fxnode
mkdir -p vendor/fxnode
git -C /tmp/fxnode archive HEAD | tar -x -C vendor/fxnode
+7 -1
View File
@@ -1,6 +1,6 @@
# Examples
The repository has five current experiences. Images below use the examples' existing captured assets—there are no documentation copies.
The repository has six current experiences. Images below use the examples' existing captured assets—there are no documentation copies.
## Minimal
@@ -26,6 +26,12 @@ _Replacing a node definition and migrating its instance. [Source](https://github
_One worker and graph with independent cameras and selections. The application-owned toolbar targets the active view, and the canvases forward pointer events only. [Source](https://github.com/Heaust-ops/fxnode/blob/main/examples/multi-view/main.ts)._
## Logic nodes
![Boolean logic nodes connected through vertical multi-input socket pills](../../../examples/assets/logic-nodes.png)
_A Boolean socket, app-composed AND/OR/NOT/XOR/XNOR/NAND/NOR nodes, and five-link inputs. The library presents and edits the graph; the example evaluates it in application code. [Source](https://github.com/Heaust-ops/fxnode/blob/main/examples/logic-nodes/main.ts)._
## Blender-shaped gallery
The [larger gallery source](https://github.com/Heaust-ops/fxnode/blob/main/examples/blender/main.ts) exercises many node and interaction shapes; it is repository application code, not package authority.
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

+5
View File
@@ -45,6 +45,11 @@
<span class="tag">Runtime API</span><strong>Live composition</strong>
<p>Version and migrate definitions while the editor is running.</p>
</a>
<a href="./logic-nodes/">
<img src="./assets/logic-nodes.png" alt="Boolean logic graph with multi-input socket pills" />
<span class="tag">Multi-input sockets</span><strong>Logic nodes</strong>
<p>AND, OR, NOT, XOR, XNOR, NAND, and NOR composed from a Boolean socket.</p>
</a>
<a href="./multi-view/">
<img src="./assets/multi-view.png" alt="Multi-view example" />
<span class="tag">Shared graph</span><strong>Multi-view</strong>
+87
View File
@@ -0,0 +1,87 @@
import type { FxNodeDefinition, FxNodeSocketTypeDefinition, FxNodeStyleDefinition } from "@lib/index.js";
export const booleanSocket = [
"boolean",
{ title: "Boolean", color: "#d67cff", acceptsFrom: ["boolean"] },
] as const satisfies readonly [string, FxNodeSocketTypeDefinition];
export const logicStyles = {
source: { header: "#547aa5" },
logic: { header: "#7c4d9e" },
} as const satisfies Readonly<Record<string, FxNodeStyleDefinition>>;
export const booleanValueNode = [
"example.logic.boolean",
{
version: 1,
title: "Boolean",
behavior: "standard",
style: "source",
parameters: {
value: { type: "boolean", default: { kind: "boolean", value: true } },
},
sockets: {
value: {
title: "Value",
direction: "output",
type: "boolean",
maxIncomingLinks: 0,
visible: true,
value: null,
showValue: false,
},
},
ui: [
{ kind: "parameter", parameter: "value" },
{ kind: "socket", socket: "value" },
],
muteBypass: [],
migrations: [],
},
] as const satisfies readonly [string, FxNodeDefinition];
function gateNode(title: string, inputCapacity: number): FxNodeDefinition {
return {
version: 1,
title,
behavior: "standard",
style: "logic",
parameters: {},
sockets: {
inputs: {
title: inputCapacity === 1 ? "Input" : `Inputs (up to ${inputCapacity})`,
direction: "input",
type: "boolean",
maxIncomingLinks: inputCapacity,
visible: true,
value: null,
showValue: false,
},
result: {
title: "Result",
direction: "output",
type: "boolean",
maxIncomingLinks: 0,
visible: true,
value: null,
showValue: false,
},
},
ui: [
{ kind: "socket", socket: "inputs" },
{ kind: "socket", socket: "result" },
],
muteBypass: [["inputs", "result"]],
migrations: [],
};
}
export const logicNodes = [
["example.logic.and", gateNode("AND", 5)],
["example.logic.or", gateNode("OR", 5)],
["example.logic.not", gateNode("NOT", 1)],
["example.logic.xor", gateNode("XOR", 5)],
["example.logic.xnor", gateNode("XNOR", 5)],
["example.logic.nand", gateNode("NAND", 5)],
["example.logic.nor", gateNode("NOR", 5)],
] as const satisfies readonly (readonly [string, FxNodeDefinition])[];
+22
View File
@@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<title>fxnode logic nodes</title>
<link rel="stylesheet" href="../shared/example.css" />
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<main>
<h1>Composable logic nodes</h1>
<p>
AND, OR, XOR, and XNOR accept several links through one multi-input pill. Toggle a Boolean value to evaluate the
graph in application code.
</p>
<output id="results" aria-live="polite"></output>
<canvas id="graph" width="1200" height="700" aria-label="Logic node graph"></canvas>
</main>
<script type="module" src="./main.ts"></script>
</body>
</html>
+161
View File
@@ -0,0 +1,161 @@
import { createFxNode, nodeId, socketId, type GraphSnapshot } from "@lib/index.js";
import { prepareFxNodeBrowserHost } from "../shared/browser-host.js";
import { exampleTheme } from "../shared/theme.js";
import { booleanSocket, booleanValueNode, logicNodes, logicStyles } from "./definition.js";
const canvas = document.querySelector<HTMLCanvasElement>("#graph")!;
const results = document.querySelector<HTMLOutputElement>("#results")!;
const host = prepareFxNodeBrowserHost({ canvas });
let cleanedUp = false;
let unsubscribeSnapshot: (() => void) | undefined;
const operator = new Map<string, (values: readonly boolean[]) => boolean>([
["example.logic.and", (values) => values.every(Boolean)],
["example.logic.or", (values) => values.some(Boolean)],
["example.logic.not", (values) => !values[0]],
["example.logic.xor", (values) => values.filter(Boolean).length % 2 === 1],
["example.logic.xnor", (values) => values.filter(Boolean).length % 2 === 0],
["example.logic.nand", (values) => !values.every(Boolean)],
["example.logic.nor", (values) => !values.some(Boolean)],
]);
function evaluate(snapshot: GraphSnapshot) {
const nodes = new Map(snapshot.nodes.map((node) => [node.id, node]));
const resolve = (id: string, visiting = new Set<string>()): boolean => {
const node = nodes.get(nodeId(id));
if (!node || visiting.has(id)) return false;
if (node.typeId === booleanValueNode[0]) {
const value = node.parameters.value;
return typeof value === "object" && value !== null && "kind" in value && value.kind === "boolean"
? value.value === true
: false;
}
const operation = operator.get(node.typeId);
if (!operation) return false;
const next = new Set(visiting).add(id);
const incoming = snapshot.links
.filter((link) => link.toNodeId === node.id && link.toSocketId === socketId(`${id}:inputs`) && !link.muted)
.sort((a, b) => a.id.localeCompare(b.id));
return operation(incoming.map((link) => resolve(link.fromNodeId, next)));
};
const gates = snapshot.nodes.filter((node) => operator.has(node.typeId));
results.replaceChildren(
...gates.map((node) => {
const item = document.createElement("span");
const value = resolve(node.id);
item.className = value ? "true" : "false";
item.textContent = `${node.label}: ${String(value)}`;
return item;
}),
);
}
function cleanup() {
window.removeEventListener("pagehide", cleanup);
cleanedUp = true;
unsubscribeSnapshot?.();
unsubscribeSnapshot = undefined;
const root = handle.root,
view = handle.view;
handle.root = null;
handle.view = null;
host.destroy();
const destroyRoot = () => root?.destroy();
if (view) void view.detach().then(destroyRoot, destroyRoot);
else destroyRoot();
}
const handle: StandaloneExampleHandle = {
root: null,
view: null,
host,
ready: Promise.resolve(),
cleanup,
};
window.fxnodeStandalone = handle;
window.addEventListener("pagehide", cleanup);
handle.ready = (async () => {
try {
const root = await createFxNode({
applicationId: "fxnode.example.logic-nodes",
applicationVersion: 1,
resources: {},
});
if (cleanedUp) {
root.destroy();
return;
}
handle.root = root;
await root.setTheme(exampleTheme);
await root.setHeaderStyles(logicStyles);
await root.composeSocket(...booleanSocket);
await root.composeNode(...booleanValueNode);
for (const [id, definition] of logicNodes) await root.composeNode(id, definition);
await root.setState({ graphId: "logic-nodes", catalogVersion: 1, nodes: [], links: [], metadata: {} });
const nodes = [
["a", booleanValueNode[0], { x: -460, y: 260 }],
["b", booleanValueNode[0], { x: -460, y: 130 }],
["c", booleanValueNode[0], { x: -460, y: 0 }],
["d", booleanValueNode[0], { x: -460, y: -130 }],
["e", booleanValueNode[0], { x: -460, y: -260 }],
["and", "example.logic.and", { x: -170, y: 220 }],
["or", "example.logic.or", { x: -170, y: -100 }],
["xor", "example.logic.xor", { x: 100, y: 180 }],
["not", "example.logic.not", { x: 100, y: -100 }],
["xnor", "example.logic.xnor", { x: 370, y: 100 }],
] as const;
for (const [id, type, position] of nodes)
await root.dispatch({ type: "node.add", nodeId: nodeId(id), nodeType: type, position });
await root.dispatch({
type: "node.parameter",
id: nodeId("c"),
key: "value",
value: { kind: "boolean", value: false },
});
const connections = [
["a", "and"],
["b", "and"],
["c", "and"],
["d", "and"],
["e", "and"],
["c", "or"],
["d", "or"],
["e", "or"],
["and", "xor"],
["or", "xor"],
["xor", "not"],
["and", "xnor"],
["or", "xnor"],
["not", "xnor"],
] as const;
for (const [from, to] of connections)
await root.dispatch({
type: "link.add",
link: {
fromNodeId: nodeId(from),
fromSocketId: socketId(`${from}:${from.length === 1 ? "value" : "result"}`),
toNodeId: nodeId(to),
toSocketId: socketId(`${to}:inputs`),
muted: false,
extensions: {},
},
});
const view = await root.attachView({
canvas,
viewport: host.initialViewport,
initialCamera: { center: { x: 0, y: 0 }, zoom: 0.8 },
});
handle.view = view;
host.attach(root, view);
unsubscribeSnapshot = root.onSnapshots(({ snapshot }) => evaluate(snapshot));
evaluate(await root.getState());
await view.whenRendered();
} catch (error) {
cleanup();
throw error;
}
})();
+31
View File
@@ -0,0 +1,31 @@
main {
width: 1200px;
}
#results {
display: flex;
min-height: 30px;
gap: 7px;
margin-bottom: 12px;
}
#results span {
padding: 5px 9px;
color: #d7d9de;
background: #292c32;
border: 1px solid #3a3e46;
border-radius: 999px;
font-size: 12px;
}
#results .true {
color: #dafbe1;
background: #183b25;
border-color: #2d6b40;
}
#results .false {
color: #ffd8dc;
background: #472126;
border-color: #76343c;
}
canvas {
width: 1200px;
height: 700px;
}
+1
View File
@@ -109,6 +109,7 @@ export interface FxNodeSocketDefinition<S extends string = string> {
readonly title: string;
readonly direction: "input" | "output";
readonly type: S;
/** Maximum incoming links. Inputs above 1 are presented as multi-input pills; outputs must use 0. */
readonly maxIncomingLinks: number;
readonly visible: boolean;
readonly value: FxNodeValueSchema | null;
+59 -19
View File
@@ -179,7 +179,7 @@ export function buildLayoutScene<C extends FxNodeCompositionData>(
? G.reroute * 2
: node.collapsed
? G.header
: G.header + visibleItems.reduce((sum, item) => sum + nodeRowUnits(item), 0) * G.row + G.gap;
: G.header + visibleItems.reduce((sum, item) => sum + nodeRowUnits(item, descriptor), 0) * G.row + G.gap;
const calculated = descriptor ? minimumNodeSize(descriptor, node) : { x: G.minWidth, y: contentHeight };
const minimumSize = { x: calculated.x, y: kind === "node" && node.collapsed ? G.header : calculated.y };
const width =
@@ -190,19 +190,60 @@ export function buildLayoutScene<C extends FxNodeCompositionData>(
: Math.min(G.maxWidth, Math.max(minimumSize.x, node.size.x));
const height = kind === "node" && !node.collapsed ? Math.max(contentHeight, node.size.y) : contentHeight;
const nodeBounds = { x: at.x, y: at.y, width, height };
const rowBySocket = new Map<string, number>();
const rowBySocket = new Map<string, { offset: number; units: number }>();
let socketRowOffset = 0;
for (const item of visibleItems) {
if (item.kind === "socket") rowBySocket.set(item.socket, socketRowOffset);
socketRowOffset += nodeRowUnits(item);
const units = nodeRowUnits(item, descriptor);
if (item.kind === "socket") rowBySocket.set(item.socket, { offset: socketRowOffset, units });
socketRowOffset += units;
}
const layoutSockets: LayoutSocket[] = visibleSockets.map((socket) => {
const linkIds = linksBySocket.get(socket.id) ?? [];
const linked = linkIds.length > 0;
const row = rowBySocket.get(socket.key) ?? 0;
const row = rowBySocket.get(socket.key) ?? { offset: 0, units: 1 };
const placement = descriptor?.ui.find((item) => item.kind === "socket" && item.socket === socket.key);
const socketType = descriptor ? compiled.socketTypes.get(socket.dataType as never) : undefined;
if (descriptor && !socketType) throw new Error(`Missing compiled socket type: ${socket.dataType}`);
const anchor =
kind === "reroute"
? { x: at.x + G.reroute, y: at.y - G.reroute }
: {
x: at.x + (socket.direction === "output" ? width : 0),
y: at.y - (node.collapsed ? G.half : G.header + (row.offset + row.units / 2) * G.row),
};
const shape =
kind === "node" && !node.collapsed && socket.direction === "input" && socket.maxIncomingLinks > 1
? "multi-input"
: "circle";
const pillHeight = row.units * G.row - 8;
const socketBounds =
shape === "multi-input"
? { x: anchor.x - G.socket, y: anchor.y + pillHeight / 2, width: G.socket * 2, height: pillHeight }
: undefined;
const orderedLinks = linkIds.slice().sort((a, b) => {
const left = document.links[a],
right = document.links[b];
return left && right
? `${left.fromNodeId}:${left.fromSocketId}:${left.id}`.localeCompare(
`${right.fromNodeId}:${right.fromSocketId}:${right.id}`,
)
: a.localeCompare(b);
});
const linkAnchors = new Map<LinkId, Vec2>(
orderedLinks.map((id, index) => [
id,
orderedLinks.length === 1 || shape === "circle"
? anchor
: {
x: anchor.x,
y:
anchor.y +
pillHeight / 2 -
G.socket -
(index * (pillHeight - G.socket * 2)) / (orderedLinks.length - 1),
},
]),
);
return {
id: socket.id,
nodeId: node.id,
@@ -216,20 +257,17 @@ export function buildLayoutScene<C extends FxNodeCompositionData>(
capacity: socket.maxIncomingLinks,
linkIds,
linked,
anchor:
kind === "reroute"
? { x: at.x + G.reroute, y: at.y - G.reroute }
: {
x: at.x + (socket.direction === "output" ? width : 0),
y: at.y - (node.collapsed ? G.half : G.header + G.half + row * G.row),
},
anchor,
shape,
...(socketBounds ? { bounds: socketBounds } : {}),
linkAnchors,
};
});
for (const socket of layoutSockets) sockets.set(socket.id, socket);
const rows: LayoutRow[] = [];
let rowOffset = 0;
for (const item of visibleItems) {
const units = nodeRowUnits(item);
const units = nodeRowUnits(item, descriptor);
const rowBounds: Rect = { x: at.x, y: at.y - G.header - rowOffset * G.row, width, height: units * G.row };
if (item.kind === "text") {
rows.push({ kind: item.variant, label: item.title, units, bounds: rowBounds });
@@ -412,7 +450,7 @@ export function buildLayoutScene<C extends FxNodeCompositionData>(
kind: "socket",
socketId: socket.id,
...(controlId ? { controlId } : {}),
units: 1,
units,
bounds: rowBounds,
});
}
@@ -491,13 +529,15 @@ export function buildLayoutScene<C extends FxNodeCompositionData>(
const from = sockets.get(link.fromSocketId) as LayoutSocket | undefined,
to = sockets.get(link.toSocketId) as LayoutSocket | undefined;
if (!from || !to) continue;
const points = cubic(from.anchor, to.anchor);
const dx = Math.max(40, Math.abs(to.anchor.x - from.anchor.x) * 0.5);
const fromAnchor = from.linkAnchors.get(link.id) ?? from.anchor,
toAnchor = to.linkAnchors.get(link.id) ?? to.anchor;
const points = cubic(fromAnchor, toAnchor);
const dx = Math.max(40, Math.abs(toAnchor.x - fromAnchor.x) * 0.5);
const cs = [
{ x: from.anchor.x + dx, y: from.anchor.y },
{ x: to.anchor.x - dx, y: to.anchor.y },
{ x: fromAnchor.x + dx, y: fromAnchor.y },
{ x: toAnchor.x - dx, y: toAnchor.y },
] as const,
linkBounds = cubicBounds(from.anchor, cs[0], cs[1], to.anchor);
linkBounds = cubicBounds(fromAnchor, cs[0], cs[1], toAnchor);
links.set(link.id, {
id: link.id,
fromNodeId: link.fromNodeId,
+6 -2
View File
@@ -4,7 +4,7 @@ import { GEOMETRY as G } from "./constants.js";
const title = (value: string) => value.replace(/-/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
const textWidth = (value: string) => value.length * 6.5;
export const nodeRowUnits = (item: FxNodeUiRow): number =>
export const nodeRowUnits = (item: FxNodeUiRow, definition?: FxNodeDefinition): number =>
item.kind === "text" && item.variant === "header"
? 2
: item.kind === "widget"
@@ -13,6 +13,10 @@ export const nodeRowUnits = (item: FxNodeUiRow): number =>
: 8
: item.kind === "resource"
? 4
: item.kind === "socket" &&
definition?.sockets[item.socket]?.direction === "input" &&
definition.sockets[item.socket]!.maxIncomingLinks > 1
? 2
: 1;
const controlWidth = (schema: FxNodeValueSchema | undefined, ramp = false) =>
!schema
@@ -82,7 +86,7 @@ export function minimumNodeSize(
}
return {
x: Math.min(G.maxWidth, Math.ceil(width)),
y: G.header + items.reduce((sum, item) => sum + nodeRowUnits(item), 0) * G.row + G.gap,
y: G.header + items.reduce((sum, item) => sum + nodeRowUnits(item, definition), 0) * G.row + G.gap,
};
}
export function initialNodeSize(
+6
View File
@@ -36,6 +36,12 @@ export interface LayoutSocket {
readonly capacity: number;
readonly linkIds: readonly LinkId[];
readonly anchor: Vec2;
/** Multi-capacity inputs use a vertical pill; ordinary and collapsed sockets remain circular. */
readonly shape: "circle" | "multi-input";
/** Authoritative world-space paint and hit bounds for a multi-input pill. */
readonly bounds?: Rect;
/** Stable world-space attachment point for each link occupying a multi-input pill. */
readonly linkAnchors: ReadonlyMap<LinkId, Vec2>;
readonly linked: boolean;
}
export type LayoutControlKind =
+4 -1
View File
@@ -479,7 +479,10 @@ function paintSocket(
const point = worldToView(socket.anchor, transform);
context.fillStyle = socket.color;
context.beginPath();
context.arc(point.x, point.y, G.socket * zoom, 0, Math.PI * 2);
if (socket.shape === "multi-input" && socket.bounds) {
const topLeft = worldToView({ x: socket.bounds.x, y: socket.bounds.y }, transform);
context.roundRect(topLeft.x, topLeft.y, socket.bounds.width * zoom, socket.bounds.height * zoom, G.socket * zoom);
} else context.arc(point.x, point.y, G.socket * zoom, 0, Math.PI * 2);
context.fill();
if (showLabel && zoom >= 0.35) {
context.fillStyle = theme.text;
+3 -1
View File
@@ -138,7 +138,9 @@ export function hitTest(layout: LayoutSnapshot, view: Vec2, preferredDirection?:
.filter(
(socket) =>
(!topNode || socket.nodeId === topNode.id) &&
Math.hypot(world.x - socket.anchor.x, world.y - socket.anchor.y) <= tolerance,
(socket.shape === "multi-input" && socket.bounds
? inRect(world, socket.bounds, tolerance)
: Math.hypot(world.x - socket.anchor.x, world.y - socket.anchor.y) <= tolerance),
)
.sort((a, b) => {
const an = layout.nodes.get(a.nodeId),
+1 -1
View File
@@ -1,5 +1,5 @@
import { expect, test } from "@playwright/test";
for (const example of ["minimal", "color-balance", "live-composition", "multi-view"])
for (const example of ["minimal", "color-balance", "live-composition", "logic-nodes", "multi-view"])
test(`${example} documentation image`, async ({ page }) => {
await page.goto(`/examples/${example}/`);
await page.evaluate(
+34 -3
View File
@@ -4,6 +4,7 @@ const examples = [
{ path: "minimal", nodeId: "value", typeId: "example.minimal.value" },
{ path: "color-balance", nodeId: "color-balance", typeId: "fxnode.compositor.color-balance" },
{ path: "live-composition", nodeId: "live-node", typeId: "example.live.parameter" },
{ path: "logic-nodes", nodeId: "and", typeId: "example.logic.and" },
] as const;
function capturePageErrors(page: Page): Error[] {
@@ -14,11 +15,11 @@ function capturePageErrors(page: Page): Error[] {
test("gallery links every standalone application and loads its images", async ({ page }) => {
await page.goto("/examples/");
await expect(page.locator(".gallery a")).toHaveCount(5);
await expect(page.locator(".gallery a")).toHaveCount(6);
expect(
await page.locator(".gallery a").evaluateAll((links) => links.map((link) => link.getAttribute("href"))),
).toEqual(["./minimal/", "./color-balance/", "./live-composition/", "./multi-view/", "./blender/"]);
await expect(page.locator(".gallery img")).toHaveCount(4);
).toEqual(["./minimal/", "./color-balance/", "./live-composition/", "./logic-nodes/", "./multi-view/", "./blender/"]);
await expect(page.locator(".gallery img")).toHaveCount(5);
expect(
await page
.locator(".gallery img")
@@ -144,6 +145,36 @@ test("multi-view keeps view input and selection local while graph changes fan ou
).toEqual({ root: null, views: 0 });
});
test("logic nodes accept five links through one input and application evaluation follows snapshots", async ({
page,
}) => {
const errors = capturePageErrors(page);
await page.goto("/examples/logic-nodes/");
await page.evaluate(() => window.fxnodeStandalone.ready);
const initial = await page.evaluate(async () => {
const state = await window.fxnodeStandalone.root!.getState();
return {
incoming: state.links.filter((link) => link.toSocketId === "and:inputs").length,
labels: [...document.querySelectorAll<HTMLElement>("#results span")].map((item) => item.textContent),
};
});
expect(initial.incoming).toBe(5);
expect(initial.labels).toContain("AND: false");
await page.evaluate(async () => {
const root = window.fxnodeStandalone.root!;
const source = (await root.getState()).nodes.find((node) => node.id === "c")!;
return root.dispatch({
type: "node.parameter",
id: source.id,
key: "value",
value: { kind: "boolean", value: true },
});
});
await expect(page.locator("#results span").filter({ hasText: "AND:" })).toHaveText("AND: true");
expect(errors).toEqual([]);
});
for (const example of examples) {
test(`${example.path} renders its known node and cleans up on pagehide`, async ({ page }) => {
const errors = capturePageErrors(page);
+1 -1
View File
@@ -18,7 +18,7 @@ test("examples server explicitly loads the repository Vite config", async () =>
test("standalone examples import library types and values only through the public entrypoint", async () => {
const root = new URL("../examples/", import.meta.url);
const directories = ["shared", "minimal", "color-balance", "live-composition"];
const directories = ["shared", "minimal", "color-balance", "live-composition", "logic-nodes"];
const files: string[] = [];
async function collect(directory: string): Promise<void> {
for (const entry of await readdir(new URL(directory, root), { withFileTypes: true })) {
+74 -5
View File
@@ -1,7 +1,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { APPLICATION_COMPILED, APPLICATION_HEADLESS } from "./application.js";
import { commandId, nodeId } from "@lib/core/types.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]) =>
@@ -163,8 +163,8 @@ test("all catalog types lay out deterministically", () => {
"fxnode.geometry.join-geometry",
"node",
"geometry",
["socket:input:geometry:1", "socket:output:result:1"],
[175, 76],
["socket:input:geometry:2", "socket:output:result:1"],
[175, 100],
[175, 100],
],
[
@@ -207,8 +207,8 @@ test("all catalog types lay out deterministically", () => {
"fxnode.common.group-output",
"node",
"output",
["control:string:interfaceName:1", "socket:input:input:1"],
[257, 76],
["control:string:interfaceName:1", "socket:input:input:2"],
[257, 100],
[257, 100],
],
[
@@ -448,6 +448,75 @@ test("expanded, collapsed, reroute and links have pinned geometry", () => {
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 }),