7.4 KiB
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_sizepixels 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:
- 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) - 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
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 rowsdraw_cell(img, px, py, cell_size, color)— fill a rectangledraw_letter(img, px, py, cell_size, ch, color)— render one char from embedded bitmap fontdraw_layer_label(img, y, z_index, cell_size)— "Layer N" header rowdraw_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 atscale=1for legend labels andscale = cell_size / 8for layer labelsdraw_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 (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=0orheight=0:img_width/per_layer_heightcollapse gracefully to minimal values; no panic. The "empty grid" test case (#4) uses a grid with non-zero dimensions but all-Nonecells — 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, producingresult.png. This is existing CLI behavior, unchanged for PNG.
Dependencies
Only crates/exporters/Cargo.toml gains a new dependency:
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:
dimensions_single_layer— formula correct for 1 layerdimensions_multi_layer— height increases per additional layerexport_produces_valid_png— output starts with PNG magic bytes[137, 80, 78, 71, ...]export_empty_grid_no_panic— grid with all-Nonecells (non-zero dimensions) produces valid output without panicexport_multi_layer_larger_than_single— 3-layer > 1-layer byte countletter_for_each_voxel_type— B/O/S mapping is correctexport_palette_with_no_outline— palette whereoutline = None; grid with Outline voxels still renders without panic; legend shows"minecraft:air"for Outline
Verification
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