// NodeGraph - Visual node-based editor for materials, blueprints, etc.
// Inspired by Unreal Blueprints, Blender nodes, and Unity Shader Graph
use super::traits::Renderable
// Pin types for node connections
@auto
pub enum PinType {
Flow, // Execution flow (white)
Bool, // Boolean (red)
Int, // Integer (cyan)
Float, // Float (green)
Vec2, // 2D Vector (yellow)
Vec3, // 3D Vector (gold)
Vec4, // 4D Vector (purple)
Color, // Color (pink)
Texture, // Texture reference (orange)
Object, // Object reference (blue)
Any, // Wildcard (gray)
}
// A pin on a node (input or output)
@auto
pub struct NodePin {
pub id: string,
pub name: string,
pub pin_type: PinType,
pub is_input: bool,
pub connected_to: Option<string>,
pub default_value: string,
}
impl NodePin {
pub fn input(id: string, name: string, pin_type: PinType) -> NodePin {
NodePin {
id,
name,
pin_type,
is_input: true,
connected_to: None,
default_value: "".to_string(),
}
}
pub fn output(id: string, name: string, pin_type: PinType) -> NodePin {
NodePin {
id,
name,
pin_type,
is_input: false,
connected_to: None,
default_value: "".to_string(),
}
}
pub fn default_value(self, value: string) -> NodePin {
self.default_value = value
self
}
pub fn connect(self, target: string) -> NodePin {
self.connected_to = Some(target.to_string())
self
}
fn get_color(&self) -> string {
match self.pin_type {
PinType::Flow => "#ffffff".to_string(),
PinType::Bool => "#e94560".to_string(),
PinType::Int => "#00d9ff".to_string(),
PinType::Float => "#4ade80".to_string(),
PinType::Vec2 => "#facc15".to_string(),
PinType::Vec3 => "#f59e0b".to_string(),
PinType::Vec4 => "#a855f7".to_string(),
PinType::Color => "#ec4899".to_string(),
PinType::Texture => "#fb923c".to_string(),
PinType::Object => "#3b82f6".to_string(),
PinType::Any => "#888888".to_string(),
}
}
}
// Node categories
@auto
pub enum NodeCategory {
Math,
Logic,
Texture,
Color,
Vector,
Flow,
Event,
Variable,
Custom,
}
// A node in the graph
@auto
pub struct GraphNode {
pub id: string,
pub title: string,
pub category: NodeCategory,
pub x: f32,
pub y: f32,
pub inputs: Vec<NodePin>,
pub outputs: Vec<NodePin>,
pub collapsed: bool,
pub preview_enabled: bool,
}
impl GraphNode {
pub fn new(id: string, title: string, category: NodeCategory) -> GraphNode {
GraphNode {
id,
title,
category,
x: 0.0,
y: 0.0,
inputs: Vec::new(),
outputs: Vec::new(),
collapsed: false,
preview_enabled: false,
}
}
pub fn position(self, x: f32, y: f32) -> GraphNode {
self.x = x
self.y = y
self
}
pub fn input(self, pin: NodePin) -> GraphNode {
self.inputs.push(pin)
self
}
pub fn output(self, pin: NodePin) -> GraphNode {
self.outputs.push(pin)
self
}
pub fn collapsed(self, collapsed: bool) -> GraphNode {
self.collapsed = collapsed
self
}
pub fn preview(self, enabled: bool) -> GraphNode {
self.preview_enabled = enabled
self
}
fn get_category_color(&self) -> string {
match self.category {
NodeCategory::Math => "#4ade80".to_string(),
NodeCategory::Logic => "#e94560".to_string(),
NodeCategory::Texture => "#fb923c".to_string(),
NodeCategory::Color => "#ec4899".to_string(),
NodeCategory::Vector => "#facc15".to_string(),
NodeCategory::Flow => "#ffffff".to_string(),
NodeCategory::Event => "#3b82f6".to_string(),
NodeCategory::Variable => "#a855f7".to_string(),
NodeCategory::Custom => "#888888".to_string(),
}
}
}
impl Renderable for GraphNode {
pub fn render(self) -> string {
let header_color = self.get_category_color()
// Render inputs
let mut inputs_html = "".to_string()
for pin in self.inputs {
let color = pin.get_color()
let connected_class = match pin.connected_to {
Some(_) => "connected",
None => "",
}
inputs_html = inputs_html + format!("
<div class='node-pin input {}'>
<div class='pin-socket' style='background: {};' data-pin='{}'></div>
<span class='pin-name'>{}</span>
</div>
", connected_class, color, pin.id, pin.name).as_str()
}
// Render outputs
let mut outputs_html = "".to_string()
for pin in self.outputs {
let color = pin.get_color()
let connected_class = match pin.connected_to {
Some(_) => "connected",
None => "",
}
outputs_html = outputs_html + format!("
<div class='node-pin output {}'>
<span class='pin-name'>{}</span>
<div class='pin-socket' style='background: {};' data-pin='{}'></div>
</div>
", connected_class, pin.name, color, pin.id).as_str()
}
let preview_html = if self.preview_enabled {
"<div class='node-preview'><canvas class='preview-canvas'></canvas></div>"
} else {
""
}
format!("
<div class='graph-node' id='{}' style='left: {}px; top: {}px;'>
<div class='node-header' style='background: {};'>
<span class='node-title'>{}</span>
<div class='node-actions'>
<button class='node-btn preview' title='Preview'>👁</button>
<button class='node-btn collapse' title='Collapse'>−</button>
</div>
</div>
<div class='node-body'>
<div class='node-inputs'>
{}
</div>
<div class='node-outputs'>
{}
</div>
</div>
{}
</div>
", self.id, self.x, self.y, header_color, self.title, inputs_html, outputs_html, preview_html)
}
}
// Connection between nodes
@auto
pub struct NodeConnection {
pub from_node: string,
pub from_pin: string,
pub to_node: string,
pub to_pin: string,
}
// The full node graph
@auto
pub struct NodeGraph {
pub width: i32,
pub height: i32,
pub nodes: Vec<GraphNode>,
pub connections: Vec<NodeConnection>,
pub zoom: f32,
pub pan_x: f32,
pub pan_y: f32,
pub show_grid: bool,
pub on_change: string,
}
impl NodeGraph {
pub fn new() -> NodeGraph {
NodeGraph {
width: 800,
height: 600,
nodes: Vec::new(),
connections: Vec::new(),
zoom: 1.0,
pan_x: 0.0,
pan_y: 0.0,
show_grid: true,
on_change: "".to_string(),
}
}
pub fn size(self, width: i32, height: i32) -> NodeGraph {
self.width = width
self.height = height
self
}
pub fn node(self, node: GraphNode) -> NodeGraph {
self.nodes.push(node)
self
}
pub fn connect(self, from_node: string, from_pin: string, to_node: string, to_pin: string) -> NodeGraph {
self.connections.push(NodeConnection {
from_node,
from_pin,
to_node,
to_pin,
})
self
}
pub fn zoom(self, zoom: f32) -> NodeGraph {
self.zoom = zoom
self
}
pub fn pan(self, x: f32, y: f32) -> NodeGraph {
self.pan_x = x
self.pan_y = y
self
}
}
impl Renderable for NodeGraph {
pub fn render(self) -> string {
let mut nodes_html = "".to_string()
for n in self.nodes {
nodes_html = nodes_html + n.clone().render().as_str() + "\n"
}
// Generate SVG connections
let mut connections_html = "".to_string()
for c in self.connections {
connections_html = connections_html + format!("
<path class='node-connection'
data-from='{}:{}'
data-to='{}:{}'/>
", c.from_node, c.from_pin, c.to_node, c.to_pin).as_str()
}
let grid_class = if self.show_grid { "show-grid" } else { "" }
format!("
<div class='node-graph {}' style='width: {}px; height: {}px;'>
<div class='graph-toolbar'>
<button onclick='addNode()'>+ Add Node</button>
<span class='toolbar-sep'></span>
<button onclick='zoomIn()'>🔍+</button>
<button onclick='zoomOut()'>🔍−</button>
<button onclick='fitAll()'>⊞</button>
<span class='zoom-level'>{:.0}%</span>
</div>
<div class='graph-canvas'
style='transform: scale({}) translate({}px, {}px);'>
<svg class='connections-layer'>
{}
</svg>
<div class='nodes-layer'>
{}
</div>
</div>
<div class='graph-minimap'>
<div class='minimap-viewport'></div>
</div>
</div>
", grid_class, self.width, self.height, self.zoom * 100.0,
self.zoom, self.pan_x, self.pan_y, connections_html, nodes_html)
}
}
// CSS styles
pub fn node_graph_styles() -> string {
"
.node-graph {
position: relative;
background: #0a0a1a;
border-radius: 8px;
overflow: hidden;
}
.node-graph.show-grid {
background-image:
linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px);
background-size: 20px 20px;
}
.graph-toolbar {
position: absolute;
top: 8px;
left: 8px;
display: flex;
gap: 4px;
padding: 4px;
background: rgba(22, 33, 62, 0.9);
border-radius: 4px;
z-index: 100;
}
.graph-toolbar button {
padding: 4px 8px;
border: none;
border-radius: 4px;
background: #0f3460;
color: #888;
cursor: pointer;
}
.graph-toolbar button:hover {
background: #1a4a8a;
color: #e0e0e0;
}
.toolbar-sep {
width: 1px;
background: #333;
}
.zoom-level {
padding: 0 8px;
font-size: 12px;
color: #666;
}
.graph-canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
transform-origin: center center;
}
.connections-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.node-connection {
fill: none;
stroke: #666;
stroke-width: 2;
}
.nodes-layer {
position: absolute;
top: 0;
left: 0;
}
/* Graph Node */
.graph-node {
position: absolute;
min-width: 180px;
background: #16213e;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
user-select: none;
}
.node-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-radius: 8px 8px 0 0;
cursor: move;
}
.node-title {
font-size: 12px;
font-weight: 600;
color: #1a1a2e;
}
.node-actions {
display: flex;
gap: 4px;
}
.node-btn {
width: 20px;
height: 20px;
border: none;
background: rgba(0,0,0,0.2);
border-radius: 4px;
font-size: 10px;
cursor: pointer;
color: rgba(0,0,0,0.6);
}
.node-btn:hover {
background: rgba(0,0,0,0.4);
color: rgba(0,0,0,0.8);
}
.node-body {
display: flex;
justify-content: space-between;
padding: 8px 0;
}
.node-inputs, .node-outputs {
display: flex;
flex-direction: column;
gap: 4px;
}
.node-pin {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 12px;
cursor: pointer;
}
.node-pin.input {
flex-direction: row;
}
.node-pin.output {
flex-direction: row-reverse;
}
.pin-socket {
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid rgba(255,255,255,0.3);
transition: transform 0.15s;
}
.node-pin:hover .pin-socket {
transform: scale(1.3);
border-color: white;
}
.node-pin.connected .pin-socket {
border-color: white;
}
.pin-name {
font-size: 11px;
color: #888;
}
.node-preview {
padding: 8px;
border-top: 1px solid rgba(255,255,255,0.1);
}
.preview-canvas {
width: 100%;
height: 60px;
background: #0a0a1a;
border-radius: 4px;
}
/* Minimap */
.graph-minimap {
position: absolute;
bottom: 8px;
right: 8px;
width: 150px;
height: 100px;
background: rgba(22, 33, 62, 0.9);
border-radius: 4px;
border: 1px solid #333;
}
.minimap-viewport {
position: absolute;
border: 2px solid #e94560;
background: rgba(233, 69, 96, 0.1);
}
".to_string()
}