feat(exporters): implement PNG image exporter with grid and legend rendering

- Added PngExporter struct to generate PNG images from voxel grids.
- Implemented image dimensions calculation, drawing primitives, and legend rendering.
- Integrated PNG export functionality into the CLI with a new --cell-size flag.
- Removed outdated design spec and implementation plan documents.
This commit is contained in:
2026-03-23 02:59:11 +01:00
parent 4842000c31
commit 797714d1e1
4 changed files with 88 additions and 943 deletions

86
Cargo.lock generated
View File

@@ -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"

View File

@@ -37,7 +37,7 @@ pub struct Metadata {
#[serde(rename_all = "PascalCase")]
pub struct Region {
pub block_state_palette: Vec<BlockState>,
pub block_states: Vec<i64>,
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,
},

View File

@@ -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<String>`. 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<Vec<u8>> {
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<dyn StructureExporter> {
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<VoxelType> {
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<Vec<u8>> {
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<Box<dyn lib::StructureExporter>> {
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::<anyhow::Result<Vec<_>>>()?;
```
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<dyn lib::StructureExporter>` 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 16 each produce one commit).

View File

@@ -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 <PIXELS> 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<Vec<u8>>;
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<dyn StructureExporter>` — used by CLI for user-specified cell size
The generic `ExporterFactory = fn(&BlockPalette) -> Box<dyn StructureExporter>` 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 (03)
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
```