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 0000000..7ea6396 Binary files /dev/null and b/vendor/fxnode/examples/assets/logic-nodes.png differ 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 }),