Another project
1
fork

Configure Feed

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

feat(render): relation glyph pipeline

Lewis: May this revision serve well! <lu5a@proton.me>

+842
crates/bone-render/assets/relation_glyphs.png

This is a binary file and will not be displayed.

+532
crates/bone-render/src/pipelines/glyph.rs
··· 1 + use wgpu::util::DeviceExt; 2 + 3 + use crate::camera::Camera2; 4 + use crate::gpu::{Gpu, PICK_FORMAT}; 5 + use crate::scene::{SceneRelationGlyph, SketchScene}; 6 + use crate::snapshot::{GlyphStyle, Style, decode_png}; 7 + 8 + pub const ATLAS_SIDE: u32 = 96; 9 + pub const TILE_SIDE: u32 = 32; 10 + pub const GRID_COLS: u32 = 3; 11 + pub const GRID_ROWS: u32 = 3; 12 + pub const GLYPH_COUNT: u32 = 9; 13 + 14 + const COMMITTED_ATLAS: &[u8] = include_bytes!("../../assets/relation_glyphs.png"); 15 + 16 + #[repr(C, align(16))] 17 + #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] 18 + struct GlyphUniform { 19 + clip_from_world: [f32; 16], 20 + glyph_color: [f32; 4], 21 + pixels_per_mm: f32, 22 + offset_px: f32, 23 + tile_px: f32, 24 + _pad: f32, 25 + } 26 + 27 + const UNIFORM_SIZE: u64 = core::mem::size_of::<GlyphUniform>() as u64; 28 + 29 + #[repr(C)] 30 + #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] 31 + struct GlyphInstance { 32 + anchor_mm: [f32; 2], 33 + offset_dir: [f32; 2], 34 + pick_id: u32, 35 + tile_index: u32, 36 + } 37 + 38 + const INSTANCE_STRIDE: u64 = core::mem::size_of::<GlyphInstance>() as u64; 39 + 40 + pub struct GlyphPipeline { 41 + device: wgpu::Device, 42 + queue: wgpu::Queue, 43 + pipeline: wgpu::RenderPipeline, 44 + uniform_buffer: wgpu::Buffer, 45 + bind_group: wgpu::BindGroup, 46 + } 47 + 48 + impl GlyphPipeline { 49 + #[must_use] 50 + pub fn new(gpu: &Gpu, color_format: wgpu::TextureFormat) -> Self { 51 + let device = gpu.device().clone(); 52 + let queue = gpu.queue().clone(); 53 + let (atlas_view, sampler) = upload_atlas(&device, &queue); 54 + let bind_group_layout = create_bind_group_layout(&device); 55 + let pipeline = create_pipeline(&device, &bind_group_layout, color_format); 56 + let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { 57 + label: Some("bone-render:glyph-uniform"), 58 + size: UNIFORM_SIZE, 59 + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, 60 + mapped_at_creation: false, 61 + }); 62 + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { 63 + label: Some("bone-render:glyph-bg"), 64 + layout: &bind_group_layout, 65 + entries: &[ 66 + wgpu::BindGroupEntry { 67 + binding: 0, 68 + resource: uniform_buffer.as_entire_binding(), 69 + }, 70 + wgpu::BindGroupEntry { 71 + binding: 1, 72 + resource: wgpu::BindingResource::TextureView(&atlas_view), 73 + }, 74 + wgpu::BindGroupEntry { 75 + binding: 2, 76 + resource: wgpu::BindingResource::Sampler(&sampler), 77 + }, 78 + ], 79 + }); 80 + Self { 81 + device, 82 + queue, 83 + pipeline, 84 + uniform_buffer, 85 + bind_group, 86 + } 87 + } 88 + 89 + pub fn draw( 90 + &self, 91 + encoder: &mut wgpu::CommandEncoder, 92 + color_view: &wgpu::TextureView, 93 + pick_view: &wgpu::TextureView, 94 + camera: Camera2, 95 + style: &Style, 96 + scene: &SketchScene, 97 + ) { 98 + let instances: Vec<GlyphInstance> = scene 99 + .relations() 100 + .iter() 101 + .map(|glyph| build_instance(*glyph)) 102 + .collect(); 103 + if instances.is_empty() { 104 + return; 105 + } 106 + let uniform = build_uniform(camera, style.glyphs()); 107 + self.queue 108 + .write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&uniform)); 109 + let instance_buffer = self 110 + .device 111 + .create_buffer_init(&wgpu::util::BufferInitDescriptor { 112 + label: Some("bone-render:glyph-instances"), 113 + contents: bytemuck::cast_slice(&instances), 114 + usage: wgpu::BufferUsages::VERTEX, 115 + }); 116 + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { 117 + label: Some("bone-render:glyph-pass"), 118 + color_attachments: &[ 119 + Some(wgpu::RenderPassColorAttachment { 120 + view: color_view, 121 + resolve_target: None, 122 + depth_slice: None, 123 + ops: wgpu::Operations { 124 + load: wgpu::LoadOp::Load, 125 + store: wgpu::StoreOp::Store, 126 + }, 127 + }), 128 + Some(wgpu::RenderPassColorAttachment { 129 + view: pick_view, 130 + resolve_target: None, 131 + depth_slice: None, 132 + ops: wgpu::Operations { 133 + load: wgpu::LoadOp::Load, 134 + store: wgpu::StoreOp::Store, 135 + }, 136 + }), 137 + ], 138 + depth_stencil_attachment: None, 139 + timestamp_writes: None, 140 + occlusion_query_set: None, 141 + multiview_mask: None, 142 + }); 143 + pass.set_pipeline(&self.pipeline); 144 + pass.set_bind_group(0, &self.bind_group, &[]); 145 + pass.set_vertex_buffer(0, instance_buffer.slice(..)); 146 + let len = instances.len(); 147 + let Ok(count) = u32::try_from(len) else { 148 + panic!("glyph instance count {len} exceeds u32::MAX"); 149 + }; 150 + pass.draw(0..6, 0..count); 151 + } 152 + } 153 + 154 + impl core::fmt::Debug for GlyphPipeline { 155 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 156 + f.debug_struct("GlyphPipeline").finish_non_exhaustive() 157 + } 158 + } 159 + 160 + const INSTANCE_ATTRS: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![ 161 + 0 => Float32x2, 162 + 1 => Float32x2, 163 + 2 => Uint32, 164 + 3 => Uint32, 165 + ]; 166 + 167 + fn create_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { 168 + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { 169 + label: Some("bone-render:glyph-bgl"), 170 + entries: &[ 171 + wgpu::BindGroupLayoutEntry { 172 + binding: 0, 173 + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, 174 + ty: wgpu::BindingType::Buffer { 175 + ty: wgpu::BufferBindingType::Uniform, 176 + has_dynamic_offset: false, 177 + min_binding_size: wgpu::BufferSize::new(UNIFORM_SIZE), 178 + }, 179 + count: None, 180 + }, 181 + wgpu::BindGroupLayoutEntry { 182 + binding: 1, 183 + visibility: wgpu::ShaderStages::FRAGMENT, 184 + ty: wgpu::BindingType::Texture { 185 + multisampled: false, 186 + view_dimension: wgpu::TextureViewDimension::D2, 187 + sample_type: wgpu::TextureSampleType::Float { filterable: true }, 188 + }, 189 + count: None, 190 + }, 191 + wgpu::BindGroupLayoutEntry { 192 + binding: 2, 193 + visibility: wgpu::ShaderStages::FRAGMENT, 194 + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), 195 + count: None, 196 + }, 197 + ], 198 + }) 199 + } 200 + 201 + fn create_pipeline( 202 + device: &wgpu::Device, 203 + bind_group_layout: &wgpu::BindGroupLayout, 204 + color_format: wgpu::TextureFormat, 205 + ) -> wgpu::RenderPipeline { 206 + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { 207 + label: Some("bone-render:glyph-shader"), 208 + source: wgpu::ShaderSource::Wgsl(include_str!("glyph.wgsl").into()), 209 + }); 210 + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { 211 + label: Some("bone-render:glyph-layout"), 212 + bind_group_layouts: &[Some(bind_group_layout)], 213 + immediate_size: 0, 214 + }); 215 + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { 216 + label: Some("bone-render:glyph-pipeline"), 217 + layout: Some(&pipeline_layout), 218 + vertex: wgpu::VertexState { 219 + module: &shader, 220 + entry_point: Some("vs"), 221 + compilation_options: wgpu::PipelineCompilationOptions::default(), 222 + buffers: &[wgpu::VertexBufferLayout { 223 + array_stride: INSTANCE_STRIDE, 224 + step_mode: wgpu::VertexStepMode::Instance, 225 + attributes: &INSTANCE_ATTRS, 226 + }], 227 + }, 228 + fragment: Some(wgpu::FragmentState { 229 + module: &shader, 230 + entry_point: Some("fs"), 231 + compilation_options: wgpu::PipelineCompilationOptions::default(), 232 + targets: &[ 233 + Some(wgpu::ColorTargetState { 234 + format: color_format, 235 + blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING), 236 + write_mask: wgpu::ColorWrites::ALL, 237 + }), 238 + Some(wgpu::ColorTargetState { 239 + format: PICK_FORMAT, 240 + blend: None, 241 + write_mask: wgpu::ColorWrites::ALL, 242 + }), 243 + ], 244 + }), 245 + primitive: wgpu::PrimitiveState { 246 + topology: wgpu::PrimitiveTopology::TriangleList, 247 + strip_index_format: None, 248 + front_face: wgpu::FrontFace::Ccw, 249 + cull_mode: None, 250 + polygon_mode: wgpu::PolygonMode::Fill, 251 + conservative: false, 252 + unclipped_depth: false, 253 + }, 254 + depth_stencil: None, 255 + multisample: wgpu::MultisampleState::default(), 256 + multiview_mask: None, 257 + cache: None, 258 + }) 259 + } 260 + 261 + #[allow(clippy::cast_possible_truncation)] 262 + fn build_instance(glyph: SceneRelationGlyph) -> GlyphInstance { 263 + let (ax, ay) = glyph.anchor_mm().coords_mm(); 264 + let (dx, dy) = glyph.offset_dir().coords_mm(); 265 + GlyphInstance { 266 + anchor_mm: [ax as f32, ay as f32], 267 + offset_dir: [dx as f32, dy as f32], 268 + pick_id: glyph.pick().raw(), 269 + tile_index: glyph.kind().tile_index(), 270 + } 271 + } 272 + 273 + #[allow(clippy::cast_possible_truncation)] 274 + fn build_uniform(camera: Camera2, glyphs: GlyphStyle) -> GlyphUniform { 275 + GlyphUniform { 276 + clip_from_world: camera.clip_from_world_mm(), 277 + glyph_color: glyphs.color().to_rgba_array(), 278 + pixels_per_mm: camera.zoom().value() as f32, 279 + offset_px: glyphs.offset_px(), 280 + tile_px: glyphs.tile_px(), 281 + _pad: 0.0, 282 + } 283 + } 284 + 285 + fn upload_atlas( 286 + device: &wgpu::Device, 287 + queue: &wgpu::Queue, 288 + ) -> (wgpu::TextureView, wgpu::Sampler) { 289 + let Ok((extent, rgba)) = decode_png(COMMITTED_ATLAS) else { 290 + panic!("committed relation_glyphs.png failed to decode; repo state is broken"); 291 + }; 292 + assert_eq!( 293 + (extent.width().value(), extent.height().value()), 294 + (ATLAS_SIDE, ATLAS_SIDE), 295 + "committed relation_glyphs.png extent must equal ATLAS_SIDE on every side", 296 + ); 297 + assert_eq!( 298 + rgba.len(), 299 + (ATLAS_SIDE * ATLAS_SIDE * 4) as usize, 300 + "committed relation_glyphs.png rgba length mismatch", 301 + ); 302 + let texture = device.create_texture(&wgpu::TextureDescriptor { 303 + label: Some("bone-render:glyph-atlas"), 304 + size: wgpu::Extent3d { 305 + width: ATLAS_SIDE, 306 + height: ATLAS_SIDE, 307 + depth_or_array_layers: 1, 308 + }, 309 + mip_level_count: 1, 310 + sample_count: 1, 311 + dimension: wgpu::TextureDimension::D2, 312 + format: wgpu::TextureFormat::Rgba8Unorm, 313 + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, 314 + view_formats: &[], 315 + }); 316 + queue.write_texture( 317 + wgpu::TexelCopyTextureInfo { 318 + texture: &texture, 319 + mip_level: 0, 320 + origin: wgpu::Origin3d::ZERO, 321 + aspect: wgpu::TextureAspect::All, 322 + }, 323 + &rgba, 324 + wgpu::TexelCopyBufferLayout { 325 + offset: 0, 326 + bytes_per_row: Some(ATLAS_SIDE * 4), 327 + rows_per_image: Some(ATLAS_SIDE), 328 + }, 329 + wgpu::Extent3d { 330 + width: ATLAS_SIDE, 331 + height: ATLAS_SIDE, 332 + depth_or_array_layers: 1, 333 + }, 334 + ); 335 + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); 336 + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { 337 + label: Some("bone-render:glyph-sampler"), 338 + address_mode_u: wgpu::AddressMode::ClampToEdge, 339 + address_mode_v: wgpu::AddressMode::ClampToEdge, 340 + address_mode_w: wgpu::AddressMode::ClampToEdge, 341 + mag_filter: wgpu::FilterMode::Linear, 342 + min_filter: wgpu::FilterMode::Linear, 343 + mipmap_filter: wgpu::MipmapFilterMode::Nearest, 344 + ..Default::default() 345 + }); 346 + (view, sampler) 347 + } 348 + 349 + #[cfg(test)] 350 + mod tests { 351 + use super::*; 352 + use crate::scene::RelationGlyphKind; 353 + use crate::snapshot::{SnapshotFrame, encode_png}; 354 + use crate::{BackendTag, ViewportExtent, ViewportPx}; 355 + 356 + const UPDATE_ENV: &str = "BONE_UPDATE_GLYPH_ATLAS"; 357 + 358 + #[must_use] 359 + fn build_atlas_rgba() -> Vec<u8> { 360 + (0..ATLAS_SIDE) 361 + .flat_map(|y| (0..ATLAS_SIDE).flat_map(move |x| pixel_rgba(x, y))) 362 + .collect() 363 + } 364 + 365 + #[allow( 366 + clippy::cast_possible_truncation, 367 + clippy::cast_precision_loss, 368 + clippy::cast_sign_loss 369 + )] 370 + fn pixel_rgba(x: u32, y: u32) -> [u8; 4] { 371 + let col = x / TILE_SIDE; 372 + let row = y / TILE_SIDE; 373 + let local_x = (x % TILE_SIDE) as f32 + 0.5; 374 + let local_y = (y % TILE_SIDE) as f32 + 0.5; 375 + let kind = row * GRID_COLS + col; 376 + let coverage = sample_tile(kind, local_x, local_y); 377 + let alpha = (coverage.clamp(0.0, 1.0) * 255.0).round() as u8; 378 + [255, 255, 255, alpha] 379 + } 380 + 381 + fn sample_tile(kind: u32, x: f32, y: f32) -> f32 { 382 + RelationGlyphKind::from_index(kind).map_or(0.0, |k| tile_coverage(k, x, y)) 383 + } 384 + 385 + fn tile_coverage(kind: RelationGlyphKind, x: f32, y: f32) -> f32 { 386 + match kind { 387 + RelationGlyphKind::Coincident => disc(x, y, 16.0, 16.0, 4.5), 388 + RelationGlyphKind::Horizontal => segment(x, y, 6.0, 16.0, 26.0, 16.0, 1.8), 389 + RelationGlyphKind::Vertical => segment(x, y, 16.0, 6.0, 16.0, 26.0, 1.8), 390 + RelationGlyphKind::Parallel => combine( 391 + segment(x, y, 10.0, 24.0, 18.0, 8.0, 1.8), 392 + segment(x, y, 16.0, 24.0, 24.0, 8.0, 1.8), 393 + ), 394 + RelationGlyphKind::Perpendicular => combine( 395 + segment(x, y, 11.0, 6.0, 11.0, 24.0, 1.8), 396 + segment(x, y, 8.0, 24.0, 24.0, 24.0, 1.8), 397 + ), 398 + RelationGlyphKind::Tangent => combine( 399 + ring(x, y, 16.0, 15.0, 7.0, 1.8), 400 + segment(x, y, 5.0, 22.0, 27.0, 22.0, 1.8), 401 + ), 402 + RelationGlyphKind::Equal => combine( 403 + segment(x, y, 7.0, 13.0, 25.0, 13.0, 1.8), 404 + segment(x, y, 7.0, 19.0, 25.0, 19.0, 1.8), 405 + ), 406 + RelationGlyphKind::Concentric => combine( 407 + ring(x, y, 16.0, 16.0, 10.0, 1.8), 408 + ring(x, y, 16.0, 16.0, 4.0, 1.8), 409 + ), 410 + RelationGlyphKind::Fix => fix_anchor(x, y), 411 + } 412 + } 413 + 414 + fn disc(x: f32, y: f32, cx: f32, cy: f32, radius: f32) -> f32 { 415 + let dx = x - cx; 416 + let dy = y - cy; 417 + aa((dx * dx + dy * dy).sqrt() - radius) 418 + } 419 + 420 + fn ring(x: f32, y: f32, cx: f32, cy: f32, radius: f32, half_thickness: f32) -> f32 { 421 + let dx = x - cx; 422 + let dy = y - cy; 423 + let d = (dx * dx + dy * dy).sqrt(); 424 + aa((d - radius).abs() - half_thickness) 425 + } 426 + 427 + fn segment(x: f32, y: f32, x0: f32, y0: f32, x1: f32, y1: f32, half_thickness: f32) -> f32 { 428 + let dx = x1 - x0; 429 + let dy = y1 - y0; 430 + let len2 = dx * dx + dy * dy; 431 + let t = if len2 > 0.0 { 432 + (((x - x0) * dx + (y - y0) * dy) / len2).clamp(0.0, 1.0) 433 + } else { 434 + 0.0 435 + }; 436 + let px = x0 + t * dx; 437 + let py = y0 + t * dy; 438 + let ex = x - px; 439 + let ey = y - py; 440 + aa((ex * ex + ey * ey).sqrt() - half_thickness) 441 + } 442 + 443 + fn fix_anchor(x: f32, y: f32) -> f32 { 444 + let dx = (x - 16.0).abs(); 445 + let dy = (y - 16.0).abs(); 446 + let outer = aa(dx.max(dy) - 7.5); 447 + let inner = aa(4.0 - dx.max(dy)); 448 + (outer - inner).clamp(0.0, 1.0) 449 + } 450 + 451 + fn combine(a: f32, b: f32) -> f32 { 452 + a.max(b) 453 + } 454 + 455 + fn aa(signed_distance: f32) -> f32 { 456 + (0.5 - signed_distance).clamp(0.0, 1.0) 457 + } 458 + 459 + fn atlas_extent() -> ViewportExtent { 460 + ViewportExtent::square(ViewportPx::new(ATLAS_SIDE)) 461 + } 462 + 463 + fn tag() -> BackendTag { 464 + BackendTag::from_backend(wgpu::Backend::Noop) 465 + } 466 + 467 + fn atlas_frame() -> SnapshotFrame { 468 + SnapshotFrame::new(atlas_extent(), build_atlas_rgba(), tag()) 469 + } 470 + 471 + #[test] 472 + fn committed_atlas_matches_generator() { 473 + let Ok(generated_png) = encode_png(&atlas_frame()) else { 474 + panic!("encode_png on generated atlas failed"); 475 + }; 476 + if std::env::var(UPDATE_ENV).is_ok() { 477 + let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) 478 + .join("assets/relation_glyphs.png"); 479 + if let Some(parent) = path.parent() 480 + && let Err(e) = std::fs::create_dir_all(parent) 481 + { 482 + panic!("create assets dir {}: {e}", parent.display()); 483 + } 484 + if let Err(e) = std::fs::write(&path, &generated_png) { 485 + panic!("write atlas {}: {e}", path.display()); 486 + } 487 + return; 488 + } 489 + assert_eq!( 490 + COMMITTED_ATLAS, generated_png, 491 + "committed relation_glyphs.png drifted from build_atlas_rgba; \ 492 + rerun with {UPDATE_ENV}=1 to refresh", 493 + ); 494 + } 495 + 496 + #[test] 497 + fn atlas_is_square_and_ninefold() { 498 + let rgba = build_atlas_rgba(); 499 + assert_eq!(rgba.len(), (ATLAS_SIDE * ATLAS_SIDE * 4) as usize); 500 + assert_eq!(GLYPH_COUNT, GRID_COLS * GRID_ROWS); 501 + } 502 + 503 + #[test] 504 + fn every_tile_has_visible_coverage() { 505 + let rgba = build_atlas_rgba(); 506 + let empty: Vec<RelationGlyphKind> = RelationGlyphKind::all() 507 + .into_iter() 508 + .filter(|kind| tile_alpha_sum(&rgba, *kind) == 0) 509 + .collect(); 510 + assert!( 511 + empty.is_empty(), 512 + "tiles rasterised empty: {empty:?} — coverage fn regressed", 513 + ); 514 + } 515 + 516 + fn tile_alpha_sum(rgba: &[u8], kind: RelationGlyphKind) -> u32 { 517 + let idx = kind.tile_index(); 518 + let col = idx % GRID_COLS; 519 + let row = idx / GRID_COLS; 520 + let x0 = col * TILE_SIDE; 521 + let y0 = row * TILE_SIDE; 522 + (0..TILE_SIDE) 523 + .flat_map(|dy| (0..TILE_SIDE).map(move |dx| (dx, dy))) 524 + .map(|(dx, dy)| { 525 + let x = x0 + dx; 526 + let y = y0 + dy; 527 + let base = ((y * ATLAS_SIDE + x) * 4 + 3) as usize; 528 + u32::from(rgba[base]) 529 + }) 530 + .sum() 531 + } 532 + }
+83
crates/bone-render/src/pipelines/glyph.wgsl
··· 1 + struct Frame { 2 + clip_from_world: mat4x4<f32>, 3 + glyph_color: vec4<f32>, 4 + pixels_per_mm: f32, 5 + offset_px: f32, 6 + tile_px: f32, 7 + _pad: f32, 8 + }; 9 + 10 + struct Instance { 11 + @location(0) anchor_mm: vec2<f32>, 12 + @location(1) offset_dir: vec2<f32>, 13 + @location(2) pick_id: u32, 14 + @location(3) tile_index: u32, 15 + }; 16 + 17 + struct VsOut { 18 + @builtin(position) clip: vec4<f32>, 19 + @location(0) uv: vec2<f32>, 20 + @location(1) @interpolate(flat) pick_id: u32, 21 + }; 22 + 23 + struct FsOut { 24 + @location(0) color: vec4<f32>, 25 + @location(1) pick_id: u32, 26 + }; 27 + 28 + @group(0) @binding(0) var<uniform> u: Frame; 29 + @group(0) @binding(1) var atlas: texture_2d<f32>; 30 + @group(0) @binding(2) var atlas_sampler: sampler; 31 + 32 + const CORNERS: array<vec2<f32>, 6> = array<vec2<f32>, 6>( 33 + vec2<f32>(0.0, 0.0), 34 + vec2<f32>(1.0, 0.0), 35 + vec2<f32>(0.0, 1.0), 36 + vec2<f32>(0.0, 1.0), 37 + vec2<f32>(1.0, 0.0), 38 + vec2<f32>(1.0, 1.0), 39 + ); 40 + 41 + const GRID_DIM: f32 = 3.0; 42 + const TILE_UV: f32 = 1.0 / 3.0; 43 + const HALF_TEXEL: f32 = 0.5 / 96.0; 44 + const INNER_UV: f32 = TILE_UV - 2.0 * HALF_TEXEL; 45 + 46 + @vertex 47 + fn vs(@builtin(vertex_index) vid: u32, inst: Instance) -> VsOut { 48 + var corners = CORNERS; 49 + let corner = corners[vid]; 50 + let center_mm = inst.anchor_mm + inst.offset_dir * (u.offset_px / u.pixels_per_mm); 51 + let half_mm = (u.tile_px * 0.5) / u.pixels_per_mm; 52 + let corner_signed = corner * 2.0 - vec2<f32>(1.0, 1.0); 53 + let world_mm = center_mm + corner_signed * half_mm; 54 + let clip = u.clip_from_world * vec4<f32>(world_mm, 0.0, 1.0); 55 + 56 + let col = f32(inst.tile_index % 3u); 57 + let row = f32(inst.tile_index / 3u); 58 + let local_u = corner.x; 59 + let local_v = 1.0 - corner.y; 60 + let u0 = col * TILE_UV + HALF_TEXEL; 61 + let v0 = row * TILE_UV + HALF_TEXEL; 62 + let uv = vec2<f32>(u0 + local_u * INNER_UV, v0 + local_v * INNER_UV); 63 + 64 + var out: VsOut; 65 + out.clip = clip; 66 + out.uv = uv; 67 + out.pick_id = inst.pick_id; 68 + return out; 69 + } 70 + 71 + @fragment 72 + fn fs(in: VsOut) -> FsOut { 73 + let sample = textureSample(atlas, atlas_sampler, in.uv); 74 + let coverage = sample.a; 75 + if (coverage <= 1.0e-3) { 76 + discard; 77 + } 78 + let a = u.glyph_color.a * coverage; 79 + var out: FsOut; 80 + out.color = vec4<f32>(u.glyph_color.rgb * a, a); 81 + out.pick_id = in.pick_id; 82 + return out; 83 + }
crates/bone-render/tests/goldens/relations_256.png

