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