diff --git a/crates/bin/src/cli.rs b/crates/bin/src/cli.rs index 31c6c37..7d7c1cb 100644 --- a/crates/bin/src/cli.rs +++ b/crates/bin/src/cli.rs @@ -2,7 +2,7 @@ use std::{fs, path::PathBuf}; use clap::Parser; use exporters::McFunctionExporter; -use lib::{BlockPalette, StructureExporter, TextBuilder, TtfFont}; +use lib::{BlockPalette, OutlineMode, StructureExporter, TextBuilder, TtfFont}; #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] @@ -38,6 +38,30 @@ struct Cli { /// Height of the text in blocks #[arg(long, default_value_t = 16.0)] 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, + + /// Width in blocks inserted for a space character + #[arg(long, default_value_t = 4)] + word_spacing: u32, } 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))?; 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); tracing::info!( diff --git a/crates/lib/src/engine.rs b/crates/lib/src/engine.rs index 674c6b2..f334b7d 100644 --- a/crates/lib/src/engine.rs +++ b/crates/lib/src/engine.rs @@ -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 extrusion_depth: u32, pub generate_outline: bool, - // Future options can be added here, such as: - // shadow, scale, italics, etc. + pub outline_mode: OutlineMode, + pub generate_shadow: bool, + pub shadow_offset: (i32, i32), + /// Overrides the font's default letter spacing (None = use font default). + pub letter_spacing: Option, + /// Width in blocks inserted for a space character. + pub word_spacing: u32, } impl Default for GenerationOptions { @@ -12,6 +27,11 @@ impl Default for GenerationOptions { Self { extrusion_depth: 1, 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 } + 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 { + 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, u32, u32) { + let letter_spacing = self.effective_letter_spacing(); let mut total_width = 0; let mut max_height = 0; - let mut glyphs_to_render = Vec::new(); - + let mut tokens = Vec::new(); for c in text.chars() { - if let Some(glyph) = self.font.get_glyph(c) { - total_width += glyph.width + self.font.letter_spacing(); + if c == ' ' { + 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); - 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( - total_width + padding, - max_height + padding, - self.options.extrusion_depth, - ); + let grid_w = text_width + outline_pad * 2 + shadow_x_extra; + let grid_h = max_height + outline_pad * 2 + shadow_y_extra; + let base_x = outline_pad; + let base_y = outline_pad + shadow_y_extra; - let mut current_x = if self.options.generate_outline { 1 } else { 0 }; - let base_y = if self.options.generate_outline { 1 } else { 0 }; + (base_x, base_y, grid_w, grid_h) + } - for glyph in glyphs_to_render { - for gy in 0..glyph.height { - for gx in 0..glyph.width { - let glyph_index = (gy * glyph.width + gx) as usize; + fn place_glyphs(&self, grid: &mut VoxelGrid, tokens: &[GlyphToken], base_x: u32, base_y: u32) { + let letter_spacing = self.effective_letter_spacing(); + let mut current_x = base_x; + 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] { - let world_x = current_x + gx; - let world_y = base_y + (glyph.height - 1 - gy); // Flip vertically + fn apply_shadow(&self, grid: &mut VoxelGrid) { + let (dx, dy) = self.options.shadow_offset; + 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 { - if let Err(e) = grid.set(world_x, world_y, z, VoxelType::Body) { - tracing::warn!("failed to set voxel: {}", e); + for (x, y, z) in body_positions { + let sx = x as i32 + dx; + 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. - - grid + fn neighbor_offsets(mode: &OutlineMode) -> &'static [(i32, i32)] { + match mode { + 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), + ], + } } } diff --git a/crates/lib/src/lib.rs b/crates/lib/src/lib.rs index 0dffd7b..e531707 100644 --- a/crates/lib/src/lib.rs +++ b/crates/lib/src/lib.rs @@ -6,7 +6,7 @@ mod grid; mod models; mod palette; -pub use engine::{GenerationOptions, TextBuilder}; +pub use engine::{GenerationOptions, OutlineMode, TextBuilder}; pub use error::{FontError, VoxelError}; pub use font::FontProvider; pub use fonts::ttf_font::TtfFont;