WASM plugins, scripting, i18n, GUI pan/zoom, cargo-dist CI/CD

- WASM plugin host (wasmtime 46, 17 host fns) + example noisemap plugin
- Rhai scripting engine (12 API fns, `terrafier run` cmd)
- i18n: en/ru translations, auto-detect, t! macro
- GUI: pan (click-drag) + zoom (scroll wheel) with viewport transform
- cargo-dist 0.31: MSI/pkg/shell installers, both binaries
- CI: .github/workflows/ci.yml + release.yml
- Security: wasmtime 30→46, bincode 1.3→2.0, quick-xml CVE docs
- Code review: SAFETY comments, unwrap→match cleanup
- Fixes: clippy approx_constant, needless_borrows, rustfmt
This commit is contained in:
loki5512344 2026-07-04 11:14:47 +02:00
parent bdd4564240
commit 18613260a8
100 changed files with 4262 additions and 585 deletions

View file

@ -3,8 +3,16 @@ name = "terrafier-gui"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
authors.workspace = true
description = "Native GUI for Terrafier — interactive world painting with GPU rendering"
[package.metadata.wix]
upgrade-guid = "A7365100-6923-4DAC-B4A5-2BD817EB802B"
path-guid = "097E8AD4-180B-4B52-8BFE-04AA942D03A6"
license = false
eula = false
[dependencies]
terrafier-core = { path = "../core" }
anyhow.workspace = true

View file

@ -74,6 +74,8 @@ pub struct TerrafierApp {
pub renderer: Option<GpuRenderer>,
pub show_gpu_render: bool,
pub gpu_status: GpuStatus,
pub view_offset: egui::Vec2,
pub view_zoom: f32,
pub cpu_texture_handle: Option<egui::TextureHandle>,
pub layer_visible: [bool; 6],
}
@ -96,7 +98,10 @@ impl TerrafierApp {
gpu_renderer.register_texture(&mut renderer_lock);
(Some(gpu_renderer), GpuStatus::Enabled)
} else {
(None, GpuStatus::FallbackCpu("GPU init failed — no wgpu adapter found"))
(
None,
GpuStatus::FallbackCpu("GPU init failed — no wgpu adapter found"),
)
};
Self {
@ -124,9 +129,10 @@ impl TerrafierApp {
renderer,
show_gpu_render: true,
gpu_status,
view_offset: egui::Vec2::ZERO,
view_zoom: 1.0,
cpu_texture_handle: None,
layer_visible: [false; 6],
}
}
}

View file

