Group nodes

This commit is contained in:
Skyler Lehmkuhl 2026-02-16 03:33:32 -05:00
parent ffe7799b6a
commit 0bd933fd45
11 changed files with 1261 additions and 137 deletions

View File

@ -1426,6 +1426,34 @@ impl Engine {
}
}
Command::GraphSetGroups(track_id, groups) => {
let graph = match self.project.get_track_mut(track_id) {
Some(TrackNode::Midi(track)) => Some(&mut track.instrument_graph),
Some(TrackNode::Audio(track)) => Some(&mut track.effects_graph),
_ => None,
};
if let Some(graph) = graph {
graph.set_frontend_groups(groups);
}
}
Command::GraphSetGroupsInTemplate(track_id, voice_allocator_id, groups) => {
use crate::audio::node_graph::nodes::VoiceAllocatorNode;
let graph = match self.project.get_track_mut(track_id) {
Some(TrackNode::Midi(track)) => Some(&mut track.instrument_graph),
Some(TrackNode::Audio(track)) => Some(&mut track.effects_graph),
_ => None,
};
if let Some(graph) = graph {
let node_idx = NodeIndex::new(voice_allocator_id as usize);
if let Some(graph_node) = graph.get_node_mut(node_idx) {
if let Some(va_node) = graph_node.as_any_mut().downcast_mut::<VoiceAllocatorNode>() {
va_node.template_graph_mut().set_frontend_groups(groups);
}
}
}
}
Command::GraphSavePreset(track_id, preset_path, preset_name, description, tags) => {
let graph = match self.project.get_track(track_id) {
Some(TrackNode::Midi(track)) => Some(&track.instrument_graph),
@ -3029,6 +3057,16 @@ impl EngineController {
let _ = self.command_tx.push(Command::GraphSetOutputNode(track_id, node_id));
}
/// Set frontend-only group definitions on a track's graph
pub fn graph_set_groups(&mut self, track_id: TrackId, groups: Vec<crate::audio::node_graph::preset::SerializedGroup>) {
let _ = self.command_tx.push(Command::GraphSetGroups(track_id, groups));
}
/// Set frontend-only group definitions on a VA template graph
pub fn graph_set_groups_in_template(&mut self, track_id: TrackId, voice_allocator_id: u32, groups: Vec<crate::audio::node_graph::preset::SerializedGroup>) {
let _ = self.command_tx.push(Command::GraphSetGroupsInTemplate(track_id, voice_allocator_id, groups));
}
/// Save the current graph as a preset
pub fn graph_save_preset(&mut self, track_id: TrackId, preset_path: String, preset_name: String, description: String, tags: Vec<String>) {
let _ = self.command_tx.push(Command::GraphSavePreset(track_id, preset_path, preset_name, description, tags));

View File

@ -98,6 +98,9 @@ pub struct AudioGraph {
/// Cached topological sort order (invalidated on graph mutation)
topo_cache: Option<Vec<NodeIndex>>,
/// Frontend-only group definitions (stored opaquely for persistence)
frontend_groups: Vec<crate::audio::node_graph::preset::SerializedGroup>,
}
impl AudioGraph {
@ -117,6 +120,7 @@ impl AudioGraph {
node_positions: std::collections::HashMap::new(),
playback_time: 0.0,
topo_cache: None,
frontend_groups: Vec::new(),
}
}
@ -645,6 +649,10 @@ impl AudioGraph {
self.graph.node_weight(idx).map(|n| &*n.node)
}
pub fn get_node_mut(&mut self, idx: NodeIndex) -> Option<&mut (dyn AudioNode + 'static)> {
self.graph.node_weight_mut(idx).map(|n| &mut *n.node)
}
/// Get oscilloscope data from a specific node
pub fn get_oscilloscope_data(&self, idx: NodeIndex, sample_count: usize) -> Option<Vec<f32>> {
self.get_node(idx).and_then(|node| node.get_oscilloscope_data(sample_count))
@ -729,9 +737,17 @@ impl AudioGraph {
}
}
// Clone frontend groups
new_graph.frontend_groups = self.frontend_groups.clone();
new_graph
}
/// Set frontend-only group definitions (stored opaquely for persistence)
pub fn set_frontend_groups(&mut self, groups: Vec<crate::audio::node_graph::preset::SerializedGroup>) {
self.frontend_groups = groups;
}
/// Serialize the graph to a preset
pub fn to_preset(&self, name: impl Into<String>) -> crate::audio::node_graph::preset::GraphPreset {
use crate::audio::node_graph::preset::{GraphPreset, SerializedConnection, SerializedNode};
@ -897,6 +913,9 @@ impl AudioGraph {
// Output node
preset.output_node = self.output_node.map(|idx| idx.index() as u32);
// Frontend groups (stored opaquely)
preset.groups = self.frontend_groups.clone();
preset
}
@ -1118,6 +1137,9 @@ impl AudioGraph {
}
}
// Restore frontend groups (stored opaquely)
graph.frontend_groups = preset.groups.clone();
Ok(graph)
}
}

