diff --git a/.rustfmt.toml b/.rustfmt.toml new file mode 100644 index 0000000..f216078 --- /dev/null +++ b/.rustfmt.toml @@ -0,0 +1 @@ +edition = "2024" diff --git a/Cargo.toml b/Cargo.toml index 3e8bfa5..1663bc3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,6 +67,10 @@ path = "tools/streamline.rs" name = "snap" path = "tools/snap.rs" +[[bin]] +name = "attractor" +path = "tools/attractor.rs" + [[bin]] name = "geom2graph" path = "tools/geom2graph.rs" @@ -79,6 +83,7 @@ cxx = { version = "1.0", optional = true } delaunator = "1.0" eyre = "0.6.12" geo = "0.31" +image = { version = "0.25.8", default-features = false, features = ["png"] } itertools = "0.14" kdtree = "0.7" noise = "0.9" diff --git a/README.md b/README.md index 36b242b..b7e5250 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ A polyglot collection of composable generative art tools, with a focus on 2D com * [How to build](#how-to-build) * [Philosophy](#philosophy) * [Examples](#examples) + * [Chaotic attractors](#chaotic-attractors) * [Asemic writing](#asemic-writing) * [Random L-Systems](#random-l-systems) * [The tools](#the-tools) @@ -29,6 +30,7 @@ A polyglot collection of composable generative art tools, with a focus on 2D com * [streamline](#streamline) * [traverse](#traverse) * [urquhart](#urquhart) + * [attractor](#attractor) * [Transformations](#transformations) * [project.py](#projectpy) * [geom2graph](#geom2graph) @@ -96,6 +98,24 @@ to produce/consume a standard textual interface. * Graphs are in [TGF](https://en.wikipedia.org/wiki/Trivial_Graph_Format) format # Examples + +## Chaotic attractors +See the [`attractor`](#attractor) tool for more details on how to generate these attractor images. + +![](examples/attractor/clifford.png) + +![](examples/attractor/ikeda.png) + +![](examples/attractor/johnny-svensson.png) + +![](examples/attractor/peter-de-jong.png) + +![](examples/attractor/tinkerbell.png) + +![](examples/attractor/fractal-dreams-ssss.png) + +![](examples/attractor/gumowski-mira.png) + ## Asemic writing The following snippet generates random asemic writing glyphs ```sh @@ -553,6 +573,27 @@ point-cloud --seed 11878883030565683752 --points 20 --scale 200 | ``` ![](examples/urquhart/urquhart.svg) +### attractor +The `attractor` tool is very similar to the [`streamline`](#streamline) tool. It takes a Rune script +defining a 2D dynamical system, and traces the trajectories of points in that system. + +For example the [tinkerbell.rn](examples/attractor/tinkerbell.rn) script defines the Tinkerbell +attractor: + +```sh +attractor \ + --script ./examples/attractor/tinkerbell.rn \ + --output ./examples/attractor/tinkerbell.png \ + --output-format image \ + -x=-0.72 \ + -y=-0.64 \ + --iterations 500000 +``` +![](examples/attractor/tinkerbell.png) + +You may use `--output-format` to get WKT POINTs or LINESTRINGs instead of a PNG image, but the PGN +image looks better than an SVG rendering of the WKT geometries. + ## Transformations ### project.py The `project.py` tool can be used to project 3D geometries to 2D. It supports several projection diff --git a/README.md.in b/README.md.in index c8f914c..68f95a8 100644 --- a/README.md.in +++ b/README.md.in @@ -12,6 +12,7 @@ A polyglot collection of composable generative art tools, with a focus on 2D com * [How to build](#how-to-build) * [Philosophy](#philosophy) * [Examples](#examples) + * [Chaotic attractors](#chaotic-attractors) * [Asemic writing](#asemic-writing) * [Random L-Systems](#random-l-systems) * [The tools](#the-tools) @@ -29,6 +30,7 @@ A polyglot collection of composable generative art tools, with a focus on 2D com * [streamline](#streamline) * [traverse](#traverse) * [urquhart](#urquhart) + * [attractor](#attractor) * [Transformations](#transformations) * [project.py](#projectpy) * [geom2graph](#geom2graph) @@ -96,6 +98,24 @@ to produce/consume a standard textual interface. * Graphs are in [TGF](https://en.wikipedia.org/wiki/Trivial_Graph_Format) format # Examples + +## Chaotic attractors +See the [`attractor`](#attractor) tool for more details on how to generate these attractor images. + +![](examples/attractor/clifford.png) + +![](examples/attractor/ikeda.png) + +![](examples/attractor/johnny-svensson.png) + +![](examples/attractor/peter-de-jong.png) + +![](examples/attractor/tinkerbell.png) + +![](examples/attractor/fractal-dreams-ssss.png) + +![](examples/attractor/gumowski-mira.png) + ## Asemic writing The following snippet generates random asemic writing glyphs ```sh @@ -347,6 +367,21 @@ triangulation. ``` ![](examples/urquhart/urquhart.svg) +### attractor +The `attractor` tool is very similar to the [`streamline`](#streamline) tool. It takes a Rune script +defining a 2D dynamical system, and traces the trajectories of points in that system. + +For example the [tinkerbell.rn](examples/attractor/tinkerbell.rn) script defines the Tinkerbell +attractor: + +```sh +@TINKERBELL_SNIPPET@ +``` +![](examples/attractor/tinkerbell.png) + +You may use `--output-format` to get WKT POINTs or LINESTRINGs instead of a PNG image, but the PGN +image looks better than an SVG rendering of the WKT geometries. + ## Transformations ### project.py The `project.py` tool can be used to project 3D geometries to 2D. It supports several projection diff --git a/examples/attractor/clifford.png b/examples/attractor/clifford.png new file mode 100644 index 0000000..bcb65ce Binary files /dev/null and b/examples/attractor/clifford.png differ diff --git a/examples/attractor/clifford.rn b/examples/attractor/clifford.rn new file mode 100644 index 0000000..f4bfcee --- /dev/null +++ b/examples/attractor/clifford.rn @@ -0,0 +1,7 @@ +let a = -2.0; +let b = -2.4; +let c = 1.1; +let d = -0.9; + +let x_new = f64::sin(a * y) + c * f64::cos(a * x); +let y_new = f64::sin(b * x) + d * f64::cos(b * y); diff --git a/examples/attractor/fractal-dreams-ssss.png b/examples/attractor/fractal-dreams-ssss.png new file mode 100644 index 0000000..6f378eb Binary files /dev/null and b/examples/attractor/fractal-dreams-ssss.png differ diff --git a/examples/attractor/fractal-dreams-ssss.rn b/examples/attractor/fractal-dreams-ssss.rn new file mode 100644 index 0000000..06ac2da --- /dev/null +++ b/examples/attractor/fractal-dreams-ssss.rn @@ -0,0 +1,9 @@ +// From Chaos in Wonderland, version SSSS + +let a = 1.468; +let b = 2.407; +let c = 0.194; +let d = 1.438; + +let x_new = f64::sin(y * b) + c * f64::sin(x * b); +let y_new = f64::sin(x * a) + d * f64::sin(y * a); diff --git a/examples/attractor/generate.sh b/examples/attractor/generate.sh new file mode 100755 index 0000000..708487e --- /dev/null +++ b/examples/attractor/generate.sh @@ -0,0 +1,71 @@ +#!/bin/bash +set -o errexit +set -o pipefail +set -o nounset +set -o noclobber + +debug "Generating clifford ..." +attractor \ + --seed 1193803725491079949 \ + --script ./examples/attractor/clifford.rn \ + --output ./examples/attractor/clifford.png \ + --output-format image \ + --num-points 400 \ + --iterations 800 + +debug "Generating ikeda ..." +attractor \ + --seed 14245741203239691500 \ + --script ./examples/attractor/ikeda.rn \ + --output ./examples/attractor/ikeda.png \ + --output-format image \ + --num-points 500 \ + --iterations 500 + +debug "Generating johnny-svensson ..." +attractor \ + --seed 2310402659768744900 \ + --script ./examples/attractor/johnny-svensson.rn \ + --output ./examples/attractor/johnny-svensson.png \ + --output-format image \ + --num-points 200 \ + --iterations 800 + +debug "Generating peter-de-jong ..." +attractor \ + --seed 10329922707181609977 \ + --script ./examples/attractor/peter-de-jong.rn \ + --output ./examples/attractor/peter-de-jong.png \ + --output-format image \ + --num-points 200 \ + --iterations 800 + +debug "Generating tinkerbell ..." +# BEGIN TINKERBELL_SNIPPET +attractor \ + --script ./examples/attractor/tinkerbell.rn \ + --output ./examples/attractor/tinkerbell.png \ + --output-format image \ + -x=-0.72 \ + -y=-0.64 \ + --iterations 500000 +# END TINKERBELL_SNIPPET +extract_snippet TINKERBELL_SNIPPET + +debug "Generating fractal-dreams-ssss ..." +attractor \ + --seed 4392994853744049110 \ + --script ./examples/attractor/fractal-dreams-ssss.rn \ + --output ./examples/attractor/fractal-dreams-ssss.png \ + --output-format image \ + --num-points 1000 \ + --iterations 2000 + +debug "Generating gumowski-mira ..." +attractor \ + --seed 6844197751594810350 \ + --script ./examples/attractor/gumowski-mira.rn \ + --output ./examples/attractor/gumowski-mira.png \ + --output-format image \ + --num-points 1000 \ + --iterations 5000 diff --git a/examples/attractor/gumowski-mira.png b/examples/attractor/gumowski-mira.png new file mode 100644 index 0000000..602c0c2 Binary files /dev/null and b/examples/attractor/gumowski-mira.png differ diff --git a/examples/attractor/gumowski-mira.rn b/examples/attractor/gumowski-mira.rn new file mode 100644 index 0000000..5fab7aa --- /dev/null +++ b/examples/attractor/gumowski-mira.rn @@ -0,0 +1,12 @@ +let a = 0.192; +let b = 0.982; + +fn f(x) { + let a = 0.192; + let b = 0.982; + + a * x + 2.0 * (1.0 - a) * x.powi(2) / (1.0 + x.powi(2)).powi(2) +} + +let x_new = b * y + f(x); +let y_new = f(x_new) - x; diff --git a/examples/attractor/ikeda.png b/examples/attractor/ikeda.png new file mode 100644 index 0000000..31d24bb Binary files /dev/null and b/examples/attractor/ikeda.png differ diff --git a/examples/attractor/ikeda.rn b/examples/attractor/ikeda.rn new file mode 100644 index 0000000..efc939f --- /dev/null +++ b/examples/attractor/ikeda.rn @@ -0,0 +1,6 @@ +// ikeda is a chaotic attractor for u>=0.6 +let u = 0.9; + +let t_n = 0.4 - 6.0 / (1.0 + x.powi(2) + y.powi(2)); +let x_new = 1.0 + u * (x * f64::cos(t_n) - y * f64::sin(t_n)); +let y_new = u * (x * f64::sin(t_n) + y * f64::cos(t_n)); diff --git a/examples/attractor/johnny-svensson.png b/examples/attractor/johnny-svensson.png new file mode 100644 index 0000000..5059699 Binary files /dev/null and b/examples/attractor/johnny-svensson.png differ diff --git a/examples/attractor/johnny-svensson.rn b/examples/attractor/johnny-svensson.rn new file mode 100644 index 0000000..02605f1 --- /dev/null +++ b/examples/attractor/johnny-svensson.rn @@ -0,0 +1,7 @@ +let a = 1.4; +let b = 1.56; +let c = 1.4; +let d = -6.56; + +let x_new = d * f64::sin(a * x) - f64::sin(b * y); +let y_new = c * f64::cos(a * x) + f64::cos(b * y); diff --git a/examples/attractor/peter-de-jong.png b/examples/attractor/peter-de-jong.png new file mode 100644 index 0000000..210c135 Binary files /dev/null and b/examples/attractor/peter-de-jong.png differ diff --git a/examples/attractor/peter-de-jong.rn b/examples/attractor/peter-de-jong.rn new file mode 100644 index 0000000..c426f59 --- /dev/null +++ b/examples/attractor/peter-de-jong.rn @@ -0,0 +1,7 @@ +let a = 0.970; +let b = -1.899; +let c = 1.381; +let d = -1.506; + +let x_new = f64::sin(a * y) - f64::cos(b * x); +let y_new = f64::sin(c * x) - f64::cos(d * y); diff --git a/examples/attractor/tinkerbell.png b/examples/attractor/tinkerbell.png new file mode 100644 index 0000000..74e6deb Binary files /dev/null and b/examples/attractor/tinkerbell.png differ diff --git a/examples/attractor/tinkerbell.rn b/examples/attractor/tinkerbell.rn new file mode 100644 index 0000000..818e3f2 --- /dev/null +++ b/examples/attractor/tinkerbell.rn @@ -0,0 +1,7 @@ +let a = 0.9; +let b = -0.6013; +let c = 2.0; +let d = 0.5; + +let x_new = x.powi(2) - y.powi(2) + a * x + b * y; +let y_new = 2.0 * x * y + c * x + d * y; diff --git a/generative/attractor.rs b/generative/attractor.rs new file mode 100644 index 0000000..54575ac --- /dev/null +++ b/generative/attractor.rs @@ -0,0 +1,192 @@ +use std::io::{BufWriter, Write}; +use std::path::{Path, PathBuf}; + +use geo::{BoundingRect, Coord, LineString}; +use wkt::ToWkt; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)] +pub enum OutputFormat { + /// Write out each visited point as a WKT POINT + Points, + /// Write out each visited point in a WKT LINESTRING + Line, + /// Write out the visited points as a PNG image + Image, +} + +impl std::fmt::Display for OutputFormat { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + // important: Should match clap::ValueEnum format + OutputFormat::Points => write!(f, "points"), + OutputFormat::Line => write!(f, "line"), + OutputFormat::Image => write!(f, "image"), + } + } +} + +pub struct AttractorFormatter { + format: OutputFormat, + writer: BufWriter>, + output: Option, + + accumulated: Vec, + + width: Option, + height: Option, +} + +// public +impl AttractorFormatter { + pub fn new( + format: OutputFormat, + output: Option, + expected_coords: usize, + width: Option, + height: Option, + ) -> eyre::Result { + let writer: Box = match &output { + Some(path) => { + if path == Path::new("-") { + Box::new(std::io::stdout()) + } else { + let file = std::fs::File::create(path)?; + Box::new(file) + } + } + None => Box::new(std::io::stdout()), + }; + let writer = BufWriter::new(writer); + let buffer_capacity = match format { + OutputFormat::Points | OutputFormat::Line => 1024, + OutputFormat::Image => expected_coords, + }; + let accumulated = Vec::with_capacity(buffer_capacity); + + Ok(Self { + format, + writer, + output, + accumulated, + width, + height, + }) + } + + pub fn handle_point(&mut self, x: f64, y: f64) -> eyre::Result<()> { + self.accumulate_coord(Coord { x, y }) + } + + pub fn flush(&mut self) -> eyre::Result<()> { + self.write_accumulated()?; + self.writer.flush()?; + Ok(()) + } +} + +// private +impl AttractorFormatter { + fn accumulate_coord(&mut self, coord: Coord) -> eyre::Result<()> { + self.accumulated.push(coord); + // Writing an image requires saving all of the points in memory so we can map them to pixel + // coordinates later. For other formats, we can flush periodically to save memory. + if self.format != OutputFormat::Image + && self.accumulated.len() == self.accumulated.capacity() + { + self.write_accumulated()?; + } + Ok(()) + } + + fn write_accumulated(&mut self) -> eyre::Result<()> { + let mut accumulated = Vec::new(); + std::mem::swap(&mut self.accumulated, &mut accumulated); + match self.format { + OutputFormat::Points => { + for coord in &accumulated { + writeln!(self.writer, "POINT({} {})", coord.x, coord.y)?; + } + } + OutputFormat::Line => { + if !accumulated.is_empty() { + let linestring = LineString::from(accumulated); + writeln!(self.writer, "{}", linestring.to_wkt())?; + } + } + OutputFormat::Image => self.write_image(accumulated)?, + } + + Ok(()) + } + + fn write_image(&mut self, accumulated: Vec>) -> eyre::Result<()> { + let accumulated = LineString::from(accumulated); + let bbox = accumulated.bounding_rect().ok_or_else(|| { + eyre::eyre!("Cannot determine bounding box of accumulated coordinates") + })?; + let (width, height) = self.determine_image_size(&bbox); + + // Padding is to avoid off-by-one errors due to rounding floats -> int + let mut image = image::GrayImage::new(width + 1, height + 1); + for pixel in image.pixels_mut() { + pixel.0[0] = 255; // white + // I struggled using GrayAlphaImage and setting the alpha values correctly. Maybe I'll + // revisit that later. For now, just darken the pixels on each visit. + } + for coord in accumulated { + let (x, y) = Self::map_coordinate_to_pixel(&coord, &bbox, width, height); + let pixel = image.get_pixel_mut(x, y); + pixel.0[0] = pixel.0[0].saturating_sub(64); // darken the pixel, but don't wrap around! + } + + image.save_with_format(self.output.as_ref().unwrap(), image::ImageFormat::Png)?; + + Ok(()) + } + + fn determine_image_size(&self, bbox: &geo::Rect) -> (u32, u32) { + let coord_width = bbox.max().x - bbox.min().x; + let coord_height = bbox.max().y - bbox.min().y; + let aspect_ratio = coord_width / coord_height; + tracing::debug!("extents: {bbox:?}"); + tracing::debug!( + "dimensions: {coord_width:.4} x {coord_height:.4}, aspect: {aspect_ratio:.4}" + ); + + let (width, height) = match (self.width, self.height) { + (Some(width), Some(height)) => (width, height), + (None, None) => { + let default_width = 800; + let height = (default_width as f64 / aspect_ratio) as u32; + (default_width, height) + } + (Some(width), None) => { + let height = (width as f64 / aspect_ratio) as u32; + (width, height) + } + (None, Some(height)) => { + let width = (height as f64 * aspect_ratio) as u32; + (width, height) + } + }; + tracing::debug!("Image size: {width}x{height}"); + + (width, height) + } + + fn map_coordinate_to_pixel( + coord: &Coord, + bbox: &geo::Rect, + image_width: u32, + image_height: u32, + ) -> (u32, u32) { + let image_width = image_width as f64; + let image_height = image_height as f64; + // TODO: If this is expensive, we can precompute the scale factor + let px_x = (coord.x - bbox.min().x) * image_width / (bbox.max().x - bbox.min().x); + let px_y = (coord.y - bbox.min().y) * image_height / (bbox.max().y - bbox.min().y); + + // TODO: Round? Truncate? + (px_x as u32, px_y as u32) + } +} diff --git a/generative/lib.rs b/generative/lib.rs index 21919b2..df7f297 100644 --- a/generative/lib.rs +++ b/generative/lib.rs @@ -1,3 +1,4 @@ +pub mod attractor; #[cfg(feature = "cxx-bindings")] mod cxxbridge; pub mod dla; diff --git a/tools/attractor.rs b/tools/attractor.rs new file mode 100644 index 0000000..15d3a0b --- /dev/null +++ b/tools/attractor.rs @@ -0,0 +1,277 @@ +use std::io::BufRead; +use std::path::PathBuf; + +use clap::Parser; +use generative::attractor::{AttractorFormatter, OutputFormat}; +use rand::rngs::StdRng; +use rand::{Rng, SeedableRng}; +use rand_distr::{Distribution, Uniform}; + +/// Attractor; runs dynamical systems +/// +/// If neither --math nor --script are provided, a default dynamical system is provided for +/// prototyping. +#[derive(Debug, Parser)] +#[clap(name = "attractor", verbatim_doc_comment)] +struct CmdlineOptions { + /// The log level + #[clap(short, long, default_value_t = tracing::Level::INFO)] + log_level: tracing::Level, + + #[clap(short = 'O', long, default_value_t = OutputFormat::Points)] + output_format: OutputFormat, + + #[clap(short, long, required_if_eq("output_format", "image"))] + output: Option, + + /// The width of the output image + /// + /// If not given, an appropriate width will be chosen + #[clap(short = 'W', long)] + width: Option, + + /// The height of the output image + /// + /// If not given, an appropriate height will be chosen + #[clap(short = 'H', long)] + height: Option, + + /// Mathematical expressions defining the dynamical system + /// + /// The --math argument may be provided multiple times. The initial values of x and y will be + /// populated, and the expression is expected to define new variables x_new and y_new. + #[clap(short, long)] + math: Vec, + + /// The path to a rune script defining the dynamical system + /// + /// Has the same rules as --math. May be combined with --math (all --math arguments are + /// appended to the end of the script). + #[clap(short, long)] + script: Option, + + /// Parameters to define when the dynamical system is evaluated + /// + /// May be given multiple times. Each parameter should be defined as '-p name=value' + /// + /// Anything defined with --math or --script may override these parameters. + #[clap(short, long)] + parameter: Vec, + + /// Letters A..=Y used to generate the values of parameters a_1, a_2, ..., a_n + /// + /// Example: "ABCD" will generate parameters a_1=-1.2, a_2=-1.1, a_3=-1.0, a_4=-0.9 + /// + /// Example: "WXY" will generate parameters a_1=1.0, a_2=1.1, a_3=1.2 + /// + /// Anything defined with --math or --script may override these parameters. + #[clap(long)] + letters: Option, + + /// The random seed to use. Use zero to let the tool pick its own random seed. + #[clap(long, default_value_t = 0)] + seed: u64, + + /// The initial x value + /// + /// If not given, a random value will be used. + #[clap(short = 'x', long)] + initial_x: Option, + + /// The initial y value + /// + /// If not given, a random value will be used. + #[clap(short = 'y', long)] + initial_y: Option, + + /// Number of iterations to perform + #[clap(short, long, default_value_t = 10)] + iterations: u64, + + /// Number of points to trace + /// + /// --initial-x and --initial-y will be ignored if this is greater than one. + #[clap(short, long, default_value_t = 1)] + num_points: u64, +} + +fn generate_random_seed_if_not_specified(seed: u64) -> u64 { + if seed == 0 { + let mut rng = rand::rng(); + rng.random() + } else { + seed + } +} + +type DynamicalSystemFn = Box (f64, f64) + 'static>; + +fn build_dynamical_system_function(args: &CmdlineOptions) -> eyre::Result { + // Interpret the CLI arguments to build the dynamical system function + if !args.math.is_empty() || args.script.is_some() { + build_dynamical_system_function_from_args(args) + } else { + // If no --math arguments are provided, use a default function useful for prototyping + Ok(Box::new(|x, y| { + ( + f64::sin(0.97 * y) - f64::cos(-1.899 * x), + f64::sin(1.381 * x) - f64::cos(-1.506 * y), + ) + })) + } +} + +fn build_dynamical_system_function_from_args( + args: &CmdlineOptions, +) -> eyre::Result { + let context = rune::Context::with_default_modules()?; + let runtime = rune::sync::Arc::try_new(context.runtime()?)?; + let mut script = rune::Sources::new(); + script.insert(build_rune_script(args)?)?; + let mut diagnostics = rune::Diagnostics::new(); + let maybe_unit = rune::prepare(&mut script) + .with_context(&context) + .with_diagnostics(&mut diagnostics) + .build(); + if !diagnostics.is_empty() { + let mut writer = + rune::termcolor::StandardStream::stderr(rune::termcolor::ColorChoice::Always); + diagnostics.emit(&mut writer, &script)?; + } + let unit = rune::sync::Arc::try_new(maybe_unit?)?; + + // the Vm is captured and retains state between calls + let vm = rune::Vm::new(runtime, unit); + let iterate = vm.lookup_function(["iterate"])?; + let closure = move |x: f64, y: f64| -> (f64, f64) { + iterate + .call((x, y)) + .expect("Failed to call iterate function") + }; + Ok(Box::new(closure)) +} + +fn params_from_letters(letters: &str) -> eyre::Result> { + let mut params = Vec::new(); + for (param_index, ch) in letters.chars().enumerate() { + if !('A'..='Y').contains(&ch) { + eyre::bail!("--letters may only contain letters A..=Y; found '{ch}'"); + } + let alphabet_index = ((ch as u8) - b'A') as f64; + let value = -1.2 + (alphabet_index * 0.1); + + params.push(format!("let a_{param_index} = {value}_f64;")); + } + + Ok(params) +} + +fn build_rune_script(args: &CmdlineOptions) -> eyre::Result { + let mut lines = Vec::new(); + lines.push("pub fn iterate(x, y) {".into()); + lines.push("// parameters".into()); + // Add the alphabetic parameters first + if let Some(letters) = &args.letters { + let mut params = params_from_letters(letters)?; + lines.append(&mut params); + } + + // Add the explicit parameters second (allows overrides if you so wanted) + for parameter in &args.parameter { + lines.push(format!("let {parameter};")); + } + + // Add the script if given + if let Some(script) = &args.script { + lines.push("// script".into()); + if script == "-" { + for maybe_line in std::io::stdin().lock().lines() { + let line = maybe_line?; + lines.push(line); + } + } else { + let file = std::fs::File::open(script)?; + let reader = std::io::BufReader::new(file); + for maybe_line in reader.lines() { + let line = maybe_line?; + lines.push(line); + } + } + } + + // Finally append any --math expressions + if !args.math.is_empty() { + lines.push("// math".into()); + } + lines.append(&mut args.math.clone()); + + lines.push("return (x_new, y_new);".into()); + lines.push("}".into()); + let script = lines.join("\n"); + tracing::debug!("Generated rune script:\n==========\n{script}\n=========="); + let source = rune::Source::memory(script)?; + Ok(source) +} + +fn main() -> eyre::Result<()> { + color_eyre::install()?; + let mut args = CmdlineOptions::parse(); + + let filter = tracing_subscriber::EnvFilter::builder() + .with_default_directive(args.log_level.into()) + .from_env_lossy(); + tracing_subscriber::fmt() + .with_env_filter(filter) + .with_ansi(true) + .with_writer(std::io::stderr) + .init(); + + let seed = generate_random_seed_if_not_specified(args.seed); + tracing::info!("Seeding RNG with: {seed}"); + let mut rng = StdRng::seed_from_u64(seed); + + let dynamical_system = build_dynamical_system_function(&args)?; + + let dist = Uniform::new(-1.0, 1.0).unwrap(); + if args.num_points > 1 { + args.initial_x = None; + args.initial_y = None; + } + + let mut initial_values = Vec::with_capacity(args.num_points as usize); + for _ in 0..args.num_points { + let initial_x = args.initial_x.unwrap_or_else(|| dist.sample(&mut rng)); + let initial_y = args.initial_y.unwrap_or_else(|| dist.sample(&mut rng)); + initial_values.push((initial_x, initial_y)); + } + + let expected_coords = (args.iterations * args.num_points) as usize; + let mut formatter = AttractorFormatter::new( + args.output_format, + args.output, + expected_coords, + args.width, + args.height, + )?; + + let start = std::time::Instant::now(); + for (i, (mut x, mut y)) in initial_values.into_iter().enumerate() { + tracing::trace!("i={i}: Starting at: ({x}, {y})"); + for j in 0..args.iterations { + let (x_new, y_new) = dynamical_system(x, y); + if !x_new.is_finite() || !y_new.is_finite() || x_new.abs() > 1e6 || y_new.abs() > 1e6 { + tracing::warn!( + "Dynamical system produced non-finite value at iteration {j} for point {i}: ({x}, {y})" + ); + break; + } + (x, y) = (x_new, y_new); + formatter.handle_point(x, y)?; + } + } + formatter.flush()?; + let elapsed = start.elapsed(); + tracing::info!("Finished in {elapsed:?}"); + + Ok(()) +}