Another project
1
fork

Configure Feed

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

feat(render): dimension text

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

+1394 -21
+101
Cargo.lock
··· 247 247 "bone-kernel", 248 248 "bone-types", 249 249 "bytemuck", 250 + "lyon_tessellation", 250 251 "png", 251 252 "pollster", 252 253 "proptest", 253 254 "slotmap", 255 + "swash", 254 256 "thiserror 2.0.18", 255 257 "tracing", 256 258 "uom", ··· 632 634 ] 633 635 634 636 [[package]] 637 + name = "euclid" 638 + version = "0.22.14" 639 + source = "registry+https://github.com/rust-lang/crates.io-index" 640 + checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" 641 + dependencies = [ 642 + "num-traits", 643 + ] 644 + 645 + [[package]] 635 646 name = "faer" 636 647 version = "0.24.0" 637 648 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 701 712 ] 702 713 703 714 [[package]] 715 + name = "float_next_after" 716 + version = "1.0.0" 717 + source = "registry+https://github.com/rust-lang/crates.io-index" 718 + checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" 719 + 720 + [[package]] 704 721 name = "foldhash" 705 722 version = "0.1.5" 706 723 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 711 728 version = "0.2.0" 712 729 source = "registry+https://github.com/rust-lang/crates.io-index" 713 730 checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" 731 + 732 + [[package]] 733 + name = "font-types" 734 + version = "0.11.3" 735 + source = "registry+https://github.com/rust-lang/crates.io-index" 736 + checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" 737 + dependencies = [ 738 + "bytemuck", 739 + ] 714 740 715 741 [[package]] 716 742 name = "foreign-types" ··· 1235 1261 checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" 1236 1262 1237 1263 [[package]] 1264 + name = "lyon_geom" 1265 + version = "1.0.19" 1266 + source = "registry+https://github.com/rust-lang/crates.io-index" 1267 + checksum = "4336502e29e32af93cf2dad2214ed6003c17ceb5bd499df77b1de663b9042b92" 1268 + dependencies = [ 1269 + "arrayvec", 1270 + "euclid", 1271 + "num-traits", 1272 + ] 1273 + 1274 + [[package]] 1275 + name = "lyon_path" 1276 + version = "1.0.19" 1277 + source = "registry+https://github.com/rust-lang/crates.io-index" 1278 + checksum = "5c463f9c428b7fc5ec885dcd39ce4aa61e29111d0e33483f6f98c74e89d8621e" 1279 + dependencies = [ 1280 + "lyon_geom", 1281 + "num-traits", 1282 + ] 1283 + 1284 + [[package]] 1285 + name = "lyon_tessellation" 1286 + version = "1.0.20" 1287 + source = "registry+https://github.com/rust-lang/crates.io-index" 1288 + checksum = "8e43b7e44161571868f5c931d12583592c223c5583eef86b08aa02b7048a3552" 1289 + dependencies = [ 1290 + "float_next_after", 1291 + "lyon_path", 1292 + "num-traits", 1293 + ] 1294 + 1295 + [[package]] 1238 1296 name = "matchers" 1239 1297 version = "0.2.0" 1240 1298 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2127 2185 checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" 2128 2186 2129 2187 [[package]] 2188 + name = "read-fonts" 2189 + version = "0.37.0" 2190 + source = "registry+https://github.com/rust-lang/crates.io-index" 2191 + checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5" 2192 + dependencies = [ 2193 + "bytemuck", 2194 + "font-types", 2195 + ] 2196 + 2197 + [[package]] 2130 2198 name = "reborrow" 2131 2199 version = "0.5.5" 2132 2200 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2385 2453 checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" 2386 2454 2387 2455 [[package]] 2456 + name = "skrifa" 2457 + version = "0.40.0" 2458 + source = "registry+https://github.com/rust-lang/crates.io-index" 2459 + checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac" 2460 + dependencies = [ 2461 + "bytemuck", 2462 + "read-fonts", 2463 + ] 2464 + 2465 + [[package]] 2388 2466 name = "slab" 2389 2467 version = "0.4.12" 2390 2468 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2460 2538 version = "0.1.1" 2461 2539 source = "registry+https://github.com/rust-lang/crates.io-index" 2462 2540 checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" 2541 + 2542 + [[package]] 2543 + name = "swash" 2544 + version = "0.2.7" 2545 + source = "registry+https://github.com/rust-lang/crates.io-index" 2546 + checksum = "842f3cd369c2ba38966204f983eaa5e54a8e84a7d7159ed36ade2b6c335aae64" 2547 + dependencies = [ 2548 + "skrifa", 2549 + "yazi", 2550 + "zeno", 2551 + ] 2463 2552 2464 2553 [[package]] 2465 2554 name = "syn" ··· 3467 3556 version = "0.8.28" 3468 3557 source = "registry+https://github.com/rust-lang/crates.io-index" 3469 3558 checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" 3559 + 3560 + [[package]] 3561 + name = "yazi" 3562 + version = "0.2.1" 3563 + source = "registry+https://github.com/rust-lang/crates.io-index" 3564 + checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" 3565 + 3566 + [[package]] 3567 + name = "zeno" 3568 + version = "0.3.3" 3569 + source = "registry+https://github.com/rust-lang/crates.io-index" 3570 + checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" 3470 3571 3471 3572 [[package]] 3472 3573 name = "zerocopy"
+2
Cargo.toml
··· 37 37 bytemuck = { version = "1", default-features = false, features = ["derive"] } 38 38 faer = { version = "0.24", default-features = false, features = ["std"] } 39 39 insta = "1" 40 + lyon_tessellation = "1" 40 41 nalgebra = { version = "0.33", default-features = false, features = ["std"] } 41 42 png = { version = "0.17", default-features = false } 42 43 pollster = "0.4" ··· 44 45 ron = "0.12" 45 46 serde = { version = "1", default-features = false, features = ["std", "derive", "rc"] } 46 47 slotmap = { version = "1", default-features = false, features = ["std", "serde"] } 48 + swash = { version = "0.2", default-features = false, features = ["std", "scale"] } 47 49 tempfile = "3" 48 50 thiserror = "2" 49 51 tracing = "0.1"
+2 -1
crates/bone-app/src/main.rs
··· 318 318 } 319 319 WindowEvent::RedrawRequested => { 320 320 let surface = &mut state.surface; 321 - let renderer = &state.renderer; 321 + let renderer = &mut state.renderer; 322 322 let scene = &state.scene; 323 323 let camera = state.camera; 324 324 let style = &state.style; 325 + renderer.prepare(scene, style); 325 326 surface.render( 326 327 |encoder, color, pick| { 327 328 renderer.encode_passes(encoder, color, pick, scene, camera, style);
+2
crates/bone-render/Cargo.toml
··· 10 10 bone-kernel = { workspace = true } 11 11 bone-types = { workspace = true } 12 12 bytemuck = { workspace = true } 13 + lyon_tessellation = { workspace = true } 13 14 png = { workspace = true } 14 15 slotmap = { workspace = true } 16 + swash = { workspace = true } 15 17 thiserror = { workspace = true } 16 18 tracing = { workspace = true } 17 19 uom = { workspace = true }
+44
crates/bone-render/assets/DejaVuSansMono.LICENSE
··· 1 + DejaVu Sans Mono (unmodified redistribution) 2 + 3 + Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. 4 + Bitstream Vera is a trademark of Bitstream, Inc. 5 + DejaVu changes are in public domain. 6 + 7 + Permission is hereby granted, free of charge, to any person obtaining a copy 8 + of the fonts accompanying this license ("Fonts") and associated documentation 9 + files (the "Font Software"), to reproduce and distribute the Font Software, 10 + including without limitation the rights to use, copy, merge, publish, 11 + distribute, and/or sell copies of the Font Software, and to permit persons to 12 + whom the Font Software is furnished to do so, subject to the following 13 + conditions: 14 + 15 + The above copyright and trademark notices and this permission notice shall be 16 + included in all copies of one or more of the Font Software typefaces. 17 + 18 + The Font Software may be modified, altered, or added to, and in particular 19 + the designs of glyphs or characters in the Fonts may be modified and 20 + additional glyphs or characters may be added to the Fonts, only if the fonts 21 + are renamed to names not containing either the words "Bitstream" or the word 22 + "Vera". 23 + 24 + This License becomes null and void to the extent applicable to Fonts or Font 25 + Software that has been modified and is distributed under the "Bitstream Vera" 26 + names. 27 + 28 + The Font Software may be sold as part of a larger software package but no 29 + copy of one or more of the Font Software typefaces may be sold by itself. 30 + 31 + THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 32 + OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, 33 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, 34 + TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION 35 + BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, 36 + SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION 37 + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO 38 + USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. 39 + 40 + Except as contained in this notice, the names of Gnome, the Gnome Foundation, 41 + and Bitstream Inc., shall not be used in advertising or otherwise to promote 42 + the sale, use or other dealings in this Font Software without prior written 43 + authorization from the Gnome Foundation or Bitstream Inc., respectively. For 44 + further information, contact: fonts at gnome dot org.
crates/bone-render/assets/DejaVuSansMono.ttf

This is a binary file and will not be displayed.

+26 -4
crates/bone-render/src/lib.rs
··· 11 11 pub use diff::{PixelDiff, PixelDiffError, PixelDiffReport, PixelDiffThreshold, PixelMismatch}; 12 12 pub use gpu::{BackendTag, Capabilities, Gpu, OffscreenContext}; 13 13 pub use pick::{EntityKindTag, PickId, PickIdError, PickIndex, PickQuery, PickedItem, Picker}; 14 - pub use pipelines::{ArcPipeline, GridPipeline, LinesPipeline}; 15 - pub use scene::{SceneArc, SceneCircle, SceneLine, ScenePoint, SketchScene}; 14 + pub use pipelines::{ArcPipeline, GlyphPipeline, GridPipeline, LinesPipeline, TextPipeline}; 15 + pub use scene::{ 16 + RelationGlyphKind, SceneArc, SceneCircle, SceneDimension, SceneLine, ScenePoint, 17 + SceneRelationGlyph, SketchScene, 18 + }; 16 19 pub use snapshot::{ 17 - ClearColor, GridStyle, SnapshotFrame, StrokeStyle, Style, decode_png, encode_png, 20 + ClearColor, GlyphStyle, GridStyle, SnapshotFrame, StrokeStyle, Style, TextStyle, decode_png, 21 + encode_png, 18 22 }; 19 23 pub use surface::{SurfaceContext, SurfaceError}; 20 24 ··· 57 61 grid: GridPipeline, 58 62 arcs: ArcPipeline, 59 63 lines: LinesPipeline, 64 + glyphs: GlyphPipeline, 65 + text: TextPipeline, 60 66 } 61 67 62 68 impl SketchRenderer { ··· 66 72 grid: GridPipeline::new(gpu, color_format), 67 73 arcs: ArcPipeline::new(gpu, color_format), 68 74 lines: LinesPipeline::new(gpu, color_format), 75 + glyphs: GlyphPipeline::new(gpu, color_format), 76 + text: TextPipeline::new(gpu, color_format), 69 77 } 70 78 } 71 79 80 + pub fn prepare(&mut self, scene: &SketchScene, style: &Style) { 81 + self.text.prepare(scene, style); 82 + } 83 + 84 + #[must_use] 85 + pub fn text_cache_len(&self) -> usize { 86 + self.text.cache_len() 87 + } 88 + 72 89 pub fn encode_passes( 73 90 &self, 74 91 encoder: &mut wgpu::CommandEncoder, ··· 84 101 .draw(encoder, color_view, pick_view, camera, style, scene); 85 102 self.lines 86 103 .draw(encoder, color_view, pick_view, camera, style, scene); 104 + self.glyphs 105 + .draw(encoder, color_view, pick_view, camera, style, scene); 106 + self.text 107 + .draw(encoder, color_view, pick_view, camera, style, scene); 87 108 } 88 109 89 110 pub fn render( 90 - &self, 111 + &mut self, 91 112 ctx: &OffscreenContext, 92 113 scene: &SketchScene, 93 114 camera: Camera2, ··· 98 119 ctx.extent(), 99 120 "camera extent must match offscreen context extent", 100 121 ); 122 + self.prepare(scene, style); 101 123 ctx.render(|encoder, color_view, pick_view| { 102 124 self.encode_passes(encoder, color_view, pick_view, scene, camera, style); 103 125 })
+4
crates/bone-render/src/pipelines/mod.rs
··· 1 1 pub mod arc; 2 + pub mod glyph; 2 3 pub mod grid; 3 4 pub mod lines; 5 + pub mod text; 4 6 5 7 pub use arc::ArcPipeline; 8 + pub use glyph::GlyphPipeline; 6 9 pub use grid::GridPipeline; 7 10 pub use lines::LinesPipeline; 11 + pub use text::TextPipeline; 8 12 9 13 use crate::camera::Camera2; 10 14 use crate::snapshot::Style;
+493
crates/bone-render/src/pipelines/text.rs
··· 1 + use std::collections::HashMap; 2 + 3 + use bone_types::SketchDimensionId; 4 + use lyon_tessellation::{ 5 + BuffersBuilder, FillOptions, FillTessellator, FillVertex, VertexBuffers, 6 + path::{ 7 + Path as LyonPath, 8 + builder::WithSvg, 9 + math::Point as LyonPoint, 10 + path::BuilderImpl, 11 + }, 12 + }; 13 + use swash::{ 14 + FontRef, 15 + scale::{ScaleContext, outline::Outline}, 16 + zeno::{PathBuilder as ZenoPathBuilder, PathData as _, Point as ZenoPoint}, 17 + }; 18 + use wgpu::util::DeviceExt; 19 + 20 + use crate::camera::Camera2; 21 + use crate::gpu::{Gpu, PICK_FORMAT}; 22 + use crate::scene::SketchScene; 23 + use crate::snapshot::{Style, TextStyle}; 24 + 25 + const FONT_DATA: &[u8] = include_bytes!("../../assets/DejaVuSansMono.ttf"); 26 + 27 + #[repr(C, align(16))] 28 + #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] 29 + struct TextUniform { 30 + clip_from_world: [f32; 16], 31 + text_color: [f32; 4], 32 + pixels_per_mm: f32, 33 + _pad0: f32, 34 + _pad1: f32, 35 + _pad2: f32, 36 + } 37 + 38 + const UNIFORM_SIZE: u64 = core::mem::size_of::<TextUniform>() as u64; 39 + 40 + #[repr(C)] 41 + #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] 42 + struct TextVertex { 43 + anchor_mm: [f32; 2], 44 + offset_px: [f32; 2], 45 + pick_id: u32, 46 + _pad: u32, 47 + } 48 + 49 + const VERTEX_STRIDE: u64 = core::mem::size_of::<TextVertex>() as u64; 50 + 51 + #[derive(Clone)] 52 + struct Tessellated { 53 + vertices_px: Vec<[f32; 2]>, 54 + indices: Vec<u32>, 55 + } 56 + 57 + struct Cached { 58 + text: String, 59 + size_px: f32, 60 + tess: Tessellated, 61 + } 62 + 63 + impl Cached { 64 + fn matches(&self, text: &str, size_px: f32) -> bool { 65 + self.size_px.to_bits() == size_px.to_bits() && self.text == text 66 + } 67 + } 68 + 69 + pub struct TextPipeline { 70 + device: wgpu::Device, 71 + queue: wgpu::Queue, 72 + pipeline: wgpu::RenderPipeline, 73 + uniform_buffer: wgpu::Buffer, 74 + bind_group: wgpu::BindGroup, 75 + font: FontRef<'static>, 76 + scale_context: ScaleContext, 77 + tessellator: FillTessellator, 78 + cache: HashMap<SketchDimensionId, Cached>, 79 + } 80 + 81 + impl TextPipeline { 82 + #[must_use] 83 + pub fn new(gpu: &Gpu, color_format: wgpu::TextureFormat) -> Self { 84 + let device = gpu.device().clone(); 85 + let queue = gpu.queue().clone(); 86 + let bind_group_layout = create_bind_group_layout(&device); 87 + let pipeline = create_pipeline(&device, &bind_group_layout, color_format); 88 + let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { 89 + label: Some("bone-render:text-uniform"), 90 + size: UNIFORM_SIZE, 91 + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, 92 + mapped_at_creation: false, 93 + }); 94 + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { 95 + label: Some("bone-render:text-bg"), 96 + layout: &bind_group_layout, 97 + entries: &[wgpu::BindGroupEntry { 98 + binding: 0, 99 + resource: uniform_buffer.as_entire_binding(), 100 + }], 101 + }); 102 + let Some(font) = FontRef::from_index(FONT_DATA, 0) else { 103 + panic!("bundled DejaVuSansMono.ttf failed to parse; asset is broken"); 104 + }; 105 + Self { 106 + device, 107 + queue, 108 + pipeline, 109 + uniform_buffer, 110 + bind_group, 111 + font, 112 + scale_context: ScaleContext::new(), 113 + tessellator: FillTessellator::new(), 114 + cache: HashMap::new(), 115 + } 116 + } 117 + 118 + pub fn prepare(&mut self, scene: &SketchScene, style: &Style) { 119 + let size_px = style.text().font_size_px(); 120 + let mut prev = std::mem::take(&mut self.cache); 121 + self.cache = scene.dimensions().iter().fold( 122 + HashMap::with_capacity(scene.dimensions().len()), 123 + |mut acc, d| { 124 + let id = d.dimension(); 125 + let text = d.text(); 126 + let entry = match prev.remove(&id) { 127 + Some(c) if c.matches(text, size_px) => c, 128 + _ => Cached { 129 + text: text.to_owned(), 130 + size_px, 131 + tess: tessellate( 132 + text, 133 + size_px, 134 + &self.font, 135 + &mut self.scale_context, 136 + &mut self.tessellator, 137 + ), 138 + }, 139 + }; 140 + acc.insert(id, entry); 141 + acc 142 + }, 143 + ); 144 + } 145 + 146 + pub fn draw( 147 + &self, 148 + encoder: &mut wgpu::CommandEncoder, 149 + color_view: &wgpu::TextureView, 150 + pick_view: &wgpu::TextureView, 151 + camera: Camera2, 152 + style: &Style, 153 + scene: &SketchScene, 154 + ) { 155 + let (vertices, indices) = assemble_geometry(scene, &self.cache); 156 + if indices.is_empty() { 157 + return; 158 + } 159 + let uniform = build_uniform(camera, style.text()); 160 + self.queue 161 + .write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&uniform)); 162 + let vertex_buffer = self 163 + .device 164 + .create_buffer_init(&wgpu::util::BufferInitDescriptor { 165 + label: Some("bone-render:text-vertices"), 166 + contents: bytemuck::cast_slice(&vertices), 167 + usage: wgpu::BufferUsages::VERTEX, 168 + }); 169 + let index_buffer = self 170 + .device 171 + .create_buffer_init(&wgpu::util::BufferInitDescriptor { 172 + label: Some("bone-render:text-indices"), 173 + contents: bytemuck::cast_slice(&indices), 174 + usage: wgpu::BufferUsages::INDEX, 175 + }); 176 + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { 177 + label: Some("bone-render:text-pass"), 178 + color_attachments: &[ 179 + Some(wgpu::RenderPassColorAttachment { 180 + view: color_view, 181 + resolve_target: None, 182 + depth_slice: None, 183 + ops: wgpu::Operations { 184 + load: wgpu::LoadOp::Load, 185 + store: wgpu::StoreOp::Store, 186 + }, 187 + }), 188 + Some(wgpu::RenderPassColorAttachment { 189 + view: pick_view, 190 + resolve_target: None, 191 + depth_slice: None, 192 + ops: wgpu::Operations { 193 + load: wgpu::LoadOp::Load, 194 + store: wgpu::StoreOp::Store, 195 + }, 196 + }), 197 + ], 198 + depth_stencil_attachment: None, 199 + timestamp_writes: None, 200 + occlusion_query_set: None, 201 + multiview_mask: None, 202 + }); 203 + pass.set_pipeline(&self.pipeline); 204 + pass.set_bind_group(0, &self.bind_group, &[]); 205 + pass.set_vertex_buffer(0, vertex_buffer.slice(..)); 206 + pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint32); 207 + let len = indices.len(); 208 + let Ok(count) = u32::try_from(len) else { 209 + panic!("text index count {len} exceeds u32::MAX"); 210 + }; 211 + pass.draw_indexed(0..count, 0, 0..1); 212 + } 213 + 214 + #[must_use] 215 + pub fn cache_len(&self) -> usize { 216 + self.cache.len() 217 + } 218 + } 219 + 220 + impl core::fmt::Debug for TextPipeline { 221 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 222 + f.debug_struct("TextPipeline") 223 + .field("cache_len", &self.cache.len()) 224 + .finish_non_exhaustive() 225 + } 226 + } 227 + 228 + const VERTEX_ATTRS: [wgpu::VertexAttribute; 3] = wgpu::vertex_attr_array![ 229 + 0 => Float32x2, 230 + 1 => Float32x2, 231 + 2 => Uint32, 232 + ]; 233 + 234 + fn create_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { 235 + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { 236 + label: Some("bone-render:text-bgl"), 237 + entries: &[wgpu::BindGroupLayoutEntry { 238 + binding: 0, 239 + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, 240 + ty: wgpu::BindingType::Buffer { 241 + ty: wgpu::BufferBindingType::Uniform, 242 + has_dynamic_offset: false, 243 + min_binding_size: wgpu::BufferSize::new(UNIFORM_SIZE), 244 + }, 245 + count: None, 246 + }], 247 + }) 248 + } 249 + 250 + fn create_pipeline( 251 + device: &wgpu::Device, 252 + bind_group_layout: &wgpu::BindGroupLayout, 253 + color_format: wgpu::TextureFormat, 254 + ) -> wgpu::RenderPipeline { 255 + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { 256 + label: Some("bone-render:text-shader"), 257 + source: wgpu::ShaderSource::Wgsl(include_str!("text.wgsl").into()), 258 + }); 259 + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { 260 + label: Some("bone-render:text-layout"), 261 + bind_group_layouts: &[Some(bind_group_layout)], 262 + immediate_size: 0, 263 + }); 264 + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { 265 + label: Some("bone-render:text-pipeline"), 266 + layout: Some(&pipeline_layout), 267 + vertex: wgpu::VertexState { 268 + module: &shader, 269 + entry_point: Some("vs"), 270 + compilation_options: wgpu::PipelineCompilationOptions::default(), 271 + buffers: &[wgpu::VertexBufferLayout { 272 + array_stride: VERTEX_STRIDE, 273 + step_mode: wgpu::VertexStepMode::Vertex, 274 + attributes: &VERTEX_ATTRS, 275 + }], 276 + }, 277 + fragment: Some(wgpu::FragmentState { 278 + module: &shader, 279 + entry_point: Some("fs"), 280 + compilation_options: wgpu::PipelineCompilationOptions::default(), 281 + targets: &[ 282 + Some(wgpu::ColorTargetState { 283 + format: color_format, 284 + blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING), 285 + write_mask: wgpu::ColorWrites::ALL, 286 + }), 287 + Some(wgpu::ColorTargetState { 288 + format: PICK_FORMAT, 289 + blend: None, 290 + write_mask: wgpu::ColorWrites::ALL, 291 + }), 292 + ], 293 + }), 294 + primitive: wgpu::PrimitiveState { 295 + topology: wgpu::PrimitiveTopology::TriangleList, 296 + strip_index_format: None, 297 + front_face: wgpu::FrontFace::Ccw, 298 + cull_mode: None, 299 + polygon_mode: wgpu::PolygonMode::Fill, 300 + conservative: false, 301 + unclipped_depth: false, 302 + }, 303 + depth_stencil: None, 304 + multisample: wgpu::MultisampleState::default(), 305 + multiview_mask: None, 306 + cache: None, 307 + }) 308 + } 309 + 310 + #[allow(clippy::cast_possible_truncation)] 311 + fn build_uniform(camera: Camera2, text: TextStyle) -> TextUniform { 312 + TextUniform { 313 + clip_from_world: camera.clip_from_world_mm(), 314 + text_color: text.color().to_rgba_array(), 315 + pixels_per_mm: camera.zoom().value() as f32, 316 + _pad0: 0.0, 317 + _pad1: 0.0, 318 + _pad2: 0.0, 319 + } 320 + } 321 + 322 + #[allow(clippy::cast_possible_truncation)] 323 + fn assemble_geometry( 324 + scene: &SketchScene, 325 + cache: &HashMap<SketchDimensionId, Cached>, 326 + ) -> (Vec<TextVertex>, Vec<u32>) { 327 + scene 328 + .dimensions() 329 + .iter() 330 + .fold((Vec::new(), Vec::new()), |(mut vs, mut is), d| { 331 + let Some(cached) = cache.get(&d.dimension()) else { 332 + return (vs, is); 333 + }; 334 + let (ax, ay) = d.anchor_mm().coords_mm(); 335 + let anchor = [ax as f32, ay as f32]; 336 + let pick_id = d.pick().raw(); 337 + let Ok(base) = u32::try_from(vs.len()) else { 338 + panic!("text vertex count exceeds u32::MAX"); 339 + }; 340 + vs.extend(cached.tess.vertices_px.iter().map(|offset| TextVertex { 341 + anchor_mm: anchor, 342 + offset_px: *offset, 343 + pick_id, 344 + _pad: 0, 345 + })); 346 + is.extend(cached.tess.indices.iter().map(|idx| base + idx)); 347 + (vs, is) 348 + }) 349 + } 350 + 351 + fn tessellate( 352 + text: &str, 353 + size_px: f32, 354 + font: &FontRef<'_>, 355 + scale_ctx: &mut ScaleContext, 356 + tessellator: &mut FillTessellator, 357 + ) -> Tessellated { 358 + let mut scaler = scale_ctx.builder(*font).size(size_px).build(); 359 + let charmap = font.charmap(); 360 + let glyph_metrics = font.glyph_metrics(&[]).scale(size_px); 361 + let metrics = font.metrics(&[]).scale(size_px); 362 + 363 + let placements = text 364 + .chars() 365 + .map(|c| charmap.map(u32::from(c))) 366 + .scan(0.0_f32, |cursor, glyph_id| { 367 + let advance = glyph_metrics.advance_width(glyph_id); 368 + let origin = *cursor; 369 + *cursor += advance; 370 + Some((glyph_id, origin, *cursor)) 371 + }) 372 + .collect::<Vec<_>>(); 373 + 374 + let total_width = placements.last().map_or(0.0, |(_, _, end)| *end); 375 + let center_x = -total_width * 0.5; 376 + let center_y = -metrics.cap_height * 0.5; 377 + 378 + let path = build_path(&placements, &mut scaler, center_x, center_y); 379 + let mut buffers: VertexBuffers<[f32; 2], u32> = VertexBuffers::new(); 380 + if let Err(e) = tessellator.tessellate_path( 381 + &path, 382 + &FillOptions::default().with_tolerance(0.2), 383 + &mut BuffersBuilder::new(&mut buffers, |vertex: FillVertex| { 384 + [vertex.position().x, vertex.position().y] 385 + }), 386 + ) { 387 + tracing::warn!(error = %e, text, size_px, "text tessellation failed"); 388 + } 389 + Tessellated { 390 + vertices_px: buffers.vertices, 391 + indices: buffers.indices, 392 + } 393 + } 394 + 395 + fn build_path( 396 + placements: &[(u16, f32, f32)], 397 + scaler: &mut swash::scale::Scaler<'_>, 398 + offset_x: f32, 399 + offset_y: f32, 400 + ) -> LyonPath { 401 + placements 402 + .iter() 403 + .fold(LyonPath::svg_builder(), |mut builder, (glyph_id, origin, _)| { 404 + let mut outline = Outline::new(); 405 + if scaler.scale_outline_into(*glyph_id, &mut outline) { 406 + let mut adapter = LyonAdapter::new(&mut builder, offset_x + origin, offset_y); 407 + outline.path().copy_to(&mut adapter); 408 + } 409 + builder 410 + }) 411 + .build() 412 + } 413 + 414 + struct LyonAdapter<'a> { 415 + builder: &'a mut WithSvg<BuilderImpl>, 416 + offset_x: f32, 417 + offset_y: f32, 418 + current: ZenoPoint, 419 + open: bool, 420 + } 421 + 422 + impl<'a> LyonAdapter<'a> { 423 + fn new(builder: &'a mut WithSvg<BuilderImpl>, offset_x: f32, offset_y: f32) -> Self { 424 + Self { 425 + builder, 426 + offset_x, 427 + offset_y, 428 + current: ZenoPoint::new(0.0, 0.0), 429 + open: false, 430 + } 431 + } 432 + 433 + fn transform(&self, p: ZenoPoint) -> LyonPoint { 434 + LyonPoint::new(p.x + self.offset_x, p.y + self.offset_y) 435 + } 436 + } 437 + 438 + impl ZenoPathBuilder for LyonAdapter<'_> { 439 + fn current_point(&self) -> ZenoPoint { 440 + self.current 441 + } 442 + 443 + fn move_to(&mut self, to: impl Into<ZenoPoint>) -> &mut Self { 444 + let p = to.into(); 445 + self.current = p; 446 + self.builder.move_to(self.transform(p)); 447 + self.open = true; 448 + self 449 + } 450 + 451 + fn line_to(&mut self, to: impl Into<ZenoPoint>) -> &mut Self { 452 + let p = to.into(); 453 + self.current = p; 454 + self.builder.line_to(self.transform(p)); 455 + self 456 + } 457 + 458 + fn quad_to( 459 + &mut self, 460 + control: impl Into<ZenoPoint>, 461 + to: impl Into<ZenoPoint>, 462 + ) -> &mut Self { 463 + let c = control.into(); 464 + let p = to.into(); 465 + self.current = p; 466 + self.builder 467 + .quadratic_bezier_to(self.transform(c), self.transform(p)); 468 + self 469 + } 470 + 471 + fn curve_to( 472 + &mut self, 473 + control1: impl Into<ZenoPoint>, 474 + control2: impl Into<ZenoPoint>, 475 + to: impl Into<ZenoPoint>, 476 + ) -> &mut Self { 477 + let c1 = control1.into(); 478 + let c2 = control2.into(); 479 + let p = to.into(); 480 + self.current = p; 481 + self.builder 482 + .cubic_bezier_to(self.transform(c1), self.transform(c2), self.transform(p)); 483 + self 484 + } 485 + 486 + fn close(&mut self) -> &mut Self { 487 + if self.open { 488 + self.builder.close(); 489 + self.open = false; 490 + } 491 + self 492 + } 493 + }
+45
crates/bone-render/src/pipelines/text.wgsl
··· 1 + struct Frame { 2 + clip_from_world: mat4x4<f32>, 3 + text_color: vec4<f32>, 4 + pixels_per_mm: f32, 5 + _pad0: f32, 6 + _pad1: f32, 7 + _pad2: f32, 8 + }; 9 + 10 + struct Vertex { 11 + @location(0) anchor_mm: vec2<f32>, 12 + @location(1) offset_px: vec2<f32>, 13 + @location(2) pick_id: u32, 14 + }; 15 + 16 + struct VsOut { 17 + @builtin(position) clip: vec4<f32>, 18 + @location(0) @interpolate(flat) pick_id: u32, 19 + }; 20 + 21 + struct FsOut { 22 + @location(0) color: vec4<f32>, 23 + @location(1) pick_id: u32, 24 + }; 25 + 26 + @group(0) @binding(0) var<uniform> u: Frame; 27 + 28 + @vertex 29 + fn vs(in: Vertex) -> VsOut { 30 + let pos_mm = in.anchor_mm + in.offset_px / u.pixels_per_mm; 31 + let clip = u.clip_from_world * vec4<f32>(pos_mm, 0.0, 1.0); 32 + var out: VsOut; 33 + out.clip = clip; 34 + out.pick_id = in.pick_id; 35 + return out; 36 + } 37 + 38 + @fragment 39 + fn fs(in: VsOut) -> FsOut { 40 + let c = u.text_color; 41 + var out: FsOut; 42 + out.color = vec4<f32>(c.rgb * c.a, c.a); 43 + out.pick_id = in.pick_id; 44 + return out; 45 + }
+310 -8
crates/bone-render/src/scene.rs
··· 1 - use bone_document::{Sketch, SketchEntity}; 1 + use bone_document::{ 2 + ArcData, CircleData, DimensionValue, LineData, Sketch, SketchDimension, SketchEntity, 3 + SketchRelation, 4 + }; 2 5 use bone_kernel::{Aabb2, arc_bounding_box}; 3 - use bone_types::{Angle, Length, Point2, SketchEntityId}; 4 - use core::f64::consts::TAU; 5 - use uom::si::angle::radian; 6 + use bone_types::{ 7 + Angle, Length, Point2, SketchDimensionId, SketchEntityId, SketchRelationId, Vec2, 8 + }; 9 + use core::f64::consts::{FRAC_1_SQRT_2, TAU}; 10 + use uom::si::angle::{degree, radian}; 6 11 use uom::si::length::millimeter; 7 12 8 13 use crate::pick::{EntityKindTag, PickId, PickIdError, PickIndex}; ··· 131 136 } 132 137 } 133 138 139 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] 140 + #[repr(u32)] 141 + pub enum RelationGlyphKind { 142 + Coincident = 0, 143 + Horizontal = 1, 144 + Vertical = 2, 145 + Parallel = 3, 146 + Perpendicular = 4, 147 + Tangent = 5, 148 + Equal = 6, 149 + Concentric = 7, 150 + Fix = 8, 151 + } 152 + 153 + impl RelationGlyphKind { 154 + #[must_use] 155 + pub const fn tile_index(self) -> u32 { 156 + self as u32 157 + } 158 + 159 + #[must_use] 160 + pub const fn from_index(idx: u32) -> Option<Self> { 161 + match idx { 162 + 0 => Some(Self::Coincident), 163 + 1 => Some(Self::Horizontal), 164 + 2 => Some(Self::Vertical), 165 + 3 => Some(Self::Parallel), 166 + 4 => Some(Self::Perpendicular), 167 + 5 => Some(Self::Tangent), 168 + 6 => Some(Self::Equal), 169 + 7 => Some(Self::Concentric), 170 + 8 => Some(Self::Fix), 171 + _ => None, 172 + } 173 + } 174 + 175 + #[must_use] 176 + pub const fn from_relation(rel: SketchRelation) -> Self { 177 + match rel { 178 + SketchRelation::Coincident(_, _) => Self::Coincident, 179 + SketchRelation::Horizontal(_) => Self::Horizontal, 180 + SketchRelation::Vertical(_) => Self::Vertical, 181 + SketchRelation::Parallel(_, _) => Self::Parallel, 182 + SketchRelation::Perpendicular(_, _) => Self::Perpendicular, 183 + SketchRelation::Tangent(_, _) => Self::Tangent, 184 + SketchRelation::Equal(_, _) => Self::Equal, 185 + SketchRelation::Concentric(_, _) => Self::Concentric, 186 + SketchRelation::Fix(_) => Self::Fix, 187 + } 188 + } 189 + 190 + #[must_use] 191 + pub const fn all() -> [Self; 9] { 192 + [ 193 + Self::Coincident, 194 + Self::Horizontal, 195 + Self::Vertical, 196 + Self::Parallel, 197 + Self::Perpendicular, 198 + Self::Tangent, 199 + Self::Equal, 200 + Self::Concentric, 201 + Self::Fix, 202 + ] 203 + } 204 + } 205 + 206 + #[derive(Copy, Clone, Debug, PartialEq)] 207 + pub struct SceneRelationGlyph { 208 + anchor_mm: Point2, 209 + offset_dir: Vec2, 210 + kind: RelationGlyphKind, 211 + relation: SketchRelationId, 212 + pick: PickId, 213 + } 214 + 215 + impl SceneRelationGlyph { 216 + #[must_use] 217 + pub const fn anchor_mm(self) -> Point2 { 218 + self.anchor_mm 219 + } 220 + 221 + #[must_use] 222 + pub const fn offset_dir(self) -> Vec2 { 223 + self.offset_dir 224 + } 225 + 226 + #[must_use] 227 + pub const fn kind(self) -> RelationGlyphKind { 228 + self.kind 229 + } 230 + 231 + #[must_use] 232 + pub const fn relation(self) -> SketchRelationId { 233 + self.relation 234 + } 235 + 236 + #[must_use] 237 + pub const fn pick(self) -> PickId { 238 + self.pick 239 + } 240 + } 241 + 242 + #[derive(Clone, Debug, PartialEq)] 243 + pub struct SceneDimension { 244 + anchor_mm: Point2, 245 + text: String, 246 + dimension: SketchDimensionId, 247 + pick: PickId, 248 + } 249 + 250 + impl SceneDimension { 251 + #[must_use] 252 + pub const fn anchor_mm(&self) -> Point2 { 253 + self.anchor_mm 254 + } 255 + 256 + #[must_use] 257 + pub fn text(&self) -> &str { 258 + &self.text 259 + } 260 + 261 + #[must_use] 262 + pub const fn dimension(&self) -> SketchDimensionId { 263 + self.dimension 264 + } 265 + 266 + #[must_use] 267 + pub const fn pick(&self) -> PickId { 268 + self.pick 269 + } 270 + } 271 + 134 272 #[derive(Clone, Debug, Default, PartialEq)] 135 273 pub struct SketchScene { 136 274 points: Vec<ScenePoint>, 137 275 lines: Vec<SceneLine>, 138 276 arcs: Vec<SceneArc>, 139 277 circles: Vec<SceneCircle>, 278 + relations: Vec<SceneRelationGlyph>, 279 + dimensions: Vec<SceneDimension>, 140 280 } 141 281 142 282 impl SketchScene { ··· 147 287 lines: Vec::new(), 148 288 arcs: Vec::new(), 149 289 circles: Vec::new(), 290 + relations: Vec::new(), 291 + dimensions: Vec::new(), 150 292 } 151 293 } 152 294 153 295 pub fn extract(sketch: &Sketch) -> Result<Self, PickIdError> { 154 - sketch 296 + let with_entities = sketch 155 297 .entity_order() 156 298 .iter() 157 299 .copied() 158 - .try_fold(Self::empty(), |acc, id| acc.push_from_sketch(sketch, id)) 300 + .try_fold(Self::empty(), |acc, id| acc.push_from_sketch(sketch, id))?; 301 + let with_relations = sketch 302 + .relation_order() 303 + .iter() 304 + .copied() 305 + .try_fold(with_entities, |acc, id| acc.push_relation(sketch, id))?; 306 + sketch 307 + .dimension_order() 308 + .iter() 309 + .copied() 310 + .try_fold(with_relations, |acc, id| acc.push_dimension(sketch, id)) 159 311 } 160 312 161 313 #[must_use] ··· 179 331 } 180 332 181 333 #[must_use] 334 + pub fn relations(&self) -> &[SceneRelationGlyph] { 335 + &self.relations 336 + } 337 + 338 + #[must_use] 339 + pub fn dimensions(&self) -> &[SceneDimension] { 340 + &self.dimensions 341 + } 342 + 343 + #[must_use] 182 344 pub fn is_empty(&self) -> bool { 183 345 self.points.is_empty() 184 346 && self.lines.is_empty() 185 347 && self.arcs.is_empty() 186 348 && self.circles.is_empty() 349 + && self.relations.is_empty() 350 + && self.dimensions.is_empty() 187 351 } 188 352 189 353 #[must_use] ··· 226 390 ); 227 391 PickIndex::build( 228 392 entities, 229 - core::iter::empty::<bone_types::SketchRelationId>(), 230 - core::iter::empty::<bone_types::SketchDimensionId>(), 393 + self.relations.iter().map(|g| g.relation), 394 + self.dimensions.iter().map(|d| d.dimension), 231 395 ) 232 396 } 233 397 398 + fn push_relation( 399 + mut self, 400 + sketch: &Sketch, 401 + id: SketchRelationId, 402 + ) -> Result<Self, PickIdError> { 403 + let Some(rel) = sketch.relations().get(id).copied() else { 404 + return Ok(self); 405 + }; 406 + let Some((anchor, offset_dir)) = relation_anchor(sketch, rel) else { 407 + return Ok(self); 408 + }; 409 + self.relations.push(SceneRelationGlyph { 410 + anchor_mm: anchor, 411 + offset_dir, 412 + kind: RelationGlyphKind::from_relation(rel), 413 + relation: id, 414 + pick: PickId::relation(id)?, 415 + }); 416 + Ok(self) 417 + } 418 + 419 + fn push_dimension( 420 + mut self, 421 + sketch: &Sketch, 422 + id: SketchDimensionId, 423 + ) -> Result<Self, PickIdError> { 424 + let Some(dim) = sketch.dimensions().get(id).copied() else { 425 + return Ok(self); 426 + }; 427 + let Some(anchor) = dimension_anchor(sketch, dim) else { 428 + return Ok(self); 429 + }; 430 + self.dimensions.push(SceneDimension { 431 + anchor_mm: anchor, 432 + text: format_dimension(dim), 433 + dimension: id, 434 + pick: PickId::dimension(id)?, 435 + }); 436 + Ok(self) 437 + } 438 + 234 439 fn push_from_sketch( 235 440 mut self, 236 441 sketch: &Sketch, ··· 290 495 ); 291 496 }; 292 497 p.at() 498 + } 499 + 500 + fn relation_anchor(sketch: &Sketch, rel: SketchRelation) -> Option<(Point2, Vec2)> { 501 + rel.references() 502 + .into_iter() 503 + .next() 504 + .and_then(|id| entity_anchor(sketch, id)) 505 + } 506 + 507 + fn entity_anchor(sketch: &Sketch, id: SketchEntityId) -> Option<(Point2, Vec2)> { 508 + let e = sketch.entities().get(id)?; 509 + Some(match *e { 510 + SketchEntity::Point(p) => ( 511 + p.at(), 512 + Vec2::from_mm(FRAC_1_SQRT_2, FRAC_1_SQRT_2), 513 + ), 514 + SketchEntity::Line(l) => line_anchor(sketch, l), 515 + SketchEntity::Arc(a) => arc_anchor(sketch, a), 516 + SketchEntity::Circle(c) => circle_anchor(sketch, c), 517 + }) 518 + } 519 + 520 + fn line_anchor(sketch: &Sketch, line: LineData) -> (Point2, Vec2) { 521 + let (ax, ay) = point_position(sketch, line.a()).coords_mm(); 522 + let (bx, by) = point_position(sketch, line.b()).coords_mm(); 523 + let mid = Point2::from_mm(0.5 * (ax + bx), 0.5 * (ay + by)); 524 + let tangent = Vec2::from_mm(bx - ax, by - ay); 525 + (mid, unit_or(tangent.perp_ccw(), Vec2::from_mm(0.0, 1.0))) 526 + } 527 + 528 + fn arc_anchor(sketch: &Sketch, arc: ArcData) -> (Point2, Vec2) { 529 + let center = point_position(sketch, arc.center()); 530 + let start = point_position(sketch, arc.start()); 531 + let (cx, cy) = center.coords_mm(); 532 + let (sx, sy) = start.coords_mm(); 533 + let radial = Vec2::from_mm(sx - cx, sy - cy); 534 + (start, unit_or(radial, Vec2::from_mm(1.0, 0.0))) 535 + } 536 + 537 + fn circle_anchor(sketch: &Sketch, circle: CircleData) -> (Point2, Vec2) { 538 + let (cx, cy) = point_position(sketch, circle.center()).coords_mm(); 539 + let r_mm = circle.radius().get::<millimeter>(); 540 + ( 541 + Point2::from_mm(cx + r_mm, cy), 542 + Vec2::from_mm(1.0, 0.0), 543 + ) 544 + } 545 + 546 + fn dimension_anchor(sketch: &Sketch, dim: SketchDimension) -> Option<Point2> { 547 + match dim { 548 + SketchDimension::Linear { a, b, .. } | SketchDimension::Angular { a, b, .. } => { 549 + let (ax, ay) = entity_anchor(sketch, a)?.0.coords_mm(); 550 + let (bx, by) = entity_anchor(sketch, b)?.0.coords_mm(); 551 + Some(Point2::from_mm(0.5 * (ax + bx), 0.5 * (ay + by))) 552 + } 553 + SketchDimension::Radius { target, .. } | SketchDimension::Diameter { target, .. } => { 554 + target_center(sketch, target) 555 + } 556 + } 557 + } 558 + 559 + fn target_center(sketch: &Sketch, id: SketchEntityId) -> Option<Point2> { 560 + match sketch.entities().get(id)? { 561 + SketchEntity::Arc(a) => Some(point_position(sketch, a.center())), 562 + SketchEntity::Circle(c) => Some(point_position(sketch, c.center())), 563 + _ => None, 564 + } 565 + } 566 + 567 + fn format_dimension(dim: SketchDimension) -> String { 568 + match (dim, dim.value()) { 569 + (SketchDimension::Linear { .. }, DimensionValue::Length(len)) => { 570 + format!("{:.2} mm", len.get::<millimeter>()) 571 + } 572 + (SketchDimension::Radius { .. }, DimensionValue::Length(len)) => { 573 + format!("R {:.2}", len.get::<millimeter>()) 574 + } 575 + (SketchDimension::Diameter { .. }, DimensionValue::Length(len)) => { 576 + format!("D {:.2}", len.get::<millimeter>()) 577 + } 578 + (SketchDimension::Angular { .. }, DimensionValue::Angle(a)) => { 579 + format!("{:.1}°", a.get::<degree>()) 580 + } 581 + _ => unreachable!( 582 + "SketchDimension::value pins Length for Linear/Radius/Diameter and Angle for Angular" 583 + ), 584 + } 585 + } 586 + 587 + fn unit_or(v: Vec2, fallback: Vec2) -> Vec2 { 588 + let len = v.norm_mm(); 589 + if len > 1e-9 { 590 + let (x, y) = v.coords_mm(); 591 + Vec2::from_mm(x / len, y / len) 592 + } else { 593 + fallback 594 + } 293 595 } 294 596 295 597 fn derive_arc(
+92
crates/bone-render/src/snapshot.rs
··· 190 190 } 191 191 192 192 #[derive(Copy, Clone, Debug, PartialEq)] 193 + pub struct GlyphStyle { 194 + color: ClearColor, 195 + offset_px: f32, 196 + tile_px: f32, 197 + } 198 + 199 + impl GlyphStyle { 200 + pub const DEFAULT: Self = Self { 201 + color: ClearColor::new(0.35, 0.78, 0.35, 1.0), 202 + offset_px: 18.0, 203 + tile_px: 22.0, 204 + }; 205 + 206 + #[must_use] 207 + pub const fn color(self) -> ClearColor { 208 + self.color 209 + } 210 + 211 + #[must_use] 212 + pub const fn offset_px(self) -> f32 { 213 + self.offset_px 214 + } 215 + 216 + #[must_use] 217 + pub const fn tile_px(self) -> f32 { 218 + self.tile_px 219 + } 220 + } 221 + 222 + impl Default for GlyphStyle { 223 + fn default() -> Self { 224 + Self::DEFAULT 225 + } 226 + } 227 + 228 + #[derive(Copy, Clone, Debug, PartialEq)] 229 + pub struct TextStyle { 230 + color: ClearColor, 231 + font_size_px: f32, 232 + } 233 + 234 + impl TextStyle { 235 + pub const DEFAULT: Self = Self { 236 + color: ClearColor::new(0.95, 0.95, 0.98, 1.0), 237 + font_size_px: 14.0, 238 + }; 239 + 240 + #[must_use] 241 + pub const fn color(self) -> ClearColor { 242 + self.color 243 + } 244 + 245 + #[must_use] 246 + pub const fn font_size_px(self) -> f32 { 247 + self.font_size_px 248 + } 249 + 250 + #[must_use] 251 + pub const fn with_font_size_px(self, font_size_px: f32) -> Self { 252 + Self { 253 + font_size_px, 254 + ..self 255 + } 256 + } 257 + } 258 + 259 + impl Default for TextStyle { 260 + fn default() -> Self { 261 + Self::DEFAULT 262 + } 263 + } 264 + 265 + #[derive(Copy, Clone, Debug, PartialEq)] 193 266 pub struct Style { 194 267 background: ClearColor, 195 268 grid: GridStyle, 196 269 strokes: StrokeStyle, 270 + glyphs: GlyphStyle, 271 + text: TextStyle, 197 272 } 198 273 199 274 impl Style { ··· 203 278 background, 204 279 grid: GridStyle::DEFAULT, 205 280 strokes: StrokeStyle::DEFAULT, 281 + glyphs: GlyphStyle::DEFAULT, 282 + text: TextStyle::DEFAULT, 206 283 } 207 284 } 208 285 ··· 219 296 #[must_use] 220 297 pub const fn strokes(self) -> StrokeStyle { 221 298 self.strokes 299 + } 300 + 301 + #[must_use] 302 + pub const fn glyphs(self) -> GlyphStyle { 303 + self.glyphs 304 + } 305 + 306 + #[must_use] 307 + pub const fn text(self) -> TextStyle { 308 + self.text 309 + } 310 + 311 + #[must_use] 312 + pub const fn with_text(self, text: TextStyle) -> Self { 313 + Self { text, ..self } 222 314 } 223 315 } 224 316
+1 -1
crates/bone-render/tests/arcs.rs
··· 98 98 fn arc_circle_at_100x_zoom_matches_golden() { 99 99 let size = extent(256); 100 100 let ctx = make_context(size); 101 - let renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format()); 101 + let mut renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format()); 102 102 let scene = arc_circle_scene(); 103 103 let camera = Camera2::new(size).with_zoom(PixelsPerMm::new(100.0)); 104 104 let style = Style::default();
+1 -1
crates/bone-render/tests/construction_styling.rs
··· 113 113 114 114 fn render_scene(ctx: &OffscreenContext, scene: &SketchScene) -> SnapshotFrame { 115 115 let camera = Camera2::new(ctx.extent()).with_zoom(PixelsPerMm::new(100.0)); 116 - let renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format()); 116 + let mut renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format()); 117 117 let style = Style::default(); 118 118 let Ok(frame) = renderer.render(ctx, scene, camera, &style) else { 119 119 panic!("SketchRenderer::render failed");
+265
crates/bone-render/tests/dimensions.rs
··· 1 + use std::path::PathBuf; 2 + 3 + use bone_document::{ 4 + DimensionKind, EditOutcome, Sketch, SketchDimension, SketchEdit, SketchEntity, 5 + }; 6 + use bone_render::{ 7 + Camera2, OffscreenContext, PixelDiff, PixelDiffThreshold, PixelsPerMm, RenderError, 8 + SketchRenderer, SketchScene, Style, ViewportExtent, ViewportPx, decode_png, encode_png, 9 + }; 10 + use bone_types::{ 11 + Angle, Length, Point2, Point3, SketchEntityId, SketchPlaneBasis, Tolerance, UnitVec3, 12 + }; 13 + use uom::si::angle::degree; 14 + use uom::si::length::millimeter; 15 + 16 + const GOLDEN: &str = "tests/goldens/dimensions_256.png"; 17 + const UPDATE_ENV: &str = "BONE_UPDATE_DIMENSIONS_GOLDEN"; 18 + const DIFF_TOLERANCE: f64 = 20.0 / 255.0; 19 + 20 + fn extent(side: u32) -> ViewportExtent { 21 + ViewportExtent::square(ViewportPx::new(side)) 22 + } 23 + 24 + fn make_context(extent: ViewportExtent) -> OffscreenContext { 25 + match pollster::block_on(OffscreenContext::new(extent)) { 26 + Ok(ctx) => ctx, 27 + Err(RenderError::NoAdapter(e)) => panic!( 28 + "no wgpu adapter available, configure lavapipe or an iGPU for this test host: {e}" 29 + ), 30 + Err(e) => panic!("offscreen context init failed: {e}"), 31 + } 32 + } 33 + 34 + fn plane() -> SketchPlaneBasis { 35 + let Ok(basis) = SketchPlaneBasis::new( 36 + Point3::origin(), 37 + UnitVec3::x_axis(), 38 + UnitVec3::y_axis(), 39 + Tolerance::new(1e-9), 40 + ) else { 41 + panic!("xy plane basis is orthogonal"); 42 + }; 43 + basis 44 + } 45 + 46 + fn add_point(s: Sketch, x: f64, y: f64) -> (Sketch, SketchEntityId) { 47 + let Ok((next, EditOutcome::Entity(id))) = s.apply(SketchEdit::AddEntity(SketchEntity::point( 48 + Point2::from_mm(x, y), 49 + ))) else { 50 + panic!("add point"); 51 + }; 52 + (next, id) 53 + } 54 + 55 + fn add_line(s: Sketch, a: SketchEntityId, b: SketchEntityId) -> (Sketch, SketchEntityId) { 56 + let Ok((next, EditOutcome::Entity(id))) = 57 + s.apply(SketchEdit::AddEntity(SketchEntity::line(a, b, false))) 58 + else { 59 + panic!("add line"); 60 + }; 61 + (next, id) 62 + } 63 + 64 + fn add_circle(s: Sketch, center: SketchEntityId, radius_mm: f64) -> (Sketch, SketchEntityId) { 65 + let Ok((next, EditOutcome::Entity(id))) = s.apply(SketchEdit::AddEntity(SketchEntity::circle( 66 + center, 67 + Length::new::<millimeter>(radius_mm), 68 + false, 69 + ))) else { 70 + panic!("add circle"); 71 + }; 72 + (next, id) 73 + } 74 + 75 + fn add_dimension(s: Sketch, dim: SketchDimension) -> Sketch { 76 + let Ok((next, _)) = s.apply(SketchEdit::AddDimension(dim)) else { 77 + panic!("add dimension {dim:?}"); 78 + }; 79 + next 80 + } 81 + 82 + fn four_kind_scene() -> (Sketch, SketchScene) { 83 + let s = Sketch::new(plane()); 84 + 85 + let (s, lp0) = add_point(s, -6.0, 3.0); 86 + let (s, lp1) = add_point(s, 0.0, 3.0); 87 + let s = add_dimension( 88 + s, 89 + SketchDimension::Linear { 90 + a: lp0, 91 + b: lp1, 92 + value: Length::new::<millimeter>(6.0), 93 + kind: DimensionKind::Driving, 94 + }, 95 + ); 96 + 97 + let (s, rc) = add_point(s, 4.5, 3.0); 98 + let (s, rcirc) = add_circle(s, rc, 1.5); 99 + let s = add_dimension( 100 + s, 101 + SketchDimension::Radius { 102 + target: rcirc, 103 + value: Length::new::<millimeter>(1.5), 104 + kind: DimensionKind::Driving, 105 + }, 106 + ); 107 + 108 + let (s, dc) = add_point(s, -3.0, -2.5); 109 + let (s, dcirc) = add_circle(s, dc, 2.0); 110 + let s = add_dimension( 111 + s, 112 + SketchDimension::Diameter { 113 + target: dcirc, 114 + value: Length::new::<millimeter>(4.0), 115 + kind: DimensionKind::Driving, 116 + }, 117 + ); 118 + 119 + let (s, ap0) = add_point(s, 2.5, -4.0); 120 + let (s, ap1) = add_point(s, 5.5, -4.0); 121 + let (s, aline_a) = add_line(s, ap0, ap1); 122 + let (s, ap2) = add_point(s, 4.0, -1.0); 123 + let (s, aline_b) = add_line(s, ap0, ap2); 124 + let s = add_dimension( 125 + s, 126 + SketchDimension::Angular { 127 + a: aline_a, 128 + b: aline_b, 129 + value: Angle::new::<degree>(45.0), 130 + kind: DimensionKind::Driving, 131 + }, 132 + ); 133 + 134 + let Ok(scene) = SketchScene::extract(&s) else { 135 + panic!("scene extract"); 136 + }; 137 + (s, scene) 138 + } 139 + 140 + fn golden_path() -> PathBuf { 141 + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(GOLDEN) 142 + } 143 + 144 + #[test] 145 + fn four_dimension_kinds_match_golden() { 146 + let size = extent(256); 147 + let ctx = make_context(size); 148 + let (_sketch, scene) = four_kind_scene(); 149 + assert_eq!(scene.dimensions().len(), 4); 150 + let mut renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format()); 151 + let camera = Camera2::new(ctx.extent()).with_zoom(PixelsPerMm::new(18.0)); 152 + let style = Style::default(); 153 + let Ok(frame) = renderer.render(&ctx, &scene, camera, &style) else { 154 + panic!("SketchRenderer::render failed"); 155 + }; 156 + let path = golden_path(); 157 + 158 + if std::env::var(UPDATE_ENV).is_ok() { 159 + let Ok(bytes) = encode_png(&frame) else { 160 + panic!("encode_png failed"); 161 + }; 162 + if let Some(parent) = path.parent() 163 + && let Err(e) = std::fs::create_dir_all(parent) 164 + { 165 + panic!("create goldens dir {}: {e}", parent.display()); 166 + } 167 + if let Err(e) = std::fs::write(&path, &bytes) { 168 + panic!("write golden {}: {e}", path.display()); 169 + } 170 + return; 171 + } 172 + 173 + let Ok(bytes) = std::fs::read(&path) else { 174 + panic!( 175 + "golden missing at {}: rerun with {UPDATE_ENV}=1 to generate", 176 + path.display() 177 + ); 178 + }; 179 + let Ok((golden_extent, golden_rgba)) = decode_png(&bytes) else { 180 + panic!("failed to decode golden PNG"); 181 + }; 182 + assert_eq!(golden_extent, size); 183 + let threshold = PixelDiffThreshold::new(DIFF_TOLERANCE); 184 + let Ok(report) = PixelDiff::compare(&frame, &golden_rgba, threshold) else { 185 + panic!("PixelDiff rejected inputs"); 186 + }; 187 + assert!( 188 + report.is_clean(), 189 + "dimension render drifted: {} mismatches, worst {:?}, backend {}", 190 + report.over_threshold(), 191 + report.worst(), 192 + frame.backend(), 193 + ); 194 + } 195 + 196 + #[test] 197 + fn cache_reuses_across_repeated_renders_same_text() { 198 + let size = extent(128); 199 + let ctx = make_context(size); 200 + let (_sketch, scene) = four_kind_scene(); 201 + let mut renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format()); 202 + renderer.prepare(&scene, &Style::default()); 203 + let first_len = renderer.text_cache_len(); 204 + renderer.prepare(&scene, &Style::default()); 205 + let second_len = renderer.text_cache_len(); 206 + assert_eq!( 207 + first_len, second_len, 208 + "cache size should be stable across repeated prepares on identical scene", 209 + ); 210 + } 211 + 212 + #[test] 213 + fn cache_refreshes_on_value_change() { 214 + use bone_document::SketchEdit; 215 + let size = extent(128); 216 + let ctx = make_context(size); 217 + let (sketch, scene) = four_kind_scene(); 218 + let mut renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format()); 219 + renderer.prepare(&scene, &Style::default()); 220 + let initial_len = renderer.text_cache_len(); 221 + 222 + let Some(&target_id) = sketch.dimension_order().first() else { 223 + panic!("scene has at least one dimension"); 224 + }; 225 + let Ok((updated_sketch, _)) = sketch.apply(SketchEdit::UpdateDimensionValue { 226 + id: target_id, 227 + value: bone_document::DimensionValue::Length(Length::new::<millimeter>(999.0)), 228 + }) else { 229 + panic!("update dimension value"); 230 + }; 231 + let Ok(updated_scene) = SketchScene::extract(&updated_sketch) else { 232 + panic!("scene extract after update"); 233 + }; 234 + 235 + let Some(first) = updated_scene.dimensions().first() else { 236 + panic!("updated scene has dimensions"); 237 + }; 238 + let new_text = first.text().to_owned(); 239 + assert!( 240 + new_text.contains("999"), 241 + "expected refreshed text to reflect updated value, got {new_text:?}", 242 + ); 243 + 244 + renderer.prepare(&updated_scene, &Style::default()); 245 + let refreshed_len = renderer.text_cache_len(); 246 + assert_eq!(initial_len, refreshed_len); 247 + } 248 + 249 + #[test] 250 + fn cache_refreshes_on_font_size_change() { 251 + let size = extent(128); 252 + let ctx = make_context(size); 253 + let (_sketch, scene) = four_kind_scene(); 254 + let mut renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format()); 255 + let small = Style::default(); 256 + renderer.prepare(&scene, &small); 257 + let baseline_len = renderer.text_cache_len(); 258 + let big = Style::default().with_text(small.text().with_font_size_px(28.0)); 259 + renderer.prepare(&scene, &big); 260 + assert_eq!( 261 + baseline_len, 262 + renderer.text_cache_len(), 263 + "font-size change must keep cache size equal to dimension count", 264 + ); 265 + }
crates/bone-render/tests/goldens/dimensions_256.png

This is a binary file and will not be displayed.

+1 -1
crates/bone-render/tests/grid.rs
··· 33 33 fn grid_empty_sketch_matches_golden() { 34 34 let size = extent(256); 35 35 let ctx = make_context(size); 36 - let renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format()); 36 + let mut renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format()); 37 37 let scene = SketchScene::empty(); 38 38 let camera = Camera2::new(size); 39 39 let style = Style::default();
+4 -4
crates/bone-render/tests/picker.rs
··· 133 133 #[test] 134 134 fn each_entity_centroid_round_trips_to_pick_id() { 135 135 let ctx = make_context(); 136 - let renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format()); 136 + let mut renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format()); 137 137 let camera = Camera2::new(extent()); 138 138 let style = Style::default(); 139 139 ··· 177 177 #[test] 178 178 fn empty_space_decodes_to_none() { 179 179 let ctx = make_context(); 180 - let renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format()); 180 + let mut renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format()); 181 181 let camera = Camera2::new(extent()); 182 182 let style = Style::default(); 183 183 ··· 200 200 #[test] 201 201 fn repeated_render_picks_deterministic() { 202 202 let ctx = make_context(); 203 - let renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format()); 203 + let mut renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format()); 204 204 let camera = Camera2::new(extent()); 205 205 let style = Style::default(); 206 206 ··· 209 209 panic!("pick index"); 210 210 }; 211 211 212 - let collect_picks = || { 212 + let mut collect_picks = || { 213 213 let Ok(_) = renderer.render(&ctx, &fx.scene, camera, &style) else { 214 214 panic!("render"); 215 215 };
+1 -1
crates/bone-render/tests/rectangle.rs
··· 79 79 fn rectangle_sketch_matches_golden() { 80 80 let size = extent(256); 81 81 let ctx = make_context(size); 82 - let renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format()); 82 + let mut renderer = SketchRenderer::new(ctx.gpu(), ctx.color_format()); 83 83 let scene = rectangle_scene(); 84 84 let camera = Camera2::new(size); 85 85 let style = Style::default();