@ -14,16 +14,20 @@ impl TerrafierApp {
pub fn undo(&mut self) {
if let Some(op) = self.undo_stack.pop_back() {
if let Some(ref mut world) = self.world {
let dim = world.overworld_mut().expect("world has no overworld");
let inv = op.inverse();
if let Err(e) = inv.apply(dim) {
self.status_message = format!("Undo error: {:?}", e);
self.undo_stack.push_back(op);
if let Some(dim) = world.overworld_mut() {
let inv = op.inverse();
if let Err(e) = inv.apply(dim) {
self.status_message = format!("Undo error: {:?}", e);
self.undo_stack.push_back(op);
return;
}
self.redo_stack.push(op);
self.status_message = "Undo".to_string();
return;
}
self.redo_stack.push(op);
self.status_message = "Undo".to_string();
}
self.undo_stack.push_back(op);
self.status_message = "Undo error: world has no overworld".to_string();
} else {
self.status_message = "Nothing to undo".to_string();
}
@ -32,15 +36,19 @@ impl TerrafierApp {
pub fn redo(&mut self) {
if let Some(op) = self.redo_stack.pop() {
if let Some(ref mut world) = self.world {
let dim = world.overworld_mut().expect("world has no overworld");
if let Err(e) = op.apply(dim) {
self.status_message = format!("Redo error: {:?}", e);
self.redo_stack.push(op);
if let Some(dim) = world.overworld_mut() {
if let Err(e) = op.apply(dim) {
self.status_message = format!("Redo error: {:?}", e);
self.redo_stack.push(op);
return;
}
self.undo_stack.push_back(op);
self.status_message = "Redo".to_string();
return;
}
self.undo_stack.push_back(op);
self.status_message = "Redo".to_string();
}
self.redo_stack.push(op);
self.status_message = "Redo error: world has no overworld".to_string();
} else {
self.status_message = "Nothing to redo".to_string();
}

View file

@ -71,8 +71,8 @@ impl eframe::App for TerrafierApp {
&& let Some(tile) = world.dimensions[0].tiles.values().next()
{
renderer.render(
&tile.heightmap.as_slice().try_into().expect("heightmap size"),
&tile.terrain.as_slice().try_into().expect("terrain size"),
&tile.heightmap.as_slice().try_into().unwrap_or_else(|_| [0; 16384]),
&tile.terrain.as_slice().try_into().unwrap_or_else(|_| [0; 16384]),
self.show_heightmap,
);

View file

@ -47,57 +47,103 @@ impl GpuRenderer {
needed_storage,
);
}
let bind_group_layout =
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("terrain_bind_group_layout"),
entries: &[
wgpu::BindGroupLayoutEntry { binding: 0, visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None }, count: None },
wgpu::BindGroupLayoutEntry { binding: 1, visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only: true }, has_dynamic_offset: false, min_binding_size: None }, count: None },
wgpu::BindGroupLayoutEntry { binding: 2, visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only: true }, has_dynamic_offset: false, min_binding_size: None }, count: None },
wgpu::BindGroupLayoutEntry { binding: 3, visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only: false }, has_dynamic_offset: false, min_binding_size: None }, count: None },
],
});
let shader_module =
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("terrain_shader"),
source: wgpu::ShaderSource::Wgsl(shader_source.into()),
});
let pipeline_layout =
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("terrain_pipeline_layout"),
bind_group_layouts: &[&bind_group_layout],
push_constant_ranges: &[],
});
let compute_pipeline =
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("terrain_compute_pipeline"),
layout: Some(&pipeline_layout),
module: &shader_module,
entry_point: Some("main"),
compilation_options: wgpu::PipelineCompilationOptions::default(),
cache: None,
});
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("terrain_bind_group_layout"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 3,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: false },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
});
let shader_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("terrain_shader"),
source: wgpu::ShaderSource::Wgsl(shader_source.into()),
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("terrain_pipeline_layout"),
bind_group_layouts: &[&bind_group_layout],
push_constant_ranges: &[],
});
let compute_pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("terrain_compute_pipeline"),
layout: Some(&pipeline_layout),
module: &shader_module,
entry_point: Some("main"),
compilation_options: wgpu::PipelineCompilationOptions::default(),
cache: None,
});
let heightmap_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("heightmap_buffer"), size: 8192 * 4,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false,
label: Some("heightmap_buffer"),
size: 8192 * 4,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let terrain_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("terrain_buffer"), size: 4096 * 4,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false,
label: Some("terrain_buffer"),
size: 4096 * 4,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("uniform_buffer"), size: std::mem::size_of::<Uniforms>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false,
label: Some("uniform_buffer"),
size: std::mem::size_of::<Uniforms>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let output_size = (width * height * 4) as u64;
let output_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("output_buffer"), size: output_size,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, mapped_at_creation: false,
label: Some("output_buffer"),
size: output_size,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let output_texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("output_texture"),
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2,
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8UnormSrgb,
view_formats: &[wgpu::TextureFormat::Rgba8UnormSrgb],
usage: wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING,
@ -107,16 +153,54 @@ impl GpuRenderer {
label: Some("terrain_bind_group"),
layout: &bind_group_layout,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { buffer: &uniform_buffer, offset: 0, size: None }) },
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { buffer: &heightmap_buffer, offset: 0, size: None }) },
wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { buffer: &terrain_buffer, offset: 0, size: None }) },
wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { buffer: &output_buffer, offset: 0, size: None }) },
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &uniform_buffer,
offset: 0,
size: None,
}),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &heightmap_buffer,
offset: 0,
size: None,
}),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &terrain_buffer,
offset: 0,
size: None,
}),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &output_buffer,
offset: 0,
size: None,
}),
},
],
});
Self {
device, queue, compute_pipeline, heightmap_buffer, terrain_buffer,
uniform_buffer, output_buffer, output_texture, output_texture_view, bind_group,
texture_id: None, width, height,
device,
queue,
compute_pipeline,
heightmap_buffer,
terrain_buffer,
uniform_buffer,
output_buffer,
output_texture,
output_texture_view,
bind_group,
texture_id: None,
width,
height,
last_heightmap: vec![0i16; 16384],
last_terrain: vec![0u8; 16384],
last_show_heightmap: false,
@ -125,16 +209,25 @@ impl GpuRenderer {
}
pub(crate) fn pack_heightmap(heightmap: &[i16; 16384]) -> Vec<u32> {
heightmap.chunks(2).map(|chunk| {
let low = chunk[0] as u32 & 0xFFFF;
let high = chunk[1] as u32 & 0xFFFF;
low | (high << 16)
}).collect()
heightmap
.chunks(2)
.map(|chunk| {
let low = chunk[0] as u32 & 0xFFFF;
let high = chunk[1] as u32 & 0xFFFF;
low | (high << 16)
})
.collect()
}
pub(crate) fn pack_terrain(terrain: &[u8; 16384]) -> Vec<u32> {
terrain.chunks(4).map(|chunk| {
chunk[0] as u32 | (chunk[1] as u32) << 8 | (chunk[2] as u32) << 16 | (chunk[3] as u32) << 24
}).collect()
terrain
.chunks(4)
.map(|chunk| {
chunk[0] as u32
| (chunk[1] as u32) << 8
| (chunk[2] as u32) << 16
| (chunk[3] as u32) << 24
})
.collect()
}
}

