Another project
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}