diff --git a/docs/superpowers/plans/2026-03-23-png-image-exporter.md b/docs/superpowers/plans/2026-03-23-png-image-exporter.md new file mode 100644 index 0000000..f400144 --- /dev/null +++ b/docs/superpowers/plans/2026-03-23-png-image-exporter.md @@ -0,0 +1,790 @@ +# PNG Image Exporter Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `--format png` that generates a color-coded block grid image with B/O/S letter codes in each cell, all z-layers stacked vertically, and a legend mapping colors to block names. + +**Architecture:** New `PngExporter` in `crates/exporters` implements `StructureExporter`. Renders via `image` crate (`RgbaImage` pixel ops) using an embedded 5×7 bitmap font — no external font files. CLI routes `"png"` through a `build_png(palette, cell_size)` factory to honor `--cell-size`. + +**Tech Stack:** Rust, `image = "0.25"` (PNG via `RgbaImage`), `clap` (CLI flag) + +--- + +### Task 1: Scaffold + dependency + +**Files:** +- Modify: `crates/exporters/Cargo.toml` +- Create: `crates/exporters/src/image_export.rs` +- Modify: `crates/exporters/src/lib.rs` +- Modify: `crates/lib/src/lib.rs` + +- [ ] **Step 1: Export `PaletteMappings` from lib** + +`image_export.rs` needs to inspect `palette.blocks.outline: Option`. Currently `PaletteMappings` is not re-exported. Add to `crates/lib/src/lib.rs`: + +```rust +pub use palette::PaletteMappings; +``` + +- [ ] **Step 2: Add `image` crate** + +In `crates/exporters/Cargo.toml`, under `[dependencies]`: +```toml +image = { version = "0.25", default-features = false, features = ["png"] } +``` + +- [ ] **Step 3: Create stub module** + +Create `crates/exporters/src/image_export.rs`: +```rust +use lib::{BlockPalette, StructureExporter, VoxelGrid, VoxelType}; + +pub struct PngExporter { + palette: BlockPalette, + cell_size: u32, +} + +impl PngExporter { + pub fn new(palette: &BlockPalette, cell_size: u32) -> Self { + Self { palette: palette.clone(), cell_size } + } +} + +impl StructureExporter for PngExporter { + fn export(&self, _grid: &VoxelGrid) -> anyhow::Result> { + todo!("png export not yet implemented") + } + + fn file_extension(&self) -> &'static str { + "png" + } +} +``` + +- [ ] **Step 4: Register in `crates/exporters/src/lib.rs`** + +Add at the top: +```rust +mod image_export; +pub use image_export::PngExporter; +``` + +Add to `REGISTRY`: +```rust +("png", |p| Box::new(PngExporter::new(p, 16))), +``` + +Add after the `build` function: +```rust +pub fn build_png(palette: &BlockPalette, cell_size: u32) -> Box { + Box::new(PngExporter::new(palette, cell_size)) +} +``` + +- [ ] **Step 5: Verify it compiles** + +```bash +cargo check +``` +Expected: clean (the `todo!()` is fine) + +- [ ] **Step 6: Commit scaffold** + +```bash +git add crates/lib/src/lib.rs crates/exporters/Cargo.toml crates/exporters/src/image_export.rs crates/exporters/src/lib.rs +git commit -m "feat(exporters): scaffold PngExporter" +``` + +--- + +### Task 2: Dimension calculation (TDD) + +**Files:** +- Modify: `crates/exporters/src/image_export.rs` + +- [ ] **Step 1: Write failing dimension tests** + +Add to `image_export.rs`: + +```rust +const GAP: u32 = 1; + +// Returns VoxelTypes that (1) are configured in the palette AND (2) appear in the grid. +// This matches the spec's definition of "active" for legend entries. +fn active_voxel_types(palette: &BlockPalette, grid: &VoxelGrid) -> Vec { + let mut candidates = vec![VoxelType::Body]; + if palette.blocks.outline.is_some() { + candidates.push(VoxelType::Outline); + } + if palette.blocks.shadow.is_some() { + candidates.push(VoxelType::Shadow); + } + candidates.retain(|&vt| { + (0..grid.depth).any(|z| + (0..grid.height).any(|y| + (0..grid.width).any(|x| grid.get(x, y, z) == Some(vt)) + ) + ) + }); + candidates +} + +pub fn image_dimensions(grid: &VoxelGrid, palette: &BlockPalette, cell_size: u32) -> (u32, u32) { + todo!() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn palette_full() -> BlockPalette { + serde_json::from_str(r#"{ + "name": "test", + "blocks": { + "body": "minecraft:stone", + "outline": "minecraft:cobblestone", + "shadow": "minecraft:gravel" + } + }"#).unwrap() + } + + fn palette_body_only() -> BlockPalette { + serde_json::from_str(r#"{ + "name": "test", + "blocks": { "body": "minecraft:stone" } + }"#).unwrap() + } + + #[test] + fn dimensions_single_layer() { + let grid = VoxelGrid::new(4, 3, 1); + let palette = palette_full(); + let (w, h) = image_dimensions(&grid, &palette, 16); + // width: 1 + 4*(16+1) = 69 + assert_eq!(w, 69); + // label_height = 20, per_layer = 3*17-1 = 50, layer_block = 71 + // total_grid = 71 + // legend = 3*17 + 1 = 52 + // total_h = 71 + 1 + 52 = 124 + assert_eq!(h, 124); + } + + #[test] + fn dimensions_multi_layer() { + let g1 = VoxelGrid::new(4, 3, 1); + let g3 = VoxelGrid::new(4, 3, 3); + let palette = palette_full(); + let (_, h1) = image_dimensions(&g1, &palette, 16); + let (_, h3) = image_dimensions(&g3, &palette, 16); + assert!(h3 > h1); + } +} +``` + +- [ ] **Step 2: Run to confirm they fail** + +```bash +cargo test -p exporters 2>&1 | head -10 +``` +Expected: `panicked at 'not yet implemented'` + +- [ ] **Step 3: Implement `image_dimensions`** + +Replace the `todo!()`: +```rust +pub fn image_dimensions(grid: &VoxelGrid, palette: &BlockPalette, cell_size: u32) -> (u32, u32) { + let label_height = cell_size + 4; + let per_layer_height = if grid.height == 0 { + 0 + } else { + grid.height * (cell_size + GAP) - GAP + }; + let layer_block_height = label_height + GAP + per_layer_height; + let total_grid_height = grid.depth * layer_block_height + + grid.depth.saturating_sub(1) * GAP; + + let active = active_voxel_types(palette, grid); + let legend_height = if active.is_empty() { + 0 + } else { + active.len() as u32 * (cell_size + GAP) + GAP + }; + + let img_width = if grid.width == 0 { + 1 + } else { + GAP + grid.width * (cell_size + GAP) + }; + let img_height = total_grid_height + + if legend_height > 0 { GAP + legend_height } else { 0 }; + + (img_width, img_height) +} +``` + +- [ ] **Step 4: Run tests** + +```bash +cargo test -p exporters dimensions +``` +Expected: both pass + +- [ ] **Step 5: Commit** + +```bash +git add crates/exporters/src/image_export.rs +git commit -m "feat(exporters): implement image_dimensions with TDD" +``` + +--- + +### Task 3: Color constants, font, drawing primitives + +**Files:** +- Modify: `crates/exporters/src/image_export.rs` + +- [ ] **Step 1: Add color constants and helpers** + +Add near the top of `image_export.rs` (after `use` statements): + +```rust +use image::{ImageFormat, Rgba, RgbaImage}; + +const COLOR_BODY: [u8; 4] = [0x4A, 0x90, 0xD9, 0xFF]; +const COLOR_OUTLINE: [u8; 4] = [0x88, 0x88, 0x88, 0xFF]; +const COLOR_SHADOW: [u8; 4] = [0xC0, 0xA0, 0x60, 0xFF]; +const COLOR_BG: [u8; 4] = [0x22, 0x22, 0x22, 0xFF]; +const COLOR_TEXT: [u8; 4] = [0xFF, 0xFF, 0xFF, 0xFF]; + +fn voxel_color(vt: VoxelType) -> [u8; 4] { + match vt { + VoxelType::Body => COLOR_BODY, + VoxelType::Outline => COLOR_OUTLINE, + VoxelType::Shadow => COLOR_SHADOW, + } +} + +fn voxel_letter(vt: VoxelType) -> char { + match vt { + VoxelType::Body => 'B', + VoxelType::Outline => 'O', + VoxelType::Shadow => 'S', + } +} +``` + +- [ ] **Step 2: Add `letter_for_each_voxel_type` test and run it** + +In the test module: +```rust +#[test] +fn letter_for_each_voxel_type() { + assert_eq!(voxel_letter(VoxelType::Body), 'B'); + assert_eq!(voxel_letter(VoxelType::Outline), 'O'); + assert_eq!(voxel_letter(VoxelType::Shadow), 'S'); +} +``` + +```bash +cargo test -p exporters letter_for +``` +Expected: passes + +- [ ] **Step 3: Add embedded 5×7 bitmap font** + +Each `[u8; 7]` entry encodes 7 rows; each `u8` encodes 5 pixels (bit 4 = leftmost col, bit 0 = rightmost). Add to `image_export.rs`: + +```rust +/// 5×7 bitmap font indexed by ASCII code. Bit4=left, Bit0=right per row. +static FONT_5X7: [[u8; 7]; 128] = { + const __: [u8; 7] = [0; 7]; + [ + // 0-31: control chars (blank) + __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, + __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, __, + // 32 ' ' + [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], + // 33 '!' + [0x04, 0x04, 0x04, 0x04, 0x04, 0x00, 0x04], + // 34-47: unused (blank) + __, __, __, __, __, __, __, __, __, __, __, __, __, __, + // 48 '0' + [0x0E, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0E], + // 49 '1' + [0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E], + // 50 '2' + [0x0E, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1F], + // 51 '3' + [0x0E, 0x11, 0x01, 0x06, 0x01, 0x11, 0x0E], + // 52 '4' + [0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02], + // 53 '5' + [0x1F, 0x10, 0x1E, 0x01, 0x01, 0x11, 0x0E], + // 54 '6' + [0x06, 0x08, 0x10, 0x1E, 0x11, 0x11, 0x0E], + // 55 '7' + [0x1F, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08], + // 56 '8' + [0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E], + // 57 '9' + [0x0E, 0x11, 0x11, 0x0F, 0x01, 0x02, 0x0C], + // 58 ':' + [0x00, 0x04, 0x04, 0x00, 0x04, 0x04, 0x00], + // 59-64: blank + __, __, __, __, __, __, + // 65 'A' + [0x04, 0x0A, 0x11, 0x11, 0x1F, 0x11, 0x11], + // 66 'B' + [0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E], + // 67 'C' + [0x0E, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0E], + // 68 'D' + [0x1C, 0x12, 0x11, 0x11, 0x11, 0x12, 0x1C], + // 69 'E' + [0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x1F], + // 70 'F' + [0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x10], + // 71 'G' + [0x0E, 0x11, 0x10, 0x17, 0x11, 0x11, 0x0F], + // 72 'H' + [0x11, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11], + // 73 'I' + [0x0E, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0E], + // 74 'J' + [0x07, 0x02, 0x02, 0x02, 0x02, 0x12, 0x0C], + // 75 'K' + [0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11], + // 76 'L' + [0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1F], + // 77 'M' + [0x11, 0x1B, 0x15, 0x11, 0x11, 0x11, 0x11], + // 78 'N' + [0x11, 0x19, 0x15, 0x13, 0x11, 0x11, 0x11], + // 79 'O' + [0x0E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E], + // 80 'P' + [0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10, 0x10], + // 81 'Q' + [0x0E, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0D], + // 82 'R' + [0x1E, 0x11, 0x11, 0x1E, 0x14, 0x12, 0x11], + // 83 'S' + [0x0E, 0x11, 0x10, 0x0E, 0x01, 0x11, 0x0E], + // 84 'T' + [0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04], + // 85 'U' + [0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E], + // 86 'V' + [0x11, 0x11, 0x11, 0x11, 0x11, 0x0A, 0x04], + // 87 'W' + [0x11, 0x11, 0x11, 0x15, 0x15, 0x1B, 0x11], + // 88 'X' + [0x11, 0x11, 0x0A, 0x04, 0x0A, 0x11, 0x11], + // 89 'Y' + [0x11, 0x11, 0x0A, 0x04, 0x04, 0x04, 0x04], + // 90 'Z' + [0x1F, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1F], + // 91-94: blank + __, __, __, __, + // 95 '_' + [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F], + // 96: blank + __, + // 97 'a' + [0x00, 0x00, 0x0E, 0x01, 0x0F, 0x11, 0x0F], + // 98 'b' + [0x10, 0x10, 0x1E, 0x11, 0x11, 0x11, 0x1E], + // 99 'c' + [0x00, 0x00, 0x0E, 0x10, 0x10, 0x10, 0x0E], + // 100 'd' + [0x01, 0x01, 0x0F, 0x11, 0x11, 0x11, 0x0F], + // 101 'e' + [0x00, 0x00, 0x0E, 0x11, 0x1F, 0x10, 0x0E], + // 102 'f' + [0x06, 0x09, 0x08, 0x1E, 0x08, 0x08, 0x08], + // 103 'g' + [0x00, 0x0F, 0x11, 0x11, 0x0F, 0x01, 0x0E], + // 104 'h' + [0x10, 0x10, 0x1E, 0x11, 0x11, 0x11, 0x11], + // 105 'i' + [0x04, 0x00, 0x0C, 0x04, 0x04, 0x04, 0x0E], + // 106 'j' + [0x02, 0x00, 0x06, 0x02, 0x02, 0x12, 0x0C], + // 107 'k' + [0x10, 0x10, 0x12, 0x14, 0x18, 0x14, 0x12], + // 108 'l' + [0x0C, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0E], + // 109 'm' + [0x00, 0x00, 0x1A, 0x15, 0x15, 0x11, 0x11], + // 110 'n' + [0x00, 0x00, 0x16, 0x19, 0x11, 0x11, 0x11], + // 111 'o' + [0x00, 0x00, 0x0E, 0x11, 0x11, 0x11, 0x0E], + // 112 'p' + [0x00, 0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10], + // 113 'q' + [0x00, 0x0F, 0x11, 0x11, 0x0F, 0x01, 0x01], + // 114 'r' + [0x00, 0x00, 0x16, 0x19, 0x10, 0x10, 0x10], + // 115 's' + [0x00, 0x00, 0x0E, 0x10, 0x0E, 0x01, 0x0E], + // 116 't' + [0x08, 0x08, 0x1E, 0x08, 0x08, 0x09, 0x06], + // 117 'u' + [0x00, 0x00, 0x11, 0x11, 0x11, 0x13, 0x0D], + // 118 'v' + [0x00, 0x00, 0x11, 0x11, 0x11, 0x0A, 0x04], + // 119 'w' + [0x00, 0x00, 0x11, 0x11, 0x15, 0x15, 0x0A], + // 120 'x' + [0x00, 0x00, 0x11, 0x0A, 0x04, 0x0A, 0x11], + // 121 'y' + [0x00, 0x11, 0x11, 0x0F, 0x01, 0x11, 0x0E], + // 122 'z' + [0x00, 0x00, 0x1F, 0x02, 0x04, 0x08, 0x1F], + // 123-127: blank + __, __, __, __, __, + ] +}; +``` + +- [ ] **Step 4: Add drawing helpers** + +```rust +fn draw_rect(img: &mut RgbaImage, x: u32, y: u32, w: u32, h: u32, color: [u8; 4]) { + let c = Rgba(color); + for dy in 0..h { + for dx in 0..w { + let px = x + dx; + let py = y + dy; + if px < img.width() && py < img.height() { + img.put_pixel(px, py, c); + } + } + } +} + +/// Render one char at (x, y). Each font pixel = scale×scale actual pixels. +fn draw_char(img: &mut RgbaImage, x: u32, y: u32, ch: char, scale: u32, color: [u8; 4]) { + let code = ch as usize; + if code >= 128 { return; } + let glyph = FONT_5X7[code]; + for (row, &bits) in glyph.iter().enumerate() { + for col in 0..5u32 { + if bits & (1 << (4 - col)) != 0 { + draw_rect(img, x + col * scale, y + row as u32 * scale, scale, scale, color); + } + } + } +} + +/// Render a string left-to-right. Each char is 6*scale px wide (5px + 1px gap). +fn draw_text(img: &mut RgbaImage, x: u32, y: u32, text: &str, scale: u32, color: [u8; 4]) { + let char_w = 6 * scale; + for (i, ch) in text.chars().enumerate() { + draw_char(img, x + i as u32 * char_w, y, ch, scale, color); + } +} +``` + +- [ ] **Step 5: Run all tests** + +```bash +cargo test -p exporters +``` +Expected: 3 passing (dimensions ×2, letter_for) + +- [ ] **Step 6: Commit** + +```bash +git add crates/exporters/src/image_export.rs +git commit -m "feat(exporters): bitmap font, colors, drawing primitives" +``` + +--- + +### Task 4: Full export implementation (TDD) + +**Files:** +- Modify: `crates/exporters/src/image_export.rs` + +- [ ] **Step 1: Write the 4 export tests** + +Add to the test module: + +```rust +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()); +} +``` + +- [ ] **Step 2: Run to confirm they fail** + +```bash +cargo test -p exporters export 2>&1 | grep -E "FAILED|panicked" +``` +Expected: all 4 fail with `not yet implemented` + +- [ ] **Step 3: Implement layer label and legend helpers** + +Add to `image_export.rs`: + +```rust +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); // letter scale inside swatch + 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); + // Color swatch + draw_rect(img, GAP, y, cell_size, cell_size, color); + // Letter centered in swatch + 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); + // `BlockPalette::resolve(&VoxelType) -> String` is defined in crates/lib/src/palette.rs. + // It returns the block name string (e.g. "minecraft:stone") or "minecraft:air" for None fields. + 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); + } +} +``` + +- [ ] **Step 4: Implement `export`** + +Replace `todo!()` in the `StructureExporter` impl: + +```rust +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 { + // y=0 in VoxelGrid is the bottom row; flip for image (top-down) + 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); + } + } + } + } + } + + // Legend below all layers + 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()) +} +``` + +- [ ] **Step 5: Run all tests** + +```bash +cargo test -p exporters +``` +Expected: all 7 tests pass + +- [ ] **Step 6: Commit** + +```bash +git add crates/exporters/src/image_export.rs +git commit -m "feat(exporters): implement full PNG grid + legend rendering" +``` + +--- + +### Task 5: CLI integration + +**Files:** +- Modify: `crates/bin/src/cli.rs` + +- [ ] **Step 1: Add `--cell-size` flag to `Cli` struct** + +In `crates/bin/src/cli.rs`, add to the `Cli` struct (after `word_spacing`): +```rust +/// Cell size in pixels for PNG export (8-64) +#[arg(long, default_value_t = 16, value_parser = clap::value_parser!(u32).range(8..=64))] +cell_size: u32, +``` + +- [ ] **Step 2: Route `"png"` through `build_png`** + +In `run()`, find the `// Build and validate in one pass` comment and replace the existing `exporters_to_run` collection with: + +```rust +// Build and validate in one pass; route "png" through build_png to honour --cell-size +let exporters_to_run = format_names + .iter() + .map(|&name| -> anyhow::Result> { + if name == "png" { + Ok(exporters::build_png(&palette, cli.cell_size)) + } else { + exporters::build(name, &palette) + .ok_or_else(|| anyhow::anyhow!( + "unknown format '{}'. Available: {}", + name, + all_names.join(", ") + )) + } + }) + .collect::>>()?; +``` + +The `for` loop that writes files stays unchanged. + +Note: you may need to add `use lib::StructureExporter;` at the top of `cli.rs` if the explicit trait bound `Box` requires it. Check the import list — if `lib::StructureExporter` is not imported, add it alongside the existing `lib::` imports. + +- [ ] **Step 3: Build** + +```bash +cargo build +``` +Expected: clean build + +- [ ] **Step 4: Smoke test — basic PNG generation** + +```bash +# Find a TTF font available on your system: +# fc-list | grep -i ttf | head -5 +# Then substitute the path below: +cargo run -- --text "Hi" --font /usr/share/fonts/TTF/DejaVuSans.ttf \ + --format png --cell-size 32 --out /tmp/test_hi +``` +Expected: `/tmp/test_hi.png` created. Open it — should show "Layer 0" header, a grid of colored cells with B/O letters, and a legend below. + +- [ ] **Step 5: Test `--format all`** + +```bash +# Use the same font path from Step 4 +cargo run -- --text "Hi" --font /usr/share/fonts/TTF/DejaVuSans.ttf \ + --format all --cell-size 32 --out /tmp/test_all +``` +Expected: `/tmp/test_all.mcfunction`, `/tmp/test_all.litematica`, and `/tmp/test_all.png` all created + +- [ ] **Step 6: Test depth > 1** + +```bash +cargo run -- --text "Hi" --font /usr/share/fonts/TTF/DejaVuSans.ttf \ + --format png --depth 3 --cell-size 16 --out /tmp/test_depth +``` +Expected: PNG with 3 stacked "Layer 0", "Layer 1", "Layer 2" sections + +- [ ] **Step 7: Commit** + +```bash +git add crates/bin/src/cli.rs +git commit -m "feat(cli): add --cell-size flag, route png through build_png" +``` + +--- + +### Task 6: Final check + +- [ ] **Full test suite** + +```bash +cargo test +``` +Expected: all tests pass, no regressions + +- [ ] **Test shadow (3 legend entries)** + +```bash +cargo run -- --text "Hi" --font /usr/share/fonts/TTF/DejaVuSans.ttf \ + --format png --shadow --cell-size 32 --out /tmp/test_shadow +``` +Expected: PNG with S (tan/gold) cells and all 3 legend rows (B blue, O gray, S tan) + +- [ ] **Test invalid cell-size is rejected** + +```bash +cargo run -- --text "Hi" --font /usr/share/fonts/TTF/DejaVuSans.ttf \ + --format png --cell-size 4 --out /tmp/x 2>&1 | head -3 +``` +Expected: clap error about value out of range + +- [ ] **Final commit if needed** + +```bash +git log --oneline -6 +``` +Confirm all 6 feature commits are present and clean (Tasks 1–6 each produce one commit).