feat: add minimal app shell #2

Merged
moldybits merged 1 commits from feat/minimal-app-shell into main 2026-05-15 20:35:13 -04:00
7 changed files with 3169 additions and 8 deletions
Showing only changes of commit 5a02903a2e - Show all commits
Generated
+2823 -8
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -3,8 +3,18 @@ name = "openvistapro"
version = "0.1.0"
edition = "2024"
[features]
default = []
app = ["dep:eframe"]
[dependencies]
clap = { version = "4.6.1", features = ["derive"] }
eframe = { version = "0.32.3", optional = true, default-features = false, features = ["default_fonts", "wayland", "wgpu", "x11"] }
image = { version = "0.25.9", default-features = false, features = ["png"] }
serde = { version = "1", features = ["derive"] }
toml = "0.8"
[[bin]]
name = "openvistapro_app"
path = "src/bin/openvistapro_app.rs"
required-features = ["app"]
+4
View File
@@ -23,8 +23,12 @@ cargo run -- scene export --output /tmp/openvistapro-default.ovp.toml
cargo run -- render --preset hill --width 256 --height 256 --output /tmp/openvistapro-hill.png
cargo run -- render --preset hill --scene /tmp/openvistapro-default.ovp.toml --width 256 --height 256 --output /tmp/openvistapro-hill-from-scene.png
cargo run -- render --preset hill --camera-demo --width 256 --height 192 --output /tmp/openvistapro-perspective.png
cargo run --features app --bin openvistapro_app
```
The optional app shell is gated behind the `app` feature so default CLI builds stay GPU-free.
It opens an `eframe`/`egui` window titled `OpenVistaPro` with scene controls and a CPU-rendered terrain preview.
The default `render` mode writes a deterministic top-down elevation preview.
Passing `--camera-demo` switches to the current CPU perspective renderer spike:
a simple pinhole-camera raymarcher with bilinear height sampling, fixed step
+153
View File
@@ -0,0 +1,153 @@
use eframe::egui;
use image::RgbImage;
use crate::app_state::{AppAction, AppData, RendererMode, TerrainPreset};
use crate::scene::Vec3;
pub const WINDOW_TITLE: &str = "OpenVistaPro";
#[derive(Default)]
pub struct OpenVistaProApp {
data: AppData,
texture: Option<egui::TextureHandle>,
}
impl OpenVistaProApp {
pub fn new() -> Self {
Self::default()
}
fn rebuild_texture(&mut self, ctx: &egui::Context) {
let Ok(preview) = self.data.render_preview() else {
return;
};
let color_image = rgb_image_to_color_image(&preview);
match &mut self.texture {
Some(texture) => texture.set(color_image, egui::TextureOptions::NEAREST),
None => {
self.texture = Some(ctx.load_texture(
"openvistapro_cpu_preview",
color_image,
egui::TextureOptions::NEAREST,
));
}
}
}
}
impl eframe::App for OpenVistaProApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
let mut changed = false;
egui::SidePanel::left("scene_controls").show(ctx, |ui| {
ui.heading("OpenVistaPro");
ui.label("CPU preview shell");
ui.separator();
ui.label("Terrain");
let mut preset = self.data.terrain_preset;
changed |= ui
.radio_value(&mut preset, TerrainPreset::RadialHill, "Radial hill")
.changed();
changed |= ui
.radio_value(&mut preset, TerrainPreset::Plane, "Plane")
.changed();
if preset != self.data.terrain_preset {
self.data.apply(AppAction::SetTerrainPreset(preset));
}
ui.separator();
ui.label("Renderer");
let mut renderer_mode = self.data.renderer_mode;
changed |= ui
.radio_value(&mut renderer_mode, RendererMode::TopDown, "Top-down")
.changed();
changed |= ui
.radio_value(&mut renderer_mode, RendererMode::Perspective, "Perspective")
.changed();
if renderer_mode != self.data.renderer_mode {
self.data.apply(AppAction::SetRendererMode(renderer_mode));
}
ui.separator();
ui.label("Scene bands");
let mut water = self.data.scene.water_level;
let mut trees = self.data.scene.tree_line;
let mut snow = self.data.scene.snow_line;
let mut haze = self.data.scene.haze;
changed |= ui
.add(egui::Slider::new(&mut water, -5.0..=10.0).text("Water"))
.changed();
changed |= ui
.add(egui::Slider::new(&mut trees, -5.0..=12.0).text("Trees"))
.changed();
changed |= ui
.add(egui::Slider::new(&mut snow, -5.0..=15.0).text("Snow"))
.changed();
changed |= ui
.add(egui::Slider::new(&mut haze, 0.0..=1.0).text("Haze"))
.changed();
self.data.apply(AppAction::SetWaterLevel(water));
self.data.apply(AppAction::SetTreeLine(trees));
self.data.apply(AppAction::SetSnowLine(snow));
self.data.apply(AppAction::SetHaze(haze));
ui.separator();
ui.label("Camera");
let mut camera_position = self.data.scene.camera.position;
let mut camera_target = self.data.scene.camera.target;
changed |= vec3_controls(ui, "Position", &mut camera_position);
changed |= vec3_controls(ui, "Target", &mut camera_target);
self.data
.apply(AppAction::SetCameraPosition(camera_position));
self.data.apply(AppAction::SetCameraTarget(camera_target));
});
if changed || self.texture.is_none() {
self.rebuild_texture(ctx);
}
egui::CentralPanel::default().show(ctx, |ui| {
ui.heading("Preview");
if let Some(texture) = &self.texture {
ui.image((texture.id(), texture.size_vec2()));
} else {
ui.label("Preview unavailable");
}
});
}
}
fn vec3_controls(ui: &mut egui::Ui, label: &str, value: &mut Vec3) -> bool {
let mut changed = false;
ui.label(label);
ui.horizontal(|ui| {
changed |= ui
.add(egui::DragValue::new(&mut value.x).prefix("x "))
.changed();
changed |= ui
.add(egui::DragValue::new(&mut value.y).prefix("y "))
.changed();
changed |= ui
.add(egui::DragValue::new(&mut value.z).prefix("z "))
.changed();
});
changed
}
fn rgb_image_to_color_image(image: &RgbImage) -> egui::ColorImage {
let size = [image.width() as usize, image.height() as usize];
egui::ColorImage::from_rgb(size, image.as_raw())
}
pub fn run() -> eframe::Result<()> {
let options = eframe::NativeOptions {
renderer: eframe::Renderer::Wgpu,
..Default::default()
};
eframe::run_native(
WINDOW_TITLE,
options,
Box::new(|_cc| Ok(Box::new(OpenVistaProApp::new()))),
)
}
+173
View File
@@ -0,0 +1,173 @@
use image::RgbImage;
use crate::render::{render_perspective, render_top_down};
use crate::scene::{Scene, Vec3};
use crate::terrain::{HeightGrid, TerrainError};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TerrainPreset {
Plane,
RadialHill,
}
impl TerrainPreset {
pub fn build_grid(self, width: u32, height: u32) -> Result<HeightGrid, TerrainError> {
match self {
TerrainPreset::Plane => HeightGrid::plane(width, height),
TerrainPreset::RadialHill => HeightGrid::radial_hill(width, height, 10.0),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RendererMode {
TopDown,
Perspective,
}
#[derive(Debug, Clone, PartialEq)]
pub struct AppData {
pub scene: Scene,
pub terrain_preset: TerrainPreset,
pub renderer_mode: RendererMode,
pub preview_size: (u32, u32),
pub loaded_scene_path: Option<String>,
}
impl Default for AppData {
fn default() -> Self {
Self {
scene: Scene::default(),
terrain_preset: TerrainPreset::RadialHill,
renderer_mode: RendererMode::TopDown,
preview_size: (256, 256),
loaded_scene_path: None,
}
}
}
impl AppData {
pub fn apply(&mut self, action: AppAction) {
match action {
AppAction::SetTerrainPreset(preset) => self.terrain_preset = preset,
AppAction::SetRendererMode(mode) => self.renderer_mode = mode,
AppAction::SetWaterLevel(value) => self.scene.water_level = value,
AppAction::SetTreeLine(value) => self.scene.tree_line = value,
AppAction::SetSnowLine(value) => self.scene.snow_line = value,
AppAction::SetHaze(value) => self.scene.haze = value.clamp(0.0, 1.0),
AppAction::SetCameraPosition(position) => self.scene.camera.position = position,
AppAction::SetCameraTarget(target) => self.scene.camera.target = target,
AppAction::SetPreviewSize { width, height } => {
self.preview_size = (width.max(1), height.max(1));
}
AppAction::SetLoadedScenePath(path) => self.loaded_scene_path = path,
}
}
pub fn build_preview_grid(&self) -> Result<HeightGrid, TerrainError> {
let (width, height) = self.preview_size;
self.terrain_preset.build_grid(width, height)
}
pub fn render_preview(&self) -> Result<RgbImage, TerrainError> {
let grid = self.build_preview_grid()?;
let (width, height) = self.preview_size;
let image = match self.renderer_mode {
RendererMode::TopDown => render_top_down(&grid, &self.scene),
RendererMode::Perspective => render_perspective(&grid, &self.scene, width, height),
};
Ok(image)
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum AppAction {
SetTerrainPreset(TerrainPreset),
SetRendererMode(RendererMode),
SetWaterLevel(f32),
SetTreeLine(f32),
SetSnowLine(f32),
SetHaze(f32),
SetCameraPosition(Vec3),
SetCameraTarget(Vec3),
SetPreviewSize { width: u32, height: u32 },
SetLoadedScenePath(Option<String>),
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scene::{Scene, Vec3};
#[test]
fn default_app_data_uses_hill_preset_and_scene_defaults() {
let app = AppData::default();
assert_eq!(app.scene, Scene::default());
assert_eq!(app.terrain_preset, TerrainPreset::RadialHill);
assert_eq!(app.renderer_mode, RendererMode::TopDown);
assert_eq!(app.preview_size, (256, 256));
assert_eq!(app.loaded_scene_path, None);
}
#[test]
fn reducer_updates_existing_scene_controls_without_replacing_scene() {
let mut app = AppData::default();
let original_camera = app.scene.camera;
app.apply(AppAction::SetWaterLevel(2.5));
app.apply(AppAction::SetTreeLine(5.5));
app.apply(AppAction::SetSnowLine(8.5));
app.apply(AppAction::SetHaze(0.75));
assert_eq!(app.scene.water_level, 2.5);
assert_eq!(app.scene.tree_line, 5.5);
assert_eq!(app.scene.snow_line, 8.5);
assert_eq!(app.scene.haze, 0.75);
assert_eq!(app.scene.camera, original_camera);
}
#[test]
fn reducer_updates_camera_vectors_and_mode() {
let mut app = AppData::default();
let position = Vec3::new(1.0, 2.0, 3.0);
let target = Vec3::new(4.0, 5.0, 6.0);
app.apply(AppAction::SetCameraPosition(position));
app.apply(AppAction::SetCameraTarget(target));
app.apply(AppAction::SetRendererMode(RendererMode::Perspective));
assert_eq!(app.scene.camera.position, position);
assert_eq!(app.scene.camera.target, target);
assert_eq!(app.renderer_mode, RendererMode::Perspective);
}
#[test]
fn terrain_preset_builds_expected_height_grid() {
let plane = TerrainPreset::Plane.build_grid(8, 4).unwrap();
let hill = TerrainPreset::RadialHill.build_grid(9, 9).unwrap();
assert_eq!(plane.min_max(), Some((0.0, 0.0)));
assert!(hill.min_max().unwrap().1 > 0.0);
}
#[test]
fn preview_image_matches_requested_preview_size() {
let mut app = AppData::default();
app.apply(AppAction::SetPreviewSize {
width: 96,
height: 64,
});
let preview = app.render_preview().unwrap();
assert_eq!(preview.width(), 96);
assert_eq!(preview.height(), 64);
}
#[cfg(not(feature = "app"))]
#[test]
fn app_feature_is_declared_but_not_enabled_by_default() {
assert!(!cfg!(feature = "app"));
}
}
+3
View File
@@ -0,0 +1,3 @@
fn main() -> eframe::Result<()> {
openvistapro::app::run()
}
+3
View File
@@ -1,3 +1,6 @@
#[cfg(feature = "app")]
pub mod app;
pub mod app_state;
pub mod cli;
pub mod colormap;
pub mod render;