use std::{fs::File, io::BufWriter, path::PathBuf, sync::Arc};
use anyhow::Result;
use spacecurve::registry;
pub const APP_NAME: &str = "spacecurve";
pub const APP_REPO_URL: &str = "https://github.com/cortesi/spacecurve";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Pane {
#[default]
TwoD,
ThreeD,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScreenshotTarget {
TwoD,
ThreeD,
About,
Settings,
Settings3D,
}
#[derive(Debug, Clone)]
pub struct ScreenshotConfig {
pub target: ScreenshotTarget,
pub output_path: PathBuf,
}
#[derive(Debug)]
struct ActiveScreenshot {
output_path: PathBuf,
requested: bool,
}
#[derive(Debug, Clone, Default)]
pub struct GuiOptions {
pub include_experimental_curves: bool,
pub screenshot: Option<ScreenshotConfig>,
pub show_dev_overlay: bool,
}
pub mod about;
pub mod selection;
pub mod snake;
pub mod state;
pub mod theme;
pub mod threed;
pub mod twod;
pub mod widgets;
pub use selection::{Selected3DCurve, SelectedCurve};
use state::AnimationController;
use threed::show_3d_pane;
use twod::show_2d_pane;
pub struct SharedSettings {
pub curve_opacity: f32,
pub curve_long_jumps: bool,
pub snake_long_jumps: bool,
pub snake_enabled: bool,
pub snake_length: f32, pub snake_speed: f32,
pub spin_speed: f32,
}
impl Default for SharedSettings {
fn default() -> Self {
Self {
curve_opacity: 0.35, curve_long_jumps: false,
snake_long_jumps: false,
snake_enabled: true,
snake_length: 5.0, snake_speed: 30.0, spin_speed: 50.0, }
}
}
pub struct AppState {
pub current_pane: Pane,
pub animation_time: f32,
pub paused: bool,
pub rotation_angle: f32,
pub mouse_dragging: bool,
pub last_mouse_x: f32,
pub snake_time: f32,
pub settings_dropdown_open: bool,
pub settings_dropdown_pos: Option<egui::Pos2>,
pub about_open: bool,
pub frame_time_ms: Option<f32>,
pub frame_time_display_ms: Option<f32>,
pub frame_time_last_display_s: Option<f64>,
}
impl Default for AppState {
fn default() -> Self {
Self {
current_pane: Pane::TwoD,
animation_time: 0.0,
paused: false,
rotation_angle: 0.0,
mouse_dragging: false,
last_mouse_x: 0.0,
snake_time: 0.0,
settings_dropdown_open: false,
settings_dropdown_pos: None,
about_open: false,
frame_time_ms: None,
frame_time_display_ms: None,
frame_time_last_display_s: None,
}
}
}
pub struct RenderCache {
pub snake_segments_2d: Vec<usize>,
pub snake_segments_3d: Vec<usize>,
pub snake_mask_2d: Vec<bool>,
pub snake_mask_3d: Vec<bool>,
pub snake_included_3d: Vec<bool>,
pub last_canvas_rect: Option<egui::Rect>,
pub cache_3d_points: Vec<[f32; 3]>,
pub cache_3d_screen: Vec<egui::Pos2>,
pub cache_connected: Vec<bool>,
pub cache_caps: Vec<(bool, bool)>,
pub cache_depths: Vec<(usize, f32)>,
pub cache_2d_screen: Vec<egui::Pos2>,
pub cache_2d_run: Vec<egui::Pos2>,
pub cache_bins: Vec<Vec<usize>>,
}
impl Default for RenderCache {
fn default() -> Self {
Self {
snake_segments_2d: Vec::new(),
snake_segments_3d: Vec::new(),
snake_mask_2d: Vec::new(),
snake_mask_3d: Vec::new(),
snake_included_3d: Vec::new(),
last_canvas_rect: None,
cache_3d_points: Vec::new(),
cache_3d_screen: Vec::new(),
cache_connected: Vec::new(),
cache_caps: Vec::new(),
cache_depths: Vec::new(),
cache_2d_screen: Vec::new(),
cache_2d_run: Vec::new(),
cache_bins: vec![Vec::new(); 128],
}
}
}
pub struct ScurveApp {
selected_curve: SelectedCurve,
selected_3d_curve: Selected3DCurve,
available_curves: Vec<&'static str>,
app_state: AppState,
render_cache: RenderCache,
shared_settings: SharedSettings,
screenshot: Option<ActiveScreenshot>,
last_time: Option<f64>,
commonmark_cache: egui_commonmark::CommonMarkCache,
show_dev_overlay: bool,
}
impl ScurveApp {
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
Self::with_options(cc, GuiOptions::default())
}
pub fn with_screenshot_config(
cc: &eframe::CreationContext<'_>,
screenshot_config: Option<ScreenshotConfig>,
) -> Self {
Self::with_options(
cc,
GuiOptions {
screenshot: screenshot_config,
..GuiOptions::default()
},
)
}
pub fn with_options(cc: &eframe::CreationContext<'_>, options: GuiOptions) -> Self {
theme::configure_visuals(&cc.egui_ctx);
let include_experimental = options.include_experimental_curves;
let mut available_curves = registry::curve_names(include_experimental);
if available_curves.is_empty() {
available_curves = registry::curve_names(true);
}
let default_curve = available_curves
.first()
.copied()
.unwrap_or(registry::CURVE_NAMES[0]);
let mut app_state = AppState::default();
let render_cache = RenderCache::default();
let screenshot_config = options.screenshot;
let mut screenshot_runtime = screenshot_config.as_ref().map(|cfg| ActiveScreenshot {
output_path: cfg.output_path.clone(),
requested: false,
});
if let Some(config) = screenshot_config {
match config.target {
ScreenshotTarget::TwoD => {
app_state.current_pane = Pane::TwoD;
}
ScreenshotTarget::ThreeD => {
app_state.current_pane = Pane::ThreeD;
}
ScreenshotTarget::About => {
app_state.current_pane = Pane::TwoD;
app_state.about_open = true;
}
ScreenshotTarget::Settings => {
app_state.current_pane = Pane::TwoD;
app_state.settings_dropdown_open = true;
}
ScreenshotTarget::Settings3D => {
app_state.current_pane = Pane::ThreeD;
app_state.settings_dropdown_open = true;
}
}
app_state.paused = true;
}
Self {
selected_curve: SelectedCurve::with_name(default_curve),
selected_3d_curve: Selected3DCurve::with_name(default_curve),
available_curves,
app_state,
render_cache,
shared_settings: Default::default(),
screenshot: screenshot_runtime.take(),
last_time: None,
commonmark_cache: Default::default(),
show_dev_overlay: options.show_dev_overlay,
}
}
fn show_menu_bar(&mut self, ctx: &egui::Context) {
egui::TopBottomPanel::top("menu_bar")
.frame(egui::Frame::new().inner_margin(egui::Margin {
left: theme::menu_bar::PADDING_HORIZONTAL as i8,
right: theme::menu_bar::PADDING_HORIZONTAL as i8,
top: theme::menu_bar::PADDING_VERTICAL as i8,
bottom: theme::menu_bar::PADDING_VERTICAL as i8,
}))
.show(ctx, |ui| {
ui.horizontal(|ui| {
if ui
.link(
egui::RichText::new(APP_NAME)
.size(theme::font_size::TITLE)
.strong()
.color(theme::TEXT_HEADING),
)
.clicked()
&& let Err(e) = webbrowser::open(APP_REPO_URL)
{
eprintln!("Failed to open browser: {e}");
}
ui.add_space(theme::menu_bar::TITLE_SPACING);
let tab_text_size = 15.0;
if ui
.selectable_label(
self.app_state.current_pane == Pane::TwoD,
egui::RichText::new("2D").size(tab_text_size),
)
.clicked()
{
self.app_state.current_pane = Pane::TwoD;
}
ui.add_space(theme::menu_bar::TAB_SPACING);
if ui
.selectable_label(
self.app_state.current_pane == Pane::ThreeD,
egui::RichText::new("3D").size(tab_text_size),
)
.clicked()
{
self.app_state.current_pane = Pane::ThreeD;
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.add_space(theme::menu_bar::BUTTON_PADDING);
if ui.button("About").clicked() {
self.app_state.about_open = !self.app_state.about_open;
}
});
});
});
}
fn handle_screenshot(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
let Some(screenshot) = self.screenshot.as_mut() else {
return;
};
if !screenshot.requested {
screenshot.requested = true;
ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(Default::default()));
ctx.request_repaint();
return;
}
let mut captured: Option<Arc<egui::ColorImage>> = None;
ctx.input(|input| {
for event in &input.events {
if let egui::Event::Screenshot { image, .. } = event {
captured = Some(image.clone());
break;
}
}
});
if let Some(image) = captured {
if let Err(err) = save_color_image(&screenshot.output_path, &image) {
eprintln!("Failed to save screenshot: {err}");
}
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
} else {
ctx.request_repaint();
}
}
fn update_frame_time(&mut self, delta_seconds: f32, now_seconds: f64) {
const DISPLAY_INTERVAL_S: f64 = 0.25;
if !self.show_dev_overlay {
return;
}
let ms = delta_seconds * 1000.0;
let smoothed = match self.app_state.frame_time_ms {
Some(prev) => prev * 0.85 + ms * 0.15,
None => ms,
};
self.app_state.frame_time_ms = Some(smoothed);
let should_update = match self.app_state.frame_time_last_display_s {
Some(last) => now_seconds - last >= DISPLAY_INTERVAL_S,
None => true,
};
if should_update {
self.app_state.frame_time_display_ms = Some(smoothed);
self.app_state.frame_time_last_display_s = Some(now_seconds);
}
}
fn show_frame_time_overlay(&self, ctx: &egui::Context) {
let Some(ms) = self
.app_state
.frame_time_display_ms
.or(self.app_state.frame_time_ms)
else {
return;
};
let fps = if ms > 0.0 { 1000.0 / ms } else { 0.0 };
let pos = if let Some(rect) = self.render_cache.last_canvas_rect {
egui::pos2(rect.max.x - 12.0, rect.min.y + 12.0)
} else {
let screen_rect = ctx.viewport_rect();
egui::pos2(screen_rect.max.x - 12.0, screen_rect.min.y + 12.0)
};
egui::Area::new(egui::Id::new("dev_frame_time_overlay"))
.order(egui::Order::Tooltip)
.fixed_pos(pos)
.show(ctx, |ui| {
egui::Frame::new()
.fill(theme::PANEL_BACKGROUND)
.stroke(egui::Stroke::new(1.0, theme::BORDER))
.corner_radius(egui::CornerRadius::same(4))
.inner_margin(egui::Margin::symmetric(8, 6))
.show(ui, |ui| {
ui.set_min_width(130.0);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(format!("{ms:.1} ms"))
.color(theme::TEXT_PRIMARY)
.size(theme::font_size::INFO),
);
ui.add_space(6.0);
ui.label(
egui::RichText::new(format!("{fps:.1} fps"))
.color(theme::TEXT_PRIMARY)
.size(theme::font_size::INFO),
);
});
});
});
}
}
impl eframe::App for ScurveApp {
fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
let now = ctx.input(|i| i.time);
if let Some(prev) = self.last_time {
let delta = (now - prev) as f32;
let clamped_delta = delta.max(0.0);
self.update_frame_time(clamped_delta, now);
AnimationController::update(
clamped_delta,
&mut self.app_state,
&self.shared_settings,
&mut self.selected_curve,
&mut self.selected_3d_curve,
);
}
self.last_time = Some(now);
let needs_repaint = self.shared_settings.snake_enabled
|| (self.app_state.current_pane == Pane::ThreeD
&& (!self.app_state.paused || self.app_state.mouse_dragging));
if needs_repaint {
ctx.request_repaint();
}
self.show_menu_bar(ctx);
if self.app_state.about_open {
about::show_about_dialog(
ctx,
&mut self.app_state.about_open,
&mut self.commonmark_cache,
);
}
egui::CentralPanel::default().show(ctx, |ui| match self.app_state.current_pane {
Pane::TwoD => {
show_2d_pane(
ui,
&mut self.app_state,
&mut self.render_cache,
&mut self.selected_curve,
&self.available_curves,
&mut self.shared_settings,
);
}
Pane::ThreeD => {
show_3d_pane(
ui,
&mut self.app_state,
&mut self.render_cache,
&mut self.selected_3d_curve,
&self.available_curves,
&mut self.shared_settings,
);
}
});
AnimationController::sync_panes(
self.app_state.current_pane,
&mut self.selected_curve,
&mut self.selected_3d_curve,
&self.available_curves,
);
self.handle_screenshot(ctx, frame);
if self.show_dev_overlay {
self.show_frame_time_overlay(ctx);
}
}
}
fn save_color_image(path: &PathBuf, image: &egui::ColorImage) -> anyhow::Result<()> {
use png::{BitDepth, ColorType, Encoder};
let file = File::create(path)?;
let buffered_file = BufWriter::new(file);
let mut encoder = Encoder::new(buffered_file, image.size[0] as u32, image.size[1] as u32);
encoder.set_color(ColorType::Rgba);
encoder.set_depth(BitDepth::Eight);
let mut writer = encoder.write_header()?;
let mut data = Vec::with_capacity(image.pixels.len() * 4);
for color in &image.pixels {
let [red, green, blue, alpha] = color.to_srgba_unmultiplied();
data.extend_from_slice(&[red, green, blue, alpha]);
}
writer.write_image_data(&data)?;
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
pub fn gui() -> Result<()> {
gui_with_options(GuiOptions::default())
}
#[cfg(not(target_arch = "wasm32"))]
pub fn gui_with_screenshot(screenshot_config: Option<ScreenshotConfig>) -> Result<()> {
gui_with_options(GuiOptions {
screenshot: screenshot_config,
..GuiOptions::default()
})
}
#[cfg(not(target_arch = "wasm32"))]
pub fn gui_with_options(options: GuiOptions) -> Result<()> {
let native_options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size(theme::window::DEFAULT_SIZE)
.with_title(format!("{APP_NAME} gui")),
..Default::default()
};
let options_clone = options;
eframe::run_native(
&format!("{APP_NAME} gui"),
native_options,
Box::new(move |cc| Ok(Box::new(ScurveApp::with_options(cc, options_clone)))),
)
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
Ok(())
}
#[cfg(target_arch = "wasm32")]
pub fn gui() -> Result<()> {
Ok(())
}