feat: add variadic render graph logic nodes

Amp-Thread-ID: https://ampcode.com/threads/T-019f9d91-77c1-7206-a60f-ed6554ce92ab

Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
Amp
2026-07-29 10:03:06 +00:00
co-authored by heaust
parent 0e866596c0
commit 90406ae18f
34 changed files with 1031 additions and 273 deletions
+187 -119
View File
@@ -306,10 +306,10 @@ pub fn parse_and_compile(bytes: &[u8]) -> Result<CompiledGraph, GraphError> {
.map_err(|_| GraphError::new("GRAPH_ENCODING_INVALID", "graph payload is not UTF-8"))?; .map_err(|_| GraphError::new("GRAPH_ENCODING_INVALID", "graph payload is not UTF-8"))?;
let probe: serde_json::Value = serde_json::from_str(text) let probe: serde_json::Value = serde_json::from_str(text)
.map_err(|e| GraphError::new("GRAPH_JSON_INVALID", e.to_string()))?; .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( return Err(GraphError::new(
"GRAPH_SCHEMA_UNSUPPORTED", "GRAPH_SCHEMA_UNSUPPORTED",
"schemaVersion must be 2", "schemaVersion must be 3",
)); ));
} }
let graph = serde_json::from_str(text) let graph = serde_json::from_str(text)
@@ -790,15 +790,20 @@ fn decode(node: &Node, i: usize) -> Result<NormalizedParameters, GraphError> {
base.clone(), 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( return Err(error(
"GRAPH_PARAMETERS_INVALID", "GRAPH_PARAMETERS_INVALID",
"expression defaults must exactly match inputs", "expression defaults must exactly match inputs",
base, base,
)); ));
} }
let mut defaults = Vec::with_capacity(contract.inputs.len()); let mut defaults = Vec::with_capacity(default_inputs.len());
for input in contract.inputs { for input in default_inputs {
let key = format!("{}Default", input.name); let key = format!("{}Default", input.name);
let value = object.get(&key).ok_or_else(|| { let value = object.get(&key).ok_or_else(|| {
error( error(
@@ -841,7 +846,7 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
} }
let mut input_count = 0usize; let mut input_count = 0usize;
for (i, node) in graph.nodes.iter().enumerate() { for (i, node) in graph.nodes.iter().enumerate() {
input_count = input_count.saturating_add(node.inputs.len()); input_count = input_count.saturating_add(node.inputs.values().map(Vec::len).sum::<usize>());
if input_count > 8192 { if input_count > 8192 {
return Err(error( return Err(error(
"GRAPH_LIMIT_EXCEEDED", "GRAPH_LIMIT_EXCEEDED",
@@ -850,10 +855,10 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
)); ));
} }
} }
if graph.schema_version != 2 { if graph.schema_version != 3 {
return Err(GraphError::new( return Err(GraphError::new(
"GRAPH_SCHEMA_UNSUPPORTED", "GRAPH_SCHEMA_UNSUPPORTED",
"schemaVersion must be 2", "schemaVersion must be 3",
)); ));
} }
validate_name_length(&graph.graph_id, "graphId")?; validate_name_length(&graph.graph_id, "graphId")?;
@@ -864,13 +869,14 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
] { ] {
validate_name_length(value, path)?; validate_name_length(value, path)?;
} }
for (socket, r) in &n.inputs { for (socket, refs) in &n.inputs {
for (value, path) in [ validate_name_length(socket, format!("nodes[{i}].inputs.{socket}"))?;
(socket, format!("nodes[{i}].inputs.{socket}")), for (index, r) in refs.iter().enumerate() {
(&r.node, format!("nodes[{i}].inputs.{socket}.node")), validate_name_length(&r.node, format!("nodes[{i}].inputs.{socket}[{index}].node"))?;
(&r.socket, format!("nodes[{i}].inputs.{socket}.socket")), validate_name_length(
] { &r.socket,
validate_name_length(value, path)?; format!("nodes[{i}].inputs.{socket}[{index}].socket"),
)?;
} }
} }
} }
@@ -891,24 +897,30 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
format!("nodes[{i}].id"), format!("nodes[{i}].id"),
)); ));
} }
for (socket, r) in &n.inputs { for (socket, refs) in &n.inputs {
for (value, path) in [ validate_name_grammar(socket, format!("nodes[{i}].inputs.{socket}"))?;
(socket, format!("nodes[{i}].inputs.{socket}")), for (index, r) in refs.iter().enumerate() {
(&r.node, format!("nodes[{i}].inputs.{socket}.node")), validate_name_grammar(
(&r.socket, format!("nodes[{i}].inputs.{socket}.socket")), &r.node,
] { format!("nodes[{i}].inputs.{socket}[{index}].node"),
validate_name_grammar(value, path)?; )?;
validate_name_grammar(
&r.socket,
format!("nodes[{i}].inputs.{socket}[{index}].socket"),
)?;
} }
} }
} }
for (i, n) in graph.nodes.iter().enumerate() { for (i, n) in graph.nodes.iter().enumerate() {
for (s, r) in &n.inputs { for (s, refs) in &n.inputs {
if !ids.contains_key(r.node.as_str()) { for (index, r) in refs.iter().enumerate() {
return Err(error( if !ids.contains_key(r.node.as_str()) {
"GRAPH_UNKNOWN_NODE", return Err(error(
"unknown input node", "GRAPH_UNKNOWN_NODE",
format!("nodes[{i}].inputs.{s}.node"), "unknown input node",
)); format!("nodes[{i}].inputs.{s}[{index}].node"),
));
}
} }
} }
} }
@@ -978,27 +990,36 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
} }
} }
for (i, n) in graph.nodes.iter().enumerate() { for (i, n) in graph.nodes.iter().enumerate() {
for (name, r) in &n.inputs { for (name, refs) in &n.inputs {
let pn = ids[r.node.as_str()]; for (index, r) in refs.iter().enumerate() {
if !contracts[pn].outputs.iter().any(|out| out.name == r.socket) { let pn = ids[r.node.as_str()];
return Err(error( if !contracts[pn].outputs.iter().any(|out| out.name == r.socket) {
"GRAPH_UNKNOWN_SOCKET", return Err(error(
"unknown output socket", "GRAPH_UNKNOWN_SOCKET",
format!("nodes[{i}].inputs.{name}.socket"), "unknown output socket",
)); format!("nodes[{i}].inputs.{name}[{index}].socket"),
));
}
} }
} }
} }
for (i, n) in graph.nodes.iter().enumerate() { for (i, n) in graph.nodes.iter().enumerate() {
for input in contracts[i].inputs { for input in contracts[i].inputs {
if !n.inputs.contains_key(input.name) { let sources = n.inputs.get(input.name);
if input.cardinality == InputCardinality::RequiredOne { if sources.is_some_and(Vec::is_empty) {
return Err(error( return Err(error(
"GRAPH_SOCKET_CARDINALITY", "GRAPH_SOCKET_CARDINALITY",
"required input is missing", "present input bindings must not be empty",
format!("nodes[{i}].inputs.{}", input.name), 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<CompiledGraph, GraphError> {
let mut bound: Vec<BTreeMap<&str, BoundInput>> = vec![BTreeMap::new(); graph.nodes.len()]; let mut bound: Vec<BTreeMap<&str, BoundInput>> = vec![BTreeMap::new(); graph.nodes.len()];
for (i, n) in graph.nodes.iter().enumerate() { for (i, n) in graph.nodes.iter().enumerate() {
for input in contracts[i].inputs { 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; continue;
}; };
let pn = ids[r.node.as_str()]; for (source_index, r) in refs.iter().enumerate() {
let (ordinal, out) = contracts[pn] let pn = ids[r.node.as_str()];
.outputs let (ordinal, out) = contracts[pn]
.iter() .outputs
.enumerate() .iter()
.find(|(_, o)| o.name == r.socket) .enumerate()
.expect("producer sockets were globally validated"); .find(|(_, o)| o.name == r.socket)
if !accepts(input.accepted, out.semantic_type) { .expect("producer sockets were globally validated");
return Err(error( if !accepts(input.accepted, out.semantic_type) {
"GRAPH_SOCKET_TYPE_MISMATCH", return Err(error(
"socket type mismatch", "GRAPH_SOCKET_TYPE_MISMATCH",
format!("nodes[{i}].inputs.{}", input.name), "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(); let mut edges = Vec::new();
for i in 0..graph.nodes.len() { for i in 0..graph.nodes.len() {
for (input_ordinal, input) in contracts[i].inputs.iter().enumerate() { 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 { edges.push(DependencyEdge {
from_node: b.producer.0, from_node: b.producer.0,
from_socket: contracts[b.producer.0].outputs[b.producer.1 as usize] from_socket: contracts[b.producer.0].outputs[b.producer.1 as usize]
@@ -1044,7 +1089,7 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
to_node: i, to_node: i,
to_socket: input.name.into(), to_socket: input.name.into(),
consumer_input_ordinal: input_ordinal as u16, 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<CompiledGraph, GraphError> {
let mut operands = Vec::new(); let mut operands = Vec::new();
let mut operand_provenance = Vec::new(); let mut operand_provenance = Vec::new();
for (ordinal, input) in contracts[i].inputs.iter().enumerate() { for (ordinal, input) in contracts[i].inputs.iter().enumerate() {
if let Some(binding) = bound[i].get(input.name) { let bindings: Vec<_> = graph.nodes[i]
let key = binding.producer; .inputs
let producer_type = contracts[key.0].outputs[key.1 as usize].semantic_type; .get(input.name)
let operand_mesh = if contracts[key.0].key == "mesh" { .into_iter()
Some(output_ids[&OutputKey(key.0, 0)]) .flatten()
} else { .map(|r| {
expression_provenance[&key] let producer = ids[r.node.as_str()];
}; let output = contracts[producer]
let id = if producer_type.is_virtual() { .outputs
if contracts[key.0].key == "mesh" { .iter()
let mesh = output_ids[&OutputKey(key.0, 0)]; .position(|o| o.name == r.socket)
if mesh_root.is_some_and(|root| root != mesh) { .unwrap();
return Err(error( BoundInput {
"GRAPH_SOCKET_TYPE_MISMATCH", producer: OutputKey(producer, output as u16),
"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]
} }
} else { })
.collect();
if bindings.is_empty() {
if input.cardinality.max > 1 {
continue; continue;
}; }
operands.push(id);
operand_provenance.push(operand_mesh);
} else {
let literal = defaults[ordinal].clone(); let literal = defaults[ordinal].clone();
operands.push(intern( operands.push(intern(
literal.semantic_type(), literal.semantic_type(),
@@ -2403,6 +2423,55 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
None, None,
)); ));
operand_provenance.push(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(); let mut provenances = operand_provenance.into_iter().flatten();
@@ -2418,15 +2487,14 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
let key = contracts[i].key; let key = contracts[i].key;
let op = match key { let op = match key {
"not" => ExpressionOp::Not { value: operands[0] }, "not" => ExpressionOp::Not { value: operands[0] },
"and" | "or" | "xor" | "xnor" => ExpressionOp::BooleanBinary { "and" | "or" | "xor" | "xnor" => ExpressionOp::Boolean {
operation: match key { operation: match key {
"and" => BooleanBinaryOp::And, "and" => BooleanOp::And,
"or" => BooleanBinaryOp::Or, "or" => BooleanOp::Or,
"xor" => BooleanBinaryOp::Xor, "xor" => BooleanOp::Xor,
_ => BooleanBinaryOp::Xnor, _ => BooleanOp::Xnor,
}, },
left: operands[0], operands: operands.clone(),
right: operands[1],
}, },
"greater_than_f32" | "less_than_f32" | "equals_f32" => ExpressionOp::CompareF32 { "greater_than_f32" | "less_than_f32" | "equals_f32" => ExpressionOp::CompareF32 {
operation: if key.starts_with("greater") { operation: if key.starts_with("greater") {
@@ -2636,7 +2704,7 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
)); ));
} }
Ok(CompiledGraph { Ok(CompiledGraph {
schema_version: 2, schema_version: 3,
graph_id: graph.graph_id, graph_id: graph.graph_id,
revision: graph.revision, revision: graph.revision,
node_count: graph.nodes.len() as u32, node_count: graph.nodes.len() as u32,
+47 -12
View File
@@ -39,9 +39,9 @@ pub enum FullscreenPolicy {
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum InputCardinality { pub struct InputCardinality {
RequiredOne, pub min: u8,
OptionalOne, pub max: u8,
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
@@ -97,17 +97,19 @@ pub struct Contract {
} }
use SemanticType::*; use SemanticType::*;
const R: InputCardinality = InputCardinality::RequiredOne; const R: InputCardinality = InputCardinality { min: 1, max: 1 };
const O: InputCardinality = InputCardinality::OptionalOne; const O: InputCardinality = InputCardinality { min: 0, max: 1 };
const V: InputCardinality = InputCardinality { min: 0, max: 8 };
const fn i( const fn i(
name: &'static str, name: &'static str,
ty: SemanticType, ty: SemanticType,
cardinality: InputCardinality, cardinality: InputCardinality,
role: InputRole, role: InputRole,
) -> InputSocketContract { ) -> InputSocketContract {
let default_policy = match cardinality { let default_policy = match (cardinality.min, cardinality.max) {
InputCardinality::RequiredOne => InputDefaultPolicy::None, (_, 2..) => InputDefaultPolicy::None,
InputCardinality::OptionalOne => match role { (1, _) => InputDefaultPolicy::None,
_ => match role {
InputRole::ColorTarget { .. } | InputRole::DepthTarget => { InputRole::ColorTarget { .. } | InputRole::DepthTarget => {
InputDefaultPolicy::CompilerTexture InputDefaultPolicy::CompilerTexture
} }
@@ -194,17 +196,50 @@ macro_rules! ex {
c!($k, 1, Expression, $ins, $outs, false, None) c!($k, 1, Expression, $ins, $outs, false, None)
}; };
} }
const BOOL_VARIADIC_I: &[InputSocketContract] = &[i("inputs", Bool, V, InputRole::Expression)];
pub static CONTRACTS: &[Contract] = &[ pub static CONTRACTS: &[Contract] = &[
c!("mesh", 2, Source, NONE_I, MESH_O, false, None), c!("mesh", 2, Source, NONE_I, MESH_O, false, None),
c!("texture", 2, Source, NONE_I, TEXTURE_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!("frustum_cull", 2, Expression, CULL_I, CULL_O, false, None),
c!("pipeline", 4, Render, PIPE_I, PIPE_O, false, None), c!("pipeline", 4, Render, PIPE_I, PIPE_O, false, None),
ex!("and", ins!("left":Bool,"right":Bool), outs!("value":Bool)), c!(
ex!("or", ins!("left":Bool,"right":Bool), outs!("value":Bool)), "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!("not", ins!("operand":Bool), outs!("value":Bool)),
ex!("xor", ins!("left":Bool,"right":Bool), outs!("value":Bool)), c!(
ex!("xnor", ins!("left":Bool,"right":Bool), outs!("value":Bool)), "xor",
2,
Expression,
BOOL_VARIADIC_I,
outs!("value":Bool),
false,
None
),
c!(
"xnor",
2,
Expression,
BOOL_VARIADIC_I,
outs!("value":Bool),
false,
None
),
ex!( ex!(
"greater_than_f32", "greater_than_f32",
ins!("left":F32,"right":F32), ins!("left":F32,"right":F32),
+4 -5
View File
@@ -70,7 +70,7 @@ pub enum CompareOp {
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum BooleanBinaryOp { pub enum BooleanOp {
And, And,
Or, Or,
Xor, Xor,
@@ -93,10 +93,9 @@ pub enum ExpressionOp {
Not { Not {
value: ExprId, value: ExprId,
}, },
BooleanBinary { Boolean {
operation: BooleanBinaryOp, operation: BooleanOp,
left: ExprId, operands: Vec<ExprId>,
right: ExprId,
}, },
CompareF32 { CompareF32 {
operation: CompareOp, operation: CompareOp,
+10 -8
View File
@@ -802,9 +802,9 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> {
)); ));
} }
} }
if graph.schema_version != 2 { if graph.schema_version != 3 {
return Err(invalid( return Err(invalid(
"compiled graph schema version must be 2", "compiled graph schema version must be 3",
"schemaVersion", "schemaVersion",
)); ));
} }
@@ -1303,14 +1303,16 @@ fn validate_instance_traversal(graph: &CompiledGraph) -> Result<(), GraphError>
} }
vec![*value] vec![*value]
} }
ExpressionOp::BooleanBinary { left, right, .. } => { ExpressionOp::Boolean { operands, .. } => {
if expression.semantic_type != SemanticType::Bool if expression.semantic_type != SemanticType::Bool
|| ty(*left) != Some(SemanticType::Bool) || operands.len() > 8
|| ty(*right) != Some(SemanticType::Bool) || operands
.iter()
.any(|operand| ty(*operand) != Some(SemanticType::Bool))
{ {
return Err(invalid("boolean signature is invalid", &path)); return Err(invalid("boolean signature is invalid", &path));
} }
vec![*left, *right] operands.clone()
} }
ExpressionOp::CompareF32 { left, right, .. } => { ExpressionOp::CompareF32 { left, right, .. } => {
if expression.semantic_type != SemanticType::Bool if expression.semantic_type != SemanticType::Bool
@@ -1511,8 +1513,8 @@ fn expression_operands(op: &ExpressionOp) -> Vec<ExprId> {
| ExpressionOp::U32Bit { value, .. } => vec![*value], | ExpressionOp::U32Bit { value, .. } => vec![*value],
ExpressionOp::AabbMin { aabb } | ExpressionOp::AabbMax { aabb } => vec![*aabb], ExpressionOp::AabbMin { aabb } | ExpressionOp::AabbMax { aabb } => vec![*aabb],
ExpressionOp::FrustumCulled { local_aabb, .. } => vec![*local_aabb], ExpressionOp::FrustumCulled { local_aabb, .. } => vec![*local_aabb],
ExpressionOp::BooleanBinary { left, right, .. } ExpressionOp::Boolean { operands, .. } => operands.clone(),
| ExpressionOp::CompareF32 { left, right, .. } ExpressionOp::CompareF32 { left, right, .. }
| ExpressionOp::CompareU32 { left, right, .. } => vec![*left, *right], | ExpressionOp::CompareU32 { left, right, .. } => vec![*left, *right],
ExpressionOp::VectorConstruct { components } => components.clone(), ExpressionOp::VectorConstruct { components } => components.clone(),
ExpressionOp::MatrixConstruct { columns } => columns.clone(), ExpressionOp::MatrixConstruct { columns } => columns.clone(),
+1 -1
View File
@@ -18,7 +18,7 @@ pub struct Node {
pub state: NodeState, pub state: NodeState,
pub executor: ExecutorRef, pub executor: ExecutorRef,
pub parameters: serde_json::Value, pub parameters: serde_json::Value,
pub inputs: BTreeMap<String, NodeOutputRef>, pub inputs: BTreeMap<String, Vec<NodeOutputRef>>,
} }
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
+36 -15
View File
@@ -7,7 +7,7 @@ fn compile_value(value: Value) -> Result<CompiledGraph, GraphError> {
} }
fn input(node: &str, socket: &str) -> Value { fn input(node: &str, socket: &str) -> Value {
json!({ "node": node, "socket": socket }) json!([{ "node": node, "socket": socket }])
} }
fn texture(id: &str, format: &str) -> Value { 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 { 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"), texture("color", "rgba16_float"), texture("depth", "depth32_float"),
node("mesh", "mesh", 2, json!({}), json!({})), 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]}), 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"}), node("cull", "frustum_cull", 2, json!({"camera":"active"}),
json!({"mesh":input("mesh","mesh"),"localAabb":input("mesh","localAabb")})), json!({"mesh":input("mesh","mesh"),"localAabb":input("mesh","localAabb")})),
node("visible", "not", 1, json!({"operandDefault":false}), json!({"operand":input("cull","isFrustumCulled")})), node("visible", "not", 1, json!({"operandDefault":false}), json!({"operand":input("cull","isFrustumCulled")})),
node("class", "and", 1, json!({"leftDefault":true,"rightDefault":true}), node("class", "and", 2, json!({}),
json!({"left":input("bits","bit0"),"right":input("visible","value")})), json!({"inputs":[input("bits","bit0")[0].clone(),input("visible","value")[0].clone()]})),
node("pipeline", "pipeline", 4, node("pipeline", "pipeline", 4,
json!({"pipeline":"gltf_standard","depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}), 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")})), 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() .iter()
.find(|i| i.name == "predicate") .find(|i| i.name == "predicate")
.unwrap(); .unwrap();
assert_eq!(predicate.cardinality, InputCardinality::OptionalOne); assert_eq!(predicate.cardinality, InputCardinality { min: 0, max: 1 });
for key in [ for key in [
"and", "and",
"xnor", "xnor",
@@ -98,6 +98,23 @@ fn catalog_exposes_final_mesh_pipeline_and_generic_expression_contracts() {
} }
} }
#[test]
fn variadic_boolean_inputs_reject_empty_and_over_capacity_bindings() {
let mut empty = full_cull_graph();
empty["nodes"][7]["inputs"]["inputs"] = json!([]);
let error = compile_value(empty).unwrap_err();
assert_eq!(error.code, "GRAPH_SOCKET_CARDINALITY");
assert_eq!(error.details["path"], "nodes[7].inputs.inputs");
let mut over_capacity = full_cull_graph();
over_capacity["nodes"][7]["inputs"]["inputs"] = json!((0..9)
.map(|_| json!({ "node": "bits", "socket": "bit0" }))
.collect::<Vec<_>>());
let error = compile_value(over_capacity).unwrap_err();
assert_eq!(error.code, "GRAPH_SOCKET_CARDINALITY");
assert_eq!(error.details["path"], "nodes[7].inputs.inputs");
}
#[test] #[test]
fn typed_graph_builds_one_dense_deterministic_traversal() { fn typed_graph_builds_one_dense_deterministic_traversal() {
let first = compile_value(full_cull_graph()).unwrap(); let first = compile_value(full_cull_graph()).unwrap();
@@ -356,7 +373,7 @@ fn expression_provenance_rejects_cross_mesh_values() {
} }
fn implicit_pipeline_graph() -> Value { 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("mesh", "mesh", 2, json!({}), json!({})),
node("first", "pipeline", 4, node("first", "pipeline", 4,
json!({"pipeline":"ground_plane","depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}), 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) .flat_map(|c| c.inputs)
.all(|input| matches!( .all(|input| matches!(
(input.cardinality, input.default_policy), (input.cardinality, input.default_policy),
(InputCardinality::RequiredOne, InputDefaultPolicy::None) (
| ( InputCardinality { min: 1, max: 1 },
InputCardinality::OptionalOne, InputDefaultPolicy::None
InputDefaultPolicy::ParameterLiteral ) | (
) InputCardinality { min: 0, max: 1 },
| ( InputDefaultPolicy::ParameterLiteral
InputCardinality::OptionalOne, ) | (
InputDefaultPolicy::CompilerTexture InputCardinality { min: 0, max: 1 },
) InputDefaultPolicy::CompilerTexture
) | (
InputCardinality { min: 0, max: 8 },
InputDefaultPolicy::None
)
))); )));
} }
+72 -15
View File
@@ -1,7 +1,7 @@
//! Graph-owned instance predicate compute support. //! Graph-owned instance predicate compute support.
use crate::render_graph::{ use crate::render_graph::{
BooleanBinaryOp, CompareOp, ExpressionOp, InstanceTraversalPlan, SemanticType, TypedLiteral, BooleanOp, CompareOp, ExpressionOp, InstanceTraversalPlan, SemanticType, TypedLiteral,
}; };
use super::gpu_scene::{DrawIndexedIndirect, GpuSceneCache}; use super::gpu_scene::{DrawIndexedIndirect, GpuSceneCache};
@@ -58,21 +58,29 @@ pub fn generate_wgsl(plan: &InstanceTraversalPlan) -> Result<String, String> {
ExpressionOp::InstanceType { .. } => "types[i]".into(), ExpressionOp::InstanceType { .. } => "types[i]".into(),
ExpressionOp::LocalAabb { .. } => "LocalAabb(aabbs[i].min.xyz,aabbs[i].max.xyz)".into(), ExpressionOp::LocalAabb { .. } => "LocalAabb(aabbs[i].min.xyz,aabbs[i].max.xyz)".into(),
ExpressionOp::Not { value } => format!("!{}", x(*value)), ExpressionOp::Not { value } => format!("!{}", x(*value)),
ExpressionOp::BooleanBinary { ExpressionOp::Boolean {
operation, operation,
left, operands,
right, } => {
} => format!( let identity = matches!(operation, BooleanOp::And | BooleanOp::Xnor);
"({} {} {})", let operator = match operation {
x(*left), BooleanOp::And => "&&",
match operation { BooleanOp::Or => "||",
BooleanBinaryOp::And => "&&", BooleanOp::Xor | BooleanOp::Xnor => "!=",
BooleanBinaryOp::Or => "||", };
BooleanBinaryOp::Xor => "!=", let folded = operands
BooleanBinaryOp::Xnor => "==", .iter()
}, .map(|operand| x(*operand))
x(*right) .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 { ExpressionOp::CompareF32 {
operation, operation,
left, left,
@@ -432,6 +440,55 @@ mod tests {
assert_eq!(wgsl_type(&SemanticType::Mat4).unwrap(), "mat4x4<f32>"); assert_eq!(wgsl_type(&SemanticType::Mat4).unwrap(), "mat4x4<f32>");
} }
#[test]
fn variadic_boolean_wgsl_uses_identities_and_ordered_parity() {
let expressions = vec![
expression(
SemanticType::Bool,
ExpressionOp::Boolean {
operation: BooleanOp::And,
operands: vec![],
},
),
expression(
SemanticType::Bool,
ExpressionOp::Boolean {
operation: BooleanOp::Xor,
operands: vec![],
},
),
expression(
SemanticType::Bool,
ExpressionOp::Boolean {
operation: BooleanOp::Xnor,
operands: vec![crate::render_graph::ExprId(0)],
},
),
expression(
SemanticType::Bool,
ExpressionOp::Boolean {
operation: BooleanOp::Xnor,
operands: vec![
crate::render_graph::ExprId(0),
crate::render_graph::ExprId(1),
crate::render_graph::ExprId(2),
],
},
),
];
let wgsl = generate_wgsl(&InstanceTraversalPlan {
mesh: 0,
expressions: ExpressionPlan { expressions },
pipelines: vec![],
requires_camera: false,
})
.unwrap();
assert!(wgsl.contains("let e0=true;"));
assert!(wgsl.contains("let e1=false;"));
assert!(wgsl.contains("let e2=!(e0);"));
assert!(wgsl.contains("let e3=!((e0 != e1) != e2);"));
}
#[test] #[test]
fn u32_construct_wgsl_is_parenthesized() { fn u32_construct_wgsl_is_parenthesized() {
let plan = InstanceTraversalPlan { let plan = InstanceTraversalPlan {
+1 -1
View File
@@ -1155,7 +1155,7 @@ mod switch_request_tests {
assert_eq!(pending, None); assert_eq!(pending, None);
let invalid_replacement = 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!( assert_eq!(
registry.compile(invalid_replacement).unwrap_err().code, registry.compile(invalid_replacement).unwrap_err().code,
"GRAPH_JSON_INVALID" "GRAPH_JSON_INVALID"
+10 -8
View File
@@ -446,10 +446,10 @@ export function adaptFxNodeSnapshot(raw, revision = 1) {
linkSources.set(link.id, linkSource); linkSources.set(link.id, linkSource);
if (!link.muted) { if (!link.muted) {
incoming.set(link.toSocketId, (incoming.get(link.toSocketId) ?? 0) + 1); 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, node: from.node,
socket: from.key, socket: from.key,
}; });
} }
} }
const ordered = [...nodes.values()].sort((a, b) => const ordered = [...nodes.values()].sort((a, b) =>
@@ -548,13 +548,13 @@ export function adaptFxNodeSnapshot(raw, revision = 1) {
for (const key of Object.keys( for (const key of Object.keys(
descriptors[item.value.executor.key].inputs, descriptors[item.value.executor.key].inputs,
)) { )) {
const link = raw.links.find( const links = raw.links.filter(
(x) => (x) =>
!x.muted && !x.muted &&
x.toNodeId === item.value.id && x.toNodeId === item.value.id &&
sockets.get(x.toSocketId)?.key === key, sockets.get(x.toSocketId)?.key === key,
); );
const source = linkSources.get(link?.id) ?? { const source = linkSources.get(links[0]?.id) ?? {
kind: "input", kind: "input",
nodeId: item.value.id, nodeId: item.value.id,
input: key, input: key,
@@ -562,9 +562,11 @@ export function adaptFxNodeSnapshot(raw, revision = 1) {
unconnected: true, unconnected: true,
}; };
paths[`${base}.inputs.${key}`] = source; paths[`${base}.inputs.${key}`] = source;
if (link) { for (const [index, link] of links.entries()) {
paths[`${base}.inputs.${key}.node`] = source; const linkSource = linkSources.get(link.id);
paths[`${base}.inputs.${key}.socket`] = { paths[`${base}.inputs.${key}[${index}]`] = linkSource;
paths[`${base}.inputs.${key}[${index}].node`] = linkSource;
paths[`${base}.inputs.${key}[${index}].socket`] = {
kind: "socket", kind: "socket",
nodeId: link.fromNodeId, nodeId: link.fromNodeId,
socketId: link.fromSocketId, socketId: link.fromSocketId,
@@ -576,7 +578,7 @@ export function adaptFxNodeSnapshot(raw, revision = 1) {
paths[`${base}.inputs`] = nodeSource; paths[`${base}.inputs`] = nodeSource;
} }
const ir = { const ir = {
schemaVersion: 2, schemaVersion: 3,
graphId: GRAPH_ID, graphId: GRAPH_ID,
revision, revision,
nodes: ordered.map((item) => item.value), nodes: ordered.map((item) => item.value),
+14 -13
View File
@@ -1,9 +1,9 @@
export const GRAPH_ID = "authored_gpu_culling"; 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 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, accepted: typeof type === "string" ? exact(type) : type,
required, cardinality: { minimum, maximum },
...(authoringType ? { authoringType } : {}), ...(authoringType ? { authoringType } : {}),
defaultPolicy, defaultPolicy,
}); });
@@ -11,18 +11,18 @@ const o = (type) => ({ type });
const expression = (inputs, outputs) => ({ const expression = (inputs, outputs) => ({
version: 1, version: 1,
execution: "expression", 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)])), outputs: Object.fromEntries(Object.entries(outputs).map(([name, type]) => [name, o(type)])),
parameters: {}, parameters: {},
}); });
const numbered = (prefix, count, type) => const numbered = (prefix, count, type) =>
Object.fromEntries(Array.from({ length: count }, (_, index) => [`${prefix}${index}`, type])); Object.fromEntries(Array.from({ length: count }, (_, index) => [`${prefix}${index}`, type]));
const expressionCatalog = { const expressionCatalog = {
and: 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({ left: "bool", right: "bool" }, { value: "bool" }), or: { ...expression({ inputs: "bool" }, { value: "bool" }), version: 2, inputs: { inputs: i("bool", 0, undefined, "none", 8) } },
not: expression({ operand: "bool" }, { value: "bool" }), not: expression({ operand: "bool" }, { value: "bool" }),
xor: 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({ left: "bool", right: "bool" }, { value: "bool" }), 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" }), greater_than_f32: expression({ left: "f32", right: "f32" }, { value: "bool" }),
less_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" }), equals_f32: expression({ left: "f32", right: "f32" }, { value: "bool" }),
@@ -112,9 +112,9 @@ export const semanticCatalog = Object.freeze({
execution: "render", execution: "render",
inputs: { inputs: {
mesh: i("mesh_data"), mesh: i("mesh_data"),
predicate: i("bool", false), predicate: i("bool", 0),
colorTarget: i("texture", false, undefined, "compiler_texture"), colorTarget: i("texture", 0, undefined, "compiler_texture"),
depthTarget: i("texture", false, undefined, "compiler_texture"), depthTarget: i("texture", 0, undefined, "compiler_texture"),
}, },
outputs: { color: o("texture"), depth: o("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] }, 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" }, render: { header: "#426b43" },
frame: { header: "#a75d37" }, frame: { header: "#a75d37" },
}; };
const socket = (title, direction, type, value = null) => ({ const socket = (title, direction, type, value = null, capacity = 1) => ({
title, title,
direction, direction,
type, type,
maxIncomingLinks: direction === "input" ? 1 : 0, maxIncomingLinks: direction === "input" ? capacity : 0,
visible: true, visible: true,
value, value,
showValue: value !== null, showValue: value !== null,
@@ -429,6 +429,7 @@ export const nodeDefinitions = Object.fromEntries(
"input", "input",
v.authoringType ?? v.accepted.types[0], v.authoringType ?? v.accepted.types[0],
v.defaultPolicy === "parameter_literal" ? socketDefault(v.accepted.types[0], defaultForInput(key, n, v.accepted.types[0])) : null, v.defaultPolicy === "parameter_literal" ? socketDefault(v.accepted.types[0], defaultForInput(key, n, v.accepted.types[0])) : null,
v.cardinality.maximum,
), ),
]), ]),
), ),
+4 -4
View File
@@ -16,10 +16,10 @@ async function seed(root) {
for (const [index, item] of culling.nodes.entries()) for (const [index, item] of culling.nodes.entries())
await root.dispatch({ type: "node.add", nodeId: item.id, nodeType: item.executor.key, 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 } }); 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]) => const links = culling.nodes.flatMap((item) => Object.entries(item.inputs).flatMap(([socket, sources]) =>
[from.node, from.socket, item.id, socket])); sources.map((from, index) => [from.node, from.socket, item.id, socket, index])));
for (const [a, as, b, bs] of links) { for (const [a, as, b, bs, index] of links) {
const id = `${a}_${as}_${b}_${bs}`; const id = `${a}_${as}_${b}_${bs}_${index}`;
await root.dispatch({ await root.dispatch({
type: "link.add", type: "link.add",
link: { link: {
+8 -9
View File
@@ -1,6 +1,6 @@
import { descriptors } from "./catalog.js"; import { descriptors } from "./catalog.js";
const input = (node, socket) => ({ node, socket }); const input = (node, socket) => [{ node, socket }];
const node = (id, key, parameters = {}, inputs = {}) => ({ const node = (id, key, parameters = {}, inputs = {}) => ({
id, state: "enabled", executor: { key, version: descriptors[key].version }, parameters, inputs, id, state: "enabled", executor: { key, version: descriptors[key].version }, parameters, inputs,
}); });
@@ -17,20 +17,19 @@ const predicates = (withCulling = false) => {
const result = [ const result = [
node("type_words", "separate_u32x16", { valueDefault: Array(16).fill(0) }, { value: input("mesh", "type") }), 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("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("ground_class", "and", {}, { inputs: [...input("type_bits", "bit0"), ...input("type_bits", "bit1")] }),
node("visible_pbr", "and", { leftDefault: true, rightDefault: true }, { left: input("type_bits", "bit0"), right: input("type_bits", "bit2") }),
node("not_double", "not", { operandDefault: false }, { operand: input("type_bits", "bit3") }), 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("standard_class", "and", {}, { inputs: [...input("type_bits", "bit0"), ...input("type_bits", "bit2"), ...input("not_double", "value")] }),
node("double_class", "and", { leftDefault: true, rightDefault: true }, { left: input("type_bits", "bit0"), right: input("type_bits", "bit3") }), 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" } }; if (!withCulling) return { nodes: result, classes: { ground: "ground_class", pbr: "standard_class", pbr_double: "double_class" } };
result.push( result.push(
node("cull", "frustum_cull", { camera: "active" }, { mesh: input("mesh", "mesh"), localAabb: input("mesh", "localAabb") }), node("cull", "frustum_cull", { camera: "active" }, { mesh: input("mesh", "mesh"), localAabb: input("mesh", "localAabb") }),
node("not_culled", "not", { operandDefault: false }, { operand: input("cull", "isFrustumCulled") }), 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 scene = (colorTarget, clearColor = [0.015, 0.02, 0.03, 1], heightScale = 1, withCulling = false) => {
const classification = predicates(withCulling); 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") }), 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, [ const direct = (graphId, clearColor) => graph(graphId, [
node("ldr", "texture", texture("rgba8_unorm")), node("ldr", "texture", texture("rgba8_unorm")),
...scene("ldr", clearColor), ...scene("ldr", clearColor),
+1 -1
View File
@@ -36,7 +36,7 @@ test("production render graph composition passes fxnode's public validator", asy
result.ok ? undefined : JSON.stringify(result.issues, null, 2), result.ok ? undefined : JSON.stringify(result.issues, null, 2),
); );
assert.equal(fxNodeComposition.schemaVersion, 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.equal(Object.keys(fxNodeComposition.nodes).length, 42);
assert.ok( assert.ok(
Object.values(fxNodeComposition.nodes).every( Object.values(fxNodeComposition.nodes).every(
+61 -7
View File
@@ -2,18 +2,46 @@ import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { culling } from "../static/render-graph/presets.js"; import { culling } from "../static/render-graph/presets.js";
import { import {
CATALOG_VERSION, semanticCatalog, nodeDefinitions, descriptors, CATALOG_VERSION, GRAPH_ID, semanticCatalog, nodeDefinitions, descriptors, socketTypes,
} from "../static/render-graph/catalog.js"; } 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", () => { const authoredNode = (id, typeId) => {
assert.equal(CATALOG_VERSION, 10); 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, { assert.deepEqual(semanticCatalog.mesh.outputs, {
mesh: { type: "mesh_data" }, type: { type: "u32x16" }, localAabb: { type: "local_aabb" }, mesh: { type: "mesh_data" }, type: { type: "u32x16" }, localAabb: { type: "local_aabb" },
}); });
assert.equal(semanticCatalog.mesh.version, 2); assert.equal(semanticCatalog.mesh.version, 2);
assert.equal(nodeDefinitions.mesh.sockets.localAabb.title, "Local AABB"); assert.equal(nodeDefinitions.mesh.sockets.localAabb.title, "Local AABB");
assert.equal(semanticCatalog.pipeline.version, 4); 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"]); assert.deepEqual(nodeDefinitions.texture.parameters.sampleCount.enum, ["1", "4"]);
for (const key of ["and", "xnor", "equals_f32", "greater_than_u32", "combine_vec4", for (const key of ["and", "xnor", "equals_f32", "greater_than_u32", "combine_vec4",
"separate_mat4", "combine_u32_bits", "separate_u32x16", "separate_local_aabb"]) "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", () => { test("current culling fixture uses type-bit predicates and final socket versions", () => {
const byId = Object.fromEntries(culling.nodes.map((node) => [node.id, node])); const byId = Object.fromEntries(culling.nodes.map((node) => [node.id, node]));
assert.deepEqual(byId.cull.inputs.localAabb, { node: "mesh", socket: "localAabb" }); assert.deepEqual(byId.cull.inputs.localAabb, [{ node: "mesh", socket: "localAabb" }]);
assert.deepEqual(byId.type_words.inputs.value, { node: "mesh", socket: "type" }); assert.deepEqual(byId.type_words.inputs.value, [{ node: "mesh", socket: "type" }]);
assert.equal(byId.ground.executor.version, 4); 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", () => { 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"]) for (const removedSocket of ["isVisible", "localAabbs", "activation"])
assert.equal(serialized.includes(`\"${removedSocket}\"`), false); assert.equal(serialized.includes(`\"${removedSocket}\"`), false);
}); });
test("adapter preserves ordered multisocket links and indexed diagnostics", () => {
const raw = {
graphId: GRAPH_ID, catalogVersion: CATALOG_VERSION,
nodes: ["source_a", "source_b", "source_c"].map((id) => authoredNode(id, "not"))
.concat(authoredNode("target", "and")),
links: [
authoredLink("link_a", "source_a"),
authoredLink("link_muted", "source_c", "target", true),
authoredLink("link_b", "source_b"),
],
metadata: {}, version: 1,
};
const graph = adaptFxNodeSnapshot(raw, 2);
const targetIndex = graph.nodes.findIndex((node) => node.id === "target");
assert.equal(graph.schemaVersion, 3);
assert.deepEqual(graph.nodes[targetIndex].inputs.inputs, [
{ node: "source_a", socket: "value" },
{ node: "source_b", socket: "value" },
]);
const diagnostic = mapAuthoringDiagnostic(graph, {
code: "GRAPH_SOCKET_TYPE_MISMATCH",
details: { path: `nodes[${targetIndex}].inputs.inputs[1].node` },
});
assert.equal(diagnostic.source.linkId, "link_b");
});
+14 -15
View File
@@ -6,7 +6,7 @@ import { descriptors } from "../static/render-graph/catalog.js";
test("all presets use current schemas, versions, and one frame output", () => { test("all presets use current schemas, versions, and one frame output", () => {
assert.equal(Object.keys(presets.renderGraphPresets).length, 13); assert.equal(Object.keys(presets.renderGraphPresets).length, 13);
for (const [name, graph] of Object.entries(presets.renderGraphPresets)) { 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(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); assert.equal(graph.nodes.filter((node) => node.executor.key === "frame_out").length, 1, name);
for (const node of graph.nodes) 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", () => { test("presets classify demo-owned enable and material bits through type.words[0] predicates", () => {
for (const [name, graph] of Object.entries(presets.renderGraphPresets)) { for (const [name, graph] of Object.entries(presets.renderGraphPresets)) {
const byId = Object.fromEntries(graph.nodes.map((node) => [node.id, node])); 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_words.inputs.value, [{ node: "mesh", socket: "type" }], name);
assert.deepEqual(byId.type_bits.inputs.value, { node: "type_words", socket: "word0" }, 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_class", socket: "value" }], name);
assert.deepEqual(byId.ground.inputs.predicate, { node: `ground${suffix}`, socket: "value" }, name); assert.deepEqual(byId.pbr.inputs.predicate, [{ node: "standard_class", 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: "double_class", socket: "value" }], name);
assert.deepEqual(byId.pbr_double.inputs.predicate, { node: name === "culling" ? "pbr_double_final" : "double_class", socket: "value" }, name);
for (const pipeline of [byId.ground, byId.pbr, byId.pbr_double]) { 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); 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", () => { test("culling adds a local-AABB expression to each material predicate", () => {
const byId = Object.fromEntries(presets.culling.nodes.map((node) => [node.id, node])); const byId = Object.fromEntries(presets.culling.nodes.map((node) => [node.id, node]));
assert.deepEqual(byId.cull.inputs, { 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" }); assert.deepEqual(byId.not_culled.inputs.operand, [{ node: "cull", socket: "isFrustumCulled" }]);
for (const id of ["ground", "pbr", "pbr_double"]) for (const id of ["ground_class", "standard_class", "double_class"])
assert.equal(byId[id].inputs.predicate.node.endsWith("_final"), true); assert.equal(byId[id].inputs.inputs.at(-1).node, "not_culled");
}); });
test("implicit presets start disconnected and then chain both attachments", () => { 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])); const byId = Object.fromEntries(presets[name].nodes.map((node) => [node.id, node]));
assert.equal("colorTarget" in byId.ground.inputs, false, name); assert.equal("colorTarget" in byId.ground.inputs, false, name);
assert.equal("depthTarget" 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.colorTarget, [{ node: "ground", socket: "color" }], name);
assert.deepEqual(byId.pbr.inputs.depthTarget, { node: "ground", socket: "depth" }, name); assert.deepEqual(byId.pbr.inputs.depthTarget, [{ node: "ground", socket: "depth" }], name);
} }
const midnight = Object.fromEntries(presets.midnight.nodes.map((node) => [node.id, node])); const midnight = Object.fromEntries(presets.midnight.nodes.map((node) => [node.id, node]));
assert.deepEqual(midnight.ground.inputs.colorTarget, { node: "ldr", socket: "texture" }); assert.deepEqual(midnight.ground.inputs.colorTarget, [{ node: "ldr", socket: "texture" }]);
}); });
+53
View File
@@ -130,6 +130,59 @@ try {
Complete sources: [definition](https://github.com/Heaust-ops/fxnode/blob/main/examples/minimal/definition.ts), [bootstrap](https://github.com/Heaust-ops/fxnode/blob/main/examples/minimal/main.ts), and 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). [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 ## One graph, zero or many views
`createFxNode()` creates the shared graph authority and starts one worker, but creates no canvas. This is valid for `createFxNode()` creates the shared graph authority and starts one worker, but creates no canvas. This is valid for
+5 -5
View File
@@ -1,9 +1,9 @@
# fxnode upstream provenance # fxnode upstream provenance
- Source: https://github.com/Heaust-ops/fxnode - Source: https://github.com/Heaust-ops/fxnode
- Commit: `3f8745717bf4574577be72e9769373475cc300c9` - Commit: `4e96585e99742959660d4107b0078c27ff13e708`
- Tree: `fb8bf407854b68dad8c0249c9ba63c7fd0bd9332` - Tree: `4fcafa60557bcbd760d7aa8d8898c0adbe0bb2c8`
- Imported: 2026-07-26 - Imported: 2026-07-29
- License: MIT (see `LICENSE` and `NOTICE.md`) - License: MIT (see `LICENSE` and `NOTICE.md`)
- Local patches: none - Local patches: none
@@ -14,8 +14,8 @@ This directory is a committed source snapshot. Application adaptations belong ou
```sh ```sh
git clone --filter=blob:none https://github.com/Heaust-ops/fxnode /tmp/fxnode 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 fetch --depth=1 origin 4e96585e99742959660d4107b0078c27ff13e708
git -C /tmp/fxnode checkout --detach 3f8745717bf4574577be72e9769373475cc300c9 git -C /tmp/fxnode checkout --detach 4e96585e99742959660d4107b0078c27ff13e708
rm -rf vendor/fxnode rm -rf vendor/fxnode
mkdir -p vendor/fxnode mkdir -p vendor/fxnode
git -C /tmp/fxnode archive HEAD | tar -x -C vendor/fxnode git -C /tmp/fxnode archive HEAD | tar -x -C vendor/fxnode
+7 -1
View File
@@ -1,6 +1,6 @@
# Examples # 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 ## 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)._ _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 ## 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. The [larger gallery source](https://github.com/Heaust-ops/fxnode/blob/main/examples/blender/main.ts) exercises many node and interaction shapes; it is repository application code, not package authority.
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

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