feat: add minimal app shell #2
Generated
+2823
-8
File diff suppressed because it is too large
Load Diff
+10
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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()))),
|
||||
)
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() -> eframe::Result<()> {
|
||||
openvistapro::app::run()
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
#[cfg(feature = "app")]
|
||||
pub mod app;
|
||||
pub mod app_state;
|
||||
pub mod cli;
|
||||
pub mod colormap;
|
||||
pub mod render;
|
||||
|
||||
Reference in New Issue
Block a user