1use crate::core::{Camera, ImageData, PipelineType, ProjectionType, RenderData};
2use crate::plots::surface::ColorMap;
3use crate::plots::{AxesKind, Figure, PlotElement};
4use crate::styling::PlotThemeConfig;
5use font8x8::{UnicodeFonts, BASIC_FONTS};
6use glam::{Vec2, Vec3, Vec4};
7
8#[derive(Clone, Debug)]
9struct AxesView {
10 viewport: (u32, u32, u32, u32),
11 plot_rect: (u32, u32, u32, u32),
12 bounds_2d: (f32, f32, f32, f32),
13 bounds_3d: (Vec3, Vec3),
14 camera_3d: Option<Camera>,
15 has_3d_content: bool,
16 title: Option<String>,
17 subtitle: Option<String>,
18 x_label: Option<String>,
19 y_label: Option<String>,
20 z_label: Option<String>,
21 x_ticks: Option<Vec<f64>>,
22 y_ticks: Option<Vec<f64>>,
23 x_tick_format: Option<String>,
24 y_tick_format: Option<String>,
25 x_tick_label_rotation: f64,
26 y_tick_label_rotation: f64,
27 title_scale: u32,
28 label_scale: u32,
29 tick_scale: u32,
30 show_grid: bool,
31 show_minor_grid: bool,
32 show_box: bool,
33 axes_kind: AxesKind,
34 colorbar_enabled: bool,
35 colormap: ColorMap,
36}
37
38#[derive(Clone, Debug, Default)]
39struct AxesTextLabels {
40 title: Option<String>,
41 subtitle: Option<String>,
42 x_label: Option<String>,
43 y_label: Option<String>,
44 z_label: Option<String>,
45}
46
47#[derive(Clone, Copy, Debug)]
48struct ScreenVertex {
49 x: f32,
50 y: f32,
51 z: f32,
52 color: [u8; 4],
53}
54
55#[derive(Clone, Copy, Debug)]
56struct BitmapTextAnchor {
57 x: i32,
58 y: i32,
59 frac_x: f32,
60 frac_y: f32,
61}
62
63struct Canvas {
64 width: u32,
65 height: u32,
66 pixels: Vec<u8>,
67 depth: Vec<f32>,
68}
69
70impl Canvas {
71 fn new(width: u32, height: u32, background: [u8; 4]) -> Self {
72 let mut pixels = vec![0u8; (width.max(1) * height.max(1) * 4) as usize];
73 for px in pixels.chunks_exact_mut(4) {
74 px.copy_from_slice(&background);
75 }
76 let depth = vec![f32::INFINITY; (width.max(1) * height.max(1)) as usize];
77 Self {
78 width: width.max(1),
79 height: height.max(1),
80 pixels,
81 depth,
82 }
83 }
84
85 fn rgba(self) -> Vec<u8> {
86 self.pixels
87 }
88
89 fn blend_pixel(&mut self, x: i32, y: i32, rgba: [u8; 4], depth: f32, use_depth: bool) {
90 if x < 0 || y < 0 || x >= self.width as i32 || y >= self.height as i32 {
91 return;
92 }
93 let idx = (y as u32 * self.width + x as u32) as usize;
94 if use_depth {
95 if !depth.is_finite() || depth >= self.depth[idx] {
96 return;
97 }
98 self.depth[idx] = depth;
99 }
100
101 let p = idx * 4;
102 let src_a = rgba[3] as f32 / 255.0;
103 let dst_a = self.pixels[p + 3] as f32 / 255.0;
104 let out_a = src_a + dst_a * (1.0 - src_a);
105 if out_a <= f32::EPSILON {
106 self.pixels[p..p + 4].copy_from_slice(&[0, 0, 0, 0]);
107 return;
108 }
109 for (i, src_u8) in rgba.iter().take(3).enumerate() {
110 let src = *src_u8 as f32 / 255.0;
111 let dst = self.pixels[p + i] as f32 / 255.0;
112 let out = (src * src_a + dst * dst_a * (1.0 - src_a)) / out_a;
113 self.pixels[p + i] = (out.clamp(0.0, 1.0) * 255.0) as u8;
114 }
115 self.pixels[p + 3] = (out_a.clamp(0.0, 1.0) * 255.0) as u8;
116 }
117
118 fn draw_disc(&mut self, center: Vec2, radius: f32, rgba: [u8; 4], depth: f32, use_depth: bool) {
119 let r = radius.max(0.5);
120 let min_x = (center.x - r).floor() as i32;
121 let max_x = (center.x + r).ceil() as i32;
122 let min_y = (center.y - r).floor() as i32;
123 let max_y = (center.y + r).ceil() as i32;
124 let rr = r * r;
125 for y in min_y..=max_y {
126 for x in min_x..=max_x {
127 let dx = x as f32 + 0.5 - center.x;
128 let dy = y as f32 + 0.5 - center.y;
129 if dx * dx + dy * dy <= rr {
130 self.blend_pixel(x, y, rgba, depth, use_depth);
131 }
132 }
133 }
134 }
135
136 fn fill_rect(&mut self, x: i32, y: i32, w: i32, h: i32, rgba: [u8; 4]) {
137 if w <= 0 || h <= 0 {
138 return;
139 }
140 let x0 = x.max(0);
141 let y0 = y.max(0);
142 let x1 = (x + w).min(self.width as i32);
143 let y1 = (y + h).min(self.height as i32);
144 for yy in y0..y1 {
145 for xx in x0..x1 {
146 self.blend_pixel(xx, yy, rgba, 0.0, false);
147 }
148 }
149 }
150
151 fn stroke_rect(&mut self, x: i32, y: i32, w: i32, h: i32, rgba: [u8; 4], width_px: f32) {
152 let l = x as f32;
153 let r = (x + w - 1) as f32;
154 let t = y as f32;
155 let b = (y + h - 1) as f32;
156 let c = rgba;
157 self.draw_line(
158 ScreenVertex {
159 x: l,
160 y: t,
161 z: 0.0,
162 color: c,
163 },
164 ScreenVertex {
165 x: r,
166 y: t,
167 z: 0.0,
168 color: c,
169 },
170 width_px,
171 0,
172 false,
173 );
174 self.draw_line(
175 ScreenVertex {
176 x: r,
177 y: t,
178 z: 0.0,
179 color: c,
180 },
181 ScreenVertex {
182 x: r,
183 y: b,
184 z: 0.0,
185 color: c,
186 },
187 width_px,
188 0,
189 false,
190 );
191 self.draw_line(
192 ScreenVertex {
193 x: r,
194 y: b,
195 z: 0.0,
196 color: c,
197 },
198 ScreenVertex {
199 x: l,
200 y: b,
201 z: 0.0,
202 color: c,
203 },
204 width_px,
205 0,
206 false,
207 );
208 self.draw_line(
209 ScreenVertex {
210 x: l,
211 y: b,
212 z: 0.0,
213 color: c,
214 },
215 ScreenVertex {
216 x: l,
217 y: t,
218 z: 0.0,
219 color: c,
220 },
221 width_px,
222 0,
223 false,
224 );
225 }
226
227 fn draw_line(
228 &mut self,
229 a: ScreenVertex,
230 b: ScreenVertex,
231 width_px: f32,
232 style_code: i32,
233 use_depth: bool,
234 ) {
235 let radius = width_px.max(1.0) * 0.5;
236 let segments = dash_segments(a, b, style_code, radius.max(1.0));
237 for (s0, s1) in segments {
238 self.draw_capsule_segment(s0, s1, radius, use_depth);
239 }
240 }
241
242 fn draw_capsule_segment(
243 &mut self,
244 a: ScreenVertex,
245 b: ScreenVertex,
246 radius: f32,
247 use_depth: bool,
248 ) {
249 let min_x = (a.x.min(b.x) - radius - 1.0).floor() as i32;
250 let max_x = (a.x.max(b.x) + radius + 1.0).ceil() as i32;
251 let min_y = (a.y.min(b.y) - radius - 1.0).floor() as i32;
252 let max_y = (a.y.max(b.y) + radius + 1.0).ceil() as i32;
253
254 let av = Vec2::new(a.x, a.y);
255 let bv = Vec2::new(b.x, b.y);
256 let ab = bv - av;
257 let ab_len2 = ab.length_squared().max(1e-8);
258
259 for y in min_y..=max_y {
260 for x in min_x..=max_x {
261 let p = Vec2::new(x as f32 + 0.5, y as f32 + 0.5);
262 let t = ((p - av).dot(ab) / ab_len2).clamp(0.0, 1.0);
263 let closest = av + ab * t;
264 let dist = p.distance(closest);
265 if dist > radius + 1.0 {
266 continue;
267 }
268 let coverage = (radius + 1.0 - dist).clamp(0.0, 1.0);
269 if coverage <= 0.0 {
270 continue;
271 }
272
273 let depth = a.z + (b.z - a.z) * t;
274 let mut color = lerp_rgba(a.color, b.color, t);
275 color[3] = ((color[3] as f32) * coverage).round().clamp(0.0, 255.0) as u8;
276 self.blend_pixel(x, y, color, depth, use_depth);
277 }
278 }
279 }
280
281 fn fill_triangle(
282 &mut self,
283 v0: ScreenVertex,
284 v1: ScreenVertex,
285 v2: ScreenVertex,
286 use_depth: bool,
287 ) {
288 let min_x = v0.x.min(v1.x).min(v2.x).floor() as i32;
289 let max_x = v0.x.max(v1.x).max(v2.x).ceil() as i32;
290 let min_y = v0.y.min(v1.y).min(v2.y).floor() as i32;
291 let max_y = v0.y.max(v1.y).max(v2.y).ceil() as i32;
292
293 let p0 = Vec2::new(v0.x, v0.y);
294 let p1 = Vec2::new(v1.x, v1.y);
295 let p2 = Vec2::new(v2.x, v2.y);
296 let area = edge_fn(p0, p1, p2);
297 if area.abs() <= f32::EPSILON {
298 return;
299 }
300
301 for y in min_y..=max_y {
302 for x in min_x..=max_x {
303 let p = Vec2::new(x as f32 + 0.5, y as f32 + 0.5);
304 let w0 = edge_fn(p1, p2, p) / area;
305 let w1 = edge_fn(p2, p0, p) / area;
306 let w2 = edge_fn(p0, p1, p) / area;
307 if w0 < 0.0 || w1 < 0.0 || w2 < 0.0 {
308 continue;
309 }
310 let depth = w0 * v0.z + w1 * v1.z + w2 * v2.z;
311 let color = blend_barycentric_rgba(v0.color, v1.color, v2.color, w0, w1, w2);
312 self.blend_pixel(x, y, color, depth, use_depth);
313 }
314 }
315 }
316}
317
318fn edge_fn(a: Vec2, b: Vec2, c: Vec2) -> f32 {
319 (c.x - a.x) * (b.y - a.y) - (c.y - a.y) * (b.x - a.x)
320}
321
322fn blend_barycentric_rgba(
323 c0: [u8; 4],
324 c1: [u8; 4],
325 c2: [u8; 4],
326 w0: f32,
327 w1: f32,
328 w2: f32,
329) -> [u8; 4] {
330 let mix = |a: u8, b: u8, c: u8| -> u8 {
331 (a as f32 * w0 + b as f32 * w1 + c as f32 * w2)
332 .round()
333 .clamp(0.0, 255.0) as u8
334 };
335 [
336 mix(c0[0], c1[0], c2[0]),
337 mix(c0[1], c1[1], c2[1]),
338 mix(c0[2], c1[2], c2[2]),
339 mix(c0[3], c1[3], c2[3]),
340 ]
341}
342
343fn lerp_rgba(a: [u8; 4], b: [u8; 4], t: f32) -> [u8; 4] {
344 let mix = |x: u8, y: u8| -> u8 {
345 (x as f32 + (y as f32 - x as f32) * t)
346 .round()
347 .clamp(0.0, 255.0) as u8
348 };
349 [
350 mix(a[0], b[0]),
351 mix(a[1], b[1]),
352 mix(a[2], b[2]),
353 mix(a[3], b[3]),
354 ]
355}
356
357fn with_alpha(color: [u8; 4], alpha_scale: f32) -> [u8; 4] {
358 let mut out = color;
359 out[3] = ((out[3] as f32) * alpha_scale).round().clamp(0.0, 255.0) as u8;
360 out
361}
362
363fn dash_segments(
364 a: ScreenVertex,
365 b: ScreenVertex,
366 style_code: i32,
367 width_px: f32,
368) -> Vec<(ScreenVertex, ScreenVertex)> {
369 let dx = b.x - a.x;
370 let dy = b.y - a.y;
371 let len = (dx * dx + dy * dy).sqrt();
372 if len <= 1e-5 {
373 return vec![(a, b)];
374 }
375 let pattern = match style_code {
376 1 => vec![(6.0 * width_px, true), (6.0 * width_px, false)],
377 2 => vec![(1.5 * width_px, true), (5.0 * width_px, false)],
378 3 => vec![
379 (6.0 * width_px, true),
380 (4.0 * width_px, false),
381 (1.5 * width_px, true),
382 (4.0 * width_px, false),
383 ],
384 _ => return vec![(a, b)],
385 };
386
387 let mut out = Vec::new();
388 let mut s = 0.0f32;
389 let mut pi = 0usize;
390 while s < len {
391 let (step, draw) = pattern[pi % pattern.len()];
392 let e = (s + step.max(1.0)).min(len);
393 if draw {
394 let t0 = s / len;
395 let t1 = e / len;
396 out.push((lerp_screen(a, b, t0), lerp_screen(a, b, t1)));
397 }
398 s = e;
399 pi += 1;
400 }
401 out
402}
403
404fn lerp_screen(a: ScreenVertex, b: ScreenVertex, t: f32) -> ScreenVertex {
405 ScreenVertex {
406 x: a.x + (b.x - a.x) * t,
407 y: a.y + (b.y - a.y) * t,
408 z: a.z + (b.z - a.z) * t,
409 color: lerp_rgba(a.color, b.color, t),
410 }
411}
412
413fn to_u8_rgba(color: [f32; 4]) -> [u8; 4] {
414 [
415 (color[0].clamp(0.0, 1.0) * 255.0) as u8,
416 (color[1].clamp(0.0, 1.0) * 255.0) as u8,
417 (color[2].clamp(0.0, 1.0) * 255.0) as u8,
418 (color[3].clamp(0.0, 1.0) * 255.0) as u8,
419 ]
420}
421
422fn is_default_figure_bg(bg: Vec4) -> bool {
423 const EPS: f32 = 1e-3;
424 (bg.x - 1.0).abs() <= EPS
425 && (bg.y - 1.0).abs() <= EPS
426 && (bg.z - 1.0).abs() <= EPS
427 && (bg.w - 1.0).abs() <= EPS
428}
429
430fn compute_tiled_viewports(
431 width: u32,
432 height: u32,
433 rows: usize,
434 cols: usize,
435) -> Vec<(u32, u32, u32, u32)> {
436 if rows == 0 || cols == 0 {
437 return vec![(0, 0, width.max(1), height.max(1))];
438 }
439 let rows_u = rows as u32;
440 let cols_u = cols as u32;
441 let cell_w = (width / cols_u).max(1);
442 let cell_h = (height / rows_u).max(1);
443 let mut out = Vec::with_capacity(rows * cols);
444 for r in 0..rows_u {
445 for c in 0..cols_u {
446 let x = c * cell_w;
447 let y = r * cell_h;
448 let w = if c + 1 == cols_u {
449 width.saturating_sub(x).max(1)
450 } else {
451 cell_w
452 };
453 let h = if r + 1 == rows_u {
454 height.saturating_sub(y).max(1)
455 } else {
456 cell_h
457 };
458 out.push((x, y, w, h));
459 }
460 }
461 out
462}
463
464fn rotation_margin(angle_degrees: f64, margin: u32) -> u32 {
465 if angle_degrees.abs() <= f64::EPSILON {
466 0
467 } else {
468 margin
469 }
470}
471
472fn compute_plot_rect(
473 viewport: (u32, u32, u32, u32),
474 has_3d: bool,
475 x_tick_label_rotation: f64,
476 y_tick_label_rotation: f64,
477) -> (u32, u32, u32, u32) {
478 let (vx, vy, vw, vh) = viewport;
479 let left = if has_3d {
480 48
481 } else {
482 62 + rotation_margin(y_tick_label_rotation, 24)
483 };
484 let right = 24;
485 let top = if has_3d { 34 } else { 40 };
486 let bottom = if has_3d {
487 48
488 } else {
489 54 + rotation_margin(x_tick_label_rotation, 32)
490 };
491
492 let px = vx + left.min(vw.saturating_sub(2));
493 let py = vy + top.min(vh.saturating_sub(2));
494 let pw = vw
495 .saturating_sub(left + right)
496 .max(vw.saturating_sub(2).max(1));
497 let ph = vh
498 .saturating_sub(top + bottom)
499 .max(vh.saturating_sub(2).max(1));
500 (px, py, pw.max(1), ph.max(1))
501}
502
503fn square_plot_rect(rect: (u32, u32, u32, u32)) -> (u32, u32, u32, u32) {
504 let (x, y, w, h) = rect;
505 let size = w.min(h).max(1);
506 let x = x + (w.saturating_sub(size) / 2);
507 let y = y + (h.saturating_sub(size) / 2);
508 (x, y, size, size)
509}
510
511fn project_2d(
512 pos: Vec3,
513 plot_rect: (u32, u32, u32, u32),
514 bounds: (f32, f32, f32, f32),
515 color: [u8; 4],
516) -> ScreenVertex {
517 let (x_min, x_max, y_min, y_max) = bounds;
518 let xr = (x_max - x_min).max(1e-6);
519 let yr = (y_max - y_min).max(1e-6);
520 let tx = ((pos.x - x_min) / xr).clamp(0.0, 1.0);
521 let ty = ((pos.y - y_min) / yr).clamp(0.0, 1.0);
522 let sx = plot_rect.0 as f32 + tx * plot_rect.2.max(1) as f32;
523 let sy = plot_rect.1 as f32 + (1.0 - ty) * plot_rect.3.max(1) as f32;
524 ScreenVertex {
525 x: sx,
526 y: sy,
527 z: 0.0,
528 color,
529 }
530}
531
532fn project_3d(
533 pos: Vec3,
534 plot_rect: (u32, u32, u32, u32),
535 camera: &Camera,
536 color: [u8; 4],
537) -> Option<ScreenVertex> {
538 let mut cam = camera.clone();
539 cam.update_aspect_ratio((plot_rect.2.max(1) as f32) / (plot_rect.3.max(1) as f32));
540 let vp = cam.view_proj_matrix();
541 let clip = vp * pos.extend(1.0);
542 if clip.w.abs() <= 1e-6 {
543 return None;
544 }
545 let ndc = clip.truncate() / clip.w;
546 if ndc.z < -1.2 || ndc.z > 1.2 {
547 return None;
548 }
549 let sx = plot_rect.0 as f32 + (ndc.x * 0.5 + 0.5) * plot_rect.2.max(1) as f32;
550 let sy = plot_rect.1 as f32 + (1.0 - (ndc.y * 0.5 + 0.5)) * plot_rect.3.max(1) as f32;
551 let depth = ndc.z * 0.5 + 0.5;
552 Some(ScreenVertex {
553 x: sx,
554 y: sy,
555 z: depth,
556 color,
557 })
558}
559
560fn axes_has_3d_content(figure: &Figure, axes_index: usize) -> bool {
561 figure
562 .plots()
563 .zip(figure.plot_axes_indices().iter().copied())
564 .any(|(plot, idx)| {
565 idx == axes_index
566 && match plot {
567 PlotElement::Surface(surface) => !surface.image_mode,
568 PlotElement::Mesh(_) => true,
569 PlotElement::Patch(patch) => {
570 patch.force_3d() || patch.vertices().iter().any(|p| p.z.abs() > 1e-6)
571 }
572 PlotElement::Line3(_) | PlotElement::Scatter3(_) => true,
573 _ => false,
574 }
575 })
576}
577
578fn choose_axes_bounds(
579 figure: &Figure,
580 axes_index: usize,
581 render_data: &[(usize, RenderData)],
582) -> (f32, f32, f32, f32) {
583 let mut min_x = f32::INFINITY;
584 let mut max_x = f32::NEG_INFINITY;
585 let mut min_y = f32::INFINITY;
586 let mut max_y = f32::NEG_INFINITY;
587
588 for (ax, rd) in render_data {
589 if *ax != axes_index {
590 continue;
591 }
592 if let Some(bounds) = rd.bounds {
593 min_x = min_x.min(bounds.min.x);
594 max_x = max_x.max(bounds.max.x);
595 min_y = min_y.min(bounds.min.y);
596 max_y = max_y.max(bounds.max.y);
597 }
598 }
599
600 if !min_x.is_finite() || !max_x.is_finite() || !min_y.is_finite() || !max_y.is_finite() {
601 min_x = -1.0;
602 max_x = 1.0;
603 min_y = -1.0;
604 max_y = 1.0;
605 }
606
607 if let Some(meta) = figure.axes_metadata(axes_index) {
608 if let Some((l, r)) = meta.x_limits {
609 min_x = l as f32;
610 max_x = r as f32;
611 }
612 if let Some((b, t)) = meta.y_limits {
613 min_y = b as f32;
614 max_y = t as f32;
615 }
616 if meta.axis_equal || meta.data_aspect_ratio_mode == "manual" {
617 (min_x, max_x, min_y, max_y) =
618 data_aspect_adjusted_bounds(min_x, max_x, min_y, max_y, meta.data_aspect_ratio);
619 }
620 }
621
622 (min_x, max_x, min_y, max_y)
623}
624
625fn data_aspect_adjusted_bounds(
626 x_min: f32,
627 x_max: f32,
628 y_min: f32,
629 y_max: f32,
630 ratio: [f64; 3],
631) -> (f32, f32, f32, f32) {
632 let x_ratio = (ratio[0].abs().max(1.0e-12)) as f32;
633 let y_ratio = (ratio[1].abs().max(1.0e-12)) as f32;
634 let cx = (x_min + x_max) * 0.5;
635 let cy = (y_min + y_max) * 0.5;
636 let x_span = (x_max - x_min).abs().max(0.1);
637 let y_span = (y_max - y_min).abs().max(0.1);
638 let units = (x_span / x_ratio).max(y_span / y_ratio).max(0.1);
639 let next_x = units * x_ratio;
640 let next_y = units * y_ratio;
641 (
642 cx - next_x * 0.5,
643 cx + next_x * 0.5,
644 cy - next_y * 0.5,
645 cy + next_y * 0.5,
646 )
647}
648
649fn choose_axes_bounds_3d(
650 figure: &Figure,
651 axes_index: usize,
652 render_data: &[(usize, RenderData)],
653 bounds_2d: (f32, f32, f32, f32),
654) -> (Vec3, Vec3) {
655 let mut min = Vec3::splat(f32::INFINITY);
656 let mut max = Vec3::splat(f32::NEG_INFINITY);
657
658 for (ax, rd) in render_data {
659 if *ax != axes_index {
660 continue;
661 }
662 if let Some(bounds) = rd.bounds {
663 min = min.min(bounds.min);
664 max = max.max(bounds.max);
665 }
666 }
667
668 let (mut min, mut max) = if !min.x.is_finite() || !max.x.is_finite() {
669 (
670 Vec3::new(bounds_2d.0, bounds_2d.2, -1.0),
671 Vec3::new(bounds_2d.1, bounds_2d.3, 1.0),
672 )
673 } else {
674 if (max.z - min.z).abs() < 1e-6 {
675 min.z -= 0.5;
676 max.z += 0.5;
677 }
678 (min, max)
679 };
680
681 if let Some(meta) = figure.axes_metadata(axes_index) {
682 if let Some((lo, hi)) = meta.x_limits {
683 min.x = lo as f32;
684 max.x = hi as f32;
685 }
686 if let Some((lo, hi)) = meta.y_limits {
687 min.y = lo as f32;
688 max.y = hi as f32;
689 }
690 if let Some((lo, hi)) = meta.z_limits {
691 min.z = lo as f32;
692 max.z = hi as f32;
693 }
694 if meta.axis_equal || meta.data_aspect_ratio_mode == "manual" {
695 (min, max) = data_aspect_adjusted_bounds_3d(min, max, meta.data_aspect_ratio);
696 }
697 }
698 (min, max)
699}
700
701fn data_aspect_adjusted_bounds_3d(min: Vec3, max: Vec3, ratio: [f64; 3]) -> (Vec3, Vec3) {
702 let aspect = Vec3::new(
703 ratio[0].abs().max(1.0e-12) as f32,
704 ratio[1].abs().max(1.0e-12) as f32,
705 ratio[2].abs().max(1.0e-12) as f32,
706 );
707 let center = (min + max) * 0.5;
708 let span = (max - min).abs().max(Vec3::splat(0.1));
709 let units = (span.x / aspect.x)
710 .max(span.y / aspect.y)
711 .max(span.z / aspect.z)
712 .max(0.1);
713 let next = aspect * units;
714 (center - next * 0.5, center + next * 0.5)
715}
716
717fn default_3d_camera_for_bounds(min: Vec3, max: Vec3) -> Camera {
718 let center = (min + max) * 0.5;
719 let extent = (max - min).abs();
720 let radius = extent.length().max(1e-3) * 0.5;
721
722 let mut cam = Camera::new();
723 let fov = match cam.projection {
724 ProjectionType::Perspective { fov, .. } => fov.max(0.2),
725 _ => 45.0f32.to_radians(),
726 };
727 let distance = (radius / (fov * 0.5).tan()).max(radius * 2.5) * 1.05;
728
729 let dir = Vec3::new(1.0, -1.0, 0.8).normalize_or_zero();
730 cam.target = center;
731 cam.position = center + dir * distance;
732 cam.up = Vec3::Z;
733 cam
734}
735
736fn choose_axes_camera(
737 figure: &Figure,
738 axes_index: usize,
739 axes_cameras: Option<&[Camera]>,
740 min: Vec3,
741 max: Vec3,
742) -> Camera {
743 if let Some(cams) = axes_cameras {
744 if let Some(cam) = cams.get(axes_index) {
745 return cam.clone();
746 }
747 }
748
749 let mut cam = default_3d_camera_for_bounds(min, max);
750
751 if let Some(meta) = figure.axes_metadata(axes_index) {
752 if let (Some(az), Some(el)) = (meta.view_azimuth_deg, meta.view_elevation_deg) {
753 cam.set_view_angles_deg(az, el);
754 }
755 }
756
757 cam
758}
759
760fn get_axes_title_and_labels(figure: &Figure, axes_index: usize) -> AxesTextLabels {
761 let meta = figure.axes_metadata(axes_index);
762 let title = meta
763 .and_then(|m| m.title.as_ref())
764 .or(figure.title.as_ref())
765 .map(|s| s.trim().to_string())
766 .filter(|s| !s.is_empty());
767 let subtitle = meta
768 .and_then(|m| m.subtitle.as_ref())
769 .map(|s| s.trim().to_string())
770 .filter(|s| !s.is_empty());
771 let x_label = meta
772 .and_then(|m| m.x_label.as_ref())
773 .or(figure.x_label.as_ref())
774 .map(|s| s.trim().to_string())
775 .filter(|s| !s.is_empty());
776 let y_label = meta
777 .and_then(|m| m.y_label.as_ref())
778 .or(figure.y_label.as_ref())
779 .map(|s| s.trim().to_string())
780 .filter(|s| !s.is_empty());
781 let z_label = meta
782 .and_then(|m| m.z_label.as_ref())
783 .or(figure.z_label.as_ref())
784 .map(|s| s.trim().to_string())
785 .filter(|s| !s.is_empty());
786 AxesTextLabels {
787 title,
788 subtitle,
789 x_label,
790 y_label,
791 z_label,
792 }
793}
794
795fn text_scale_from_font_size(font_size: Option<f32>, default_scale: u32) -> u32 {
796 let base = font_size.unwrap_or((default_scale.max(1) * 8) as f32);
797 ((base / 8.0).round() as i32).clamp(1, 4) as u32
798}
799
800fn get_axes_style_and_display_prefs(
801 figure: &Figure,
802 axes_index: usize,
803) -> (u32, u32, u32, bool, bool, bool) {
804 let Some(meta) = figure.axes_metadata(axes_index) else {
805 return (
806 2,
807 2,
808 1,
809 figure.grid_enabled,
810 figure.minor_grid_enabled,
811 figure.box_enabled,
812 );
813 };
814
815 let title_scale = text_scale_from_font_size(
816 meta.title_style.font_size.or(meta.subtitle_style.font_size),
817 2,
818 );
819 let label_font = meta
820 .x_label_style
821 .font_size
822 .or(meta.y_label_style.font_size)
823 .or(meta.z_label_style.font_size);
824 let label_scale = text_scale_from_font_size(label_font, 2);
825 let tick_scale = text_scale_from_font_size(meta.axes_style.font_size, 1);
826
827 (
828 title_scale,
829 label_scale,
830 tick_scale,
831 meta.grid_enabled,
832 figure.minor_grid_enabled_for_axes(axes_index),
833 meta.box_enabled,
834 )
835}
836
837fn project_vertex(vertex: &crate::core::Vertex, axes: &AxesView) -> Option<ScreenVertex> {
838 let pos = Vec3::from_array(vertex.position);
839 let color = to_u8_rgba(vertex.color);
840 if axes.has_3d_content {
841 axes.camera_3d
842 .as_ref()
843 .and_then(|camera| project_3d(pos, axes.plot_rect, camera, color))
844 } else {
845 Some(project_2d(pos, axes.plot_rect, axes.bounds_2d, color))
846 }
847}
848
849fn draw_bitmap_text(canvas: &mut Canvas, x: i32, y: i32, text: &str, scale: u32, color: [u8; 4]) {
850 let mut cursor_x = x;
851 let sc = scale.max(1) as i32;
852 let fallback = BASIC_FONTS.get('?').unwrap_or([0u8; 8]);
853
854 for ch in text.chars() {
855 let glyph = BASIC_FONTS
856 .get(ch)
857 .or_else(|| BASIC_FONTS.get(' '))
858 .unwrap_or(fallback);
859 for (row, bits) in glyph.iter().enumerate() {
860 for col in 0..8i32 {
861 if ((bits >> col) & 1) == 0 {
862 continue;
863 }
864 for sy in 0..sc {
865 for sx in 0..sc {
866 canvas.blend_pixel(
867 cursor_x + col * sc + sx,
868 y + row as i32 * sc + sy,
869 color,
870 0.0,
871 false,
872 );
873 }
874 }
875 }
876 }
877 cursor_x += 8 * sc + sc;
878 }
879}
880
881fn bitmap_text_size(text: &str, scale: u32) -> (i32, i32) {
882 let sc = scale.max(1) as i32;
883 ((text.chars().count() as i32) * (8 * sc + sc), 8 * sc)
884}
885
886fn draw_bitmap_text_rotated(
887 canvas: &mut Canvas,
888 anchor: BitmapTextAnchor,
889 text: &str,
890 scale: u32,
891 color: [u8; 4],
892 angle_degrees: f64,
893) {
894 if angle_degrees.abs() <= f64::EPSILON {
895 draw_bitmap_text(canvas, anchor.x, anchor.y, text, scale, color);
896 return;
897 }
898
899 let sc = scale.max(1) as i32;
900 let (text_w, text_h) = bitmap_text_size(text, scale);
901 let local_anchor = Vec2::new(text_w as f32 * anchor.frac_x, text_h as f32 * anchor.frac_y);
902 let fallback = BASIC_FONTS.get('?').unwrap_or([0u8; 8]);
903 let sin = (angle_degrees as f32).to_radians().sin();
904 let cos = (angle_degrees as f32).to_radians().cos();
905 let mut cursor_x = 0i32;
906
907 for ch in text.chars() {
908 let glyph = BASIC_FONTS
909 .get(ch)
910 .or_else(|| BASIC_FONTS.get(' '))
911 .unwrap_or(fallback);
912 for (row, bits) in glyph.iter().enumerate() {
913 for col in 0..8i32 {
914 if ((bits >> col) & 1) == 0 {
915 continue;
916 }
917 for sy in 0..sc {
918 for sx in 0..sc {
919 let local_x = cursor_x + col * sc + sx;
920 let local_y = row as i32 * sc + sy;
921 let local = Vec2::new(local_x as f32, local_y as f32) - local_anchor;
922 let rx = local.x * cos - local.y * sin;
923 let ry = local.x * sin + local.y * cos;
924 canvas.blend_pixel(
925 anchor.x + rx.round() as i32,
926 anchor.y + ry.round() as i32,
927 color,
928 0.0,
929 false,
930 );
931 }
932 }
933 }
934 }
935 cursor_x += 8 * sc + sc;
936 }
937}
938
939fn draw_tick_text(
940 canvas: &mut Canvas,
941 anchor: BitmapTextAnchor,
942 text: &str,
943 scale: u32,
944 color: [u8; 4],
945 angle_degrees: f64,
946) {
947 let (text_w, text_h) = bitmap_text_size(text, scale);
948 if angle_degrees.abs() <= f64::EPSILON {
949 draw_bitmap_text(
950 canvas,
951 anchor.x - (text_w as f32 * anchor.frac_x).round() as i32,
952 anchor.y - (text_h as f32 * anchor.frac_y).round() as i32,
953 text,
954 scale,
955 color,
956 );
957 } else {
958 draw_bitmap_text_rotated(canvas, anchor, text, scale, color, angle_degrees);
959 }
960}
961
962fn draw_text_centered(
963 canvas: &mut Canvas,
964 center_x: i32,
965 y: i32,
966 text: &str,
967 scale: u32,
968 color: [u8; 4],
969) {
970 let sc = scale.max(1) as i32;
971 let text_w = (text.chars().count() as i32) * (8 * sc + sc);
972 let x = center_x - text_w / 2;
973 draw_bitmap_text(canvas, x, y, text, scale, color);
974}
975
976fn draw_2d_axes_decorations(canvas: &mut Canvas, axes: &AxesView) {
977 let frame_color = [162, 170, 184, 255];
978 let grid_color = [104, 114, 130, 110];
979 let minor_grid_color = [104, 114, 130, 56];
980 let text_color = [212, 220, 234, 255];
981
982 let (px, py, pw, ph) = axes.plot_rect;
983 let left = px as i32;
984 let right = (px + pw.saturating_sub(1)) as i32;
985 let top = py as i32;
986 let bottom = (py + ph.saturating_sub(1)) as i32;
987
988 if axes.axes_kind == AxesKind::Polar {
989 draw_polar_axes_decorations(
990 canvas,
991 axes,
992 (left, right, top, bottom),
993 grid_color,
994 minor_grid_color,
995 frame_color,
996 text_color,
997 );
998 draw_axes_titles_and_labels(canvas, axes, text_color);
999 return;
1000 }
1001
1002 let (x_min, x_max, y_min, y_max) = axes.bounds_2d;
1003 let data_to_x = |value: f32| -> Option<i32> {
1004 if value < x_min || value > x_max {
1005 return None;
1006 }
1007 let span = x_max - x_min;
1008 if !span.is_finite() || span.abs() <= f32::EPSILON {
1009 return None;
1010 }
1011 let t = (value - x_min) / span;
1012 Some((left as f32 + t * (right - left) as f32).round() as i32)
1013 };
1014 let data_to_y = |value: f32| -> Option<i32> {
1015 if value < y_min || value > y_max {
1016 return None;
1017 }
1018 let span = y_max - y_min;
1019 if !span.is_finite() || span.abs() <= f32::EPSILON {
1020 return None;
1021 }
1022 let t = (y_max - value) / span;
1023 Some((top as f32 + t * (bottom - top) as f32).round() as i32)
1024 };
1025
1026 if axes.show_minor_grid {
1027 let subdivisions = 5;
1028 for i in 0..=(6 * subdivisions) {
1029 if i % subdivisions == 0 {
1030 continue;
1031 }
1032 let t = i as f32 / (6 * subdivisions) as f32;
1033 let x = (left as f32 + t * (right - left) as f32).round() as i32;
1034 let y = (top as f32 + t * (bottom - top) as f32).round() as i32;
1035
1036 canvas.draw_line(
1037 ScreenVertex {
1038 x: x as f32,
1039 y: top as f32,
1040 z: 0.0,
1041 color: minor_grid_color,
1042 },
1043 ScreenVertex {
1044 x: x as f32,
1045 y: bottom as f32,
1046 z: 0.0,
1047 color: minor_grid_color,
1048 },
1049 0.8,
1050 0,
1051 false,
1052 );
1053 canvas.draw_line(
1054 ScreenVertex {
1055 x: left as f32,
1056 y: y as f32,
1057 z: 0.0,
1058 color: minor_grid_color,
1059 },
1060 ScreenVertex {
1061 x: right as f32,
1062 y: y as f32,
1063 z: 0.0,
1064 color: minor_grid_color,
1065 },
1066 0.8,
1067 0,
1068 false,
1069 );
1070 }
1071 }
1072
1073 if axes.show_grid {
1074 let x_grid: Vec<i32> = axes
1075 .x_ticks
1076 .as_ref()
1077 .map(|ticks| {
1078 ticks
1079 .iter()
1080 .filter_map(|value| data_to_x(*value as f32))
1081 .collect()
1082 })
1083 .unwrap_or_else(|| {
1084 (0..=6)
1085 .map(|i| {
1086 let t = i as f32 / 6.0;
1087 (left as f32 + t * (right - left) as f32).round() as i32
1088 })
1089 .collect()
1090 });
1091 for x in x_grid {
1092 let gv = ScreenVertex {
1093 x: x as f32,
1094 y: top as f32,
1095 z: 0.0,
1096 color: grid_color,
1097 };
1098 let gv2 = ScreenVertex {
1099 x: x as f32,
1100 y: bottom as f32,
1101 z: 0.0,
1102 color: grid_color,
1103 };
1104 canvas.draw_line(gv, gv2, 1.0, 0, false);
1105 }
1106
1107 let y_grid: Vec<i32> = axes
1108 .y_ticks
1109 .as_ref()
1110 .map(|ticks| {
1111 ticks
1112 .iter()
1113 .filter_map(|value| data_to_y(*value as f32))
1114 .collect()
1115 })
1116 .unwrap_or_else(|| {
1117 (0..=6)
1118 .map(|i| {
1119 let t = i as f32 / 6.0;
1120 (top as f32 + t * (bottom - top) as f32).round() as i32
1121 })
1122 .collect()
1123 });
1124 for y in y_grid {
1125 let gh = ScreenVertex {
1126 x: left as f32,
1127 y: y as f32,
1128 z: 0.0,
1129 color: grid_color,
1130 };
1131 let gh2 = ScreenVertex {
1132 x: right as f32,
1133 y: y as f32,
1134 z: 0.0,
1135 color: grid_color,
1136 };
1137 canvas.draw_line(gh, gh2, 1.0, 0, false);
1138 }
1139 }
1140
1141 if axes.show_box {
1142 let corners = [
1143 (left as f32, top as f32),
1144 (right as f32, top as f32),
1145 (right as f32, bottom as f32),
1146 (left as f32, bottom as f32),
1147 ];
1148 for i in 0..4 {
1149 let a = corners[i];
1150 let b = corners[(i + 1) % 4];
1151 canvas.draw_line(
1152 ScreenVertex {
1153 x: a.0,
1154 y: a.1,
1155 z: 0.0,
1156 color: frame_color,
1157 },
1158 ScreenVertex {
1159 x: b.0,
1160 y: b.1,
1161 z: 0.0,
1162 color: frame_color,
1163 },
1164 1.2,
1165 0,
1166 false,
1167 );
1168 }
1169 }
1170
1171 let tick_sc = axes.tick_scale as i32;
1172 let x_tick_formatter = crate::core::plot_renderer::plot_utils::TickLabelFormatter::new(
1173 axes.x_tick_format.as_deref(),
1174 );
1175 let y_tick_formatter = crate::core::plot_renderer::plot_utils::TickLabelFormatter::new(
1176 axes.y_tick_format.as_deref(),
1177 );
1178 let x_ticks = axes
1179 .x_ticks
1180 .as_ref()
1181 .map(|ticks| ticks.iter().map(|value| *value as f32).collect::<Vec<_>>())
1182 .unwrap_or_else(|| {
1183 (0..=4)
1184 .map(|i| x_min + (i as f32 / 4.0) * (x_max - x_min))
1185 .collect()
1186 });
1187 for xv in x_ticks {
1188 if xv < x_min || xv > x_max {
1189 continue;
1190 }
1191 let t = (xv - x_min) / (x_max - x_min).max(f32::EPSILON);
1192 let x = (left as f32 + t * (right - left) as f32).round() as i32;
1193 draw_tick_text(
1194 canvas,
1195 BitmapTextAnchor {
1196 x,
1197 y: bottom + 6 + tick_sc,
1198 frac_x: 0.5,
1199 frac_y: 0.0,
1200 },
1201 &x_tick_formatter.format(xv as f64),
1202 axes.tick_scale,
1203 with_alpha(text_color, 0.9),
1204 axes.x_tick_label_rotation,
1205 );
1206 }
1207 let y_ticks = axes
1208 .y_ticks
1209 .as_ref()
1210 .map(|ticks| ticks.iter().map(|value| *value as f32).collect::<Vec<_>>())
1211 .unwrap_or_else(|| {
1212 (0..=4)
1213 .map(|i| y_max - (i as f32 / 4.0) * (y_max - y_min))
1214 .collect()
1215 });
1216 for yv in y_ticks {
1217 if yv < y_min || yv > y_max {
1218 continue;
1219 }
1220 let t = (y_max - yv) / (y_max - y_min).max(f32::EPSILON);
1221 let y = (top as f32 + t * (bottom - top) as f32).round() as i32;
1222 draw_tick_text(
1223 canvas,
1224 BitmapTextAnchor {
1225 x: left - 8 * tick_sc,
1226 y,
1227 frac_x: 1.0,
1228 frac_y: 0.5,
1229 },
1230 &y_tick_formatter.format(yv as f64),
1231 axes.tick_scale,
1232 with_alpha(text_color, 0.9),
1233 axes.y_tick_label_rotation,
1234 );
1235 }
1236
1237 draw_axes_titles_and_labels(canvas, axes, text_color);
1238}
1239
1240fn draw_axes_titles_and_labels(canvas: &mut Canvas, axes: &AxesView, text_color: [u8; 4]) {
1241 if let Some(title) = &axes.title {
1242 draw_text_centered(
1243 canvas,
1244 (axes.viewport.0 + axes.viewport.2 / 2) as i32,
1245 axes.viewport.1 as i32 + 6,
1246 title,
1247 axes.title_scale,
1248 text_color,
1249 );
1250 }
1251 if let Some(subtitle) = &axes.subtitle {
1252 draw_text_centered(
1253 canvas,
1254 (axes.viewport.0 + axes.viewport.2 / 2) as i32,
1255 axes.viewport.1 as i32 + 8 + (10 * axes.title_scale) as i32,
1256 subtitle,
1257 axes.title_scale.saturating_sub(1).max(1),
1258 with_alpha(text_color, 0.9),
1259 );
1260 }
1261 if let Some(x_label) = &axes.x_label {
1262 let label_sc = axes.label_scale as i32;
1263 draw_text_centered(
1264 canvas,
1265 (axes.viewport.0 + axes.viewport.2 / 2) as i32,
1266 (axes.viewport.1 + axes.viewport.3).saturating_sub((12 + 10 * label_sc) as u32) as i32,
1267 x_label,
1268 axes.label_scale,
1269 text_color,
1270 );
1271 }
1272 if let Some(y_label) = &axes.y_label {
1273 let label_sc = axes.label_scale as i32;
1274 draw_bitmap_text(
1275 canvas,
1276 axes.viewport.0 as i32 + 6,
1277 (axes.viewport.1 + axes.viewport.3 / 2).saturating_sub((8 * label_sc) as u32) as i32,
1278 y_label,
1279 axes.label_scale,
1280 text_color,
1281 );
1282 }
1283}
1284
1285fn draw_polar_axes_decorations(
1286 canvas: &mut Canvas,
1287 axes: &AxesView,
1288 bounds: (i32, i32, i32, i32),
1289 grid_color: [u8; 4],
1290 minor_grid_color: [u8; 4],
1291 frame_color: [u8; 4],
1292 text_color: [u8; 4],
1293) {
1294 let (left, right, top, bottom) = bounds;
1295 let cx = (left + right) as f32 * 0.5;
1296 let cy = (top + bottom) as f32 * 0.5;
1297 let max_r = ((right - left).min(bottom - top) as f32 * 0.5).max(1.0);
1298
1299 if axes.show_minor_grid {
1300 for i in 1..30 {
1301 if i % 5 == 0 {
1302 continue;
1303 }
1304 draw_screen_circle(canvas, cx, cy, max_r * i as f32 / 30.0, minor_grid_color);
1305 }
1306 }
1307 if axes.show_grid {
1308 for i in 1..=6 {
1309 draw_screen_circle(canvas, cx, cy, max_r * i as f32 / 6.0, grid_color);
1310 }
1311 for i in 0..12 {
1312 let theta = i as f32 * std::f32::consts::TAU / 12.0;
1313 let x = cx + theta.cos() * max_r;
1314 let y = cy - theta.sin() * max_r;
1315 canvas.draw_line(
1316 ScreenVertex {
1317 x: cx,
1318 y: cy,
1319 z: 0.0,
1320 color: grid_color,
1321 },
1322 ScreenVertex {
1323 x,
1324 y,
1325 z: 0.0,
1326 color: grid_color,
1327 },
1328 1.0,
1329 0,
1330 false,
1331 );
1332 if i % 3 == 0 {
1333 let label = format!("{}deg", i * 30);
1334 draw_text_centered(
1335 canvas,
1336 (cx + theta.cos() * (max_r + 18.0)) as i32,
1337 (cy - theta.sin() * (max_r + 18.0)) as i32,
1338 &label,
1339 axes.tick_scale,
1340 with_alpha(text_color, 0.9),
1341 );
1342 }
1343 }
1344 }
1345
1346 if axes.show_box {
1347 draw_screen_circle(canvas, cx, cy, max_r, frame_color);
1348 }
1349}
1350
1351fn draw_screen_circle(canvas: &mut Canvas, cx: f32, cy: f32, radius: f32, color: [u8; 4]) {
1352 const SEGMENTS: usize = 96;
1353 if !radius.is_finite() || radius <= 0.0 {
1354 return;
1355 }
1356 let mut prev = None;
1357 for i in 0..=SEGMENTS {
1358 let theta = i as f32 * std::f32::consts::TAU / SEGMENTS as f32;
1359 let point = ScreenVertex {
1360 x: cx + theta.cos() * radius,
1361 y: cy - theta.sin() * radius,
1362 z: 0.0,
1363 color,
1364 };
1365 if let Some(prev) = prev {
1366 canvas.draw_line(prev, point, 1.0, 0, false);
1367 }
1368 prev = Some(point);
1369 }
1370}
1371
1372fn draw_3d_axes_decorations(canvas: &mut Canvas, axes: &AxesView) {
1373 let floor_grid_minor = [44, 54, 70, 68];
1374 let axis_x_color = [235, 80, 80, 230];
1375 let axis_y_color = [90, 220, 120, 230];
1376 let axis_z_color = [90, 160, 255, 230];
1377 let text_color = [212, 220, 234, 255];
1378
1379 let (bmin, bmax) = axes.bounds_3d;
1380 let Some(cam) = axes.camera_3d.as_ref() else {
1381 return;
1382 };
1383
1384 let origin_component = |lo: f32, hi: f32| -> f32 {
1385 if lo <= 0.0 && hi >= 0.0 {
1386 0.0
1387 } else {
1388 lo
1389 }
1390 };
1391 let ox = origin_component(bmin.x, bmax.x);
1392 let oy = origin_component(bmin.y, bmax.y);
1393 let oz = origin_component(bmin.z, bmax.z);
1394 let floor_z = oz;
1395
1396 if axes.show_minor_grid {
1397 let divisions = 28usize;
1398 for i in 0..=divisions {
1399 if i % 4 == 0 {
1400 continue;
1401 }
1402 let t = i as f32 / divisions as f32;
1403 let x = bmin.x + t * (bmax.x - bmin.x);
1404 let y = bmin.y + t * (bmax.y - bmin.y);
1405
1406 let gx0 = Vec3::new(x, bmin.y, floor_z);
1407 let gx1 = Vec3::new(x, bmax.y, floor_z);
1408 let gy0 = Vec3::new(bmin.x, y, floor_z);
1409 let gy1 = Vec3::new(bmax.x, y, floor_z);
1410
1411 let Some(a0) = project_3d(gx0, axes.plot_rect, cam, floor_grid_minor) else {
1412 continue;
1413 };
1414 let Some(a1) = project_3d(gx1, axes.plot_rect, cam, floor_grid_minor) else {
1415 continue;
1416 };
1417 canvas.draw_line(a0, a1, 0.9, 0, false);
1418
1419 let Some(b0) = project_3d(gy0, axes.plot_rect, cam, floor_grid_minor) else {
1420 continue;
1421 };
1422 let Some(b1) = project_3d(gy1, axes.plot_rect, cam, floor_grid_minor) else {
1423 continue;
1424 };
1425 canvas.draw_line(b0, b1, 0.9, 0, false);
1426 }
1427 }
1428
1429 if axes.show_grid {
1430 let divisions = 7usize;
1431 let floor_grid_major = [74, 86, 106, 116];
1432 for i in 0..=divisions {
1433 let t = i as f32 / divisions as f32;
1434 let x = bmin.x + t * (bmax.x - bmin.x);
1435 let y = bmin.y + t * (bmax.y - bmin.y);
1436
1437 let gx0 = Vec3::new(x, bmin.y, floor_z);
1438 let gx1 = Vec3::new(x, bmax.y, floor_z);
1439 let gy0 = Vec3::new(bmin.x, y, floor_z);
1440 let gy1 = Vec3::new(bmax.x, y, floor_z);
1441
1442 let Some(a0) = project_3d(gx0, axes.plot_rect, cam, floor_grid_major) else {
1443 continue;
1444 };
1445 let Some(a1) = project_3d(gx1, axes.plot_rect, cam, floor_grid_major) else {
1446 continue;
1447 };
1448 canvas.draw_line(a0, a1, 1.1, 0, false);
1449
1450 let Some(b0) = project_3d(gy0, axes.plot_rect, cam, floor_grid_major) else {
1451 continue;
1452 };
1453 let Some(b1) = project_3d(gy1, axes.plot_rect, cam, floor_grid_major) else {
1454 continue;
1455 };
1456 canvas.draw_line(b0, b1, 1.1, 0, false);
1457 }
1458 }
1459
1460 let x_end = if bmax.x >= ox {
1461 Vec3::new(bmax.x, oy, floor_z)
1462 } else {
1463 Vec3::new(bmin.x, oy, floor_z)
1464 };
1465 let y_end = if bmax.y >= oy {
1466 Vec3::new(ox, bmax.y, floor_z)
1467 } else {
1468 Vec3::new(ox, bmin.y, floor_z)
1469 };
1470 let z_end = if bmax.z >= oz {
1471 Vec3::new(ox, oy, bmax.z)
1472 } else {
1473 Vec3::new(ox, oy, bmin.z)
1474 };
1475 let origin = Vec3::new(ox, oy, oz);
1476
1477 if let (Some(o), Some(xp)) = (
1478 project_3d(origin, axes.plot_rect, cam, axis_x_color),
1479 project_3d(x_end, axes.plot_rect, cam, axis_x_color),
1480 ) {
1481 canvas.draw_line(o, xp, 1.8, 0, false);
1482 draw_bitmap_text(
1483 canvas,
1484 xp.x as i32 + 6,
1485 xp.y as i32 + 2,
1486 axes.x_label.as_deref().unwrap_or("x"),
1487 axes.label_scale,
1488 axis_x_color,
1489 );
1490 }
1491 if let (Some(o), Some(yp)) = (
1492 project_3d(origin, axes.plot_rect, cam, axis_y_color),
1493 project_3d(y_end, axes.plot_rect, cam, axis_y_color),
1494 ) {
1495 canvas.draw_line(o, yp, 1.8, 0, false);
1496 draw_bitmap_text(
1497 canvas,
1498 yp.x as i32 + 6,
1499 yp.y as i32 + 2,
1500 axes.y_label.as_deref().unwrap_or("y"),
1501 axes.label_scale,
1502 axis_y_color,
1503 );
1504 }
1505 if let (Some(o), Some(zp)) = (
1506 project_3d(origin, axes.plot_rect, cam, axis_z_color),
1507 project_3d(z_end, axes.plot_rect, cam, axis_z_color),
1508 ) {
1509 canvas.draw_line(o, zp, 1.8, 0, false);
1510 draw_bitmap_text(
1511 canvas,
1512 zp.x as i32 + 6,
1513 zp.y as i32 + 2,
1514 axes.z_label.as_deref().unwrap_or("z"),
1515 axes.label_scale,
1516 axis_z_color,
1517 );
1518 }
1519
1520 if let Some(title) = &axes.title {
1521 draw_text_centered(
1522 canvas,
1523 (axes.viewport.0 + axes.viewport.2 / 2) as i32,
1524 axes.viewport.1 as i32 + 6,
1525 title,
1526 axes.title_scale,
1527 text_color,
1528 );
1529 }
1530}
1531
1532fn draw_3d_orientation_gizmo(canvas: &mut Canvas, axes: &AxesView) {
1533 let Some(cam) = axes.camera_3d.as_ref() else {
1534 return;
1535 };
1536 let forward = (cam.target - cam.position).normalize_or_zero();
1537 if forward.length_squared() < 1e-9 {
1538 return;
1539 }
1540 let world_up = cam.up.normalize_or_zero();
1541 let right = forward.cross(world_up).normalize_or_zero();
1542 if right.length_squared() < 1e-9 {
1543 return;
1544 }
1545 let up = right.cross(forward).normalize_or_zero();
1546 if up.length_squared() < 1e-9 {
1547 return;
1548 }
1549
1550 #[derive(Clone, Copy)]
1551 struct AxisItem {
1552 label: &'static str,
1553 dir_world: Vec3,
1554 color: [u8; 4],
1555 z_sort: f32,
1556 }
1557
1558 let mut axis_items = [
1559 AxisItem {
1560 label: "X",
1561 dir_world: Vec3::X,
1562 color: [235, 80, 80, 255],
1563 z_sort: 0.0,
1564 },
1565 AxisItem {
1566 label: "Y",
1567 dir_world: Vec3::Y,
1568 color: [90, 220, 120, 255],
1569 z_sort: 0.0,
1570 },
1571 AxisItem {
1572 label: "Z",
1573 dir_world: Vec3::Z,
1574 color: [90, 160, 255, 255],
1575 z_sort: 0.0,
1576 },
1577 ];
1578
1579 for a in &mut axis_items {
1580 let x = a.dir_world.dot(right);
1581 let y = a.dir_world.dot(up);
1582 let z = a.dir_world.dot(-forward);
1583 a.z_sort = z;
1584 a.dir_world = Vec3::new(x, y, z);
1585 }
1586 axis_items.sort_by(|a, b| a.z_sort.total_cmp(&b.z_sort));
1587
1588 let scale = ((axes.viewport.2.min(axes.viewport.3) as f32) / 720.0).clamp(0.8, 1.6);
1589 let gizmo_size =
1590 ((axes.viewport.2.min(axes.viewport.3) as f32) * 0.16).clamp(44.0, 110.0) * scale;
1591 let pad = (30.0 * scale).round() as i32;
1592 let origin = Vec2::new(
1593 (axes.viewport.0 as i32 + pad) as f32,
1594 ((axes.viewport.1 + axes.viewport.3) as i32 - pad) as f32,
1595 );
1596 canvas.draw_disc(
1597 origin,
1598 (2.0 * scale).max(1.0),
1599 [210, 214, 224, 255],
1600 0.0,
1601 false,
1602 );
1603
1604 let axis_len = gizmo_size * 0.65;
1605 let head_len = (8.0 * scale).min(axis_len * 0.35);
1606 let head_w = 5.0 * scale;
1607 for a in &axis_items {
1608 let dir2 = Vec2::new(a.dir_world.x, -a.dir_world.y);
1609 let mag = dir2.length();
1610 if !mag.is_finite() || mag < 1e-4 {
1611 continue;
1612 }
1613 let d = dir2 / mag;
1614 let end = origin + d * axis_len;
1615 canvas.draw_line(
1616 ScreenVertex {
1617 x: origin.x,
1618 y: origin.y,
1619 z: 0.0,
1620 color: a.color,
1621 },
1622 ScreenVertex {
1623 x: end.x,
1624 y: end.y,
1625 z: 0.0,
1626 color: a.color,
1627 },
1628 (2.0 * scale).max(1.2),
1629 0,
1630 false,
1631 );
1632
1633 let base = end - d * head_len;
1634 let perp = Vec2::new(-d.y, d.x);
1635 canvas.draw_line(
1636 ScreenVertex {
1637 x: end.x,
1638 y: end.y,
1639 z: 0.0,
1640 color: a.color,
1641 },
1642 ScreenVertex {
1643 x: (base + perp * head_w).x,
1644 y: (base + perp * head_w).y,
1645 z: 0.0,
1646 color: a.color,
1647 },
1648 (2.0 * scale).max(1.2),
1649 0,
1650 false,
1651 );
1652 canvas.draw_line(
1653 ScreenVertex {
1654 x: end.x,
1655 y: end.y,
1656 z: 0.0,
1657 color: a.color,
1658 },
1659 ScreenVertex {
1660 x: (base - perp * head_w).x,
1661 y: (base - perp * head_w).y,
1662 z: 0.0,
1663 color: a.color,
1664 },
1665 (2.0 * scale).max(1.2),
1666 0,
1667 false,
1668 );
1669
1670 let label_pos = end + d * (10.0 * scale);
1671 draw_bitmap_text(
1672 canvas,
1673 label_pos.x as i32 - 3,
1674 label_pos.y as i32 - 3,
1675 a.label,
1676 1,
1677 a.color,
1678 );
1679 }
1680}
1681
1682fn draw_legend_for_axes(canvas: &mut Canvas, figure: &Figure, axes: &AxesView) {
1683 if !figure.legend_enabled {
1684 return;
1685 }
1686 let entries = figure.legend_entries();
1687 if entries.is_empty() {
1688 return;
1689 }
1690
1691 let max_entries = entries.len().min(8);
1692 let pad = 10i32;
1693 let row_h = 20i32;
1694 let legend_w = ((axes.viewport.2 as f32 * 0.30).clamp(92.0, 148.0)).round() as i32;
1695 let legend_h = row_h * max_entries as i32 + 10;
1696 let x = (axes.viewport.0 + axes.viewport.2) as i32 - legend_w - pad;
1697 let y = axes.viewport.1 as i32 + 12;
1698
1699 canvas.fill_rect(x, y, legend_w, legend_h, [8, 14, 24, 220]);
1700 canvas.stroke_rect(x, y, legend_w, legend_h, [36, 52, 74, 245], 1.0);
1701
1702 for (i, entry) in entries.into_iter().take(max_entries).enumerate() {
1703 let yy = y + 6 + i as i32 * row_h + row_h / 2;
1704 let swatch_x0 = x + 10;
1705 let swatch_x1 = swatch_x0 + 18;
1706 let swatch_color = to_u8_rgba(entry.color.to_array());
1707 canvas.draw_line(
1708 ScreenVertex {
1709 x: swatch_x0 as f32,
1710 y: yy as f32,
1711 z: 0.0,
1712 color: swatch_color,
1713 },
1714 ScreenVertex {
1715 x: swatch_x1 as f32,
1716 y: yy as f32,
1717 z: 0.0,
1718 color: swatch_color,
1719 },
1720 2.0,
1721 0,
1722 false,
1723 );
1724
1725 let label = if entry.label.is_empty() {
1726 "Series".to_string()
1727 } else {
1728 entry.label
1729 };
1730 draw_bitmap_text(canvas, x + 34, yy - 4, &label, 1, [220, 228, 239, 255]);
1731 }
1732}
1733
1734pub async fn render_figure_rgba_bytes(
1735 mut figure: Figure,
1736 width: u32,
1737 height: u32,
1738 theme: Option<PlotThemeConfig>,
1739 camera: Option<&Camera>,
1740 axes_cameras: Option<&[Camera]>,
1741 _textmark: Option<&str>,
1742) -> Result<Vec<u8>, String> {
1743 let width = width.max(1);
1744 let height = height.max(1);
1745 let bg = if is_default_figure_bg(figure.background_color) {
1746 theme
1747 .as_ref()
1748 .map(|cfg| cfg.build_theme().get_background_color())
1749 .unwrap_or_else(|| Vec4::new(1.0, 1.0, 1.0, 1.0))
1750 } else {
1751 figure.background_color
1752 };
1753 let mut canvas = Canvas::new(width, height, to_u8_rgba(bg.to_array()));
1754
1755 let (rows, cols) = figure.axes_grid();
1756 let viewports = compute_tiled_viewports(width, height, rows.max(1), cols.max(1));
1757 let axes_count = rows.max(1) * cols.max(1);
1758
1759 let has_3d_flags: Vec<bool> = (0..axes_count)
1760 .map(|axes_index| axes_has_3d_content(&figure, axes_index))
1761 .collect();
1762 let axes_sizes: Vec<(u32, u32)> = viewports
1763 .iter()
1764 .enumerate()
1765 .map(|(axes_index, vp)| {
1766 let has_3d = has_3d_flags[axes_index];
1767 let meta = figure.axes_metadata(axes_index);
1768 let mut rect = compute_plot_rect(
1769 *vp,
1770 has_3d,
1771 meta.and_then(|m| m.x_tick_label_rotation).unwrap_or(0.0),
1772 meta.and_then(|m| m.y_tick_label_rotation).unwrap_or(0.0),
1773 );
1774 if !has_3d && figure.axes_kind(axes_index) == AxesKind::Polar {
1775 rect = square_plot_rect(rect);
1776 }
1777 (rect.2.max(1), rect.3.max(1))
1778 })
1779 .collect();
1780
1781 let render_items = figure.render_data_with_axes_with_viewport_and_gpu(
1782 Some((width, height)),
1783 Some(&axes_sizes),
1784 None,
1785 None,
1786 );
1787
1788 let mut axes_views = Vec::with_capacity(axes_count);
1789 for axes_index in 0..axes_count {
1790 let has_3d = has_3d_flags[axes_index];
1791 let viewport = viewports[axes_index];
1792 let meta = figure.axes_metadata(axes_index);
1793 let mut plot_rect = compute_plot_rect(
1794 viewport,
1795 has_3d,
1796 meta.and_then(|m| m.x_tick_label_rotation).unwrap_or(0.0),
1797 meta.and_then(|m| m.y_tick_label_rotation).unwrap_or(0.0),
1798 );
1799 if !has_3d && figure.axes_kind(axes_index) == AxesKind::Polar {
1800 plot_rect = square_plot_rect(plot_rect);
1801 }
1802 let bounds_2d = choose_axes_bounds(&figure, axes_index, &render_items);
1803 let (bmin, bmax) = choose_axes_bounds_3d(&figure, axes_index, &render_items, bounds_2d);
1804 let camera_3d = if has_3d {
1805 Some(if axes_count == 1 {
1806 camera.cloned().unwrap_or_else(|| {
1807 choose_axes_camera(&figure, axes_index, axes_cameras, bmin, bmax)
1808 })
1809 } else {
1810 choose_axes_camera(&figure, axes_index, axes_cameras, bmin, bmax)
1811 })
1812 } else {
1813 None
1814 };
1815
1816 let text = get_axes_title_and_labels(&figure, axes_index);
1817 let (
1818 x_ticks,
1819 y_ticks,
1820 x_tick_format,
1821 y_tick_format,
1822 x_tick_label_rotation,
1823 y_tick_label_rotation,
1824 ) = figure
1825 .axes_metadata(axes_index)
1826 .map(|meta| {
1827 (
1828 meta.x_ticks.clone(),
1829 meta.y_ticks.clone(),
1830 meta.x_tick_format.clone(),
1831 meta.y_tick_format.clone(),
1832 meta.x_tick_label_rotation.unwrap_or(0.0),
1833 meta.y_tick_label_rotation.unwrap_or(0.0),
1834 )
1835 })
1836 .unwrap_or((None, None, None, None, 0.0, 0.0));
1837 let (title_scale, label_scale, tick_scale, show_grid, show_minor_grid, show_box) =
1838 get_axes_style_and_display_prefs(&figure, axes_index);
1839 let (colorbar_enabled, colormap) = get_axes_colorbar_prefs(&figure, axes_index);
1840
1841 axes_views.push(AxesView {
1842 viewport,
1843 plot_rect,
1844 bounds_2d,
1845 bounds_3d: (bmin, bmax),
1846 camera_3d,
1847 has_3d_content: has_3d,
1848 title: text.title,
1849 subtitle: text.subtitle,
1850 x_label: text.x_label,
1851 y_label: text.y_label,
1852 z_label: text.z_label,
1853 x_ticks,
1854 y_ticks,
1855 x_tick_format,
1856 y_tick_format,
1857 x_tick_label_rotation,
1858 y_tick_label_rotation,
1859 title_scale,
1860 label_scale,
1861 tick_scale,
1862 show_grid,
1863 show_minor_grid,
1864 show_box,
1865 axes_kind: figure.axes_kind(axes_index),
1866 colorbar_enabled,
1867 colormap,
1868 });
1869 }
1870
1871 for axes in &axes_views {
1872 if axes.has_3d_content {
1873 draw_3d_axes_decorations(&mut canvas, axes);
1874 } else {
1875 draw_2d_axes_decorations(&mut canvas, axes);
1876 }
1877 }
1878
1879 for (axes_index, rd) in render_items.iter() {
1880 if rd.vertices.is_empty() {
1881 continue;
1882 }
1883 let Some(axes) = axes_views.get(*axes_index) else {
1884 continue;
1885 };
1886 draw_render_data(&mut canvas, rd, axes);
1887 }
1888 for axes in &axes_views {
1889 if axes.has_3d_content {
1890 draw_3d_orientation_gizmo(&mut canvas, axes);
1891 if axes_views.len() == 1 {
1892 draw_legend_for_axes(&mut canvas, &figure, axes);
1893 }
1894 }
1895 draw_colorbar_for_axes(&mut canvas, axes);
1896 }
1897
1898 Ok(canvas.rgba())
1899}
1900
1901fn get_axes_colorbar_prefs(figure: &Figure, axes_index: usize) -> (bool, ColorMap) {
1902 let meta = figure.axes_metadata(axes_index);
1903 let enabled = figure.colorbar_enabled
1904 || meta
1905 .map(|meta| meta.colorbar_enabled)
1906 .unwrap_or(figure.colorbar_enabled);
1907 let colormap = meta
1908 .map(|meta| meta.colormap.clone())
1909 .unwrap_or_else(|| figure.colormap.clone());
1910 (enabled, colormap)
1911}
1912
1913fn draw_colorbar_for_axes(canvas: &mut Canvas, axes: &AxesView) {
1914 if !axes.colorbar_enabled {
1915 return;
1916 }
1917 let (_, py, pw, ph) = axes.plot_rect;
1918 if pw < 48 || ph < 48 {
1919 return;
1920 }
1921
1922 let bar_width = 12_i32;
1923 let pad = 8_i32;
1924 let x = (axes.plot_rect.0 + pw) as i32 - bar_width - pad;
1925 let y = py as i32 + pad;
1926 let h = ph as i32 - 2 * pad;
1927 if h <= 0 {
1928 return;
1929 }
1930
1931 for row in 0..h {
1932 let t = 1.0 - (row as f32 / (h - 1).max(1) as f32);
1933 let color = axes.colormap.map_value(t);
1934 let rgba = [
1935 (color.x.clamp(0.0, 1.0) * 255.0).round() as u8,
1936 (color.y.clamp(0.0, 1.0) * 255.0).round() as u8,
1937 (color.z.clamp(0.0, 1.0) * 255.0).round() as u8,
1938 255,
1939 ];
1940 canvas.fill_rect(x, y + row, bar_width, 1, rgba);
1941 }
1942 canvas.stroke_rect(x, y, bar_width, h, [50, 58, 72, 255], 1.0);
1943}
1944
1945fn draw_render_data(canvas: &mut Canvas, render_data: &RenderData, axes: &AxesView) {
1946 let width_px = render_data.material.roughness.max(1.0);
1947 let style_code = render_data.material.metallic as i32;
1948
1949 match render_data.pipeline_type {
1950 PipelineType::Lines | PipelineType::LinesNoDepth => {
1951 let depth_test =
1952 axes.has_3d_content && render_data.pipeline_type != PipelineType::LinesNoDepth;
1953 for segment in render_data.vertices.chunks_exact(2) {
1954 let Some(a) = project_vertex(&segment[0], axes) else {
1955 continue;
1956 };
1957 let Some(b) = project_vertex(&segment[1], axes) else {
1958 continue;
1959 };
1960 canvas.draw_line(a, b, width_px, style_code, depth_test);
1961 }
1962 }
1963 PipelineType::Points | PipelineType::Scatter3 => {
1964 for v in &render_data.vertices {
1965 let Some(p) = project_vertex(v, axes) else {
1966 continue;
1967 };
1968 let marker_radius = (v.normal[2].max(1.0) * 0.5).max(1.0);
1969 canvas.draw_disc(
1970 Vec2::new(p.x, p.y),
1971 marker_radius,
1972 p.color,
1973 p.z,
1974 axes.has_3d_content,
1975 );
1976 }
1977 }
1978 PipelineType::Triangles => {
1979 if axes.has_3d_content && render_data.indices.is_none() {
1980 return;
1981 }
1982 if let Some(indices) = &render_data.indices {
1983 for tri in indices.chunks_exact(3) {
1984 let (Some(v0), Some(v1), Some(v2)) = (
1985 render_data.vertices.get(tri[0] as usize),
1986 render_data.vertices.get(tri[1] as usize),
1987 render_data.vertices.get(tri[2] as usize),
1988 ) else {
1989 continue;
1990 };
1991 let (Some(p0), Some(p1), Some(p2)) = (
1992 project_vertex(v0, axes),
1993 project_vertex(v1, axes),
1994 project_vertex(v2, axes),
1995 ) else {
1996 continue;
1997 };
1998 canvas.fill_triangle(p0, p1, p2, axes.has_3d_content);
1999 }
2000 } else {
2001 for tri in render_data.vertices.chunks_exact(3) {
2002 let (Some(p0), Some(p1), Some(p2)) = (
2003 project_vertex(&tri[0], axes),
2004 project_vertex(&tri[1], axes),
2005 project_vertex(&tri[2], axes),
2006 ) else {
2007 continue;
2008 };
2009 canvas.fill_triangle(p0, p1, p2, axes.has_3d_content);
2010 }
2011 }
2012 }
2013 PipelineType::Textured => {
2014 if draw_textured_image(canvas, render_data, axes) {
2015 return;
2016 }
2017
2018 if let Some(indices) = &render_data.indices {
2019 for tri in indices.chunks_exact(3) {
2020 let (Some(v0), Some(v1), Some(v2)) = (
2021 render_data.vertices.get(tri[0] as usize),
2022 render_data.vertices.get(tri[1] as usize),
2023 render_data.vertices.get(tri[2] as usize),
2024 ) else {
2025 continue;
2026 };
2027 let (Some(p0), Some(p1), Some(p2)) = (
2028 project_vertex(v0, axes),
2029 project_vertex(v1, axes),
2030 project_vertex(v2, axes),
2031 ) else {
2032 continue;
2033 };
2034 canvas.fill_triangle(p0, p1, p2, axes.has_3d_content);
2035 }
2036 } else {
2037 for tri in render_data.vertices.chunks_exact(3) {
2038 let (Some(p0), Some(p1), Some(p2)) = (
2039 project_vertex(&tri[0], axes),
2040 project_vertex(&tri[1], axes),
2041 project_vertex(&tri[2], axes),
2042 ) else {
2043 continue;
2044 };
2045 canvas.fill_triangle(p0, p1, p2, axes.has_3d_content);
2046 }
2047 }
2048 }
2049 }
2050}
2051
2052fn draw_textured_image(canvas: &mut Canvas, render_data: &RenderData, axes: &AxesView) -> bool {
2053 let Some(ImageData::Rgba8 {
2054 width,
2055 height,
2056 data,
2057 }) = render_data.image.as_ref()
2058 else {
2059 return false;
2060 };
2061 if *width == 0 || *height == 0 || data.len() < (*width as usize * *height as usize * 4) {
2062 return false;
2063 }
2064
2065 let projected: Vec<ScreenVertex> = render_data
2066 .vertices
2067 .iter()
2068 .filter_map(|vertex| project_vertex(vertex, axes))
2069 .collect();
2070 if projected.is_empty() {
2071 return false;
2072 }
2073
2074 let min_x = projected
2075 .iter()
2076 .map(|vertex| vertex.x)
2077 .fold(f32::INFINITY, f32::min)
2078 .floor()
2079 .max(0.0) as i32;
2080 let max_x = projected
2081 .iter()
2082 .map(|vertex| vertex.x)
2083 .fold(f32::NEG_INFINITY, f32::max)
2084 .ceil()
2085 .min(canvas.width as f32 - 1.0) as i32;
2086 let min_y = projected
2087 .iter()
2088 .map(|vertex| vertex.y)
2089 .fold(f32::INFINITY, f32::min)
2090 .floor()
2091 .max(0.0) as i32;
2092 let max_y = projected
2093 .iter()
2094 .map(|vertex| vertex.y)
2095 .fold(f32::NEG_INFINITY, f32::max)
2096 .ceil()
2097 .min(canvas.height as f32 - 1.0) as i32;
2098 if min_x > max_x || min_y > max_y {
2099 return false;
2100 }
2101
2102 let dst_w = (max_x - min_x).max(1) as f32;
2103 let dst_h = (max_y - min_y).max(1) as f32;
2104 let src_w = (*width).saturating_sub(1).max(1) as f32;
2105 let src_h = (*height).saturating_sub(1).max(1) as f32;
2106 let depth = projected.iter().map(|vertex| vertex.z).sum::<f32>() / projected.len() as f32;
2107
2108 for y in min_y..=max_y {
2109 let v = (y - min_y) as f32 / dst_h;
2110 let src_y = (v * src_h).round().clamp(0.0, src_h) as usize;
2111 for x in min_x..=max_x {
2112 let u = (x - min_x) as f32 / dst_w;
2113 let src_x = (u * src_w).round().clamp(0.0, src_w) as usize;
2114 let src = (src_y * *width as usize + src_x) * 4;
2115 canvas.blend_pixel(
2116 x,
2117 y,
2118 [data[src], data[src + 1], data[src + 2], data[src + 3]],
2119 depth,
2120 axes.has_3d_content,
2121 );
2122 }
2123 }
2124
2125 true
2126}
2127
2128pub fn encode_png_bytes(width: u32, height: u32, rgba: &[u8]) -> Result<Vec<u8>, String> {
2129 use image::{ImageBuffer, ImageFormat, Rgba};
2130
2131 let image = ImageBuffer::<Rgba<u8>, _>::from_raw(width.max(1), height.max(1), rgba.to_vec())
2132 .ok_or_else(|| "Failed to create image buffer for CPU PNG encoding".to_string())?;
2133 let mut out = std::io::Cursor::new(Vec::new());
2134 image
2135 .write_to(&mut out, ImageFormat::Png)
2136 .map_err(|err| format!("Failed to encode CPU PNG bytes: {err}"))?;
2137 Ok(out.into_inner())
2138}
2139
2140pub async fn render_figure_png_bytes(
2141 figure: Figure,
2142 width: u32,
2143 height: u32,
2144 theme: Option<PlotThemeConfig>,
2145 camera: Option<&Camera>,
2146 axes_cameras: Option<&[Camera]>,
2147 textmark: Option<&str>,
2148) -> Result<Vec<u8>, String> {
2149 let rgba =
2150 render_figure_rgba_bytes(figure, width, height, theme, camera, axes_cameras, textmark)
2151 .await?;
2152 encode_png_bytes(width.max(1), height.max(1), &rgba)
2153}
2154
2155#[cfg(test)]
2156mod tests {
2157 use super::*;
2158 use crate::plots::{MeshEdgeMode, MeshFieldLocation, MeshPlot, MeshScalarField};
2159 use glam::Vec3;
2160 use std::collections::BTreeSet;
2161
2162 #[test]
2163 fn cpu_export_respects_explicit_axes_minor_grid_override() {
2164 let mut figure = Figure::new();
2165 figure.minor_grid_enabled = true;
2166 figure.set_axes_minor_grid_enabled(0, false);
2167
2168 let (_, _, _, _, show_minor_grid, _) = get_axes_style_and_display_prefs(&figure, 0);
2169 assert!(!show_minor_grid);
2170
2171 let mut inherited = Figure::new();
2172 inherited.minor_grid_enabled = true;
2173 let (_, _, _, _, inherited_minor_grid, _) = get_axes_style_and_display_prefs(&inherited, 0);
2174 assert!(inherited_minor_grid);
2175 }
2176
2177 #[test]
2178 fn cpu_export_renders_scalar_mesh_with_colorbar() {
2179 let mut mesh = MeshPlot::new(
2180 vec![
2181 Vec3::new(0.0, 0.0, 0.0),
2182 Vec3::new(1.0, 0.0, 0.0),
2183 Vec3::new(0.0, 1.0, 0.0),
2184 Vec3::new(0.0, 0.0, 1.0),
2185 ],
2186 vec![[0, 1, 2], [0, 1, 3], [0, 2, 3], [1, 2, 3]],
2187 )
2188 .expect("tetrahedron boundary mesh should be valid");
2189 mesh.set_edge_mode(MeshEdgeMode::None);
2190 mesh.set_scalar_field(Some(MeshScalarField::new(
2191 "structural.von_mises",
2192 MeshFieldLocation::Triangle,
2193 vec![0.0, 0.33, 0.66, 1.0],
2194 )))
2195 .expect("triangle scalar field should attach");
2196
2197 let mut figure = Figure::new();
2198 figure.colorbar_enabled = true;
2199 figure.add_mesh_plot(mesh);
2200
2201 let width = 320;
2202 let height = 240;
2203 let rgba = pollster::block_on(render_figure_rgba_bytes(
2204 figure, width, height, None, None, None, None,
2205 ))
2206 .expect("CPU render should succeed");
2207
2208 let background = [255, 255, 255, 255];
2209 let non_background = rgba
2210 .chunks_exact(4)
2211 .filter(|pixel| *pixel != background)
2212 .count();
2213 assert!(
2214 non_background > 2_000,
2215 "scalar mesh render should not be blank"
2216 );
2217
2218 let mut max_vertical_color_variation = 0;
2219 for x in (width / 2)..(width - 12) {
2220 let mut colorbar_colors = BTreeSet::new();
2221 for y in 40..(height - 40) {
2222 let idx = ((y * width + x) * 4) as usize;
2223 colorbar_colors.insert([rgba[idx], rgba[idx + 1], rgba[idx + 2], rgba[idx + 3]]);
2224 }
2225 max_vertical_color_variation = max_vertical_color_variation.max(colorbar_colors.len());
2226 }
2227 assert!(
2228 max_vertical_color_variation > 12,
2229 "colorbar strip should contain a visible gradient; saw {max_vertical_color_variation} unique colors"
2230 );
2231 }
2232}