feat: wire vertical exaggeration through the shell #13
+67
-4
@@ -3,7 +3,9 @@ use std::path::Path;
|
||||
use image::RgbImage;
|
||||
|
||||
use crate::path::{CameraPath, build_demo_path};
|
||||
use crate::render::{render_perspective, render_top_down};
|
||||
use crate::render::{
|
||||
render_perspective_with_quality, render_top_down_with_quality, RenderQualityPreset,
|
||||
};
|
||||
use crate::scene::{Scene, Vec3};
|
||||
use crate::scene_file::{self, SceneFileError};
|
||||
use crate::script::{Command, parse_script};
|
||||
@@ -49,6 +51,32 @@ impl RendererMode {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RenderQuality {
|
||||
Preview,
|
||||
Balanced,
|
||||
Final,
|
||||
}
|
||||
|
||||
impl RenderQuality {
|
||||
/// Human-readable label used by the UI shell and CLI.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
RenderQuality::Preview => "Preview",
|
||||
RenderQuality::Balanced => "Balanced",
|
||||
RenderQuality::Final => "Final",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn preset(self) -> RenderQualityPreset {
|
||||
match self {
|
||||
RenderQuality::Preview => RenderQualityPreset::Preview,
|
||||
RenderQuality::Balanced => RenderQualityPreset::Balanced,
|
||||
RenderQuality::Final => RenderQualityPreset::Final,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A pure, derived summary of a parsed script, suitable for display in the UI.
|
||||
///
|
||||
/// All counts are zero and `error` carries the parser message when the script
|
||||
@@ -194,10 +222,25 @@ impl AppData {
|
||||
river_level,
|
||||
lake_level,
|
||||
drainage,
|
||||
lake_center_x,
|
||||
lake_center_z,
|
||||
lake_radius,
|
||||
river_center_x,
|
||||
river_width,
|
||||
river_bend,
|
||||
} => {
|
||||
self.scene.hydrology.river_level = river_level;
|
||||
self.scene.hydrology.lake_level = lake_level;
|
||||
self.scene.hydrology.drainage = drainage.max(0.0);
|
||||
self.scene.hydrology.lake_center_x = lake_center_x.clamp(0.0, 1.0);
|
||||
self.scene.hydrology.lake_center_z = lake_center_z.clamp(0.0, 1.0);
|
||||
self.scene.hydrology.lake_radius = lake_radius.max(0.0);
|
||||
self.scene.hydrology.river_center_x = river_center_x.clamp(0.0, 1.0);
|
||||
self.scene.hydrology.river_width = river_width.max(0.0);
|
||||
self.scene.hydrology.river_bend = river_bend.clamp(0.0, 0.5);
|
||||
}
|
||||
AppAction::ResetHydrology => {
|
||||
self.scene.hydrology = crate::scene::Hydrology::default();
|
||||
}
|
||||
AppAction::SetPalette(palette) => self.scene.palette = palette,
|
||||
AppAction::SetPreviewSize { width, height } => {
|
||||
@@ -280,8 +323,8 @@ impl AppData {
|
||||
import_label: "Import terrain".to_string(),
|
||||
script_label: "Scripts / paths".to_string(),
|
||||
path_label: "Path tools".to_string(),
|
||||
scene_controls_label: "Scene / camera / palette".to_string(),
|
||||
palette_label: "Palette / color map".to_string(),
|
||||
scene_controls_label: "Scene / camera / color map".to_string(),
|
||||
palette_label: "Color map".to_string(),
|
||||
hydrology_label: "Hydrology".to_string(),
|
||||
legacy_dialogs_label: "Legacy dialogs".to_string(),
|
||||
scene_file_path: self.loaded_scene_path.clone(),
|
||||
@@ -373,12 +416,31 @@ mod tests {
|
||||
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.palette.water_level, 2.5);
|
||||
assert_eq!(app.scene.palette.tree_line, 5.5);
|
||||
assert_eq!(app.scene.palette.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() {
|
||||
fn reducer_syncs_palette_thresholds_back_into_scene_state() {
|
||||
let mut app = AppData::default();
|
||||
let mut palette = app.scene.palette;
|
||||
palette.water_level = 0.25;
|
||||
palette.tree_line = 2.75;
|
||||
palette.snow_line = 6.5;
|
||||
palette.water = [1, 2, 3];
|
||||
|
||||
app.apply(AppAction::SetPalette(palette));
|
||||
|
||||
assert_eq!(app.scene.water_level, 0.25);
|
||||
assert_eq!(app.scene.tree_line, 2.75);
|
||||
assert_eq!(app.scene.snow_line, 6.5);
|
||||
assert_eq!(app.scene.palette, palette);
|
||||
assert_eq!(app.scene.palette.water, [1, 2, 3]);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -426,6 +488,7 @@ mod tests {
|
||||
assert_eq!(shell.import_label, "Import terrain");
|
||||
assert_eq!(shell.script_label, "Scripts / paths");
|
||||
assert_eq!(shell.path_label, "Path tools");
|
||||
assert_eq!(shell.palette_label, "Color map");
|
||||
assert!(shell.scene_file_path.is_some());
|
||||
assert!(shell.import_path.is_some());
|
||||
assert!(shell.path_target.is_none());
|
||||
|
||||
@@ -195,4 +195,13 @@ mod tests {
|
||||
);
|
||||
assert_eq!(scene_color(&scene, scene.snow_line + 1.0), SNOW_COLOR);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scene_color_uses_palette_band_colors_for_the_default_scene() {
|
||||
let scene = Scene::default();
|
||||
assert_eq!(scene.palette.water, WATER_COLOR);
|
||||
assert_eq!(scene.palette.lowland, LOWLAND_COLOR);
|
||||
assert_eq!(scene.palette.highland, HIGHLAND_COLOR);
|
||||
assert_eq!(scene.palette.snow, SNOW_COLOR);
|
||||
}
|
||||
}
|
||||
|
||||
+67
-1
@@ -70,6 +70,56 @@ pub fn render_top_down(grid: &HeightGrid, scene: &Scene) -> RgbImage {
|
||||
img
|
||||
}
|
||||
|
||||
fn blur_pass(image: &RgbImage) -> RgbImage {
|
||||
let mut blurred = RgbImage::new(image.width(), image.height());
|
||||
for y in 0..image.height() {
|
||||
for x in 0..image.width() {
|
||||
let mut accum = [0u32; 3];
|
||||
let mut count = 0u32;
|
||||
for dy in -1i32..=1 {
|
||||
for dx in -1i32..=1 {
|
||||
let nx = x as i32 + dx;
|
||||
let ny = y as i32 + dy;
|
||||
if nx < 0 || ny < 0 {
|
||||
continue;
|
||||
}
|
||||
let (nx, ny) = (nx as u32, ny as u32);
|
||||
if nx >= image.width() || ny >= image.height() {
|
||||
continue;
|
||||
}
|
||||
let px = image.get_pixel(nx, ny).0;
|
||||
accum[0] += u32::from(px[0]);
|
||||
accum[1] += u32::from(px[1]);
|
||||
accum[2] += u32::from(px[2]);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
let avg = [
|
||||
(accum[0] / count.max(1)) as u8,
|
||||
(accum[1] / count.max(1)) as u8,
|
||||
(accum[2] / count.max(1)) as u8,
|
||||
];
|
||||
blurred.put_pixel(x, y, Rgb(avg));
|
||||
}
|
||||
}
|
||||
blurred
|
||||
}
|
||||
|
||||
fn apply_top_down_quality(mut image: RgbImage, quality: RenderQualityPreset) -> RgbImage {
|
||||
for _ in 0..quality.profile().top_down_blur_passes {
|
||||
image = blur_pass(&image);
|
||||
}
|
||||
image
|
||||
}
|
||||
|
||||
pub fn render_top_down_with_quality(
|
||||
grid: &HeightGrid,
|
||||
scene: &Scene,
|
||||
quality: RenderQualityPreset,
|
||||
) -> RgbImage {
|
||||
apply_top_down_quality(render_top_down(grid, scene), quality)
|
||||
}
|
||||
|
||||
pub fn render_top_down_to_path(
|
||||
grid: &HeightGrid,
|
||||
scene: &Scene,
|
||||
@@ -227,6 +277,22 @@ pub fn demo_camera_for(grid: &HeightGrid) -> Camera {
|
||||
/// Coordinate convention: world X and Z map onto the grid's `(x, y)` indices,
|
||||
/// world Y is elevation, up = +Y.
|
||||
pub fn render_perspective(grid: &HeightGrid, scene: &Scene, width: u32, height: u32) -> RgbImage {
|
||||
render_perspective_with_quality(
|
||||
grid,
|
||||
scene,
|
||||
width,
|
||||
height,
|
||||
RenderQualityPreset::Preview,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn render_perspective_with_quality(
|
||||
grid: &HeightGrid,
|
||||
scene: &Scene,
|
||||
width: u32,
|
||||
height: u32,
|
||||
quality: RenderQualityPreset,
|
||||
) -> RgbImage {
|
||||
let mut img = RgbImage::new(width.max(1), height.max(1));
|
||||
|
||||
let cam = &scene.camera;
|
||||
@@ -245,7 +311,7 @@ pub fn render_perspective(grid: &HeightGrid, scene: &Scene, width: u32, height:
|
||||
let grid_w = grid.width() as f32;
|
||||
let grid_h = grid.height() as f32;
|
||||
let max_dist = cam.far_range.max(cam.near_range + 0.1);
|
||||
let step = 0.5_f32;
|
||||
let step = quality.profile().perspective_step;
|
||||
|
||||
let haze_strength = scene.haze.clamp(0.0, 1.0);
|
||||
|
||||
|
||||
@@ -103,12 +103,58 @@ pub struct Hydrology {
|
||||
pub river_level: f32,
|
||||
pub lake_level: f32,
|
||||
pub drainage: f32,
|
||||
/// Normalized center point for the generated lake overlay.
|
||||
pub lake_center_x: f32,
|
||||
/// Normalized center point for the generated lake overlay.
|
||||
pub lake_center_z: f32,
|
||||
/// Normalized radius for the generated lake overlay.
|
||||
pub lake_radius: f32,
|
||||
/// Normalized centerline for the generated river overlay.
|
||||
pub river_center_x: f32,
|
||||
/// Normalized half-width for the generated river overlay.
|
||||
pub river_width: f32,
|
||||
/// Horizontal meander amplitude for the generated river overlay.
|
||||
pub river_bend: f32,
|
||||
}
|
||||
|
||||
fn smoothstep(edge0: f32, edge1: f32, value: f32) -> f32 {
|
||||
if edge0 >= edge1 {
|
||||
return if value < edge0 { 0.0 } else { 1.0 };
|
||||
}
|
||||
let t = ((value - edge0) / (edge1 - edge0)).clamp(0.0, 1.0);
|
||||
t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
|
||||
impl Hydrology {
|
||||
pub fn effective_water_level(self, base_water_level: f32) -> f32 {
|
||||
(base_water_level.max(self.river_level).max(self.lake_level) - self.drainage).max(0.0)
|
||||
}
|
||||
|
||||
/// Coverage of the generated lake primitive at normalized coordinates.
|
||||
pub fn lake_coverage(self, x: f32, z: f32) -> f32 {
|
||||
let radius = self.lake_radius.max(0.0);
|
||||
if radius <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let dx = x - self.lake_center_x;
|
||||
let dz = z - self.lake_center_z;
|
||||
let dist = (dx * dx + dz * dz).sqrt();
|
||||
let falloff = (radius * 0.2).max(0.01);
|
||||
1.0 - smoothstep(radius - falloff, radius + falloff, dist)
|
||||
}
|
||||
|
||||
/// Coverage of the generated river primitive at normalized coordinates.
|
||||
pub fn river_coverage(self, x: f32, z: f32) -> f32 {
|
||||
let half_width = self.river_width.max(0.0);
|
||||
if half_width <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let meander = self.river_bend * (z * std::f32::consts::TAU * 1.25).sin();
|
||||
let center_x = (self.river_center_x + meander).clamp(0.0, 1.0);
|
||||
let dist = (x - center_x).abs();
|
||||
let falloff = (half_width * 0.35).max(0.01);
|
||||
1.0 - smoothstep(half_width - falloff, half_width + falloff, dist)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Hydrology {
|
||||
@@ -117,6 +163,12 @@ impl Default for Hydrology {
|
||||
river_level: 0.5,
|
||||
lake_level: 0.75,
|
||||
drainage: 0.0,
|
||||
lake_center_x: 0.38,
|
||||
lake_center_z: 0.62,
|
||||
lake_radius: 0.16,
|
||||
river_center_x: 0.58,
|
||||
river_width: 0.08,
|
||||
river_bend: 0.08,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +152,9 @@ mod tests {
|
||||
drainage: 0.2,
|
||||
},
|
||||
palette: Palette {
|
||||
water_level: 0.5,
|
||||
tree_line: 3.0,
|
||||
snow_line: 8.0,
|
||||
water: [12, 34, 56],
|
||||
lowland: [78, 90, 12],
|
||||
highland: [123, 111, 99],
|
||||
|
||||
@@ -141,6 +141,11 @@ pub fn run_script(script: &Script, base_dir: &Path) -> Result<ExecReport, Script
|
||||
scene.water_level = thresholds.water;
|
||||
scene.tree_line = thresholds.tree;
|
||||
scene.snow_line = thresholds.snow;
|
||||
scene.palette.sync_thresholds_from_scene(
|
||||
thresholds.water,
|
||||
thresholds.tree,
|
||||
thresholds.snow,
|
||||
);
|
||||
}
|
||||
Command::ImportHeightmap { path } => {
|
||||
grid = Some(load_heightmap(&resolve(base_dir, path))?);
|
||||
|
||||
Reference in New Issue
Block a user