diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/script/executor.rs b/hcie-iced-app/crates/hcie-iced-gui/src/script/executor.rs new file mode 100644 index 0000000..25da890 --- /dev/null +++ b/hcie-iced-app/crates/hcie-iced-gui/src/script/executor.rs @@ -0,0 +1,306 @@ +//! DSL script executor for the HCIE-Rust ICED GUI. +//! +//! Translates parsed `ScriptAction` values into `hcie_engine_api::Engine` +//! calls. This module handles: +//! +//! - Hex color conversion to `[u8; 4]` RGBA +//! - Blend mode string parsing to `BlendMode` enum +//! - Brush style string parsing to `BrushStyle` enum +//! - Layer lookup by name or index +//! - Stroke drawing with per-point pressure +//! - Raster rectangle fills +//! - Vector shape creation + +use crate::script::parser::ScriptAction; +use hcie_engine_api::Engine; +use hcie_engine_api::{BlendMode, BrushStyle, BrushTip}; + +/// Converts a hex color string to `[r, g, b, a]` byte array. +/// +/// Supports both 6-character (`#RRGGBB`) and 8-character (`#RRGGBBAA`) formats. +/// Defaults alpha to 255 if not specified. +/// +/// # Arguments +/// +/// * `hex` — The hex color string (leading `#` is stripped). +/// +/// # Returns +/// +/// A 4-element array `[r, g, b, a]` with values in `[0, 255]`. +fn hex_to_rgba(hex: &str) -> [u8; 4] { + let hex = hex.trim_start_matches('#'); + let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0); + let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0); + let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0); + let a = if hex.len() >= 8 { + u8::from_str_radix(&hex[6..8], 16).unwrap_or(255) + } else { + 255 + }; + [r, g, b, a] +} + +/// Parses a blend mode string into the corresponding `BlendMode` enum variant. +/// +/// Accepts both camelCase and snake_case forms (e.g. `"colorBurn"` or +/// `"color_burn"`). Defaults to `Normal` for unrecognized strings. +/// +/// # Arguments +/// +/// * `s` — The blend mode name string. +/// +/// # Returns +/// +/// The matching `BlendMode` variant. +fn parse_blend_mode(s: &str) -> BlendMode { + match s.to_lowercase().as_str() { + "normal" => BlendMode::Normal, + "dissolve" => BlendMode::Dissolve, + "darken" => BlendMode::Darken, + "multiply" => BlendMode::Multiply, + "colorburn" | "color_burn" => BlendMode::ColorBurn, + "linearburn" | "linear_burn" => BlendMode::LinearBurn, + "darker" | "darkercolor" => BlendMode::DarkerColor, + "lighten" => BlendMode::Lighten, + "screen" => BlendMode::Screen, + "colordodge" | "color_dodge" => BlendMode::ColorDodge, + "lineardodge" | "linear_dodge" => BlendMode::LinearDodge, + "lighter" | "lightercolor" => BlendMode::LighterColor, + "overlay" => BlendMode::Overlay, + "softlight" | "soft_light" => BlendMode::SoftLight, + "hardlight" | "hard_light" => BlendMode::HardLight, + "vividlight" | "vivid_light" => BlendMode::VividLight, + "linearlight" | "linear_light" => BlendMode::LinearLight, + "pinlight" | "pin_light" => BlendMode::PinLight, + "hardmix" | "hard_mix" => BlendMode::HardMix, + "difference" => BlendMode::Difference, + "exclusion" => BlendMode::Exclusion, + "subtract" => BlendMode::Subtract, + "divide" => BlendMode::Divide, + "hue" => BlendMode::Hue, + "saturation" => BlendMode::Saturation, + "color" => BlendMode::Color, + "luminosity" => BlendMode::Luminosity, + _ => BlendMode::Normal, + } +} + +/// Parses a brush style string into the corresponding `BrushStyle` enum variant. +/// +/// Accepts various aliases (e.g. `"soft_round"`, `"inkpen"`, `"wet_paint"`). +/// Defaults to `Round` for unrecognized strings. +/// +/// # Arguments +/// +/// * `s` — The brush style name string. +/// +/// # Returns +/// +/// The matching `BrushStyle` variant. +fn parse_brush_style(s: &str) -> BrushStyle { + match s.to_lowercase().as_str() { + "default" | "round" => BrushStyle::Round, + "hardround" => BrushStyle::HardRound, + "softround" | "soft_round" => BrushStyle::SoftRound, + "square" => BrushStyle::Square, + "pencil" => BrushStyle::Pencil, + "inkpen" | "pen" => BrushStyle::InkPen, + "charcoal" => BrushStyle::Charcoal, + "watercolor" => BrushStyle::Watercolor, + "marker" => BrushStyle::Marker, + "glow" => BrushStyle::Glow, + "airbrush" => BrushStyle::Airbrush, + "spray" => BrushStyle::Spray, + "oil" => BrushStyle::Oil, + "tree" => BrushStyle::Tree, + "meadow" => BrushStyle::Meadow, + "rock" => BrushStyle::Rock, + "clouds" => BrushStyle::Clouds, + "dirt" => BrushStyle::Dirt, + "bristle" => BrushStyle::Bristle, + "crayon" => BrushStyle::Crayon, + "leaf" => BrushStyle::Leaf, + "wetpaint" | "wet_paint" => BrushStyle::WetPaint, + "sketch" => BrushStyle::Sketch, + "hatch" => BrushStyle::Hatch, + "calligraphy" => BrushStyle::Calligraphy, + "blender" => BrushStyle::Blender, + "mixer" => BrushStyle::Mixer, + _ => BrushStyle::Round, + } +} + +/// Finds a layer ID by name or numeric index. +/// +/// First attempts to parse `name_or_index` as a `usize` index into the +/// layer list. If that fails, searches layer names for an exact match. +/// +/// # Arguments +/// +/// * `engine` — The engine instance to query. +/// * `name_or_index` — The layer name or zero-based index string. +/// +/// # Returns +/// +/// The layer `u64` ID if found, otherwise `None`. +fn find_layer_id(engine: &mut Engine, name_or_index: &str) -> Option { + if let Ok(idx) = name_or_index.parse::() { + let ids = engine.get_all_layer_ids(); + return ids.get(idx).copied(); + } + let infos = engine.layer_infos(); + for info in &infos { + if info.name == name_or_index { + return Some(info.id); + } + } + None +} + +/// Executes a sequence of parsed `ScriptAction` values against the engine. +/// +/// Iterates through actions in order, translating each into the appropriate +/// engine API call. Layer operations, brush settings, drawing commands, and +/// vector shapes are all handled here. +/// +/// # Arguments +/// +/// * `engine` — The mutable engine instance to operate on. +/// * `actions` — The slice of actions to execute. +pub fn execute_actions(engine: &mut Engine, actions: &[ScriptAction]) { + for action in actions { + match action { + ScriptAction::CreateLayer { name, layer_type: _ } => { + engine.add_layer(name); + } + ScriptAction::SelectLayer { name_or_index } => { + if let Some(id) = find_layer_id(engine, name_or_index) { + engine.set_active_layer(id); + } + } + ScriptAction::SetLayerOpacity { name_or_index, opacity } => { + if let Some(id) = find_layer_id(engine, name_or_index) { + engine.set_layer_opacity(id, *opacity); + } + } + ScriptAction::SetLayerBlendMode { name_or_index, blend_mode } => { + if let Some(id) = find_layer_id(engine, name_or_index) { + let bm = parse_blend_mode(blend_mode); + engine.set_layer_blend_mode(id, bm); + } + } + ScriptAction::SetForegroundColor { hex } => { + let rgba = hex_to_rgba(hex); + engine.set_color(rgba); + } + ScriptAction::SetBrush { + style, + size, + opacity, + spacing, + hardness, + } => { + let mut tip = BrushTip::default(); + if let Some(s) = style { + tip.style = parse_brush_style(s); + } + if let Some(sz) = size { + tip.size = *sz; + } + if let Some(o) = opacity { + tip.opacity = *o; + } + if let Some(sp) = spacing { + tip.spacing = *sp; + } + if let Some(h) = hardness { + tip.hardness = *h; + } + engine.set_brush_tip(tip); + } + ScriptAction::PaintStroke { + points, + color, + brush_style, + size, + opacity, + spacing, + hardness, + } => { + let rgba = hex_to_rgba(color); + engine.set_color(rgba); + + let mut tip = BrushTip::default(); + tip.style = parse_brush_style(brush_style); + tip.size = *size; + tip.opacity = *opacity; + tip.spacing = *spacing; + tip.hardness = *hardness; + engine.set_brush_tip(tip); + + let stroke_points: Vec<(f32, f32, f32)> = points + .iter() + .map(|p| (p[0], p[1], 1.0)) + .collect(); + engine.draw_stroke(&stroke_points); + } + ScriptAction::FillRasterRect { + x1, + y1, + x2, + y2, + color, + } => { + let rgba = hex_to_rgba(color); + let (cw, ch) = engine.get_canvas_size(); + let dx1 = (x1 * cw as f32) as u32; + let dy1 = (y1 * ch as f32) as u32; + let dx2 = (x2 * cw as f32) as u32; + let dy2 = (y2 * ch as f32) as u32; + engine.draw_filled_rect_rgba(dx1, dy1, dx2, dy2, rgba); + } + ScriptAction::AddVectorPath { + points, + closed, + fill_color, + stroke_color, + stroke_width, + } => { + let path_points: Vec<[f32; 2]> = points.clone(); + let fc = [ + hex_to_rgba(fill_color)[0], + hex_to_rgba(fill_color)[1], + hex_to_rgba(fill_color)[2], + 255, + ]; + let sc = [ + hex_to_rgba(stroke_color)[0], + hex_to_rgba(stroke_color)[1], + hex_to_rgba(stroke_color)[2], + 255, + ]; + engine.add_vector_shape(hcie_engine_api::VectorShape::FreePath { + pts: path_points, + closed: *closed, + stroke: *stroke_width, + color: sc, + fill_color: fc, + fill: true, + opacity: 1.0, + hardness: 0.8, + }); + } + ScriptAction::Finish { .. } => {} + ScriptAction::DeleteLayer { name_or_index } => { + if let Some(id) = find_layer_id(engine, name_or_index) { + engine.delete_layer(id); + } + } + ScriptAction::RenameLayer { name_or_index, new_name } => { + if let Some(id) = find_layer_id(engine, name_or_index) { + engine.rename_layer(id, new_name); + } + } + } + } +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/script/mod.rs b/hcie-iced-app/crates/hcie-iced-gui/src/script/mod.rs new file mode 100644 index 0000000..1f94dcc --- /dev/null +++ b/hcie-iced-app/crates/hcie-iced-gui/src/script/mod.rs @@ -0,0 +1,5 @@ +/// DSL parser module — tokenizer, recursive-descent parser, and action types +/// for the HCIE-Rust scripting engine. +pub mod types; +pub mod parser; +pub mod executor; diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/script/parser.rs b/hcie-iced-app/crates/hcie-iced-gui/src/script/parser.rs new file mode 100644 index 0000000..458ca64 --- /dev/null +++ b/hcie-iced-app/crates/hcie-iced-gui/src/script/parser.rs @@ -0,0 +1,1341 @@ +//! Full DSL parser for the HCIE-Rust scripting engine. +//! +//! Provides a recursive-descent parser that converts a text-based DSL +//! into a sequence of `ScriptAction` values. The DSL supports: +//! +//! - Layer management (`layer`, `select_layer`, `vector_layer`) +//! - Brush and color configuration (`brush`, `color`, `size`, `opacity`, etc.) +//! - Drawing commands (`draw`, `line`, `rect`, `circle`, `bezier`, `scatter`, `gradient`) +//! - Vector shapes (`triangle`, `bezier`) +//! - Variable definitions and arithmetic expressions (`var`, `$name`) +//! - Loop constructs (`repeat N { ... }`) +//! - Compound settings (`set key=value ...`) +//! +//! # Architecture +//! +//! 1. **Tokenizer** — Breaks arithmetic expressions into `Token` values. +//! 2. **Expression evaluator** — Parses infix arithmetic with proper precedence. +//! 3. **Line parser** — Dispatches each DSL command to the appropriate handler. +//! 4. **Main parser** — Iterates lines, handles `repeat` blocks, collects actions. + +use crate::script::types::ScriptParserState; + +// ── Expression Evaluator ────────────────────────────────────────────────── + +/// Token type for the arithmetic expression tokenizer. +#[derive(Debug, Clone)] +enum Token { + /// Numeric literal. + Num(f32), + /// Variable reference (e.g. `$i`, `$count`). + Var(String), + /// Addition operator. + Plus, + /// Subtraction operator. + Minus, + /// Multiplication operator. + Mul, + /// Division operator. + Div, + /// Left parenthesis. + LParen, + /// Right parenthesis. + RParen, +} + +/// Tokenizes an arithmetic expression string into a sequence of `Token` values. +/// +/// Supports numbers, variables (`$name` or `$[name]`), and the operators +/// `+`, `-`, `*`, `/`, `(`, `)`. Whitespace is ignored. +/// +/// # Arguments +/// +/// * `s` — The expression string to tokenize. +/// +/// # Returns +/// +/// A `Vec` on success, or an error message describing the invalid character. +fn tokenize(s: &str) -> Result, String> { + let mut tokens = Vec::new(); + let chars: Vec = s.chars().collect(); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + if c.is_whitespace() { + i += 1; + continue; + } + match c { + '+' => { + tokens.push(Token::Plus); + i += 1; + } + '-' => { + tokens.push(Token::Minus); + i += 1; + } + '*' => { + tokens.push(Token::Mul); + i += 1; + } + '/' => { + tokens.push(Token::Div); + i += 1; + } + '(' => { + tokens.push(Token::LParen); + i += 1; + } + ')' => { + tokens.push(Token::RParen); + i += 1; + } + '0'..='9' | '.' => { + let start = i; + while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '.') { + i += 1; + } + let num: &str = &s[start..i]; + tokens.push(Token::Num( + num.parse::() + .map_err(|_| format!("invalid number '{}'", num))?, + )); + } + '$' => { + let start = i; + i += 1; + if i < chars.len() && chars[i] == '[' { + i += 1; + } + while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_') { + i += 1; + } + if i < chars.len() && chars[i] == ']' { + i += 1; + } + let name: &str = &s[start..i]; + tokens.push(Token::Var(name.to_string())); + } + _ => { + return Err(format!("unexpected character '{}' in expression", c)); + } + } + } + Ok(tokens) +} + +/// Parses an expression (addition/subtraction precedence level). +/// +/// Delegates to `parse_term` for multiplication/division, then combines +/// results with addition or subtraction operators. +fn parse_expression(tokens: &[Token], pos: usize, vars: &std::collections::HashMap) -> Result<(f32, usize), String> { + let (mut left, mut pos) = parse_term(tokens, pos, vars)?; + while pos < tokens.len() { + match &tokens[pos] { + Token::Plus => { + let (right, new_pos) = parse_term(tokens, pos + 1, vars)?; + left += right; + pos = new_pos; + } + Token::Minus => { + let (right, new_pos) = parse_term(tokens, pos + 1, vars)?; + left -= right; + pos = new_pos; + } + _ => break, + } + } + Ok((left, pos)) +} + +/// Parses a term (multiplication/division precedence level). +/// +/// Delegates to `parse_factor` for atoms, then combines results with +/// multiplication or division operators. +fn parse_term(tokens: &[Token], pos: usize, vars: &std::collections::HashMap) -> Result<(f32, usize), String> { + let (mut left, mut pos) = parse_factor(tokens, pos, vars)?; + while pos < tokens.len() { + match &tokens[pos] { + Token::Mul => { + let (right, new_pos) = parse_factor(tokens, pos + 1, vars)?; + left *= right; + pos = new_pos; + } + Token::Div => { + let (right, new_pos) = parse_factor(tokens, pos + 1, vars)?; + if right.abs() < 1e-10 { + return Err("division by zero".to_string()); + } + left /= right; + pos = new_pos; + } + _ => break, + } + } + Ok((left, pos)) +} + +/// Parses a factor — the highest-precedence atom in the expression grammar. +/// +/// Supports numeric literals, variable references (resolved from `vars`), +/// unary negation, and parenthesized sub-expressions. +fn parse_factor(tokens: &[Token], pos: usize, vars: &std::collections::HashMap) -> Result<(f32, usize), String> { + if pos >= tokens.len() { + return Err("unexpected end of expression".to_string()); + } + match &tokens[pos] { + Token::Num(v) => Ok((*v, pos + 1)), + Token::Var(name) => { + let clean = name.trim_start_matches('$'); + let clean = clean.trim_start_matches('['); + let clean = clean.trim_end_matches(']'); + let val = vars + .get(clean) + .copied() + .ok_or_else(|| format!("undefined variable '{}'", clean))?; + Ok((val, pos + 1)) + } + Token::Minus => { + let (v, new_pos) = parse_factor(tokens, pos + 1, vars)?; + Ok((-v, new_pos)) + } + Token::LParen => { + let (v, new_pos) = parse_expression(tokens, pos + 1, vars)?; + if new_pos >= tokens.len() || !matches!(tokens[new_pos], Token::RParen) { + return Err("missing closing parenthesis".to_string()); + } + Ok((v, new_pos + 1)) + } + _ => Err(format!("unexpected token {:?}", tokens[pos])), + } +} + +/// Evaluates an arithmetic expression string using the given variable map. +/// +/// Handles three fast paths before falling through to the full tokenizer: +/// 1. Pure numeric literal — parsed directly. +/// 2. Bare variable reference (`$name`) — looked up in `vars`. +/// 3. Full expression — tokenized and parsed via recursive descent. +/// +/// # Arguments +/// +/// * `expr` — The expression string (e.g. `"3 + $i * 2"`). +/// * `vars` — Map of variable names to their current values. +/// +/// # Returns +/// +/// The evaluated `f32` result, or an error message. +fn eval_expr(expr: &str, vars: &std::collections::HashMap) -> Result { + let expr = expr.trim(); + if expr.is_empty() { + return Err("empty expression".to_string()); + } + let clean: String = expr.chars().filter(|c| !c.is_whitespace()).collect(); + if let Ok(v) = clean.parse::() { + return Ok(v); + } + if let Some(name) = clean.strip_prefix('$') { + if let Some(parts) = name.strip_prefix('[') { + let inner = parts.strip_suffix(']').unwrap_or(parts); + return vars + .get(inner) + .copied() + .ok_or_else(|| format!("undefined variable '{}'", inner)); + } + return vars + .get(name) + .copied() + .ok_or_else(|| format!("undefined variable '{}'", name)); + } + let tokens = tokenize(&clean)?; + let (result, _) = parse_expression(&tokens, 0, vars)?; + Ok(result) +} + +// ── Parsed Action Types ────────────────────────────────────────────────── + +/// Represents a single executable action produced by the DSL parser. +/// +/// Each variant maps to one or more engine operations. The executor +/// processes these in order, translating them into `hcie_engine_api` +/// calls. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub enum ScriptAction { + /// Create a new layer with the given name and type. + CreateLayer { name: String, layer_type: String }, + /// Delete a layer identified by name or index. + DeleteLayer { name_or_index: String }, + /// Select (make active) a layer by name or index. + SelectLayer { name_or_index: String }, + /// Rename a layer identified by name or index. + RenameLayer { name_or_index: String, new_name: String }, + /// Set the opacity of a layer identified by name or index. + SetLayerOpacity { name_or_index: String, opacity: f32 }, + /// Set the blend mode of a layer identified by name or index. + SetLayerBlendMode { name_or_index: String, blend_mode: String }, + /// Paint a stroke through the given points with specified brush parameters. + PaintStroke { + points: Vec<[f32; 2]>, + color: String, + brush_style: String, + size: f32, + opacity: f32, + spacing: f32, + hardness: f32, + }, + /// Update brush settings (any field that is `Some` is applied). + SetBrush { + style: Option, + size: Option, + opacity: Option, + spacing: Option, + hardness: Option, + }, + /// Set the foreground color from a hex string. + SetForegroundColor { hex: String }, + /// Fill a rectangle on the raster layer with a solid color. + FillRasterRect { + x1: f32, + y1: f32, + x2: f32, + y2: f32, + color: String, + }, + /// Add a vector path (free-form or closed shape). + AddVectorPath { + points: Vec<[f32; 2]>, + closed: bool, + fill_color: String, + stroke_color: String, + stroke_width: f32, + }, + /// Script completion marker. + Finish { summary: String }, +} + +// ── Helper functions ──────────────────────────────────────────────────── + +/// Converts an absolute pixel coordinate to normalized `[0, 1]` range. +fn to_norm(coord: f32, canvas_dim: f32) -> f32 { + (coord / canvas_dim).clamp(0.0, 1.0) +} + +/// Evaluates an expression and normalizes the result to canvas coordinates. +fn parse_coord(s: &str, vars: &std::collections::HashMap, canvas: f32) -> Result { + let val = eval_expr(s, vars)?; + Ok(to_norm(val, canvas)) +} + +/// Evaluates an expression and returns the raw value (no normalization). +fn parse_coord_abs(s: &str, vars: &std::collections::HashMap) -> Result { + eval_expr(s, vars) +} + +/// Parses a 6- or 8-character hex color string into `(r, g, b)` components. +/// +/// Each component is in `[0, 255]` range. The alpha channel (if present) is ignored. +fn hex_to_rgba_components(hex: &str) -> Result<(f32, f32, f32), String> { + let hex = hex.trim_start_matches('#'); + if hex.len() != 6 && hex.len() != 8 { + return Err(format!("invalid hex color '{}'", hex)); + } + let r = u8::from_str_radix(&hex[0..2], 16).map_err(|_| "invalid red")?; + let g = u8::from_str_radix(&hex[2..4], 16).map_err(|_| "invalid green")?; + let b = u8::from_str_radix(&hex[4..6], 16).map_err(|_| "invalid blue")?; + Ok((r as f32, g as f32, b as f32)) +} + +/// Linearly interpolates between two hex colors at parameter `t`. +/// +/// # Arguments +/// +/// * `hex1` — Start color as hex string. +/// * `hex2` — End color as hex string. +/// * `t` — Interpolation parameter in `[0, 1]`. +/// +/// # Returns +/// +/// The interpolated color as a 6-character hex string. +fn interpolate_hex(hex1: &str, hex2: &str, t: f32) -> Result { + let (r1, g1, b1) = hex_to_rgba_components(hex1)?; + let (r2, g2, b2) = hex_to_rgba_components(hex2)?; + let r = (r1 + (r2 - r1) * t) as u8; + let g = (g1 + (g2 - g1) * t) as u8; + let b = (b1 + (b2 - b1) * t) as u8; + Ok(format!("#{:02X}{:02X}{:02X}", r, g, b)) +} + +/// Samples a cubic Bezier curve at evenly-spaced parameter values. +/// +/// # Arguments +/// +/// * `p0`, `p1`, `p2`, `p3` — The four control points. +/// * `steps` — Number of segments to divide the curve into. +/// +/// # Returns +/// +/// A `Vec` of `steps + 1` points along the curve. +fn bezier_sample(p0: [f32; 2], p1: [f32; 2], p2: [f32; 2], p3: [f32; 2], steps: usize) -> Vec<[f32; 2]> { + let mut pts = Vec::with_capacity(steps + 1); + for i in 0..=steps { + let t = i as f32 / steps as f32; + let u = 1.0 - t; + let x = u * u * u * p0[0] + 3.0 * u * u * t * p1[0] + 3.0 * u * t * t * p2[0] + t * t * t * p3[0]; + let y = u * u * u * p0[1] + 3.0 * u * u * t * p1[1] + 3.0 * u * t * t * p2[1] + t * t * t * p3[1]; + pts.push([x, y]); + } + pts +} + +/// Generates pseudo-random scattered points within a bounding box. +/// +/// Uses a deterministic hash-based RNG to produce reproducible scatter +/// patterns. Points are distributed using golden-ratio spacing for +/// uniform coverage. +/// +/// # Arguments +/// +/// * `count` — Number of points to generate. +/// * `bx`, `by` — Top-left corner of the bounding box (absolute coords). +/// * `bw`, `bh` — Width and height of the bounding box. +fn pseudo_scatter(count: usize, bx: f32, by: f32, bw: f32, bh: f32) -> Vec<[f32; 2]> { + let mut pts = Vec::with_capacity(count); + for i in 0..count { + let seed = (i as u64).wrapping_mul(0x9E3779B97F4A7C15); + let fx = ((seed >> 32) as u32) as f32 / (u32::MAX as f32); + let fy = (((seed.wrapping_mul(2654435761)) >> 32) as u32) as f32 / (u32::MAX as f32); + let phi = 0.6180339887498949; + let gx = ((i as f32 * phi) % 1.0) * bw + bx; + let gy = ((i as f32 * phi * phi) % 1.0) * bh + by; + let x = gx * 0.7 + fx * bw * 0.3 + bx * 0.3; + let y = gy * 0.7 + fy * bh * 0.3 + by * 0.3; + pts.push([x, y]); + } + pts +} + +/// Applies a single deterministic wiggle displacement to a point. +/// +/// # Arguments +/// +/// * `px`, `py` — Original point coordinates. +/// * `amp` — Maximum displacement amplitude. +/// * `seed` — Seed for the deterministic displacement. +/// +/// # Returns +/// +/// The displaced `(x, y)` coordinates. +fn wiggle_point(px: f32, py: f32, amp: f32, seed: u64) -> (f32, f32) { + if amp <= 0.0 { + return (px, py); + } + let sx = (seed.wrapping_mul(0x9E3779B97F4A7C15) >> 32) as u32; + let sy = (seed.wrapping_mul(0x85A308D3) >> 32) as u32; + let dx = (sx as f32 / u32::MAX as f32 - 0.5) * 2.0 * amp; + let dy = (sy as f32 / u32::MAX as f32 - 0.5) * 2.0 * amp; + (px + dx, py + dy) +} + +/// Applies wiggle displacement to all points in a normalized coordinate slice. +/// +/// Points are temporarily un-normalized to pixel space (assumed 800x600 canvas), +/// displaced, then re-normalized. +fn apply_wiggle(pts: &mut [[f32; 2]], wiggle: f32) { + if wiggle <= 0.0 { + return; + } + for (i, pt) in pts.iter_mut().enumerate() { + let px = pt[0] * 800.0; + let py = pt[1] * 600.0; + let (wx, wy) = wiggle_point(px, py, wiggle, i as u64 + 1); + pt[0] = to_norm(wx, 800.0); + pt[1] = to_norm(wy, 600.0); + } +} + +/// Converts a list of points into one or more `PaintStroke` actions. +/// +/// Handles single-point strokes, uniform-pressure strokes, and +/// pressure-tapered strokes (where each segment gets interpolated pressure). +/// +/// # Arguments +/// +/// * `points` — The stroke points in normalized coordinates. +/// * `state` — Current parser state for brush/color/pressure settings. +fn build_strokes(mut points: Vec<[f32; 2]>, state: &ScriptParserState) -> Vec { + if points.len() < 2 { + if points.len() == 1 { + return vec![ScriptAction::PaintStroke { + points, + color: state.color.clone(), + brush_style: state.style.clone(), + size: state.size, + opacity: state.opacity * state.pressure_start, + spacing: 0.01, + hardness: state.hardness, + }]; + } + return vec![]; + } + + apply_wiggle(&mut points, state.wiggle); + + if state.pressure_start == state.pressure_end { + return vec![ScriptAction::PaintStroke { + points, + color: state.color.clone(), + brush_style: state.style.clone(), + size: state.size, + opacity: state.opacity * state.pressure_start, + spacing: state.spacing, + hardness: state.hardness, + }]; + } + + let mut actions = Vec::with_capacity(points.len() - 1); + let n = points.len(); + for seg in 0..(n - 1) { + let t = seg as f32 / (n - 1).max(1) as f32; + let p = state.pressure_start + (state.pressure_end - state.pressure_start) * t; + actions.push(ScriptAction::PaintStroke { + points: vec![points[seg], points[seg + 1]], + color: state.color.clone(), + brush_style: state.style.clone(), + size: state.size, + opacity: (state.opacity * p).clamp(0.0, 1.0), + spacing: state.spacing, + hardness: state.hardness, + }); + } + actions +} + +/// Strips leading `[` and trailing `]` from a string (for bracket-enclosed arguments). +fn strip_brackets(s: &str) -> &str { + let s = s.strip_prefix('[').unwrap_or(s); + s.strip_suffix(']').unwrap_or(s) +} + +// ── Line Parser ────────────────────────────────────────────────────────── + +/// Parses a single DSL command line and returns the corresponding actions. +/// +/// Dispatches on the first word of the line to the appropriate handler. +/// Each handler updates `state` in place and returns zero or more actions. +/// +/// # Arguments +/// +/// * `state` — Mutable parser state (updated by side-effect commands). +/// * `line` — The trimmed DSL command line. +/// * `line_num` — Line number for error reporting. +/// +/// # Returns +/// +/// A `Vec` on success, or a formatted error string with line number. +fn parse_single_line(state: &mut ScriptParserState, line: &str, line_num: u64) -> Result, String> { + let err = |msg: &str| format!("Line {}: {}", line_num, msg); + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.is_empty() { + return Ok(vec![]); + } + + match parts[0].to_lowercase().as_str() { + "layer" => { + if parts.len() < 2 { + return Err(err("layer name missing")); + } + Ok(vec![ScriptAction::CreateLayer { + name: parts[1..].join(" "), + layer_type: "raster".to_string(), + }]) + } + "vector_layer" => { + if parts.len() < 2 { + return Err(err("vector layer name missing")); + } + Ok(vec![ScriptAction::CreateLayer { + name: parts[1..].join(" "), + layer_type: "vector".to_string(), + }]) + } + "select_layer" => { + if parts.len() < 2 { + return Err(err("layer name missing")); + } + Ok(vec![ScriptAction::SelectLayer { + name_or_index: parts[1..].join(" "), + }]) + } + "layer_opacity" => { + let val = eval_expr(parts.get(1).unwrap_or(&"1.0"), &state.vars).map_err(|e| err(&e))?; + state.opacity = val.clamp(0.0, 1.0); + Ok(vec![ScriptAction::SetLayerOpacity { + name_or_index: "0".to_string(), + opacity: state.opacity, + }]) + } + "blend" => { + if parts.len() < 2 { + return Err(err("blend mode missing")); + } + state.blend_mode = parts[1].to_lowercase(); + Ok(vec![ScriptAction::SetLayerBlendMode { + name_or_index: "0".to_string(), + blend_mode: state.blend_mode.clone(), + }]) + } + "brush" => { + if parts.len() < 2 { + return Err(err("brush style missing")); + } + state.style = parts[1].to_string(); + Ok(vec![ScriptAction::SetBrush { + style: Some(state.style.clone()), + size: None, + opacity: None, + spacing: None, + hardness: None, + }]) + } + "color" => { + if parts.len() < 2 { + return Err(err("color hex missing")); + } + state.color = parts[1].to_string(); + Ok(vec![ScriptAction::SetForegroundColor { hex: state.color.clone() }]) + } + "size" => { + state.size = eval_expr(parts.get(1).unwrap_or(&"20"), &state.vars) + .map_err(|e| err(&e))? + .max(1.0); + Ok(vec![ScriptAction::SetBrush { + size: Some(state.size), + style: None, + opacity: None, + spacing: None, + hardness: None, + }]) + } + "opacity" => { + state.opacity = eval_expr(parts.get(1).unwrap_or(&"1.0"), &state.vars) + .map_err(|e| err(&e))? + .clamp(0.0, 1.0); + Ok(vec![ScriptAction::SetBrush { + opacity: Some(state.opacity), + style: None, + size: None, + spacing: None, + hardness: None, + }]) + } + "hardness" => { + state.hardness = eval_expr(parts.get(1).unwrap_or(&"0.8"), &state.vars) + .map_err(|e| err(&e))? + .clamp(0.0, 1.0); + Ok(vec![ScriptAction::SetBrush { + hardness: Some(state.hardness), + style: None, + size: None, + opacity: None, + spacing: None, + }]) + } + "flow" => { + let flow = eval_expr(parts.get(1).unwrap_or(&"0.5"), &state.vars) + .map_err(|e| err(&e))? + .clamp(0.01, 2.0); + state.spacing = (0.2 / flow).clamp(0.01, 2.0); + Ok(vec![ScriptAction::SetBrush { + spacing: Some(state.spacing), + style: None, + size: None, + opacity: None, + hardness: None, + }]) + } + "scatter_size" => { + state.scatter_size = eval_expr(parts.get(1).unwrap_or(&"8"), &state.vars) + .map_err(|e| err(&e))? + .max(1.0); + Ok(vec![]) + } + "pressure" => { + let v = eval_expr(parts.get(1).unwrap_or(&"1.0"), &state.vars) + .map_err(|e| err(&e))? + .clamp(0.0, 1.0); + state.pressure = v; + state.pressure_start = v; + state.pressure_end = v; + Ok(vec![]) + } + "pressure_start" => { + let v = eval_expr(parts.get(1).unwrap_or(&"1.0"), &state.vars) + .map_err(|e| err(&e))? + .clamp(0.0, 1.0); + state.pressure_start = v; + Ok(vec![]) + } + "pressure_end" => { + let v = eval_expr(parts.get(1).unwrap_or(&"1.0"), &state.vars) + .map_err(|e| err(&e))? + .clamp(0.0, 1.0); + state.pressure_end = v; + Ok(vec![]) + } + "wiggle" => { + let v = eval_expr(parts.get(1).unwrap_or(&"0"), &state.vars) + .map_err(|e| err(&e))? + .max(0.0); + state.wiggle = v; + Ok(vec![]) + } + "set" => { + let mut actions = Vec::new(); + for kv in &parts[1..] { + let (key, val) = + kv.split_once('=').ok_or_else(|| err("set syntax: key=value"))?; + match key.to_lowercase().as_str() { + "color" => { + state.color = val.to_string(); + actions.push(ScriptAction::SetForegroundColor { hex: state.color.clone() }); + } + "brush" | "style" => { + state.style = val.to_string(); + actions.push(ScriptAction::SetBrush { + style: Some(state.style.clone()), + size: None, + opacity: None, + spacing: None, + hardness: None, + }); + } + "size" => { + state.size = + eval_expr(val, &state.vars).map_err(|e| err(&e))?.max(1.0); + actions.push(ScriptAction::SetBrush { + size: Some(state.size), + style: None, + opacity: None, + spacing: None, + hardness: None, + }); + } + "opacity" => { + state.opacity = eval_expr(val, &state.vars) + .map_err(|e| err(&e))? + .clamp(0.0, 1.0); + actions.push(ScriptAction::SetBrush { + opacity: Some(state.opacity), + style: None, + size: None, + spacing: None, + hardness: None, + }); + } + "hardness" => { + state.hardness = eval_expr(val, &state.vars) + .map_err(|e| err(&e))? + .clamp(0.0, 1.0); + actions.push(ScriptAction::SetBrush { + hardness: Some(state.hardness), + style: None, + size: None, + opacity: None, + spacing: None, + }); + } + "flow" => { + let flow = eval_expr(val, &state.vars) + .map_err(|e| err(&e))? + .clamp(0.01, 2.0); + state.spacing = (0.2 / flow).clamp(0.01, 2.0); + actions.push(ScriptAction::SetBrush { + spacing: Some(state.spacing), + style: None, + size: None, + opacity: None, + hardness: None, + }); + } + "pressure" => { + let v = eval_expr(val, &state.vars) + .map_err(|e| err(&e))? + .clamp(0.0, 1.0); + state.pressure = v; + state.pressure_start = v; + state.pressure_end = v; + } + "pressure_start" => { + state.pressure_start = eval_expr(val, &state.vars) + .map_err(|e| err(&e))? + .clamp(0.0, 1.0); + } + "pressure_end" => { + state.pressure_end = eval_expr(val, &state.vars) + .map_err(|e| err(&e))? + .clamp(0.0, 1.0); + } + "scatter_size" | "scatter_sz" => { + state.scatter_size = + eval_expr(val, &state.vars).map_err(|e| err(&e))?.max(1.0); + } + "wiggle" => { + state.wiggle = eval_expr(val, &state.vars).map_err(|e| err(&e))?.max(0.0); + } + _ => { + return Err(err(&format!("unknown set key '{}'", key))); + } + } + } + Ok(actions) + } + "draw" => { + let mut points = Vec::new(); + for p in &parts[1..] { + let coords: Vec<&str> = p.split(',').collect(); + if coords.len() != 2 { + return Err(err(&format!("invalid point {}", p))); + } + let x = parse_coord(coords[0], &state.vars, 800.0)?; + let y = parse_coord(coords[1], &state.vars, 600.0)?; + points.push([x, y]); + } + if points.len() < 2 { + return Err(err("draw needs at least 2 points")); + } + Ok(build_strokes(points, state)) + } + "line" => { + if parts.len() < 3 { + return Err(err("line expects x1,y1 x2,y2")); + } + let p1: Vec<&str> = parts[1].split(',').collect(); + let p2: Vec<&str> = parts[2].split(',').collect(); + if p1.len() != 2 || p2.len() != 2 { + return Err(err("invalid line coordinates")); + } + let x1 = parse_coord(p1[0], &state.vars, 800.0)?; + let y1 = parse_coord(p1[1], &state.vars, 600.0)?; + let x2 = parse_coord(p2[0], &state.vars, 800.0)?; + let y2 = parse_coord(p2[1], &state.vars, 600.0)?; + Ok(build_strokes(vec![[x1, y1], [x2, y2]], state)) + } + "rect" | "fill" => { + if parts.len() < 3 { + return Err(err("rect expects x1,y1 x2,y2")); + } + let p1: Vec<&str> = parts[1].split(',').collect(); + let p2: Vec<&str> = parts[2].split(',').collect(); + if p1.len() != 2 || p2.len() != 2 { + return Err(err("invalid rect coordinates")); + } + let x1 = parse_coord(p1[0], &state.vars, 800.0)?; + let y1 = parse_coord(p1[1], &state.vars, 600.0)?; + let x2 = parse_coord(p2[0], &state.vars, 800.0)?; + let y2 = parse_coord(p2[1], &state.vars, 600.0)?; + Ok(vec![ScriptAction::FillRasterRect { + x1, + y1, + x2, + y2, + color: state.color.clone(), + }]) + } + "circle" => { + if parts.len() < 2 { + return Err(err("circle expects cx,cy")); + } + let c: Vec<&str> = parts[1].split(',').collect(); + if c.len() != 2 { + return Err(err("invalid circle center")); + } + let cx = parse_coord(c[0], &state.vars, 800.0)?; + let cy = parse_coord(c[1], &state.vars, 600.0)?; + Ok(vec![ScriptAction::PaintStroke { + points: vec![[cx, cy], [cx + 0.0001, cy]], + color: state.color.clone(), + brush_style: state.style.clone(), + size: state.size, + opacity: state.opacity * state.pressure_start, + spacing: 0.01, + hardness: state.hardness, + }]) + } + "triangle" => { + if parts.len() < 4 { + return Err(err("triangle expects x1,y1 x2,y2 x3,y3")); + } + let pts: Result, String> = parts[1..4] + .iter() + .map(|p| { + let coords: Vec<&str> = p.split(',').collect(); + if coords.len() != 2 { + return Err(format!("invalid triangle point {}", p)); + } + Ok([ + parse_coord(coords[0], &state.vars, 800.0)?, + parse_coord(coords[1], &state.vars, 600.0)?, + ]) + }) + .collect(); + let pts = pts?; + Ok(vec![ScriptAction::AddVectorPath { + points: pts, + closed: true, + fill_color: state.color.clone(), + stroke_color: "#000000".to_string(), + stroke_width: 1.0, + }]) + } + "bezier" | "curve" => { + if parts.len() < 6 { + return Err(err("bezier expects x1,y1 cx1,cy1 cx2,cy2 x2,y2 [steps]")); + } + let parsed: Result, String> = parts[1..5] + .iter() + .map(|p| { + let coords: Vec<&str> = p.split(',').collect(); + if coords.len() != 2 { + return Err(format!("invalid bezier point {}", p)); + } + Ok([ + parse_coord(coords[0], &state.vars, 800.0)?, + parse_coord(coords[1], &state.vars, 600.0)?, + ]) + }) + .collect(); + let pts = parsed?; + let steps = if parts.len() >= 6 { + eval_expr(strip_brackets(parts[5]), &state.vars).map_err(|e| err(&e))? as usize + } else { + 40 + }; + let curve = bezier_sample(pts[0], pts[1], pts[2], pts[3], steps.max(4)); + Ok(build_strokes(curve, state)) + } + "scatter" => { + if parts.len() < 3 { + return Err(err("scatter expects count x,y w,h [size]")); + } + let count = eval_expr(parts[1], &state.vars).map_err(|e| err(&e))? as usize; + let bx_c: Vec<&str> = parts[2].split(',').collect(); + if bx_c.len() < 2 { + return Err(err("scatter expects count x,y w,h [size]")); + } + let bx = parse_coord_abs(bx_c[0], &state.vars)?; + let by = parse_coord_abs(bx_c[1], &state.vars)?; + let bw: f32; + let bh: f32; + let mut scatter_sz = state.scatter_size; + let mut next_arg = 3; + + if parts.len() > next_arg { + let raw3 = parts[next_arg]; + let cleaned3 = strip_brackets(raw3); + let xy3: Vec<&str> = cleaned3.split(',').collect(); + if xy3.len() == 2 { + bw = eval_expr(xy3[0], &state.vars).map_err(|e| err(&e))?; + bh = eval_expr(xy3[1], &state.vars).map_err(|e| err(&e))?; + next_arg = 4; + } else { + bw = eval_expr(cleaned3, &state.vars).map_err(|e| err(&e))?; + if parts.len() > next_arg + 1 { + let raw4 = strip_brackets(parts[next_arg + 1]); + bh = eval_expr(raw4, &state.vars).map_err(|e| err(&e))?; + next_arg = 5; + } else { + bh = 100.0; + next_arg = 4; + } + } + } else { + bw = 200.0; + bh = 100.0; + } + + if parts.len() > next_arg { + let sz_raw = strip_brackets(parts[next_arg]); + scatter_sz = eval_expr(sz_raw, &state.vars).map_err(|e| err(&e))?; + } + + let points = pseudo_scatter(count, bx, by, bw, bh); + let mut npts: Vec<[f32; 2]> = points.iter().map(|pt| [to_norm(pt[0], 800.0), to_norm(pt[1], 600.0)]).collect(); + apply_wiggle(&mut npts, state.wiggle); + + Ok(vec![ScriptAction::PaintStroke { + points: npts, + color: state.color.clone(), + brush_style: state.style.clone(), + size: scatter_sz, + opacity: state.opacity * state.pressure_start, + spacing: 999.0, + hardness: state.hardness, + }]) + } + "gradient" => { + if parts.len() < 6 { + return Err(err("gradient expects x1,y1 x2,y2 hex1 hex2 [hex3..] N")); + } + let p1: Vec<&str> = parts[1].split(',').collect(); + let p2: Vec<&str> = parts[2].split(',').collect(); + if p1.len() != 2 || p2.len() != 2 { + return Err(err("invalid gradient coordinates")); + } + let x1 = parse_coord_abs(p1[0], &state.vars)?; + let y1 = parse_coord_abs(p1[1], &state.vars)?; + let x2 = parse_coord_abs(p2[0], &state.vars)?; + let y2 = parse_coord_abs(p2[1], &state.vars)?; + + let steps = eval_expr(strip_brackets(parts[parts.len() - 1]), &state.vars) + .map_err(|e| err(&format!("gradient step count: {}", e)))? as usize; + + let color_stops: Vec<&str> = parts[3..parts.len() - 1].to_vec(); + if color_stops.len() < 2 { + return Err(err("gradient requires at least 2 color stops")); + } + + let dx = x2 - x1; + let dy = y2 - y1; + let use_vertical = dy.abs() > dx.abs(); + let seg_count = color_stops.len() - 1; + let mut actions = Vec::new(); + + let mut x1_draw = x1; + let mut x2_draw = x2; + let mut y1_draw = y1; + let mut y2_draw = y2; + + if use_vertical && dx.abs() < 1e-3 { + x1_draw = 0.0; + x2_draw = 800.0; + } + if !use_vertical && dy.abs() < 1e-3 { + y1_draw = 0.0; + y2_draw = 600.0; + } + + let alpha_u8 = (state.opacity * 255.0).clamp(0.0, 255.0) as u8; + + for i in 0..steps { + let t = i as f32 / steps as f32; + let t_next = (i + 1) as f32 / steps as f32; + let t_mid = (t + t_next) * 0.5; + + let seg_f = t_mid * seg_count as f32; + let seg_i = (seg_f as usize).min(seg_count - 1); + let seg_t = seg_f - seg_i as f32; + + let color_mid = interpolate_hex(color_stops[seg_i], color_stops[seg_i + 1], seg_t)?; + let color_with_alpha = format!("{}{:02X}", color_mid, alpha_u8); + + if use_vertical { + let sy = y1_draw + dy * t; + let ey = y1_draw + dy * t_next; + actions.push(ScriptAction::FillRasterRect { + x1: to_norm(x1_draw, 800.0), + y1: to_norm(sy, 600.0), + x2: to_norm(x2_draw, 800.0), + y2: to_norm(ey, 600.0), + color: color_with_alpha, + }); + } else { + let sx = x1_draw + dx * t; + let ex = x1_draw + dx * t_next; + actions.push(ScriptAction::FillRasterRect { + x1: to_norm(sx, 800.0), + y1: to_norm(y1_draw, 600.0), + x2: to_norm(ex, 800.0), + y2: to_norm(y2_draw, 600.0), + color: color_with_alpha, + }); + } + } + Ok(actions) + } + "var" => { + if parts.len() < 3 { + return Err(err("var requires name value")); + } + let name = parts[1].trim_start_matches('$').to_string(); + let val = eval_expr(parts[2], &state.vars).map_err(|e| err(&e))?; + state.vars.insert(name, val); + Ok(vec![]) + } + _ => Err(err(&format!("unknown command '{}'", parts[0]))), + } +} + +// ── Main Parser ────────────────────────────────────────────────────────── + +/// Parses a complete DSL script into a sequence of executable actions. +/// +/// Iterates through lines, handles `repeat N { ... }` block constructs +/// by forking parser state and injecting loop variables `$i` and `$n`, +/// and collects all resulting actions. A final `Finish` action is appended. +/// +/// # Arguments +/// +/// * `script_text` — The complete DSL script as a string. +/// +/// # Returns +/// +/// A `Vec` on success, or a formatted error string with line number. +pub fn parse_dsl_script(script_text: &str) -> Result, String> { + let mut actions = Vec::new(); + let mut state = ScriptParserState::default(); + let lines: Vec<&str> = script_text.lines().collect(); + let total = lines.len(); + let mut i = 0; + + while i < total { + let line = lines[i].trim(); + i += 1; + if line.is_empty() || line.starts_with('#') { + continue; + } + + let line_num = i; + + if let Some(rest) = line.strip_prefix("repeat ") { + let rest = rest.trim(); + let brace_pos = rest.find('{'); + let count_str = if let Some(bp) = brace_pos { + &rest[..bp] + } else { + rest + }; + let count = eval_expr(count_str.trim(), &state.vars) + .map_err(|e| format!("Line {}: repeat count: {}", line_num, e))? as usize; + if count == 0 { + continue; + } + + let mut inner_lines = Vec::new(); + if brace_pos.is_some() { + while i < total { + let bl = lines[i].trim(); + let actual_ln = i + 1; + i += 1; + if bl == "}" { + break; + } + if bl.starts_with('#') || bl.is_empty() { + continue; + } + inner_lines.push((actual_ln, bl.to_string())); + } + } else { + return Err(format!( + "Line {}: repeat without {{ block not supported; use repeat N {{ ... }}", + line_num + )); + } + + for rep in 0..count { + let mut rep_state = ScriptParserState { + color: state.color.clone(), + style: state.style.clone(), + size: state.size, + opacity: state.opacity, + hardness: state.hardness, + spacing: state.spacing, + blend_mode: state.blend_mode.clone(), + vars: state.vars.clone(), + scatter_size: state.scatter_size, + flow: state.flow, + pressure: state.pressure, + pressure_start: state.pressure_start, + pressure_end: state.pressure_end, + wiggle: state.wiggle, + }; + rep_state.vars.insert("i".to_string(), rep as f32); + rep_state.vars.insert("n".to_string(), count as f32); + for (_actual_ln, il) in &inner_lines { + let result = parse_single_line(&mut rep_state, il, *_actual_ln as u64)?; + actions.extend(result); + } + } + continue; + } + + let result = parse_single_line(&mut state, line, line_num as u64)?; + actions.extend(result); + } + + actions.push(ScriptAction::Finish { + summary: "Script executed successfully".to_string(), + }); + Ok(actions) +} + +/// Extracts a line number from a DSL error string (e.g. `"Line 42: ..."`). +/// +/// # Arguments +/// +/// * `err` — The error message to parse. +/// +/// # Returns +/// +/// The line number if the error matches the expected format, otherwise `None`. +pub fn parse_error_line(err: &str) -> Option { + let rest = err.strip_prefix("Line ")?; + let colon = rest.find(':')?; + rest[..colon].trim().parse().ok() +} + +/// Provides context-aware fix suggestions for common DSL parse errors. +/// +/// # Arguments +/// +/// * `err` — The error message to analyze. +/// +/// # Returns +/// +/// A static hint string if the error pattern is recognized, otherwise `None`. +pub fn suggest_fix(err: &str) -> Option<&'static str> { + if err.contains("gradient expects") { + Some("gradient x1,y1 x2,y2 #hex1 #hex2 N — e.g. gradient 0,0 800,600 #87CEEB #FFD700 30") + } else if err.contains("unexpected character") { + Some("Only numbers, $vars and +-*/ are valid in expressions. Remove keywords like 'steps'.") + } else if err.contains("scatter expects") { + Some("scatter N x,y w,h — e.g. scatter 20 100,400 600,160") + } else if err.contains("bezier expects") || err.contains("curve expects") { + Some("bezier x1,y1 cx1,cy1 cx2,cy2 x2,y2 [steps] — 4 control points") + } else if err.contains("draw needs at least 2") { + Some("draw x1,y1 x2,y2 ... — provide at least 2 coordinate pairs") + } else if err.contains("invalid point") || (err.contains("invalid") && err.contains("coordinates")) { + Some("Coordinates must be comma-separated without spaces: 100,200 not '100, 200'") + } else if err.contains("set syntax") { + Some("set key=value key=value — e.g. set brush=oil size=30 opacity=0.8") + } else if err.contains("unknown command") { + Some("Valid commands: layer, brush, color, size, opacity, hardness, flow, draw, line, rect, fill, circle, triangle, bezier, scatter, gradient, set, var, repeat, pressure, wiggle, blend") + } else if err.contains("repeat without") { + Some("repeat N {\n draw ...\n} — braces are required") + } else if err.contains("undefined variable") { + Some("Declare variables with: var name value — then reference as $name") + } else if err.contains("layer name missing") || err.contains("brush style missing") { + Some("This command requires an argument. Example: layer Sky / brush watercolor") + } else { + None + } +} + +/// Default DSL script demonstrating a mountain lake landscape. +/// +/// This script showcases the full range of DSL capabilities: +/// layer management, gradients, bezier curves, scatter, pressure +/// tapering, wiggle, blend modes, and multiple brush styles. +pub const DEFAULT_SCRIPT: &str = r#"# Mountain Lake Landscape — 800×600 +# Back-to-front drawing + +layer Sky +gradient 0,0 800,400 #4A80C9 #A0D8F0 24 + +brush clouds +opacity 0.20 +size 120 +hardness 0.05 +wiggle 4 +pressure 0.4 +draw 100,80 280,60 520,70 700,55 + +color #FFF8DC +size 160 +opacity 0.30 +hardness 0.05 +circle 660,90 + +layer FarMountains +blend multiply + +brush rock +color #7B8FA8 +size 200 +opacity 0.30 +hardness 0.35 +wiggle 2 +pressure_start 0.25 +pressure_end 0.55 +bezier 0,400 180,220 380,260 480,240 30 + +brush rock +color #6B7E9A +size 180 +opacity 0.35 +hardness 0.40 +wiggle 3 +pressure_start 0.30 +pressure_end 0.60 +bezier 320,400 520,180 640,240 800,350 30 + +layer MidMountains + +brush rock +color #5A6D85 +size 170 +opacity 0.50 +hardness 0.45 +wiggle 4 +pressure_start 0.35 +pressure_end 0.70 +bezier 0,420 240,200 500,260 800,400 32 + +color #4A5C73 +size 140 +opacity 0.65 +hardness 0.55 +pressure_start 0.40 +pressure_end 0.75 +bezier 0,400 160,260 360,340 580,380 28 + +layer Lake +color #3A7CC3 +opacity 0.75 +rect 0,380 800,600 + +layer Shore + +brush meadow +color #4A7A3F +size 90 +opacity 0.85 +hardness 0.35 +wiggle 6 +pressure_start 0.50 +pressure_end 0.90 +draw 0,430 180,390 350,415 550,395 750,430 + +layer Ground + +brush meadow +color #3A6B2E +size 110 +opacity 0.80 +hardness 0.30 +pressure_start 0.60 +pressure_end 0.85 +draw 0,460 800,465 + +brush meadow +color #5A8B3E +size 35 +opacity 0.40 +hardness 0.50 +wiggle 4 +draw 0,490 220,475 450,495 650,480 800,492 + +layer Flowers + +brush bristle +size 7 +opacity 0.90 +hardness 0.80 +color #E84060 +scatter 18 60,460 300,560 + +color #FFD740 +scatter 14 350,470 600,560 + +color #E86090 +scatter 12 500,465 700,560 +"#; diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/script/types.rs b/hcie-iced-app/crates/hcie-iced-gui/src/script/types.rs new file mode 100644 index 0000000..1583594 --- /dev/null +++ b/hcie-iced-app/crates/hcie-iced-gui/src/script/types.rs @@ -0,0 +1,64 @@ +use serde::{Deserialize, Serialize}; + +/// Persistent parser state for the DSL script interpreter. +/// +/// Tracks the current drawing context — active brush settings, color, +/// layer properties, variables, and pressure mapping — across lines +/// of a parsed DSL script. Each `repeat` block forks a copy of this +/// state so inner iterations can override values without leaking back +/// to the outer scope. +/// +/// # Fields +/// +/// * `color` — Current foreground color as a hex string (e.g. `"#FF0000"`). +/// * `style` — Current brush style name (e.g. `"round"`, `"watercolor"`). +/// * `size` — Current brush size in pixels. +/// * `opacity` — Current drawing opacity, clamped to `[0.0, 1.0]`. +/// * `hardness` — Current brush hardness, clamped to `[0.0, 1.0]`. +/// * `spacing` — Brush spacing factor derived from the `flow` setting. +/// * `blend_mode` — Current layer blend mode as a lowercase string. +/// * `vars` — User-defined variables accessible via `$name` syntax. +/// * `scatter_size` — Brush size used by the `scatter` command. +/// * `flow` — Brush flow value (higher = denser strokes). +/// * `pressure` — Uniform pen pressure multiplier. +/// * `pressure_start` — Pressure at stroke start for tapering. +/// * `pressure_end` — Pressure at stroke end for tapering. +/// * `wiggle` — Random displacement amplitude applied to stroke points. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScriptParserState { + pub color: String, + pub style: String, + pub size: f32, + pub opacity: f32, + pub hardness: f32, + pub spacing: f32, + pub blend_mode: String, + pub vars: std::collections::HashMap, + pub scatter_size: f32, + pub flow: f32, + pub pressure: f32, + pub pressure_start: f32, + pub pressure_end: f32, + pub wiggle: f32, +} + +impl Default for ScriptParserState { + fn default() -> Self { + Self { + color: "#000000".to_string(), + style: "round".to_string(), + size: 20.0, + opacity: 1.0, + hardness: 0.8, + spacing: 0.1, + blend_mode: "normal".to_string(), + vars: std::collections::HashMap::new(), + scatter_size: 12.0, + flow: 0.5, + pressure: 1.0, + pressure_start: 1.0, + pressure_end: 1.0, + wiggle: 0.0, + } + } +}