use clap::Parser;
use geodesic_wallpaper::config::{Config, SharedConfig};
use geodesic_wallpaper::error::GeodesicError;
use geodesic_wallpaper::events::KeyEvent;
use geodesic_wallpaper::geodesic::Geodesic;
use geodesic_wallpaper::renderer::Renderer;
use geodesic_wallpaper::surface::{
boy_surface::BoySurface, catenoid::Catenoid, ellipsoid::Ellipsoid, enneper::Enneper,
helicoid::Helicoid, hyperbolic_paraboloid::HyperbolicParaboloid, hyperboloid::Hyperboloid,
klein_bottle::KleinBottle, pseudosphere::Pseudosphere, saddle::Saddle, sphere::Sphere,
torus::Torus, torus_knot::TorusKnot, trefoil::TrefoilTube, Surface,
};
use geodesic_wallpaper::trail::TrailBuffer;
use geodesic_wallpaper::tray;
use geodesic_wallpaper::wallpaper;
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
use rayon::prelude::*;
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::{Arc, RwLock};
use std::time::Duration;
use windows::Win32::UI::WindowsAndMessaging::{
DispatchMessageW, MessageBoxW, PeekMessageW, TranslateMessage, MB_ICONWARNING, MB_OK, MSG,
PM_REMOVE,
};
#[derive(Parser)]
#[command(
version,
about = "Animated Windows wallpaper: colored geodesics flowing over curved 3D surfaces.",
after_help = "Examples:
geodesic-wallpaper Start the live wallpaper (reads config.toml)
geodesic-wallpaper --preset ocean Use presets/ocean.toml (also: cosmic, fire, matrix, neon)
geodesic-wallpaper --headless --output shot.png
Render one 1920x1080 image, no window
geodesic-wallpaper --headless --frames 240 --record frames --record-start 150 --record-every 2
Save 45 frames as PNGs for a GIF or video
geodesic-wallpaper --headless --width 640 --height 360 --frames 240 --record-start 150 --record-every 2 --gif loop.gif
Write those 45 frames straight to an animated GIF
(--apng loop.png for full colour, --mp4 clip.mp4 via ffmpeg)
config.toml and presets/ are read from the current folder first, then from the
folder that holds the exe. Edit config.toml while it runs and it reloads.
Quit from the tray icon (right-click). Keys: ] [ surface, + - speed, Space pause."
)]
struct Args {
#[arg(long)]
headless: bool,
#[arg(long, default_value = "screenshot.png")]
output: String,
#[arg(long, default_value_t = 300)]
frames: u32,
#[arg(long, value_name = "DIR")]
record: Option<String>,
#[arg(long, default_value_t = 0, value_name = "N")]
record_start: u32,
#[arg(long, default_value_t = 1, value_name = "N")]
record_every: u32,
#[arg(long, value_name = "FILE")]
gif: Option<String>,
#[arg(long, value_name = "FILE")]
apng: Option<String>,
#[arg(long, value_name = "FILE")]
mp4: Option<String>,
#[arg(long, value_name = "PATH", default_value = "ffmpeg")]
ffmpeg: String,
#[arg(long, value_name = "SHELL")]
completions: Option<clap_complete::Shell>,
#[arg(long, default_value_t = 1920)]
width: u32,
#[arg(long, default_value_t = 1080)]
height: u32,
#[arg(long)]
preset: Option<String>,
#[arg(long)]
animate: bool,
#[arg(long, default_value_t = 30)]
fps: u32,
#[arg(long, default_value = "./frames")]
out_dir: String,
#[arg(long)]
palette: Option<String>,
#[arg(long, default_value_t = 8)]
palette_steps: usize,
#[arg(long, value_name = "PRESET")]
gradient: Option<String>,
#[arg(long)]
preview: bool,
#[arg(long, value_name = "SHAPE")]
tile: Option<String>,
#[arg(long, default_value_t = 32)]
tile_size: u32,
#[arg(long, value_name = "TYPE")]
fractal: Option<String>,
#[arg(long, default_value_t = 0.3)]
fractal_blend: f32,
#[arg(long, value_name = "FORMAT", default_value = "png")]
output_format: String,
#[arg(long, value_name = "COLORSPACE", default_value = "rgb")]
colorspace: String,
}
const SURFACE_CYCLE: &[&str] = &[
"torus",
"sphere",
"saddle",
"enneper",
"catenoid",
"helicoid",
"hyperboloid",
"hyperbolic_paraboloid",
"ellipsoid",
"klein_bottle",
"boy_surface",
"torus_knot",
"pseudosphere",
"trefoil",
];
#[tracing::instrument(skip(cfg), fields(surface = %cfg.surface))]
fn build_surface(cfg: &Config) -> Arc<dyn Surface> {
tracing::info!(surface = %cfg.surface, "building surface");
match cfg.surface.as_str() {
"sphere" => Arc::new(Sphere::new(2.5)),
"saddle" => Arc::new(Saddle::new(2.0)),
"enneper" => Arc::new(Enneper::new(1.5)),
"catenoid" => Arc::new(Catenoid::new(cfg.catenoid_c)),
"helicoid" => Arc::new(Helicoid::new(cfg.helicoid_c)),
"hyperboloid" => Arc::new(Hyperboloid::new(cfg.hyperboloid_a, cfg.hyperboloid_b)),
"hyperbolic_paraboloid" => Arc::new(HyperbolicParaboloid::new(
cfg.hyperbolic_paraboloid_a,
cfg.hyperbolic_paraboloid_b,
)),
"ellipsoid" => Arc::new(Ellipsoid::new(
cfg.ellipsoid_a,
cfg.ellipsoid_b,
cfg.ellipsoid_c,
)),
"klein_bottle" => Arc::new(KleinBottle::new(2.0, 0.4)),
"boy_surface" => Arc::new(BoySurface::new(1.0)),
"torus_knot" => Arc::new(TorusKnot::new(2, 3, 2.0, 0.8, 0.15)),
"pseudosphere" => Arc::new(Pseudosphere::new(1.5, 3.0)),
"trefoil" => Arc::new(TrefoilTube::new(0.25, 0.8)),
_ => Arc::new(Torus::new(cfg.torus_R, cfg.torus_r)),
}
}
fn build_surface_by_name(name: &str) -> Arc<dyn Surface> {
match name {
"sphere" => Arc::new(Sphere::new(2.5)),
"saddle" => Arc::new(Saddle::new(2.0)),
"enneper" => Arc::new(Enneper::new(1.5)),
"catenoid" => Arc::new(Catenoid::new(1.0)),
"helicoid" => Arc::new(Helicoid::new(1.0)),
"hyperboloid" => Arc::new(Hyperboloid::new(1.0, 1.0)),
"hyperbolic_paraboloid" => Arc::new(HyperbolicParaboloid::new(1.0, 1.0)),
"ellipsoid" => Arc::new(Ellipsoid::new(2.0, 1.5, 1.0)),
"klein_bottle" => Arc::new(KleinBottle::new(2.0, 0.4)),
"boy_surface" => Arc::new(BoySurface::new(1.0)),
"torus_knot" => Arc::new(TorusKnot::new(2, 3, 2.0, 0.8, 0.15)),
"pseudosphere" => Arc::new(Pseudosphere::new(1.5, 3.0)),
"trefoil" => Arc::new(TrefoilTube::new(0.25, 0.8)),
_ => Arc::new(Torus::new(2.0, 0.7)),
}
}
fn data_path(rel: &str) -> PathBuf {
let here = PathBuf::from(rel);
if here.exists() {
return here;
}
if let Some(dir) = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.to_path_buf()))
{
let beside = dir.join(rel);
if beside.exists() {
return beside;
}
}
here
}
fn load_preset(base: &Config, name: &str) -> Config {
let path = data_path(&format!("presets/{name}.toml"));
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(_) => {
tracing::warn!(preset = name, "preset file not found: {}", path.display());
return base.clone();
}
};
match toml::from_str::<Config>(&text) {
Ok(preset_cfg) => {
let default = Config::default();
let mut out = base.clone();
macro_rules! merge {
($field:ident) => {
if preset_cfg.$field != default.$field {
out.$field = preset_cfg.$field.clone();
}
};
}
merge!(surface);
merge!(num_geodesics);
merge!(trail_length);
merge!(rotation_speed);
merge!(color_palette);
merge!(torus_R);
merge!(torus_r);
merge!(time_step);
merge!(camera_distance);
merge!(camera_elevation);
merge!(camera_fov);
merge!(camera_elevation_speed);
merge!(show_wireframe);
merge!(max_trail_verts);
merge!(trail_fade_power);
merge!(color_mode);
merge!(target_fps);
merge!(show_hud);
merge!(background_color);
merge!(trail_mode);
merge!(color_cycle_speed);
merge!(gradient_stops);
merge!(gradient_mode);
merge!(catenoid_c);
merge!(helicoid_c);
merge!(hyperboloid_a);
merge!(hyperboloid_b);
merge!(light_dir);
merge!(color_cycle_enabled);
merge!(hyperbolic_paraboloid_a);
merge!(hyperbolic_paraboloid_b);
merge!(ellipsoid_a);
merge!(ellipsoid_b);
merge!(ellipsoid_c);
merge!(monitor);
tracing::info!(preset = name, "preset loaded from {}", path.display());
out
}
Err(e) => {
tracing::warn!(preset = name, "failed to parse preset: {e}");
base.clone()
}
}
}
fn parse_colors(cfg: &Config) -> Vec<[f32; 4]> {
cfg.effective_colors()
}
fn rotate_hue(colors: &[[f32; 4]], degrees: f32) -> Vec<[f32; 4]> {
let shift = degrees / 360.0;
colors
.iter()
.map(|&c| {
let hsv = rgb_to_hsv(c);
let h = (hsv[0] + shift).rem_euclid(1.0);
hsv_to_rgb([h, hsv[1], hsv[2]])
})
.collect()
}
fn pick_color_idx(i: usize, color_count: usize, mode: &str, rng: &mut StdRng) -> usize {
if mode == "random" {
rng.gen_range(0..color_count.max(1))
} else {
i % color_count.max(1)
}
}
fn rgb_to_hsv(c: [f32; 4]) -> [f32; 3] {
let (r, g, b) = (c[0], c[1], c[2]);
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let delta = max - min;
let v = max;
let s = if max < 1e-6 { 0.0 } else { delta / max };
let h = if delta < 1e-6 {
0.0
} else if max == r {
((g - b) / delta).rem_euclid(6.0) / 6.0
} else if max == g {
((b - r) / delta + 2.0) / 6.0
} else {
((r - g) / delta + 4.0) / 6.0
};
[h, s, v]
}
fn hsv_to_rgb(hsv: [f32; 3]) -> [f32; 4] {
let (h, s, v) = (hsv[0], hsv[1], hsv[2]);
let i = (h * 6.0).floor() as i32;
let f = h * 6.0 - i as f32;
let p = v * (1.0 - s);
let q = v * (1.0 - f * s);
let t = v * (1.0 - (1.0 - f) * s);
let (r, g, b) = match i % 6 {
0 => (v, t, p),
1 => (q, v, p),
2 => (p, v, t),
3 => (p, q, v),
4 => (t, p, v),
_ => (v, p, q),
};
[r, g, b, 1.0]
}
fn parse_bg_color(hex: &str) -> (f64, f64, f64) {
let c = Config::parse_color(hex);
let lin = |v: f32| -> f64 {
let v = v as f64;
if v <= 0.04045 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
};
(lin(c[0]), lin(c[1]), lin(c[2]))
}
fn screen_size(monitor: &str, monitors: &[(i32, i32, u32, u32)]) -> (i32, i32) {
unsafe {
use windows::Win32::UI::WindowsAndMessaging::{
GetSystemMetrics, SM_CXSCREEN, SM_CXVIRTUALSCREEN, SM_CYSCREEN, SM_CYVIRTUALSCREEN,
};
if monitor == "all" {
let w = GetSystemMetrics(SM_CXVIRTUALSCREEN);
let h = GetSystemMetrics(SM_CYVIRTUALSCREEN);
if w > 0 && h > 0 {
return (w, h);
}
}
if let Ok(idx) = monitor.parse::<usize>() {
if let Some(&(_x, _y, w, h)) = monitors.get(idx) {
if w > 0 && h > 0 {
return (w as i32, h as i32);
}
}
}
let w = GetSystemMetrics(SM_CXSCREEN);
let h = GetSystemMetrics(SM_CYSCREEN);
if w > 0 && h > 0 {
(w, h)
} else {
(1920, 1080)
}
}
}
fn show_epilepsy_warning() {
unsafe {
let text: Vec<u16> = "Geodesic Wallpaper displays animated moving patterns.\n\n\
If you or anyone in your household is photosensitive or has a history \
of epilepsy, please be aware that the animated visuals may trigger \
symptoms.\n\nPress OK to continue."
.encode_utf16()
.chain(std::iter::once(0u16))
.collect();
let caption: Vec<u16> = "Photosensitivity Notice"
.encode_utf16()
.chain(std::iter::once(0u16))
.collect();
let _ = MessageBoxW(
None,
windows::core::PCWSTR(text.as_ptr()),
windows::core::PCWSTR(caption.as_ptr()),
MB_ICONWARNING | MB_OK,
);
}
}
fn main() {
tracing_subscriber::fmt()
.with_ansi(std::env::var_os("NO_COLOR").is_none())
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
tracing_subscriber::EnvFilter::new("info,wgpu_core=error,wgpu_hal=error,naga=warn")
}),
)
.init();
let args = Args::parse();
if let Some(shell) = args.completions {
use clap::CommandFactory;
let mut cmd = Args::command();
clap_complete::generate(
shell,
&mut cmd,
"geodesic-wallpaper",
&mut std::io::stdout(),
);
return;
}
if args.preview {
use geodesic_wallpaper::preview::{TuiApp, WallpaperParams};
let config = geodesic_wallpaper::config::Config::load(&data_path("config.toml"));
let mut params = WallpaperParams {
scale: (1.0_f32 / config.time_step.max(1e-6) * 0.01).clamp(0.1, 10.0),
rotation: config.rotation_speed * 10000.0,
..WallpaperParams::default()
};
params.clamp();
let app = TuiApp {
params,
width: 40,
height: 20,
};
let _ = app.run();
return;
}
if let Some(ref tile_shape_str) = args.tile.clone() {
use geodesic_wallpaper::tiling::{TileGrid, TileRenderer, TileShape};
let shape = match tile_shape_str.as_str() {
"hex" | "hexagonal" => TileShape::Hexagonal,
"triangular" => TileShape::Triangular,
"rhombic" => TileShape::Rhombic,
_ => TileShape::Square,
};
let tile_size = args.tile_size.max(1);
let w = 256u32;
let h = 256u32;
let grid = TileGrid::new(shape, w, h, tile_size);
let pixels = TileRenderer::render(
&grid,
|cell| {
let r = ((cell.col.rem_euclid(8) as u8) * 32).saturating_add(64);
let g = ((cell.row.rem_euclid(8) as u8) * 32).saturating_add(64);
let b = 128u8;
[r, g, b]
},
w,
h,
);
println!("[tile] shape: {}", tile_shape_str);
println!("[tile] tile_size: {}", tile_size);
println!("[tile] canvas: {}x{}", w, h);
println!("[tile] total pixels: {}", pixels.len());
for &(px, py) in &[(0u32, 0u32), (tile_size, 0), (0, tile_size)] {
if px < w && py < h {
let cell = grid.cell_at(px, py);
let neighbors = grid.neighbors(&cell);
println!(
" cell({},{}) center=({:.1},{:.1}) neighbors={}",
cell.col,
cell.row,
cell.center_x,
cell.center_y,
neighbors.len()
);
}
}
}
if let Some(ref fractal_str) = args.fractal.clone() {
use geodesic_wallpaper::fractal::{FractalOverlay, FractalRenderer, FractalType};
let fractal = match fractal_str.as_str() {
"julia" => FractalType::Julia {
c_re: -0.7,
c_im: 0.27,
},
"burning-ship" => FractalType::BurningShip,
_ => FractalType::Mandelbrot,
};
let w = 256u32;
let h = 256u32;
let field = FractalRenderer::render(&fractal, w, h, -0.5, 0.0, 1.0, 128);
let base: Vec<[u8; 3]> = field
.iter()
.map(|&v| {
let c = (v * 200.0) as u8;
[c, c / 2, 255 - c]
})
.collect();
let blended = FractalOverlay::apply(&base, &field, args.fractal_blend);
println!("[fractal] type: {}", fractal_str);
println!("[fractal] blend: {:.2}", args.fractal_blend);
println!("[fractal] rendered {}x{} = {} pixels", w, h, field.len());
let nonzero = field.iter().filter(|&&v| v > 0.0).count();
println!("[fractal] escaped pixels: {}", nonzero);
println!("[fractal] inside-set pixels: {}", field.len() - nonzero);
println!("[fractal] blended pixels: {}", blended.len());
if !blended.is_empty() {
let p = blended[blended.len() / 2];
println!(
"[fractal] sample pixel (center): rgb({},{},{})",
p[0], p[1], p[2]
);
}
}
if let Some(ref preset_name) = args.gradient {
use geodesic_wallpaper::gradient::{GradientPreset, GradientTexture};
match GradientPreset::from_str(preset_name) {
Some(preset) => {
let gradient = preset.into_gradient();
let pixels = GradientTexture::generate(
16,
8,
|x, y| (x as f32 + y as f32) / (16.0 + 8.0 - 2.0),
&gradient,
);
println!("[gradient] preset: {}", preset_name);
println!("[gradient] stops: {}", gradient.stops.len());
println!("[gradient] generated {} pixels", pixels.len());
for (i, p) in pixels.iter().take(5).enumerate() {
println!(" [{}] rgb({}, {}, {})", i, p[0], p[1], p[2]);
}
}
None => {
eprintln!(
"[gradient] Unknown preset '{}'. Choose from: sunset, ocean, forest, plasma, greyscale",
preset_name
);
std::process::exit(1);
}
}
}
if let Some(ref palette_spec) = args.palette {
use geodesic_wallpaper::palette::PaletteGenerator;
match PaletteGenerator::from_spec(palette_spec, args.palette_steps) {
Some(palette) => {
println!(
"[palette] {} ({} colors)",
palette.name,
palette.colors.len()
);
for (i, hex) in palette.to_hex_strings().iter().enumerate() {
println!(" [{}] {}", i, hex);
}
}
None => {
eprintln!("[palette] Could not parse palette spec: '{}'. Use format TYPE[:HUE], e.g. triadic:240 or rainbow", palette_spec);
}
}
}
if args.colorspace != "rgb" {
use geodesic_wallpaper::colorspace::{ColorInterpolator, Rgb};
let a = Rgb { r: 255, g: 0, b: 0 };
let b = Rgb { r: 0, g: 0, b: 255 };
let steps = 8usize;
println!(
"[colorspace] interpolating red → blue in {} space ({} steps)",
args.colorspace, steps
);
for i in 0..steps {
let t = i as f32 / (steps - 1) as f32;
let c = match args.colorspace.to_lowercase().as_str() {
"hsv" => ColorInterpolator::lerp_hsv(a, b, t),
"oklab" => ColorInterpolator::lerp_oklab(a, b, t),
"lab" => {
use geodesic_wallpaper::colorspace::{lab_to_rgb, rgb_to_lab, Lab};
let la = rgb_to_lab(a);
let lb = rgb_to_lab(b);
let lm = Lab {
l: la.l + (lb.l - la.l) * t,
a: la.a + (lb.a - la.a) * t,
b: la.b + (lb.b - la.b) * t,
};
lab_to_rgb(lm)
}
_ => ColorInterpolator::lerp_rgb(a, b, t),
};
println!(" t={:.3} rgb({},{},{})", t, c.r, c.g, c.b);
}
}
let info_only = args.tile.is_some()
|| args.fractal.is_some()
|| args.gradient.is_some()
|| args.palette.is_some()
|| args.colorspace != "rgb";
if info_only && !args.headless && !args.animate {
return;
}
if args.animate {
use geodesic_wallpaper::animation::{
AnimationConfig, AnimationExporter, AnimationParameter, FrameInterpolator,
InterpolationMode,
};
use std::path::PathBuf;
let config = AnimationConfig {
frames: args.frames as usize,
fps: args.fps,
width: 1920,
height: 1080,
output_dir: PathBuf::from(&args.out_dir),
};
let interp = FrameInterpolator::new(
AnimationParameter::RotationAngle,
0.0,
std::f64::consts::TAU,
InterpolationMode::Linear,
);
let exporter = AnimationExporter::new(config.clone(), vec![interp]);
eprintln!(
"[animate] Exporting {} frames to {} at {} fps",
config.frames,
config.output_dir.display(),
config.fps
);
match exporter.export(|_frame_idx, _params, path| {
AnimationExporter::write_test_frame(path, 1920, 1080).map_err(|e| e.to_string())
}) {
Ok(stats) => {
eprintln!(
"[animate] Done: {} frames in {}ms ({:.1}ms/frame)",
stats.frames_written, stats.duration_ms, stats.avg_frame_ms
);
}
Err(e) => {
eprintln!("[animate] Export failed: {e}");
std::process::exit(1);
}
}
return;
}
if args.headless {
let config_path = data_path("config.toml");
let mut cfg = Config::load(&config_path).resolve_profile();
if let Some(ref preset_name) = args.preset {
cfg = load_preset(&cfg, preset_name);
}
if let Err(e) = run_headless(&args, &cfg) {
tracing::error!("Headless render failed: {e}");
eprintln!("Error: {e}");
std::process::exit(1);
}
return;
}
if let Err(e) = run(&args) {
tracing::error!("Fatal error: {e}");
unsafe {
use windows::core::PCWSTR;
use windows::Win32::UI::WindowsAndMessaging::{MB_ICONERROR, MB_OK};
let msg = format!("Geodesic Wallpaper encountered a fatal error:\n\n{e}\n\nCheck that your GPU drivers are up to date.")
.encode_utf16()
.chain(std::iter::once(0u16))
.collect::<Vec<u16>>();
let caption = "Geodesic Wallpaper — Error"
.encode_utf16()
.chain(std::iter::once(0u16))
.collect::<Vec<u16>>();
let _ = MessageBoxW(
None,
PCWSTR(msg.as_ptr()),
PCWSTR(caption.as_ptr()),
MB_ICONERROR | MB_OK,
);
}
std::process::exit(1);
}
}
fn run_headless(args: &Args, cfg: &Config) -> Result<(), GeodesicError> {
tracing::info!(frames = args.frames, output = %args.output, "starting headless render");
let surf = build_surface(cfg);
let (mesh_verts, mesh_indices) = surf.mesh_vertices(40, 40);
let width = args.width.max(16);
let height = args.height.max(16);
let (mut renderer, offscreen_tex) = pollster::block_on(Renderer::new_headless(
width,
height,
&mesh_verts,
&mesh_indices,
))?;
{
let (br, bg, bb) = parse_bg_color(&cfg.background_color);
renderer.set_background(br, bg, bb);
}
renderer.light_dir = cfg.light_dir;
renderer.show_wireframe = cfg.show_wireframe;
renderer.camera = geodesic_wallpaper::renderer::camera::Camera::new_with_params(
width as f32 / height as f32,
cfg.camera_distance,
cfg.camera_elevation,
cfg.camera_fov,
cfg.camera_elevation_speed,
);
let record_dir = match &args.record {
Some(d) => {
let dir = PathBuf::from(d);
std::fs::create_dir_all(&dir).map_err(|e| {
GeodesicError::render(format!(
"could not create --record folder {}: {e}",
dir.display()
))
})?;
Some(dir)
}
None => None,
};
let record_every = args.record_every.max(1);
let mut recorded = 0u32;
let anim_fps = (30 / record_every).max(1);
let anim_frames = recorded_frame_count(args.frames, args.record_start, record_every);
let fail = |what: &str, p: &str, e: geodesic_wallpaper::recorder::RecorderError| {
GeodesicError::render(format!("could not create {what} {p}: {e}"))
};
let mut gif = match &args.gif {
Some(p) => Some(
geodesic_wallpaper::recorder::GifWriter::create(
std::path::Path::new(p),
width,
height,
anim_fps,
)
.map_err(|e| fail("GIF", p, e))?,
),
None => None,
};
let mut apng =
match &args.apng {
Some(_) if anim_frames == 0 => return Err(GeodesicError::render(
"--apng needs at least one recorded frame: --frames must be above --record-start",
)),
Some(p) => Some(
geodesic_wallpaper::recorder::ApngWriter::create(
std::path::Path::new(p),
width,
height,
anim_fps,
anim_frames,
)
.map_err(|e| fail("APNG", p, e))?,
),
None => None,
};
let mut mp4 = match &args.mp4 {
Some(p) => Some(
geodesic_wallpaper::recorder::FfmpegWriter::spawn(
&args.ffmpeg,
std::path::Path::new(p),
width,
height,
anim_fps,
)
.map_err(|e| fail("MP4", p, e))?,
),
None => None,
};
let mut rng = match cfg.seed {
Some(s) => StdRng::seed_from_u64(s),
None => StdRng::seed_from_u64(42),
};
let colors = parse_colors(cfg);
let mut geodesics: Vec<Geodesic> = Vec::new();
let mut trails: Vec<TrailBuffer> = Vec::new();
for i in 0..cfg.num_geodesics {
let (u, v) = surf.random_position(&mut rng);
let (du, dv) = surf.random_tangent(u, v, &mut rng);
let ci = i % colors.len().max(1);
geodesics.push(Geodesic::new(u, v, du, dv, cfg.trail_length, ci));
trails.push(TrailBuffer::new(
cfg.trail_length,
colors[ci],
cfg.effective_fade_power(),
));
}
let dt = cfg.time_step;
for frame in 0..args.frames {
renderer.camera.orbit(cfg.rotation_speed * dt);
renderer.camera.drift_elevation(dt);
let surf_ref = &*surf;
geodesics
.iter_mut()
.zip(trails.iter_mut())
.for_each(|(geo, trail)| {
if !geo.alive {
return;
}
let pos = surf_ref.position(geo.u, geo.v);
trail.push([pos.x, pos.y, pos.z]);
geo.step(surf_ref, dt);
});
for (i, geo) in geodesics.iter_mut().enumerate() {
if !geo.alive {
let (u, v) = surf.random_position(&mut rng);
let (du, dv) = surf.random_tangent(u, v, &mut rng);
let ci = i % colors.len().max(1);
*geo = Geodesic::new(u, v, du, dv, cfg.trail_length, ci);
trails[i].clear();
trails[i].color = colors[ci];
}
}
let capture = record_dir.is_some() || gif.is_some() || apng.is_some() || mp4.is_some();
if capture
&& frame >= args.record_start
&& (frame - args.record_start).is_multiple_of(record_every)
{
let (verts, lens) = collect_trail_verts(&trails, &geodesics, &colors);
let px = renderer.render_to_texture(&offscreen_tex, &verts, &lens)?;
if let Some(dir) = &record_dir {
let path = dir.join(format!("frame_{recorded:05}.png"));
image::save_buffer(&path, &px, width, height, image::ColorType::Rgba8)
.map_err(|e| GeodesicError::render(format!("image save failed: {e}")))?;
}
if let Some(g) = gif.as_mut() {
g.push_rgba(&px)
.map_err(|e| GeodesicError::render(format!("GIF frame failed: {e}")))?;
}
if let Some(a) = apng.as_mut() {
a.push_rgba(&px)
.map_err(|e| GeodesicError::render(format!("APNG frame failed: {e}")))?;
}
if let Some(m) = mp4.as_mut() {
m.push_rgba(&px)
.map_err(|e| GeodesicError::render(format!("MP4 frame failed: {e}")))?;
}
recorded += 1;
}
}
if let Some(g) = gif.take() {
let n = g
.finish()
.map_err(|e| GeodesicError::render(format!("GIF write failed: {e}")))?;
eprintln!(
"Wrote {n}-frame GIF ({width}x{height}, {anim_fps} fps) to {}",
args.gif.as_deref().unwrap_or_default()
);
}
if let Some(a) = apng.take() {
let n = a
.finish()
.map_err(|e| GeodesicError::render(format!("APNG write failed: {e}")))?;
eprintln!(
"Wrote {n}-frame APNG ({width}x{height}, {anim_fps} fps) to {}",
args.apng.as_deref().unwrap_or_default()
);
}
if let Some(m) = mp4.take() {
let n = m
.finish()
.map_err(|e| GeodesicError::render(format!("MP4 write failed: {e}")))?;
eprintln!(
"Wrote {n}-frame MP4 ({width}x{height}, {anim_fps} fps) to {}",
args.mp4.as_deref().unwrap_or_default()
);
}
if let Some(dir) = &record_dir {
eprintln!(
"Recorded {recorded} frames ({width}x{height}) to {}",
dir.display()
);
if recorded > 0 {
eprintln!(
"Make a video: ffmpeg -framerate {} -i {}/frame_%05d.png -pix_fmt yuv420p out.mp4",
(30 / record_every).max(1),
dir.display()
);
}
}
let (all_verts, seg_lens) = collect_trail_verts(&trails, &geodesics, &colors);
let pixels = renderer.render_to_texture(&offscreen_tex, &all_verts, &seg_lens)?;
let out_format_str = args.output_format.to_lowercase();
let use_custom_format = matches!(out_format_str.as_str(), "ppm" | "bmp" | "svg");
if use_custom_format {
use geodesic_wallpaper::export::{ExportFormat, ImageExporter};
let rgb_pixels: Vec<[u8; 3]> = pixels.chunks(4).map(|c| [c[0], c[1], c[2]]).collect();
let fmt = match out_format_str.as_str() {
"ppm" => ExportFormat::Ppm,
"bmp" => ExportFormat::Bmp,
"svg" => ExportFormat::Svg,
_ => ExportFormat::Png,
};
let path = std::path::Path::new(&args.output);
match ImageExporter::export(&rgb_pixels, width, height, fmt, path) {
Ok(stats) => {
tracing::info!(
path = %args.output,
format = ?fmt,
bytes = stats.bytes_written,
elapsed_ms = stats.elapsed_ms,
"screenshot saved"
);
}
Err(e) => {
return Err(GeodesicError::render(format!("export failed: {e}")));
}
}
} else {
image::save_buffer(
&args.output,
&pixels,
width,
height,
image::ColorType::Rgba8,
)
.map_err(|e| GeodesicError::render(format!("image save failed: {e}")))?;
tracing::info!(path = %args.output, "screenshot saved");
}
Ok(())
}
fn recorded_frame_count(frames: u32, start: u32, every: u32) -> u32 {
if frames <= start {
0
} else {
(frames - 1 - start) / every.max(1) + 1
}
}
fn collect_trail_verts(
trails: &[TrailBuffer],
geodesics: &[Geodesic],
colors: &[[f32; 4]],
) -> (Vec<geodesic_wallpaper::trail::TrailVertex>, Vec<usize>) {
let mut all_verts = Vec::new();
let mut seg_lens = Vec::new();
for (trail_idx, trail) in trails.iter().enumerate() {
let ci = geodesics[trail_idx].color_idx % colors.len().max(1);
let [dr, dg, db, _] = colors[ci];
let before = all_verts.len();
trail.append_ordered(&mut all_verts, Some([dr, dg, db]));
seg_lens.push(all_verts.len() - before);
}
(all_verts, seg_lens)
}
#[tracing::instrument(skip(args))]
fn run(args: &Args) -> Result<(), GeodesicError> {
let config_path = data_path("config.toml");
let base_cfg = Config::load(&config_path).resolve_profile();
let cfg = if let Some(ref preset_name) = args.preset {
load_preset(&base_cfg, preset_name)
} else {
base_cfg
};
tracing::info!(
surface = %cfg.surface,
num_geodesics = cfg.num_geodesics,
trail_length = cfg.trail_length,
target_fps = cfg.target_fps,
monitor = %cfg.monitor,
"configuration loaded"
);
if cfg.epilepsy_warning {
show_epilepsy_warning();
}
let shared_cfg: SharedConfig = Arc::new(RwLock::new(cfg.clone()));
let (reload_tx, reload_rx) = std::sync::mpsc::channel::<()>();
let _config_watcher = {
let shared = shared_cfg.clone();
match geodesic_wallpaper::hot_reload::watch_config(
&config_path,
Duration::from_millis(250),
move |res| match res {
Ok(new_cfg) => {
if let Ok(mut w) = shared.write() {
*w = new_cfg;
tracing::info!("config reloaded from disk");
}
let _ = reload_tx.send(());
}
Err(e) => tracing::warn!("config reload skipped: {e}"),
},
) {
Ok(w) => Some(w),
Err(e) => {
tracing::warn!("config hot reload disabled: {e}");
None
}
}
};
let tray_state = tray::spawn_tray(cfg.surface.clone());
let (key_tx, key_rx) = std::sync::mpsc::channel::<KeyEvent>();
wallpaper::set_key_sender(key_tx);
let monitors = wallpaper::enumerate_monitors();
tracing::info!(
monitor_count = monitors.len(),
monitor_config = %cfg.monitor,
"detected monitors"
);
for (i, (x, y, w, h)) in monitors.iter().enumerate() {
tracing::info!(idx = i, x, y, width = w, height = h, "monitor");
}
let (sw, sh) = screen_size(&cfg.monitor, &monitors);
tracing::info!(width = sw, height = sh, "detected screen resolution");
let hwnd = wallpaper::create_wallpaper_hwnd(sw, sh)
.ok_or_else(|| GeodesicError::window("Failed to create wallpaper window"))?;
let mut surf = build_surface(&cfg);
let mut colors = parse_colors(&cfg);
let fade_power = cfg.effective_fade_power();
let (mesh_verts, mesh_indices) = surf.mesh_vertices(40, 40);
tracing::info!(
verts = mesh_verts.len(),
indices = mesh_indices.len(),
"surface mesh generated"
);
let mut renderer = pollster::block_on(Renderer::new(
hwnd,
sw as u32,
sh as u32,
&mesh_verts,
&mesh_indices,
cfg.max_trail_verts,
cfg.show_wireframe,
))?;
renderer.camera = geodesic_wallpaper::renderer::camera::Camera::new_with_params(
sw as f32 / sh as f32,
cfg.camera_distance,
cfg.camera_elevation,
cfg.camera_fov,
cfg.camera_elevation_speed,
);
{
let (br, bg, bb) = parse_bg_color(&cfg.background_color);
renderer.set_background(br, bg, bb);
}
renderer.light_dir = cfg.light_dir;
let mut rng = match cfg.seed {
Some(s) => StdRng::seed_from_u64(s),
None => StdRng::from_entropy(),
};
let mut geodesics: Vec<Geodesic> = Vec::new();
let mut trails: Vec<TrailBuffer> = Vec::new();
let color_mode = cfg.color_mode.clone();
for i in 0..cfg.num_geodesics {
let (u, v) = surf.random_position(&mut rng);
let (du, dv) = surf.random_tangent(u, v, &mut rng);
let ci = pick_color_idx(i, colors.len(), &color_mode, &mut rng);
geodesics.push(Geodesic::new(u, v, du, dv, cfg.trail_length, ci));
trails.push(TrailBuffer::new(cfg.trail_length, colors[ci], fade_power));
}
tracing::info!(count = cfg.num_geodesics, "geodesics spawned");
let dt = cfg.time_step;
let mut target_frame: Duration;
let mut last_frame = std::time::Instant::now();
let mut hud_last = std::time::Instant::now();
let mut frame_count: u64 = 0;
let mut current_surface_name = cfg.surface.clone();
let mut elapsed_secs: f32 = 0.0;
let mut reload_flash_timer: f32 = 0.0;
let mut frame_times: VecDeque<Duration> = VecDeque::with_capacity(60);
let mut fps_log_timer: f32 = 0.0;
let mut surface_idx: usize = SURFACE_CYCLE
.iter()
.position(|&s| s == cfg.surface.as_str())
.unwrap_or(0);
let mut preset_timer: f32 = 0.0;
let mut preset_idx: usize = 0;
tracing::info!("entering render loop");
let mut all_verts: Vec<geodesic_wallpaper::trail::TrailVertex> = Vec::new();
let mut seg_lens: Vec<usize> = Vec::new();
loop {
if tray_state.quit_requested() {
tracing::info!("quit requested via tray icon");
return Ok(());
}
if let Some(new_surface) = tray_state.take_surface_request() {
if new_surface != current_surface_name {
tracing::info!(surface = %new_surface, "switching surface via tray");
current_surface_name = new_surface.clone();
if let Ok(mut w) = shared_cfg.write() {
w.surface = new_surface.clone();
}
surf = build_surface_by_name(&new_surface);
let (mv, mi) = surf.mesh_vertices(40, 40);
let show_wire = renderer.show_wireframe;
let max_tv = renderer.trail_vbuf_capacity();
renderer = pollster::block_on(Renderer::new(
hwnd, sw as u32, sh as u32, &mv, &mi, max_tv, show_wire,
))?;
renderer.camera = geodesic_wallpaper::renderer::camera::Camera::new_with_params(
sw as f32 / sh as f32,
cfg.camera_distance,
cfg.camera_elevation,
cfg.camera_fov,
cfg.camera_elevation_speed,
);
geodesics.clear();
trails.clear();
let (tl, ng, fp, cm) = {
let c = shared_cfg
.read()
.map(|c| {
(
c.trail_length,
c.num_geodesics,
c.effective_fade_power(),
c.color_mode.clone(),
)
})
.unwrap_or((300, 30, 2.0, "cycle".into()));
c
};
for i in 0..ng {
let (u, v) = surf.random_position(&mut rng);
let (du, dv) = surf.random_tangent(u, v, &mut rng);
let ci = pick_color_idx(i, colors.len(), &cm, &mut rng);
geodesics.push(Geodesic::new(u, v, du, dv, tl, ci));
trails.push(TrailBuffer::new(tl, colors[ci], fp));
}
}
}
unsafe {
let mut msg = MSG::default();
while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() {
if msg.message == 0x0012 {
tracing::info!("WM_QUIT received, exiting");
return Ok(());
}
let _ = TranslateMessage(&msg);
DispatchMessageW(&msg);
}
}
{
let fps = shared_cfg
.read()
.map(|c| c.effective_target_fps())
.unwrap_or(30);
target_frame = Duration::from_micros(1_000_000 / fps as u64);
}
let now = std::time::Instant::now();
let frame_dt = now.duration_since(last_frame);
if frame_dt < target_frame {
let remaining = target_frame - frame_dt;
if remaining > Duration::from_millis(1) {
std::thread::sleep(remaining - Duration::from_micros(500));
}
continue;
}
let frame_dt_secs = frame_dt.as_secs_f32().min(0.1); last_frame = std::time::Instant::now();
frame_count += 1;
elapsed_secs += frame_dt_secs;
renderer.elapsed_secs = elapsed_secs;
{
let show_hud = shared_cfg.read().map(|c| c.show_hud).unwrap_or(false);
if show_hud {
let hud_elapsed = hud_last.elapsed();
if hud_elapsed >= Duration::from_secs(1) {
let fps = frame_count as f64 / hud_elapsed.as_secs_f64();
tracing::info!(
fps = format!("{fps:.1}"),
surface = %current_surface_name,
geodesics = geodesics.len(),
"HUD"
);
hud_last = std::time::Instant::now();
frame_count = 0;
}
}
}
if tray_state.is_paused() {
continue;
}
{
let show_wire = shared_cfg.read().map(|c| c.show_wireframe).unwrap_or(true);
renderer.show_wireframe = show_wire;
}
frame_times.push_back(frame_dt);
if frame_times.len() > 60 {
frame_times.pop_front();
}
fps_log_timer += frame_dt_secs;
if fps_log_timer >= 5.0 {
fps_log_timer = 0.0;
if !frame_times.is_empty() {
let avg_ms: f32 = frame_times
.iter()
.map(|d| d.as_secs_f32() * 1000.0)
.sum::<f32>()
/ frame_times.len() as f32;
let fps = 1000.0 / avg_ms;
if renderer.show_fps_hud {
tracing::info!(fps = fps as u32, frame_ms = avg_ms as u32, "FPS");
}
}
}
{
let (cycle_secs, presets_len) = shared_cfg
.read()
.map(|c| (c.preset_cycle_secs, c.presets_order.len()))
.unwrap_or((None, 0));
if let Some(secs) = cycle_secs {
if presets_len > 0 {
preset_timer += frame_dt_secs;
if preset_timer >= secs {
preset_timer = 0.0;
preset_idx = (preset_idx + 1) % presets_len;
if let Ok(mut w) = shared_cfg.write() {
let next_profile = w.presets_order.get(preset_idx).cloned();
w.active_profile = next_profile;
let resolved = w.clone().resolve_profile();
*w = resolved;
tracing::info!(preset_idx, "advanced to next preset profile");
}
}
}
}
}
while let Ok(event) = key_rx.try_recv() {
let respawn = |geodesics: &mut Vec<Geodesic>,
trails: &mut Vec<TrailBuffer>,
surf: &Arc<dyn Surface>,
colors: &[[f32; 4]],
rng: &mut StdRng,
tl: usize,
ng: usize,
fp: f32,
cm: &str| {
geodesics.clear();
trails.clear();
for i in 0..ng {
let (u, v) = surf.random_position(rng);
let (du, dv) = surf.random_tangent(u, v, rng);
let ci = pick_color_idx(i, colors.len(), cm, rng);
geodesics.push(Geodesic::new(u, v, du, dv, tl, ci));
trails.push(TrailBuffer::new(tl, colors[ci], fp));
}
};
match event {
KeyEvent::CycleSurface | KeyEvent::CycleSurfaceBack => {
let forward = matches!(event, KeyEvent::CycleSurface);
if forward {
surface_idx = (surface_idx + 1) % SURFACE_CYCLE.len();
} else {
surface_idx = surface_idx
.checked_sub(1)
.unwrap_or(SURFACE_CYCLE.len() - 1);
}
let name = SURFACE_CYCLE[surface_idx];
tracing::info!(surface = name, forward, "cycling surface");
surf = build_surface_by_name(name);
current_surface_name = name.to_string();
let (mv, mi) = surf.mesh_vertices(40, 40);
renderer.update_surface_mesh(&mv, &mi);
let (tl, ng, fp, cm) = shared_cfg
.read()
.map(|c| {
(
c.trail_length,
c.num_geodesics,
c.effective_fade_power(),
c.color_mode.clone(),
)
})
.unwrap_or((300, 30, 2.0, "cycle".into()));
respawn(
&mut geodesics,
&mut trails,
&surf,
&colors,
&mut rng,
tl,
ng,
fp,
&cm,
);
}
KeyEvent::SpeedUp => {
if let Ok(mut c) = shared_cfg.write() {
c.rotation_speed *= 1.1;
tracing::info!(rotation_speed = c.rotation_speed, "speed increased");
}
}
KeyEvent::SpeedDown => {
if let Ok(mut c) = shared_cfg.write() {
c.rotation_speed *= 0.9;
tracing::info!(rotation_speed = c.rotation_speed, "speed decreased");
}
}
KeyEvent::ResetGeodesics => {
let (tl, ng, fp, cm) = shared_cfg
.read()
.map(|c| {
(
c.trail_length,
c.num_geodesics,
c.effective_fade_power(),
c.color_mode.clone(),
)
})
.unwrap_or((300, 30, 2.0, "cycle".into()));
respawn(
&mut geodesics,
&mut trails,
&surf,
&colors,
&mut rng,
tl,
ng,
fp,
&cm,
);
tracing::info!("geodesics reset");
}
KeyEvent::ToggleFpsHud => {
renderer.toggle_fps_hud();
tracing::info!(show_fps_hud = renderer.show_fps_hud, "toggled FPS HUD");
}
KeyEvent::TogglePause => {
tray_state.toggle_pause();
tracing::info!(
paused = tray_state.is_paused(),
"toggled pause via keyboard"
);
}
KeyEvent::Screenshot => {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let path = format!("geodesic_screenshot_{ts}.png");
match renderer.capture_screenshot(sw as u32, sh as u32) {
Ok(pixels) => {
match image::save_buffer(
&path,
&pixels,
sw as u32,
sh as u32,
image::ColorType::Rgba8,
) {
Ok(()) => tracing::info!(path, "screenshot saved"),
Err(e) => tracing::warn!(path, "screenshot save failed: {e}"),
}
}
Err(e) => tracing::warn!("screenshot capture failed: {e}"),
}
}
KeyEvent::TunerPrevParam
| KeyEvent::TunerNextParam
| KeyEvent::TunerDecrease
| KeyEvent::TunerIncrease
| KeyEvent::ToggleRecording
| KeyEvent::ToggleGallery
| KeyEvent::GalleryPrev
| KeyEvent::GalleryNext => {}
}
}
if reload_rx.try_recv().is_ok() {
reload_flash_timer = 0.5;
}
let (rot, bg_color_str, cycle_enabled, cycle_speed, light_dir_cfg) = shared_cfg
.read()
.map(|c| {
(
c.rotation_speed,
c.background_color.clone(),
c.color_cycle_enabled,
c.color_cycle_speed,
c.light_dir,
)
})
.unwrap_or((0.001047, "#050510".into(), false, 0.0, [1.0, 1.0, 1.0]));
renderer.light_dir = light_dir_cfg;
let (base_r, base_g, base_b) = parse_bg_color(&bg_color_str);
if reload_flash_timer > 0.0 {
reload_flash_timer -= frame_dt_secs;
let t = (reload_flash_timer / 0.5).clamp(0.0, 1.0);
let flash_r = base_r * (1.0 - t as f64) + 0.3 * t as f64;
let flash_g = base_g * (1.0 - t as f64) + 0.3 * t as f64;
let flash_b = base_b * (1.0 - t as f64) + 0.8 * t as f64;
renderer.set_background(flash_r, flash_g, flash_b);
} else {
renderer.set_background(base_r, base_g, base_b);
}
renderer.camera.orbit(rot * frame_dt_secs);
renderer.camera.drift_elevation(frame_dt_secs);
colors = if let Ok(c) = shared_cfg.read() {
c.effective_colors()
} else {
colors.clone()
};
let display_colors = if cycle_enabled && cycle_speed > 0.0 {
let degrees = elapsed_secs * cycle_speed * 360.0 % 360.0;
rotate_hue(&colors, degrees)
} else {
colors.clone()
};
{
let surf_ref = &*surf;
geodesics
.par_iter_mut()
.zip(trails.par_iter_mut())
.for_each(|(geo, trail)| {
if !geo.alive {
return;
}
let pos = surf_ref.position(geo.u, geo.v);
trail.push([pos.x, pos.y, pos.z]);
geo.step(surf_ref, dt);
});
}
let (tl, cm, fp) = shared_cfg
.read()
.map(|c| {
(
c.trail_length,
c.color_mode.clone(),
c.effective_fade_power(),
)
})
.unwrap_or((300, "cycle".into(), 2.0));
for (i, geo) in geodesics.iter_mut().enumerate() {
if !geo.alive {
let (u, v) = surf.random_position(&mut rng);
let (du, dv) = surf.random_tangent(u, v, &mut rng);
let ci = pick_color_idx(i, display_colors.len(), &cm, &mut rng);
*geo = Geodesic::new(u, v, du, dv, tl, ci);
trails[i].clear();
trails[i].color = display_colors[ci % display_colors.len().max(1)];
trails[i].fade_power = fp;
}
}
all_verts.clear();
seg_lens.clear();
for (trail_idx, trail) in trails.iter().enumerate() {
let ci = geodesics[trail_idx].color_idx % display_colors.len().max(1);
let [dr, dg, db, _] = display_colors[ci];
let before = all_verts.len();
trail.append_ordered(&mut all_verts, Some([dr, dg, db]));
seg_lens.push(all_verts.len() - before);
}
renderer.render(&all_verts, &seg_lens);
}
}