Add shadow and outline options to text generation CLI and engine
This commit is contained in:
@@ -2,7 +2,7 @@ use std::{fs, path::PathBuf};
|
|||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use exporters::McFunctionExporter;
|
use exporters::McFunctionExporter;
|
||||||
use lib::{BlockPalette, StructureExporter, TextBuilder, TtfFont};
|
use lib::{BlockPalette, OutlineMode, StructureExporter, TextBuilder, TtfFont};
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(author, version, about, long_about = None)]
|
#[command(author, version, about, long_about = None)]
|
||||||
@@ -38,6 +38,30 @@ struct Cli {
|
|||||||
/// Height of the text in blocks
|
/// Height of the text in blocks
|
||||||
#[arg(long, default_value_t = 16.0)]
|
#[arg(long, default_value_t = 16.0)]
|
||||||
size: f32,
|
size: f32,
|
||||||
|
|
||||||
|
/// Generate a drop shadow
|
||||||
|
#[arg(long)]
|
||||||
|
shadow: bool,
|
||||||
|
|
||||||
|
/// Shadow X offset in blocks (positive = right)
|
||||||
|
#[arg(long, default_value_t = 1)]
|
||||||
|
shadow_x: i32,
|
||||||
|
|
||||||
|
/// Shadow Y offset in blocks (negative = down)
|
||||||
|
#[arg(long, default_value_t = -1)]
|
||||||
|
shadow_y: i32,
|
||||||
|
|
||||||
|
/// Outline connectivity: 4 (cardinal only) or 8 (includes corners)
|
||||||
|
#[arg(long, default_value_t = 8, value_parser = clap::value_parser!(u8).range(4..=8))]
|
||||||
|
outline_mode: u8,
|
||||||
|
|
||||||
|
/// Extra blocks between each character (overrides font default)
|
||||||
|
#[arg(long)]
|
||||||
|
letter_spacing: Option<u32>,
|
||||||
|
|
||||||
|
/// Width in blocks inserted for a space character
|
||||||
|
#[arg(long, default_value_t = 4)]
|
||||||
|
word_spacing: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn palettes_dir() -> PathBuf {
|
fn palettes_dir() -> PathBuf {
|
||||||
@@ -93,7 +117,20 @@ pub fn run() -> anyhow::Result<()> {
|
|||||||
let font = TtfFont::from_bytes(&font_bytes, cli.size).map_err(|e| anyhow::anyhow!(e))?;
|
let font = TtfFont::from_bytes(&font_bytes, cli.size).map_err(|e| anyhow::anyhow!(e))?;
|
||||||
|
|
||||||
tracing::info!("generating voxel grid for text: '{}'", text);
|
tracing::info!("generating voxel grid for text: '{}'", text);
|
||||||
let builder = TextBuilder::new(&font).with_depth(cli.depth);
|
let outline_mode = if cli.outline_mode == 4 {
|
||||||
|
OutlineMode::FourConnected
|
||||||
|
} else {
|
||||||
|
OutlineMode::EightConnected
|
||||||
|
};
|
||||||
|
let mut builder = TextBuilder::new(&font)
|
||||||
|
.with_depth(cli.depth)
|
||||||
|
.with_shadow(cli.shadow)
|
||||||
|
.with_shadow_offset(cli.shadow_x, cli.shadow_y)
|
||||||
|
.with_outline_mode(outline_mode)
|
||||||
|
.with_word_spacing(cli.word_spacing);
|
||||||
|
if let Some(ls) = cli.letter_spacing {
|
||||||
|
builder = builder.with_letter_spacing(ls);
|
||||||
|
}
|
||||||
let grid = builder.generate(&text);
|
let grid = builder.generate(&text);
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
|
|||||||
@@ -1,10 +1,25 @@
|
|||||||
use crate::{font::FontProvider, grid::VoxelGrid, models::VoxelType};
|
use crate::{font::{FontProvider, Glyph}, grid::VoxelGrid, models::VoxelType};
|
||||||
|
|
||||||
|
enum GlyphToken {
|
||||||
|
Char(Glyph),
|
||||||
|
Space(u32),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum OutlineMode {
|
||||||
|
FourConnected,
|
||||||
|
EightConnected,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct GenerationOptions {
|
pub struct GenerationOptions {
|
||||||
pub extrusion_depth: u32,
|
pub extrusion_depth: u32,
|
||||||
pub generate_outline: bool,
|
pub generate_outline: bool,
|
||||||
// Future options can be added here, such as:
|
pub outline_mode: OutlineMode,
|
||||||
// shadow, scale, italics, etc.
|
pub generate_shadow: bool,
|
||||||
|
pub shadow_offset: (i32, i32),
|
||||||
|
/// Overrides the font's default letter spacing (None = use font default).
|
||||||
|
pub letter_spacing: Option<u32>,
|
||||||
|
/// Width in blocks inserted for a space character.
|
||||||
|
pub word_spacing: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for GenerationOptions {
|
impl Default for GenerationOptions {
|
||||||
@@ -12,6 +27,11 @@ impl Default for GenerationOptions {
|
|||||||
Self {
|
Self {
|
||||||
extrusion_depth: 1,
|
extrusion_depth: 1,
|
||||||
generate_outline: true,
|
generate_outline: true,
|
||||||
|
outline_mode: OutlineMode::EightConnected,
|
||||||
|
generate_shadow: false,
|
||||||
|
shadow_offset: (1, -1),
|
||||||
|
letter_spacing: None,
|
||||||
|
word_spacing: 4,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,53 +59,162 @@ impl<'a> TextBuilder<'a> {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_outline_mode(mut self, mode: OutlineMode) -> Self {
|
||||||
|
self.options.outline_mode = mode;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_shadow(mut self, generate: bool) -> Self {
|
||||||
|
self.options.generate_shadow = generate;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_shadow_offset(mut self, dx: i32, dy: i32) -> Self {
|
||||||
|
self.options.shadow_offset = (dx, dy);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_letter_spacing(mut self, spacing: u32) -> Self {
|
||||||
|
self.options.letter_spacing = Some(spacing);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_word_spacing(mut self, spacing: u32) -> Self {
|
||||||
|
self.options.word_spacing = spacing;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn generate(&self, text: &str) -> VoxelGrid {
|
pub fn generate(&self, text: &str) -> VoxelGrid {
|
||||||
|
let (tokens, text_width, max_height) = self.collect_glyphs(text);
|
||||||
|
let (base_x, base_y, grid_w, grid_h) = self.compute_layout(text_width, max_height);
|
||||||
|
let mut grid = VoxelGrid::new(grid_w, grid_h, self.options.extrusion_depth);
|
||||||
|
self.place_glyphs(&mut grid, &tokens, base_x, base_y);
|
||||||
|
if self.options.generate_shadow {
|
||||||
|
self.apply_shadow(&mut grid);
|
||||||
|
}
|
||||||
|
if self.options.generate_outline {
|
||||||
|
self.apply_outline(&mut grid);
|
||||||
|
}
|
||||||
|
grid
|
||||||
|
}
|
||||||
|
|
||||||
|
fn effective_letter_spacing(&self) -> u32 {
|
||||||
|
self.options.letter_spacing.unwrap_or_else(|| self.font.letter_spacing())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_glyphs(&self, text: &str) -> (Vec<GlyphToken>, u32, u32) {
|
||||||
|
let letter_spacing = self.effective_letter_spacing();
|
||||||
let mut total_width = 0;
|
let mut total_width = 0;
|
||||||
let mut max_height = 0;
|
let mut max_height = 0;
|
||||||
let mut glyphs_to_render = Vec::new();
|
let mut tokens = Vec::new();
|
||||||
|
|
||||||
for c in text.chars() {
|
for c in text.chars() {
|
||||||
if let Some(glyph) = self.font.get_glyph(c) {
|
if c == ' ' {
|
||||||
total_width += glyph.width + self.font.letter_spacing();
|
total_width += self.options.word_spacing;
|
||||||
|
tokens.push(GlyphToken::Space(self.options.word_spacing));
|
||||||
|
} else if let Some(glyph) = self.font.get_glyph(c) {
|
||||||
|
total_width += glyph.width + letter_spacing;
|
||||||
max_height = max_height.max(glyph.height);
|
max_height = max_height.max(glyph.height);
|
||||||
glyphs_to_render.push(glyph);
|
tokens.push(GlyphToken::Char(glyph));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
(tokens, total_width, max_height)
|
||||||
|
}
|
||||||
|
|
||||||
let padding = if self.options.generate_outline { 2 } else { 0 };
|
fn compute_layout(&self, text_width: u32, max_height: u32) -> (u32, u32, u32, u32) {
|
||||||
|
let outline_pad = if self.options.generate_outline { 1u32 } else { 0 };
|
||||||
|
let (sdx, sdy) = self.options.shadow_offset;
|
||||||
|
let shadow_x_extra = if self.options.generate_shadow { sdx.max(0) as u32 } else { 0 };
|
||||||
|
let shadow_y_extra = if self.options.generate_shadow { (-sdy).max(0) as u32 } else { 0 };
|
||||||
|
|
||||||
let mut grid = VoxelGrid::new(
|
let grid_w = text_width + outline_pad * 2 + shadow_x_extra;
|
||||||
total_width + padding,
|
let grid_h = max_height + outline_pad * 2 + shadow_y_extra;
|
||||||
max_height + padding,
|
let base_x = outline_pad;
|
||||||
self.options.extrusion_depth,
|
let base_y = outline_pad + shadow_y_extra;
|
||||||
);
|
|
||||||
|
|
||||||
let mut current_x = if self.options.generate_outline { 1 } else { 0 };
|
(base_x, base_y, grid_w, grid_h)
|
||||||
let base_y = if self.options.generate_outline { 1 } else { 0 };
|
}
|
||||||
|
|
||||||
for glyph in glyphs_to_render {
|
fn place_glyphs(&self, grid: &mut VoxelGrid, tokens: &[GlyphToken], base_x: u32, base_y: u32) {
|
||||||
for gy in 0..glyph.height {
|
let letter_spacing = self.effective_letter_spacing();
|
||||||
for gx in 0..glyph.width {
|
let mut current_x = base_x;
|
||||||
let glyph_index = (gy * glyph.width + gx) as usize;
|
for token in tokens {
|
||||||
|
match token {
|
||||||
|
GlyphToken::Space(w) => current_x += w,
|
||||||
|
GlyphToken::Char(glyph) => {
|
||||||
|
for gy in 0..glyph.height {
|
||||||
|
for gx in 0..glyph.width {
|
||||||
|
if glyph.data[(gy * glyph.width + gx) as usize] {
|
||||||
|
let wx = current_x + gx;
|
||||||
|
let wy = base_y + (glyph.height - 1 - gy); // flip vertically
|
||||||
|
for z in 0..self.options.extrusion_depth {
|
||||||
|
if let Err(e) = grid.set(wx, wy, z, VoxelType::Body) {
|
||||||
|
tracing::warn!("failed to set voxel: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current_x += glyph.width + letter_spacing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if glyph.data[glyph_index] {
|
fn apply_shadow(&self, grid: &mut VoxelGrid) {
|
||||||
let world_x = current_x + gx;
|
let (dx, dy) = self.options.shadow_offset;
|
||||||
let world_y = base_y + (glyph.height - 1 - gy); // Flip vertically
|
let (w, h, d) = (grid.width, grid.height, grid.depth);
|
||||||
|
let body_positions: Vec<(u32, u32, u32)> = (0..w)
|
||||||
|
.flat_map(|x| (0..h).flat_map(move |y| (0..d).map(move |z| (x, y, z))))
|
||||||
|
.filter(|&(x, y, z)| grid.get(x, y, z) == Some(VoxelType::Body))
|
||||||
|
.collect();
|
||||||
|
|
||||||
for z in 0..self.options.extrusion_depth {
|
for (x, y, z) in body_positions {
|
||||||
if let Err(e) = grid.set(world_x, world_y, z, VoxelType::Body) {
|
let sx = x as i32 + dx;
|
||||||
tracing::warn!("failed to set voxel: {}", e);
|
let sy = y as i32 + dy;
|
||||||
|
if sx >= 0 && sy >= 0 {
|
||||||
|
let (sx, sy) = (sx as u32, sy as u32);
|
||||||
|
if grid.get(sx, sy, z).is_none() {
|
||||||
|
if let Err(e) = grid.set(sx, sy, z, VoxelType::Shadow) {
|
||||||
|
tracing::warn!("failed to set shadow voxel: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_outline(&self, grid: &mut VoxelGrid) {
|
||||||
|
let (w, h, d) = (grid.width, grid.height, grid.depth);
|
||||||
|
let body_xy: Vec<(u32, u32)> = (0..w)
|
||||||
|
.flat_map(|x| (0..h).map(move |y| (x, y)))
|
||||||
|
.filter(|&(x, y)| (0..d).any(|z| grid.get(x, y, z) == Some(VoxelType::Body)))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for (x, y) in body_xy {
|
||||||
|
for (ox, oy) in Self::neighbor_offsets(&self.options.outline_mode) {
|
||||||
|
let nx = x as i32 + ox;
|
||||||
|
let ny = y as i32 + oy;
|
||||||
|
if nx >= 0 && ny >= 0 {
|
||||||
|
let (nx, ny) = (nx as u32, ny as u32);
|
||||||
|
for z in 0..d {
|
||||||
|
if grid.get(nx, ny, z) != Some(VoxelType::Body) {
|
||||||
|
if let Err(e) = grid.set(nx, ny, z, VoxelType::Outline) {
|
||||||
|
tracing::warn!("failed to set outline voxel: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
current_x += glyph.width + self.font.letter_spacing();
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: Add outline generation logic here if self.options.generate_outline is true.
|
fn neighbor_offsets(mode: &OutlineMode) -> &'static [(i32, i32)] {
|
||||||
|
match mode {
|
||||||
grid
|
OutlineMode::FourConnected => &[(-1, 0), (1, 0), (0, -1), (0, 1)],
|
||||||
|
OutlineMode::EightConnected => &[
|
||||||
|
(-1, -1), (0, -1), (1, -1),
|
||||||
|
(-1, 0), (1, 0),
|
||||||
|
(-1, 1), (0, 1), (1, 1),
|
||||||
|
],
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ mod grid;
|
|||||||
mod models;
|
mod models;
|
||||||
mod palette;
|
mod palette;
|
||||||
|
|
||||||
pub use engine::{GenerationOptions, TextBuilder};
|
pub use engine::{GenerationOptions, OutlineMode, TextBuilder};
|
||||||
pub use error::{FontError, VoxelError};
|
pub use error::{FontError, VoxelError};
|
||||||
pub use font::FontProvider;
|
pub use font::FontProvider;
|
||||||
pub use fonts::ttf_font::TtfFont;
|
pub use fonts::ttf_font::TtfFont;
|
||||||
|
|||||||
Reference in New Issue
Block a user