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:
@@ -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,27 +897,33 @@ 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 {
|
||||||
|
for (index, r) in refs.iter().enumerate() {
|
||||||
if !ids.contains_key(r.node.as_str()) {
|
if !ids.contains_key(r.node.as_str()) {
|
||||||
return Err(error(
|
return Err(error(
|
||||||
"GRAPH_UNKNOWN_NODE",
|
"GRAPH_UNKNOWN_NODE",
|
||||||
"unknown input node",
|
"unknown input node",
|
||||||
format!("nodes[{i}].inputs.{s}.node"),
|
format!("nodes[{i}].inputs.{s}[{index}].node"),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
let contracts: Vec<_> = graph
|
let contracts: Vec<_> = graph
|
||||||
.nodes
|
.nodes
|
||||||
.iter()
|
.iter()
|
||||||
@@ -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 {
|
||||||
|
for (index, r) in refs.iter().enumerate() {
|
||||||
let pn = ids[r.node.as_str()];
|
let pn = ids[r.node.as_str()];
|
||||||
if !contracts[pn].outputs.iter().any(|out| out.name == r.socket) {
|
if !contracts[pn].outputs.iter().any(|out| out.name == r.socket) {
|
||||||
return Err(error(
|
return Err(error(
|
||||||
"GRAPH_UNKNOWN_SOCKET",
|
"GRAPH_UNKNOWN_SOCKET",
|
||||||
"unknown output socket",
|
"unknown output socket",
|
||||||
format!("nodes[{i}].inputs.{name}.socket"),
|
format!("nodes[{i}].inputs.{name}[{index}].socket"),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
for (i, n) in graph.nodes.iter().enumerate() {
|
for (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,9 +1027,10 @@ 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;
|
||||||
};
|
};
|
||||||
|
for (source_index, r) in refs.iter().enumerate() {
|
||||||
let pn = ids[r.node.as_str()];
|
let pn = ids[r.node.as_str()];
|
||||||
let (ordinal, out) = contracts[pn]
|
let (ordinal, out) = contracts[pn]
|
||||||
.outputs
|
.outputs
|
||||||
@@ -1020,9 +1042,10 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
|
|||||||
return Err(error(
|
return Err(error(
|
||||||
"GRAPH_SOCKET_TYPE_MISMATCH",
|
"GRAPH_SOCKET_TYPE_MISMATCH",
|
||||||
"socket type mismatch",
|
"socket type mismatch",
|
||||||
format!("nodes[{i}].inputs.{}", input.name),
|
format!("nodes[{i}].inputs.{}[{source_index}]", input.name),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if source_index == 0 {
|
||||||
bound[i].insert(
|
bound[i].insert(
|
||||||
input.name,
|
input.name,
|
||||||
BoundInput {
|
BoundInput {
|
||||||
@@ -1031,10 +1054,32 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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,7 +2391,40 @@ 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]
|
||||||
|
.inputs
|
||||||
|
.get(input.name)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.map(|r| {
|
||||||
|
let producer = ids[r.node.as_str()];
|
||||||
|
let output = contracts[producer]
|
||||||
|
.outputs
|
||||||
|
.iter()
|
||||||
|
.position(|o| o.name == r.socket)
|
||||||
|
.unwrap();
|
||||||
|
BoundInput {
|
||||||
|
producer: OutputKey(producer, output as u16),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if bindings.is_empty() {
|
||||||
|
if input.cardinality.max > 1 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let literal = defaults[ordinal].clone();
|
||||||
|
operands.push(intern(
|
||||||
|
literal.semantic_type(),
|
||||||
|
ExpressionOp::Literal { literal },
|
||||||
|
NodeOutputRef {
|
||||||
|
node: graph.nodes[i].id.clone(),
|
||||||
|
socket: input.name.into(),
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
operand_provenance.push(None);
|
||||||
|
} else {
|
||||||
|
for binding in bindings {
|
||||||
let key = binding.producer;
|
let key = binding.producer;
|
||||||
let producer_type = contracts[key.0].outputs[key.1 as usize].semantic_type;
|
let producer_type = contracts[key.0].outputs[key.1 as usize].semantic_type;
|
||||||
let operand_mesh = if contracts[key.0].key == "mesh" {
|
let operand_mesh = if contracts[key.0].key == "mesh" {
|
||||||
@@ -2376,10 +2454,12 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
|
|||||||
graph.nodes[key.0]
|
graph.nodes[key.0]
|
||||||
.inputs
|
.inputs
|
||||||
.get("")
|
.get("")
|
||||||
.cloned()
|
.and_then(|refs| refs.first().cloned())
|
||||||
.unwrap_or(NodeOutputRef {
|
.unwrap_or(NodeOutputRef {
|
||||||
node: graph.nodes[key.0].id.clone(),
|
node: graph.nodes[key.0].id.clone(),
|
||||||
socket: contracts[key.0].outputs[key.1 as usize].name.into(),
|
socket: contracts[key.0].outputs[key.1 as usize]
|
||||||
|
.name
|
||||||
|
.into(),
|
||||||
}),
|
}),
|
||||||
Some(mesh),
|
Some(mesh),
|
||||||
)
|
)
|
||||||
@@ -2391,18 +2471,7 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
|
|||||||
};
|
};
|
||||||
operands.push(id);
|
operands.push(id);
|
||||||
operand_provenance.push(operand_mesh);
|
operand_provenance.push(operand_mesh);
|
||||||
} else {
|
}
|
||||||
let literal = defaults[ordinal].clone();
|
|
||||||
operands.push(intern(
|
|
||||||
literal.semantic_type(),
|
|
||||||
ExpressionOp::Literal { literal },
|
|
||||||
NodeOutputRef {
|
|
||||||
node: graph.nodes[i].id.clone(),
|
|
||||||
socket: input.name.into(),
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
));
|
|
||||||
operand_provenance.push(None);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut provenances = operand_provenance.into_iter().flatten();
|
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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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,14 +409,18 @@ 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
|
||||||
|
) | (
|
||||||
|
InputCardinality { min: 0, max: 1 },
|
||||||
InputDefaultPolicy::ParameterLiteral
|
InputDefaultPolicy::ParameterLiteral
|
||||||
)
|
) | (
|
||||||
| (
|
InputCardinality { min: 0, max: 1 },
|
||||||
InputCardinality::OptionalOne,
|
|
||||||
InputDefaultPolicy::CompilerTexture
|
InputDefaultPolicy::CompilerTexture
|
||||||
|
) | (
|
||||||
|
InputCardinality { min: 0, max: 8 },
|
||||||
|
InputDefaultPolicy::None
|
||||||
)
|
)
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
|||||||
@@ -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" }]);
|
||||||
});
|
});
|
||||||
|
|||||||
Vendored
+53
@@ -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);
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
Vendored
+5
-5
@@ -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
@@ -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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
_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.
|
||||||
|
|||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
Vendored
+5
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
+6
-2
@@ -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,6 +13,10 @@ export const nodeRowUnits = (item: FxNodeUiRow): number =>
|
|||||||
: 8
|
: 8
|
||||||
: item.kind === "resource"
|
: item.kind === "resource"
|
||||||
? 4
|
? 4
|
||||||
|
: item.kind === "socket" &&
|
||||||
|
definition?.sockets[item.socket]?.direction === "input" &&
|
||||||
|
definition.sockets[item.socket]!.maxIncomingLinks > 1
|
||||||
|
? 2
|
||||||
: 1;
|
: 1;
|
||||||
const controlWidth = (schema: FxNodeValueSchema | undefined, ramp = false) =>
|
const controlWidth = (schema: FxNodeValueSchema | undefined, ramp = false) =>
|
||||||
!schema
|
!schema
|
||||||
@@ -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(
|
||||||
|
|||||||
Vendored
+6
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 })) {
|
||||||
|
|||||||
Vendored
+74
-5
@@ -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 }),
|
||||||
|
|||||||
Reference in New Issue
Block a user