From 886b2bbc1bcf727fa66eed7f04727bcde950802a Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Wed, 4 Nov 2026 01:21:01 -0800 Subject: [PATCH 1/2] New node: 'L-System' --- Cargo.lock | 1 + frontend/src/icons.ts | 2 + node-graph/nodes/text/Cargo.toml | 1 + node-graph/nodes/text/src/l_system.rs | 655 ++++++++++++++++++++++++++ node-graph/nodes/text/src/lib.rs | 1 + 5 files changed, 660 insertions(+) create mode 100644 node-graph/nodes/text/src/l_system.rs diff --git a/Cargo.lock b/Cargo.lock index cb7a2ea999a..a9f806e30e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7252,6 +7252,7 @@ dependencies = [ "graphene-hash", "graphene-resource", "ipsum", + "math-parser", "node-macro", "parley", "rand", diff --git a/frontend/src/icons.ts b/frontend/src/icons.ts index 7bdee773a07..f8a11b98517 100644 --- a/frontend/src/icons.ts +++ b/frontend/src/icons.ts @@ -230,6 +230,7 @@ import NodeIsNonzero from "/../branding/assets/16x16-nodes/node-is-nonzero.svg"; import NodeItemAtIndex from "/../branding/assets/16x16-nodes/node-item-at-index.svg"; import NodeIterate from "/../branding/assets/16x16-nodes/node-iterate.svg"; import NodeJitterPoints from "/../branding/assets/16x16-nodes/node-jitter-points.svg"; +import NodeLSystem from "/../branding/assets/16x16-nodes/node-l-system.svg"; import NodeLeastCommonMultiple from "/../branding/assets/16x16-nodes/node-least-common-multiple.svg"; import NodeLerp from "/../branding/assets/16x16-nodes/node-lerp.svg"; import NodeLessThan from "/../branding/assets/16x16-nodes/node-less-than.svg"; @@ -524,6 +525,7 @@ export const ICON_16X16_NODES = { NodeLogicalNot: { svg: NodeLogicalNot, size: 16 }, NodeLogicalOr: { svg: NodeLogicalOr, size: 16 }, NodeLoremIpsum: { svg: NodeLoremIpsum, size: 16 }, + NodeLSystem: { svg: NodeLSystem, size: 16 }, NodeMagnitude: { svg: NodeMagnitude, size: 16 }, NodeMakeOpaque: { svg: NodeMakeOpaque, size: 16 }, NodeMandelbrot: { svg: NodeMandelbrot, size: 16 }, diff --git a/node-graph/nodes/text/Cargo.toml b/node-graph/nodes/text/Cargo.toml index 162107a05f0..63a9b96afa1 100644 --- a/node-graph/nodes/text/Cargo.toml +++ b/node-graph/nodes/text/Cargo.toml @@ -26,6 +26,7 @@ graphene-hash = { workspace = true } graphene-resource = { workspace = true } vector-types = { workspace = true } node-macro = { workspace = true } +math-parser = { workspace = true } # Workspace dependencies dyn-any = { workspace = true } diff --git a/node-graph/nodes/text/src/l_system.rs b/node-graph/nodes/text/src/l_system.rs new file mode 100644 index 00000000000..29a46367320 --- /dev/null +++ b/node-graph/nodes/text/src/l_system.rs @@ -0,0 +1,655 @@ +use core_types::Ctx; +use core_types::list::Item; +use core_types::math::float_noise::round_away_float_noise; +use core_types::misc::{format_f64, parse_f64}; +use math_parser::ast; +use math_parser::context::{EvalContext, NothingMap, ValueProvider}; +use math_parser::value::Value; +use rand::rngs::StdRng; +use rand::{Rng, SeedableRng}; +use std::collections::HashMap; + +/// The most symbols a generation may grow to, since each generation can multiply the string's length. +const MAX_SYMBOLS: usize = 1_000_000; + +/// Why the "L-System" node's string stops short of what its inputs describe. +#[derive(Debug, thiserror::Error)] +pub enum LSystemError { + #[error("Line {line} of the rules isn't a valid rule: {reason}")] + InvalidRule { line: usize, reason: String }, + #[error("The axiom isn't valid: {0}")] + InvalidAxiom(String), + #[error("A rule's math gives no number: {0}")] + Evaluation(String), + #[error("Stopped after {completed} generations, since the next would be longer than a million symbols")] + TooLong { completed: i64 }, +} + +/// Grows a string by rewriting all of its symbols at once with the rules, once per generation, for drawing with the **Turtle Path** node. +/// +/// Since every symbol with a rule is replaced in each generation, patterns repeat inside themselves at ever finer scales, like the branching of plants or the folds of fractal curves. +#[node_macro::node(name("L-System"), category("String"), icon("NodeLSystem"))] +fn l_system( + _: impl Ctx, + /// The string to start growing from. + axiom: Item, + /// One rule per line, like `F = F+F-F-F+F`, which replaces every `F` with `F+F-F-F+F` in each generation. Symbols without a rule stay as they are. + /// + /// A rule can also check the symbols around one, as in `A < B > C = D`, which rewrites a `B` only between an `A` and a `C`. When several rules for a symbol end with a chance, like `F = F[+F]F : 0.6`, one of them is picked at random by those odds. + /// + /// Symbols can carry numbers, like `F(10)`. A rule names them to work out new numbers with math, and to apply only when the condition after `:` holds, as in `F(x) : x > 2 = F(x/2)[+F(x/2)]`. + /// + /// Lines starting with `//` are skipped. + #[default("F = F+F-F-F+F")] + #[widget(ParsedWidgetOverride::Custom = "text_area")] + rules: Item, + /// How many times the rules rewrite the whole string. Each generation can multiply its length. + #[default(3)] + #[hard(0..)] + generations: Item, + /// Symbols to look past when a rule checks the symbols around one, usually the turns between them. + #[default("+-")] + ignored_symbols: Item, + /// Seed to determine unique variations on which rule is picked where several give chances. + #[hard(0..)] + seed: Item, +) -> Result, (Item, LSystemError)> { + // The grown string takes the axiom's place in its item, keeping the attributes it carries + let mut string = axiom; + + let axiom = match parse_axiom(string.element()) { + Ok(axiom) => axiom, + Err(reason) => { + string.element_mut().clear(); + return Err((string, LSystemError::InvalidAxiom(reason))); + } + }; + let (rule_set, rule_error) = RuleSet::parse(rules.element()); + let ignored_symbols: Vec = ignored_symbols.element().chars().filter(|symbol| !symbol.is_whitespace()).collect(); + + let (modules, rewrite_error) = rewrite(axiom, &rule_set, *generations.element(), &ignored_symbols, *seed.element() as u64); + *string.element_mut() = modules_to_string(&modules); + + // An invalid rule is reported first, since it is usually being typed and explains any other surprise + match rule_error.or(rewrite_error) { + Some(error) => Err((string, error)), + None => Ok(string), + } +} + +/// One symbol of an L-system string, with the numbers in parentheses after it, like `F(10, 2)`. +#[derive(Clone, Debug, PartialEq)] +struct Module { + symbol: char, + parameters: Vec, +} + +/// A symbol a rule matches, with names for the numbers it carries, like `F(x, y)`. +struct Pattern { + symbol: char, + parameter_names: Vec, +} + +/// A symbol a rule writes, with the math that works out each of its numbers. +struct WrittenSymbol { + symbol: char, + parameters: Vec, +} + +/// One line of the rules: `left < symbol > right : condition = successor : chance`, where all but the symbol and successor are optional. +struct Rule { + /// The symbols that must come before the rewritten one, in reading order. + left_context: Vec, + predecessor: Pattern, + /// The symbols that must come after the rewritten one, in reading order. + right_context: Vec, + condition: Option, + successor: Vec, + /// The odds of picking this rule among the other matching rules that give chances. + chance: Option, +} + +/// The rules, looked up by the symbol each one rewrites. +struct RuleSet { + rules: Vec, + by_symbol: HashMap>, + uses_context: bool, +} + +impl RuleSet { + /// Parses one rule per line, keeping the valid rules and the error of the first invalid one. + fn parse(text: &str) -> (Self, Option) { + let mut rules = Vec::new(); + let mut first_error = None; + + for (index, line) in text.lines().enumerate() { + let line = line.trim(); + if line.is_empty() || line.starts_with("//") { + continue; + } + + match parse_rule(line) { + Ok(rule) => rules.push(rule), + Err(reason) if first_error.is_none() => first_error = Some(LSystemError::InvalidRule { line: index + 1, reason }), + Err(_) => {} + } + } + + let mut by_symbol: HashMap> = HashMap::new(); + for (index, rule) in rules.iter().enumerate() { + by_symbol.entry(rule.predecessor.symbol).or_default().push(index); + } + let uses_context = rules.iter().any(|rule| !rule.left_context.is_empty() || !rule.right_context.is_empty()); + + (Self { rules, by_symbol, uses_context }, first_error) + } +} + +/// The numbers a matched rule's names stand for. +#[derive(Default)] +struct Bindings<'a>(Vec<(&'a str, f64)>); + +impl ValueProvider for Bindings<'_> { + fn get_value(&self, name: &str) -> Option { + self.0.iter().find(|(bound_name, _)| *bound_name == name).map(|&(_, value)| Value::from_f64(value)) + } +} + +impl<'a> Bindings<'a> { + /// Names the numbers of a module that matches the pattern, or gives `false` when the symbol or count of numbers differs. + fn bind(&mut self, pattern: &'a Pattern, module: &Module) -> bool { + if pattern.symbol != module.symbol || pattern.parameter_names.len() != module.parameters.len() { + return false; + } + + self.0.extend(pattern.parameter_names.iter().map(String::as_str).zip(module.parameters.iter().copied())); + true + } +} + +/// Rewrites every module once per generation, giving the last complete generation and why it stopped early, if it did. +fn rewrite(axiom: Vec, rule_set: &RuleSet, generations: i64, ignored_symbols: &[char], seed: u64) -> (Vec, Option) { + let mut rng = StdRng::seed_from_u64(seed); + let mut modules = axiom; + + for generation in 0..generations { + let partners = if rule_set.uses_context { bracket_partners(&modules) } else { Vec::new() }; + let neighbors = Neighbors { + modules: &modules, + partners: &partners, + ignored_symbols, + }; + + let mut next = Vec::with_capacity(modules.len()); + for (index, module) in modules.iter().enumerate() { + let chosen = match choose_rule(rule_set, &neighbors, index, &mut rng) { + Ok(chosen) => chosen, + Err(reason) => return (modules, Some(LSystemError::Evaluation(reason))), + }; + + let Some((rule, bindings)) = chosen else { + next.push(module.clone()); + continue; + }; + for written in &rule.successor { + let parameters = match written.parameters.iter().map(|expression| evaluate_number(expression, &bindings)).collect() { + Ok(parameters) => parameters, + Err(reason) => return (modules, Some(LSystemError::Evaluation(reason))), + }; + next.push(Module { symbol: written.symbol, parameters }); + } + + if next.len() > MAX_SYMBOLS { + return (modules, Some(LSystemError::TooLong { completed: generation })); + } + } + + modules = next; + } + + (modules, None) +} + +/// The rule that rewrites the module at `index` with its names bound. The first matching rule applies, unless it gives a chance, in which case one of the matching rules that give chances is picked by the odds. +fn choose_rule<'a>(rule_set: &'a RuleSet, neighbors: &Neighbors, index: usize, rng: &mut StdRng) -> Result)>, String> { + let module = &neighbors.modules[index]; + let Some(candidates) = rule_set.by_symbol.get(&module.symbol) else { return Ok(None) }; + + let mut chance_matches = Vec::new(); + for &rule_index in candidates { + let rule = &rule_set.rules[rule_index]; + let Some(bindings) = matching_bindings(rule, neighbors, index)? else { continue }; + + match rule.chance { + Some(chance) => chance_matches.push((rule, bindings, chance)), + None if chance_matches.is_empty() => return Ok(Some((rule, bindings))), + None => {} + } + } + + let total: f64 = chance_matches.iter().map(|(_, _, chance)| chance).sum(); + if total <= 0. { + return Ok(None); + } + + let mut pick = rng.random::() * total; + let last = chance_matches.len() - 1; + for (position, (rule, bindings, chance)) in chance_matches.into_iter().enumerate() { + if pick < chance || position == last { + return Ok(Some((rule, bindings))); + } + pick -= chance; + } + + Ok(None) +} + +/// The rule's names bound to the numbers of the module at `index` and its context, or `None` when the rule doesn't match there. +fn matching_bindings<'a>(rule: &'a Rule, neighbors: &Neighbors, index: usize) -> Result>, String> { + let mut bindings = Bindings::default(); + if !bindings.bind(&rule.predecessor, &neighbors.modules[index]) { + return Ok(None); + } + + // The left context is checked from the nearest symbol outward, so in reverse reading order + let mut position = index; + for pattern in rule.left_context.iter().rev() { + let Some(previous) = neighbors.previous(position) else { return Ok(None) }; + if !bindings.bind(pattern, &neighbors.modules[previous]) { + return Ok(None); + } + position = previous; + } + + let mut position = index; + for pattern in &rule.right_context { + let Some(next) = neighbors.next(position) else { return Ok(None) }; + if !bindings.bind(pattern, &neighbors.modules[next]) { + return Ok(None); + } + position = next; + } + + if let Some(condition) = &rule.condition + && evaluate_number(condition, &bindings)? == 0. + { + return Ok(None); + } + + Ok(Some(bindings)) +} + +/// A generation's modules, read the way context sees them: along the turtle's path, past side branches and ignored symbols. +struct Neighbors<'a> { + modules: &'a [Module], + /// The index of each bracket's partner, from [`bracket_partners`]. + partners: &'a [Option], + ignored_symbols: &'a [char], +} + +impl Neighbors<'_> { + /// The nearest module before `index` on the way back toward the start, stepping over whole branches and out of the one `index` sits in. + fn previous(&self, index: usize) -> Option { + let mut current = index; + while current > 0 { + current -= 1; + match self.modules[current].symbol { + ']' => current = self.partners[current]?, + '[' => {} + symbol if self.ignored_symbols.contains(&symbol) => {} + _ => return Some(current), + } + } + None + } + + /// The nearest module after `index` along the same branch, stepping over side branches, or `None` where the branch ends. + fn next(&self, index: usize) -> Option { + let mut current = index + 1; + while current < self.modules.len() { + match self.modules[current].symbol { + '[' => current = self.partners[current]? + 1, + ']' => return None, + symbol if self.ignored_symbols.contains(&symbol) => current += 1, + _ => return Some(current), + } + } + None + } +} + +/// The index of each `[` and `]` module's partner, so context matching can step over whole branches in O(1). +fn bracket_partners(modules: &[Module]) -> Vec> { + let mut partners = vec![None; modules.len()]; + let mut open = Vec::new(); + + for (index, module) in modules.iter().enumerate() { + match module.symbol { + '[' => open.push(index), + ']' => { + if let Some(opening) = open.pop() { + partners[index] = Some(opening); + partners[opening] = Some(index); + } + } + _ => {} + } + } + + partners +} + +/// Writes modules back out as a string, with each one's numbers in parentheses after it. +fn modules_to_string(modules: &[Module]) -> String { + let mut text = String::with_capacity(modules.len()); + + for module in modules { + text.push(module.symbol); + if !module.parameters.is_empty() { + let numbers: Vec = module.parameters.iter().map(|&number| format_f64(number)).collect(); + text.push('('); + text.push_str(&numbers.join(",")); + text.push(')'); + } + } + + text +} + +/// Reads the axiom's modules, whose numbers may be written as math like `F(10*2)`. +fn parse_axiom(text: &str) -> Result, String> { + split_symbols(text)? + .into_iter() + .map(|(symbol, parameters)| { + let parameters = arguments(parameters).into_iter().map(evaluate_constant).collect::>()?; + Ok(Module { symbol, parameters }) + }) + .collect() +} + +/// Parses one line of the rules. +fn parse_rule(line: &str) -> Result { + let Some(separator) = rule_separator(line) else { + return Err("it needs an `=` between the symbol and what it becomes".to_string()); + }; + let (left_side, right_side) = (&line[..separator], &line[separator + 1..]); + + // The left side reads `left < symbol > right : condition`, where all but the symbol are optional + let (pattern_text, condition_text) = split_at_first(left_side, ':'); + let (left_context_text, rest) = match split_at_first(pattern_text, '<') { + (left_context, Some(rest)) => (Some(left_context), rest), + (rest, None) => (None, rest), + }; + let (predecessor_text, right_context_text) = split_at_first(rest, '>'); + + let mut predecessor = parse_patterns(predecessor_text)?; + if predecessor.len() != 1 { + return Err("the left side must name exactly one symbol to rewrite".to_string()); + } + let predecessor = predecessor.remove(0); + + let left_context = left_context_text.map(parse_patterns).transpose()?.unwrap_or_default(); + let right_context = right_context_text.map(parse_patterns).transpose()?.unwrap_or_default(); + let condition = condition_text.map(parse_expression).transpose()?; + + // The right side reads `successor : chance`, where the chance is optional + let (successor_text, chance_text) = match right_side.rfind(':') { + Some(colon) => (&right_side[..colon], Some(right_side[colon + 1..].trim())), + None => (right_side, None), + }; + let chance = chance_text + .map(|text| { + parse_f64(text) + .filter(|chance| *chance >= 0.) + .ok_or_else(|| format!("the chance `{text}` isn't a number of zero or more")) + }) + .transpose()?; + + let successor = split_symbols(successor_text)? + .into_iter() + .map(|(symbol, parameters)| { + let parameters = arguments(parameters).into_iter().map(parse_expression).collect::>()?; + Ok(WrittenSymbol { symbol, parameters }) + }) + .collect::>()?; + + Ok(Rule { + left_context, + predecessor, + right_context, + condition, + successor, + chance, + }) +} + +/// Reads symbols with names for their numbers, like `A(x)B`, as a rule's predecessor or context. +fn parse_patterns(text: &str) -> Result, String> { + split_symbols(text)? + .into_iter() + .map(|(symbol, parameters)| { + let parameter_names = arguments(parameters) + .into_iter() + .map(|name| { + let name = name.trim(); + let mut characters = name.chars(); + let valid = characters.next().is_some_and(|first| first.is_ascii_lowercase()) && characters.all(|character| character.is_ascii_alphanumeric() || character == '_'); + if valid { + Ok(name.to_string()) + } else { + Err(format!("`{name}` can't name a number, since names start with a lowercase letter, like `x`")) + } + }) + .collect::>()?; + Ok(Pattern { symbol, parameter_names }) + }) + .collect() +} + +/// The byte index of the `=` between a rule's two sides, passing over the ones in comparisons like `==`, `<=`, `>=` and `!=`. +fn rule_separator(line: &str) -> Option { + let bytes = line.as_bytes(); + (0..bytes.len()).find(|&index| { + let previous = index.checked_sub(1).map(|previous| bytes[previous]); + bytes[index] == b'=' && !matches!(previous, Some(b'=' | b'<' | b'>' | b'!')) && bytes.get(index + 1) != Some(&b'=') + }) +} + +/// Splits the text at the first `delimiter` outside parentheses, giving the text before it and, if found, the text after it. +fn split_at_first(text: &str, delimiter: char) -> (&str, Option<&str>) { + let mut depth = 0; + for (index, character) in text.char_indices() { + match character { + '(' => depth += 1, + ')' => depth -= 1, + _ if character == delimiter && depth == 0 => return (&text[..index], Some(&text[index + character.len_utf8()..])), + _ => {} + } + } + (text, None) +} + +/// Reads a string as symbols, each with the text inside any parentheses right after it. Whitespace between symbols is skipped. +fn split_symbols(text: &str) -> Result)>, String> { + let mut symbols = Vec::new(); + let mut characters = text.char_indices().peekable(); + + while let Some((index, character)) = characters.next() { + if character.is_whitespace() { + continue; + } + if matches!(character, '(' | ')') { + return Err(format!("`{character}` must come right after a symbol, like `F(10)`")); + } + + let after = index + character.len_utf8(); + if !text[after..].starts_with('(') { + symbols.push((character, None)); + continue; + } + + let Some(close) = closing_parenthesis(text, after) else { + return Err(format!("the `(` after `{character}` is never closed")); + }; + symbols.push((character, Some(&text[after + 1..close]))); + while characters.next_if(|&(index, _)| index <= close).is_some() {} + } + + Ok(symbols) +} + +/// The byte index of the `)` that closes the `(` at `open`. +fn closing_parenthesis(text: &str, open: usize) -> Option { + let mut depth = 0; + for (index, character) in text[open..].char_indices() { + match character { + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + return Some(open + index); + } + } + _ => {} + } + } + None +} + +/// Splits the text inside a symbol's parentheses at its top-level commas, keeping the commas of nested calls like `max(a, b)`. +fn arguments(parameters: Option<&str>) -> Vec<&str> { + let Some(text) = parameters.filter(|text| !text.trim().is_empty()) else { return Vec::new() }; + + let mut pieces = Vec::new(); + let mut depth = 0; + let mut start = 0; + for (index, character) in text.char_indices() { + match character { + '(' => depth += 1, + ')' => depth -= 1, + ',' if depth == 0 => { + pieces.push(&text[start..index]); + start = index + 1; + } + _ => {} + } + } + pieces.push(&text[start..]); + + pieces +} + +fn parse_expression(text: &str) -> Result { + ast::Node::try_parse_from_str(text.trim()).map_err(|error| error.to_string()) +} + +/// Reads a written number, or works out the math of an expression like `10*2`. +fn evaluate_constant(text: &str) -> Result { + if let Some(number) = parse_f64(text.trim()) { + return Ok(number); + } + evaluate_number(&parse_expression(text)?, &Bindings::default()) +} + +/// Works out the math of an expression as a number, rounding away float noise so the string stays readable. +fn evaluate_number(expression: &ast::Node, bindings: &Bindings) -> Result { + let result = expression.eval(&EvalContext::new(bindings, NothingMap)).map_err(|error| error.to_string())?; + let number = result.as_value().and_then(|value| value.as_real()).ok_or_else(|| format!("`{result}` isn't a number"))?; + Ok(round_away_float_noise(number)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn grow(axiom: &str, rules: &str, generations: i64) -> Result { + grow_with(axiom, rules, generations, "+-", 0) + } + + fn grow_with(axiom: &str, rules: &str, generations: i64, ignored_symbols: &str, seed: i64) -> Result { + let item = |text: &str| Item::new_from_element(text.to_string()); + l_system((), item(axiom), item(rules), Item::new_from_element(generations), item(ignored_symbols), Item::new_from_element(seed)) + .map(Item::into_element) + .map_err(|(result, error)| (result.into_element(), error.to_string())) + } + + #[test] + fn rewrites_every_symbol_at_once() { + assert_eq!(grow("F", "F = F+F-F-F+F", 1).unwrap(), "F+F-F-F+F"); + assert_eq!(grow("FX", "X = X+YF+\nY = -FX-Y", 2).unwrap(), "FX+YF++-FX-YF+"); + assert_eq!(grow("FX", "X = X+YF+", 0).unwrap(), "FX"); + } + + #[test] + fn skips_whitespace_and_comment_lines() { + assert_eq!(grow("F G", "// F = G\n\nF = F + F", 1).unwrap(), "F+FG"); + } + + #[test] + fn matches_context_past_ignored_symbols_and_branches() { + assert_eq!(grow("ABC", "A < B > C = X", 1).unwrap(), "AXC"); + assert_eq!(grow("ABD", "A < B > C = X", 1).unwrap(), "ABD"); + assert_eq!(grow("A+BC", "A < B > C = X", 1).unwrap(), "A+XC"); + + // Left context steps out of the branch a symbol starts and over whole branches before it + assert_eq!(grow("A[B]", "A < B = X", 1).unwrap(), "A[X]"); + assert_eq!(grow("A[C]B", "A < B = X", 1).unwrap(), "A[C]X"); + + // Right context ends with the branch + assert_eq!(grow("[AB]", "A > B = X", 1).unwrap(), "[XB]"); + assert_eq!(grow("[A]B", "A > B = X", 1).unwrap(), "[A]B"); + } + + #[test] + fn the_first_matching_rule_applies() { + assert_eq!(grow("ABB", "A < B = X\nB = Y", 1).unwrap(), "AXY"); + } + + #[test] + fn works_out_parameters_and_conditions() { + let rules = "F(x) : x > 2 = F(x/2)[+F(x/2)]"; + assert_eq!(grow("F(10)", rules, 1).unwrap(), "F(5)[+F(5)]"); + assert_eq!(grow("F(5*2)", rules, 2).unwrap(), "F(2.5)[+F(2.5)][+F(2.5)[+F(2.5)]]"); + + // Below the condition's threshold, nothing changes + assert_eq!(grow("F(1)", rules, 3).unwrap(), "F(1)"); + + // A symbol carrying a different count of numbers doesn't match + assert_eq!(grow("F(1,2)F", rules, 1).unwrap(), "F(1,2)F"); + + // Context binds its own names + assert_eq!(grow("A(3)B(4)", "A(x) < B(y) = B(x+y)", 1).unwrap(), "A(3)B(7)"); + } + + #[test] + fn picks_among_chances_the_same_way_for_a_seed() { + let rules = "F = A : 1\nF = B : 1"; + let first = grow_with("FFFFFFFFFFFFFFFFFFFF", rules, 1, "", 7).unwrap(); + assert_eq!(first, grow_with("FFFFFFFFFFFFFFFFFFFF", rules, 1, "", 7).unwrap()); + assert!(first.contains('A') && first.contains('B')); + + // A chance of zero is never picked + assert_eq!(grow_with("FFFF", "F = A : 0\nF = B : 1", 1, "", 3).unwrap(), "BBBB"); + } + + #[test] + fn reports_the_first_invalid_line_and_keeps_the_valid_rules() { + let (result, error) = grow("F", "F = FF\nnonsense\nG(X) = G", 1).unwrap_err(); + assert_eq!(result, "FF"); + assert_eq!(error, "Line 2 of the rules isn't a valid rule: it needs an `=` between the symbol and what it becomes"); + + let (_, error) = grow("F", "G(X) = G", 1).unwrap_err(); + assert!(error.starts_with("Line 1 of the rules isn't a valid rule: `X` can't name a number")); + + let (result, error) = grow("F(", "", 1).unwrap_err(); + assert_eq!(result, ""); + assert_eq!(error, "The axiom isn't valid: the `(` after `F` is never closed"); + } + + #[test] + fn stops_before_a_generation_grows_too_long() { + let (result, error) = grow("F", "F = FFFFFFFFFF", 7).unwrap_err(); + assert_eq!(result.len(), 1_000_000); + assert_eq!(error, "Stopped after 6 generations, since the next would be longer than a million symbols"); + } +} diff --git a/node-graph/nodes/text/src/lib.rs b/node-graph/nodes/text/src/lib.rs index d7ab88bc3aa..f870d33cec9 100644 --- a/node-graph/nodes/text/src/lib.rs +++ b/node-graph/nodes/text/src/lib.rs @@ -1,6 +1,7 @@ pub mod fallback; mod font; pub mod json; +pub mod l_system; mod path_builder; pub mod regex; mod text_context; From 9a3c38b9f31121794836b7b0fab7a41d2113655b Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Wed, 4 Nov 2026 01:21:02 -0800 Subject: [PATCH 2/2] New node: 'Turtle Path' --- frontend/src/icons.ts | 2 + node-graph/libraries/core-types/src/list.rs | 9 + .../nodes/vector/src/generator_nodes.rs | 86 +++++- node-graph/nodes/vector/src/lib.rs | 1 + node-graph/nodes/vector/src/turtle.rs | 246 ++++++++++++++++++ 5 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 node-graph/nodes/vector/src/turtle.rs diff --git a/frontend/src/icons.ts b/frontend/src/icons.ts index f8a11b98517..ba811e57dd4 100644 --- a/frontend/src/icons.ts +++ b/frontend/src/icons.ts @@ -376,6 +376,7 @@ import NodeText from "/../branding/assets/16x16-nodes/node-text.svg"; import NodeThreshold from "/../branding/assets/16x16-nodes/node-threshold.svg"; import NodeTransform from "/../branding/assets/16x16-nodes/node-transform.svg"; import NodeTriangulate from "/../branding/assets/16x16-nodes/node-triangulate.svg"; +import NodeTurtlePath from "/../branding/assets/16x16-nodes/node-turtle-path.svg"; import NodeUngroup from "/../branding/assets/16x16-nodes/node-ungroup.svg"; import NodeUnweld from "/../branding/assets/16x16-nodes/node-unweld.svg"; import NodeVaryFillColors from "/../branding/assets/16x16-nodes/node-vary-fill-colors.svg"; @@ -659,6 +660,7 @@ export const ICON_16X16_NODES = { NodeThreshold: { svg: NodeThreshold, size: 16 }, NodeTransform: { svg: NodeTransform, size: 16 }, NodeTriangulate: { svg: NodeTriangulate, size: 16 }, + NodeTurtlePath: { svg: NodeTurtlePath, size: 16 }, NodeUngroup: { svg: NodeUngroup, size: 16 }, NodeUnweld: { svg: NodeUnweld, size: 16 }, NodeVaryFillColors: { svg: NodeVaryFillColors, size: 16 }, diff --git a/node-graph/libraries/core-types/src/list.rs b/node-graph/libraries/core-types/src/list.rs index 1c75ea823ad..83be7da6a71 100644 --- a/node-graph/libraries/core-types/src/list.rs +++ b/node-graph/libraries/core-types/src/list.rs @@ -124,6 +124,15 @@ pub const ATTR_SMOOTH: &str = "smooth"; /// the internal addressing: a `PointId` on a `Vector`'s point pool, or a `SegmentId` on the `List` /// inside a `Path` at the anchor reference the segment departs from. pub const ATTR_ID: &str = "id"; +/// Point's `i64` count of the `[` branches the turtle was nested in when drawing it, on a `Vector`'s point pool from Turtle Path. +pub const ATTR_DEPTH: &str = "depth"; +/// Point's `f64` distance the turtle traveled from its start to reach it, on a `Vector`'s point pool from Turtle Path. +pub const ATTR_DISTANCE: &str = "distance"; +/// Point's `f64` direction in degrees the turtle faced arriving at it (`0.` along +X, clockwise on screen), on a `Vector`'s +/// point pool from Turtle Path. +pub const ATTR_HEADING: &str = "heading"; +/// Point's `f64` width the turtle drew with there (`1.` unless set by `!(w)`), on a `Vector`'s point pool from Turtle Path. +pub const ATTR_WIDTH: &str = "width"; /// Text item's font size in document-space units (`f64`, implicit default `24.`). pub const ATTR_FONT_SIZE: &str = "font_size"; /// Text item's font, as a `Resource` of the loaded font file. diff --git a/node-graph/nodes/vector/src/generator_nodes.rs b/node-graph/nodes/vector/src/generator_nodes.rs index 12697de4547..90a28922079 100644 --- a/node-graph/nodes/vector/src/generator_nodes.rs +++ b/node-graph/nodes/vector/src/generator_nodes.rs @@ -459,10 +459,36 @@ fn grid( .into_item()) } +/// Draws paths by following a string of turtle commands, like the ones an **L-System** grows. A drawing symbol (`F` or `G`) moves forward a step while drawing, `f` moves without drawing, `+` and `-` turn left and right, `|` turns around, and `[` and `]` begin and end a branch. +/// +/// A number in parentheses gives one command its own amount, like `F(25)` or `+(30)`, and `!(2)` sets the width. Each point records its branch depth, distance traveled, heading, and width as attributes. +#[node_macro::node(category("String"), icon("NodeTurtlePath"))] +fn turtle_path( + _: impl Ctx, + /// The commands for the turtle to follow, starting at the origin and facing up. Symbols without a meaning are skipped. + commands: Item, + /// How far each step forward moves, unless a command gives its own distance like `F(25)`. + #[unit(" px")] + #[default(10.)] + step: Item, + /// How far `+` and `-` turn, unless a command gives its own angle like `+(30)`. + #[unit("°")] + #[default(90.)] + angle: Item, + /// The symbols that move forward while drawing. + #[default("FG")] + draw_symbols: Item, + /// The symbols that move forward without drawing. + #[default("f")] + move_symbols: Item, +) -> Item { + crate::turtle::turtle_path(commands.element(), *step.element(), *angle.element(), draw_symbols.element(), move_symbols.element()) +} + #[cfg(test)] mod tests { use super::*; - use core_types::list::ATTR_SMOOTH; + use core_types::list::{ATTR_DEPTH, ATTR_DISTANCE, ATTR_HEADING, ATTR_SMOOTH, ATTR_WIDTH}; use kurbo::ParamCurve; use vector_types::vector::misc::point_to_dvec2; use vector_types::vector::shared_paths_from_item_attributes; @@ -706,4 +732,62 @@ mod tests { assert!(anchors.iter().all(|anchor| (anchor.length() - 25.).abs() < 1e-9)); } } + + fn turtle(commands: &str) -> Item { + turtle_path((), item(commands.to_string()), item(10.), item(90.), item("FG".to_string()), item("f".to_string())) + } + + /// Each path's pool references and whether it closes. + fn turtle_runs(drawing: &Item) -> Vec<(Vec, bool)> { + let paths = shared_paths_from_item_attributes(drawing); + paths.iter_element_values().map(|path| (path.references().collect(), path.is_closed())).collect() + } + + fn point_attributes(drawing: &Item, key: &str) -> Vec { + drawing.element().0.iter_attribute_values::(key).map(|values| values.copied().collect()).unwrap_or_default() + } + + #[test] + fn turtle_square_closes_on_its_start() { + let square = turtle("F+F+F+F"); + + assert_eq!(turtle_runs(&square), [(vec![0, 1, 2, 3, 0], true)]); + assert_eq!( + square.element().positions().collect::>(), + [DVec2::ZERO, DVec2::new(0., -10.), DVec2::new(-10., -10.), DVec2::new(-10., 0.)] + ); + } + + #[test] + fn turtle_branches_weld_to_one_trunk() { + let tree = turtle("F[+F]F"); + + // The trunk stays one run through the junction, where the branch begins + assert_eq!(turtle_runs(&tree), [(vec![0, 1, 3], false), (vec![1, 2], false)]); + assert_eq!(point_attributes::(&tree, ATTR_DEPTH), [0, 0, 1, 0]); + assert_eq!(point_attributes::(&tree, ATTR_DISTANCE), [0., 10., 20., 20.]); + assert_eq!(point_attributes::(&tree, ATTR_HEADING), [-90., -90., 180., -90.]); + + // Branches leaving before anything is drawn still share their starting point + assert_eq!(turtle_runs(&turtle("[+F][-F]F")), [(vec![0, 1], false), (vec![0, 2], false), (vec![0, 3], false)]); + } + + #[test] + fn turtle_moves_break_paths_and_parameters_override_amounts() { + let drawing = turtle("FfF(25)+(45)F"); + + assert_eq!(turtle_runs(&drawing), [(vec![0, 1], false), (vec![2, 3, 4], false)]); + let positions: Vec = drawing.element().positions().collect(); + assert_eq!(positions[3], DVec2::new(0., -45.)); + assert!((positions[4] - DVec2::new(-10. * 0.5_f64.sqrt(), -45. - 10. * 0.5_f64.sqrt())).length() < 1e-9); + } + + #[test] + fn turtle_width_is_restored_after_a_branch() { + let drawing = turtle("F[!(3)F]F"); + + assert_eq!(point_attributes::(&drawing, ATTR_WIDTH), [1., 1., 3., 1.]); + assert_eq!(point_attributes::(&turtle(""), ATTR_WIDTH), Vec::::new()); + assert!(turtle("").element().0.iter_attribute_values::(ATTR_WIDTH).is_some(), "an empty drawing still has the attribute"); + } } diff --git a/node-graph/nodes/vector/src/lib.rs b/node-graph/nodes/vector/src/lib.rs index 3da8313a7f4..34bd10bec13 100644 --- a/node-graph/nodes/vector/src/lib.rs +++ b/node-graph/nodes/vector/src/lib.rs @@ -1,6 +1,7 @@ pub mod generator_nodes; mod grid_topology; pub mod merge_qr_squares; +mod turtle; pub mod vector_modification_nodes; mod vector_nodes; mod voronoi; diff --git a/node-graph/nodes/vector/src/turtle.rs b/node-graph/nodes/vector/src/turtle.rs new file mode 100644 index 00000000000..4843e058935 --- /dev/null +++ b/node-graph/nodes/vector/src/turtle.rs @@ -0,0 +1,246 @@ +use core_types::list::{ATTR_DEPTH, ATTR_DISTANCE, ATTR_HEADING, ATTR_ID, ATTR_WIDTH, Item, List}; +use core_types::misc::parse_f64; +use glam::DVec2; +use graphic_types::Vector; +use vector_types::vector::{CurveType, Path, PointId, SegmentId, SetVectorPaths, push_paths}; + +/// How close, as a fraction of the step just drawn, a draw must land on its path's first point to close the path there. +const CLOSURE_TOLERANCE: f64 = 1e-6; + +/// The turtle's place, direction, and pen, which `[` saves and `]` restores. +#[derive(Clone, Copy)] +struct Turtle { + position: DVec2, + /// Degrees, with `0.` along +X and positive angles turning clockwise on screen. + heading: f64, + distance: f64, + depth: i64, + width: f64, + /// The drawing's slot for the point the turtle stands on, which a branch shares with its parent so both weld there. + anchor_slot: usize, + /// The run being drawn, as an index into the drawing's runs. + run: Option, +} + +impl Turtle { + fn turn(&mut self, degrees: f64) { + self.heading = (self.heading + degrees).rem_euclid(360.); + } + + /// Moves forward regardless of the pen, counting the distance traveled. + fn advance(&mut self, length: f64) { + self.position += length * direction(self.heading); + self.distance += length.abs(); + } +} + +/// The unit vector a heading in degrees points along, exact for multiples of 90° so square lattices stay on whole numbers. +fn direction(heading: f64) -> DVec2 { + let quarter_turns = heading / 90.; + if quarter_turns.fract() == 0. { + return match (quarter_turns as i64).rem_euclid(4) { + 0 => DVec2::X, + 1 => DVec2::Y, + 2 => DVec2::NEG_X, + _ => DVec2::NEG_Y, + }; + } + + DVec2::from_angle(heading.to_radians()) +} + +/// A place the turtle has stood, which gains its point the first time something is drawn from it. +#[derive(Clone, Copy)] +enum AnchorSlot { + Drawn(u32), + /// The turtle as it arrived, whose state stamps the point once one is drawn. + Undrawn(Turtle), +} + +/// The points and path runs a turtle lays down, with each point's attributes alongside. +#[derive(Default)] +struct Drawing { + positions: Vec, + depths: Vec, + distances: Vec, + headings: Vec, + widths: Vec, + /// Pool indices of each run, in the order the runs began. + runs: Vec>, + anchor_slots: Vec, +} + +impl Drawing { + fn new_anchor_slot(&mut self, slot: AnchorSlot) -> usize { + self.anchor_slots.push(slot); + self.anchor_slots.len() - 1 + } + + /// Adds a point where the turtle stands, stamped with its state, and gives its pool index. + fn push_point(&mut self, turtle: &Turtle) -> u32 { + self.positions.push(turtle.position); + self.depths.push(turtle.depth); + self.distances.push(turtle.distance); + // Reported from -180° to 180°, so the starting heading up the screen reads as -90° + self.headings.push(180. - (180. - turtle.heading).rem_euclid(360.)); + self.widths.push(turtle.width); + + (self.positions.len() - 1) as u32 + } + + /// The point of the place the turtle stands, drawing it there if this is the first time it's needed. + fn anchor_point(&mut self, turtle: &Turtle) -> u32 { + let arrival = match self.anchor_slots.get(turtle.anchor_slot) { + Some(AnchorSlot::Drawn(anchor)) => return *anchor, + Some(AnchorSlot::Undrawn(arrival)) => *arrival, + None => { + log::error!("The turtle stands at a place the drawing has no slot for"); + *turtle + } + }; + + let anchor = self.push_point(&arrival); + if let Some(slot) = self.anchor_slots.get_mut(turtle.anchor_slot) { + *slot = AnchorSlot::Drawn(anchor); + } + anchor + } + + /// Draws a segment forward from the turtle, continuing its run or beginning one from the point it stands on. + fn draw(&mut self, turtle: &mut Turtle, length: f64) { + let start = self.anchor_point(turtle); + let run = match turtle.run { + Some(run) => run, + None => { + self.runs.push(vec![start]); + self.runs.len() - 1 + } + }; + + turtle.advance(length); + + // Landing back on the run's first point closes the run there rather than adding a duplicate point + let first = self.runs[run][0]; + let closes = self.runs[run].len() >= 3 && turtle.position.distance(self.positions[first as usize]) <= CLOSURE_TOLERANCE * length.abs(); + let end = if closes { + turtle.position = self.positions[first as usize]; + first + } else { + self.push_point(turtle) + }; + + self.runs[run].push(end); + turtle.anchor_slot = self.new_anchor_slot(AnchorSlot::Drawn(end)); + turtle.run = Some(run); + } + + /// Builds the vector, numbering the points and then the segments in the order they were drawn. + fn into_item(self) -> Item { + let point_count = self.positions.len(); + let mut vector = Vector(List::new_from_elements(self.positions)); + + if point_count > 0 { + let mut point_id = PointId::ZERO; + vector.0.set_attribute_values(ATTR_ID, (0..point_count).map(|_| point_id.next_id()).collect::>()); + } + vector.0.set_attribute_values(ATTR_DEPTH, self.depths); + vector.0.set_attribute_values(ATTR_DISTANCE, self.distances); + vector.0.set_attribute_values(ATTR_HEADING, self.headings); + vector.0.set_attribute_values(ATTR_WIDTH, self.widths); + + let mut segment_id = SegmentId::ZERO; + let runs = self.runs.into_iter().filter(|references| references.len() > 1).map(|references| { + let ids = (1..references.len()).map(|_| segment_id.next_id()).collect(); + (CurveType::Linear, references, ids) + }); + let mut paths = List::::new(); + push_paths(&mut paths, runs); + + Item::new_from_element(vector).with_paths(paths) + } +} + +/// Splits a parenthesized parameter list off the front of the commands, giving its insides and the commands after it. +/// An unclosed list runs to the end of the commands. +fn split_parameters(commands: &str) -> (&str, &str) { + let mut nesting = 0; + for (index, character) in commands.char_indices() { + match character { + '(' => nesting += 1, + ')' => { + nesting -= 1; + if nesting == 0 { + return (&commands[1..index], &commands[index + 1..]); + } + } + _ => {} + } + } + + (&commands[1..], "") +} + +/// Follows turtle commands from the origin, facing up the screen, and gives the paths it draws. +pub(crate) fn turtle_path(commands: &str, step: f64, angle: f64, draw_symbols: &str, move_symbols: &str) -> Item { + let mut drawing = Drawing::default(); + let mut turtle = Turtle { + position: DVec2::ZERO, + heading: 270., + distance: 0., + depth: 0, + width: 1., + anchor_slot: 0, + run: None, + }; + turtle.anchor_slot = drawing.new_anchor_slot(AnchorSlot::Undrawn(turtle)); + let mut saved = Vec::new(); + + let mut remaining = commands; + while let Some(symbol) = remaining.chars().next() { + remaining = &remaining[symbol.len_utf8()..]; + + // A parenthesized list after a symbol belongs to it, and only its first number is read + let parameter = if remaining.starts_with('(') { + let (parameters, after) = split_parameters(remaining); + remaining = after; + parameters.split(',').next().and_then(|parameter| parse_f64(parameter.trim())) + } else { + None + }; + + if draw_symbols.contains(symbol) { + drawing.draw(&mut turtle, parameter.unwrap_or(step)); + continue; + } + if move_symbols.contains(symbol) { + turtle.advance(parameter.unwrap_or(step)); + turtle.run = None; + turtle.anchor_slot = drawing.new_anchor_slot(AnchorSlot::Undrawn(turtle)); + continue; + } + + match symbol { + '+' => turtle.turn(-parameter.unwrap_or(angle)), + '-' => turtle.turn(parameter.unwrap_or(angle)), + '|' => turtle.turn(180.), + '[' => { + saved.push(turtle); + turtle.depth += 1; + turtle.run = None; + } + ']' => { + if let Some(restored) = saved.pop() { + turtle = restored; + } + } + '!' => { + if let Some(width) = parameter { + turtle.width = width; + } + } + _ => {} + } + } + + drawing.into_item() +}