Compare commits
12 Commits
fdb2d0bba8
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 5bd7b10904 | |||
| e64a0c4bf9 | |||
| 797714d1e1 | |||
| 4842000c31 | |||
| 87d49f8959 | |||
| 29cfe8e801 | |||
| 037dcf82a4 | |||
| 01f64caea1 | |||
| 56c53d7f53 | |||
| 0d0627b223 | |||
| 4e742d9ebc | |||
| d40441da55 |
86
Cargo.lock
generated
86
Cargo.lock
generated
@@ -89,12 +89,36 @@ version = "1.0.102"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
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]]
|
[[package]]
|
||||||
name = "byteorder"
|
name = "byteorder"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder-lite"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cesu8"
|
name = "cesu8"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -169,8 +193,10 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"fastnbt",
|
"fastnbt",
|
||||||
"flate2",
|
"flate2",
|
||||||
|
"image",
|
||||||
"lib",
|
"lib",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
@@ -187,6 +213,15 @@ dependencies = [
|
|||||||
"serde_bytes",
|
"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]]
|
[[package]]
|
||||||
name = "flate2"
|
name = "flate2"
|
||||||
version = "1.1.9"
|
version = "1.1.9"
|
||||||
@@ -203,6 +238,19 @@ version = "0.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
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]]
|
[[package]]
|
||||||
name = "is_terminal_polyfill"
|
name = "is_terminal_polyfill"
|
||||||
version = "1.70.2"
|
version = "1.70.2"
|
||||||
@@ -278,6 +326,16 @@ dependencies = [
|
|||||||
"simd-adler32",
|
"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]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
@@ -287,6 +345,15 @@ dependencies = [
|
|||||||
"windows-sys",
|
"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]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
@@ -314,6 +381,19 @@ version = "0.2.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
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]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.106"
|
version = "1.0.106"
|
||||||
@@ -323,6 +403,12 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pxfm"
|
||||||
|
version = "0.1.28"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.45"
|
version = "1.0.45"
|
||||||
|
|||||||
58
README.md
58
README.md
@@ -2,3 +2,61 @@
|
|||||||
|
|
||||||
This little project is a tool that takes a string of text and generates minecraft structure that represents given text in a font of your choice. User can choose blocks to use (or define their own palette) or just use preset.
|
This little project is a tool that takes a string of text and generates minecraft structure that represents given text in a font of your choice. User can choose blocks to use (or define their own palette) or just use preset.
|
||||||
Exported structure can be in various formats, such as .schematic, .litematic or .nbt, or just .mcfunction file with setblock commands.
|
Exported structure can be in various formats, such as .schematic, .litematic or .nbt, or just .mcfunction file with setblock commands.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
The binary is at `target/release/minecraft-text-generator`.
|
||||||
|
|
||||||
|
### Generate text
|
||||||
|
|
||||||
|
```bash
|
||||||
|
minecraft-text-generator -t "Hello World" -f path/to/font.ttf
|
||||||
|
```
|
||||||
|
|
||||||
|
Outputs `output.mcfunction` by default.
|
||||||
|
|
||||||
|
### Common flags
|
||||||
|
|
||||||
|
| Flag | Default | Description |
|
||||||
|
|------|---------|-------------|
|
||||||
|
| `-t, --text` | *(required)* | Text to generate |
|
||||||
|
| `-f, --font` | *(required)* | Path to `.ttf` font file |
|
||||||
|
| `--format` | `mcfunction` | Output format(s): `mcfunction`, `litematica`, `png`, `all` |
|
||||||
|
| `-o, --out` | `output` | Output file path (without extension) |
|
||||||
|
| `--size` | `16.0` | Text height in blocks |
|
||||||
|
| `-d, --depth` | `1` | How many blocks deep |
|
||||||
|
| `-p, --palette` | `default` | Built-in palette preset name |
|
||||||
|
| `--palette-file` | — | Path to a custom JSON palette file |
|
||||||
|
| `--shadow` | off | Add a drop shadow |
|
||||||
|
| `--shadow-x` / `--shadow-y` | `1` / `-1` | Shadow offset in blocks |
|
||||||
|
| `--outline-mode` | `8` | Outline connectivity: `4` (cardinal) or `8` (with corners) |
|
||||||
|
| `--letter-spacing` | font default | Extra blocks between characters |
|
||||||
|
| `--word-spacing` | `4` | Blocks wide for a space character |
|
||||||
|
| `--cell-size` | `16` | Pixel size per block for PNG export (8–64) |
|
||||||
|
|
||||||
|
### List available palettes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
minecraft-text-generator --list-palettes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Litematica schematic with a shadow
|
||||||
|
minecraft-text-generator -t "HELLO" -f fonts/myfont.ttf --format litematica --shadow
|
||||||
|
|
||||||
|
# PNG preview at 32px per block
|
||||||
|
minecraft-text-generator -t "HELLO" -f fonts/myfont.ttf --format png --cell-size 32
|
||||||
|
|
||||||
|
# All formats at once, custom palette
|
||||||
|
minecraft-text-generator -t "HELLO" -f fonts/myfont.ttf --format all -p stone
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|||||||
@@ -65,6 +65,10 @@ struct Cli {
|
|||||||
/// Width in blocks inserted for a space character
|
/// Width in blocks inserted for a space character
|
||||||
#[arg(long, default_value_t = 4)]
|
#[arg(long, default_value_t = 4)]
|
||||||
word_spacing: u32,
|
word_spacing: u32,
|
||||||
|
|
||||||
|
/// 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn palettes_dir() -> PathBuf {
|
fn palettes_dir() -> PathBuf {
|
||||||
@@ -160,12 +164,20 @@ pub fn run() -> anyhow::Result<()> {
|
|||||||
.filter(|&name| seen.insert(name))
|
.filter(|&name| seen.insert(name))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Build and validate in one pass
|
// Build and validate in one pass; route "png" through build_png to honour --cell-size
|
||||||
let exporters_to_run = format_names
|
let exporters_to_run = format_names
|
||||||
.iter()
|
.iter()
|
||||||
.map(|&name| -> anyhow::Result<_> {
|
.map(|&name| -> anyhow::Result<Box<dyn lib::StructureExporter>> {
|
||||||
|
if name == "png" {
|
||||||
|
Ok(exporters::build_png(&palette, cli.cell_size))
|
||||||
|
} else {
|
||||||
exporters::build(name, &palette)
|
exporters::build(name, &palette)
|
||||||
.ok_or_else(|| anyhow::anyhow!("unknown format '{}'. Available: {}", name, all_names.join(", ")))
|
.ok_or_else(|| anyhow::anyhow!(
|
||||||
|
"unknown format '{}'. Available: {}",
|
||||||
|
name,
|
||||||
|
all_names.join(", ")
|
||||||
|
))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect::<anyhow::Result<Vec<_>>>()?;
|
.collect::<anyhow::Result<Vec<_>>>()?;
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ edition = "2024"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
lib = { path = "../lib" }
|
lib = { path = "../lib" }
|
||||||
|
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||||
fastnbt = "2.6.1"
|
fastnbt = "2.6.1"
|
||||||
flate2 = "1.1.9"
|
flate2 = "1.1.9"
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
|
|||||||
448
crates/exporters/src/image_export.rs
Normal file
448
crates/exporters/src/image_export.rs
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
use image::{ImageFormat, Rgba, RgbaImage};
|
||||||
|
use lib::{BlockPalette, StructureExporter, VoxelGrid, VoxelType};
|
||||||
|
|
||||||
|
const GAP: u32 = 1;
|
||||||
|
|
||||||
|
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',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
__, __, __, __, __,
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
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).saturating_mul(char_w), y, ch, scale, color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns VoxelTypes that (1) are configured in the palette AND (2) appear in the grid.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
fn layer_block_height(grid: &VoxelGrid, cell_size: u32) -> u32 {
|
||||||
|
let label_height = cell_size + 4;
|
||||||
|
let per_layer_height = if grid.height == 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
grid.height * (cell_size + GAP) - GAP
|
||||||
|
};
|
||||||
|
label_height + GAP + per_layer_height
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn image_dimensions(grid: &VoxelGrid, palette: &BlockPalette, cell_size: u32) -> (u32, u32) {
|
||||||
|
let layer_block_height = layer_block_height(grid, cell_size);
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
draw_rect(img, GAP, y, cell_size, cell_size, color);
|
||||||
|
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);
|
||||||
|
let block_name = palette.resolve(vt);
|
||||||
|
let text_x = GAP + cell_size + GAP * 3;
|
||||||
|
let text_y = y + (cell_size.saturating_sub(7 * lscale)) / 2;
|
||||||
|
draw_text(img, text_x, text_y, &block_name, lscale, COLOR_TEXT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StructureExporter for PngExporter {
|
||||||
|
fn export(&self, grid: &VoxelGrid) -> anyhow::Result<Vec<u8>> {
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
let cs = self.cell_size;
|
||||||
|
let lbh = layer_block_height(grid, cs);
|
||||||
|
let label_height = cs + 4;
|
||||||
|
|
||||||
|
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 * (lbh + 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 {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_grid_height = grid.depth * lbh
|
||||||
|
+ 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())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_extension(&self) -> &'static str {
|
||||||
|
"png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 (BUT: all-None grid → 0 active types → legend_height=0)
|
||||||
|
// total_h = 71 + 0 = 71
|
||||||
|
assert_eq!(h, 71);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
|
mod image_export;
|
||||||
mod litematica;
|
mod litematica;
|
||||||
mod mcfunction;
|
mod mcfunction;
|
||||||
|
|
||||||
use lib::{BlockPalette, StructureExporter};
|
use lib::{BlockPalette, StructureExporter};
|
||||||
|
|
||||||
|
pub use image_export::PngExporter;
|
||||||
pub use litematica::LitematicaExporter;
|
pub use litematica::LitematicaExporter;
|
||||||
pub use mcfunction::McFunctionExporter;
|
pub use mcfunction::McFunctionExporter;
|
||||||
|
|
||||||
@@ -11,12 +13,18 @@ type ExporterFactory = fn(&BlockPalette) -> Box<dyn StructureExporter>;
|
|||||||
const REGISTRY: &[(&str, ExporterFactory)] = &[
|
const REGISTRY: &[(&str, ExporterFactory)] = &[
|
||||||
("mcfunction", |p| Box::new(McFunctionExporter::new(p))),
|
("mcfunction", |p| Box::new(McFunctionExporter::new(p))),
|
||||||
("litematica", |p| Box::new(LitematicaExporter::new(p))),
|
("litematica", |p| Box::new(LitematicaExporter::new(p))),
|
||||||
|
// cell_size=16 default; CLI uses build_png() to honour --cell-size
|
||||||
|
("png", |p| Box::new(PngExporter::new(p, 16))),
|
||||||
];
|
];
|
||||||
|
|
||||||
pub fn available_names() -> Vec<&'static str> {
|
pub fn available_names() -> Vec<&'static str> {
|
||||||
REGISTRY.iter().map(|(name, _)| *name).collect()
|
REGISTRY.iter().map(|(name, _)| *name).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn build_png(palette: &BlockPalette, cell_size: u32) -> Box<dyn StructureExporter> {
|
||||||
|
Box::new(PngExporter::new(palette, cell_size))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn build(name: &str, palette: &BlockPalette) -> Option<Box<dyn StructureExporter>> {
|
pub fn build(name: &str, palette: &BlockPalette) -> Option<Box<dyn StructureExporter>> {
|
||||||
REGISTRY.iter()
|
REGISTRY.iter()
|
||||||
.find(|(n, _)| *n == name)
|
.find(|(n, _)| *n == name)
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ pub struct Metadata {
|
|||||||
#[serde(rename_all = "PascalCase")]
|
#[serde(rename_all = "PascalCase")]
|
||||||
pub struct Region {
|
pub struct Region {
|
||||||
pub block_state_palette: Vec<BlockState>,
|
pub block_state_palette: Vec<BlockState>,
|
||||||
pub block_states: Vec<i64>,
|
pub block_states: fastnbt::LongArray,
|
||||||
pub position: Vector3,
|
pub position: Vector3,
|
||||||
pub size: Vector3,
|
pub size: Vector3,
|
||||||
}
|
}
|
||||||
@@ -144,7 +144,7 @@ impl StructureExporter for LitematicaExporter {
|
|||||||
"TextRegion".to_string(),
|
"TextRegion".to_string(),
|
||||||
Region {
|
Region {
|
||||||
block_state_palette: palette_list,
|
block_state_palette: palette_list,
|
||||||
block_states: packed_states,
|
block_states: fastnbt::LongArray::new(packed_states),
|
||||||
position: Vector3 { x: 0, y: 0, z: 0 },
|
position: Vector3 { x: 0, y: 0, z: 0 },
|
||||||
size: dimensions,
|
size: dimensions,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ pub use fonts::ttf_font::TtfFont;
|
|||||||
pub use grid::VoxelGrid;
|
pub use grid::VoxelGrid;
|
||||||
pub use models::VoxelType;
|
pub use models::VoxelType;
|
||||||
pub use palette::BlockPalette;
|
pub use palette::BlockPalette;
|
||||||
|
pub use palette::PaletteMappings;
|
||||||
|
|
||||||
pub trait StructureExporter {
|
pub trait StructureExporter {
|
||||||
fn export(&self, grid: &VoxelGrid) -> anyhow::Result<Vec<u8>>;
|
fn export(&self, grid: &VoxelGrid) -> anyhow::Result<Vec<u8>>;
|
||||||
|
|||||||
@@ -1,130 +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 showing a color swatch, the letter code, and the full Minecraft block name (e.g. `B minecraft:stone`).
|
|
||||||
|
|
||||||
## 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 at 16px (default).
|
|
||||||
|
|
||||||
## 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, cell_size) -> (u32, u32)` — pure dimension calculation
|
|
||||||
- `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, cell_size)` — render a text string
|
|
||||||
- `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
|
|
||||||
|
|
||||||
layer_block_height = (cell_size + 4) + gap + height * (cell_size + gap) - gap
|
|
||||||
total_grid_height = depth * layer_block_height + (depth - 1) * gap
|
|
||||||
img_width = gap + width * (cell_size + gap)
|
|
||||||
img_height = total_grid_height + gap + legend_height
|
|
||||||
```
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## 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` — all-air grid produces valid output
|
|
||||||
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
|
|
||||||
|
|
||||||
## 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
|
|
||||||
```
|
|
||||||
BIN
minecraft_world.png
Normal file
BIN
minecraft_world.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.7 MiB |
Reference in New Issue
Block a user