From 92dffbaa4e81e9c525aad3a3e5af3678289adcf7 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Thu, 19 Feb 2026 10:16:05 -0500 Subject: [PATCH] Add UI to script node --- lightningbeam-ui/beamdsp/src/ast.rs | 1 + lightningbeam-ui/beamdsp/src/codegen.rs | 177 +++++++- lightningbeam-ui/beamdsp/src/lexer.rs | 22 + lightningbeam-ui/beamdsp/src/lib.rs | 6 +- lightningbeam-ui/beamdsp/src/opcodes.rs | 26 ++ lightningbeam-ui/beamdsp/src/parser.rs | 6 + lightningbeam-ui/beamdsp/src/token.rs | 4 + lightningbeam-ui/beamdsp/src/vm.rs | 397 ++++++++++++++++++ .../src/panes/node_graph/graph_data.rs | 153 ++++++- .../src/panes/node_graph/mod.rs | 33 ++ .../src/panes/shader_editor.rs | 5 +- 11 files changed, 819 insertions(+), 11 deletions(-) diff --git a/lightningbeam-ui/beamdsp/src/ast.rs b/lightningbeam-ui/beamdsp/src/ast.rs index 42afe2e..de3a76f 100644 --- a/lightningbeam-ui/beamdsp/src/ast.rs +++ b/lightningbeam-ui/beamdsp/src/ast.rs @@ -12,6 +12,7 @@ pub struct Script { pub state: Vec, pub ui: Option>, pub process: Block, + pub draw: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/lightningbeam-ui/beamdsp/src/codegen.rs b/lightningbeam-ui/beamdsp/src/codegen.rs index b51aca4..602e3a1 100644 --- a/lightningbeam-ui/beamdsp/src/codegen.rs +++ b/lightningbeam-ui/beamdsp/src/codegen.rs @@ -37,6 +37,7 @@ struct Compiler { vars: Vec<(String, VarLoc)>, next_local: u16, scope_stack: Vec, // local count at scope entry + draw_context: bool, // true when compiling a draw {} block } impl Compiler { @@ -48,6 +49,7 @@ impl Compiler { vars: Vec::new(), next_local: 0, scope_stack: Vec::new(), + draw_context: false, } } @@ -210,6 +212,55 @@ impl Compiler { Ok(()) } + /// Compile the draw block into separate bytecode (for the DrawVM) + fn compile_draw(&mut self, script: &Script) -> Result<(), CompileError> { + self.draw_context = true; + + // Register params (same as process) + for (i, param) in script.params.iter().enumerate() { + self.vars.push((param.name.clone(), VarLoc::Param(i as u16))); + } + + // Register state variables (draw gets its own copy) + let mut scalar_idx: u16 = 0; + let mut array_idx: u16 = 0; + for state in &script.state { + match &state.ty { + StateType::F32 => { + self.vars.push((state.name.clone(), VarLoc::StateScalar(scalar_idx, VType::F32))); + scalar_idx += 1; + } + StateType::Int => { + self.vars.push((state.name.clone(), VarLoc::StateScalar(scalar_idx, VType::Int))); + scalar_idx += 1; + } + StateType::Bool => { + self.vars.push((state.name.clone(), VarLoc::StateScalar(scalar_idx, VType::Bool))); + scalar_idx += 1; + } + StateType::ArrayF32(_) => { + self.vars.push((state.name.clone(), VarLoc::StateArray(array_idx, VType::F32))); + array_idx += 1; + } + StateType::ArrayInt(_) => { + self.vars.push((state.name.clone(), VarLoc::StateArray(array_idx, VType::Int))); + array_idx += 1; + } + StateType::Sample => {} // no samples in draw context + } + } + + // Compile draw block + if let Some(draw) = &script.draw { + for stmt in draw { + self.compile_stmt(stmt)?; + } + } + + self.emit(OpCode::Halt); + Ok(()) + } + fn compile_stmt(&mut self, stmt: &Stmt) -> Result<(), CompileError> { match stmt { Stmt::Let { name, init, .. } => { @@ -235,6 +286,10 @@ impl Compiler { self.emit(OpCode::StoreState); self.emit_u16(idx); } + VarLoc::Param(idx) if self.draw_context => { + self.emit(OpCode::StoreParam); + self.emit_u16(idx); + } _ => { return Err(CompileError::new( format!("Cannot assign to {}", name), *span, @@ -336,8 +391,11 @@ impl Compiler { self.pop_scope(); } Stmt::ExprStmt(expr) => { + let is_void = self.is_void_call(expr); self.compile_expr(expr)?; - self.emit(OpCode::Pop); + if !is_void { + self.emit(OpCode::Pop); + } } } Ok(()) @@ -548,6 +606,18 @@ impl Compiler { Ok(()) } + /// Returns true if the expression is a call to a void function (no return value). + fn is_void_call(&self, expr: &Expr) -> bool { + if let Expr::Call(name, _, _) = expr { + matches!(name.as_str(), + "fill_circle" | "stroke_circle" | "stroke_arc" | + "line" | "fill_rect" | "stroke_rect" + ) + } else { + false + } + } + fn compile_call(&mut self, name: &str, args: &[Expr], span: Span) -> Result<(), CompileError> { match name { // 1-arg math → push arg, emit opcode @@ -717,6 +787,48 @@ impl Compiler { } } + // Draw builtins (only valid in draw context) + "fill_circle" | "stroke_circle" | "stroke_arc" | "line" | + "fill_rect" | "stroke_rect" | + "mouse_x" | "mouse_y" | "mouse_down" if self.draw_context => { + match name { + "fill_circle" => { + // fill_circle(cx, cy, r, color) + for arg in args { self.compile_expr(arg)?; } + self.emit(OpCode::DrawFillCircle); + } + "stroke_circle" => { + // stroke_circle(cx, cy, r, color, width) + for arg in args { self.compile_expr(arg)?; } + self.emit(OpCode::DrawStrokeCircle); + } + "stroke_arc" => { + // stroke_arc(cx, cy, r, start_deg, end_deg, color, width) + for arg in args { self.compile_expr(arg)?; } + self.emit(OpCode::DrawStrokeArc); + } + "line" => { + // line(x1, y1, x2, y2, color, width) + for arg in args { self.compile_expr(arg)?; } + self.emit(OpCode::DrawLine); + } + "fill_rect" => { + // fill_rect(x, y, w, h, color) + for arg in args { self.compile_expr(arg)?; } + self.emit(OpCode::DrawFillRect); + } + "stroke_rect" => { + // stroke_rect(x, y, w, h, color, width) + for arg in args { self.compile_expr(arg)?; } + self.emit(OpCode::DrawStrokeRect); + } + "mouse_x" => { self.emit(OpCode::MouseX); } + "mouse_y" => { self.emit(OpCode::MouseY); } + "mouse_down" => { self.emit(OpCode::MouseDown); } + _ => unreachable!(), + } + } + _ => { return Err(CompileError::new(format!("Unknown function: {}", name), span)); } @@ -793,7 +905,7 @@ impl Compiler { } /// Compile a validated AST into bytecode VM and UI declaration -pub fn compile(script: &Script) -> Result<(ScriptVM, UiDeclaration), CompileError> { +pub fn compile(script: &Script) -> Result<(ScriptVM, UiDeclaration, Option), CompileError> { let mut compiler = Compiler::new(); compiler.compile_script(script)?; @@ -843,7 +955,24 @@ pub fn compile(script: &Script) -> Result<(ScriptVM, UiDeclaration), CompileErro UiDeclaration { elements } }; - Ok((vm, ui_decl)) + // Compile draw block if present + let draw_vm = if script.draw.is_some() { + let mut draw_compiler = Compiler::new(); + draw_compiler.compile_draw(script)?; + Some(crate::vm::DrawVM::new( + draw_compiler.code, + draw_compiler.constants_f32, + draw_compiler.constants_i32, + script.params.len(), + ¶m_defaults, + num_state_scalars, + &state_array_sizes, + )) + } else { + None + }; + + Ok((vm, ui_decl, draw_vm)) } #[cfg(test)] @@ -859,7 +988,8 @@ mod tests { let mut parser = Parser::new(&tokens); let script = parser.parse()?; let validated = validator::validate(&script)?; - compile(validated) + let (vm, ui, _draw_vm) = compile(validated)?; + Ok((vm, ui)) } #[test] @@ -988,4 +1118,43 @@ mod tests { assert!(matches!(&ui.elements[0], UiElement::Sample(n) if n == "clip")); assert!(matches!(&ui.elements[1], UiElement::Param(n) if n == "gain")); } + + #[test] + fn test_draw_block() { + let src = r#" + name "Knob" + category utility + params { volume: 0.75 [0.0, 1.0] "" } + outputs { out: audio } + ui { canvas [80, 80] } + draw { + let cx = 40.0; + let cy = 40.0; + fill_circle(cx, cy, 35.0, 0x333333FF); + let angle = volume * 270.0 - 135.0; + stroke_arc(cx, cy, 30.0, -135.0, angle, 0x4488FFFF, 3.0); + } + process { + for i in 0..buffer_size { + out[i] = 0.0; + } + } + "#; + let mut lexer = Lexer::new(src); + let tokens = lexer.tokenize().unwrap(); + let mut parser = Parser::new(&tokens); + let script = parser.parse().unwrap(); + let validated = validator::validate(&script).unwrap(); + let (_vm, _ui, draw_vm) = compile(validated).unwrap(); + + // Draw VM should exist + let mut dvm = draw_vm.expect("draw_vm should be Some"); + assert!(!dvm.bytecode.is_empty()); + + // Execute should succeed without stack errors + dvm.execute().unwrap(); + + // Should have produced draw commands + assert_eq!(dvm.draw_commands.len(), 2); // fill_circle + stroke_arc + } } diff --git a/lightningbeam-ui/beamdsp/src/lexer.rs b/lightningbeam-ui/beamdsp/src/lexer.rs index b5677e8..c67ef09 100644 --- a/lightningbeam-ui/beamdsp/src/lexer.rs +++ b/lightningbeam-ui/beamdsp/src/lexer.rs @@ -172,6 +172,28 @@ impl<'a> Lexer<'a> { } fn read_number(&mut self, first: u8, span: Span) -> Result { + // Check for hex literal: 0x... + if first == b'0' && self.peek() == Some(b'x') { + self.advance(); // skip 'x' + let mut hex = String::new(); + while let Some(ch) = self.peek() { + if ch.is_ascii_hexdigit() { + hex.push(self.advance() as char); + } else { + break; + } + } + if hex.is_empty() { + return Err(CompileError::new("Expected hex digits after 0x", span)); + } + let val = u32::from_str_radix(&hex, 16) + .map_err(|_| CompileError::new(format!("Invalid hex literal: 0x{}", hex), span))?; + return Ok(Token { + kind: TokenKind::IntLit(val as i32), + span, + }); + } + let mut s = String::new(); s.push(first as char); let mut is_float = false; diff --git a/lightningbeam-ui/beamdsp/src/lib.rs b/lightningbeam-ui/beamdsp/src/lib.rs index 9b7fd4b..916f58f 100644 --- a/lightningbeam-ui/beamdsp/src/lib.rs +++ b/lightningbeam-ui/beamdsp/src/lib.rs @@ -15,7 +15,7 @@ use parser::Parser; pub use error::ScriptError; pub use ui_decl::{UiDeclaration, UiElement}; -pub use vm::{ScriptVM, SampleSlot}; +pub use vm::{ScriptVM, SampleSlot, DrawVM, DrawCommand, MouseState}; /// Compiled script metadata — everything needed to create a ScriptNode pub struct CompiledScript { @@ -28,6 +28,7 @@ pub struct CompiledScript { pub sample_slots: Vec, pub ui_declaration: UiDeclaration, pub source: String, + pub draw_vm: Option, } #[derive(Debug, Clone)] @@ -55,7 +56,7 @@ pub fn compile(source: &str) -> Result { let validated = validator::validate(&script)?; - let (vm, ui_decl) = codegen::compile(&validated)?; + let (vm, ui_decl, draw_vm) = codegen::compile(&validated)?; let input_ports = script .inputs @@ -104,5 +105,6 @@ pub fn compile(source: &str) -> Result { sample_slots, ui_declaration: ui_decl, source: source.to_string(), + draw_vm, }) } diff --git a/lightningbeam-ui/beamdsp/src/opcodes.rs b/lightningbeam-ui/beamdsp/src/opcodes.rs index 73041b7..ec7a59c 100644 --- a/lightningbeam-ui/beamdsp/src/opcodes.rs +++ b/lightningbeam-ui/beamdsp/src/opcodes.rs @@ -108,6 +108,22 @@ pub enum OpCode { LoadSampleRate = 140, LoadBufferSize = 141, + // Draw commands (pop args from stack, push to draw command buffer) + DrawFillCircle = 150, // pops: color(i32), r, cy, cx + DrawStrokeCircle = 151, // pops: width, color(i32), r, cy, cx + DrawStrokeArc = 152, // pops: width, color(i32), end_deg, start_deg, r, cy, cx + DrawLine = 153, // pops: width, color(i32), y2, x2, y1, x1 + DrawFillRect = 154, // pops: color(i32), h, w, y, x + DrawStrokeRect = 155, // pops: width, color(i32), h, w, y, x + + // Mouse input (push onto stack) + MouseX = 160, // pushes canvas-relative X as f32 + MouseY = 161, // pushes canvas-relative Y as f32 + MouseDown = 162, // pushes 1.0 if pressed, 0.0 if not + + // Param write (draw context only) + StoreParam = 170, // u16 param index, pops value from stack + Halt = 255, } @@ -190,6 +206,16 @@ impl OpCode { 130 => Some(OpCode::ArrayLen), 140 => Some(OpCode::LoadSampleRate), 141 => Some(OpCode::LoadBufferSize), + 150 => Some(OpCode::DrawFillCircle), + 151 => Some(OpCode::DrawStrokeCircle), + 152 => Some(OpCode::DrawStrokeArc), + 153 => Some(OpCode::DrawLine), + 154 => Some(OpCode::DrawFillRect), + 155 => Some(OpCode::DrawStrokeRect), + 160 => Some(OpCode::MouseX), + 161 => Some(OpCode::MouseY), + 162 => Some(OpCode::MouseDown), + 170 => Some(OpCode::StoreParam), 255 => Some(OpCode::Halt), _ => None, } diff --git a/lightningbeam-ui/beamdsp/src/parser.rs b/lightningbeam-ui/beamdsp/src/parser.rs index 22d39f2..84e6c1a 100644 --- a/lightningbeam-ui/beamdsp/src/parser.rs +++ b/lightningbeam-ui/beamdsp/src/parser.rs @@ -86,6 +86,7 @@ impl<'a> Parser<'a> { let mut state = Vec::new(); let mut ui = None; let mut process = Vec::new(); + let mut draw = None; while *self.peek() != TokenKind::Eof { match self.peek() { @@ -131,6 +132,10 @@ impl<'a> Parser<'a> { self.advance(); process = self.parse_block()?; } + TokenKind::Draw => { + self.advance(); + draw = Some(self.parse_block()?); + } _ => { return Err(CompileError::new( format!("Unexpected token {:?} at top level", self.peek()), @@ -156,6 +161,7 @@ impl<'a> Parser<'a> { state, ui, process, + draw, }) } diff --git a/lightningbeam-ui/beamdsp/src/token.rs b/lightningbeam-ui/beamdsp/src/token.rs index 222528a..660ee58 100644 --- a/lightningbeam-ui/beamdsp/src/token.rs +++ b/lightningbeam-ui/beamdsp/src/token.rs @@ -58,6 +58,9 @@ pub enum TokenKind { Canvas, Spacer, + // Draw block + Draw, + // Literals FloatLit(f32), IntLit(i32), @@ -133,6 +136,7 @@ impl TokenKind { "param" => TokenKind::Param, "canvas" => TokenKind::Canvas, "spacer" => TokenKind::Spacer, + "draw" => TokenKind::Draw, "true" => TokenKind::True, "false" => TokenKind::False, _ => TokenKind::Ident(s.to_string()), diff --git a/lightningbeam-ui/beamdsp/src/vm.rs b/lightningbeam-ui/beamdsp/src/vm.rs index 7e7058e..925345c 100644 --- a/lightningbeam-ui/beamdsp/src/vm.rs +++ b/lightningbeam-ui/beamdsp/src/vm.rs @@ -375,6 +375,15 @@ impl ScriptVM { self.push_i(buffer_size as i32)?; } + // Draw/mouse opcodes are not valid in the audio ScriptVM + Some(OpCode::DrawFillCircle) | Some(OpCode::DrawStrokeCircle) | + Some(OpCode::DrawStrokeArc) | Some(OpCode::DrawLine) | + Some(OpCode::DrawFillRect) | Some(OpCode::DrawStrokeRect) | + Some(OpCode::MouseX) | Some(OpCode::MouseY) | Some(OpCode::MouseDown) | + Some(OpCode::StoreParam) => { + return Err(ScriptError::InvalidOpcode(op)); + } + None => return Err(ScriptError::InvalidOpcode(op)), } } @@ -449,3 +458,391 @@ impl ScriptVM { v } } + +// ---- Draw VM (runs on UI thread, produces draw commands) ---- + +/// A draw command produced by the draw block +#[derive(Debug, Clone)] +pub enum DrawCommand { + FillCircle { cx: f32, cy: f32, r: f32, color: u32 }, + StrokeCircle { cx: f32, cy: f32, r: f32, color: u32, width: f32 }, + StrokeArc { cx: f32, cy: f32, r: f32, start_deg: f32, end_deg: f32, color: u32, width: f32 }, + Line { x1: f32, y1: f32, x2: f32, y2: f32, color: u32, width: f32 }, + FillRect { x: f32, y: f32, w: f32, h: f32, color: u32 }, + StrokeRect { x: f32, y: f32, w: f32, h: f32, color: u32, width: f32 }, +} + +/// Mouse state passed to the draw VM each frame +#[derive(Debug, Clone, Default)] +pub struct MouseState { + pub x: f32, + pub y: f32, + pub down: bool, +} + +/// Lightweight VM for executing draw bytecode on the UI thread +#[derive(Clone)] +pub struct DrawVM { + pub bytecode: Vec, + pub constants_f32: Vec, + pub constants_i32: Vec, + stack: Vec, + sp: usize, + locals: Vec, + pub params: Vec, + pub state_scalars: Vec, + pub state_arrays: Vec>, + pub draw_commands: Vec, + pub mouse: MouseState, + instruction_limit: u64, +} + +impl DrawVM { + pub fn new( + bytecode: Vec, + constants_f32: Vec, + constants_i32: Vec, + num_params: usize, + param_defaults: &[f32], + num_state_scalars: usize, + state_array_sizes: &[usize], + ) -> Self { + let mut params = vec![0.0f32; num_params]; + for (i, &d) in param_defaults.iter().enumerate() { + if i < params.len() { + params[i] = d; + } + } + Self { + bytecode, + constants_f32, + constants_i32, + stack: vec![Value::default(); STACK_SIZE], + sp: 0, + locals: vec![Value::default(); MAX_LOCALS], + params, + state_scalars: vec![Value::default(); num_state_scalars], + state_arrays: state_array_sizes.iter().map(|&sz| vec![0.0f32; sz]).collect(), + draw_commands: Vec::new(), + mouse: MouseState::default(), + instruction_limit: 1_000_000, // lower limit for draw (runs per frame) + } + } + + /// Execute the draw bytecode. Call once per frame. + /// Draw commands accumulate in `self.draw_commands` (cleared at start). + pub fn execute(&mut self) -> Result<(), ScriptError> { + self.sp = 0; + self.draw_commands.clear(); + for l in &mut self.locals { + *l = Value::default(); + } + + let mut pc: usize = 0; + let mut ic: u64 = 0; + let limit = self.instruction_limit; + + while pc < self.bytecode.len() { + ic += 1; + if ic > limit { + return Err(ScriptError::ExecutionLimitExceeded); + } + + let op = self.bytecode[pc]; + pc += 1; + + match OpCode::from_u8(op) { + Some(OpCode::Halt) => return Ok(()), + + Some(OpCode::PushF32) => { + let idx = self.read_u16(&mut pc) as usize; + self.push_f(self.constants_f32[idx])?; + } + Some(OpCode::PushI32) => { + let idx = self.read_u16(&mut pc) as usize; + self.push_i(self.constants_i32[idx])?; + } + Some(OpCode::PushBool) => { + let v = self.bytecode[pc]; + pc += 1; + self.push_b(v != 0)?; + } + Some(OpCode::Pop) => { self.pop()?; } + + Some(OpCode::LoadLocal) => { + let idx = self.read_u16(&mut pc) as usize; + self.push(self.locals[idx])?; + } + Some(OpCode::StoreLocal) => { + let idx = self.read_u16(&mut pc) as usize; + self.locals[idx] = self.pop()?; + } + Some(OpCode::LoadParam) => { + let idx = self.read_u16(&mut pc) as usize; + self.push_f(self.params[idx])?; + } + Some(OpCode::LoadState) => { + let idx = self.read_u16(&mut pc) as usize; + self.push(self.state_scalars[idx])?; + } + Some(OpCode::StoreState) => { + let idx = self.read_u16(&mut pc) as usize; + self.state_scalars[idx] = self.pop()?; + } + Some(OpCode::LoadStateArray) => { + let arr_id = self.read_u16(&mut pc) as usize; + let idx = unsafe { self.pop()?.i }; + let val = if arr_id < self.state_arrays.len() { + let arr_len = self.state_arrays[arr_id].len(); + let idx = ((idx % arr_len as i32) + arr_len as i32) as usize % arr_len; + self.state_arrays[arr_id][idx] + } else { + 0.0 + }; + self.push_f(val)?; + } + Some(OpCode::StoreStateArray) => { + let arr_id = self.read_u16(&mut pc) as usize; + let val = unsafe { self.pop()?.f }; + let idx = unsafe { self.pop()?.i }; + if arr_id < self.state_arrays.len() { + let arr_len = self.state_arrays[arr_id].len(); + let idx = ((idx % arr_len as i32) + arr_len as i32) as usize % arr_len; + self.state_arrays[arr_id][idx] = val; + } + } + Some(OpCode::ArrayLen) => { + let arr_id = self.read_u16(&mut pc) as usize; + let len = if arr_id < self.state_arrays.len() { + self.state_arrays[arr_id].len() as i32 + } else { + 0 + }; + self.push_i(len)?; + } + + // Audio I/O not available in draw context + Some(OpCode::LoadInput) | Some(OpCode::StoreOutput) => { + return Err(ScriptError::InvalidOpcode(op)); + } + Some(OpCode::LoadSampleRate) | Some(OpCode::LoadBufferSize) => { + return Err(ScriptError::InvalidOpcode(op)); + } + // Sample access not available in draw context + Some(OpCode::SampleLen) | Some(OpCode::SampleRead) | Some(OpCode::SampleRateOf) => { + pc += 1; // skip slot byte + self.push_i(0)?; + } + + // Float arithmetic + Some(OpCode::AddF) => { let b = self.pop_f()?; let a = self.pop_f()?; self.push_f(a + b)?; } + Some(OpCode::SubF) => { let b = self.pop_f()?; let a = self.pop_f()?; self.push_f(a - b)?; } + Some(OpCode::MulF) => { let b = self.pop_f()?; let a = self.pop_f()?; self.push_f(a * b)?; } + Some(OpCode::DivF) => { let b = self.pop_f()?; let a = self.pop_f()?; self.push_f(if b.abs() > 1e-30 { a / b } else { 0.0 })?; } + Some(OpCode::ModF) => { let b = self.pop_f()?; let a = self.pop_f()?; self.push_f(if b.abs() > 1e-30 { a % b } else { 0.0 })?; } + Some(OpCode::NegF) => { let v = self.pop_f()?; self.push_f(-v)?; } + + // Int arithmetic + Some(OpCode::AddI) => { let b = self.pop_i()?; let a = self.pop_i()?; self.push_i(a.wrapping_add(b))?; } + Some(OpCode::SubI) => { let b = self.pop_i()?; let a = self.pop_i()?; self.push_i(a.wrapping_sub(b))?; } + Some(OpCode::MulI) => { let b = self.pop_i()?; let a = self.pop_i()?; self.push_i(a.wrapping_mul(b))?; } + Some(OpCode::DivI) => { let b = self.pop_i()?; let a = self.pop_i()?; self.push_i(if b != 0 { a / b } else { 0 })?; } + Some(OpCode::ModI) => { let b = self.pop_i()?; let a = self.pop_i()?; self.push_i(if b != 0 { a % b } else { 0 })?; } + Some(OpCode::NegI) => { let v = self.pop_i()?; self.push_i(-v)?; } + + // Float comparison + Some(OpCode::EqF) => { let b = self.pop_f()?; let a = self.pop_f()?; self.push_b(a == b)?; } + Some(OpCode::NeF) => { let b = self.pop_f()?; let a = self.pop_f()?; self.push_b(a != b)?; } + Some(OpCode::LtF) => { let b = self.pop_f()?; let a = self.pop_f()?; self.push_b(a < b)?; } + Some(OpCode::GtF) => { let b = self.pop_f()?; let a = self.pop_f()?; self.push_b(a > b)?; } + Some(OpCode::LeF) => { let b = self.pop_f()?; let a = self.pop_f()?; self.push_b(a <= b)?; } + Some(OpCode::GeF) => { let b = self.pop_f()?; let a = self.pop_f()?; self.push_b(a >= b)?; } + + // Int comparison + Some(OpCode::EqI) => { let b = self.pop_i()?; let a = self.pop_i()?; self.push_b(a == b)?; } + Some(OpCode::NeI) => { let b = self.pop_i()?; let a = self.pop_i()?; self.push_b(a != b)?; } + Some(OpCode::LtI) => { let b = self.pop_i()?; let a = self.pop_i()?; self.push_b(a < b)?; } + Some(OpCode::GtI) => { let b = self.pop_i()?; let a = self.pop_i()?; self.push_b(a > b)?; } + Some(OpCode::LeI) => { let b = self.pop_i()?; let a = self.pop_i()?; self.push_b(a <= b)?; } + Some(OpCode::GeI) => { let b = self.pop_i()?; let a = self.pop_i()?; self.push_b(a >= b)?; } + + // Logical + Some(OpCode::And) => { let b = self.pop_b()?; let a = self.pop_b()?; self.push_b(a && b)?; } + Some(OpCode::Or) => { let b = self.pop_b()?; let a = self.pop_b()?; self.push_b(a || b)?; } + Some(OpCode::Not) => { let v = self.pop_b()?; self.push_b(!v)?; } + + // Casts + Some(OpCode::F32ToI32) => { let v = self.pop_f()?; self.push_i(v as i32)?; } + Some(OpCode::I32ToF32) => { let v = self.pop_i()?; self.push_f(v as f32)?; } + + // Control flow + Some(OpCode::Jump) => { + pc = self.read_u32(&mut pc) as usize; + } + Some(OpCode::JumpIfFalse) => { + let target = self.read_u32(&mut pc) as usize; + let cond = self.pop_b()?; + if !cond { + pc = target; + } + } + + // Math builtins + Some(OpCode::Sin) => { let v = self.pop_f()?; self.push_f(v.sin())?; } + Some(OpCode::Cos) => { let v = self.pop_f()?; self.push_f(v.cos())?; } + Some(OpCode::Tan) => { let v = self.pop_f()?; self.push_f(v.tan())?; } + Some(OpCode::Asin) => { let v = self.pop_f()?; self.push_f(v.asin())?; } + Some(OpCode::Acos) => { let v = self.pop_f()?; self.push_f(v.acos())?; } + Some(OpCode::Atan) => { let v = self.pop_f()?; self.push_f(v.atan())?; } + Some(OpCode::Atan2) => { let x = self.pop_f()?; let y = self.pop_f()?; self.push_f(y.atan2(x))?; } + Some(OpCode::Exp) => { let v = self.pop_f()?; self.push_f(v.exp())?; } + Some(OpCode::Log) => { let v = self.pop_f()?; self.push_f(v.ln())?; } + Some(OpCode::Log2) => { let v = self.pop_f()?; self.push_f(v.log2())?; } + Some(OpCode::Pow) => { let e = self.pop_f()?; let b = self.pop_f()?; self.push_f(b.powf(e))?; } + Some(OpCode::Sqrt) => { let v = self.pop_f()?; self.push_f(v.sqrt())?; } + Some(OpCode::Floor) => { let v = self.pop_f()?; self.push_f(v.floor())?; } + Some(OpCode::Ceil) => { let v = self.pop_f()?; self.push_f(v.ceil())?; } + Some(OpCode::Round) => { let v = self.pop_f()?; self.push_f(v.round())?; } + Some(OpCode::Trunc) => { let v = self.pop_f()?; self.push_f(v.trunc())?; } + Some(OpCode::Fract) => { let v = self.pop_f()?; self.push_f(v.fract())?; } + Some(OpCode::Abs) => { let v = self.pop_f()?; self.push_f(v.abs())?; } + Some(OpCode::Sign) => { let v = self.pop_f()?; self.push_f(v.signum())?; } + Some(OpCode::Clamp) => { + let hi = self.pop_f()?; + let lo = self.pop_f()?; + let v = self.pop_f()?; + self.push_f(v.clamp(lo, hi))?; + } + Some(OpCode::Min) => { let b = self.pop_f()?; let a = self.pop_f()?; self.push_f(a.min(b))?; } + Some(OpCode::Max) => { let b = self.pop_f()?; let a = self.pop_f()?; self.push_f(a.max(b))?; } + Some(OpCode::Mix) => { + let t = self.pop_f()?; + let b = self.pop_f()?; + let a = self.pop_f()?; + self.push_f(a + (b - a) * t)?; + } + Some(OpCode::Smoothstep) => { + let x = self.pop_f()?; + let e1 = self.pop_f()?; + let e0 = self.pop_f()?; + let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0); + self.push_f(t * t * (3.0 - 2.0 * t))?; + } + Some(OpCode::IsNan) => { let v = self.pop_f()?; self.push_b(v.is_nan())?; } + + // Draw commands + Some(OpCode::DrawFillCircle) => { + let color = self.pop_i()? as u32; + let r = self.pop_f()?; + let cy = self.pop_f()?; + let cx = self.pop_f()?; + self.draw_commands.push(DrawCommand::FillCircle { cx, cy, r, color }); + } + Some(OpCode::DrawStrokeCircle) => { + let width = self.pop_f()?; + let color = self.pop_i()? as u32; + let r = self.pop_f()?; + let cy = self.pop_f()?; + let cx = self.pop_f()?; + self.draw_commands.push(DrawCommand::StrokeCircle { cx, cy, r, color, width }); + } + Some(OpCode::DrawStrokeArc) => { + let width = self.pop_f()?; + let color = self.pop_i()? as u32; + let end_deg = self.pop_f()?; + let start_deg = self.pop_f()?; + let r = self.pop_f()?; + let cy = self.pop_f()?; + let cx = self.pop_f()?; + self.draw_commands.push(DrawCommand::StrokeArc { cx, cy, r, start_deg, end_deg, color, width }); + } + Some(OpCode::DrawLine) => { + let width = self.pop_f()?; + let color = self.pop_i()? as u32; + let y2 = self.pop_f()?; + let x2 = self.pop_f()?; + let y1 = self.pop_f()?; + let x1 = self.pop_f()?; + self.draw_commands.push(DrawCommand::Line { x1, y1, x2, y2, color, width }); + } + Some(OpCode::DrawFillRect) => { + let color = self.pop_i()? as u32; + let h = self.pop_f()?; + let w = self.pop_f()?; + let y = self.pop_f()?; + let x = self.pop_f()?; + self.draw_commands.push(DrawCommand::FillRect { x, y, w, h, color }); + } + Some(OpCode::DrawStrokeRect) => { + let width = self.pop_f()?; + let color = self.pop_i()? as u32; + let h = self.pop_f()?; + let w = self.pop_f()?; + let y = self.pop_f()?; + let x = self.pop_f()?; + self.draw_commands.push(DrawCommand::StrokeRect { x, y, w, h, color, width }); + } + + // Mouse input + Some(OpCode::MouseX) => { self.push_f(self.mouse.x)?; } + Some(OpCode::MouseY) => { self.push_f(self.mouse.y)?; } + Some(OpCode::MouseDown) => { self.push_f(if self.mouse.down { 1.0 } else { 0.0 })?; } + + // Param write + Some(OpCode::StoreParam) => { + let idx = self.read_u16(&mut pc) as usize; + let val = self.pop_f()?; + if idx < self.params.len() { + self.params[idx] = val; + } + } + + None => return Err(ScriptError::InvalidOpcode(op)), + } + } + + Ok(()) + } + + // Stack helpers (identical to ScriptVM) + #[inline] + fn push(&mut self, v: Value) -> Result<(), ScriptError> { + if self.sp >= STACK_SIZE { return Err(ScriptError::StackOverflow); } + self.stack[self.sp] = v; + self.sp += 1; + Ok(()) + } + #[inline] + fn push_f(&mut self, v: f32) -> Result<(), ScriptError> { self.push(Value { f: v }) } + #[inline] + fn push_i(&mut self, v: i32) -> Result<(), ScriptError> { self.push(Value { i: v }) } + #[inline] + fn push_b(&mut self, v: bool) -> Result<(), ScriptError> { self.push(Value { b: v }) } + #[inline] + fn pop(&mut self) -> Result { + if self.sp == 0 { return Err(ScriptError::StackUnderflow); } + self.sp -= 1; + Ok(self.stack[self.sp]) + } + #[inline] + fn pop_f(&mut self) -> Result { Ok(unsafe { self.pop()?.f }) } + #[inline] + fn pop_i(&mut self) -> Result { Ok(unsafe { self.pop()?.i }) } + #[inline] + fn pop_b(&mut self) -> Result { Ok(unsafe { self.pop()?.b }) } + #[inline] + fn read_u16(&self, pc: &mut usize) -> u16 { + let v = u16::from_le_bytes([self.bytecode[*pc], self.bytecode[*pc + 1]]); + *pc += 2; + v + } + #[inline] + fn read_u32(&self, pc: &mut usize) -> u32 { + let v = u32::from_le_bytes([ + self.bytecode[*pc], self.bytecode[*pc + 1], + self.bytecode[*pc + 2], self.bytecode[*pc + 3], + ]); + *pc += 4; + v + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/graph_data.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/graph_data.rs index 19bc42e..36559aa 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/graph_data.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/graph_data.rs @@ -241,6 +241,10 @@ pub struct GraphState { pub pending_load_script_file: Option, /// Pending script sample load request from bottom_ui sample picker pub pending_script_sample_load: Option, + /// Draw VMs for canvas rendering, keyed by node ID + pub draw_vms: HashMap, + /// Pending param changes from draw block (node_id, param_index, new_value) + pub pending_draw_param_changes: Vec<(NodeId, u32, f32)>, } impl Default for GraphState { @@ -261,6 +265,8 @@ impl Default for GraphState { pending_new_script: None, pending_load_script_file: None, pending_script_sample_load: None, + draw_vms: HashMap::new(), + pending_draw_param_changes: Vec::new(), } } } @@ -1362,7 +1368,21 @@ impl NodeDataTrait for NodeData { egui::Popup::close_id(ui.ctx(), popup_id); } - // Render declarative UI elements (sample pickers, groups) + // Sync param values from node input ports to draw VM + if let Some(draw_vm) = user_state.draw_vms.get_mut(&node_id) { + if let Some(node) = _graph.nodes.get(node_id) { + for (_name, input_id) in &node.inputs { + if let ValueType::Float { value, backend_param_id: Some(pid), .. } = &_graph.get_input(*input_id).value { + let idx = *pid as usize; + if idx < draw_vm.params.len() { + draw_vm.params[idx] = *value; + } + } + } + } + } + + // Render declarative UI elements (sample pickers, groups, canvas) if let Some(ref ui_decl) = self.ui_declaration { let backend_node_id = user_state.node_backend_ids.get(&node_id).copied().unwrap_or(0); render_script_ui_elements( @@ -1373,6 +1393,8 @@ impl NodeDataTrait for NodeData { &user_state.available_clips, &mut user_state.sampler_search_text, &mut user_state.pending_script_sample_load, + &mut user_state.draw_vms, + &mut user_state.pending_draw_param_changes, ); } } else { @@ -1382,7 +1404,17 @@ impl NodeDataTrait for NodeData { } } -/// Render UiDeclaration elements for Script nodes (sample pickers, groups, spacers) +/// Convert a u32 RGBA color to egui Color32 +fn color_from_u32(c: u32) -> egui::Color32 { + egui::Color32::from_rgba_unmultiplied( + ((c >> 24) & 0xFF) as u8, + ((c >> 16) & 0xFF) as u8, + ((c >> 8) & 0xFF) as u8, + (c & 0xFF) as u8, + ) +} + +/// Render UiDeclaration elements for Script nodes (sample pickers, groups, canvas, spacers) fn render_script_ui_elements( ui: &mut egui::Ui, node_id: NodeId, @@ -1393,9 +1425,121 @@ fn render_script_ui_elements( available_clips: &[SamplerClipInfo], search_text: &mut String, pending_load: &mut Option, + draw_vms: &mut HashMap, + pending_param_changes: &mut Vec<(NodeId, u32, f32)>, ) { for element in elements { match element { + beamdsp::UiElement::Canvas { width, height } => { + let size = egui::vec2(*width, *height); + let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click_and_drag()); + let painter = ui.painter_at(rect); + + // Dark background + painter.rect_filled(rect, 2.0, egui::Color32::from_rgb(0x1a, 0x1a, 0x1a)); + + if let Some(draw_vm) = draw_vms.get_mut(&node_id) { + // Set mouse state + if let Some(pos) = response.hover_pos() { + draw_vm.mouse.x = pos.x - rect.left(); + draw_vm.mouse.y = pos.y - rect.top(); + } + draw_vm.mouse.down = response.dragged() || response.drag_started(); + + // Save params before execution to detect changes + let params_before: Vec = draw_vm.params.clone(); + + // Execute draw block + if let Err(e) = draw_vm.execute() { + painter.text( + rect.center(), egui::Align2::CENTER_CENTER, + &format!("draw error: {}", e), + egui::FontId::monospace(9.0), egui::Color32::RED, + ); + } else { + // Render draw commands + for cmd in &draw_vm.draw_commands { + match cmd { + beamdsp::DrawCommand::FillCircle { cx, cy, r, color } => { + painter.circle_filled( + egui::pos2(rect.left() + cx, rect.top() + cy), + *r, color_from_u32(*color), + ); + } + beamdsp::DrawCommand::StrokeCircle { cx, cy, r, color, width } => { + painter.circle_stroke( + egui::pos2(rect.left() + cx, rect.top() + cy), + *r, egui::Stroke::new(*width, color_from_u32(*color)), + ); + } + beamdsp::DrawCommand::StrokeArc { cx, cy, r, start_deg, end_deg, color, width } => { + // Generate arc as polyline + let center = egui::pos2(rect.left() + cx, rect.top() + cy); + let start_rad = start_deg.to_radians(); + let end_rad = end_deg.to_radians(); + let arc_len = (end_rad - start_rad).abs(); + let segments = ((arc_len * *r / 2.0).ceil() as usize).max(8).min(128); + let points: Vec = (0..=segments) + .map(|i| { + let t = i as f32 / segments as f32; + let angle = start_rad + (end_rad - start_rad) * t; + egui::pos2( + center.x + angle.cos() * r, + center.y + angle.sin() * r, + ) + }) + .collect(); + painter.add(egui::Shape::line( + points, + egui::Stroke::new(*width, color_from_u32(*color)), + )); + } + beamdsp::DrawCommand::Line { x1, y1, x2, y2, color, width } => { + painter.line_segment( + [ + egui::pos2(rect.left() + x1, rect.top() + y1), + egui::pos2(rect.left() + x2, rect.top() + y2), + ], + egui::Stroke::new(*width, color_from_u32(*color)), + ); + } + beamdsp::DrawCommand::FillRect { x, y, w, h, color } => { + painter.rect_filled( + egui::Rect::from_min_size( + egui::pos2(rect.left() + x, rect.top() + y), + egui::vec2(*w, *h), + ), + 0.0, color_from_u32(*color), + ); + } + beamdsp::DrawCommand::StrokeRect { x, y, w, h, color, width } => { + painter.rect_stroke( + egui::Rect::from_min_size( + egui::pos2(rect.left() + x, rect.top() + y), + egui::vec2(*w, *h), + ), + 0.0, + egui::Stroke::new(*width, color_from_u32(*color)), + egui::StrokeKind::Outside, + ); + } + } + } + } + + // Detect param changes from draw block (e.g. knob drag) + for (i, (&before, &after)) in params_before.iter().zip(draw_vm.params.iter()).enumerate() { + if (after - before).abs() > 1e-10 { + pending_param_changes.push((node_id, i as u32, after)); + } + } + + // Request repaint while interacting + if draw_vm.mouse.down || response.hovered() { + ui.ctx().request_repaint(); + } + } + } beamdsp::UiElement::Sample(slot_name) => { // Find the slot index by name let slot_index = sample_slot_names.iter().position(|n| n == slot_name); @@ -1454,14 +1598,15 @@ fn render_script_ui_elements( ui, node_id, backend_node_id, children, sample_slot_names, script_sample_names, available_clips, search_text, pending_load, + draw_vms, pending_param_changes, ); }); } beamdsp::UiElement::Spacer(height) => { ui.add_space(*height); } - beamdsp::UiElement::Param(_) | beamdsp::UiElement::Canvas { .. } => { - // Params are handled as inline input ports; Canvas is phase 6 + beamdsp::UiElement::Param(_) => { + // Params are handled as inline input ports } } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs index b4478ac..4c51531 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/node_graph/mod.rs @@ -391,6 +391,12 @@ impl NodeGraphPane { node.user_data.ui_declaration = Some(compiled.ui_declaration.clone()); node.user_data.sample_slot_names = compiled.sample_slots.clone(); } + // Store draw VM in GraphState (needs &mut, can't live on immutable NodeData) + if let Some(draw_vm) = &compiled.draw_vm { + self.user_state.draw_vms.insert(node_id, draw_vm.clone()); + } else { + self.user_state.draw_vms.remove(&node_id); + } } fn handle_graph_response( @@ -2695,6 +2701,33 @@ impl crate::panes::PaneRenderer for NodeGraphPane { } } + // Handle param changes from draw block (canvas knob drag etc.) + if !self.user_state.pending_draw_param_changes.is_empty() { + let changes: Vec<_> = self.user_state.pending_draw_param_changes.drain(..).collect(); + if let Some(backend_track_id) = self.track_id.and_then(|tid| shared.layer_to_track_map.get(&tid).copied()) { + if let Some(controller_arc) = &shared.audio_controller { + let mut controller = controller_arc.lock().unwrap(); + for (node_id, param_id, value) in changes { + // Send to backend + if let Some(backend_id) = self.node_id_map.get(&node_id) { + let BackendNodeId::Audio(node_idx) = backend_id; + controller.graph_set_parameter(backend_track_id, node_idx.index() as u32, param_id, value); + } + // Update frontend graph input port value + if let Some(node) = self.state.graph.nodes.get(node_id) { + for (_name, input_id) in &node.inputs { + if let ValueType::Float { value: ref mut v, backend_param_id: Some(pid), .. } = self.state.graph.inputs[*input_id].value { + if pid == param_id { + *v = value; + } + } + } + } + } + } + } + } + // Resolve Script nodes loaded from preset: find or create ScriptDefinitions // (ports were already rebuilt during load_graph_from_json, this just sets script_id) if !self.pending_script_resolutions.is_empty() { diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/shader_editor.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/shader_editor.rs index 9c60149..aa21332 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/shader_editor.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/shader_editor.rs @@ -105,7 +105,7 @@ fn beamdsp_syntax() -> Syntax { "if", "else", "for", "in", "let", "mut", "generator", "effect", "utility", "audio", "cv", "midi", - "param", "sample", "group", "canvas", "spacer", + "param", "sample", "group", "canvas", "spacer", "draw", ]), types: std::collections::BTreeSet::from([ "f32", "int", "bool", @@ -119,6 +119,9 @@ fn beamdsp_syntax() -> Syntax { "len", "cv_or", "float", "sample_len", "sample_read", "sample_rate_of", "sample_rate", "buffer_size", + "fill_circle", "stroke_circle", "stroke_arc", + "fill_rect", "stroke_rect", "line", + "mouse_x", "mouse_y", "mouse_down", ]), } }