Files
minecraft-text-generator/docs/superpowers/specs/2026-03-23-png-image-exporter-design.md

152 lines
7.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```