Skip to main content

runmat_plot/export/
cpu_surface.rs

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}