diff --git a/crates/voltex_editor/src/glyph_cache.rs b/crates/voltex_editor/src/glyph_cache.rs new file mode 100644 index 0000000..58077b0 --- /dev/null +++ b/crates/voltex_editor/src/glyph_cache.rs @@ -0,0 +1,197 @@ +use std::collections::HashMap; + +pub const ATLAS_SIZE: u32 = 1024; + +#[derive(Clone, Debug)] +pub struct GlyphInfo { + pub uv: [f32; 4], // u0, v0, u1, v1 + pub width: f32, + pub height: f32, + pub advance: f32, + pub bearing_x: f32, + pub bearing_y: f32, +} + +pub struct GlyphCache { + pub atlas_data: Vec, + pub atlas_width: u32, + pub atlas_height: u32, + glyphs: HashMap, + cursor_x: u32, + cursor_y: u32, + row_height: u32, + pub dirty: bool, +} + +impl GlyphCache { + pub fn new(width: u32, height: u32) -> Self { + GlyphCache { + atlas_data: vec![0u8; (width * height) as usize], + atlas_width: width, + atlas_height: height, + glyphs: HashMap::new(), + cursor_x: 0, + cursor_y: 0, + row_height: 0, + dirty: false, + } + } + + pub fn get(&self, ch: char) -> Option<&GlyphInfo> { + self.glyphs.get(&ch) + } + + /// Insert a rasterized glyph bitmap into the atlas. + /// Returns reference to the cached GlyphInfo. + pub fn insert( + &mut self, + ch: char, + bitmap: &[u8], + bmp_w: u32, + bmp_h: u32, + advance: f32, + bearing_x: f32, + bearing_y: f32, + ) -> &GlyphInfo { + // Handle zero-size glyphs (e.g., space) + if bmp_w == 0 || bmp_h == 0 { + self.glyphs.insert(ch, GlyphInfo { + uv: [0.0, 0.0, 0.0, 0.0], + width: 0.0, + height: 0.0, + advance, + bearing_x, + bearing_y, + }); + return self.glyphs.get(&ch).unwrap(); + } + + // Check if we need to wrap to next row + if self.cursor_x + bmp_w > self.atlas_width { + self.cursor_y += self.row_height + 1; // +1 pixel gap + self.cursor_x = 0; + self.row_height = 0; + } + + // Check if atlas is full (would overflow vertically) + if self.cursor_y + bmp_h > self.atlas_height { + // Atlas full — insert with zero UV (glyph won't render but won't crash) + self.glyphs.insert(ch, GlyphInfo { + uv: [0.0, 0.0, 0.0, 0.0], + width: bmp_w as f32, + height: bmp_h as f32, + advance, + bearing_x, + bearing_y, + }); + return self.glyphs.get(&ch).unwrap(); + } + + // Copy bitmap into atlas + for row in 0..bmp_h { + let src_start = (row * bmp_w) as usize; + let src_end = src_start + bmp_w as usize; + let dst_y = self.cursor_y + row; + let dst_x = self.cursor_x; + let dst_start = (dst_y * self.atlas_width + dst_x) as usize; + if src_end <= bitmap.len() && dst_start + bmp_w as usize <= self.atlas_data.len() { + self.atlas_data[dst_start..dst_start + bmp_w as usize] + .copy_from_slice(&bitmap[src_start..src_end]); + } + } + + // Calculate UV coordinates + let u0 = self.cursor_x as f32 / self.atlas_width as f32; + let v0 = self.cursor_y as f32 / self.atlas_height as f32; + let u1 = (self.cursor_x + bmp_w) as f32 / self.atlas_width as f32; + let v1 = (self.cursor_y + bmp_h) as f32 / self.atlas_height as f32; + + let info = GlyphInfo { + uv: [u0, v0, u1, v1], + width: bmp_w as f32, + height: bmp_h as f32, + advance, + bearing_x, + bearing_y, + }; + + // Advance cursor + self.cursor_x += bmp_w + 1; // +1 pixel gap + if bmp_h > self.row_height { + self.row_height = bmp_h; + } + + self.dirty = true; + self.glyphs.insert(ch, info); + self.glyphs.get(&ch).unwrap() + } + + pub fn clear_dirty(&mut self) { + self.dirty = false; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_insert_and_get() { + let mut cache = GlyphCache::new(256, 256); + let bitmap = vec![255u8; 10 * 12]; // 10x12 glyph + cache.insert('A', &bitmap, 10, 12, 11.0, 0.5, 10.0); + let info = cache.get('A'); + assert!(info.is_some()); + let info = info.unwrap(); + assert!((info.width - 10.0).abs() < 0.1); + assert!((info.advance - 11.0).abs() < 0.1); + assert!(info.uv[0] >= 0.0 && info.uv[2] <= 1.0); + } + + #[test] + fn test_cache_hit() { + let mut cache = GlyphCache::new(256, 256); + let bitmap = vec![255u8; 8 * 8]; + cache.insert('B', &bitmap, 8, 8, 9.0, 0.0, 8.0); + cache.clear_dirty(); + // Second access should be cache hit (no dirty) + let _info = cache.get('B').unwrap(); + assert!(!cache.dirty); + } + + #[test] + fn test_row_wrap() { + let mut cache = GlyphCache::new(64, 64); + let bitmap = vec![255u8; 20 * 10]; + // Insert 4 glyphs of width 20 in a 64-wide atlas + // 3 fit in first row (20+1 + 20+1 + 20 = 62), 4th wraps + cache.insert('A', &bitmap, 20, 10, 21.0, 0.0, 10.0); + cache.insert('B', &bitmap, 20, 10, 21.0, 0.0, 10.0); + cache.insert('C', &bitmap, 20, 10, 21.0, 0.0, 10.0); + cache.insert('D', &bitmap, 20, 10, 21.0, 0.0, 10.0); + let d = cache.get('D').unwrap(); + // D should be on a different row (v0 > 0) + assert!(d.uv[1] > 0.0, "D should be on second row, v0={}", d.uv[1]); + } + + #[test] + fn test_uv_range() { + let mut cache = GlyphCache::new(256, 256); + let bitmap = vec![128u8; 15 * 20]; + cache.insert('X', &bitmap, 15, 20, 16.0, 1.0, 18.0); + let info = cache.get('X').unwrap(); + assert!(info.uv[0] >= 0.0 && info.uv[0] < 1.0); + assert!(info.uv[1] >= 0.0 && info.uv[1] < 1.0); + assert!(info.uv[2] > info.uv[0] && info.uv[2] <= 1.0); + assert!(info.uv[3] > info.uv[1] && info.uv[3] <= 1.0); + } + + #[test] + fn test_zero_size_glyph() { + let mut cache = GlyphCache::new(256, 256); + cache.insert(' ', &[], 0, 0, 5.0, 0.0, 0.0); + let info = cache.get(' ').unwrap(); + assert!((info.advance - 5.0).abs() < 0.1); + assert!((info.width - 0.0).abs() < 0.1); + } +} diff --git a/crates/voltex_editor/src/lib.rs b/crates/voltex_editor/src/lib.rs index f892493..f12304d 100644 --- a/crates/voltex_editor/src/lib.rs +++ b/crates/voltex_editor/src/lib.rs @@ -28,3 +28,4 @@ pub use asset_browser::{AssetBrowser, asset_browser_panel}; pub mod ttf_parser; pub mod rasterizer; +pub mod glyph_cache;