Add a basic API and rudimentary frontend for node graph layers (#846)

* Node graph API stub

* Rename and fix SetInputValue

* Get list of links from network

* Test populating node graph UI

* Node properties

* Fix viewport bounds

* Slightly change promise usage

* A tiny bit of cleanup I did while reading code

* Cleanup and work towards hooking up node links in Vue template

* Add the brighten colour node

* Run cargo fmt

* Add to and from hsla

* GrayscaleImage node with small perf improvement

* Fix gutter panel resizing

* Display node links from backend

* Add support for connecting node links

* Use existing message

* Fix formatting error

* Add a (currently crashing) brighten node

* Replace brighten node with proto node implementation

* Add support for connecting node links

* Update watch dirs

* Add hue shift node

* Add create_node function to editor api

* Basic insert node UI

* Fix broken names

* Add log

* Fix positioning

* Set connector index to 0

* Add properties for Heu shift / brighten

* Allow deselecting nodes

* Redesign Properties panel collapsible sections

Co-authored-by: Keavon Chambers <keavon@keavon.com>
Co-authored-by: Dennis Kobert <dennis@kobert.dev>
This commit is contained in:
0HyperCube 2022-11-12 21:23:28 +00:00 committed by Keavon Chambers
parent e8256dd350
commit 504136b61b
37 changed files with 1213 additions and 294 deletions

2
Cargo.lock generated
View file

@ -358,6 +358,7 @@ dependencies = [
"dyn-clone",
"graphene-core",
"graphene-std",
"log",
"num-traits",
"rand_chacha",
"serde",
@ -460,6 +461,7 @@ dependencies = [
name = "graphite-wasm"
version = "0.0.0"
dependencies = [
"graph-craft",
"graphite-editor",
"graphite-graphene",
"js-sys",

View file

@ -1,7 +1,7 @@
use graphene::color::Color;
// Viewport
pub const VIEWPORT_ZOOM_WHEEL_RATE: f64 = 1. / 600.;
pub const VIEWPORT_ZOOM_WHEEL_RATE: f64 = (1. / 600.) * 3.;
pub const VIEWPORT_ZOOM_MOUSE_RATE: f64 = 1. / 400.;
pub const VIEWPORT_ZOOM_SCALE_MIN: f64 = 0.000_000_1;
pub const VIEWPORT_ZOOM_SCALE_MAX: f64 = 10_000.;

View file

@ -2,6 +2,7 @@ use super::utility_types::{FrontendDocumentDetails, FrontendImageData, MouseCurs
use crate::messages::layout::utility_types::layout_widget::SubLayout;
use crate::messages::layout::utility_types::misc::LayoutTarget;
use crate::messages::layout::utility_types::widgets::menu_widgets::MenuBarEntry;
use crate::messages::portfolio::document::node_graph::{FrontendNode, FrontendNodeLink, FrontendNodeType};
use crate::messages::portfolio::document::utility_types::layer_panel::{LayerPanelEntry, RawBuffer};
use crate::messages::prelude::*;
use crate::messages::tool::utility_types::HintData;
@ -198,9 +199,17 @@ pub enum FrontendMessage {
UpdateMouseCursor {
cursor: MouseCursorIcon,
},
UpdateNodeGraph {
nodes: Vec<FrontendNode>,
links: Vec<FrontendNodeLink>,
},
UpdateNodeGraphVisibility {
visible: bool,
},
UpdateNodeTypes {
#[serde(rename = "nodeTypes")]
node_types: Vec<FrontendNodeType>,
},
UpdateOpenDocumentsList {
#[serde(rename = "openDocuments")]
open_documents: Vec<FrontendDocumentDetails>,

View file

@ -32,6 +32,9 @@ pub enum DocumentMessage {
#[remain::unsorted]
#[child]
PropertiesPanel(PropertiesPanelMessage),
#[remain::unsorted]
#[child]
NodeGraph(NodeGraphMessage),
// Messages
AbortTransaction,

View file

@ -64,6 +64,8 @@ pub struct DocumentMessageHandler {
#[serde(skip)]
transform_layer_handler: TransformLayerMessageHandler,
properties_panel_message_handler: PropertiesPanelMessageHandler,
#[serde(skip)]
node_graph_handler: NodeGraphMessageHandler,
}
impl Default for DocumentMessageHandler {
@ -91,6 +93,7 @@ impl Default for DocumentMessageHandler {
artboard_message_handler: ArtboardMessageHandler::default(),
transform_layer_handler: TransformLayerMessageHandler::default(),
properties_panel_message_handler: PropertiesPanelMessageHandler::default(),
node_graph_handler: Default::default(),
}
}
}
@ -165,10 +168,15 @@ impl MessageHandler<DocumentMessage, (u64, &InputPreprocessorMessageHandler, &Pe
artwork_document: &self.graphene_document,
artboard_document: &self.artboard_message_handler.artboards_graphene_document,
selected_layers: &mut self.layer_metadata.iter().filter_map(|(path, data)| data.selected.then_some(path.as_slice())),
node_graph_message_handler: &self.node_graph_handler,
};
self.properties_panel_message_handler
.process_message(message, (persistent_data, properties_panel_message_handler_data), responses);
}
#[remain::unsorted]
NodeGraph(message) => {
self.node_graph_handler.process_message(message, (&mut self.graphene_document, ipp), responses);
}
// Messages
AbortTransaction => {

View file

@ -3,6 +3,7 @@ mod document_message_handler;
pub mod artboard;
pub mod navigation;
pub mod node_graph;
pub mod overlays;
pub mod properties_panel;
pub mod transform_layer;

View file

@ -0,0 +1,7 @@
mod node_graph_message;
mod node_graph_message_handler;
#[doc(inline)]
pub use node_graph_message::{NodeGraphMessage, NodeGraphMessageDiscriminant};
#[doc(inline)]
pub use node_graph_message_handler::*;

View file

@ -0,0 +1,44 @@
use crate::messages::prelude::*;
use graph_craft::document::{value::TaggedValue, NodeId};
use graph_craft::proto::NodeIdentifier;
#[remain::sorted]
#[impl_message(Message, DocumentMessage, NodeGraph)]
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum NodeGraphMessage {
// Messages
AddLink {
from: NodeId,
to: NodeId,
to_index: usize,
},
CloseNodeGraph,
ConnectNodesByLink {
output_node: u64,
input_node: u64,
input_node_connector_index: u32,
},
CreateNode {
// Having the caller generate the id means that we don't have to return it. This can be a random u64.
node_id: NodeId,
// I don't really know what this is for (perhaps a user identifiable name).
name: String,
// The node identifier must mach that found in `node-graph/graph-craft/src/node_registry.rs` e.g. "graphene_core::raster::GrayscaleNode
identifier: NodeIdentifier,
num_inputs: u32,
},
DeleteNode {
node_id: NodeId,
},
OpenNodeGraph {
layer_path: Vec<graphene::LayerId>,
},
SelectNodes {
nodes: Vec<NodeId>,
},
SetInputValue {
node: NodeId,
input_index: usize,
value: TaggedValue,
},
}

View file

@ -0,0 +1,321 @@
use crate::messages::layout::utility_types::layout_widget::{LayoutGroup, Widget, WidgetCallback, WidgetHolder};
use crate::messages::layout::utility_types::widgets::input_widgets::{NumberInput, NumberInputMode};
use crate::messages::layout::utility_types::widgets::label_widgets::{Separator, SeparatorDirection, SeparatorType, TextLabel};
use crate::messages::prelude::*;
use graph_craft::document::value::TaggedValue;
use graph_craft::document::{DocumentNode, DocumentNodeImplementation, NodeInput, NodeNetwork};
use graphene::document::Document;
use graphene::layers::layer_info::LayerDataType;
use graphene::layers::nodegraph_layer::NodeGraphFrameLayer;
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct FrontendNode {
pub id: graph_craft::document::NodeId,
#[serde(rename = "displayName")]
pub display_name: String,
}
// (link_start, link_end, link_end_input_index)
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct FrontendNodeLink {
#[serde(rename = "linkStart")]
pub link_start: u64,
#[serde(rename = "linkEnd")]
pub link_end: u64,
#[serde(rename = "linkEndInputIndex")]
pub link_end_input_index: u64,
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct FrontendNodeType {
pub name: String,
}
impl FrontendNodeType {
pub fn new(name: &'static str) -> Self {
Self { name: name.to_string() }
}
}
#[derive(Debug, Clone, Eq, PartialEq, Default, serde::Serialize, serde::Deserialize)]
pub struct NodeGraphMessageHandler {
pub layer_path: Option<Vec<graphene::LayerId>>,
pub selected_nodes: Vec<graph_craft::document::NodeId>,
}
impl NodeGraphMessageHandler {
/// Get the active graph_craft NodeNetwork struct
fn get_active_network_mut<'a>(&self, document: &'a mut Document) -> Option<&'a mut graph_craft::document::NodeNetwork> {
self.layer_path.as_ref().and_then(|path| document.layer_mut(path).ok()).and_then(|layer| match &mut layer.data {
LayerDataType::NodeGraphFrame(n) => Some(&mut n.network),
_ => None,
})
}
pub fn collate_properties(&self, node_graph_frame: &NodeGraphFrameLayer) -> Vec<LayoutGroup> {
let network = &node_graph_frame.network;
let mut section = Vec::new();
for node_id in &self.selected_nodes {
let node = *node_id;
let Some(document_node) = network.nodes.get(node_id) else {
continue;
};
let name = format!("Node {} Properties", document_node.name);
let layout = match &document_node.implementation {
DocumentNodeImplementation::Network(_) => match document_node.name.as_str() {
"Hue Shift Color" => vec![LayoutGroup::Row {
widgets: vec![
WidgetHolder::new(Widget::TextLabel(TextLabel {
value: "Shift degrees".into(),
..Default::default()
})),
WidgetHolder::new(Widget::Separator(Separator {
separator_type: SeparatorType::Unrelated,
direction: SeparatorDirection::Horizontal,
})),
WidgetHolder::new(Widget::NumberInput(NumberInput {
value: Some({
let NodeInput::Value (TaggedValue::F32(x)) = document_node.inputs[1] else {
panic!("Hue rotate should be f32")
};
x as f64
}),
unit: "°".into(),
mode: NumberInputMode::Range,
range_min: Some(-180.),
range_max: Some(180.),
on_update: WidgetCallback::new(move |number_input: &NumberInput| {
NodeGraphMessage::SetInputValue {
node,
input_index: 1,
value: TaggedValue::F32(number_input.value.unwrap() as f32),
}
.into()
}),
..NumberInput::default()
})),
],
}],
"Brighten Color" => vec![LayoutGroup::Row {
widgets: vec![
WidgetHolder::new(Widget::TextLabel(TextLabel {
value: "Brighten Amount".into(),
..Default::default()
})),
WidgetHolder::new(Widget::Separator(Separator {
separator_type: SeparatorType::Unrelated,
direction: SeparatorDirection::Horizontal,
})),
WidgetHolder::new(Widget::NumberInput(NumberInput {
value: Some({
let NodeInput::Value (TaggedValue::F32(x)) = document_node.inputs[1] else {
panic!("Brighten amount should be f32")
};
x as f64
}),
mode: NumberInputMode::Range,
range_min: Some(-255.),
range_max: Some(255.),
on_update: WidgetCallback::new(move |number_input: &NumberInput| {
NodeGraphMessage::SetInputValue {
node,
input_index: 1,
value: TaggedValue::F32(number_input.value.unwrap() as f32),
}
.into()
}),
..NumberInput::default()
})),
],
}],
_ => vec![LayoutGroup::Row {
widgets: vec![WidgetHolder::new(Widget::TextLabel(TextLabel {
value: format!("Cannot currently display properties for network {}", document_node.name),
..Default::default()
}))],
}],
},
DocumentNodeImplementation::Unresolved(identifier) => match identifier.name.as_ref() {
"graphene_std::raster::MapImageNode" | "graphene_core::ops::IdNode" => vec![LayoutGroup::Row {
widgets: vec![WidgetHolder::new(Widget::TextLabel(TextLabel {
value: format!("{} requires no properties", document_node.name),
..Default::default()
}))],
}],
unknown => {
vec![
LayoutGroup::Row {
widgets: vec![WidgetHolder::new(Widget::TextLabel(TextLabel {
value: format!("TODO: {} properties", unknown),
..Default::default()
}))],
},
LayoutGroup::Row {
widgets: vec![WidgetHolder::new(Widget::TextLabel(TextLabel {
value: "Add in editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs".to_string(),
..Default::default()
}))],
},
]
}
},
};
section.push(LayoutGroup::Section { name, layout });
}
section
}
fn send_graph(network: &NodeNetwork, responses: &mut VecDeque<Message>) {
responses.push_back(PropertiesPanelMessage::ResendActiveProperties.into());
info!("Opening node graph with nodes {:?}", network.nodes);
// List of links in format (link_start, link_end, link_end_input_index)
let links = network
.nodes
.iter()
.flat_map(|(link_end, node)| node.inputs.iter().enumerate().map(move |(index, input)| (input, link_end, index)))
.filter_map(|(input, &link_end, link_end_input_index)| {
if let NodeInput::Node(link_start) = *input {
Some(FrontendNodeLink {
link_start,
link_end,
link_end_input_index: link_end_input_index as u64,
})
} else {
None
}
})
.collect::<Vec<_>>();
let mut nodes = Vec::new();
for (id, node) in &network.nodes {
nodes.push(FrontendNode {
id: *id,
display_name: node.name.clone(),
})
}
log::debug!("Nodes:\n{:#?}\n\nFrontend Nodes:\n{:#?}\n\nLinks:\n{:#?}", network.nodes, nodes, links);
responses.push_back(FrontendMessage::UpdateNodeGraph { nodes, links }.into());
}
}
impl MessageHandler<NodeGraphMessage, (&mut Document, &InputPreprocessorMessageHandler)> for NodeGraphMessageHandler {
#[remain::check]
fn process_message(&mut self, message: NodeGraphMessage, (document, _ipp): (&mut Document, &InputPreprocessorMessageHandler), responses: &mut VecDeque<Message>) {
#[remain::sorted]
match message {
NodeGraphMessage::AddLink { from, to, to_index } => {
log::debug!("Connect primary output from node {from} to input of index {to_index} on node {to}.");
if let Some(network) = self.get_active_network_mut(document) {
if let Some(to) = network.nodes.get_mut(&to) {
// Extend number of inputs if not already large enough
if to_index >= to.inputs.len() {
to.inputs.extend(((to.inputs.len() - 1)..to_index).map(|_| NodeInput::Network));
}
to.inputs[to_index] = NodeInput::Node(from);
}
}
}
NodeGraphMessage::CloseNodeGraph => {
if let Some(_old_layer_path) = self.layer_path.take() {
info!("Closing node graph");
responses.push_back(FrontendMessage::UpdateNodeGraphVisibility { visible: false }.into());
responses.push_back(PropertiesPanelMessage::ResendActiveProperties.into());
// TODO: Close UI and clean up old node graph
}
}
NodeGraphMessage::ConnectNodesByLink {
output_node,
input_node,
input_node_connector_index,
} => {
log::debug!("Connect primary output from node {output_node} to input of index {input_node_connector_index} on node {input_node}.");
}
NodeGraphMessage::CreateNode {
node_id,
name,
identifier,
num_inputs,
} => {
if let Some(network) = self.get_active_network_mut(document) {
let inner_network = NodeNetwork {
inputs: (0..num_inputs).map(|_| 0).collect(),
output: 0,
nodes: [(
node_id,
DocumentNode {
name: format!("{}_impl", name),
// TODO: Allow inserting nodes that contain other nodes.
implementation: DocumentNodeImplementation::Unresolved(identifier),
inputs: (0..num_inputs).map(|_| NodeInput::Network).collect(),
},
)]
.into_iter()
.collect(),
};
network.nodes.insert(
node_id,
DocumentNode {
name,
inputs: (0..num_inputs).map(|_| NodeInput::Network).collect(),
// TODO: Allow inserting nodes that contain other nodes.
implementation: DocumentNodeImplementation::Network(inner_network),
},
);
Self::send_graph(network, responses);
}
}
NodeGraphMessage::DeleteNode { node_id } => {
if let Some(network) = self.get_active_network_mut(document) {
network.nodes.remove(&node_id);
// TODO: Update UI if it is not already updated.
}
}
NodeGraphMessage::OpenNodeGraph { layer_path } => {
if let Some(_old_layer_path) = self.layer_path.replace(layer_path) {
// TODO: Necessary cleanup of old node graph
}
if let Some(network) = self.get_active_network_mut(document) {
self.selected_nodes.clear();
responses.push_back(FrontendMessage::UpdateNodeGraphVisibility { visible: true }.into());
Self::send_graph(network, responses);
// TODO: Dynamic node library
responses.push_back(
FrontendMessage::UpdateNodeTypes {
node_types: vec![
FrontendNodeType::new("Identity"),
FrontendNodeType::new("Grayscale Color"),
FrontendNodeType::new("Brighten Color"),
FrontendNodeType::new("Hue Shift Color"),
FrontendNodeType::new("Add"),
FrontendNodeType::new("Map Image"),
],
}
.into(),
);
}
}
NodeGraphMessage::SelectNodes { nodes } => {
self.selected_nodes = nodes;
responses.push_back(PropertiesPanelMessage::ResendActiveProperties.into());
}
NodeGraphMessage::SetInputValue { node, input_index, value } => {
if let Some(network) = self.get_active_network_mut(document) {
if let Some(node) = network.nodes.get_mut(&node) {
// Extend number of inputs if not already large enough
if input_index >= node.inputs.len() {
node.inputs.extend(((node.inputs.len() - 1)..input_index).map(|_| NodeInput::Network));
}
node.inputs[input_index] = NodeInput::Value(value);
}
}
}
}
}
advertise_actions!(NodeGraphMessageDiscriminant; DeleteNode,);
}

View file

@ -24,6 +24,7 @@ impl<'a> MessageHandler<PropertiesPanelMessage, (&PersistentData, PropertiesPane
artwork_document,
artboard_document,
selected_layers,
node_graph_message_handler,
} = data;
let get_document = |document_selector: TargetDocument| match document_selector {
TargetDocument::Artboard => artboard_document,
@ -34,11 +35,15 @@ impl<'a> MessageHandler<PropertiesPanelMessage, (&PersistentData, PropertiesPane
SetActiveLayers { paths, document } => {
if paths.len() != 1 {
// TODO: Allow for multiple selected layers
responses.push_back(PropertiesPanelMessage::ClearSelection.into())
responses.push_back(PropertiesPanelMessage::ClearSelection.into());
responses.push_back(NodeGraphMessage::CloseNodeGraph.into());
} else {
let path = paths.into_iter().next().unwrap();
self.active_selection = Some((path, document));
responses.push_back(PropertiesPanelMessage::ResendActiveProperties.into())
if Some((path.clone(), document)) != self.active_selection {
self.active_selection = Some((path, document));
responses.push_back(PropertiesPanelMessage::ResendActiveProperties.into());
responses.push_back(NodeGraphMessage::CloseNodeGraph.into());
}
}
}
ClearSelection => {
@ -138,7 +143,7 @@ impl<'a> MessageHandler<PropertiesPanelMessage, (&PersistentData, PropertiesPane
let layer = get_document(target_document).layer(&path).unwrap();
match target_document {
TargetDocument::Artboard => register_artboard_layer_properties(layer, responses, persistent_data),
TargetDocument::Artwork => register_artwork_layer_properties(layer, responses, persistent_data),
TargetDocument::Artwork => register_artwork_layer_properties(path, layer, responses, persistent_data, node_graph_message_handler),
}
}
}

View file

@ -223,7 +223,13 @@ pub fn register_artboard_layer_properties(layer: &Layer, responses: &mut VecDequ
);
}
pub fn register_artwork_layer_properties(layer: &Layer, responses: &mut VecDeque<Message>, persistent_data: &PersistentData) {
pub fn register_artwork_layer_properties(
layer_path: Vec<graphene::LayerId>,
layer: &Layer,
responses: &mut VecDeque<Message>,
persistent_data: &PersistentData,
node_graph_message_handler: &NodeGraphMessageHandler,
) {
let options_bar = vec![LayoutGroup::Row {
widgets: vec![
match &layer.data {
@ -314,7 +320,16 @@ pub fn register_artwork_layer_properties(layer: &Layer, responses: &mut VecDeque
vec![node_section_transform(layer, persistent_data), node_section_imaginate(imaginate, layer, persistent_data, responses)]
}
LayerDataType::NodeGraphFrame(node_graph_frame) => {
vec![node_section_transform(layer, persistent_data), node_section_node_graph_frame(node_graph_frame)]
let is_graph_open = node_graph_message_handler.layer_path.as_ref().filter(|node_graph| *node_graph == &layer_path).is_some();
let selected_nodes = &node_graph_message_handler.selected_nodes;
if !selected_nodes.is_empty() && is_graph_open {
node_graph_message_handler.collate_properties(&node_graph_frame)
} else {
vec![
node_section_transform(layer, persistent_data),
node_section_node_graph_frame(layer_path, node_graph_frame, is_graph_open),
]
}
}
LayerDataType::Folder(_) => {
vec![node_section_transform(layer, persistent_data)]
@ -1045,35 +1060,50 @@ fn node_section_imaginate(imaginate_layer: &ImaginateLayer, layer: &Layer, persi
}
}
fn node_section_node_graph_frame(node_graph_frame: &NodeGraphFrameLayer) -> LayoutGroup {
fn node_section_node_graph_frame(layer_path: Vec<graphene::LayerId>, node_graph_frame: &NodeGraphFrameLayer, open_graph: bool) -> LayoutGroup {
LayoutGroup::Section {
name: "Node Graph Frame".into(),
layout: vec![
LayoutGroup::Row {
widgets: vec![WidgetHolder::new(Widget::TextLabel(TextLabel {
value: "Temporary layer that applies a grayscale to the layers below it.".into(),
..TextLabel::default()
}))],
},
LayoutGroup::Row {
widgets: vec![WidgetHolder::new(Widget::TextLabel(TextLabel {
value: "Powered by the node graph! :)".into(),
..TextLabel::default()
}))],
},
LayoutGroup::Row {
widgets: vec![WidgetHolder::new(Widget::TextButton(TextButton {
label: "Open Node Graph UI (coming soon)".into(),
tooltip: "Open the node graph associated with this layer".into(),
on_update: WidgetCallback::new(|_| DialogMessage::RequestComingSoonDialog { issue: Some(800) }.into()),
..Default::default()
}))],
widgets: vec![
WidgetHolder::new(Widget::TextLabel(TextLabel {
value: "Network".into(),
tooltip: "Button to edit the node graph network for this layer".into(),
..Default::default()
})),
WidgetHolder::new(Widget::Separator(Separator {
separator_type: SeparatorType::Unrelated,
direction: SeparatorDirection::Horizontal,
})),
WidgetHolder::new(Widget::TextButton(TextButton {
label: if open_graph { "Close Node Graph".into() } else { "Open Node Graph".into() },
tooltip: format!("{} the node graph associated with this layer", if open_graph { "Close" } else { "Open" }),
on_update: WidgetCallback::new(move |_| {
let layer_path = layer_path.clone();
if open_graph {
NodeGraphMessage::CloseNodeGraph.into()
} else {
NodeGraphMessage::OpenNodeGraph { layer_path }.into()
}
}),
..Default::default()
})),
],
},
LayoutGroup::Row {
widgets: vec![
WidgetHolder::new(Widget::TextLabel(TextLabel {
value: "Image".into(),
tooltip: "Buttons to render the node graph and clear the last rendered image".into(),
..Default::default()
})),
WidgetHolder::new(Widget::Separator(Separator {
separator_type: SeparatorType::Unrelated,
direction: SeparatorDirection::Horizontal,
})),
WidgetHolder::new(Widget::TextButton(TextButton {
label: "Generate".into(),
tooltip: "Fill layer frame by generating a new image".into(),
label: "Render".into(),
tooltip: "Fill layer frame by rendering the node graph".into(),
on_update: WidgetCallback::new(|_| DocumentMessage::NodeGraphFrameGenerate.into()),
..Default::default()
})),
@ -1083,7 +1113,7 @@ fn node_section_node_graph_frame(node_graph_frame: &NodeGraphFrameLayer) -> Layo
})),
WidgetHolder::new(Widget::TextButton(TextButton {
label: "Clear".into(),
tooltip: "Remove generated image from the layer frame".into(),
tooltip: "Remove rendered node graph from the layer frame".into(),
disabled: node_graph_frame.blob_url.is_none(),
on_update: WidgetCallback::new(|_| DocumentMessage::FrameClear.into()),
..Default::default()

View file

@ -3,10 +3,13 @@ use graphene::LayerId;
use serde::{Deserialize, Serialize};
use crate::messages::prelude::NodeGraphMessageHandler;
pub struct PropertiesPanelMessageHandlerData<'a> {
pub artwork_document: &'a GrapheneDocument,
pub artboard_document: &'a GrapheneDocument,
pub selected_layers: &'a mut dyn Iterator<Item = &'a [LayerId]>,
pub node_graph_message_handler: &'a NodeGraphMessageHandler,
}
#[derive(PartialEq, Eq, Clone, Copy, Debug, Serialize, Deserialize)]

View file

@ -221,33 +221,26 @@ impl PropertyHolder for MenuBarMessageHandler {
),
MenuBarEntry::new_root(
"View".into(),
MenuBarEntryChildren(vec![
vec![
MenuBarEntry {
label: "Zoom to Fit".into(),
shortcut: action_keys!(DocumentMessageDiscriminant::ZoomCanvasToFitAll),
action: MenuBarEntry::create_action(|_| DocumentMessage::ZoomCanvasToFitAll.into()),
..MenuBarEntry::default()
},
MenuBarEntry {
label: "Zoom to 100%".into(),
shortcut: action_keys!(DocumentMessageDiscriminant::ZoomCanvasTo100Percent),
action: MenuBarEntry::create_action(|_| DocumentMessage::ZoomCanvasTo100Percent.into()),
..MenuBarEntry::default()
},
MenuBarEntry {
label: "Zoom to 200%".into(),
shortcut: action_keys!(DocumentMessageDiscriminant::ZoomCanvasTo200Percent),
action: MenuBarEntry::create_action(|_| DocumentMessage::ZoomCanvasTo200Percent.into()),
..MenuBarEntry::default()
},
],
vec![MenuBarEntry {
label: "Node Graph (In Development)".into(),
action: MenuBarEntry::create_action(|_| WorkspaceMessage::NodeGraphToggleVisibility.into()),
MenuBarEntryChildren(vec![vec![
MenuBarEntry {
label: "Zoom to Fit".into(),
shortcut: action_keys!(DocumentMessageDiscriminant::ZoomCanvasToFitAll),
action: MenuBarEntry::create_action(|_| DocumentMessage::ZoomCanvasToFitAll.into()),
..MenuBarEntry::default()
}],
]),
},
MenuBarEntry {
label: "Zoom to 100%".into(),
shortcut: action_keys!(DocumentMessageDiscriminant::ZoomCanvasTo100Percent),
action: MenuBarEntry::create_action(|_| DocumentMessage::ZoomCanvasTo100Percent.into()),
..MenuBarEntry::default()
},
MenuBarEntry {
label: "Zoom to 200%".into(),
shortcut: action_keys!(DocumentMessageDiscriminant::ZoomCanvasTo200Percent),
action: MenuBarEntry::create_action(|_| DocumentMessage::ZoomCanvasTo200Percent.into()),
..MenuBarEntry::default()
},
]]),
),
MenuBarEntry::new_root(
"Help".into(),

View file

@ -437,14 +437,18 @@ impl MessageHandler<PortfolioMessage, (&InputPreprocessorMessageHandler, &Prefer
// Execute the node graph
let mut network = node_graph_frame.network.clone();
info!("Network {network:?}");
let stack = borrow_stack::FixedSizeStack::new(256);
network.flatten(0);
for node_id in node_graph_frame.network.nodes.keys() {
network.flatten(*node_id);
}
let mut proto_network = network.into_proto_network();
proto_network.reorder_ids();
for (_id, node) in proto_network.nodes {
info!("Node {:?}", node);
graph_craft::node_registry::push_node(node, &stack);
}

View file

@ -15,6 +15,7 @@ pub use crate::messages::input_preprocessor::{InputPreprocessorMessage, InputPre
pub use crate::messages::layout::{LayoutMessage, LayoutMessageDiscriminant, LayoutMessageHandler};
pub use crate::messages::portfolio::document::artboard::{ArtboardMessage, ArtboardMessageDiscriminant, ArtboardMessageHandler};
pub use crate::messages::portfolio::document::navigation::{NavigationMessage, NavigationMessageDiscriminant, NavigationMessageHandler};
pub use crate::messages::portfolio::document::node_graph::{NodeGraphMessage, NodeGraphMessageDiscriminant, NodeGraphMessageHandler};
pub use crate::messages::portfolio::document::overlays::{OverlaysMessage, OverlaysMessageDiscriminant, OverlaysMessageHandler};
pub use crate::messages::portfolio::document::properties_panel::{PropertiesPanelMessage, PropertiesPanelMessageDiscriminant, PropertiesPanelMessageHandler};
pub use crate::messages::portfolio::document::transform_layer::{TransformLayerMessage, TransformLayerMessageDiscriminant, TransformLayerMessageHandler};

View file

@ -54,7 +54,7 @@ module.exports = {
rules: {
// Standard ESLint config
indent: "off",
quotes: ["error", "double"],
quotes: ["error", "double", { allowTemplateLiterals: true }],
camelcase: ["error", { properties: "always" }],
"linebreak-style": ["error", "unix"],
"eol-last": ["error", "always"],
@ -110,6 +110,7 @@ module.exports = {
tabWidth: 4,
tabs: true,
printWidth: 200,
singleQuote: false,
},
],

View file

@ -70,6 +70,9 @@
linear-gradient(45deg, #cccccc 25%, transparent 25%, transparent 75%, #cccccc 75%), linear-gradient(#ffffff, #ffffff);
--color-transparent-checkered-background-size: 16px 16px;
--color-transparent-checkered-background-position: 0 0, 8px 8px;
--icon-expand-collapse-arrow: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8"><polygon fill="%23eee" points="3,0 1,0 5,4 1,8 3,8 7,4" /></svg>');
--icon-expand-collapse-arrow-hover: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8"><polygon fill="%23fff" points="3,0 1,0 5,4 1,8 3,8 7,4" /></svg>');
}
html,
@ -238,6 +241,7 @@ import { createPersistenceManager } from "@/io-managers/persistence";
import { createDialogState, type DialogState } from "@/state-providers/dialog";
import { createFontsState, type FontsState } from "@/state-providers/fonts";
import { createFullscreenState, type FullscreenState } from "@/state-providers/fullscreen";
import { createNodeGraphState, type NodeGraphState } from "@/state-providers/node-graph";
import { createPanelsState, type PanelsState } from "@/state-providers/panels";
import { createPortfolioState, type PortfolioState } from "@/state-providers/portfolio";
import { createWorkspaceState, type WorkspaceState } from "@/state-providers/workspace";
@ -270,6 +274,7 @@ declare module "@vue/runtime-core" {
panels: PanelsState;
portfolio: PortfolioState;
workspace: WorkspaceState;
nodeGraph: NodeGraphState;
}
}
@ -290,9 +295,10 @@ export default defineComponent({
panels: createPanelsState(editor),
portfolio: createPortfolioState(editor),
workspace: createWorkspaceState(editor),
nodeGraph: createNodeGraphState(editor),
};
},
async mounted() {
mounted() {
// Initialize managers, which are isolated systems that subscribe to backend messages to link them to browser API functionality (like JS events, IndexedDB, etc.)
Object.assign(managerDestructors, {
createClipboardManager: createClipboardManager(this.editor),

View file

@ -1,9 +1,11 @@
# Overview of `/frontend/src/`
## Vue components: `components/`
Vue components that build the Graphite editor GUI, which are mounted in `App.vue`. These are Vue SFCs (single-file components) which each contain a Vue-templated HTML section, an SCSS (Stylus CSS) section, and a script section. The aim is to avoid implementing much editor business logic here, just enough to make things interactive and communicate to the backend where the real business logic should occur.
## I/O managers: `io-managers/`
TypeScript files which manage the input/output of browser APIs and link this functionality with the editor backend. These files subscribe to backend events to execute JS APIs, and in response to these APIs or user interactions, they may call functions into the backend (defined in `/frontend/wasm/editor_api.rs`).
Each I/O manager is a self-contained module where one instance is created in `App.vue` when it's mounted to the DOM at app startup.
@ -11,14 +13,17 @@ Each I/O manager is a self-contained module where one instance is created in `Ap
During development when HMR (hot-module replacement) occurs, these are also unmounted to clean up after themselves, so they can be mounted again with the updated code. Therefore, any side-effects that these managers cause (e.g. adding event listeners to the page) need a destructor function that cleans them up. The destructor function, when applicable, is returned by the module and automatically called in `App.vue` on unmount.
## State providers: `state-providers/`
TypeScript files which provide reactive state and importable functions to Vue components. Each module defines a Vue reactive state object `const state = reactive({ ... });` and exports this from the module in the returned object as the key-value pair `state: readonly(state) as typeof state,` using Vue's `readonly()` wrapper. Other functions may also be defined in the module and exported after `state`, which provide a way for Vue components to call functions to manipulate the state.
In `App.vue`, an instance of each of these are given to Vue's [`provide()`](https://vuejs.org/api/application.html#app-provide) function. This allows any component to access the state provider instance by specifying it in its `inject: [...]` array. The state is accessed in a component with `this.stateProviderName.state.someReactiveVariable` and any exposed functions are accessed with `this.stateProviderName.state.someExposedVariable()`. They can also be used in the Vue HTML template (sans the `this.` prefix).
## *I/O managers vs. state providers*
*Some state providers, similarly to I/O managers, may subscribe to backend events, call functions from `editor_api.rs` into the backend, and interact with browser APIs and user input. The difference is that state providers are meant to `inject`ed by components to use them for reactive state, while I/O managers are meant to be self-contained systems that operate for the lifetime of the application and aren't touched by Vue components.*
## _I/O managers vs. state providers_
_Some state providers, similarly to I/O managers, may subscribe to backend events, call functions from `editor_api.rs` into the backend, and interact with browser APIs and user input. The difference is that state providers are meant to be `inject`ed by components to use them for reactive state, while I/O managers are meant to be self-contained systems that operate for the lifetime of the application and aren't touched by Vue components._
## Utility functions: `utility-functions/`
TypeScript files which define and `export` individual helper functions for use elsewhere in the codebase. These files should not persist state outside each function.
## WASM communication: `wasm-communication/`
@ -26,18 +31,23 @@ TypeScript files which define and `export` individual helper functions for use e
TypeScript files which serve as the JS interface to the WASM bindings for the editor backend.
### WASM editor: `editor.ts`
Instantiates the WASM and editor backend instances. The function `initWasm()` asynchronously constructs and initializes an instance of the WASM bindings JS module provided by wasm-bindgen/wasm-pack. The function `createEditor()` constructs an instance of the editor backend. In theory there could be multiple editor instances sharing the same WASM module instance. The function returns an object where `raw` is the WASM module, `instance` is the editor, and `subscriptions` is the subscription router (described below).
`initWasm()` occurs in `main.ts` right before the Vue application exists, then `createEditor()` is run in `App.vue` during the Vue app's creation. Similarly to the state providers described above, the editor is `provide`d so other components can `inject` it and call functions on `this.editor.raw`, `this.editor.instance`, or `this.editor.subscriptions`.
### Message definitions: `messages.ts`
Defines the message formats and data types received from the backend. Since Rust and JS support different styles of data representation, this bridges the gap from Rust into JS land. Messages (and the data contained within) are serialized in Rust by `serde` into JSON, and these definitions are manually kept up-to-date to parallel the message structs and their data types. (However, directives like `#[serde(skip)]` or `#[serde(rename = "someOtherName")]` may cause the TypeScript format to look slightly different from the Rust structs.) These definitions are basically just for the sake of TypeScript to understand the format, although in some cases we may perform data conversion here using translation functions that we can provide.
### Subscription router: `subscription-router.ts`
Associates messages from the backend with subscribers in the frontend, and routes messages to subscriber callbacks. This module provides a `subscribeJsMessage(messageType, callback)` function which JS code throughout the frontend can call to be registered as the exclusive handler for a chosen message type. This file's other exported function, `handleJsMessage(messageType, messageData, wasm, instance)`, is called in `editor.ts` by the associated editor instance when the backend sends a `FrontendMessage`. When this occurs, the subscription router delivers the message to the subscriber for given `messageType` by executing its registered `callback` function. As an argument to the function, it provides the `messageData` payload transformed into its TypeScript-friendly format defined in `messages.ts`.
## Vue app: `App.vue`
The entry point for the Vue application. This is where we define global CSS style rules, construct the editor,construct/destruct the editor and I/O managers, and construct/provide state providers.
The entry point for the Vue application. This is where we define global CSS style rules, create/destroy the editor instance, construct/destruct the I/O managers, and construct and provide the state providers.
## Entry point: `main.ts`
The entry point for the entire project. Here we simply initialize the WASM module with `await initWasm();` then initialize the Vue application with `createApp(App).mount("#app");`.
The entry point for the entire project's code bundle. Here we simply initialize the WASM module with `await initWasm();` then initialize the Vue application with `createApp(App).mount("#app");`.

View file

@ -1,10 +1,16 @@
<template>
<LayoutCol class="node-graph">
<LayoutRow class="options-bar"></LayoutRow>
<div class="node-list">
<LayoutRow>Nodes:</LayoutRow>
<LayoutRow>
<TextButton v-for="nodeType in nodeTypes" v-bind:key="String(nodeType)" :label="nodeType.name + ' Node'" :action="() => createNode(nodeType.name)"></TextButton>
</LayoutRow>
</div>
<LayoutRow
class="graph"
@wheel="(e: WheelEvent) => scroll(e)"
ref="graph"
@wheel="(e: WheelEvent) => scroll(e)"
@pointerdown="(e: PointerEvent) => pointerDown(e)"
@pointermove="(e: PointerEvent) => pointerMove(e)"
@pointerup="(e: PointerEvent) => pointerUp(e)"
@ -23,21 +29,19 @@
transformOrigin: `0 0`,
}"
>
<div class="node" style="--offset-left: 3; --offset-top: 2; --data-color: var(--color-data-raster); --data-color-dim: var(--color-data-raster-dim)">
<div class="primary">
<div class="ports">
<!-- <div class="input port" data-port="input" data-datatype="raster">
<div></div>
</div> -->
<div class="output port" data-port="output" data-datatype="raster">
<div></div>
</div>
</div>
<IconLabel :icon="'NodeImage'" />
<TextLabel>Image</TextLabel>
</div>
</div>
<div class="node" style="--offset-left: 9; --offset-top: 2; --data-color: var(--color-data-raster); --data-color-dim: var(--color-data-raster-dim)">
<div
v-for="node in nodes"
:key="String(node.id)"
class="node"
:class="{ selected: selected.includes(node.id) }"
:style="{
'--offset-left': 8 + Number(node.id < 9n ? node.id : node.id - 9n) * 7,
'--offset-top': 4 + Number(node.id < 9n ? node.id : node.id - 9n) * 2,
'--data-color': 'var(--color-data-raster)',
'--data-color-dim': 'var(--color-data-raster-dim)',
}"
:data-node="node.id"
>
<div class="primary">
<div class="ports">
<div class="input port" data-port="input" data-datatype="raster">
@ -47,118 +51,8 @@
<div></div>
</div>
</div>
<IconLabel :icon="'NodeMask'" />
<TextLabel>Mask</TextLabel>
</div>
<div class="arguments">
<div class="argument">
<div class="ports">
<div class="input port" data-port="input" data-datatype="raster" style="--data-color: var(--color-data-raster); --data-color-dim: var(--color-data-vector-dim)">
<div></div>
</div>
<!-- <div class="output port" data-port="output" data-datatype="raster">
<div></div>
</div> -->
</div>
<TextLabel>Stencil</TextLabel>
</div>
</div>
</div>
<div class="node" style="--offset-left: 15; --offset-top: 2; --data-color: var(--color-data-raster); --data-color-dim: var(--color-data-raster-dim)">
<div class="primary">
<div class="ports">
<!-- <div class="input port" data-port="input" data-datatype="raster">
<div></div>
</div> -->
<div class="output port" data-port="output" data-datatype="raster">
<div></div>
</div>
</div>
<IconLabel :icon="'NodeTransform'" />
<TextLabel>Transform</TextLabel>
</div>
</div>
<div class="node" style="--offset-left: 21; --offset-top: 2; --data-color: var(--color-data-raster); --data-color-dim: var(--color-data-raster-dim)">
<div class="primary">
<div class="ports">
<div class="input port" data-port="input" data-datatype="raster">
<div></div>
</div>
<div class="output port" data-port="output" data-datatype="raster">
<div></div>
</div>
</div>
<IconLabel :icon="'NodeMotionBlur'" />
<TextLabel>Motion Blur</TextLabel>
</div>
<div class="arguments">
<div class="argument">
<div class="ports">
<div class="input port" data-port="input" data-datatype="raster">
<div></div>
</div>
<!-- <div class="output port" data-port="output" data-datatype="raster">
<div></div>
</div> -->
</div>
<TextLabel>Strength</TextLabel>
</div>
</div>
</div>
<div class="node" style="--offset-left: 2; --offset-top: 5; --data-color: var(--color-data-vector); --data-color-dim: var(--color-data-vector-dim)">
<div class="primary">
<div class="ports">
<!-- <div class="input port" data-port="input" data-datatype="vector">
<div></div>
</div> -->
<div class="output port" data-port="output" data-datatype="vector">
<div></div>
</div>
</div>
<IconLabel :icon="'NodeShape'" />
<TextLabel>Shape</TextLabel>
</div>
</div>
<div class="node" style="--offset-left: 6; --offset-top: 7; --data-color: var(--color-data-raster); --data-color-dim: var(--color-data-raster-dim)">
<div class="primary">
<div class="ports">
<!-- <div class="input port" data-port="input" data-datatype="raster">
<div></div>
</div> -->
<div class="output port" data-port="output" data-datatype="raster">
<div></div>
</div>
</div>
<IconLabel :icon="'NodeBrushwork'" />
<TextLabel>Brushwork</TextLabel>
</div>
</div>
<div class="node" style="--offset-left: 12; --offset-top: 7; --data-color: var(--color-data-raster); --data-color-dim: var(--color-data-raster-dim)">
<div class="primary">
<div class="ports">
<!-- <div class="input port" data-port="input" data-datatype="raster">
<div></div>
</div> -->
<div class="output port" data-port="output" data-datatype="raster">
<div></div>
</div>
</div>
<IconLabel :icon="'NodeBlur'" />
<TextLabel>Blur</TextLabel>
</div>
</div>
<div class="node" style="--offset-left: 12; --offset-top: 9; --data-color: var(--color-data-raster); --data-color-dim: var(--color-data-raster-dim)">
<div class="primary">
<div class="ports">
<!-- <div class="input port" data-port="input" data-datatype="raster">
<div></div>
</div> -->
<div class="output port" data-port="output" data-datatype="raster">
<div></div>
</div>
</div>
<IconLabel :icon="'NodeGradient'" />
<TextLabel>Gradient</TextLabel>
<IconLabel :icon="nodeIcon(node.displayName)" />
<TextLabel>{{ node.displayName }}</TextLabel>
</div>
</div>
</div>
@ -169,7 +63,14 @@
transformOrigin: `0 0`,
}"
>
<svg ref="wiresContainer"></svg>
<svg>
<path
v-for="([pathString, dataType], index) in linkPaths"
:key="index"
:d="pathString"
:style="{ '--data-color': `var(--color-data-${dataType})`, '--data-color-dim': `var(--color-data-${dataType}-dim)` }"
/>
</svg>
</div>
</LayoutRow>
</LayoutCol>
@ -178,6 +79,16 @@
<style lang="scss">
.node-graph {
height: 100%;
position: relative;
.node-list {
width: max-content;
position: fixed;
padding: 20px;
margin: 40px 10px;
z-index: 3;
background-color: var(--color-4-dimgray);
}
.options-bar {
height: 32px;
@ -223,6 +134,7 @@
svg {
width: 100%;
height: 100%;
overflow: visible;
path {
fill: none;
@ -244,6 +156,11 @@
left: calc((var(--offset-left) + 0.5) * 24px);
top: calc((var(--offset-top) + 0.5) * 24px);
&.selected {
border: 1px solid var(--color-e-nearwhite);
margin: -1px;
}
.primary {
display: flex;
align-items: center;
@ -344,23 +261,32 @@
</style>
<script lang="ts">
import { defineComponent } from "vue";
import { defineComponent, nextTick } from "vue";
// import type { FrontendNode } from "@/wasm-communication/messages";
import type { IconName } from "@/utility-functions/icons";
import LayoutCol from "@/components/layout/LayoutCol.vue";
import LayoutRow from "@/components/layout/LayoutRow.vue";
import TextButton from "@/components/widgets/buttons/TextButton.vue";
import IconLabel from "@/components/widgets/labels/IconLabel.vue";
import TextLabel from "@/components/widgets/labels/TextLabel.vue";
const WHEEL_RATE = 1 / 600;
const WHEEL_RATE = (1 / 600) * 3;
const GRID_COLLAPSE_SPACING = 10;
const GRID_SIZE = 24;
export default defineComponent({
inject: ["nodeGraph", "editor"],
data() {
return {
transform: { scale: 1, x: 0, y: 0 },
panning: false,
drawing: undefined as { port: HTMLDivElement; output: boolean; path: SVGElement } | undefined,
selected: [] as bigint[],
linkInProgressFromConnector: undefined as HTMLDivElement | undefined,
linkInProgressToConnector: undefined as HTMLDivElement | DOMRect | undefined,
nodeLinkPaths: [] as [string, string][],
};
},
computed: {
@ -377,8 +303,55 @@ export default defineComponent({
dotRadius(): number {
return 1 + Math.floor(this.transform.scale - 0.5 + 0.001) / 2;
},
nodes() {
return this.nodeGraph.state.nodes;
},
nodeTypes() {
return this.nodeGraph.state.nodeTypes;
},
linkPathInProgress(): [string, string] | undefined {
if (this.linkInProgressFromConnector && this.linkInProgressToConnector) {
return this.createWirePath(this.linkInProgressFromConnector, this.linkInProgressToConnector, false, false);
}
return undefined;
},
linkPaths(): [string, string][] {
const linkPathInProgress = this.linkPathInProgress ? [this.linkPathInProgress] : [];
return [...linkPathInProgress, ...this.nodeLinkPaths];
},
},
watch: {
nodes: {
immediate: true,
async handler() {
await nextTick();
const containerBounds = this.$refs.nodesContainer as HTMLDivElement | undefined;
if (!containerBounds) return;
const links = this.nodeGraph.state.links;
this.nodeLinkPaths = links.flatMap((link) => {
const connectorIndex = 0;
const nodePrimaryOutput = (containerBounds.querySelector(`[data-node="${String(link.linkStart)}"] [data-port="output"]`) || undefined) as HTMLDivElement | undefined;
const nodeInputConnectors = containerBounds.querySelectorAll(`[data-node="${String(link.linkEnd)}"] [data-port="input"]`) || undefined;
const nodePrimaryInput = nodeInputConnectors?.[connectorIndex] as HTMLDivElement | undefined;
if (!nodePrimaryInput || !nodePrimaryOutput) return [];
return [this.createWirePath(nodePrimaryOutput, nodePrimaryInput.getBoundingClientRect(), false, false)];
});
},
},
},
methods: {
nodeIcon(nodeName: string): IconName {
const iconMap: Record<string, IconName> = {
Grayscale: "NodeColorCorrection",
"Map Image": "NodeOutput",
};
return iconMap[nodeName] || "NodeNodes";
},
buildWirePathString(outputBounds: DOMRect, inputBounds: DOMRect, verticalOut: boolean, verticalIn: boolean): string {
const containerBounds = (this.$refs.nodesContainer as HTMLDivElement | undefined)?.getBoundingClientRect();
if (!containerBounds) return "[error]";
@ -392,7 +365,6 @@ export default defineComponent({
const inY = verticalIn ? inputBounds.y + inputBounds.height - 1 : inputBounds.y + inputBounds.height / 2;
const inConnectorX = (inX - containerBounds.x) / this.transform.scale;
const inConnectorY = (inY - containerBounds.y) / this.transform.scale;
// debugger;
const horizontalGap = Math.abs(outConnectorX - inConnectorX);
const verticalGap = Math.abs(outConnectorY - inConnectorY);
@ -408,89 +380,138 @@ export default defineComponent({
verticalIn ? inConnectorX : inConnectorX - horizontalCurve
},${verticalIn ? inConnectorY + verticalCurve : inConnectorY} ${inConnectorX},${inConnectorY}`;
},
createWirePath(outputPort: HTMLDivElement, inputPort: HTMLDivElement, verticalOut: boolean, verticalIn: boolean): SVGPathElement {
const pathString = this.buildWirePathString(outputPort.getBoundingClientRect(), inputPort.getBoundingClientRect(), verticalOut, verticalIn);
const dataType = outputPort.dataset.datatype;
createWirePath(outputPort: HTMLDivElement, inputPort: HTMLDivElement | DOMRect, verticalOut: boolean, verticalIn: boolean): [string, string] {
const inputPortRect = inputPort instanceof HTMLDivElement ? inputPort.getBoundingClientRect() : inputPort;
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", pathString);
path.setAttribute("style", `--data-color: var(--color-data-${dataType}); --data-color-dim: var(--color-data-${dataType}-dim)`);
(this.$refs.wiresContainer as SVGSVGElement | undefined)?.appendChild(path);
const pathString = this.buildWirePathString(outputPort.getBoundingClientRect(), inputPortRect, verticalOut, verticalIn);
const dataType = outputPort.getAttribute("data-datatype") || "general";
return path;
return [pathString, dataType];
},
scroll(e: WheelEvent) {
const scroll = e.deltaY;
let zoomFactor = 1 + Math.abs(scroll) * WHEEL_RATE;
if (scroll > 0) zoomFactor = 1 / zoomFactor;
const scrollX = e.deltaX;
const scrollY = e.deltaY;
const graphDiv: HTMLDivElement | undefined = (this.$refs.graph as typeof LayoutCol | undefined)?.$el;
if (!graphDiv) return;
const { x, y, width, height } = graphDiv.getBoundingClientRect();
// Zoom
if (e.ctrlKey) {
let zoomFactor = 1 + Math.abs(scrollY) * WHEEL_RATE;
if (scrollY > 0) zoomFactor = 1 / zoomFactor;
this.transform.scale *= zoomFactor;
const graphDiv: HTMLDivElement | undefined = (this.$refs.graph as typeof LayoutCol | undefined)?.$el;
if (!graphDiv) return;
const { x, y, width, height } = graphDiv.getBoundingClientRect();
const newViewportX = width / zoomFactor;
const newViewportY = height / zoomFactor;
this.transform.scale *= zoomFactor;
const deltaSizeX = width - newViewportX;
const deltaSizeY = height - newViewportY;
const newViewportX = width / zoomFactor;
const newViewportY = height / zoomFactor;
const deltaX = deltaSizeX * ((e.x - x) / width);
const deltaY = deltaSizeY * ((e.y - y) / height);
const deltaSizeX = width - newViewportX;
const deltaSizeY = height - newViewportY;
this.transform.x -= (deltaX / this.transform.scale) * zoomFactor;
this.transform.y -= (deltaY / this.transform.scale) * zoomFactor;
const deltaX = deltaSizeX * ((e.x - x) / width);
const deltaY = deltaSizeY * ((e.y - y) / height);
this.transform.x -= (deltaX / this.transform.scale) * zoomFactor;
this.transform.y -= (deltaY / this.transform.scale) * zoomFactor;
// Prevent actually zooming into the page when pinch-zooming on laptop trackpads
e.preventDefault();
}
// Pan
else if (!e.shiftKey) {
this.transform.x -= scrollX / this.transform.scale;
this.transform.y -= scrollY / this.transform.scale;
} else {
this.transform.x -= scrollY / this.transform.scale;
}
},
pointerDown(e: PointerEvent) {
const port = (e.target as HTMLDivElement).closest("[data-port]") as HTMLDivElement;
const node = (e.target as HTMLElement).closest("[data-node]") as HTMLElement | undefined;
if (port) {
const output = port.classList.contains("output");
const path = this.createWirePath(port, port, false, false);
this.drawing = { port, output, path };
} else {
this.panning = true;
}
const isOutput = Boolean(port.getAttribute("data-port") === "output");
const graphDiv: HTMLDivElement | undefined = (this.$refs.graph as typeof LayoutCol | undefined)?.$el;
graphDiv?.setPointerCapture(e.pointerId);
if (isOutput) this.linkInProgressFromConnector = port;
} else {
const nodeId = node?.getAttribute("data-node") || undefined;
if (nodeId) {
const id = BigInt(nodeId);
this.editor.instance.selectNodes(new BigUint64Array([id]));
this.selected = [id];
} else {
this.editor.instance.selectNodes(new BigUint64Array([]));
this.selected = [];
const graphDiv: HTMLDivElement | undefined = (this.$refs.graph as typeof LayoutCol | undefined)?.$el;
graphDiv?.setPointerCapture(e.pointerId);
this.panning = true;
}
}
},
pointerMove(e: PointerEvent) {
if (this.panning) {
this.transform.x += e.movementX / this.transform.scale;
this.transform.y += e.movementY / this.transform.scale;
} else if (this.drawing) {
const mouse = new DOMRect(e.x, e.y);
const port = this.drawing.port.getBoundingClientRect();
const output = this.drawing.output ? port : mouse;
const input = this.drawing.output ? mouse : port;
const pathString = this.buildWirePathString(output, input, false, false);
this.drawing.path.setAttribute("d", pathString);
} else if (this.linkInProgressFromConnector) {
const target = e.target as Element | undefined;
const dot = (target?.closest(`[data-port="input"]`) || undefined) as HTMLDivElement | undefined;
if (dot) {
this.linkInProgressToConnector = dot;
} else {
this.linkInProgressToConnector = new DOMRect(e.x, e.y);
}
}
},
pointerUp(e: PointerEvent) {
const graph: HTMLDivElement | undefined = (this.$refs.graph as typeof LayoutCol | undefined)?.$el;
graph?.releasePointerCapture(e.pointerId);
this.panning = false;
this.drawing = undefined;
if (this.linkInProgressToConnector instanceof HTMLDivElement && this.linkInProgressFromConnector) {
const outputNode = this.linkInProgressFromConnector.closest("[data-node]");
const inputNode = this.linkInProgressToConnector.closest("[data-node]");
const outputConnectedNodeID = outputNode?.getAttribute("data-node") ?? undefined;
const inputConnectedNodeID = inputNode?.getAttribute("data-node") ?? undefined;
if (outputNode && inputNode && outputConnectedNodeID && inputConnectedNodeID) {
const inputNodeInPorts = Array.from(inputNode.querySelectorAll(`[data-port="input"]`));
const inputNodeConnectionIndexSearch = inputNodeInPorts.indexOf(this.linkInProgressToConnector);
const inputNodeConnectionIndex = inputNodeConnectionIndexSearch > -1 ? inputNodeConnectionIndexSearch : undefined;
if (inputNodeConnectionIndex !== undefined) {
const oneBasedIndex = inputNodeConnectionIndex + 1;
this.editor.instance.connectNodesByLink(BigInt(outputConnectedNodeID), BigInt(inputConnectedNodeID), oneBasedIndex);
}
}
}
this.linkInProgressFromConnector = undefined;
this.linkInProgressToConnector = undefined;
},
createNode(nodeType: string): void {
this.editor.instance.createNode(nodeType);
},
},
mounted() {
const outputPort1 = document.querySelectorAll(`[data-port="${"output"}"]`)[4] as HTMLDivElement | undefined;
const inputPort1 = document.querySelectorAll(`[data-port="${"input"}"]`)[1] as HTMLDivElement | undefined;
if (outputPort1 && inputPort1) this.createWirePath(outputPort1, inputPort1, true, true);
const outputPort1 = document.querySelectorAll(`[data-port="output"]`)[4] as HTMLDivElement | undefined;
const inputPort1 = document.querySelectorAll(`[data-port="input"]`)[1] as HTMLDivElement | undefined;
if (outputPort1 && inputPort1) this.createWirePath(outputPort1, inputPort1.getBoundingClientRect(), true, true);
const outputPort2 = document.querySelectorAll(`[data-port="${"output"}"]`)[6] as HTMLDivElement | undefined;
const inputPort2 = document.querySelectorAll(`[data-port="${"input"}"]`)[3] as HTMLDivElement | undefined;
if (outputPort2 && inputPort2) this.createWirePath(outputPort2, inputPort2, true, false);
const outputPort2 = document.querySelectorAll(`[data-port="output"]`)[6] as HTMLDivElement | undefined;
const inputPort2 = document.querySelectorAll(`[data-port="input"]`)[3] as HTMLDivElement | undefined;
if (outputPort2 && inputPort2) this.createWirePath(outputPort2, inputPort2.getBoundingClientRect(), true, false);
},
components: {
IconLabel,
LayoutCol,
LayoutRow,
TextLabel,
TextButton,
},
});
</script>

View file

@ -25,10 +25,6 @@
.sections {
flex: 1 1 100%;
.widget-section + .widget-section {
margin-top: 1px;
}
}
}
</style>

View file

@ -1,9 +1,8 @@
<!-- TODO: Implement collapsable sections with properties system -->
<template>
<LayoutCol class="widget-section">
<button class="header" @click.stop="() => (expanded = !expanded)" tabindex="0">
<div class="expand-arrow" :class="{ expanded }"></div>
<Separator :type="'Related'" />
<button class="header" :class="{ expanded }" @click.stop="() => (expanded = !expanded)" tabindex="0">
<div class="expand-arrow"></div>
<TextLabel :bold="true">{{ widgetData.name }}</TextLabel>
</button>
<LayoutCol class="body" v-if="expanded">
@ -17,18 +16,19 @@
flex: 0 0 auto;
.header {
text-align: left;
align-items: center;
display: flex;
flex: 0 0 24px;
border: 0;
text-align: left;
padding: 0 8px;
margin: 0 -4px;
background: var(--color-4-dimgray);
align-items: center;
margin-bottom: 4px;
border: 0;
border-radius: 4px;
background: var(--color-5-dullgray);
.expand-arrow {
width: 6px;
height: 100%;
width: 8px;
height: 8px;
margin: 0;
padding: 0;
position: relative;
@ -40,29 +40,61 @@
&::after {
content: "";
position: absolute;
width: 0;
height: 0;
border-style: solid;
border-width: 3px 0 3px 6px;
border-color: transparent transparent transparent var(--color-e-nearwhite);
width: 8px;
height: 8px;
background: var(--icon-expand-collapse-arrow);
}
}
&.expanded::after {
border-width: 6px 3px 0 3px;
border-color: var(--color-e-nearwhite) transparent transparent transparent;
&.expanded {
border-radius: 4px 4px 0 0;
margin-bottom: 0;
.expand-arrow::after {
transform: rotate(90deg);
}
}
.text-label {
height: 18px;
margin-left: 8px;
display: inline-block;
}
&:hover {
background: var(--color-6-lowergray);
.expand-arrow::after {
background: var(--icon-expand-collapse-arrow-hover);
}
.text-label {
color: var(--color-f-white);
}
+ .body {
border: 1px solid var(--color-6-lowergray);
}
}
}
.body {
margin: 0 4px;
padding: 0 7px;
padding-top: 1px;
margin-top: -1px;
margin-bottom: 4px;
border: 1px solid var(--color-5-dullgray);
border-radius: 0 0 4px 4px;
.widget-row {
&:first-child {
margin-top: -1px;
}
&:last-child {
margin-bottom: -1px;
}
> .text-label:first-of-type {
flex: 0 0 30%;
text-align: right;
@ -83,7 +115,6 @@ import { isWidgetRow, isWidgetSection, type LayoutGroup, type WidgetSection as W
import LayoutCol from "@/components/layout/LayoutCol.vue";
import LayoutRow from "@/components/layout/LayoutRow.vue";
import Separator from "@/components/widgets/labels/Separator.vue";
import TextLabel from "@/components/widgets/labels/TextLabel.vue";
import WidgetRow from "@/components/widgets/WidgetRow.vue";
@ -113,7 +144,6 @@ const WidgetSection = defineComponent({
components: {
LayoutCol,
LayoutRow,
Separator,
TextLabel,
WidgetRow,
},

View file

@ -14,17 +14,17 @@
ref="documentPanel"
/>
</LayoutRow>
<LayoutRow class="workspace-grid-resize-gutter" @pointerdown="(e: PointerEvent) => resizePanel(e)" v-if="nodeGraphVisible"></LayoutRow>
<LayoutRow class="workspace-grid-resize-gutter" data-gutter-vertical @pointerdown="(e: PointerEvent) => resizePanel(e)" v-if="nodeGraphVisible"></LayoutRow>
<LayoutRow class="workspace-grid-subdivision" v-if="nodeGraphVisible">
<Panel :panelType="'NodeGraph'" :tabLabels="[{ name: 'Node Graph' }]" :tabActiveIndex="0" />
</LayoutRow>
</LayoutCol>
<LayoutCol class="workspace-grid-resize-gutter" @pointerdown="(e: PointerEvent) => resizePanel(e)"></LayoutCol>
<LayoutCol class="workspace-grid-resize-gutter" data-gutter-horizontal @pointerdown="(e: PointerEvent) => resizePanel(e)"></LayoutCol>
<LayoutCol class="workspace-grid-subdivision" style="flex-grow: 0.2">
<LayoutRow class="workspace-grid-subdivision" style="flex-grow: 402">
<Panel :panelType="'Properties'" :tabLabels="[{ name: 'Properties' }]" :tabActiveIndex="0" />
</LayoutRow>
<LayoutRow class="workspace-grid-resize-gutter" @pointerdown="(e: PointerEvent) => resizePanel(e)"></LayoutRow>
<LayoutRow class="workspace-grid-resize-gutter" data-gutter-vertical @pointerdown="(e: PointerEvent) => resizePanel(e)"></LayoutRow>
<LayoutRow class="workspace-grid-subdivision" style="flex-grow: 590">
<Panel :panelType="'LayerTree'" :tabLabels="[{ name: 'Layer Tree' }]" :tabActiveIndex="0" />
</LayoutRow>
@ -95,24 +95,26 @@ export default defineComponent({
},
methods: {
resizePanel(event: PointerEvent) {
const gutter = event.target as HTMLDivElement;
const nextSibling = gutter.nextElementSibling as HTMLDivElement;
const previousSibling = gutter.previousElementSibling as HTMLDivElement;
const gutter = (event.target || undefined) as HTMLDivElement | undefined;
const nextSibling = (gutter?.nextElementSibling || undefined) as HTMLDivElement | undefined;
const previousSibling = (gutter?.previousElementSibling || undefined) as HTMLDivElement | undefined;
if (!gutter || !nextSibling || !previousSibling) return;
// Are we resizing horizontally?
const horizontal = gutter.classList.contains("layout-col");
const isHorizontal = gutter.getAttribute("data-gutter-horizontal") !== null;
// Get the current size in px of the panels being resized
const nextSiblingSize = horizontal ? nextSibling.getBoundingClientRect().width : nextSibling.getBoundingClientRect().height;
const previousSiblingSize = horizontal ? previousSibling.getBoundingClientRect().width : previousSibling.getBoundingClientRect().height;
const nextSiblingSize = isHorizontal ? nextSibling.getBoundingClientRect().width : nextSibling.getBoundingClientRect().height;
const previousSiblingSize = isHorizontal ? previousSibling.getBoundingClientRect().width : previousSibling.getBoundingClientRect().height;
// Prevent cursor flicker as mouse temporarily leaves the gutter
gutter.setPointerCapture(event.pointerId);
const mouseStart = horizontal ? event.clientX : event.clientY;
const mouseStart = isHorizontal ? event.clientX : event.clientY;
function updatePosition(event: PointerEvent): void {
const mouseCurrent = horizontal ? event.clientX : event.clientY;
const updatePosition = (event: PointerEvent): void => {
const mouseCurrent = isHorizontal ? event.clientX : event.clientY;
let mouseDelta = mouseStart - mouseCurrent;
mouseDelta = Math.max(nextSiblingSize + mouseDelta, MIN_PANEL_SIZE) - nextSiblingSize;
@ -122,15 +124,15 @@ export default defineComponent({
previousSibling.style.flexGrow = (previousSiblingSize - mouseDelta).toString();
window.dispatchEvent(new CustomEvent("resize"));
}
};
function cleanup(event: PointerEvent): void {
const cleanup = (event: PointerEvent): void => {
gutter.releasePointerCapture(event.pointerId);
document.removeEventListener("pointermove", updatePosition);
document.removeEventListener("pointerleave", cleanup);
document.removeEventListener("pointerup", cleanup);
}
};
document.addEventListener("pointermove", updatePosition);
document.addEventListener("pointerleave", cleanup);

View file

@ -0,0 +1,28 @@
import { reactive, readonly } from "vue";
import { type Editor } from "@/wasm-communication/editor";
import { type FrontendNode, type FrontendNodeLink, type FrontendNodeType, UpdateNodeGraph, UpdateNodeTypes } from "@/wasm-communication/messages";
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function createNodeGraphState(editor: Editor) {
const state = reactive({
nodes: [] as FrontendNode[],
links: [] as FrontendNodeLink[],
nodeTypes: [] as FrontendNodeType[],
});
// Set up message subscriptions on creation
editor.subscriptions.subscribeJsMessage(UpdateNodeGraph, (updateNodeGraph) => {
state.nodes = updateNodeGraph.nodes;
state.links = updateNodeGraph.links;
console.info("Recieved updated nodes", state.nodes);
});
editor.subscriptions.subscribeJsMessage(UpdateNodeTypes, (updateNodeTypes) => {
state.nodeTypes = updateNodeTypes.nodeTypes;
});
return {
state: readonly(state) as typeof state,
};
}
export type NodeGraphState = ReturnType<typeof createNodeGraphState>;

View file

@ -1,5 +1,5 @@
/* eslint-disable max-classes-per-file */
import { reactive, readonly } from "vue";
import { nextTick, reactive, readonly } from "vue";
import { type Editor } from "@/wasm-communication/editor";
import { UpdateNodeGraphVisibility } from "@/wasm-communication/messages";
@ -11,8 +11,11 @@ export function createWorkspaceState(editor: Editor) {
});
// Set up message subscriptions on creation
editor.subscriptions.subscribeJsMessage(UpdateNodeGraphVisibility, (updateNodeGraphVisibility) => {
editor.subscriptions.subscribeJsMessage(UpdateNodeGraphVisibility, async (updateNodeGraphVisibility) => {
state.nodeGraphVisible = updateNodeGraphVisibility.visible;
// Update the viewport bounds
await nextTick();
window.dispatchEvent(new Event("resize"));
});
return {

View file

@ -21,6 +21,18 @@ export class JsMessage {
// for details about how to transform the JSON from wasm-bindgen into classes.
// ============================================================================
export class UpdateNodeGraph extends JsMessage {
@Type(() => FrontendNode)
readonly nodes!: FrontendNode[];
@Type(() => FrontendNodeLink)
readonly links!: FrontendNodeLink[];
}
export class UpdateNodeTypes extends JsMessage {
@Type(() => FrontendNode)
readonly nodeTypes!: FrontendNodeType[];
}
export class UpdateNodeGraphVisibility extends JsMessage {
readonly visible!: boolean;
}
@ -52,6 +64,24 @@ export class FrontendDocumentDetails extends DocumentDetails {
readonly id!: bigint;
}
export class FrontendNode {
readonly id!: bigint;
readonly displayName!: string;
}
export class FrontendNodeLink {
readonly linkStart!: bigint;
readonly linkEnd!: bigint;
readonly linkEndInputIndex!: bigint;
}
export class FrontendNodeType {
readonly name!: string;
}
export class IndexedDbDocumentDetails extends DocumentDetails {
@Transform(({ value }: { value: bigint }) => value.toString())
id!: string;
@ -1285,6 +1315,8 @@ export const messageMakers: Record<string, MessageMaker> = {
UpdateLayerTreeOptionsLayout,
UpdateMenuBarLayout,
UpdateMouseCursor,
UpdateNodeGraph,
UpdateNodeTypes,
UpdateNodeGraphVisibility,
UpdateOpenDocumentsList,
UpdatePropertyPanelOptionsLayout,

View file

@ -23,7 +23,7 @@ module.exports = {
crateDirectory: path.resolve(__dirname, "wasm"),
// Remove when this issue is resolved: https://github.com/wasm-tool/wasm-pack-plugin/issues/93
outDir: path.resolve(__dirname, "wasm/pkg"),
watchDirectories: ["../editor", "../graphene", "../proc-macros"].map((folder) => path.resolve(__dirname, folder)),
watchDirectories: ["../editor", "../graphene", "../proc-macros", "../node-graph"].map((folder) => path.resolve(__dirname, folder)),
})
)
.end();

View file

@ -16,6 +16,7 @@ crate-type = ["cdylib", "rlib"]
[dependencies]
editor = { path = "../../editor", package = "graphite-editor" }
graphene = { path = "../../graphene", package = "graphite-graphene" }
graph-craft = { path = "../../node-graph/graph-craft" }
log = "0.4"
serde = { version = "1.0", features = ["derive"] }
wasm-bindgen = { version = "0.2.73" }

View file

@ -539,6 +539,60 @@ impl JsEditorHandle {
self.dispatch(message);
}
/// Notifies the backend that the user connected a node's primary output to one of another node's inputs
#[wasm_bindgen(js_name = connectNodesByLink)]
pub fn connect_nodes_by_link(&self, output_node: u64, input_node: u64, input_node_connector_index: u32) {
let message = NodeGraphMessage::ConnectNodesByLink {
output_node,
input_node,
input_node_connector_index,
};
self.dispatch(message);
}
/// Creates a new document node in the node graph
#[wasm_bindgen(js_name = createNode)]
pub fn create_node(&self, node_type: String) {
use graph_craft::proto::{NodeIdentifier, Type};
use std::borrow::Cow;
fn generate_node_id() -> u64 {
static mut NODE_ID: u64 = 10;
unsafe {
NODE_ID += 1;
NODE_ID
}
}
let (ident, args) = match node_type.as_str() {
"Identity" => (NodeIdentifier::new("graphene_core::ops::IdNode", &[Type::Concrete(Cow::Borrowed("Any<'_>"))]), 1),
"Grayscale Color" => (NodeIdentifier::new("graphene_core::ops::IdNode", &[Type::Concrete(Cow::Borrowed("Any<'_>"))]), 1),
"Brighten Color" => (NodeIdentifier::new("graphene_core::ops::IdNode", &[Type::Concrete(Cow::Borrowed("Any<'_>"))]), 1),
"Hue Shift Color" => (NodeIdentifier::new("graphene_core::ops::IdNode", &[Type::Concrete(Cow::Borrowed("Any<'_>"))]), 1),
"Add" => (
NodeIdentifier::new("graphene_core::ops::AddNode", &[Type::Concrete(Cow::Borrowed("u32")), Type::Concrete(Cow::Borrowed("u32"))]),
2,
),
"Map Image" => (NodeIdentifier::new("graphene_std::raster::MapImageNode", &[]), 2),
_ => panic!("Invalid node type: {}", node_type),
};
let message = NodeGraphMessage::CreateNode {
node_id: generate_node_id(),
name: node_type,
identifier: ident,
num_inputs: args,
};
self.dispatch(message);
}
/// Notifies the backend that the user selected a node in the node graph
#[wasm_bindgen(js_name = selectNodes)]
pub fn select_nodes(&self, nodes: Vec<u64>) {
let message = NodeGraphMessage::SelectNodes { nodes };
self.dispatch(message);
}
/// Pastes an image
#[wasm_bindgen(js_name = pasteImage)]
pub fn paste_image(&self, mime: String, image_data: Vec<u8>, mouse_x: Option<f64>, mouse_y: Option<f64>) {

View file

@ -118,25 +118,69 @@ impl Default for NodeGraphFrameLayer {
fn default() -> Self {
use graph_craft::document::*;
use graph_craft::proto::NodeIdentifier;
let brighten_network = NodeNetwork {
inputs: vec![0, 0],
output: 0,
nodes: [(
0,
DocumentNode {
name: "brighten".into(),
inputs: vec![NodeInput::Network, NodeInput::Network],
implementation: DocumentNodeImplementation::Unresolved(NodeIdentifier::new(
"graphene_core::raster::BrightenColorNode",
&[graph_craft::proto::Type::Concrete(std::borrow::Cow::Borrowed("&TypeErasedNode"))],
)),
},
)]
.into_iter()
.collect(),
};
let hue_shift_network = NodeNetwork {
inputs: vec![0, 0],
output: 0,
nodes: [(
0,
DocumentNode {
name: "hue shift".into(),
inputs: vec![NodeInput::Network, NodeInput::Network],
implementation: DocumentNodeImplementation::Unresolved(NodeIdentifier::new(
"graphene_core::raster::HueShiftNode",
&[graph_craft::proto::Type::Concrete(std::borrow::Cow::Borrowed("&TypeErasedNode"))],
)),
},
)]
.into_iter()
.collect(),
};
Self {
mime: String::new(),
network: NodeNetwork {
inputs: vec![1],
output: 1,
inputs: vec![2, 1],
output: 2,
nodes: [
(
0,
DocumentNode {
name: "grayscale".into(),
inputs: vec![NodeInput::Network],
implementation: DocumentNodeImplementation::Unresolved(NodeIdentifier::new("graphene_core::raster::GrayscaleNode", &[])),
name: "Hue Shift Color".into(),
inputs: vec![NodeInput::Network, NodeInput::Value(value::TaggedValue::F32(50.))],
implementation: DocumentNodeImplementation::Network(hue_shift_network),
},
),
(
1,
DocumentNode {
name: "map image".into(),
inputs: vec![NodeInput::Network, NodeInput::Node(0)],
name: "Brighten Color".into(),
inputs: vec![NodeInput::Node(0), NodeInput::Value(value::TaggedValue::F32(10.))],
implementation: DocumentNodeImplementation::Network(brighten_network),
},
),
(
2,
DocumentNode {
name: "Map Image".into(),
inputs: vec![NodeInput::Network, NodeInput::Node(1)],
implementation: DocumentNodeImplementation::Unresolved(NodeIdentifier::new("graphene_std::raster::MapImageNode", &[])),
},
),

View file

@ -22,6 +22,58 @@ impl<'n> Node<Color> for &'n GrayscaleNode {
}
}
#[derive(Debug, Clone, Copy)]
pub struct BrightenColorNode<N: Node<(), Output = f32>>(N);
impl<N: Node<(), Output = f32>> Node<Color> for BrightenColorNode<N> {
type Output = Color;
fn eval(self, color: Color) -> Color {
let brightness = self.0.eval(());
let per_channel = |col: f32| (col + brightness / 255.).clamp(0., 1.);
Color::from_rgbaf32_unchecked(per_channel(color.r()), per_channel(color.g()), per_channel(color.b()), color.a())
}
}
impl<N: Node<(), Output = f32> + Copy> Node<Color> for &BrightenColorNode<N> {
type Output = Color;
fn eval(self, color: Color) -> Color {
let brightness = self.0.eval(());
let per_channel = |col: f32| (col + brightness / 255.).clamp(0., 1.);
Color::from_rgbaf32_unchecked(per_channel(color.r()), per_channel(color.g()), per_channel(color.b()), color.a())
}
}
impl<N: Node<(), Output = f32> + Copy> BrightenColorNode<N> {
pub fn new(node: N) -> Self {
Self(node)
}
}
#[derive(Debug, Clone, Copy)]
pub struct HueShiftNode<N: Node<(), Output = f32>>(N);
impl<N: Node<(), Output = f32>> Node<Color> for HueShiftNode<N> {
type Output = Color;
fn eval(self, color: Color) -> Color {
let hue_shift = self.0.eval(());
let [hue, saturation, luminance, alpha] = color.to_hsla();
Color::from_hsla(hue + hue_shift / 360., saturation, luminance, alpha)
}
}
impl<N: Node<(), Output = f32> + Copy> Node<Color> for &HueShiftNode<N> {
type Output = Color;
fn eval(self, color: Color) -> Color {
let hue_shift = self.0.eval(());
let [hue, saturation, luminance, alpha] = color.to_hsla();
Color::from_hsla(hue + hue_shift / 360., saturation, luminance, alpha)
}
}
impl<N: Node<(), Output = f32> + Copy> HueShiftNode<N> {
pub fn new(node: N) -> Self {
Self(node)
}
}
pub struct ForEachNode<MN>(pub MN);
impl<'n, I: Iterator<Item = S>, MN: 'n, S> Node<I> for &'n ForEachNode<MN>

View file

@ -82,6 +82,41 @@ impl Color {
}
}
/// Create a [Color] from a hue, saturation, luminance and alpha (all between 0 and 1)
///
/// # Examples
/// ```
/// use graphene_core::raster::color::Color;
/// let color = Color::from_hsla(0.5, 0.2, 0.3, 1.);
/// ```
pub fn from_hsla(hue: f32, saturation: f32, luminance: f32, alpha: f32) -> Color {
let temp1 = if luminance < 0.5 {
luminance * (saturation + 1.)
} else {
luminance + saturation - luminance * saturation
};
let temp2 = 2. * luminance - temp1;
let mut red = (hue + 1. / 3.).rem_euclid(1.);
let mut green = hue.rem_euclid(1.);
let mut blue = (hue - 1. / 3.).rem_euclid(1.);
for channel in [&mut red, &mut green, &mut blue] {
*channel = if *channel * 6. < 1. {
temp2 + (temp1 - temp2) * 6. * *channel
} else if *channel * 2. < 1. {
temp1
} else if *channel * 3. < 2. {
temp2 + (temp1 - temp2) * (2. / 3. - *channel) * 6.
} else {
temp2
}
.clamp(0., 1.);
}
Color { red, green, blue, alpha }
}
/// Return the `red` component.
///
/// # Examples
@ -154,6 +189,38 @@ impl Color {
[(self.red * 255.) as u8, (self.green * 255.) as u8, (self.blue * 255.) as u8, (self.alpha * 255.) as u8]
}
// https://www.niwa.nu/2013/05/math-behind-colorspace-conversions-rgb-hsl/
/// Convert a [Color] to a hue, saturation, luminance and alpha (all between 0 and 1)
///
/// # Examples
/// ```
/// use graphene_core::raster::color::Color;
/// let color = Color::from_hsla(0.5, 0.2, 0.3, 1.).to_hsla();
/// ```
pub fn to_hsla(&self) -> [f32; 4] {
let min_channel = self.red.min(self.green).min(self.blue);
let max_channel = self.red.max(self.green).max(self.blue);
let luminance = (min_channel + max_channel) / 2.;
let saturation = if min_channel == max_channel {
0.
} else if luminance <= 0.5 {
(max_channel - min_channel) / (max_channel + min_channel)
} else {
(max_channel - min_channel) / (2. - max_channel - min_channel)
};
let hue = if self.red > self.green && self.red > self.blue {
(self.green - self.blue) / (max_channel - min_channel)
} else if self.green > self.red && self.green > self.blue {
2. + (self.blue - self.red) / (max_channel - min_channel)
} else {
4. + (self.red - self.green) / (max_channel - min_channel)
} / 6.;
let hue = hue.rem_euclid(1.);
[hue, saturation, luminance, self.alpha]
}
// TODO: Readd formatting
/// Creates a color from a 8-character RGBA hex string (without a # prefix).
@ -191,3 +258,37 @@ impl Color {
Some(Color::from_rgb8(r, g, b))
}
}
#[test]
fn hsl_roundtrip() {
for (red, green, blue) in [
(24, 98, 118),
(69, 11, 89),
(54, 82, 38),
(47, 76, 50),
(25, 15, 73),
(62, 57, 33),
(55, 2, 18),
(12, 3, 82),
(91, 16, 98),
(91, 39, 82),
(97, 53, 32),
(76, 8, 91),
(54, 87, 19),
(56, 24, 88),
(14, 82, 34),
(61, 86, 31),
(73, 60, 75),
(95, 79, 88),
(13, 34, 4),
(82, 84, 84),
] {
let col = Color::from_rgb8(red, green, blue);
let [hue, saturation, luminance, alpha] = col.to_hsla();
let result = Color::from_hsla(hue, saturation, luminance, alpha);
assert!((col.r() - result.r()) < f32::EPSILON * 100.);
assert!((col.g() - result.g()) < f32::EPSILON * 100.);
assert!((col.b() - result.b()) < f32::EPSILON * 100.);
assert!((col.a() - result.a()) < f32::EPSILON * 100.);
}
}

View file

@ -14,6 +14,7 @@ num-traits = "0.2"
borrow_stack = { path = "../borrow_stack" }
dyn-clone = "1.0"
rand_chacha = "0.3.1"
log = "0.4"
[dependencies.serde]
version = "1.0"

View file

@ -62,7 +62,12 @@ impl DocumentNode {
NodeInput::Network => (ProtoNodeInput::Network, ConstructionArgs::Nodes(vec![])),
};
assert!(!self.inputs.iter().any(|input| matches!(input, NodeInput::Network)), "recieved non resolved parameter");
assert!(!self.inputs.iter().any(|input| matches!(input, NodeInput::Value(_))), "recieved value as parameter");
assert!(
!self.inputs.iter().any(|input| matches!(input, NodeInput::Value(_))),
"recieved value as parameter. inupts: {:#?}, construction_args: {:#?}",
&self.inputs,
&args
);
if let ConstructionArgs::Nodes(nodes) = &mut args {
nodes.extend(self.inputs.iter().map(|input| match input {
@ -143,7 +148,10 @@ impl NodeNetwork {
/// Recursively dissolve non primitive document nodes and return a single flattened network of nodes.
pub fn flatten_with_fns(&mut self, node: NodeId, map_ids: impl Fn(NodeId, NodeId) -> NodeId + Copy, gen_id: impl Fn() -> NodeId + Copy) {
let (id, mut node) = self.nodes.remove_entry(&node).expect("The node which was supposed to be flattened does not exist in the network");
let (id, mut node) = self
.nodes
.remove_entry(&node)
.unwrap_or_else(|| panic!("The node which was supposed to be flattened does not exist in the network, id {} network {:#?}", node, self));
match node.implementation {
DocumentNodeImplementation::Network(mut inner_network) => {

View file

@ -10,6 +10,7 @@ use dyn_any::{DynAny, Upcast};
pub enum TaggedValue {
String(String),
U32(u32),
F32(f32),
//Image(graphene_std::raster::Image),
Color(graphene_core::raster::color::Color),
}
@ -19,6 +20,7 @@ impl TaggedValue {
match self {
TaggedValue::String(x) => Box::new(x),
TaggedValue::U32(x) => Box::new(x),
TaggedValue::F32(x) => Box::new(x),
TaggedValue::Color(x) => Box::new(x),
}
}

View file

@ -1,3 +1,6 @@
#[macro_use]
extern crate log;
pub mod node_registry;
pub mod document;

View file

@ -1,3 +1,5 @@
use std::borrow::Cow;
use borrow_stack::FixedSizeStack;
use graphene_core::generic::FnNode;
use graphene_core::ops::AddNode;
@ -200,6 +202,44 @@ static NODE_REGISTRY: &[(NodeIdentifier, NodeConstructor)] = &[
}
})
}),
(
NodeIdentifier::new("graphene_core::raster::BrightenColorNode", &[Type::Concrete(Cow::Borrowed("&TypeErasedNode"))]),
|proto_node, stack| {
info!("proto node {:?}", proto_node);
stack.push_fn(|nodes| {
let ConstructionArgs::Nodes(construction_nodes) = proto_node.construction_args else { unreachable!("Brighten Color Node constructed with out brightness input node") };
let value_node = nodes.get(construction_nodes[0] as usize).unwrap();
let input_node: DowncastBothNode<_, (), f32> = DowncastBothNode::new(value_node);
let node = DynAnyNode::new(graphene_core::raster::BrightenColorNode::new(input_node));
if let ProtoNodeInput::Node(pre_id) = proto_node.input {
let pre_node = nodes.get(pre_id as usize).unwrap();
(pre_node).then(node).into_type_erased()
} else {
node.into_type_erased()
}
})
},
),
(
NodeIdentifier::new("graphene_core::raster::HueShiftNode", &[Type::Concrete(Cow::Borrowed("&TypeErasedNode"))]),
|proto_node, stack| {
info!("proto node {:?}", proto_node);
stack.push_fn(|nodes| {
let ConstructionArgs::Nodes(construction_nodes) = proto_node.construction_args else { unreachable!("Hue Shift Color Node constructed with out shift input node") };
let value_node = nodes.get(construction_nodes[0] as usize).unwrap();
let input_node: DowncastBothNode<_, (), f32> = DowncastBothNode::new(value_node);
let node = DynAnyNode::new(graphene_core::raster::HueShiftNode::new(input_node));
if let ProtoNodeInput::Node(pre_id) = proto_node.input {
let pre_node = nodes.get(pre_id as usize).unwrap();
(pre_node).then(node).into_type_erased()
} else {
node.into_type_erased()
}
})
},
),
(NodeIdentifier::new("graphene_std::raster::MapImageNode", &[]), |proto_node, stack| {
if let ConstructionArgs::Nodes(operation_node_id) = proto_node.construction_args {
stack.push_fn(move |nodes| {
@ -218,6 +258,45 @@ static NODE_REGISTRY: &[(NodeIdentifier, NodeConstructor)] = &[
unimplemented!()
}
}),
(NodeIdentifier::new("graph_craft::node_registry::GrayscaleImage", &[]), |proto_node, stack| {
stack.push_fn(move |nodes| {
let grayscale_node = DynAnyNode::new(FnNode::new(|mut image: Image| {
for pixel in &mut image.data {
let avg = (pixel.r() + pixel.g() + pixel.b()) / 3.;
*pixel = Color::from_rgbaf32_unchecked(avg, avg, avg, pixel.a());
}
image
}));
if let ProtoNodeInput::Node(node_id) = proto_node.input {
let pre_node = nodes.get(node_id as usize).unwrap();
(pre_node).then(grayscale_node).into_type_erased()
} else {
grayscale_node.into_type_erased()
}
})
}),
(NodeIdentifier::new("graph_craft::node_registry::HueShiftImage", &[]), |_proto_node, _stack| {
todo!();
// stack.push_fn(move |nodes| {
// let hue_shift_node = DynAnyNode::new(FnNode::new(|(mut image, amount): (Image, f32)| {
// for pixel in &mut image.data {
// let [mut hue, saturation, luminance, alpha] = pixel.to_hsla();
// hue += amount;
// *pixel = Color::from_hsla(hue, saturation, luminance, alpha);
// }
// image
// }));
// if let ProtoNodeInput::Node(node_id) = proto_node.input {
// let pre_node = nodes.get(node_id as usize).unwrap();
// (pre_node).then(hue_shift_node).into_type_erased()
// } else {
// hue_shift_node.into_type_erased()
// }
// })
}),
(
NodeIdentifier::new("graphene_std::raster::ImageNode", &[Concrete(std::borrow::Cow::Borrowed("&str"))]),
|_proto_node, stack| {

View file

@ -121,14 +121,26 @@ impl ProtoNode {
}
impl ProtoNetwork {
fn check_ref(&self, ref_id: &NodeId, id: &NodeId) {
assert!(
self.nodes.iter().any(|(check_id, _)| check_id == ref_id),
"Node id:{} has a reference which uses node id:{} which doesn't exist in network {:#?}",
id,
ref_id,
self
);
}
pub fn collect_outwards_edges(&self) -> HashMap<NodeId, Vec<NodeId>> {
let mut edges: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
for (id, node) in &self.nodes {
if let ProtoNodeInput::Node(ref_id) = &node.input {
self.check_ref(ref_id, id);
edges.entry(*ref_id).or_default().push(*id)
}
if let ConstructionArgs::Nodes(ref_nodes) = &node.construction_args {
for ref_id in ref_nodes {
self.check_ref(ref_id, id);
edges.entry(*ref_id).or_default().push(*id)
}
}
@ -140,10 +152,12 @@ impl ProtoNetwork {
let mut edges: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
for (id, node) in &self.nodes {
if let ProtoNodeInput::Node(ref_id) = &node.input {
self.check_ref(ref_id, id);
edges.entry(*id).or_default().push(*ref_id)
}
if let ConstructionArgs::Nodes(ref_nodes) = &node.construction_args {
for ref_id in ref_nodes {
self.check_ref(ref_id, id);
edges.entry(*id).or_default().push(*ref_id)
}
}