use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::brush::bundle::{Brush, BrushMetadata};
use crate::brush::stabilizer::StabilizerConfig;
use crate::brush::wire::BrushWireType;
use crate::brush::BrushNodeRegistry;
use crate::gpu::params::{ParamValue, PortableValue};
use crate::nodegraph::{exposed_port_key, ExposedPortMeta, Graph, NodeId, PortDir, PortRef};
use indexmap::IndexMap;
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PortableBrush {
#[serde(default, skip_serializing_if = "String::is_empty")]
pub name: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub category: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub description: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub author: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stabilizer: Option<StabilizerConfig>,
pub nodes: BTreeMap<u64, PortableNode>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub connections: Vec<PortableConnection>,
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
pub exposed_ports: IndexMap<String, ExposedPortMeta>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PortableNode {
#[serde(rename = "type")]
pub type_id: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub params: BTreeMap<String, PortableValue>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub port_defaults: BTreeMap<String, f32>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct PortableConnection {
pub from_node: u64,
pub from_port: String,
pub to_node: u64,
pub to_port: String,
}
impl std::fmt::Display for PortableConnection {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}.{} -> {}.{}",
self.from_node, self.from_port, self.to_node, self.to_port
)
}
}
impl std::str::FromStr for PortableConnection {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let (lhs, rhs) = s.split_once("->").ok_or_else(|| {
format!("connection '{s}': expected 'from_id.from_port -> to_id.to_port'")
})?;
let parse_side = |side: &str, label: &str| -> Result<(u64, String), String> {
let (id, port) = side
.trim()
.split_once('.')
.ok_or_else(|| format!("connection '{s}': {label} side must be 'id.port'"))?;
let id: u64 = id
.trim()
.parse()
.map_err(|e| format!("connection '{s}': {label} id: {e}"))?;
Ok((id, port.trim().to_string()))
};
let (from_node, from_port) = parse_side(lhs, "from")?;
let (to_node, to_port) = parse_side(rhs, "to")?;
Ok(Self {
from_node,
from_port,
to_node,
to_port,
})
}
}
impl Serialize for PortableConnection {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.collect_str(self)
}
}
impl<'de> Deserialize<'de> for PortableConnection {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
s.parse().map_err(serde::de::Error::custom)
}
}
impl PortableBrush {
pub fn from_brush(brush: &Brush, registry: &BrushNodeRegistry) -> Result<Self, String> {
let stabilizer = (brush.metadata.stabilizer != StabilizerConfig::default())
.then(|| brush.metadata.stabilizer.clone());
Ok(Self {
name: brush.metadata.name.clone(),
category: brush.metadata.category.clone(),
description: brush.metadata.description.clone(),
author: brush.metadata.author.clone(),
tags: brush.metadata.tags.clone(),
stabilizer,
..Self::from_graph_only(&brush.metadata.graph, registry)?
})
}
pub fn from_graph_only(
graph: &Graph<BrushWireType>,
registry: &BrushNodeRegistry,
) -> Result<Self, String> {
let mut nodes = BTreeMap::new();
for (id, node) in graph.nodes() {
let reg = registry.get(&node.type_id).ok_or_else(|| {
format!(
"node {} has unknown type '{}' — cannot serialize",
id.0, node.type_id
)
})?;
let mut params = BTreeMap::new();
for (i, def) in reg.params.iter().enumerate() {
let Some(value) = node.params.get(i) else {
continue;
};
if *value == def.default_value() {
continue;
}
params.insert(def.name().to_string(), PortableValue::from_param(value));
}
let mut port_defaults = BTreeMap::new();
for port in &node.ports {
if port.dir != PortDir::Input {
continue;
}
let reg_default = reg
.ports
.iter()
.find(|p| p.name == port.name)
.map(|p| p.default);
if Some(port.default) != reg_default {
port_defaults.insert(port.name.clone(), port.default);
}
}
nodes.insert(
id.0,
PortableNode {
type_id: node.type_id.clone(),
params,
port_defaults,
},
);
}
let mut connections: Vec<PortableConnection> = graph
.connections
.iter()
.map(|c| PortableConnection {
from_node: c.from.node.0,
from_port: c.from.port.clone(),
to_node: c.to.node.0,
to_port: c.to.port.clone(),
})
.collect();
connections.sort_by(|a, b| {
(a.from_node, &a.from_port, a.to_node, &a.to_port).cmp(&(
b.from_node,
&b.from_port,
b.to_node,
&b.to_port,
))
});
let exposed_ports = graph.exposed_ports.clone();
Ok(Self {
nodes,
connections,
exposed_ports,
..Self::default()
})
}
pub fn into_brush(self, registry: &BrushNodeRegistry) -> Result<Brush, String> {
let graph = self.graph_from_nodes(registry)?;
crate::brush::compile_graph(&graph)?;
let mut metadata = BrushMetadata::from_graph(self.name, graph);
metadata.category = self.category;
metadata.description = self.description;
metadata.author = self.author;
metadata.tags = self.tags;
if let Some(s) = self.stabilizer {
metadata.stabilizer = s;
}
Ok(Brush::from_metadata(metadata))
}
pub fn into_graph(self, registry: &BrushNodeRegistry) -> Result<Graph<BrushWireType>, String> {
let graph = self.graph_from_nodes(registry)?;
crate::brush::compile_graph(&graph)?;
Ok(graph)
}
fn graph_from_nodes(
&self,
registry: &BrushNodeRegistry,
) -> Result<Graph<BrushWireType>, String> {
let mut graph = Graph::<BrushWireType>::new();
let mut id_map: BTreeMap<u64, NodeId> = BTreeMap::new();
for (&yaml_id, pn) in &self.nodes {
let reg = registry
.get(&pn.type_id)
.ok_or_else(|| format!("unknown node type '{}'", pn.type_id))?;
let mut params: Vec<ParamValue> =
reg.params.iter().map(|d| d.default_value()).collect();
for (name, value) in &pn.params {
let Some((idx, def)) = reg
.params
.iter()
.enumerate()
.find(|(_, d)| d.name() == name)
else {
return Err(format!("unknown param '{name}' on '{}'", pn.type_id));
};
params[idx] = def.coerce_portable(value.clone()).map_err(|m| {
format!(
"param '{name}' on '{}': expected {}, got {}",
pn.type_id, m.expected, m.actual
)
})?;
}
let mut ports = reg.ports.clone();
for (name, &value) in &pn.port_defaults {
let port = ports
.iter_mut()
.find(|p| p.name == *name && p.dir == PortDir::Input)
.ok_or_else(|| {
format!(
"unknown input port '{name}' on '{}' (port_defaults)",
pn.type_id
)
})?;
port.default = value;
}
let new_id = graph.add_node(pn.type_id.clone(), ports, params);
id_map.insert(yaml_id, new_id);
}
for c in &self.connections {
let from = *id_map
.get(&c.from_node)
.ok_or_else(|| format!("connection '{c}': unknown node id {}", c.from_node))?;
let to = *id_map
.get(&c.to_node)
.ok_or_else(|| format!("connection '{c}': unknown node id {}", c.to_node))?;
graph
.connect(
PortRef {
node: from,
port: c.from_port.clone(),
},
PortRef {
node: to,
port: c.to_port.clone(),
},
)
.map_err(|e| format!("connection '{c}': {e}"))?;
}
graph.connections.sort_by(|a, b| {
(a.from.node.0, &a.from.port, a.to.node.0, &a.to.port).cmp(&(
b.from.node.0,
&b.from.port,
b.to.node.0,
&b.to.port,
))
});
graph.exposed_ports.clear();
for (yaml_key, meta) in &self.exposed_ports {
let Some((yid_str, port_name)) = yaml_key.split_once('.') else {
continue;
};
let Ok(yaml_id) = yid_str.parse::<u64>() else {
continue;
};
let Some(&new_id) = id_map.get(&yaml_id) else {
continue;
};
let new_key = exposed_port_key(new_id, port_name);
graph.exposed_ports.insert(new_key, meta.clone());
}
Ok(graph)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::brush::registry;
#[test]
fn default_graph_round_trip() {
let registry = registry();
let graph = crate::brush::default_graph();
let portable = PortableBrush::from_graph_only(&graph, registry).expect("serialize");
let yaml = serde_yaml_ng::to_string(&portable).expect("yaml");
let parsed: PortableBrush = serde_yaml_ng::from_str(&yaml).expect("parse");
let restored = parsed.into_graph(registry).expect("import");
assert_eq!(graph.nodes().len(), restored.nodes().len());
assert_eq!(graph.connections.len(), restored.connections.len());
for original in graph.nodes().values() {
let restored_node = restored
.nodes()
.values()
.find(|n| n.type_id == original.type_id)
.unwrap_or_else(|| panic!("missing node of type '{}'", original.type_id));
assert_eq!(original.params, restored_node.params);
for port in &original.ports {
if port.dir != PortDir::Input {
continue;
}
let r = restored_node
.ports
.iter()
.find(|p| p.name == port.name)
.expect("missing input port");
assert!(
(port.default - r.default).abs() < 1e-6,
"default mismatch on {}.{}: {} vs {}",
original.type_id,
port.name,
port.default,
r.default
);
}
}
assert_eq!(graph.exposed_ports.len(), restored.exposed_ports.len());
}
#[test]
fn port_overrides_survive() {
let registry = registry();
let mut graph = crate::brush::default_graph();
let shape = *graph
.nodes()
.iter()
.find(|(_, n)| n.type_id == "shape")
.map(|(id, _)| id)
.expect("default has a shape node");
graph.set_port_default(shape, "softness", 0.37).unwrap();
graph.expose_port(shape, "softness").unwrap();
let shape_key = exposed_port_key(shape, "softness");
graph
.set_exposed_port_meta(
&shape_key,
"Softness".into(),
"Edge falloff".into(),
"fa6-solid:circle-half-stroke".into(),
)
.unwrap();
let portable = PortableBrush::from_graph_only(&graph, registry).unwrap();
let yaml = serde_yaml_ng::to_string(&portable).unwrap();
let restored = serde_yaml_ng::from_str::<PortableBrush>(&yaml)
.unwrap()
.into_graph(registry)
.unwrap();
let restored_shape = restored
.nodes()
.values()
.find(|n| n.type_id == "shape")
.expect("restored graph has a shape node");
let port = restored_shape
.ports
.iter()
.find(|p| p.name == "softness")
.unwrap();
assert!((port.default - 0.37).abs() < 1e-6);
assert!(restored.is_port_exposed(restored_shape.id, "softness"));
let restored_key = exposed_port_key(restored_shape.id, "softness");
let meta = &restored.exposed_ports[&restored_key];
assert_eq!(meta.label, "Softness");
assert_eq!(meta.description, "Edge falloff");
assert_eq!(meta.icon, "fa6-solid:circle-half-stroke");
}
#[test]
fn unknown_type_rejected() {
let registry = registry();
let yaml = "\
nodes:
1:
type: never_was_a_node
";
let portable: PortableBrush = serde_yaml_ng::from_str(yaml).unwrap();
let err = portable.into_graph(registry).unwrap_err();
assert!(err.contains("unknown node type"), "got: {err}");
}
#[test]
fn unknown_param_rejected() {
let registry = registry();
let yaml = "\
nodes:
1:
type: shape
params:
this_param_does_not_exist: 1
";
let portable: PortableBrush = serde_yaml_ng::from_str(yaml).unwrap();
let err = portable.into_graph(registry).unwrap_err();
assert!(err.contains("unknown param"), "got: {err}");
}
#[test]
fn param_type_mismatch_rejected() {
let registry = registry();
let yaml = "\
nodes:
1:
type: shape
params:
algorithm: [[0.0, 0.0], [1.0, 1.0]]
";
let portable: PortableBrush = serde_yaml_ng::from_str(yaml).unwrap();
let err = portable.into_graph(registry).unwrap_err();
assert!(err.contains("expected integer"), "got: {err}");
}
#[test]
fn unknown_top_level_field_rejected() {
let yaml = "\
name: Test
made_up_field: 1
nodes: {}
";
let err = serde_yaml_ng::from_str::<PortableBrush>(yaml).unwrap_err();
assert!(err.to_string().contains("unknown field"), "got: {err}");
}
#[test]
fn stabilizer_round_trip_and_elision() {
let registry = registry();
let mut brush = Brush::from_metadata(BrushMetadata::from_graph(
"Test",
crate::brush::default_graph(),
));
let portable = PortableBrush::from_brush(&brush, registry).unwrap();
let yaml = serde_yaml_ng::to_string(&portable).unwrap();
assert!(
!yaml.contains("stabilizer"),
"default stabilizer should be elided\n{yaml}"
);
brush.metadata.stabilizer = StabilizerConfig {
algorithm: "laplacian".into(),
params: vec![ParamValue::Float(0.6)],
};
let portable = PortableBrush::from_brush(&brush, registry).unwrap();
let yaml = serde_yaml_ng::to_string(&portable).unwrap();
let parsed: PortableBrush = serde_yaml_ng::from_str(&yaml).unwrap();
let restored = parsed.into_brush(registry).unwrap();
assert_eq!(restored.metadata.stabilizer.algorithm, "laplacian");
assert_eq!(restored.metadata.stabilizer.params.len(), 1);
}
}