diff --git a/Cargo.lock b/Cargo.lock index a59c06f..743cae3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -89,12 +89,36 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "cesu8" version = "1.1.0" @@ -169,8 +193,10 @@ dependencies = [ "anyhow", "fastnbt", "flate2", + "image", "lib", "serde", + "serde_json", "thiserror", "tracing", ] @@ -187,6 +213,15 @@ dependencies = [ "serde_bytes", ] +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "flate2" version = "1.1.9" @@ -203,6 +238,19 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -278,6 +326,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -287,6 +345,15 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -314,6 +381,19 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -323,6 +403,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" + [[package]] name = "quote" version = "1.0.45" diff --git a/crates/exporters/src/litematica.rs b/crates/exporters/src/litematica.rs index 8a000f3..1bc2e2d 100644 --- a/crates/exporters/src/litematica.rs +++ b/crates/exporters/src/litematica.rs @@ -37,7 +37,7 @@ pub struct Metadata { #[serde(rename_all = "PascalCase")] pub struct Region { pub block_state_palette: Vec, - pub block_states: Vec, + pub block_states: fastnbt::LongArray, pub position: Vector3, pub size: Vector3, } @@ -144,7 +144,7 @@ impl StructureExporter for LitematicaExporter { "TextRegion".to_string(), Region { block_state_palette: palette_list, - block_states: packed_states, + block_states: fastnbt::LongArray::new(packed_states), position: Vector3 { x: 0, y: 0, z: 0 }, size: dimensions, }, diff --git a/docs/superpowers/plans/2026-03-23-png-image-exporter.md b/docs/superpowers/plans/2026-03-23-png-image-exporter.md deleted file mode 100644 index f400144..0000000 --- a/docs/superpowers/plans/2026-03-23-png-image-exporter.md +++ /dev/null @@ -1,790 +0,0 @@ -# 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). diff --git a/docs/superpowers/specs/2026-03-23-png-image-exporter-design.md b/docs/superpowers/specs/2026-03-23-png-image-exporter-design.md deleted file mode 100644 index b1ac5e4..0000000 --- a/docs/superpowers/specs/2026-03-23-png-image-exporter-design.md +++ /dev/null @@ -1,151 +0,0 @@ -# PNG Image Exporter — Design Spec - -**Date:** 2026-03-23 -**Status:** Approved - -## Problem - -Users generating Minecraft text structures need a visual reference showing which blocks to place and where. Current exporters (mcfunction, litematica) produce machine-readable files; there is no human-readable image guide. - -## Solution - -Add a `"png"` format to the exporter registry. The output is a PNG image with a color-coded grid — one cell per block position — plus a legend mapping colors to Minecraft block names. - -## Visual Design - -### Cell style -- Each cell: `cell_size × cell_size` pixels with 1px gaps between cells (gaps act as grid lines) -- Filled cells show a letter code in the center (B = Body, O = Outline, S = Shadow) -- Empty/air positions: dark background color, no letter - -### Colors -| VoxelType | Color | Hex | Letter | -|-----------|-----------|-----------|--------| -| Body | Blue | `#4a90d9` | B | -| Outline | Gray | `#888888` | O | -| Shadow | Tan/gold | `#c0a060` | S | -| Empty | Dark | `#222222` | — | -| Background| Dark | `#222222` | — | - -### Depth handling -All z-layers are stacked vertically in one PNG. Each layer has a "Layer N" header row above its grid. - -### Legend -Below all layers: one row per **active** VoxelType. A VoxelType is active if both: -1. The palette field is `Some(_)` (i.e. `palette.blocks.outline.is_some()` for Outline, `palette.blocks.shadow.is_some()` for Shadow; Body is always active) -2. At least one cell of that type exists in the grid - -Each legend row shows: color swatch, letter code, full Minecraft block name (e.g. `B minecraft:stone`). - -If a VoxelType has cells in the grid but its palette field is `None`, `BlockPalette::resolve` returns `"minecraft:air"`. Such cells are rendered in the grid with the type's color and letter (since the voxel exists), but the legend shows `"minecraft:air"` as the block name — no special suppression. - -If the grid contains no voxels at all (all cells `None`), the legend is omitted and the image contains only the layer label rows on a dark background. - -## CLI Interface - -New flag added to `crates/bin`: - -``` ---cell-size Cell size in pixels for PNG export (16 or 32) [default: 16] -``` - -The `--format png` flag selects the image exporter. `--format all` includes PNG using whatever `--cell-size` the user specified (or the default 16). - -The CLI always routes `"png"` through `build_png(&palette, cli.cell_size)` regardless of whether it arrived via `--format png` or `--format all`. The registry entry (16px default) exists solely so `available_names()` lists `"png"` and the help text is accurate; it is never used directly for rendering. - -`--cell-size` is validated at parse time to the range `[8, 64]` via a `clap::value_parser` range constraint (matching the pattern used by `--outline-mode`). Values below 8 would produce a letter scale of 0 (invisible); values above 64 are unreasonably large. The flag is ignored for all non-PNG formats. - -## Architecture - -### New file: `crates/exporters/src/image_export.rs` - -```rust -pub struct PngExporter { - palette: BlockPalette, - cell_size: u32, -} - -impl PngExporter { - pub fn new(palette: &BlockPalette, cell_size: u32) -> Self -} - -impl StructureExporter for PngExporter { - fn export(&self, grid: &VoxelGrid) -> anyhow::Result>; - fn file_extension(&self) -> &'static str; // "png" -} -``` - -Private helpers: -- `image_dimensions(grid, palette, cell_size) -> (u32, u32)` — computes full image dimensions including legend; takes palette to determine number of active legend rows -- `draw_cell(img, px, py, cell_size, color)` — fill a rectangle -- `draw_letter(img, px, py, cell_size, ch, color)` — render one char from embedded bitmap font -- `draw_layer_label(img, y, z_index, cell_size)` — "Layer N" header row -- `draw_text_row(img, x, y, text, scale: u32)` — render a text string at a fixed pixel scale (1 = 5×7 px per char, 2 = 10×14, etc.); used at `scale=1` for legend labels and `scale = cell_size / 8` for layer labels -- `draw_legend(img, y_start, palette, cell_size)` — legend section - -### Text rendering -Letters are rendered using an embedded 5×7 bitmap font (`const FONT_5X7: [[u8; 7]; 128]`). Each font pixel is scaled by `cell_size / 8` (so 2× at 16px, 4× at 32px). No external font files or font-rendering crates required. - -### Registry integration - -`crates/exporters/src/lib.rs` changes: -- Register `("png", |p| Box::new(PngExporter::new(p, 16)))` — enables `--format all` -- Add `pub fn build_png(palette: &BlockPalette, cell_size: u32) -> Box` — used by CLI for user-specified cell size - -The generic `ExporterFactory = fn(&BlockPalette) -> Box` signature is unchanged. No changes to `McFunctionExporter` or `LitematicaExporter`. - -### CLI dispatch (`crates/bin/src/cli.rs`) - -When `"png"` appears in `format_names`, the CLI calls `exporters::build_png(&palette, cli.cell_size)` directly (to honour `--cell-size`) rather than the generic registry path. - -### Image layout (per-pixel coordinates) - -``` -gap = 1 -label_height = cell_size + 4 // "Layer N" header row -per_layer_height = height * (cell_size + gap) - gap // 0 if height=0 -layer_block_height = label_height + gap + per_layer_height -total_grid_height = depth * layer_block_height + (depth - 1) * gap -active_types = count of VoxelTypes active in palette+grid (0–3) -legend_height = if active_types > 0 { active_types * (cell_size + gap) + gap } else { 0 } -img_width = gap + width * (cell_size + gap) // 1 if width=0 -img_height = total_grid_height + (if legend_height > 0 { gap + legend_height } else { 0 }) -``` - -Row y=0 in `VoxelGrid` is the bottom row; the image renders row `height-1-y` from the top so text reads naturally top-to-bottom. - -**Edge cases:** -- `width=0` or `height=0`: `img_width`/`per_layer_height` collapse gracefully to minimal values; no panic. The "empty grid" test case (#4) uses a grid with non-zero dimensions but all-`None` cells — this is the common case and must not panic. -- `depth=1`: `(depth-1)*gap = 0`, only one layer block rendered; correct. -- `--out result.mcfunction --format png`: `set_extension("png")` replaces the existing extension, producing `result.png`. This is existing CLI behavior, unchanged for PNG. - -## Dependencies - -Only `crates/exporters/Cargo.toml` gains a new dependency: - -```toml -image = { version = "0.25", default-features = false, features = ["png"] } -``` - -No changes to `crates/lib` or `crates/bin` dependencies. - -## Tests - -All in `crates/exporters/src/image_export.rs`: - -1. `dimensions_single_layer` — formula correct for 1 layer -2. `dimensions_multi_layer` — height increases per additional layer -3. `export_produces_valid_png` — output starts with PNG magic bytes `[137, 80, 78, 71, ...]` -4. `export_empty_grid_no_panic` — grid with all-`None` cells (non-zero dimensions) produces valid output without panic -5. `export_multi_layer_larger_than_single` — 3-layer > 1-layer byte count -6. `letter_for_each_voxel_type` — B/O/S mapping is correct -7. `export_palette_with_no_outline` — palette where `outline = None`; grid with Outline voxels still renders without panic; legend shows `"minecraft:air"` for Outline - -## Verification - -```bash -cargo build -cargo test -p exporters -cargo run -- --text "Hi" --font /path/to/font.ttf --format png --cell-size 32 --out test_out -# Verify: test_out.png is a valid image showing the block grid + legend -```