Another project
0

Configure Feed

Select the types of activity you want to include in your feed.

at main 16 kB View raw
1use std::collections::HashMap; 2 3use bone_text::{FontFace, ShapedLine, ShapedText, Shaper, TessellatedOutline, load_font}; 4use bone_types::SketchDimensionId; 5use lyon_tessellation::FillTessellator; 6use swash::{FontRef, scale::ScaleContext, zeno::Point as ZenoPoint}; 7use wgpu::util::DeviceExt; 8 9use crate::RenderTargets; 10use crate::camera::Camera2; 11use crate::gpu::{Gpu, PICK_FORMAT}; 12use crate::pipelines::text_common::{shape_line, tessellate_at}; 13use crate::scene::SketchScene; 14use crate::snapshot::{Style, TextStyle}; 15 16#[repr(C, align(16))] 17#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] 18struct TextUniform { 19 clip_from_world: [f32; 16], 20 text_color: [f32; 4], 21 pixels_per_mm: f32, 22 _pad0: f32, 23 _pad1: f32, 24 _pad2: f32, 25} 26 27const UNIFORM_SIZE: u64 = core::mem::size_of::<TextUniform>() as u64; 28 29#[repr(C)] 30#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] 31struct TextVertex { 32 anchor_mm: [f32; 2], 33 offset_px: [f32; 2], 34 pick_id: u32, 35 _pad: u32, 36} 37 38const VERTEX_STRIDE: u64 = core::mem::size_of::<TextVertex>() as u64; 39 40struct Cached { 41 text: String, 42 size_px: f32, 43 tess: TessellatedOutline, 44} 45 46impl Cached { 47 fn matches(&self, text: &str, size_px: f32) -> bool { 48 self.size_px.to_bits() == size_px.to_bits() && self.text == text 49 } 50} 51 52pub struct TextPipeline { 53 device: wgpu::Device, 54 queue: wgpu::Queue, 55 pipeline: wgpu::RenderPipeline, 56 uniform_buffer: wgpu::Buffer, 57 bind_group: wgpu::BindGroup, 58 font: FontRef<'static>, 59 shaper: Shaper, 60 scale_context: ScaleContext, 61 tessellator: FillTessellator, 62 cache: HashMap<SketchDimensionId, Cached>, 63} 64 65impl TextPipeline { 66 #[must_use] 67 pub fn new(gpu: &Gpu, color_format: wgpu::TextureFormat) -> Self { 68 let device = gpu.device().clone(); 69 let queue = gpu.queue().clone(); 70 let bind_group_layout = create_bind_group_layout(&device); 71 let pipeline = create_pipeline(&device, &bind_group_layout, color_format); 72 let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { 73 label: Some("bone-render:text-uniform"), 74 size: UNIFORM_SIZE, 75 usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, 76 mapped_at_creation: false, 77 }); 78 let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { 79 label: Some("bone-render:text-bg"), 80 layout: &bind_group_layout, 81 entries: &[wgpu::BindGroupEntry { 82 binding: 0, 83 resource: uniform_buffer.as_entire_binding(), 84 }], 85 }); 86 Self { 87 device, 88 queue, 89 pipeline, 90 uniform_buffer, 91 bind_group, 92 font: load_font(FontFace::Mono), 93 shaper: Shaper::new(), 94 scale_context: ScaleContext::new(), 95 tessellator: FillTessellator::new(), 96 cache: HashMap::new(), 97 } 98 } 99 100 pub fn prepare(&mut self, scene: &SketchScene, style: &Style) { 101 let size_px = style.text().font_size_px(); 102 let mut prev = std::mem::take(&mut self.cache); 103 self.cache = scene.dimensions().iter().fold( 104 HashMap::with_capacity(scene.dimensions().len()), 105 |mut acc, d| { 106 let id = d.dimension(); 107 let text = d.text(); 108 let entry = match prev.remove(&id) { 109 Some(c) if c.matches(text, size_px) => c, 110 _ => Cached { 111 text: text.to_owned(), 112 size_px, 113 tess: tessellate( 114 text, 115 size_px, 116 &self.font, 117 &mut self.shaper, 118 &mut self.scale_context, 119 &mut self.tessellator, 120 ), 121 }, 122 }; 123 acc.insert(id, entry); 124 acc 125 }, 126 ); 127 } 128 129 pub fn draw( 130 &self, 131 encoder: &mut wgpu::CommandEncoder, 132 targets: RenderTargets<'_>, 133 camera: Camera2, 134 style: &Style, 135 scene: &SketchScene, 136 ) { 137 let (vertices, indices) = assemble_geometry(scene, &self.cache); 138 if indices.is_empty() { 139 return; 140 } 141 let uniform = build_uniform(camera, style.text()); 142 self.queue 143 .write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&uniform)); 144 let vertex_buffer = self 145 .device 146 .create_buffer_init(&wgpu::util::BufferInitDescriptor { 147 label: Some("bone-render:text-vertices"), 148 contents: bytemuck::cast_slice(&vertices), 149 usage: wgpu::BufferUsages::VERTEX, 150 }); 151 let index_buffer = self 152 .device 153 .create_buffer_init(&wgpu::util::BufferInitDescriptor { 154 label: Some("bone-render:text-indices"), 155 contents: bytemuck::cast_slice(&indices), 156 usage: wgpu::BufferUsages::INDEX, 157 }); 158 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { 159 label: Some("bone-render:text-pass"), 160 color_attachments: &[ 161 Some(wgpu::RenderPassColorAttachment { 162 view: targets.color, 163 resolve_target: None, 164 depth_slice: None, 165 ops: wgpu::Operations { 166 load: wgpu::LoadOp::Load, 167 store: wgpu::StoreOp::Store, 168 }, 169 }), 170 Some(wgpu::RenderPassColorAttachment { 171 view: targets.pick, 172 resolve_target: None, 173 depth_slice: None, 174 ops: wgpu::Operations { 175 load: wgpu::LoadOp::Load, 176 store: wgpu::StoreOp::Store, 177 }, 178 }), 179 ], 180 depth_stencil_attachment: None, 181 timestamp_writes: None, 182 occlusion_query_set: None, 183 multiview_mask: None, 184 }); 185 pass.set_pipeline(&self.pipeline); 186 pass.set_bind_group(0, &self.bind_group, &[]); 187 pass.set_vertex_buffer(0, vertex_buffer.slice(..)); 188 pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint32); 189 let len = indices.len(); 190 let Ok(count) = u32::try_from(len) else { 191 panic!("text index count {len} exceeds u32::MAX"); 192 }; 193 pass.draw_indexed(0..count, 0, 0..1); 194 } 195 196 #[must_use] 197 pub fn cache_len(&self) -> usize { 198 self.cache.len() 199 } 200} 201 202impl core::fmt::Debug for TextPipeline { 203 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 204 f.debug_struct("TextPipeline") 205 .field("cache_len", &self.cache.len()) 206 .finish_non_exhaustive() 207 } 208} 209 210const VERTEX_ATTRS: [wgpu::VertexAttribute; 3] = wgpu::vertex_attr_array![ 211 0 => Float32x2, 212 1 => Float32x2, 213 2 => Uint32, 214]; 215 216fn create_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { 217 device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { 218 label: Some("bone-render:text-bgl"), 219 entries: &[wgpu::BindGroupLayoutEntry { 220 binding: 0, 221 visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, 222 ty: wgpu::BindingType::Buffer { 223 ty: wgpu::BufferBindingType::Uniform, 224 has_dynamic_offset: false, 225 min_binding_size: wgpu::BufferSize::new(UNIFORM_SIZE), 226 }, 227 count: None, 228 }], 229 }) 230} 231 232fn create_pipeline( 233 device: &wgpu::Device, 234 bind_group_layout: &wgpu::BindGroupLayout, 235 color_format: wgpu::TextureFormat, 236) -> wgpu::RenderPipeline { 237 let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { 238 label: Some("bone-render:text-shader"), 239 source: wgpu::ShaderSource::Wgsl(include_str!("text.wgsl").into()), 240 }); 241 let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { 242 label: Some("bone-render:text-layout"), 243 bind_group_layouts: &[Some(bind_group_layout)], 244 immediate_size: 0, 245 }); 246 device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { 247 label: Some("bone-render:text-pipeline"), 248 layout: Some(&pipeline_layout), 249 vertex: wgpu::VertexState { 250 module: &shader, 251 entry_point: Some("vs"), 252 compilation_options: wgpu::PipelineCompilationOptions::default(), 253 buffers: &[wgpu::VertexBufferLayout { 254 array_stride: VERTEX_STRIDE, 255 step_mode: wgpu::VertexStepMode::Vertex, 256 attributes: &VERTEX_ATTRS, 257 }], 258 }, 259 fragment: Some(wgpu::FragmentState { 260 module: &shader, 261 entry_point: Some("fs"), 262 compilation_options: wgpu::PipelineCompilationOptions::default(), 263 targets: &[ 264 Some(wgpu::ColorTargetState { 265 format: color_format, 266 blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING), 267 write_mask: wgpu::ColorWrites::ALL, 268 }), 269 Some(wgpu::ColorTargetState { 270 format: PICK_FORMAT, 271 blend: None, 272 write_mask: wgpu::ColorWrites::ALL, 273 }), 274 ], 275 }), 276 primitive: wgpu::PrimitiveState { 277 topology: wgpu::PrimitiveTopology::TriangleList, 278 strip_index_format: None, 279 front_face: wgpu::FrontFace::Ccw, 280 cull_mode: None, 281 polygon_mode: wgpu::PolygonMode::Fill, 282 conservative: false, 283 unclipped_depth: false, 284 }, 285 depth_stencil: None, 286 multisample: wgpu::MultisampleState::default(), 287 multiview_mask: None, 288 cache: None, 289 }) 290} 291 292#[allow( 293 clippy::cast_possible_truncation, 294 reason = "mm coordinates and pixel offsets fit f32 mantissa at CAD scales" 295)] 296fn build_uniform(camera: Camera2, text: TextStyle) -> TextUniform { 297 TextUniform { 298 clip_from_world: camera.clip_from_world_mm(), 299 text_color: text.color().to_rgba_array(), 300 pixels_per_mm: camera.zoom().value() as f32, 301 _pad0: 0.0, 302 _pad1: 0.0, 303 _pad2: 0.0, 304 } 305} 306 307#[allow( 308 clippy::cast_possible_truncation, 309 reason = "mm coordinates and pixel offsets fit f32 mantissa at CAD scales" 310)] 311fn assemble_geometry( 312 scene: &SketchScene, 313 cache: &HashMap<SketchDimensionId, Cached>, 314) -> (Vec<TextVertex>, Vec<u32>) { 315 scene 316 .dimensions() 317 .iter() 318 .fold((Vec::new(), Vec::new()), |(mut vs, mut is), d| { 319 let Some(cached) = cache.get(&d.dimension()) else { 320 return (vs, is); 321 }; 322 let (ax, ay) = d.anchor_mm().coords_mm(); 323 let anchor = [ax as f32, ay as f32]; 324 let pick_id = d.pick().raw(); 325 let Ok(base) = u32::try_from(vs.len()) else { 326 panic!("text vertex count exceeds u32::MAX"); 327 }; 328 vs.extend(cached.tess.vertices_px.iter().map(|offset| TextVertex { 329 anchor_mm: anchor, 330 offset_px: *offset, 331 pick_id, 332 _pad: 0, 333 })); 334 is.extend(cached.tess.indices.iter().map(|idx| base + idx)); 335 (vs, is) 336 }) 337} 338 339fn tessellate( 340 text: &str, 341 size_px: f32, 342 font: &FontRef<'_>, 343 shaper: &mut Shaper, 344 scale_ctx: &mut ScaleContext, 345 fill: &mut FillTessellator, 346) -> TessellatedOutline { 347 let layout = shape_line(text, size_px, FontFace::Mono, shaper); 348 let metrics = font.metrics(&[]).scale(size_px); 349 let center = label_center(&layout, metrics.cap_height); 350 tessellate_at(&layout, font, scale_ctx, fill, center) 351} 352 353fn label_center(layout: &ShapedText, cap_height: f32) -> ZenoPoint { 354 let visible_advance = layout 355 .lines 356 .first() 357 .map_or(0.0, ShapedLine::visible_advance_px); 358 ZenoPoint::new(-visible_advance * 0.5, -cap_height * 0.5) 359} 360 361#[cfg(test)] 362mod tests { 363 use super::{ 364 FontFace, ShapedText, Shaper, TessellatedOutline, label_center, load_font, shape_line, 365 tessellate, 366 }; 367 use lyon_tessellation::FillTessellator; 368 use swash::scale::ScaleContext; 369 370 const DIM_FONT_SIZE_PX: f32 = 14.0; 371 372 fn shape_only(text: &str) -> ShapedText { 373 let mut shaper = Shaper::new(); 374 shape_line(text, DIM_FONT_SIZE_PX, FontFace::Mono, &mut shaper) 375 } 376 377 fn run_tessellate(text: &str) -> TessellatedOutline { 378 let font = load_font(FontFace::Mono); 379 let mut shaper = Shaper::new(); 380 let mut scale_ctx = ScaleContext::new(); 381 let mut fill = FillTessellator::new(); 382 tessellate( 383 text, 384 DIM_FONT_SIZE_PX, 385 &font, 386 &mut shaper, 387 &mut scale_ctx, 388 &mut fill, 389 ) 390 } 391 392 #[test] 393 fn arabic_dimension_label_tessellates_to_visible_geometry() { 394 let text = "\u{0627}\u{0644}\u{0637}\u{0648}\u{0644}"; 395 let shaped = shape_only(text); 396 let rtl_glyph_count: usize = shaped 397 .lines 398 .iter() 399 .flat_map(|line| line.runs.iter()) 400 .filter(|run| run.is_rtl) 401 .map(|run| run.glyphs.len()) 402 .sum(); 403 assert_eq!( 404 rtl_glyph_count, 405 text.chars().count(), 406 "ar shaping must emit one rtl glyph per codepoint, got {} for {} chars", 407 rtl_glyph_count, 408 text.chars().count(), 409 ); 410 let result = run_tessellate(text); 411 assert!( 412 !result.is_empty(), 413 "complex-script dim label must produce geometry", 414 ); 415 assert!(result.indices.len().is_multiple_of(3)); 416 } 417 418 #[test] 419 fn bidi_dimension_label_tessellates_to_visible_geometry() { 420 let text = "R 5.00 \u{0645}\u{0645}"; 421 let shaped = shape_only(text); 422 let runs: Vec<_> = shaped.lines.iter().flat_map(|l| l.runs.iter()).collect(); 423 assert!( 424 runs.iter().any(|run| !run.is_rtl), 425 "bidi dim label must retain its ltr ascii prefix", 426 ); 427 assert!( 428 runs.iter().any(|run| run.is_rtl), 429 "bidi dim label must shape its rtl arabic suffix", 430 ); 431 let result = run_tessellate(text); 432 assert!( 433 !result.is_empty(), 434 "mixed-direction dim label must produce geometry", 435 ); 436 assert!(result.indices.len().is_multiple_of(3)); 437 } 438 439 #[test] 440 fn arabic_label_center_offsets_by_half_visible_advance() { 441 let mut shaper = Shaper::new(); 442 let font = load_font(FontFace::Mono); 443 let layout = shape_line( 444 "\u{0627}\u{0644}\u{0637}\u{0648}\u{0644}", 445 DIM_FONT_SIZE_PX, 446 FontFace::Mono, 447 &mut shaper, 448 ); 449 let metrics = font.metrics(&[]).scale(DIM_FONT_SIZE_PX); 450 let center = label_center(&layout, metrics.cap_height); 451 assert!( 452 center.x < 0.0, 453 "label_center must shift the anchor by half the visible advance, got x={}", 454 center.x, 455 ); 456 } 457}