diff --git a/crates/exporters/src/image_export.rs b/crates/exporters/src/image_export.rs index 2f3444c..66bdbc7 100644 --- a/crates/exporters/src/image_export.rs +++ b/crates/exporters/src/image_export.rs @@ -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 { - fn export(&self, _grid: &VoxelGrid) -> anyhow::Result> { - todo!("png export not yet implemented") + fn export(&self, grid: &VoxelGrid) -> anyhow::Result> { + 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 { @@ -333,4 +407,43 @@ mod tests { let (_, h3) = image_dimensions(&g3, &palette, 16); 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()); + } }