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

@ -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);