Another project
0

Configure Feed

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

at main 14 kB View raw
1use bone_types::LinearRgba; 2 3use crate::camera::ViewportExtent; 4use crate::gpu::BackendTag; 5use crate::{RenderError, Result}; 6 7#[derive(Copy, Clone, Debug, PartialEq)] 8pub struct ClearColor { 9 r: f64, 10 g: f64, 11 b: f64, 12 a: f64, 13} 14 15impl ClearColor { 16 #[must_use] 17 pub const fn new(r: f64, g: f64, b: f64, a: f64) -> Self { 18 Self { r, g, b, a } 19 } 20 21 #[must_use] 22 pub const fn opaque(r: f64, g: f64, b: f64) -> Self { 23 Self::new(r, g, b, 1.0) 24 } 25 26 #[must_use] 27 pub fn to_rgba8(self) -> [u8; 4] { 28 [ 29 channel_to_u8(self.r), 30 channel_to_u8(self.g), 31 channel_to_u8(self.b), 32 channel_to_u8(self.a), 33 ] 34 } 35 36 #[must_use] 37 #[allow( 38 clippy::cast_possible_truncation, 39 reason = "clear-color channels are bounded [0, 1]" 40 )] 41 pub fn to_rgba_array(self) -> [f32; 4] { 42 [self.r as f32, self.g as f32, self.b as f32, self.a as f32] 43 } 44} 45 46impl From<ClearColor> for wgpu::Color { 47 fn from(c: ClearColor) -> Self { 48 Self { 49 r: c.r, 50 g: c.g, 51 b: c.b, 52 a: c.a, 53 } 54 } 55} 56 57#[allow( 58 clippy::cast_possible_truncation, 59 clippy::cast_sign_loss, 60 reason = "value is clamped to [0, 1] before scaling to u8" 61)] 62fn channel_to_u8(value: f64) -> u8 { 63 (value.clamp(0.0, 1.0) * 255.0).round() as u8 64} 65 66#[derive(Copy, Clone, Debug, PartialEq)] 67pub struct GridStyle { 68 minor: ClearColor, 69 major: ClearColor, 70 axis_x: ClearColor, 71 axis_y: ClearColor, 72 origin: ClearColor, 73 line_width_px: f32, 74 axis_width_px: f32, 75 origin_radius_px: f32, 76 minor_spacing_target_px: f32, 77} 78 79impl GridStyle { 80 pub const DEFAULT: Self = Self { 81 minor: ClearColor::new(1.0, 1.0, 1.0, 0.08), 82 major: ClearColor::new(1.0, 1.0, 1.0, 0.22), 83 axis_x: ClearColor::new(1.0, 0.0, 0.0, 1.0), 84 axis_y: ClearColor::new(0.0, 1.0, 0.0, 1.0), 85 origin: ClearColor::new(1.0, 1.0, 1.0, 0.95), 86 line_width_px: 1.0, 87 axis_width_px: 1.6, 88 origin_radius_px: 4.0, 89 minor_spacing_target_px: 24.0, 90 }; 91 92 pub const LIGHT: Self = Self { 93 minor: ClearColor::new(0.0, 0.0, 0.0, 0.06), 94 major: ClearColor::new(0.0, 0.0, 0.0, 0.12), 95 axis_x: ClearColor::new(0.78, 0.10, 0.10, 1.0), 96 axis_y: ClearColor::new(0.10, 0.55, 0.12, 1.0), 97 origin: ClearColor::new(0.0, 0.0, 0.0, 0.80), 98 line_width_px: 1.0, 99 axis_width_px: 1.6, 100 origin_radius_px: 4.0, 101 minor_spacing_target_px: 24.0, 102 }; 103 104 #[must_use] 105 pub const fn minor(self) -> ClearColor { 106 self.minor 107 } 108 109 #[must_use] 110 pub const fn major(self) -> ClearColor { 111 self.major 112 } 113 114 #[must_use] 115 pub const fn axis_x(self) -> ClearColor { 116 self.axis_x 117 } 118 119 #[must_use] 120 pub const fn axis_y(self) -> ClearColor { 121 self.axis_y 122 } 123 124 #[must_use] 125 pub const fn origin(self) -> ClearColor { 126 self.origin 127 } 128 129 #[must_use] 130 pub const fn line_width_px(self) -> f32 { 131 self.line_width_px 132 } 133 134 #[must_use] 135 pub const fn axis_width_px(self) -> f32 { 136 self.axis_width_px 137 } 138 139 #[must_use] 140 pub const fn origin_radius_px(self) -> f32 { 141 self.origin_radius_px 142 } 143 144 #[must_use] 145 pub const fn minor_spacing_target_px(self) -> f32 { 146 self.minor_spacing_target_px 147 } 148} 149 150impl Default for GridStyle { 151 fn default() -> Self { 152 Self::DEFAULT 153 } 154} 155 156#[derive(Copy, Clone, Debug, PartialEq)] 157pub struct StrokeStyle { 158 stroke: ClearColor, 159 construction: ClearColor, 160 stroke_width_px: f32, 161 point_radius_px: f32, 162 construction_dash_period_px: f32, 163 construction_dash_on_ratio: f32, 164} 165 166impl StrokeStyle { 167 pub const DEFAULT: Self = Self { 168 stroke: ClearColor::new(0.95, 0.95, 0.98, 1.0), 169 construction: ClearColor::new(0.55, 0.75, 1.0, 0.85), 170 stroke_width_px: 1.5, 171 point_radius_px: 3.0, 172 construction_dash_period_px: 8.0, 173 construction_dash_on_ratio: 0.5, 174 }; 175 176 pub const LIGHT: Self = Self { 177 stroke: ClearColor::new(0.14, 0.24, 0.62, 1.0), 178 construction: ClearColor::new(0.45, 0.50, 0.62, 0.85), 179 stroke_width_px: 1.5, 180 point_radius_px: 3.0, 181 construction_dash_period_px: 8.0, 182 construction_dash_on_ratio: 0.5, 183 }; 184 185 #[must_use] 186 pub const fn stroke(self) -> ClearColor { 187 self.stroke 188 } 189 190 #[must_use] 191 pub const fn construction(self) -> ClearColor { 192 self.construction 193 } 194 195 #[must_use] 196 pub const fn stroke_width_px(self) -> f32 { 197 self.stroke_width_px 198 } 199 200 #[must_use] 201 pub const fn point_radius_px(self) -> f32 { 202 self.point_radius_px 203 } 204 205 #[must_use] 206 pub const fn construction_dash_period_px(self) -> f32 { 207 self.construction_dash_period_px 208 } 209 210 #[must_use] 211 pub const fn construction_dash_on_ratio(self) -> f32 { 212 self.construction_dash_on_ratio 213 } 214} 215 216impl Default for StrokeStyle { 217 fn default() -> Self { 218 Self::DEFAULT 219 } 220} 221 222#[derive(Copy, Clone, Debug, PartialEq)] 223pub struct GlyphStyle { 224 color: ClearColor, 225 offset_px: f32, 226 tile_px: f32, 227} 228 229impl GlyphStyle { 230 pub const DEFAULT: Self = Self { 231 color: ClearColor::new(0.35, 0.78, 0.35, 1.0), 232 offset_px: 18.0, 233 tile_px: 22.0, 234 }; 235 236 pub const LIGHT: Self = Self { 237 color: ClearColor::new(0.0, 0.60, 0.17, 1.0), 238 offset_px: 18.0, 239 tile_px: 22.0, 240 }; 241 242 #[must_use] 243 pub const fn color(self) -> ClearColor { 244 self.color 245 } 246 247 #[must_use] 248 pub const fn offset_px(self) -> f32 { 249 self.offset_px 250 } 251 252 #[must_use] 253 pub const fn tile_px(self) -> f32 { 254 self.tile_px 255 } 256} 257 258impl Default for GlyphStyle { 259 fn default() -> Self { 260 Self::DEFAULT 261 } 262} 263 264#[derive(Copy, Clone, Debug, PartialEq)] 265pub struct TextStyle { 266 color: ClearColor, 267 font_size_px: f32, 268} 269 270impl TextStyle { 271 pub const DEFAULT: Self = Self { 272 color: ClearColor::new(0.95, 0.95, 0.98, 1.0), 273 font_size_px: 14.0, 274 }; 275 276 pub const LIGHT: Self = Self { 277 color: ClearColor::new(0.10, 0.11, 0.13, 1.0), 278 font_size_px: 14.0, 279 }; 280 281 #[must_use] 282 pub const fn color(self) -> ClearColor { 283 self.color 284 } 285 286 #[must_use] 287 pub const fn font_size_px(self) -> f32 { 288 self.font_size_px 289 } 290 291 #[must_use] 292 pub const fn with_font_size_px(self, font_size_px: f32) -> Self { 293 Self { 294 font_size_px, 295 ..self 296 } 297 } 298} 299 300impl Default for TextStyle { 301 fn default() -> Self { 302 Self::DEFAULT 303 } 304} 305 306#[derive(Copy, Clone, Debug, PartialEq)] 307pub struct EdgeStyle { 308 visible: ClearColor, 309 hidden: ClearColor, 310} 311 312impl EdgeStyle { 313 pub const DEFAULT: Self = Self { 314 visible: ClearColor::opaque(0.90, 0.90, 0.93), 315 hidden: ClearColor::opaque(0.249, 0.274, 0.300), 316 }; 317 318 pub const LIGHT: Self = Self { 319 visible: ClearColor::opaque(0.15, 0.16, 0.19), 320 hidden: ClearColor::opaque(0.62, 0.63, 0.66), 321 }; 322 323 #[must_use] 324 pub const fn new(visible: ClearColor, hidden: ClearColor) -> Self { 325 Self { visible, hidden } 326 } 327 328 #[must_use] 329 pub const fn visible(self) -> ClearColor { 330 self.visible 331 } 332 333 #[must_use] 334 pub const fn hidden(self) -> ClearColor { 335 self.hidden 336 } 337} 338 339const SOLID_BASE_COLOR: LinearRgba = LinearRgba::new(0.72, 0.74, 0.78, 1.0); 340 341#[derive(Copy, Clone, Debug, PartialEq)] 342pub struct Style { 343 background: ClearColor, 344 grid: GridStyle, 345 strokes: StrokeStyle, 346 glyphs: GlyphStyle, 347 text: TextStyle, 348 edges: EdgeStyle, 349 solid_base_color: LinearRgba, 350} 351 352impl Style { 353 #[must_use] 354 pub const fn new(background: ClearColor) -> Self { 355 Self { 356 background, 357 grid: GridStyle::DEFAULT, 358 strokes: StrokeStyle::DEFAULT, 359 glyphs: GlyphStyle::DEFAULT, 360 text: TextStyle::DEFAULT, 361 edges: EdgeStyle::DEFAULT, 362 solid_base_color: SOLID_BASE_COLOR, 363 } 364 } 365 366 #[must_use] 367 pub const fn light() -> Self { 368 Self { 369 background: ClearColor::opaque(0.965, 0.965, 0.97), 370 grid: GridStyle::LIGHT, 371 strokes: StrokeStyle::LIGHT, 372 glyphs: GlyphStyle::LIGHT, 373 text: TextStyle::LIGHT, 374 edges: EdgeStyle::LIGHT, 375 solid_base_color: SOLID_BASE_COLOR, 376 } 377 } 378 379 #[must_use] 380 pub const fn background(self) -> ClearColor { 381 self.background 382 } 383 384 #[must_use] 385 pub const fn solid_base_color(self) -> LinearRgba { 386 self.solid_base_color 387 } 388 389 #[must_use] 390 pub const fn with_solid_base_color(self, solid_base_color: LinearRgba) -> Self { 391 Self { 392 solid_base_color, 393 ..self 394 } 395 } 396 397 #[must_use] 398 pub const fn with_background(self, background: ClearColor) -> Self { 399 Self { background, ..self } 400 } 401 402 #[must_use] 403 pub const fn edges(self) -> EdgeStyle { 404 self.edges 405 } 406 407 #[must_use] 408 pub const fn grid(self) -> GridStyle { 409 self.grid 410 } 411 412 #[must_use] 413 pub const fn strokes(self) -> StrokeStyle { 414 self.strokes 415 } 416 417 #[must_use] 418 pub const fn glyphs(self) -> GlyphStyle { 419 self.glyphs 420 } 421 422 #[must_use] 423 pub const fn text(self) -> TextStyle { 424 self.text 425 } 426 427 #[must_use] 428 pub const fn with_text(self, text: TextStyle) -> Self { 429 Self { text, ..self } 430 } 431 432 #[must_use] 433 pub const fn with_edges(self, edges: EdgeStyle) -> Self { 434 Self { edges, ..self } 435 } 436} 437 438impl Default for Style { 439 fn default() -> Self { 440 Self::new(ClearColor::opaque(0.05, 0.05, 0.07)) 441 } 442} 443 444#[derive(Clone, Debug)] 445pub struct SnapshotFrame { 446 extent: ViewportExtent, 447 rgba: Vec<u8>, 448 backend: BackendTag, 449} 450 451impl SnapshotFrame { 452 pub(crate) fn new(extent: ViewportExtent, rgba: Vec<u8>, backend: BackendTag) -> Self { 453 Self { 454 extent, 455 rgba, 456 backend, 457 } 458 } 459 460 #[must_use] 461 pub fn extent(&self) -> ViewportExtent { 462 self.extent 463 } 464 465 #[must_use] 466 pub fn rgba(&self) -> &[u8] { 467 &self.rgba 468 } 469 470 #[must_use] 471 pub fn backend(&self) -> BackendTag { 472 self.backend 473 } 474} 475 476pub fn encode_png(frame: &SnapshotFrame) -> Result<Vec<u8>> { 477 encode_png_rgba(frame.extent, frame.rgba()) 478} 479 480pub fn encode_png_rgba(extent: ViewportExtent, rgba: &[u8]) -> Result<Vec<u8>> { 481 let mut out: Vec<u8> = Vec::new(); 482 { 483 let mut encoder = 484 png::Encoder::new(&mut out, extent.width().value(), extent.height().value()); 485 encoder.set_color(png::ColorType::Rgba); 486 encoder.set_depth(png::BitDepth::Eight); 487 let mut writer = encoder.write_header()?; 488 writer.write_image_data(rgba)?; 489 } 490 Ok(out) 491} 492 493pub fn decode_png(bytes: &[u8]) -> Result<(ViewportExtent, Vec<u8>)> { 494 use crate::camera::ViewportPx; 495 let decoder = png::Decoder::new(bytes); 496 let mut reader = decoder.read_info()?; 497 { 498 let info = reader.info(); 499 if info.color_type != png::ColorType::Rgba || info.bit_depth != png::BitDepth::Eight { 500 return Err(RenderError::PngFormat { 501 color_type: info.color_type, 502 bit_depth: info.bit_depth, 503 }); 504 } 505 } 506 let mut buf = vec![0_u8; reader.output_buffer_size()]; 507 let info = reader.next_frame(&mut buf)?; 508 let extent = ViewportExtent::new(ViewportPx::new(info.width), ViewportPx::new(info.height)); 509 buf.truncate(info.buffer_size()); 510 Ok((extent, buf)) 511} 512 513#[cfg(test)] 514mod tests { 515 use super::*; 516 use crate::camera::ViewportPx; 517 use crate::gpu::BackendTag; 518 519 fn tag() -> BackendTag { 520 BackendTag::from_backend(wgpu::Backend::Noop) 521 } 522 523 #[test] 524 fn clear_color_rounds_to_nearest_u8() { 525 let c = ClearColor::opaque(0.0, 0.5, 1.0); 526 assert_eq!(c.to_rgba8(), [0, 128, 255, 255]); 527 } 528 529 #[test] 530 fn style_default_is_navy_opaque() { 531 let rgba = Style::default().background().to_rgba8(); 532 assert_eq!(rgba[3], 255); 533 assert!(rgba[0] < 32 && rgba[1] < 32 && rgba[2] < 32); 534 } 535 536 fn encode_grayscale(width: u32, height: u32, pixels: &[u8]) -> Vec<u8> { 537 let mut bytes: Vec<u8> = Vec::new(); 538 { 539 let mut enc = png::Encoder::new(&mut bytes, width, height); 540 enc.set_color(png::ColorType::Grayscale); 541 enc.set_depth(png::BitDepth::Eight); 542 let Ok(mut writer) = enc.write_header() else { 543 panic!("grayscale png header write failed"); 544 }; 545 assert!( 546 writer.write_image_data(pixels).is_ok(), 547 "grayscale png body write failed", 548 ); 549 } 550 bytes 551 } 552 553 #[test] 554 fn decode_png_rejects_non_rgba8() { 555 let bytes = encode_grayscale(4, 4, &[0_u8; 16]); 556 let Err(RenderError::PngFormat { 557 color_type, 558 bit_depth, 559 }) = decode_png(&bytes) 560 else { 561 panic!("expected PngFormat rejection"); 562 }; 563 assert_eq!(color_type, png::ColorType::Grayscale); 564 assert_eq!(bit_depth, png::BitDepth::Eight); 565 } 566 567 #[test] 568 fn png_encode_decode_round_trips_solid_frame() { 569 let extent = ViewportExtent::new(ViewportPx::new(8), ViewportPx::new(4)); 570 let pixel = [200_u8, 80, 40, 255]; 571 let rgba: Vec<u8> = (0..extent.pixel_count()) 572 .flat_map(|_| pixel.into_iter()) 573 .collect(); 574 let frame = SnapshotFrame::new(extent, rgba.clone(), tag()); 575 576 let Ok(encoded) = encode_png(&frame) else { 577 panic!("encode_png failed"); 578 }; 579 let Ok((decoded_extent, decoded)) = decode_png(&encoded) else { 580 panic!("decode_png failed"); 581 }; 582 assert_eq!(decoded_extent, extent); 583 assert_eq!(decoded, rgba); 584 } 585}