View File

@ -6,5 +6,5 @@ pub mod preset;
pub use graph::{Connection, GraphNode, AudioGraph};
pub use node_trait::{AudioNode, cv_input_or_default};
pub use preset::{GraphPreset, PresetMetadata, SerializedConnection, SerializedNode};
pub use preset::{GraphPreset, PresetMetadata, SerializedConnection, SerializedNode, SerializedGroup, SerializedBoundaryConnection};
pub use types::{ConnectionError, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};

View File

@ -67,6 +67,10 @@ pub struct GraphPreset {
/// Which node index is the audio output (None if not set)
pub output_node: Option<u32>,
/// Frontend-only group definitions (backend stores opaquely, does not interpret)
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub groups: Vec<SerializedGroup>,
}
/// Metadata about the preset
@ -121,6 +125,32 @@ pub struct SerializedNode {
pub sample_data: Option<SampleData>,
}
/// Serialized group definition (frontend-only visual grouping, stored opaquely by backend)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SerializedGroup {
pub id: u32,
pub name: String,
pub member_nodes: Vec<u32>,
pub position: (f32, f32),
pub boundary_inputs: Vec<SerializedBoundaryConnection>,
pub boundary_outputs: Vec<SerializedBoundaryConnection>,
/// Parent group ID for nested groups (None = top-level group)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_group_id: Option<u32>,
}
/// Serialized boundary connection for group definitions
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SerializedBoundaryConnection {
pub external_node: u32,
pub external_port: usize,
pub internal_node: u32,
pub internal_port: usize,
pub port_name: String,
/// Signal type as string ("Audio", "Midi", "CV")
pub data_type: String,
}
/// Serialized connection between nodes
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SerializedConnection {
@ -152,6 +182,7 @@ impl GraphPreset {
connections: Vec::new(),
midi_targets: Vec::new(),
output_node: None,
groups: Vec::new(),
}
}

View File