View file

@ -2,21 +2,15 @@ use crate::renderer::GpuRenderer;
use crate::renderer::Uniforms;
fn as_u8_slice<T>(data: &[T]) -> &[u8] {
unsafe {
std::slice::from_raw_parts(
data.as_ptr() as *const u8,
std::mem::size_of_val(data),
)
}
// SAFETY: Reinterprets `&[T]` as `&[u8]` for GPU buffer upload.
// T is a primitive type with no padding, and the byte layout is stable.
unsafe { std::slice::from_raw_parts(data.as_ptr() as *const u8, std::mem::size_of_val(data)) }
}
fn as_u8_value<T>(data: &T) -> &[u8] {
unsafe {
std::slice::from_raw_parts(
data as *const T as *const u8,
std::mem::size_of::<T>(),
)
}
// SAFETY: Reinterprets a single `&T` as `&[u8]` for GPU buffer upload.
// T is a primitive type with no padding, and the byte layout is stable.
unsafe { std::slice::from_raw_parts(data as *const T as *const u8, std::mem::size_of::<T>()) }
}
impl GpuRenderer {
@ -39,8 +33,8 @@ impl GpuRenderer {
self.last_show_heightmap = show_heightmap;
self.needs_upload = false;
let min_height = *heightmap.iter().min().unwrap();
let max_height = *heightmap.iter().max().unwrap();
let min_height = *heightmap.iter().min().unwrap_or(&0);
let max_height = *heightmap.iter().max().unwrap_or(&255);
let packed_heightmap = Self::pack_heightmap(heightmap);
self.queue

View file

@ -1,21 +1,45 @@
use crate::app::{
LAYER_BIOME_IDX, LAYER_CAVES_IDX, LAYER_FROST_IDX, LAYER_RESOURCES_IDX, LAYER_RIVER_IDX,
LAYER_TREES_IDX,
};
use egui::{Color32, ColorImage, TextureOptions, Vec2};
use terrafier_core::model::tile::{TILE_SIZE, LayerBuffer};
use terrafier_core::model::layers::{LAYER_CAVES, LAYER_RIVER, LAYER_FROST, LAYER_TREES, LAYER_BIOME, LAYER_RESOURCES};
use crate::app::{LAYER_CAVES_IDX, LAYER_RIVER_IDX, LAYER_FROST_IDX, LAYER_TREES_IDX, LAYER_BIOME_IDX, LAYER_RESOURCES_IDX};
use terrafier_core::model::layers::{
LAYER_BIOME, LAYER_CAVES, LAYER_FROST, LAYER_RESOURCES, LAYER_RIVER, LAYER_TREES,
};
use terrafier_core::model::tile::{LayerBuffer, TILE_SIZE};
const LAYER_IDS: [u32; 6] = [LAYER_CAVES, LAYER_RIVER, LAYER_FROST, LAYER_TREES, LAYER_BIOME, LAYER_RESOURCES];
const LAYER_IDS: [u32; 6] = [
LAYER_CAVES,
LAYER_RIVER,
LAYER_FROST,
LAYER_TREES,
LAYER_BIOME,
LAYER_RESOURCES,
];
fn layer_value(tile: &terrafier_core::model::tile::Tile, idx: usize, layer_idx: usize) -> Option<f32> {
fn layer_value(
tile: &terrafier_core::model::tile::Tile,
idx: usize,
layer_idx: usize,
) -> Option<f32> {
let layer_id = LAYER_IDS[layer_idx];
let buf = tile.layer_data.get(&layer_id)?;
let val = match buf {
LayerBuffer::Bit(data) => {
let word = data.get(idx / 64)?;
Some(if (word >> (idx % 64)) & 1 != 0 { 1.0 } else { 0.0 })
Some(if (word >> (idx % 64)) & 1 != 0 {
1.0
} else {
0.0
})
}
LayerBuffer::Nibble(data) => {
let byte = *data.get(idx / 2)?;
let nib = if idx % 2 == 0 { byte & 0x0F } else { (byte >> 4) & 0x0F };
let nib = if idx % 2 == 0 {
byte & 0x0F
} else {
(byte >> 4) & 0x0F
};
Some(nib as f32 / 15.0)
}
LayerBuffer::Byte(data) => {
@ -65,7 +89,9 @@ const TERRAIN_COLORS: [Color32; 7] = [
];
pub fn render(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) {
let Some(ref world) = app.world else { return; };
let Some(ref world) = app.world else {
return;
};
let dim = &world.dimensions[0];
let mut min_tx = i32::MAX;
@ -79,20 +105,22 @@ pub fn render(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) {
max_tz = max_tz.max(tz);
}
let display_size = 64u32;
let grid_w = ((max_tx - min_tx + 1) * display_size as i32) as usize;
let grid_h = ((max_tz - min_tz + 1) * display_size as i32) as usize;
let base_size = 64u32;
let zoom = app.view_zoom;
let grid_w = ((max_tx - min_tx + 1) * base_size as i32) as usize;
let grid_h = ((max_tz - min_tz + 1) * base_size as i32) as usize;
let mut img_data = vec![0u8; grid_w * grid_h * 4];
for (&(tx, tz), tile) in &dim.tiles {
let px = ((tx - min_tx) * display_size as i32) as usize;
let pz = ((tz - min_tz) * display_size as i32) as usize;
let px = ((tx - min_tx) * base_size as i32) as usize;
let pz = ((tz - min_tz) * base_size as i32) as usize;
for ly in 0..display_size as usize {
for lx in 0..display_size as usize {
let sx = (lx * TILE_SIZE / display_size as usize).min(TILE_SIZE - 1);
let sz = (ly * TILE_SIZE / display_size as usize).min(TILE_SIZE - 1);
for ly in 0..base_size as usize {
for lx in 0..base_size as usize {
let sx = (lx * TILE_SIZE / base_size as usize).min(TILE_SIZE - 1);
let sz = (ly * TILE_SIZE / base_size as usize).min(TILE_SIZE - 1);
let idx = sz * TILE_SIZE + sx;
let terrain_type = tile.terrain[idx] as usize;
@ -127,7 +155,11 @@ pub fn render(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) {
if app.layer_visible[li] {
if let Some(val) = layer_value(tile, idx, li) {
if let Some(over) = layer_overlay(li, val) {
let alpha = if val > 0.0 { (val * 0.6 + 0.2).min(0.8) } else { 0.0 };
let alpha = if val > 0.0 {
(val * 0.6 + 0.2).min(0.8)
} else {
0.0
};
(r, g, b) = blend_overlay((r, g, b), over, alpha);
}
}
@ -156,13 +188,35 @@ pub fn render(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) {
id
};
let desired_size = Vec2::new(grid_w as f32, grid_h as f32);
let (response, painter) = ui.allocate_painter(desired_size, egui::Sense::click());
// Viewport: allocate a large area for the world with scroll
let viewport_size = Vec2::new(grid_w as f32 * zoom, grid_h as f32 * zoom);
let (response, painter) = ui.allocate_painter(
viewport_size,
egui::Sense::click_and_drag(),
);
// Handle scroll-wheel zoom
let scroll_delta = ui.input(|i| i.raw_scroll_delta);
if scroll_delta.y != 0.0 {
app.view_zoom = (app.view_zoom * (1.0 + scroll_delta.y * 0.002)).clamp(0.1, 10.0);
}
// Handle drag-to-pan
if response.dragged() {
app.view_offset += response.drag_delta();
}
// Recalculate scaled size after potential zoom change
let scaled_size = base_size as f32 * app.view_zoom;
let offset = app.view_offset;
let rect = response.rect;
painter.image(
texture_id,
rect,
egui::Rect::from_min_max(
egui::pos2(rect.min.x + offset.x, rect.min.y + offset.y),
egui::pos2(rect.min.x + offset.x + grid_w as f32 * app.view_zoom, rect.min.y + offset.y + grid_h as f32 * app.view_zoom),
),
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
Color32::WHITE,
);
@ -173,10 +227,10 @@ pub fn render(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) {
&& sel_tz >= min_tz
&& sel_tz <= max_tz
{
let bx = rect.min.x + (sel_tx - min_tx) as f32 * display_size as f32;
let bz = rect.min.y + (sel_tz - min_tz) as f32 * display_size as f32;
let bx = rect.min.x + offset.x + (sel_tx - min_tx) as f32 * scaled_size;
let bz = rect.min.y + offset.y + (sel_tz - min_tz) as f32 * scaled_size;
let border_rect =
egui::Rect::from_min_size(egui::pos2(bx, bz), Vec2::splat(display_size as f32));
egui::Rect::from_min_size(egui::pos2(bx, bz), Vec2::splat(scaled_size));
painter.rect_stroke(
border_rect,
0.0,
@ -188,13 +242,13 @@ pub fn render(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) {
if let (Some((sel_tx, sel_tz)), Some(bx), Some(bz)) =
(app.selected_tile, app.brush_local_x, app.brush_local_z)
{
let tile_x_in_pixels = (sel_tx - min_tx) as f32 * display_size as f32;
let tile_z_in_pixels = (sel_tz - min_tz) as f32 * display_size as f32;
let tile_x_in_pixels = (sel_tx - min_tx) as f32 * scaled_size;
let tile_z_in_pixels = (sel_tz - min_tz) as f32 * scaled_size;
let brush_x =
rect.min.x + tile_x_in_pixels + (bx as f32 * display_size as f32 / TILE_SIZE as f32);
rect.min.x + offset.x + tile_x_in_pixels + (bx as f32 * scaled_size / TILE_SIZE as f32);
let brush_z =
rect.min.y + tile_z_in_pixels + (bz as f32 * display_size as f32 / TILE_SIZE as f32);
let brush_radius_px = app.brush_radius as f32 * display_size as f32 / TILE_SIZE as f32;
rect.min.y + offset.y + tile_z_in_pixels + (bz as f32 * scaled_size / TILE_SIZE as f32);
let brush_radius_px = app.brush_radius as f32 * scaled_size / TILE_SIZE as f32;
painter.circle_stroke(
egui::pos2(brush_x, brush_z),
brush_radius_px.max(2.0),
@ -202,20 +256,21 @@ pub fn render(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) {
);
}
// Click-to-select with inverse transform
if response.clicked()
&& let Some(pos) = response.interact_pointer_pos()
{
let lx = pos.x - rect.min.x;
let lz = pos.y - rect.min.y;
let lx = pos.x - rect.min.x - offset.x;
let lz = pos.y - rect.min.y - offset.y;
if lx >= 0.0 && lz >= 0.0 {
let tx = (lx / display_size as f32).floor() as i32 + min_tx;
let tz = (lz / display_size as f32).floor() as i32 + min_tz;
let tx = (lx / scaled_size).floor() as i32 + min_tx;
let tz = (lz / scaled_size).floor() as i32 + min_tz;
if dim.tiles.contains_key(&(tx, tz)) {
app.selected_tile = Some((tx, tz));
let local_x =
((lx as u32 % display_size) * TILE_SIZE as u32 / display_size).min(127);
((lx as u32 % scaled_size as u32) * TILE_SIZE as u32 / scaled_size as u32).min(127);
let local_z =
((lz as u32 % display_size) * TILE_SIZE as u32 / display_size).min(127);
((lz as u32 % scaled_size as u32) * TILE_SIZE as u32 / scaled_size as u32).min(127);
app.brush_local_x = Some(local_x);
app.brush_local_z = Some(local_z);
app.status_message = format!(

View file

@ -11,37 +11,64 @@ pub fn try_render(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) -> bool
&& let Some(texture_id) = renderer.texture_id
{
let desired_size = Vec2::new(512.0, 512.0);
let (response, painter) = ui.allocate_painter(desired_size, egui::Sense::click());
let (response, painter) = ui.allocate_painter(
desired_size,
egui::Sense::click_and_drag(),
);
let rect = response.rect;
// Handle scroll-wheel zoom
let scroll_delta = ui.input(|i| i.raw_scroll_delta);
if scroll_delta.y != 0.0 {
app.view_zoom = (app.view_zoom * (1.0 + scroll_delta.y * 0.002)).clamp(0.1, 10.0);
}
// Handle drag-to-pan
if response.dragged() {
app.view_offset += response.drag_delta();
}
let zoom = app.view_zoom;
let offset = app.view_offset;
let base_display_size = rect.width() / 4.0;
let display_size = base_display_size * zoom;
// Find tile bounds for positioning
let mut min_tx = i32::MAX;
let mut max_tx = i32::MIN;
let mut min_tz = i32::MAX;
let mut max_tz = i32::MIN;
for &(tx, tz) in dim.tiles.keys() {
min_tx = min_tx.min(tx);
max_tx = max_tx.max(tx);
min_tz = min_tz.min(tz);
max_tz = max_tz.max(tz);
}
// Draw the GPU-rendered tile texture at the correct position
// GPU only renders the first tile - position it correctly
// Use a default position for the rendered tile
let first_tile_pos = dim.tiles.keys().next().copied().unwrap_or((0, 0));
let tex_origin_x = rect.min.x + offset.x + (first_tile_pos.0 - min_tx) as f32 * display_size;
let tex_origin_y = rect.min.y + offset.y + (first_tile_pos.1 - min_tz) as f32 * display_size;
let tex_rect = egui::Rect::from_min_size(
egui::pos2(tex_origin_x, tex_origin_y),
Vec2::splat(display_size),
);
painter.image(
texture_id,
rect,
tex_rect,
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
Color32::WHITE,
);
let display_size = rect.width() / 4.0;
if let Some((sel_tx, sel_tz)) = app.selected_tile {
let mut min_tx = i32::MAX;
let mut max_tx = i32::MIN;
let mut min_tz = i32::MAX;
let mut max_tz = i32::MIN;
for &(tx, tz) in dim.tiles.keys() {
min_tx = min_tx.min(tx);
max_tx = max_tx.max(tx);
min_tz = min_tz.min(tz);
max_tz = max_tz.max(tz);
}
let bx = rect.min.x;
let bz = rect.min.y;
let sel_bx = bx + (sel_tx - min_tx) as f32 * display_size;
let sel_bz = bz + (sel_tz - min_tz) as f32 * display_size;
let border_rect = egui::Rect::from_min_size(
egui::pos2(sel_bx, sel_bz),
Vec2::splat(display_size),
);
let bx = rect.min.x + offset.x + (sel_tx - min_tx) as f32 * display_size;
let bz = rect.min.y + offset.y + (sel_tz - min_tz) as f32 * display_size;
let border_rect =
egui::Rect::from_min_size(egui::pos2(bx, bz), Vec2::splat(display_size));
painter.rect_stroke(
border_rect,
0.0,
@ -53,18 +80,12 @@ pub fn try_render(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) -> bool
if let (Some((sel_tx, sel_tz)), Some(bx), Some(bz)) =
(app.selected_tile, app.brush_local_x, app.brush_local_z)
{
let mut min_tx = i32::MAX;
let mut min_tz = i32::MAX;
for &(tx, tz) in dim.tiles.keys() {
min_tx = min_tx.min(tx);
min_tz = min_tz.min(tz);
}
let tile_x_in_pixels = (sel_tx - min_tx) as f32 * display_size;
let tile_z_in_pixels = (sel_tz - min_tz) as f32 * display_size;
let brush_x = rect.min.x + tile_x_in_pixels
+ (bx as f32 * display_size / TILE_SIZE as f32);
let brush_z = rect.min.y + tile_z_in_pixels
+ (bz as f32 * display_size / TILE_SIZE as f32);
let brush_x =
rect.min.x + offset.x + tile_x_in_pixels + (bx as f32 * display_size / TILE_SIZE as f32);
let brush_z =
rect.min.y + offset.y + tile_z_in_pixels + (bz as f32 * display_size / TILE_SIZE as f32);
let brush_radius_px = app.brush_radius as f32 * display_size / TILE_SIZE as f32;
painter.circle_stroke(
egui::pos2(brush_x, brush_z),
@ -76,23 +97,19 @@ pub fn try_render(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) -> bool
if response.clicked()
&& let Some(pos) = response.interact_pointer_pos()
{
let lx = pos.x - rect.min.x;
let lz = pos.y - rect.min.y;
let lx = pos.x - rect.min.x - offset.x;
let lz = pos.y - rect.min.y - offset.y;
if lx >= 0.0 && lz >= 0.0 {
let mut min_tx = i32::MAX;
let mut min_tz = i32::MAX;
for &(tx, tz) in dim.tiles.keys() {
min_tx = min_tx.min(tx);
min_tz = min_tz.min(tz);
}
let tx = (lx / display_size).floor() as i32 + min_tx;
let tz = (lz / display_size).floor() as i32 + min_tz;
if dim.tiles.contains_key(&(tx, tz)) {
app.selected_tile = Some((tx, tz));
let local_x =
((lx as u32 % display_size as u32) * TILE_SIZE as u32 / display_size as u32).min(127);
let local_z =
((lz as u32 % display_size as u32) * TILE_SIZE as u32 / display_size as u32).min(127);
let local_x = ((lx as u32 % display_size as u32) * TILE_SIZE as u32
/ display_size as u32)
.min(127);
let local_z = ((lz as u32 % display_size as u32) * TILE_SIZE as u32
/ display_size as u32)
.min(127);
app.brush_local_x = Some(local_x);
app.brush_local_z = Some(local_z);
app.status_message = format!(

View file

@ -1,14 +1,14 @@
use std::sync::Arc;
use terrafier_core::Terrain;
use terrafier_core::model::types::SymmetricBrush;
use terrafier_core::model::tile::TILE_SIZE;
use terrafier_core::model::types::SymmetricBrush;
use terrafier_core::ops::operations::{
ErodeOperation, FillOperation, FlattenOperation, HeightOperation, MultiTileOperation,
Operation, PaintOperation, SmoothOperation,
};
use crate::app::{TerrafierApp, ToolMode, LAYER_NAMES};
use crate::app::{LAYER_NAMES, TerrafierApp, ToolMode};
pub fn show_tools_panel(ui: &mut egui::Ui, app: &mut TerrafierApp) {
ui.heading("Tools");
@ -83,7 +83,10 @@ fn apply_tool(app: &mut TerrafierApp) {
let Some(ref mut world) = app.world else {
return;
};
let dim = world.overworld_mut().expect("no overworld");
let Some(dim) = world.overworld_mut() else {
app.status_message = "World has no overworld".to_string();
return;
};
let center = app.brush_local_x.unwrap_or(64);
let center_z = app.brush_local_z.unwrap_or(64);

228
gui/wix/main.wxs Normal file
View file

@ -0,0 +1,228 @@
<?xml version='1.0' encoding='windows-1252'?>
<!--
Copyright (C) 2017 Christopher R. Field.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<!--
The "cargo wix" subcommand provides a variety of predefined variables available
for customization of this template. The values for each variable are set at
installer creation time. The following variables are available:
TargetTriple = The rustc target triple name.
TargetEnv = The rustc target environment. This is typically either
"msvc" or "gnu" depending on the toolchain downloaded and
installed.
TargetVendor = The rustc target vendor. This is typically "pc", but Rust
does support other vendors, like "uwp".
CargoTargetBinDir = The complete path to the directory containing the
binaries (exes) to include. The default would be
"target\release\". If an explicit rustc target triple is
used, i.e. cross-compiling, then the default path would
be "target\<CARGO_TARGET>\<CARGO_PROFILE>",
where "<CARGO_TARGET>" is replaced with the "CargoTarget"
variable value and "<CARGO_PROFILE>" is replaced with the
value from the "CargoProfile" variable. This can also
be overridden manually with the "target-bin-dir" flag.
CargoTargetDir = The path to the directory for the build artifacts, i.e.
"target".
CargoProfile = The cargo profile used to build the binaries
(usually "debug" or "release").
Version = The version for the installer. The default is the
"Major.Minor.Fix" semantic versioning number of the Rust
package.
-->
<!--
Please do not remove these pre-processor If-Else blocks. These are used with
the `cargo wix` subcommand to automatically determine the installation
destination for 32-bit versus 64-bit installers. Removal of these lines will
cause installation errors.
-->
<?if $(sys.BUILDARCH) = x64 or $(sys.BUILDARCH) = arm64 ?>
<?define PlatformProgramFilesFolder = "ProgramFiles64Folder" ?>
<?else ?>
<?define PlatformProgramFilesFolder = "ProgramFilesFolder" ?>
<?endif ?>
<Wix xmlns='http://schemas.microsoft.com/wix/2006/wi'>
<Product
Id='*'
Name='terrafier-gui'
UpgradeCode='A7365100-6923-4DAC-B4A5-2BD817EB802B'
Manufacturer='Loki'
Language='1033'
Codepage='1252'
Version='$(var.Version)'>
<Package Id='*'
Keywords='Installer'
Description='Native GUI for Terrafier — interactive world painting with GPU rendering'
Manufacturer='Loki'
InstallerVersion='450'
Languages='1033'
Compressed='yes'
InstallScope='perMachine'
SummaryCodepage='1252'
/>
<MajorUpgrade
Schedule='afterInstallInitialize'
DowngradeErrorMessage='A newer version of [ProductName] is already installed. Setup will now exit.'/>
<Media Id='1' Cabinet='media1.cab' EmbedCab='yes' DiskPrompt='CD-ROM #1'/>
<Property Id='DiskPrompt' Value='terrafier-gui Installation'/>
<Directory Id='TARGETDIR' Name='SourceDir'>
<Directory Id='$(var.PlatformProgramFilesFolder)' Name='PFiles'>
<Directory Id='APPLICATIONFOLDER' Name='terrafier-gui'>
<!--
Enabling the license sidecar file in the installer is a four step process:
1. Uncomment the `Component` tag and its contents.
2. Change the value for the `Source` attribute in the `File` tag to a path
to the file that should be included as the license sidecar file. The path
can, and probably should be, relative to this file.
3. Change the value for the `Name` attribute in the `File` tag to the
desired name for the file when it is installed alongside the `bin` folder
in the installation directory. This can be omitted if the desired name is
the same as the file name.
4. Uncomment the `ComponentRef` tag with the Id attribute value of "License"
further down in this file.
-->
<!--
<Component Id='License' Guid='*'>
<File Id='LicenseFile' Name='ChangeMe' DiskId='1' Source='C:\Path\To\File' KeyPath='yes'/>
</Component>
-->
<Directory Id='Bin' Name='bin'>
<Component Id='Path' Guid='097E8AD4-180B-4B52-8BFE-04AA942D03A6' KeyPath='yes'>
<Environment
Id='PATH'
Name='PATH'
Value='[Bin]'
Permanent='no'
Part='last'
Action='set'
System='yes'/>
</Component>
<Component Id='binary0' Guid='*'>
<File
Id='exe0'
Name='terrafier-gui.exe'
DiskId='1'
Source='$(var.CargoTargetBinDir)\terrafier-gui.exe'
KeyPath='yes'/>
</Component>
</Directory>
</Directory>
</Directory>
</Directory>
<Feature
Id='Binaries'
Title='Application'
Description='Installs all binaries and the license.'
Level='1'
ConfigurableDirectory='APPLICATIONFOLDER'
AllowAdvertise='no'
Display='expand'
Absent='disallow'>
<!--
Uncomment the following `ComponentRef` tag to add the license
sidecar file to the installer.
-->
<!--<ComponentRef Id='License'/>-->
<ComponentRef Id='binary0'/>
<Feature
Id='Environment'
Title='PATH Environment Variable'
Description='Add the install location of the [ProductName] executable to the PATH system environment variable. This allows the [ProductName] executable to be called from any location.'
Level='1'
Absent='allow'>
<ComponentRef Id='Path'/>
</Feature>
</Feature>
<SetProperty Id='ARPINSTALLLOCATION' Value='[APPLICATIONFOLDER]' After='CostFinalize'/>
<!--
Uncomment the following `Icon` and `Property` tags to change the product icon.
The product icon is the graphic that appears in the Add/Remove
Programs control panel for the application.
-->
<!--<Icon Id='ProductICO' SourceFile='wix\Product.ico'/>-->
<!--<Property Id='ARPPRODUCTICON' Value='ProductICO' />-->
<Property Id='ARPHELPLINK' Value='https://github.com/loki5512344/Terrafier'/>
<UI>
<UIRef Id='WixUI_FeatureTree'/>
<!--
Enabling the EULA dialog in the installer is a three step process:
1. Comment out or remove the two `Publish` tags that follow the
`WixVariable` tag.
2. Uncomment the `<WixVariable Id='WixUILicenseRtf' Value='Path\to\Eula.rft'>` tag further down
3. Replace the `Value` attribute of the `WixVariable` tag with
the path to a RTF file that will be used as the EULA and
displayed in the license agreement dialog.
-->
<Publish Dialog='WelcomeDlg' Control='Next' Event='NewDialog' Value='CustomizeDlg' Order='99'>1</Publish>
<Publish Dialog='CustomizeDlg' Control='Back' Event='NewDialog' Value='WelcomeDlg' Order='99'>1</Publish>
</UI>
<!--
Enabling the EULA dialog in the installer requires uncommenting
the following `WixUILicenseRTF` tag and changing the `Value`
attribute.
-->
<!-- <WixVariable Id='WixUILicenseRtf' Value='Relative\Path\to\Eula.rtf'/> -->
<!--
Uncomment the next `WixVariable` tag to customize the installer's
Graphical User Interface (GUI) and add a custom banner image across
the top of each screen. See the WiX Toolset documentation for details
about customization.
The banner BMP dimensions are 493 x 58 pixels.
-->
<!--<WixVariable Id='WixUIBannerBmp' Value='wix\Banner.bmp'/>-->
<!--
Uncomment the next `WixVariable` tag to customize the installer's
Graphical User Interface (GUI) and add a custom image to the first
dialog, or screen. See the WiX Toolset documentation for details about
customization.
The dialog BMP dimensions are 493 x 312 pixels.
-->
<!--<WixVariable Id='WixUIDialogBmp' Value='wix\Dialog.bmp'/>-->
</Product>
</Wix>