use ab_glyph::{Font, FontVec, PxScale, ScaleFont}; use glam::Vec3; use crate::paths; use super::text_pipeline::TextVertex; const ATLAS_COLS: u32 = 16; const ATLAS_ROWS: u32 = 8; const FIRST_CHAR: u32 = 32; const LAST_CHAR: u32 = 126; const NUM_GLYPHS: u32 = LAST_CHAR - FIRST_CHAR + 1; const FONT_PX: f32 = 124.0; struct GlyphMetrics { uv_min: [f32; 2], uv_max: [f32; 2], } pub struct FontAtlas { pub texture_view: wgpu::TextureView, pub sampler: wgpu::Sampler, glyphs: Vec, pub cell_w: f32, pub cell_h: f32, } impl FontAtlas { pub fn load(device: &wgpu::Device, queue: &wgpu::Queue) -> Self { let path = paths::fonts::departure_mono(); let data = std::fs::read(&path).unwrap_or_else(|_| panic!("Failed to read font: {path}")); let font = FontVec::try_from_vec(data).expect("Failed to parse font"); let scale = PxScale::from(FONT_PX); let scaled = font.as_scaled(scale); let ascent = scaled.ascent(); let descent = scaled.descent(); let cell_h = (ascent - descent).ceil() as u32; let cell_w = scaled.h_advance(font.glyph_id(' ')).ceil() as u32; let atlas_w = ATLAS_COLS * cell_w; let atlas_h = ATLAS_ROWS * cell_h; let mut pixels = vec![0u8; (atlas_w * atlas_h) as usize]; let mut glyphs = Vec::with_capacity(NUM_GLYPHS as usize); for idx in 0..NUM_GLYPHS { let ch = char::from_u32(FIRST_CHAR + idx).unwrap_or(' '); let col = idx % ATLAS_COLS; let row = idx / ATLAS_COLS; let cell_x = col * cell_w; let cell_y = row * cell_h; glyphs.push(GlyphMetrics { uv_min: [ cell_x as f32 / atlas_w as f32, cell_y as f32 / atlas_h as f32, ], uv_max: [ (cell_x + cell_w) as f32 / atlas_w as f32, (cell_y + cell_h) as f32 / atlas_h as f32, ], }); let glyph_id = font.glyph_id(ch); let glyph = glyph_id.with_scale_and_position( scale, ab_glyph::point(cell_x as f32, cell_y as f32 + ascent), ); if let Some(outlined) = font.outline_glyph(glyph) { let bounds = outlined.px_bounds(); outlined.draw(|x, y, coverage| { let abs_x = bounds.min.x.floor() as i32 + x as i32; let abs_y = bounds.min.y.floor() as i32 + y as i32; if abs_x >= 0 && abs_y >= 0 { let ax = abs_x as u32; let ay = abs_y as u32; if ax < atlas_w && ay < atlas_h { let i = (ay * atlas_w + ax) as usize; let v = (coverage * 255.0 + 0.5) as u8; pixels[i] = pixels[i].max(v); } } }); } } let extent = wgpu::Extent3d { width: atlas_w, height: atlas_h, depth_or_array_layers: 1, }; let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("Font Atlas"), size: extent, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: wgpu::TextureFormat::R8Unorm, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, view_formats: &[], }); queue.write_texture( wgpu::TexelCopyTextureInfo { texture: &texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, &pixels, wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(atlas_w), rows_per_image: Some(atlas_h), }, extent, ); let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); let sampler = device.create_sampler(&wgpu::SamplerDescriptor { label: Some("Font Atlas Sampler"), address_mode_u: wgpu::AddressMode::ClampToEdge, address_mode_v: wgpu::AddressMode::ClampToEdge, mag_filter: wgpu::FilterMode::Linear, min_filter: wgpu::FilterMode::Linear, ..Default::default() }); Self { texture_view, sampler, glyphs, cell_w: cell_w as f32, cell_h: cell_h as f32, } } pub fn aspect(&self) -> f32 { self.cell_w / self.cell_h } /// Build billboard-space text quads for a single dialog bubble. /// /// `anchor` – world-space centre of the bubble body /// `right`/`up` – billboard orientation vectors /// `inner_half_w` – half-width of the text area in world units /// `inner_top_y` – y-offset (up-axis) of the top edge of the text area /// `char_world_h` – world-space height of one character cell /// `line_spacing` – extra vertical gap between lines in world units /// Returns `(n_lines, max_chars_in_any_line)` for the given text wrapped at `inner_half_w`. pub fn measure_text(&self, text: &str, char_world_h: f32, inner_half_w: f32) -> (usize, usize) { let char_w = char_world_h * self.aspect(); let chars_per_line = ((inner_half_w * 2.0) / char_w).floor() as usize; if chars_per_line == 0 { return (1, 0); } let lines = word_wrap(text, chars_per_line); let n_lines = lines.len().max(1); let max_chars = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0); (n_lines, max_chars) } pub fn build_bubble_text( &self, text: &str, anchor: Vec3, right: Vec3, up: Vec3, inner_half_w: f32, inner_top_y: f32, char_world_h: f32, line_spacing: f32, ) -> Vec { let char_w = char_world_h * self.aspect(); let chars_per_line = ((inner_half_w * 2.0) / char_w).floor() as usize; if chars_per_line == 0 { return Vec::new(); } let lines = word_wrap(text, chars_per_line); let half_char_h = char_world_h * 0.5; let half_char_w = char_w * 0.5; let text_left = -inner_half_w; let mut verts = Vec::new(); for (row, line) in lines.iter().enumerate() { let y = inner_top_y - half_char_h - row as f32 * (char_world_h + line_spacing); if y < -inner_top_y + half_char_h { break; } for (col, ch) in line.chars().enumerate() { if ch == ' ' { continue; } let code = ch as u32; if code < FIRST_CHAR || code > LAST_CHAR { continue; } let g = &self.glyphs[(code - FIRST_CHAR) as usize]; let x = text_left + half_char_w + col as f32 * char_w; let centre = anchor + right * x + up * y; let tl = centre - right * half_char_w + up * half_char_h; let tr = centre + right * half_char_w + up * half_char_h; let br = centre + right * half_char_w - up * half_char_h; let bl = centre - right * half_char_w - up * half_char_h; verts.extend_from_slice(&[ TextVertex { position: tl.to_array(), uv: g.uv_min, }, TextVertex { position: tr.to_array(), uv: [g.uv_max[0], g.uv_min[1]], }, TextVertex { position: br.to_array(), uv: g.uv_max, }, TextVertex { position: bl.to_array(), uv: [g.uv_min[0], g.uv_max[1]], }, ]); } } verts } } fn word_wrap(text: &str, chars_per_line: usize) -> Vec { let mut lines: Vec = Vec::new(); let mut current = String::new(); for word in text.split_whitespace() { if current.is_empty() { current.push_str(word); } else if current.len() + 1 + word.len() <= chars_per_line { current.push(' '); current.push_str(word); } else { lines.push(current.clone()); current = word.to_string(); } } if !current.is_empty() { lines.push(current); } lines }