From 90406ae18f9f1c8914e77452704526d2d6e619f3 Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 29 Jul 2026 10:03:06 +0000 Subject: [PATCH] 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 --- renderer/src/render_graph/compiler.rs | 306 +++++++++++------- renderer/src/render_graph/contracts.rs | 59 +++- renderer/src/render_graph/expression.rs | 9 +- renderer/src/render_graph/runtime.rs | 18 +- renderer/src/render_graph/schema.rs | 2 +- renderer/src/render_graph/tests.rs | 51 ++- renderer/src/renderer/instance_traversal.rs | 87 ++++- renderer/src/renderer/mod.rs | 2 +- static/render-graph/adapter.js | 18 +- static/render-graph/catalog.js | 27 +- static/render-graph/fxnode-editor.js | 8 +- static/render-graph/presets.js | 17 +- tests/fxnode-composition.test.js | 2 +- tests/render-graph-authoring.test.js | 68 +++- tests/render-graph-presets.test.js | 29 +- vendor/fxnode/README.md | 53 +++ vendor/fxnode/UPSTREAM.md | 10 +- vendor/fxnode/docs/learn/examples/index.md | 8 +- vendor/fxnode/examples/assets/logic-nodes.png | Bin 0 -> 91797 bytes vendor/fxnode/examples/index.html | 5 + .../fxnode/examples/logic-nodes/definition.ts | 87 +++++ vendor/fxnode/examples/logic-nodes/index.html | 22 ++ vendor/fxnode/examples/logic-nodes/main.ts | 161 +++++++++ vendor/fxnode/examples/logic-nodes/style.css | 31 ++ vendor/fxnode/src/composition/types.ts | 1 + vendor/fxnode/src/layout/layout-graph.ts | 78 +++-- vendor/fxnode/src/layout/node-dimensions.ts | 10 +- vendor/fxnode/src/layout/types.ts | 6 + vendor/fxnode/src/render/canvas-renderer.ts | 5 +- vendor/fxnode/src/worker/interaction.ts | 4 +- .../test/browser/examples.docs.visual.spec.ts | 2 +- vendor/fxnode/test/browser/examples.spec.ts | 37 ++- vendor/fxnode/test/import-boundary.test.ts | 2 +- vendor/fxnode/test/layout.test.ts | 79 ++++- 34 files changed, 1031 insertions(+), 273 deletions(-) create mode 100644 vendor/fxnode/examples/assets/logic-nodes.png create mode 100644 vendor/fxnode/examples/logic-nodes/definition.ts create mode 100644 vendor/fxnode/examples/logic-nodes/index.html create mode 100644 vendor/fxnode/examples/logic-nodes/main.ts create mode 100644 vendor/fxnode/examples/logic-nodes/style.css diff --git a/renderer/src/render_graph/compiler.rs b/renderer/src/render_graph/compiler.rs index 1bfa7db..8bd242c 100644 --- a/renderer/src/render_graph/compiler.rs +++ b/renderer/src/render_graph/compiler.rs @@ -306,10 +306,10 @@ pub fn parse_and_compile(bytes: &[u8]) -> Result { .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 { 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 { } 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::()); if input_count > 8192 { return Err(error( "GRAPH_LIMIT_EXCEEDED", @@ -850,10 +855,10 @@ pub fn compile(graph: Graph) -> Result { )); } } - 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 { ] { 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,24 +897,30 @@ pub fn compile(graph: Graph) -> Result { 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 { - if !ids.contains_key(r.node.as_str()) { - return Err(error( - "GRAPH_UNKNOWN_NODE", - "unknown input node", - format!("nodes[{i}].inputs.{s}.node"), - )); + 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}[{index}].node"), + )); + } } } } @@ -978,27 +990,36 @@ pub fn compile(graph: Graph) -> Result { } } for (i, n) in graph.nodes.iter().enumerate() { - for (name, r) in &n.inputs { - 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"), - )); + 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}[{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 { - return Err(error( - "GRAPH_SOCKET_CARDINALITY", - "required input is missing", - format!("nodes[{i}].inputs.{}", input.name), - )); - } + let sources = n.inputs.get(input.name); + if sources.is_some_and(Vec::is_empty) { + return Err(error( + "GRAPH_SOCKET_CARDINALITY", + "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,35 +1027,59 @@ pub fn compile(graph: Graph) -> Result { let mut bound: Vec> = 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; }; - let pn = ids[r.node.as_str()]; - let (ordinal, out) = contracts[pn] - .outputs - .iter() - .enumerate() - .find(|(_, o)| o.name == r.socket) - .expect("producer sockets were globally validated"); - if !accepts(input.accepted, out.semantic_type) { - return Err(error( - "GRAPH_SOCKET_TYPE_MISMATCH", - "socket type mismatch", - format!("nodes[{i}].inputs.{}", input.name), - )); + for (source_index, r) in refs.iter().enumerate() { + let pn = ids[r.node.as_str()]; + let (ordinal, out) = contracts[pn] + .outputs + .iter() + .enumerate() + .find(|(_, o)| o.name == r.socket) + .expect("producer sockets were globally validated"); + if !accepts(input.accepted, out.semantic_type) { + return Err(error( + "GRAPH_SOCKET_TYPE_MISMATCH", + "socket type mismatch", + format!("nodes[{i}].inputs.{}[{source_index}]", input.name), + )); + } + if source_index == 0 { + bound[i].insert( + input.name, + BoundInput { + producer: OutputKey(pn, ordinal as u16), + }, + ); + } } - bound[i].insert( - input.name, - BoundInput { - producer: OutputKey(pn, ordinal as u16), - }, - ); } } 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 { 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,52 +2391,27 @@ pub fn compile(graph: Graph) -> Result { 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 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" { - Some(output_ids[&OutputKey(key.0, 0)]) - } else { - expression_provenance[&key] - }; - let id = if producer_type.is_virtual() { - if contracts[key.0].key == "mesh" { - let mesh = output_ids[&OutputKey(key.0, 0)]; - if mesh_root.is_some_and(|root| root != mesh) { - return Err(error( - "GRAPH_SOCKET_TYPE_MISMATCH", - "instance traversal has multiple mesh roots", - format!("nodes[{i}].inputs.{}", input.name), - )); - } - mesh_root = Some(mesh); - let op = match producer_type { - SemanticType::U32x16 => ExpressionOp::InstanceType { mesh }, - SemanticType::LocalAabb => ExpressionOp::LocalAabb { mesh }, - _ => unreachable!(), - }; - intern( - producer_type, - op, - graph.nodes[key.0] - .inputs - .get("") - .cloned() - .unwrap_or(NodeOutputRef { - node: graph.nodes[key.0].id.clone(), - socket: contracts[key.0].outputs[key.1 as usize].name.into(), - }), - Some(mesh), - ) - } else { - expression_ids[&key] + 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), } - } else { + }) + .collect(); + if bindings.is_empty() { + if input.cardinality.max > 1 { continue; - }; - operands.push(id); - operand_provenance.push(operand_mesh); - } else { + } let literal = defaults[ordinal].clone(); operands.push(intern( literal.semantic_type(), @@ -2403,6 +2423,55 @@ pub fn compile(graph: Graph) -> Result { 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" { + Some(output_ids[&OutputKey(key.0, 0)]) + } else { + expression_provenance[&key] + }; + let id = if producer_type.is_virtual() { + if contracts[key.0].key == "mesh" { + let mesh = output_ids[&OutputKey(key.0, 0)]; + if mesh_root.is_some_and(|root| root != mesh) { + return Err(error( + "GRAPH_SOCKET_TYPE_MISMATCH", + "instance traversal has multiple mesh roots", + format!("nodes[{i}].inputs.{}", input.name), + )); + } + mesh_root = Some(mesh); + let op = match producer_type { + SemanticType::U32x16 => ExpressionOp::InstanceType { mesh }, + SemanticType::LocalAabb => ExpressionOp::LocalAabb { mesh }, + _ => unreachable!(), + }; + intern( + producer_type, + op, + graph.nodes[key.0] + .inputs + .get("") + .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(), + }), + Some(mesh), + ) + } else { + expression_ids[&key] + } + } else { + continue; + }; + operands.push(id); + operand_provenance.push(operand_mesh); + } } } let mut provenances = operand_provenance.into_iter().flatten(); @@ -2418,15 +2487,14 @@ pub fn compile(graph: Graph) -> Result { 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 { )); } Ok(CompiledGraph { - schema_version: 2, + schema_version: 3, graph_id: graph.graph_id, revision: graph.revision, node_count: graph.nodes.len() as u32, diff --git a/renderer/src/render_graph/contracts.rs b/renderer/src/render_graph/contracts.rs index 66b45fe..50f2dfa 100644 --- a/renderer/src/render_graph/contracts.rs +++ b/renderer/src/render_graph/contracts.rs @@ -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), diff --git a/renderer/src/render_graph/expression.rs b/renderer/src/render_graph/expression.rs index feb881e..70717b0 100644 --- a/renderer/src/render_graph/expression.rs +++ b/renderer/src/render_graph/expression.rs @@ -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, }, CompareF32 { operation: CompareOp, diff --git a/renderer/src/render_graph/runtime.rs b/renderer/src/render_graph/runtime.rs index 154a568..eb8bf0b 100644 --- a/renderer/src/render_graph/runtime.rs +++ b/renderer/src/render_graph/runtime.rs @@ -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 { | 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(), diff --git a/renderer/src/render_graph/schema.rs b/renderer/src/render_graph/schema.rs index f0be140..01a73f4 100644 --- a/renderer/src/render_graph/schema.rs +++ b/renderer/src/render_graph/schema.rs @@ -18,7 +18,7 @@ pub struct Node { pub state: NodeState, pub executor: ExecutorRef, pub parameters: serde_json::Value, - pub inputs: BTreeMap, + pub inputs: BTreeMap>, } #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] diff --git a/renderer/src/render_graph/tests.rs b/renderer/src/render_graph/tests.rs index 3ece243..c0b1fd0 100644 --- a/renderer/src/render_graph/tests.rs +++ b/renderer/src/render_graph/tests.rs @@ -7,7 +7,7 @@ fn compile_value(value: Value) -> Result { } 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::>()); + 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,15 +409,19 @@ 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, - InputDefaultPolicy::ParameterLiteral - ) - | ( - InputCardinality::OptionalOne, - InputDefaultPolicy::CompilerTexture - ) + ( + InputCardinality { min: 1, max: 1 }, + InputDefaultPolicy::None + ) | ( + InputCardinality { min: 0, max: 1 }, + InputDefaultPolicy::ParameterLiteral + ) | ( + InputCardinality { min: 0, max: 1 }, + InputDefaultPolicy::CompilerTexture + ) | ( + InputCardinality { min: 0, max: 8 }, + InputDefaultPolicy::None + ) ))); } diff --git a/renderer/src/renderer/instance_traversal.rs b/renderer/src/renderer/instance_traversal.rs index 4d0f446..f877e25 100644 --- a/renderer/src/renderer/instance_traversal.rs +++ b/renderer/src/renderer/instance_traversal.rs @@ -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 { 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"); } + #[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 { diff --git a/renderer/src/renderer/mod.rs b/renderer/src/renderer/mod.rs index 14eef1e..dc98a0b 100644 --- a/renderer/src/renderer/mod.rs +++ b/renderer/src/renderer/mod.rs @@ -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" diff --git a/static/render-graph/adapter.js b/static/render-graph/adapter.js index c0a68fe..01c2a8a 100644 --- a/static/render-graph/adapter.js +++ b/static/render-graph/adapter.js @@ -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), diff --git a/static/render-graph/catalog.js b/static/render-graph/catalog.js index ad917a8..f32c5fa 100644 --- a/static/render-graph/catalog.js +++ b/static/render-graph/catalog.js @@ -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, ), ]), ), diff --git a/static/render-graph/fxnode-editor.js b/static/render-graph/fxnode-editor.js index 7232c15..bd7293a 100644 --- a/static/render-graph/fxnode-editor.js +++ b/static/render-graph/fxnode-editor.js @@ -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: { diff --git a/static/render-graph/presets.js b/static/render-graph/presets.js index b37ea41..25fa3cf 100644 --- a/static/render-graph/presets.js +++ b/static/render-graph/presets.js @@ -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), diff --git a/tests/fxnode-composition.test.js b/tests/fxnode-composition.test.js index 1bf4ff1..c4f4443 100644 --- a/tests/fxnode-composition.test.js +++ b/tests/fxnode-composition.test.js @@ -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( diff --git a/tests/render-graph-authoring.test.js b/tests/render-graph-authoring.test.js index b8a87f5..bf4acbf 100644 --- a/tests/render-graph-authoring.test.js +++ b/tests/render-graph-authoring.test.js @@ -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"); +}); diff --git a/tests/render-graph-presets.test.js b/tests/render-graph-presets.test.js index 6ea9e80..9d56fb3 100644 --- a/tests/render-graph-presets.test.js +++ b/tests/render-graph-presets.test.js @@ -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" }]); }); diff --git a/vendor/fxnode/README.md b/vendor/fxnode/README.md index 159a1da..7c40e81 100644 --- a/vendor/fxnode/README.md +++ b/vendor/fxnode/README.md @@ -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 diff --git a/vendor/fxnode/UPSTREAM.md b/vendor/fxnode/UPSTREAM.md index 48d085e..ca8b17a 100644 --- a/vendor/fxnode/UPSTREAM.md +++ b/vendor/fxnode/UPSTREAM.md @@ -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 diff --git a/vendor/fxnode/docs/learn/examples/index.md b/vendor/fxnode/docs/learn/examples/index.md index 42b30c2..be276ae 100644 --- a/vendor/fxnode/docs/learn/examples/index.md +++ b/vendor/fxnode/docs/learn/examples/index.md @@ -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. diff --git a/vendor/fxnode/examples/assets/logic-nodes.png b/vendor/fxnode/examples/assets/logic-nodes.png new file mode 100644 index 0000000000000000000000000000000000000000..7ea6396c700dc403a45c4cccbc71dbc802d18f1b GIT binary patch literal 91797 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 literal 0 HcmV?d00001 diff --git a/vendor/fxnode/examples/index.html b/vendor/fxnode/examples/index.html index 20d1e32..ff77a0b 100644 --- a/vendor/fxnode/examples/index.html +++ b/vendor/fxnode/examples/index.html @@ -45,6 +45,11 @@ Runtime APILive composition

Version and migrate definitions while the editor is running.

+ + Boolean logic graph with multi-input socket pills + Multi-input socketsLogic nodes +

AND, OR, NOT, XOR, XNOR, NAND, and NOR composed from a Boolean socket.

+
Multi-view example Shared graphMulti-view diff --git a/vendor/fxnode/examples/logic-nodes/definition.ts b/vendor/fxnode/examples/logic-nodes/definition.ts new file mode 100644 index 0000000..0ab3f04 --- /dev/null +++ b/vendor/fxnode/examples/logic-nodes/definition.ts @@ -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>; + +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])[]; diff --git a/vendor/fxnode/examples/logic-nodes/index.html b/vendor/fxnode/examples/logic-nodes/index.html new file mode 100644 index 0000000..b791cd1 --- /dev/null +++ b/vendor/fxnode/examples/logic-nodes/index.html @@ -0,0 +1,22 @@ + + + + + + fxnode logic nodes + + + + +
+

Composable logic nodes

+

+ AND, OR, XOR, and XNOR accept several links through one multi-input pill. Toggle a Boolean value to evaluate the + graph in application code. +

+ + +
+ + + diff --git a/vendor/fxnode/examples/logic-nodes/main.ts b/vendor/fxnode/examples/logic-nodes/main.ts new file mode 100644 index 0000000..9d9cdbe --- /dev/null +++ b/vendor/fxnode/examples/logic-nodes/main.ts @@ -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("#graph")!; +const results = document.querySelector("#results")!; +const host = prepareFxNodeBrowserHost({ canvas }); +let cleanedUp = false; +let unsubscribeSnapshot: (() => void) | undefined; + +const operator = new Map 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()): 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; + } +})(); diff --git a/vendor/fxnode/examples/logic-nodes/style.css b/vendor/fxnode/examples/logic-nodes/style.css new file mode 100644 index 0000000..04c5ad2 --- /dev/null +++ b/vendor/fxnode/examples/logic-nodes/style.css @@ -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; +} diff --git a/vendor/fxnode/src/composition/types.ts b/vendor/fxnode/src/composition/types.ts index c9fffdb..e47bb8c 100644 --- a/vendor/fxnode/src/composition/types.ts +++ b/vendor/fxnode/src/composition/types.ts @@ -109,6 +109,7 @@ export interface FxNodeSocketDefinition { 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; diff --git a/vendor/fxnode/src/layout/layout-graph.ts b/vendor/fxnode/src/layout/layout-graph.ts index 1c5fa37..6dadbea 100644 --- a/vendor/fxnode/src/layout/layout-graph.ts +++ b/vendor/fxnode/src/layout/layout-graph.ts @@ -179,7 +179,7 @@ export function buildLayoutScene( ? 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( : 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(); + const rowBySocket = new Map(); 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( + 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( 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( kind: "socket", socketId: socket.id, ...(controlId ? { controlId } : {}), - units: 1, + units, bounds: rowBounds, }); } @@ -491,13 +529,15 @@ export function buildLayoutScene( 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, diff --git a/vendor/fxnode/src/layout/node-dimensions.ts b/vendor/fxnode/src/layout/node-dimensions.ts index e79473a..957d726 100644 --- a/vendor/fxnode/src/layout/node-dimensions.ts +++ b/vendor/fxnode/src/layout/node-dimensions.ts @@ -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,7 +13,11 @@ export const nodeRowUnits = (item: FxNodeUiRow): number => : 8 : item.kind === "resource" ? 4 - : 1; + : 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 ? 80 @@ -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( diff --git a/vendor/fxnode/src/layout/types.ts b/vendor/fxnode/src/layout/types.ts index e8833d9..4f1aed3 100644 --- a/vendor/fxnode/src/layout/types.ts +++ b/vendor/fxnode/src/layout/types.ts @@ -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; readonly linked: boolean; } export type LayoutControlKind = diff --git a/vendor/fxnode/src/render/canvas-renderer.ts b/vendor/fxnode/src/render/canvas-renderer.ts index e43019d..6028296 100644 --- a/vendor/fxnode/src/render/canvas-renderer.ts +++ b/vendor/fxnode/src/render/canvas-renderer.ts @@ -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; diff --git a/vendor/fxnode/src/worker/interaction.ts b/vendor/fxnode/src/worker/interaction.ts index 083c4c6..4424d3d 100644 --- a/vendor/fxnode/src/worker/interaction.ts +++ b/vendor/fxnode/src/worker/interaction.ts @@ -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), diff --git a/vendor/fxnode/test/browser/examples.docs.visual.spec.ts b/vendor/fxnode/test/browser/examples.docs.visual.spec.ts index f281b3f..c14a5e7 100644 --- a/vendor/fxnode/test/browser/examples.docs.visual.spec.ts +++ b/vendor/fxnode/test/browser/examples.docs.visual.spec.ts @@ -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( diff --git a/vendor/fxnode/test/browser/examples.spec.ts b/vendor/fxnode/test/browser/examples.spec.ts index ff5ef34..46a62ef 100644 --- a/vendor/fxnode/test/browser/examples.spec.ts +++ b/vendor/fxnode/test/browser/examples.spec.ts @@ -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("#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); diff --git a/vendor/fxnode/test/import-boundary.test.ts b/vendor/fxnode/test/import-boundary.test.ts index 91750e8..8271bf9 100644 --- a/vendor/fxnode/test/import-boundary.test.ts +++ b/vendor/fxnode/test/import-boundary.test.ts @@ -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 { for (const entry of await readdir(new URL(directory, root), { withFileTypes: true })) { diff --git a/vendor/fxnode/test/layout.test.ts b/vendor/fxnode/test/layout.test.ts index ecf104a..7ffec61 100644 --- a/vendor/fxnode/test/layout.test.ts +++ b/vendor/fxnode/test/layout.test.ts @@ -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[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 }),