feat(exporters): implement full PNG grid + legend rendering
This commit is contained in:
@@ -271,9 +271,83 @@ impl PngExporter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn draw_layer_label(img: &mut RgbaImage, y: u32, z: u32, cell_size: u32) {
|
||||||
|
let label = format!("Layer {}", z);
|
||||||
|
let scale = (cell_size / 8).max(1);
|
||||||
|
let label_height = cell_size + 4;
|
||||||
|
let text_h = 7 * scale;
|
||||||
|
let text_y = y + (label_height.saturating_sub(text_h)) / 2;
|
||||||
|
draw_text(img, GAP, text_y, &label, scale, COLOR_TEXT);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_legend(img: &mut RgbaImage, y_start: u32, palette: &BlockPalette, grid: &VoxelGrid, cell_size: u32) {
|
||||||
|
let lscale = (cell_size / 8).max(1);
|
||||||
|
for (i, vt) in active_voxel_types(palette, grid).iter().enumerate() {
|
||||||
|
let y = y_start + i as u32 * (cell_size + GAP);
|
||||||
|
let color = voxel_color(*vt);
|
||||||
|
draw_rect(img, GAP, y, cell_size, cell_size, color);
|
||||||
|
let lw = 5 * lscale;
|
||||||
|
let lh = 7 * lscale;
|
||||||
|
let lx = GAP + (cell_size.saturating_sub(lw)) / 2;
|
||||||
|
let ly = y + (cell_size.saturating_sub(lh)) / 2;
|
||||||
|
draw_char(img, lx, ly, voxel_letter(*vt), lscale, COLOR_TEXT);
|
||||||
|
let block_name = palette.resolve(vt);
|
||||||
|
let text_x = GAP + cell_size + GAP * 3;
|
||||||
|
let text_y = y + (cell_size.saturating_sub(7)) / 2;
|
||||||
|
draw_text(img, text_x, text_y, &block_name, 1, COLOR_TEXT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl StructureExporter for PngExporter {
|
impl StructureExporter for PngExporter {
|
||||||
fn export(&self, _grid: &VoxelGrid) -> anyhow::Result<Vec<u8>> {
|
fn export(&self, grid: &VoxelGrid) -> anyhow::Result<Vec<u8>> {
|
||||||
todo!("png export not yet implemented")
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
let cs = self.cell_size;
|
||||||
|
let label_height = cs + 4;
|
||||||
|
let per_layer_height = if grid.height == 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
grid.height * (cs + GAP) - GAP
|
||||||
|
};
|
||||||
|
let layer_block_height = label_height + GAP + per_layer_height;
|
||||||
|
|
||||||
|
let (img_w, img_h) = image_dimensions(grid, &self.palette, cs);
|
||||||
|
let mut img = RgbaImage::from_pixel(img_w, img_h, Rgba(COLOR_BG));
|
||||||
|
|
||||||
|
for z in 0..grid.depth {
|
||||||
|
let layer_top_y = z * (layer_block_height + GAP);
|
||||||
|
draw_layer_label(&mut img, layer_top_y, z, cs);
|
||||||
|
|
||||||
|
let grid_top_y = layer_top_y + label_height + GAP;
|
||||||
|
for y in 0..grid.height {
|
||||||
|
let vy = grid.height - 1 - y;
|
||||||
|
for x in 0..grid.width {
|
||||||
|
let px = GAP + x * (cs + GAP);
|
||||||
|
let py = grid_top_y + y * (cs + GAP);
|
||||||
|
match grid.get(x, vy, z) {
|
||||||
|
None => draw_rect(&mut img, px, py, cs, cs, COLOR_BG),
|
||||||
|
Some(vt) => {
|
||||||
|
draw_rect(&mut img, px, py, cs, cs, voxel_color(vt));
|
||||||
|
let lscale = (cs / 8).max(1);
|
||||||
|
let lw = 5 * lscale;
|
||||||
|
let lh = 7 * lscale;
|
||||||
|
let lx = px + (cs.saturating_sub(lw)) / 2;
|
||||||
|
let ly = py + (cs.saturating_sub(lh)) / 2;
|
||||||
|
draw_char(&mut img, lx, ly, voxel_letter(vt), lscale, COLOR_TEXT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_grid_height = grid.depth * layer_block_height
|
||||||
|
+ grid.depth.saturating_sub(1) * GAP;
|
||||||
|
draw_legend(&mut img, total_grid_height + GAP, &self.palette, grid, cs);
|
||||||
|
|
||||||
|
let mut buf = Cursor::new(Vec::new());
|
||||||
|
img.write_to(&mut buf, ImageFormat::Png)
|
||||||
|
.map_err(|e| anyhow::anyhow!("PNG encode failed: {}", e))?;
|
||||||
|
Ok(buf.into_inner())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn file_extension(&self) -> &'static str {
|
fn file_extension(&self) -> &'static str {
|
||||||
@@ -333,4 +407,43 @@ mod tests {
|
|||||||
let (_, h3) = image_dimensions(&g3, &palette, 16);
|
let (_, h3) = image_dimensions(&g3, &palette, 16);
|
||||||
assert!(h3 > h1);
|
assert!(h3 > h1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn make_grid_with_voxels(w: u32, h: u32, d: u32) -> VoxelGrid {
|
||||||
|
let mut grid = VoxelGrid::new(w, h, d);
|
||||||
|
let _ = grid.set(0, 0, 0, VoxelType::Body);
|
||||||
|
let _ = grid.set(1, 0, 0, VoxelType::Outline);
|
||||||
|
if d > 1 { let _ = grid.set(0, 0, 1, VoxelType::Body); }
|
||||||
|
grid
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn export_produces_valid_png() {
|
||||||
|
let grid = make_grid_with_voxels(3, 3, 1);
|
||||||
|
let bytes = PngExporter::new(&palette_full(), 16).export(&grid).unwrap();
|
||||||
|
assert_eq!(&bytes[..8], &[137u8, 80, 78, 71, 13, 10, 26, 10]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn export_empty_grid_no_panic() {
|
||||||
|
let grid = VoxelGrid::new(5, 5, 1); // all None
|
||||||
|
let bytes = PngExporter::new(&palette_full(), 16).export(&grid).unwrap();
|
||||||
|
assert!(!bytes.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn export_multi_layer_larger_than_single() {
|
||||||
|
let g1 = make_grid_with_voxels(4, 4, 1);
|
||||||
|
let g3 = make_grid_with_voxels(4, 4, 3);
|
||||||
|
let b1 = PngExporter::new(&palette_full(), 16).export(&g1).unwrap();
|
||||||
|
let b3 = PngExporter::new(&palette_full(), 16).export(&g3).unwrap();
|
||||||
|
assert!(b3.len() > b1.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn export_palette_with_no_outline() {
|
||||||
|
let mut grid = VoxelGrid::new(3, 3, 1);
|
||||||
|
let _ = grid.set(0, 0, 0, VoxelType::Outline);
|
||||||
|
let bytes = PngExporter::new(&palette_body_only(), 16).export(&grid).unwrap();
|
||||||
|
assert!(!bytes.is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user