@ -163,6 +163,11 @@ pub enum Command {
/// Set which node is the audio output (track_id, node_index)
GraphSetOutputNode(TrackId, u32),
/// Set frontend-only group definitions on a track's graph (track_id, serialized groups)
GraphSetGroups(TrackId, Vec<crate::audio::node_graph::preset::SerializedGroup>),
/// Set frontend-only group definitions on a VA template graph (track_id, voice_allocator_id, serialized groups)
GraphSetGroupsInTemplate(TrackId, u32, Vec<crate::audio::node_graph::preset::SerializedGroup>),
/// Save current graph as a preset (track_id, preset_path, preset_name, description, tags)
GraphSavePreset(TrackId, String, String, String, Vec<String>),
/// Load a preset into a track's graph (track_id, preset_path)

View File

@ -23,6 +23,10 @@ name = "accesskit"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf203f9d3bd8f29f98833d1fbef628df18f759248a547e7e01cfbf63cda36a99"
dependencies = [
"enumn",
"serde",
]
[[package]]
name = "accesskit_atspi_common"
@ -145,6 +149,7 @@ dependencies = [
"cfg-if",
"getrandom 0.3.4",
"once_cell",
"serde",
"version_check",
"zerocopy",
]
@ -1798,6 +1803,7 @@ checksum = "71ddb8ac7643d1dba1bb02110e804406dd459a838efcb14011ced10556711a8e"
dependencies = [
"bytemuck",
"emath",
"serde",
]
[[package]]
@ -1851,6 +1857,8 @@ dependencies = [
"log",
"nohash-hasher",
"profiling",
"ron",
"serde",
"smallvec",
"unicode-segmentation",
]
@ -1943,9 +1951,9 @@ dependencies = [
[[package]]
name = "egui_node_graph2"
version = "0.7.0"
source = "git+https://github.com/PVDoriginal/egui_node_graph2#a25a90822d8f9c956e729f3907aad98f59fa46bc"
dependencies = [
"egui",
"serde",
"slotmap",
"smallvec",
"thiserror 1.0.69",
@ -1964,6 +1972,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "491bdf728bf25ddd9ad60d4cf1c48588fa82c013a2440b91aa7fc43e34a07c32"
dependencies = [
"bytemuck",
"serde",
]
[[package]]
@ -2022,6 +2031,17 @@ dependencies = [
"syn 2.0.110",
]
[[package]]
name = "enumn"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.110",
]
[[package]]
name = "epaint"
version = "0.33.3"
@ -2038,6 +2058,7 @@ dependencies = [
"nohash-hasher",
"parking_lot",
"profiling",
"serde",
]
[[package]]
@ -3411,6 +3432,7 @@ dependencies = [
name = "lightningbeam-core"
version = "0.1.0"
dependencies = [
"arboard",
"base64 0.21.7",
"bytemuck",
"chrono",
@ -5293,6 +5315,19 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "ron"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db09040cc89e461f1a265139777a2bde7f8d8c67c4936f700c63ce3e2904d468"
dependencies = [
"base64 0.22.1",
"bitflags 2.10.0",
"serde",
"serde_derive",
"unicode-ident",
]
[[package]]
name = "roxmltree"
version = "0.20.0"
@ -5629,6 +5664,7 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbff4acf519f630b3a3ddcfaea6c06b42174d9a44bc70c620e9ed1649d58b82a"
dependencies = [
"serde",
"version_check",
]

View File

@ -80,6 +80,8 @@ pub struct GraphResponse<UserResponse: UserResponseTrait, NodeData: NodeDataTrai
pub cursor_in_editor: bool,
/// Is the mouse currently hovering the node finder?
pub cursor_in_finder: bool,
/// Screen-space rects of all rendered nodes (for hit-testing)
pub node_rects: NodeRects,
}
impl<UserResponse: UserResponseTrait, NodeData: NodeDataTrait> Default
for GraphResponse<UserResponse, NodeData>
@ -89,6 +91,7 @@ impl<UserResponse: UserResponseTrait, NodeData: NodeDataTrait> Default
node_responses: Default::default(),
cursor_in_editor: false,
cursor_in_finder: false,
node_rects: NodeRects::new(),
}
}
}
@ -507,8 +510,8 @@ where
);
self.selected_nodes = node_rects
.into_iter()
.filter_map(|(node_id, rect)| {
.iter()
.filter_map(|(&node_id, &rect)| {
if selection_rect.intersects(rect) {
Some(node_id)
} else {
@ -568,6 +571,7 @@ where
node_responses: delayed_responses,
cursor_in_editor,
cursor_in_finder,
node_rects,
}
}
}

View File

@ -215,4 +215,9 @@ impl GraphBackend for AudioGraphBackend {
Ok(())
}
fn query_template_state(&self, voice_allocator_id: u32) -> Result<String, String> {
let mut controller = self.audio_controller.lock().unwrap();
controller.query_template_state(self.track_id, voice_allocator_id)
}
}

View File

@ -79,6 +79,9 @@ pub trait GraphBackend: Send {
input_node: BackendNodeId,
input_port: usize,
) -> Result<(), String>;
/// Get the state of a VoiceAllocator's template graph as JSON
fn query_template_state(&self, voice_allocator_id: u32) -> Result<String, String>;
}
/// Serializable graph state (for presets and save/load)

View File

@ -863,7 +863,7 @@ impl NodeTemplateIter for AllNodeTemplates {
NodeTemplate::Oscilloscope,
// Advanced
NodeTemplate::VoiceAllocator,
NodeTemplate::Group,
// Note: Group is not in the node finder — groups are created via Ctrl+G selection.
// Note: TemplateInput/TemplateOutput are excluded from the default finder.
// They are added dynamically when editing inside a subgraph.
// Outputs