This is a binary file and will not be displayed.

+227
crates/bone-render/tests/relations.rs
··· 1 + use std::path::PathBuf; 2 + 3 + use bone_document::{EditOutcome, Sketch, SketchEdit, SketchEntity, SketchRelation}; 4 + use bone_render::{ 5 + Camera2, OffscreenContext, PixelDiff, PixelDiffThreshold, PixelsPerMm, RenderError, 6 + RelationGlyphKind, SketchRenderer, SketchScene, Style, ViewportExtent, ViewportPx, decode_png, 7 + encode_png, 8 + }; 9 + use bone_types::{Length, Point2, Point3, SketchEntityId, SketchPlaneBasis, Tolerance, UnitVec3}; 10 + use uom::si::length::millimeter; 11 + 12 + const GOLDEN: &str = "tests/goldens/relations_256.png"; 13 + const UPDATE_ENV: &str = "BONE_UPDATE_RELATIONS_GOLDEN"; 14 + const DIFF_TOLERANCE: f64 = 16.0 / 255.0; 15 + 16 + fn extent(side: u32) -> ViewportExtent { 17 + ViewportExtent::square(ViewportPx::new(side)) 18 + } 19 + 20 + fn make_context(extent: ViewportExtent) -> OffscreenContext { 21 + match pollster::block_on(OffscreenContext::new(extent)) { 22 + Ok(ctx) => ctx, 23 + Err(RenderError::NoAdapter(e)) => panic!( 24 + "no wgpu adapter available, configure lavapipe or an iGPU for this test host: {e}" 25 + ), 26 + Err(e) => panic!("offscreen context init failed: {e}"), 27 + } 28 + } 29 + 30 + fn plane() -> SketchPlaneBasis { 31 + let Ok(basis) = SketchPlaneBasis::new( 32 + Point3::origin(), 33 + UnitVec3::x_axis(), 34 + UnitVec3::y_axis(), 35 + Tolerance::new(1e-9), 36 + ) else { 37 + panic!("xy plane basis is orthogonal"); 38 + }; 39 + basis 40 + } 41 + 42 + fn add_point(s: Sketch, x: f64, y: f64) -> (Sketch, SketchEntityId) { 43 + let Ok((next, EditOutcome::Entity(id))) = s.apply(SketchEdit::AddEntity(SketchEntity::point( 44 + Point2::from_mm(x, y), 45 + ))) else { 46 + panic!("add point"); 47 + }; 48 + (next, id) 49 + } 50 + 51 + fn add_line(s: Sketch, a: SketchEntityId, b: SketchEntityId) -> (Sketch, SketchEntityId) { 52 + let Ok((next, EditOutcome::Entity(id))) = 53 + s.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false))) 54 + else { 55 + panic!("add line"); 56 + }; 57 + (next, id) 58 + } 59 + 60 + fn add_circle(s: Sketch, center: SketchEntityId, radius_mm: f64) -> (Sketch, SketchEntityId) { 61 + let Ok((next, EditOutcome::Entity(id))) = s.apply(SketchEdit::AddEntity(SketchEntity::circle( 62 + center, 63 + Length::new::<millimeter>(radius_mm), 64 + false, 65 + ))) else { 66 + panic!("add circle"); 67 + }; 68 + (next, id) 69 + } 70 + 71 + fn add_relation(s: Sketch, rel: SketchRelation) -> Sketch { 72 + let Ok((next, _)) = s.apply(SketchEdit::AddRelation(rel)) else { 73 + panic!("add relation {rel:?}"); 74 + }; 75 + next 76 + } 77 + 78 + fn one_per_kind_scene() -> SketchScene { 79 + let s = Sketch::new(plane()); 80 + 81 + let (s, coin_a) = add_point(s, -4.0, 4.0); 82 + let (s, coin_b) = add_point(s, -4.0, 4.0); 83 + let s = add_relation(s, SketchRelation::Coincident(coin_a, coin_b)); 84 + 85 + let (s, ha) = add_point(s, -1.5, 4.2); 86 + let (s, hb) = add_point(s, 1.5, 4.2); 87 + let (s, hl) = add_line(s, ha, hb); 88 + let s = add_relation(s, SketchRelation::Horizontal(hl)); 89 + 90 + let (s, va) = add_point(s, 4.0, 3.0); 91 + let (s, vb) = add_point(s, 4.0, 5.2); 92 + let (s, vl) = add_line(s, va, vb); 93 + let s = add_relation(s, SketchRelation::Vertical(vl)); 94 + 95 + let (s, pa0) = add_point(s, -5.0, 1.0); 96 + let (s, pa1) = add_point(s, -2.5, 1.9); 97 + let (s, pla) = add_line(s, pa0, pa1); 98 + let (s, pb0) = add_point(s, -5.0, -0.5); 99 + let (s, pb1) = add_point(s, -2.5, 0.4); 100 + let (s, plb) = add_line(s, pb0, pb1); 101 + let s = add_relation(s, SketchRelation::Parallel(pla, plb)); 102 + 103 + let (s, xa0) = add_point(s, -0.5, 1.4); 104 + let (s, xa1) = add_point(s, 1.3, 1.4); 105 + let (s, xla) = add_line(s, xa0, xa1); 106 + let (s, xb0) = add_point(s, 0.5, 0.3); 107 + let (s, xb1) = add_point(s, 0.5, 1.9); 108 + let (s, xlb) = add_line(s, xb0, xb1); 109 + let s = add_relation(s, SketchRelation::Perpendicular(xla, xlb)); 110 + 111 + let (s, tc) = add_point(s, 4.5, 1.2); 112 + let (s, tcirc) = add_circle(s, tc, 0.7); 113 + let (s, tla) = add_point(s, 3.0, 1.9); 114 + let (s, tlb) = add_point(s, 6.0, 1.9); 115 + let (s, tline) = add_line(s, tla, tlb); 116 + let s = add_relation(s, SketchRelation::Tangent(tcirc, tline)); 117 + 118 + let (s, ea0) = add_point(s, -4.8, -2.5); 119 + let (s, ea1) = add_point(s, -3.0, -2.5); 120 + let (s, ela) = add_line(s, ea0, ea1); 121 + let (s, eb0) = add_point(s, -4.8, -3.8); 122 + let (s, eb1) = add_point(s, -3.0, -3.8); 123 + let (s, elb) = add_line(s, eb0, eb1); 124 + let s = add_relation(s, SketchRelation::Equal(ela, elb)); 125 + 126 + let (s, ccc) = add_point(s, -0.2, -2.8); 127 + let (s, cca) = add_circle(s, ccc, 0.8); 128 + let (s, ccb) = add_circle(s, ccc, 1.6); 129 + let s = add_relation(s, SketchRelation::Concentric(cca, ccb)); 130 + 131 + let (s, fp) = add_point(s, 4.5, -3.0); 132 + let s = add_relation(s, SketchRelation::Fix(fp)); 133 + 134 + let Ok(scene) = SketchScene::extract(&s) else { 135 + panic!("scene extract"); 136 + }; 137 + assert_eq!( 138 + scene.relations().len(), 139 + RelationGlyphKind::all().len(), 140 + "expected nine-kind coverage in relation scene", 141 + ); 142 + scene 143 + } 144 + 145 + fn render_scene(ctx: &OffscreenContext, scene: &SketchScene) -> bone_render::SnapshotFrame { 146 + let mut renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format()); 147 + let camera = Camera2::new(ctx.extent()).with_zoom(PixelsPerMm::new(20.0)); 148 + let style = Style::default(); 149 + let Ok(frame) = renderer.render(ctx, scene, camera, &style) else { 150 + panic!("SketchRenderer::render failed"); 151 + }; 152 + frame 153 + } 154 + 155 + fn golden_path() -> PathBuf { 156 + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GOLDEN) 157 + } 158 + 159 + #[test] 160 + fn nine_relation_kinds_match_golden() { 161 + let size = extent(256); 162 + let ctx = make_context(size); 163 + let scene = one_per_kind_scene(); 164 + let frame = render_scene(&ctx, &scene); 165 + let path = golden_path(); 166 + 167 + if std::env::var(UPDATE_ENV).is_ok() { 168 + let Ok(bytes) = encode_png(&frame) else { 169 + panic!("encode_png failed"); 170 + }; 171 + if let Some(parent) = path.parent() 172 + && let Err(e) = std::fs::create_dir_all(parent) 173 + { 174 + panic!("create goldens dir {}: {e}", parent.display()); 175 + } 176 + if let Err(e) = std::fs::write(&path, &bytes) { 177 + panic!("write golden {}: {e}", path.display()); 178 + } 179 + return; 180 + } 181 + 182 + let Ok(bytes) = std::fs::read(&path) else { 183 + panic!( 184 + "golden missing at {}: rerun with {UPDATE_ENV}=1 to generate", 185 + path.display() 186 + ); 187 + }; 188 + let Ok((golden_extent, golden_rgba)) = decode_png(&bytes) else { 189 + panic!("failed to decode golden PNG"); 190 + }; 191 + assert_eq!(golden_extent, size, "golden extent drift"); 192 + let threshold = PixelDiffThreshold::new(DIFF_TOLERANCE); 193 + let Ok(report) = PixelDiff::compare(&frame, &golden_rgba, threshold) else { 194 + panic!("PixelDiff rejected inputs"); 195 + }; 196 + assert!( 197 + report.is_clean(), 198 + "relation render drifted from golden: {} mismatches, worst {:?}, backend {}", 199 + report.over_threshold(), 200 + report.worst(), 201 + frame.backend(), 202 + ); 203 + } 204 + 205 + #[test] 206 + fn relation_glyph_pick_ids_survive_render() { 207 + let size = extent(256); 208 + let ctx = make_context(size); 209 + let scene = one_per_kind_scene(); 210 + let frame = render_scene(&ctx, &scene); 211 + let Ok(index) = scene.pick_index() else { 212 + panic!("pick index build"); 213 + }; 214 + 215 + let unresolved: Vec<RelationGlyphKind> = scene 216 + .relations() 217 + .iter() 218 + .filter(|g| g.pick().unpack(&index).is_none()) 219 + .map(|g| g.kind()) 220 + .collect(); 221 + assert!( 222 + unresolved.is_empty(), 223 + "relation pick ids did not decode: {unresolved:?}", 224 + ); 225 + 226 + assert_eq!(frame.extent(), size); 227 + }