# 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).