Skip to main content

ccalc_plot/
lib.rs

1//! Plot plugin for ccalc — Phase 30f.
2//!
3//! Provides `plot`, `scatter`, `bar`, `stem`, `hist`, `stairs`, `loglog`,
4//! `semilogx`, `semilogy`, `plot3`, `scatter3`, `imagesc`, `surf`, `mesh`,
5//! `contour`, `contourf`, `subplot`, `hold`, `savefig`, `fill`, `area`,
6//! `polar`, `quiver`, `text`, `axis`, and annotation functions (`xlabel`,
7//! `ylabel`, `zlabel`, `title`, `legend`, `xlim`, `ylim`, `zlim`, `grid`,
8//! `colormap`, `colorbar`).
9//! Rendering requires the `plot` or `plot-svg` feature flags; annotation-only
10//! calls work in every build configuration.
11//!
12//! # Feature flags
13//!
14//! | Flag | Backend | Extra size |
15//! |------|---------|------------|
16//! | `plot` | ASCII via `textplots` | ~100 KB |
17//! | `plot-svg` | SVG + PNG via `plotters` | ~3 MB |
18//! | `plot-all` | Both tiers | combined |
19//!
20//! Build with `--features plot` to enable ASCII rendering.
21
22pub mod colormap;
23pub mod dispatch;
24pub mod proj3d;
25pub mod style;
26
27#[cfg(feature = "plot")]
28mod ascii;
29
30#[cfg(feature = "plot-svg")]
31mod file;
32
33mod contour;
34mod surface;
35
36use std::cell::RefCell;
37
38use ccalc_engine::env::{Env, Value};
39use ccalc_engine::plugin::Plugin;
40
41use colormap::ColormapSpec;
42use dispatch::{
43    extract_file_arg, extract_flat, extract_matrix, extract_style_and_file_arg,
44    extract_style_and_file_arg_min, extract_vector,
45};
46use style::{AxisMode, StyleColor, StyleSpec, Theme};
47
48// ── PendingSeries / Panel ──────────────────────────────────────────────────
49
50/// A renderable data series stored for deferred rendering under `hold`/`subplot`.
51#[derive(Clone)]
52pub enum PendingSeries {
53    /// Connected line plot, with optional style override.
54    Line(Vec<f64>, Vec<f64>, Option<StyleSpec>),
55    /// Point-cloud scatter, with optional style override.
56    Scatter(Vec<f64>, Vec<f64>, Option<StyleSpec>),
57    /// Vertical bar chart, with optional style override.
58    Bar(Vec<f64>, Vec<f64>, Option<StyleSpec>),
59    /// Stem (lollipop) chart, with optional style override.
60    Stem(Vec<f64>, Vec<f64>, Option<StyleSpec>),
61    /// Histogram — pre-computed counts and bin edges, with optional style override.
62    Hist {
63        counts: Vec<usize>,
64        edges: Vec<f64>,
65        style: Option<StyleSpec>,
66    },
67    /// Filled polygon.
68    Fill(Vec<f64>, Vec<f64>, Option<StyleSpec>),
69    /// Area under a curve (polygon closing along y = 0).
70    Area(Vec<f64>, Vec<f64>, Option<StyleSpec>),
71    /// Vector field: origin coordinates `(x, y)` and displacement vectors `(u, v)`, with optional style override.
72    Quiver(Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>, Option<StyleSpec>),
73}
74
75/// A committed subplot panel ready for file rendering.
76#[derive(Clone, Default)]
77pub struct Panel {
78    /// Grid position `(rows, cols, index_1based)` inside a subplot layout.
79    pub layout: Option<(u32, u32, u32)>,
80    /// X-axis label.
81    pub xlabel: Option<String>,
82    /// Y-axis label.
83    pub ylabel: Option<String>,
84    /// Chart title.
85    pub title: Option<String>,
86    /// Series labels for legend.
87    pub legend: Vec<String>,
88    /// X-axis range override.
89    pub xlim: Option<(f64, f64)>,
90    /// Y-axis range override.
91    pub ylim: Option<(f64, f64)>,
92    /// Whether to draw grid lines.
93    pub grid: bool,
94    /// Accumulated data series.
95    pub series: Vec<PendingSeries>,
96    /// Text annotations placed on this panel.
97    pub annotations: Vec<(f64, f64, String)>,
98    /// Session-level font size carried into this panel.
99    pub font_size: Option<u32>,
100    /// Session-level line width carried into this panel.
101    pub line_width: Option<f32>,
102    /// Session-level marker size carried into this panel.
103    pub marker_size: Option<u32>,
104    /// Session-level grid colour override carried into this panel.
105    pub grid_color: Option<StyleColor>,
106    /// Session-level grid line width carried into this panel.
107    pub grid_width: Option<f32>,
108    /// Axis display mode override carried into this panel.
109    pub axis_mode: Option<AxisMode>,
110}
111
112// ── FigureState ────────────────────────────────────────────────────────────
113
114/// Per-figure annotation and accumulation state.
115///
116/// Annotations (`xlabel`, `title`, …) are set via their corresponding
117/// functions and consumed at the next render call (or at `hold('off')` /
118/// `savefig` when in accumulating mode).
119#[derive(Default, Clone)]
120pub struct FigureState {
121    /// X-axis label.
122    pub xlabel: Option<String>,
123    /// Y-axis label.
124    pub ylabel: Option<String>,
125    /// Z-axis label (consumed only by `plot3` / `scatter3`).
126    pub zlabel: Option<String>,
127    /// Chart title.
128    pub title: Option<String>,
129    /// Series labels for legend boxes (file export only).
130    pub legend: Vec<String>,
131    /// Override x-axis range `[min, max]`.
132    pub xlim: Option<(f64, f64)>,
133    /// Override y-axis range `[min, max]`.
134    pub ylim: Option<(f64, f64)>,
135    /// Override z-axis range `[min, max]` (3D only).
136    pub zlim: Option<(f64, f64)>,
137    /// Whether to draw grid lines (file export only; ASCII ignores).
138    pub grid: bool,
139    /// Active colormap for `imagesc` (default [`ColormapSpec::Named`]`("viridis")` when `None`).
140    pub colormap: Option<ColormapSpec>,
141    /// Whether to append a colorbar to the next `imagesc` render.
142    pub colorbar: bool,
143
144    // ── Phase 30d — subplot + hold ────────────────────────────────────────
145    /// Active subplot grid position `(rows, cols, index_1based)`.
146    pub subplot: Option<(u32, u32, u32)>,
147    /// When `true`, plot calls accumulate into [`Self::pending_series`].
148    pub hold: bool,
149    /// Series accumulated for the current in-progress panel.
150    pub pending_series: Vec<PendingSeries>,
151    /// Committed panels waiting for `savefig`.
152    pub panels: Vec<Panel>,
153    /// Text annotations accumulated for the current render (flushed at render time).
154    pub annotations: Vec<(f64, f64, String)>,
155
156    // ── Phase 30.6a — theme + background colour ───────────────────────────
157    /// Active colour theme (`None` means use the light default).
158    pub theme: Option<Theme>,
159    /// Per-figure background colour override (beats the theme background).
160    pub bg_color: Option<StyleColor>,
161
162    // ── Phase 30.6b — font / stroke sizes ─────────────────────────────────
163    /// Title and axis-label font size override in points (minimum 8).
164    pub font_size: Option<u32>,
165    /// Stroke width override for all line series (pixels).
166    pub line_width: Option<f32>,
167    /// Marker radius override for scatter / marker series (pixels).
168    pub marker_size: Option<u32>,
169
170    // ── Phase 30.6c — grid style ───────────────────────────────────────────
171    /// Grid line colour override (applied to both bold and light grid lines).
172    pub grid_color: Option<StyleColor>,
173    /// Grid line stroke width override in pixels.
174    pub grid_width: Option<f32>,
175
176    // ── Phase 30.6d — axis mode ────────────────────────────────────────────
177    /// Axis display mode (`axis('equal')`, `axis('tight')`, `axis('off')`).
178    pub axis_mode: Option<AxisMode>,
179
180    // ── Phase 31 — custom canvas size ─────────────────────────────────────
181    /// Output canvas size in pixels `(width, height)` for file export.
182    ///
183    /// `None` falls back to the default `800×600`. Set via `figure(w, h)`.
184    /// Persists across panels; cleared only when the whole state is reset.
185    pub figure_size: Option<(u32, u32)>,
186}
187
188impl FigureState {
189    /// Returns the canvas size in pixels, falling back to `800×600` if not set.
190    pub fn canvas_size(&self) -> (u32, u32) {
191        self.figure_size.unwrap_or((800, 600))
192    }
193
194    /// Returns the resolved active [`Theme`]: explicit `theme` field > light default.
195    pub fn resolve_theme(&self) -> style::Theme {
196        self.theme.clone().unwrap_or_else(style::Theme::light)
197    }
198
199    /// Returns the effective background colour as an RGB triple.
200    ///
201    /// Resolution order: explicit `bg_color` override > active theme background.
202    pub fn effective_bg_rgb(&self) -> (u8, u8, u8) {
203        let c = self.bg_color.unwrap_or_else(|| self.resolve_theme().bg);
204        (c.0, c.1, c.2)
205    }
206}
207
208// ── Terminal size helpers ───────────────────────────────────────────────────
209
210/// Returns the terminal width in columns, read from `$COLUMNS` (default 80).
211pub(crate) fn term_cols() -> usize {
212    std::env::var("COLUMNS")
213        .ok()
214        .and_then(|s| s.parse().ok())
215        .unwrap_or(80)
216}
217
218/// Returns the terminal height in rows, read from `$LINES` (default 24).
219pub(crate) fn term_rows() -> usize {
220    std::env::var("LINES")
221        .ok()
222        .and_then(|s| s.parse().ok())
223        .unwrap_or(24)
224}
225
226thread_local! {
227    static FIGURE_STATE: RefCell<FigureState> =
228        RefCell::new(FigureState::default());
229}
230
231// ── Exported names ─────────────────────────────────────────────────────────
232
233const EXPORTED: &[&str] = &[
234    "plot",
235    "scatter",
236    "bar",
237    "stem",
238    "hist",
239    "stairs",
240    "loglog",
241    "semilogx",
242    "semilogy",
243    "plot3",
244    "scatter3",
245    "xlabel",
246    "ylabel",
247    "zlabel",
248    "title",
249    "legend",
250    "xlim",
251    "ylim",
252    "zlim",
253    "grid",
254    "colormap",
255    "colorbar",
256    "imagesc",
257    "surf",
258    "mesh",
259    "contour",
260    "contourf",
261    "subplot",
262    "hold",
263    "savefig",
264    "fill",
265    "area",
266    "polar",
267    "quiver",
268    "text",
269    "figure",
270    "theme",
271    "bgcolor",
272    "fontsize",
273    "linewidth",
274    "markersize",
275    "gridcolor",
276    "gridwidth",
277    "axis",
278];
279
280// ── subplot / hold helpers ─────────────────────────────────────────────────
281
282/// Returns `true` when the figure is in accumulating mode (subplot or hold).
283fn is_accumulating(st: &FigureState) -> bool {
284    st.subplot.is_some() || st.hold
285}
286
287/// Commits the current in-progress panel to `st.panels`.
288///
289/// Only commits when there are pending series to avoid creating empty panels.
290/// Clears annotations and `pending_series` after committing.
291fn commit_current_panel(st: &mut FigureState) {
292    if !st.pending_series.is_empty() {
293        let panel = Panel {
294            layout: st.subplot,
295            xlabel: st.xlabel.take(),
296            ylabel: st.ylabel.take(),
297            title: st.title.take(),
298            legend: std::mem::take(&mut st.legend),
299            xlim: st.xlim.take(),
300            ylim: st.ylim.take(),
301            grid: std::mem::replace(&mut st.grid, false),
302            series: std::mem::take(&mut st.pending_series),
303            annotations: std::mem::take(&mut st.annotations),
304            font_size: st.font_size,
305            line_width: st.line_width,
306            marker_size: st.marker_size,
307            grid_color: st.grid_color,
308            grid_width: st.grid_width,
309            axis_mode: st.axis_mode,
310        };
311        st.panels.push(panel);
312    }
313}
314
315// ── PlotPlugin ─────────────────────────────────────────────────────────────
316
317/// Plot plugin — registers all 2D/3D plotting functions.
318pub struct PlotPlugin;
319
320impl Plugin for PlotPlugin {
321    fn name(&self) -> &str {
322        "plot"
323    }
324
325    fn exported_names(&self) -> &[&str] {
326        EXPORTED
327    }
328
329    fn call(&self, name: &str, args: &[Value], _env: &Env) -> Result<Value, String> {
330        match name {
331            // ── String annotation setters ──────────────────────────────
332            "xlabel" | "ylabel" | "title" => {
333                let s = require_string(name, args)?;
334                FIGURE_STATE.with(|f| {
335                    let mut st = f.borrow_mut();
336                    match name {
337                        "xlabel" => st.xlabel = Some(s),
338                        "ylabel" => st.ylabel = Some(s),
339                        "title" => st.title = Some(s),
340                        _ => unreachable!(),
341                    }
342                });
343                Ok(Value::Void)
344            }
345
346            "zlabel" => {
347                let s = require_string(name, args)?;
348                FIGURE_STATE.with(|f| f.borrow_mut().zlabel = Some(s));
349                Ok(Value::Void)
350            }
351
352            // ── Legend ─────────────────────────────────────────────────
353            "legend" => {
354                let labels = require_string_list(args)?;
355                FIGURE_STATE.with(|f| f.borrow_mut().legend = labels);
356                Ok(Value::Void)
357            }
358
359            // ── Grid toggle ────────────────────────────────────────────
360            "grid" => {
361                match args {
362                    [] => FIGURE_STATE.with(|f| {
363                        let mut st = f.borrow_mut();
364                        st.grid = !st.grid;
365                    }),
366                    [Value::Str(s) | Value::StringObj(s)] => {
367                        let enable = match s.as_str() {
368                            "on" => true,
369                            "off" => false,
370                            other => {
371                                return Err(format!("grid: expected 'on' or 'off', got '{other}'"));
372                            }
373                        };
374                        FIGURE_STATE.with(|f| f.borrow_mut().grid = enable);
375                    }
376                    _ => return Err("grid: expected no arguments, 'on', or 'off'".into()),
377                }
378                Ok(Value::Void)
379            }
380
381            // ── Axis limit setters ─────────────────────────────────────
382            "xlim" | "ylim" | "zlim" => {
383                let (lo, hi) = extract_lim(name, args)?;
384                FIGURE_STATE.with(|f| {
385                    let mut st = f.borrow_mut();
386                    match name {
387                        "xlim" => st.xlim = Some((lo, hi)),
388                        "ylim" => st.ylim = Some((lo, hi)),
389                        "zlim" => st.zlim = Some((lo, hi)),
390                        _ => unreachable!(),
391                    }
392                });
393                Ok(Value::Void)
394            }
395
396            // ── Render calls ───────────────────────────────────────────
397            "plot" => {
398                let (data_args, style, path) = extract_style_and_file_arg(args)?;
399                if FIGURE_STATE.with(|f| is_accumulating(&f.borrow())) {
400                    let (x, ys) = extract_xy_multi("plot", &data_args)?;
401                    FIGURE_STATE.with(|f| {
402                        let mut st = f.borrow_mut();
403                        for y in ys {
404                            st.pending_series.push(PendingSeries::Line(
405                                x.clone(),
406                                y,
407                                style.clone(),
408                            ));
409                        }
410                    });
411                    Ok(Value::Void)
412                } else {
413                    let state = FIGURE_STATE.with(|f| f.take());
414                    let (x, ys) = extract_xy_multi("plot", &data_args)?;
415                    if ys.len() == 1 {
416                        render_line_xy("plot", &x, &ys[0], path.as_deref(), state)
417                    } else {
418                        render_multi_series(&x, &ys, path.as_deref(), state)
419                    }
420                }
421            }
422
423            "scatter" | "bar" | "stem" | "stairs" => {
424                let (data_args, style, path) = extract_style_and_file_arg(args)?;
425                if FIGURE_STATE.with(|f| is_accumulating(&f.borrow())) {
426                    let (x, y) = extract_xy(name, &data_args)?;
427                    let (x, y) = if name == "stairs" {
428                        make_step_data(&x, &y)
429                    } else {
430                        (x, y)
431                    };
432                    let series = match name {
433                        "scatter" => PendingSeries::Scatter(x, y, style),
434                        "bar" | "stairs" => PendingSeries::Bar(x, y, style),
435                        "stem" => PendingSeries::Stem(x, y, style),
436                        _ => unreachable!(),
437                    };
438                    FIGURE_STATE.with(|f| f.borrow_mut().pending_series.push(series));
439                    Ok(Value::Void)
440                } else {
441                    let state = FIGURE_STATE.with(|f| f.take());
442                    match name {
443                        "bar" => {
444                            let (x, y) = extract_xy(name, &data_args)?;
445                            render_bar_xy(&x, &y, path.as_deref(), style, state)
446                        }
447                        "stem" => {
448                            let (x, y) = extract_xy(name, &data_args)?;
449                            render_stem_xy(&x, &y, path.as_deref(), style, state)
450                        }
451                        _ => render_ascii_or_file(name, &data_args, path.as_deref(), state),
452                    }
453                }
454            }
455
456            // ── Histogram ──────────────────────────────────────────────
457            "hist" => {
458                let (data_args, style, path) = extract_style_and_file_arg(args)?;
459                if FIGURE_STATE.with(|f| is_accumulating(&f.borrow())) {
460                    let (counts, edges) = parse_and_compute_hist(&data_args)?;
461                    FIGURE_STATE.with(|f| {
462                        f.borrow_mut().pending_series.push(PendingSeries::Hist {
463                            counts,
464                            edges,
465                            style,
466                        });
467                    });
468                    Ok(Value::Void)
469                } else {
470                    let state = FIGURE_STATE.with(|f| f.take());
471                    let (counts, edges) = parse_and_compute_hist(&data_args)?;
472                    match path.as_deref() {
473                        None | Some("ascii") => {
474                            render_hist_ascii(&counts, &edges, &state);
475                            Ok(Value::Void)
476                        }
477                        Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
478                            render_hist_file(&counts, &edges, p, style, state)
479                        }
480                        Some(p) => Err(format!("hist: unknown output target '{p}'")),
481                    }
482                }
483            }
484
485            // ── Log-scale plots ────────────────────────────────────────
486            "loglog" | "semilogx" | "semilogy" => {
487                let (data_args, path) = extract_file_arg(args);
488                let mut state = FIGURE_STATE.with(|f| f.take());
489                let (x_raw, y_raw) = extract_xy(name, &data_args)?;
490
491                let log_x = name == "loglog" || name == "semilogx";
492                let log_y = name == "loglog" || name == "semilogy";
493
494                // Apply log₁₀ and filter non-finite pairs.
495                let (x, y): (Vec<f64>, Vec<f64>) = x_raw
496                    .iter()
497                    .zip(y_raw.iter())
498                    .filter_map(|(&xi, &yi)| {
499                        let lx = if log_x { xi.log10() } else { xi };
500                        let ly = if log_y { yi.log10() } else { yi };
501                        if lx.is_finite() && ly.is_finite() {
502                            Some((lx, ly))
503                        } else {
504                            None
505                        }
506                    })
507                    .unzip();
508
509                if x.is_empty() {
510                    return Err(format!(
511                        "{name}: no finite values after log₁₀ transform \
512                         (check for non-positive values)"
513                    ));
514                }
515
516                // Annotate axis labels with log₁₀ notation.
517                if log_x {
518                    let lbl = state.xlabel.take().unwrap_or_default();
519                    state.xlabel = Some(if lbl.is_empty() {
520                        "log\u{2081}\u{2080}(x)".into()
521                    } else {
522                        format!("{lbl} [log\u{2081}\u{2080}]")
523                    });
524                }
525                if log_y {
526                    let lbl = state.ylabel.take().unwrap_or_default();
527                    state.ylabel = Some(if lbl.is_empty() {
528                        "log\u{2081}\u{2080}(y)".into()
529                    } else {
530                        format!("{lbl} [log\u{2081}\u{2080}]")
531                    });
532                }
533
534                render_line_xy(name, &x, &y, path.as_deref(), state)
535            }
536
537            // ── 3D plots ───────────────────────────────────────────────
538            "plot3" | "scatter3" => {
539                let (data_args, path) = extract_file_arg(args);
540                let state = FIGURE_STATE.with(|f| f.take());
541                render_3d(name, &data_args, path.as_deref(), state)
542            }
543
544            // ── Canvas size ────────────────────────────────────────────
545            "figure" => {
546                if args.len() != 2 {
547                    return Err(format!(
548                        "figure: expected 2 arguments (width, height), got {}",
549                        args.len()
550                    ));
551                }
552                let w = match &args[0] {
553                    Value::Scalar(f) if *f >= 1.0 && *f <= 16384.0 => *f as u32,
554                    _ => return Err("figure: width must be a positive integer (1–16384)".into()),
555                };
556                let h = match &args[1] {
557                    Value::Scalar(f) if *f >= 1.0 && *f <= 16384.0 => *f as u32,
558                    _ => return Err("figure: height must be a positive integer (1–16384)".into()),
559                };
560                FIGURE_STATE.with(|f| f.borrow_mut().figure_size = Some((w, h)));
561                Ok(Value::Void)
562            }
563
564            // ── Colormap / colorbar setters ────────────────────────────
565            "colormap" => {
566                if args.is_empty() {
567                    return Err("colormap: one argument required".into());
568                }
569                let spec = match &args[0] {
570                    Value::Str(name) | Value::StringObj(name) => ColormapSpec::Named(name.clone()),
571                    Value::Matrix(m) => {
572                        if m.ncols() != 3 {
573                            return Err("colormap: matrix argument must be N×3".into());
574                        }
575                        let lut: Vec<(u8, u8, u8)> = (0..m.nrows())
576                            .map(|r| {
577                                let clamp = |v: f64| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
578                                (clamp(m[[r, 0]]), clamp(m[[r, 1]]), clamp(m[[r, 2]]))
579                            })
580                            .collect();
581                        ColormapSpec::Custom(lut)
582                    }
583                    _ => {
584                        return Err("colormap: argument must be a name string or N×3 matrix".into());
585                    }
586                };
587                colormap::validate_colormap_spec(&spec)?;
588                FIGURE_STATE.with(|f| f.borrow_mut().colormap = Some(spec));
589                Ok(Value::Void)
590            }
591
592            "colorbar" => {
593                FIGURE_STATE.with(|f| f.borrow_mut().colorbar = true);
594                Ok(Value::Void)
595            }
596
597            // ── Theme / background colour ──────────────────────────────
598            "theme" => {
599                if args.is_empty() {
600                    return Err("theme: one argument required (e.g. 'dark' or 'light')".into());
601                }
602                let name = match &args[0] {
603                    Value::Str(s) | Value::StringObj(s) => s.clone(),
604                    _ => return Err("theme: argument must be a theme name string".into()),
605                };
606                let t = Theme::from_name(&name)?;
607                FIGURE_STATE.with(|f| f.borrow_mut().theme = Some(t));
608                Ok(Value::Void)
609            }
610
611            "bgcolor" => {
612                if args.is_empty() {
613                    return Err("bgcolor: one argument required".into());
614                }
615                let sc = match &args[0] {
616                    Value::Str(s) | Value::StringObj(s) => style::parse_color_token(s)
617                        .ok_or_else(|| format!("bgcolor: unrecognised color '{s}'"))?,
618                    Value::Matrix(m) if m.nrows() == 1 && m.ncols() == 3 => {
619                        let all_unit = (0..3).all(|c| {
620                            let v = m[[0, c]];
621                            (0.0..=1.0).contains(&v)
622                        });
623                        if !all_unit {
624                            return Err("bgcolor: RGB matrix values must be in [0, 1]".into());
625                        }
626                        let clamp = |v: f64| (v * 255.0).round() as u8;
627                        StyleColor(clamp(m[[0, 0]]), clamp(m[[0, 1]]), clamp(m[[0, 2]]))
628                    }
629                    _ => {
630                        return Err(
631                            "bgcolor: argument must be a color name string or 1×3 RGB matrix"
632                                .into(),
633                        );
634                    }
635                };
636                FIGURE_STATE.with(|f| f.borrow_mut().bg_color = Some(sc));
637                Ok(Value::Void)
638            }
639
640            // ── Font / stroke size setters ─────────────────────────────
641            "fontsize" => {
642                let val = match args {
643                    [Value::Scalar(f)] if *f >= 1.0 => (*f as u32).max(8),
644                    _ => return Err("fontsize: expected a positive number".into()),
645                };
646                FIGURE_STATE.with(|f| f.borrow_mut().font_size = Some(val));
647                Ok(Value::Void)
648            }
649
650            "linewidth" => {
651                let val = match args {
652                    [Value::Scalar(f)] if *f > 0.0 => *f as f32,
653                    _ => return Err("linewidth: expected a positive number".into()),
654                };
655                FIGURE_STATE.with(|f| f.borrow_mut().line_width = Some(val));
656                Ok(Value::Void)
657            }
658
659            "markersize" => {
660                let val = match args {
661                    [Value::Scalar(f)] if *f >= 1.0 => *f as u32,
662                    _ => return Err("markersize: expected a positive integer".into()),
663                };
664                FIGURE_STATE.with(|f| f.borrow_mut().marker_size = Some(val));
665                Ok(Value::Void)
666            }
667
668            // ── Grid colour / width overrides ──────────────────────────
669            "gridcolor" => {
670                if args.is_empty() {
671                    return Err("gridcolor: one argument required".into());
672                }
673                let sc = match &args[0] {
674                    Value::Str(s) | Value::StringObj(s) => style::parse_color_token(s)
675                        .ok_or_else(|| format!("gridcolor: unrecognised color '{s}'"))?,
676                    Value::Matrix(m) if m.nrows() == 1 && m.ncols() == 3 => {
677                        let all_unit = (0..3).all(|c| {
678                            let v = m[[0, c]];
679                            (0.0..=1.0).contains(&v)
680                        });
681                        if !all_unit {
682                            return Err("gridcolor: RGB matrix values must be in [0, 1]".into());
683                        }
684                        let clamp = |v: f64| (v * 255.0).round() as u8;
685                        StyleColor(clamp(m[[0, 0]]), clamp(m[[0, 1]]), clamp(m[[0, 2]]))
686                    }
687                    _ => {
688                        return Err(
689                            "gridcolor: argument must be a color name string or 1×3 RGB matrix"
690                                .into(),
691                        );
692                    }
693                };
694                FIGURE_STATE.with(|f| f.borrow_mut().grid_color = Some(sc));
695                Ok(Value::Void)
696            }
697
698            "gridwidth" => {
699                let val = match args {
700                    [Value::Scalar(f)] if *f > 0.0 => *f as f32,
701                    _ => return Err("gridwidth: expected a positive number".into()),
702                };
703                FIGURE_STATE.with(|f| f.borrow_mut().grid_width = Some(val));
704                Ok(Value::Void)
705            }
706
707            // ── axis mode ──────────────────────────────────────────────
708            "axis" => {
709                let s = require_string("axis", args)?;
710                let mode = match s.as_str() {
711                    "equal" => Some(AxisMode::Equal),
712                    "tight" => Some(AxisMode::Tight),
713                    "off" => Some(AxisMode::Off),
714                    "on" => None,
715                    other => {
716                        return Err(format!(
717                            "axis: expected 'equal', 'tight', 'off', or 'on', got '{other}'"
718                        ));
719                    }
720                };
721                FIGURE_STATE.with(|f| f.borrow_mut().axis_mode = mode);
722                Ok(Value::Void)
723            }
724
725            // ── imagesc ────────────────────────────────────────────────
726            "imagesc" => {
727                if args.is_empty() {
728                    return Err("imagesc: at least one argument required".into());
729                }
730                let (z, nrows, ncols) = extract_matrix(&args[0])?;
731                let state = FIGURE_STATE.with(|f| f.take());
732                // Accepted forms:
733                //   imagesc(Z)          — ASCII or terminal
734                //   imagesc(Z, path)    — file export; canvas from figure(w,h) or 800×600
735                let path: Option<String> = match args.len() {
736                    1 => None,
737                    2 => match &args[1] {
738                        Value::Str(s) | Value::StringObj(s) => Some(s.clone()),
739                        _ => {
740                            return Err(
741                                "imagesc: second argument must be a file path string".into()
742                            );
743                        }
744                    },
745                    n => return Err(format!("imagesc: expected 1 or 2 arguments, got {n}")),
746                };
747                render_imagesc(&z, nrows, ncols, path.as_deref(), state)
748            }
749
750            // ── surf / mesh ────────────────────────────────────────────
751            "surf" | "mesh" => {
752                let (data_args, path) = extract_file_arg(args);
753                if data_args.len() < 3 {
754                    return Err(format!(
755                        "{name}: requires (X, Y, Z) matrix arguments, got {}",
756                        data_args.len()
757                    ));
758                }
759                let (x_data, x_rows, x_cols) = extract_matrix(&data_args[0])
760                    .map_err(|_| format!("{name}: X must be a numeric matrix"))?;
761                let (y_data, y_rows, y_cols) = extract_matrix(&data_args[1])
762                    .map_err(|_| format!("{name}: Y must be a numeric matrix"))?;
763                let (z_data, z_rows, z_cols) = extract_matrix(&data_args[2])
764                    .map_err(|_| format!("{name}: Z must be a numeric matrix"))?;
765                if x_rows != y_rows || x_rows != z_rows || x_cols != y_cols || x_cols != z_cols {
766                    return Err(format!(
767                        "{name}: X ({x_rows}×{x_cols}), Y ({y_rows}×{y_cols}) and \
768                         Z ({z_rows}×{z_cols}) must have the same dimensions"
769                    ));
770                }
771                let state = FIGURE_STATE.with(|f| f.take());
772                // Unique x values = first row of X; unique y values = first column of Y.
773                let x_vals: Vec<f64> = (0..x_cols).map(|c| x_data[c]).collect();
774                let y_vals: Vec<f64> = (0..x_rows).map(|r| y_data[r * x_cols]).collect();
775                render_surface(
776                    name,
777                    &x_vals,
778                    &y_vals,
779                    &z_data,
780                    z_rows,
781                    z_cols,
782                    path.as_deref(),
783                    state,
784                )
785            }
786
787            // ── contour / contourf ─────────────────────────────────────
788            "contour" | "contourf" => {
789                let (data_args, path) = extract_file_arg(args);
790                if data_args.len() < 3 {
791                    return Err(format!(
792                        "{name}: requires (X, Y, Z) matrix arguments, got {}",
793                        data_args.len()
794                    ));
795                }
796                let (x_data, x_rows, x_cols) = extract_matrix(&data_args[0])
797                    .map_err(|_| format!("{name}: X must be a numeric matrix"))?;
798                let (y_data, y_rows, y_cols) = extract_matrix(&data_args[1])
799                    .map_err(|_| format!("{name}: Y must be a numeric matrix"))?;
800                let (z_data, z_rows, z_cols) = extract_matrix(&data_args[2])
801                    .map_err(|_| format!("{name}: Z must be a numeric matrix"))?;
802                if x_rows != y_rows || x_rows != z_rows || x_cols != y_cols || x_cols != z_cols {
803                    return Err(format!(
804                        "{name}: X ({x_rows}×{x_cols}), Y ({y_rows}×{y_cols}) and \
805                         Z ({z_rows}×{z_cols}) must have the same dimensions"
806                    ));
807                }
808                // Optional 4th arg: number of contour levels (default 10).
809                let n_levels: usize = if data_args.len() >= 4 {
810                    match &data_args[3] {
811                        Value::Scalar(v) if *v >= 1.0 => *v as usize,
812                        _ => return Err(format!("{name}: level count must be a positive integer")),
813                    }
814                } else {
815                    10
816                };
817                let state = FIGURE_STATE.with(|f| f.take());
818                // Unique coordinate vectors from meshgrid output.
819                let x_vals: Vec<f64> = (0..x_cols).map(|c| x_data[c]).collect();
820                let y_vals: Vec<f64> = (0..x_rows).map(|r| y_data[r * x_cols]).collect();
821                let filled = name == "contourf";
822                render_contour(
823                    filled,
824                    &x_vals,
825                    &y_vals,
826                    &z_data,
827                    z_rows,
828                    z_cols,
829                    n_levels,
830                    path.as_deref(),
831                    state,
832                )
833            }
834
835            // ── subplot ────────────────────────────────────────────────
836            "subplot" => match args {
837                [Value::Scalar(m), Value::Scalar(n), Value::Scalar(k)] => {
838                    let m = *m as u32;
839                    let n = *n as u32;
840                    let k = *k as u32;
841                    if m == 0 || n == 0 || k == 0 || k > m * n {
842                        return Err(format!(
843                            "subplot: invalid layout ({m},{n},{k}) — \
844                                 index must be in 1..={}",
845                            m * n
846                        ));
847                    }
848                    FIGURE_STATE.with(|f| {
849                        let mut st = f.borrow_mut();
850                        commit_current_panel(&mut st);
851                        st.subplot = Some((m, n, k));
852                    });
853                    Ok(Value::Void)
854                }
855                _ => Err("subplot: expected 3 numeric arguments (rows, cols, index)".into()),
856            },
857
858            // ── hold ───────────────────────────────────────────────────
859            "hold" => {
860                let turn_on = match args {
861                    [] => !FIGURE_STATE.with(|f| f.borrow().hold),
862                    [Value::Str(s) | Value::StringObj(s)] => match s.as_str() {
863                        "on" => true,
864                        "off" => false,
865                        other => {
866                            return Err(format!(
867                                "hold: expected 'on', 'off', or no argument, got '{other}'"
868                            ));
869                        }
870                    },
871                    _ => return Err("hold: expected 'on', 'off', or no argument".into()),
872                };
873
874                if !turn_on {
875                    let panel_opt = FIGURE_STATE.with(|f| {
876                        let mut st = f.borrow_mut();
877                        st.hold = false;
878                        // When not in subplot mode: extract panel for ASCII flush.
879                        if st.subplot.is_none() && !st.pending_series.is_empty() {
880                            Some(Panel {
881                                layout: None,
882                                xlabel: st.xlabel.take(),
883                                ylabel: st.ylabel.take(),
884                                title: st.title.take(),
885                                legend: std::mem::take(&mut st.legend),
886                                xlim: st.xlim.take(),
887                                ylim: st.ylim.take(),
888                                grid: std::mem::replace(&mut st.grid, false),
889                                series: std::mem::take(&mut st.pending_series),
890                                annotations: std::mem::take(&mut st.annotations),
891                                font_size: st.font_size,
892                                line_width: st.line_width,
893                                marker_size: st.marker_size,
894                                grid_color: st.grid_color,
895                                grid_width: st.grid_width,
896                                axis_mode: st.axis_mode,
897                            })
898                        } else {
899                            None
900                        }
901                    });
902                    if let Some(panel) = panel_opt {
903                        return render_panel_ascii(&panel);
904                    }
905                } else {
906                    FIGURE_STATE.with(|f| f.borrow_mut().hold = true);
907                }
908                Ok(Value::Void)
909            }
910
911            // ── savefig ────────────────────────────────────────────────
912            "savefig" => {
913                let path = require_string("savefig", args)?;
914                if !path.ends_with(".svg") && !path.ends_with(".png") {
915                    return Err("savefig: path must end with '.svg' or '.png'".into());
916                }
917                let (panels, canvas, theme, bg_override) = FIGURE_STATE.with(|f| {
918                    let mut st = f.borrow_mut();
919                    commit_current_panel(&mut st);
920                    st.hold = false;
921                    st.subplot = None;
922                    let canvas = st.canvas_size();
923                    let theme = st.theme.clone().unwrap_or_else(style::Theme::light);
924                    let bg_override = st.bg_color;
925                    (std::mem::take(&mut st.panels), canvas, theme, bg_override)
926                });
927                if panels.is_empty() {
928                    return Err("savefig: no panels to render".into());
929                }
930                render_panels_file(&panels, &path, canvas, &theme, bg_override)
931            }
932
933            // ── fill ──────────────────────────────────────────────────
934            "fill" => {
935                let (data_args, style, path) = extract_style_and_file_arg(args)?;
936                let (x, y) = extract_xy("fill", &data_args)?;
937                if FIGURE_STATE.with(|f| is_accumulating(&f.borrow())) {
938                    FIGURE_STATE.with(|f| {
939                        f.borrow_mut()
940                            .pending_series
941                            .push(PendingSeries::Fill(x, y, style));
942                    });
943                    Ok(Value::Void)
944                } else {
945                    let state = FIGURE_STATE.with(|f| f.take());
946                    render_fill_xy(&x, &y, path.as_deref(), style, state)
947                }
948            }
949
950            // ── area ──────────────────────────────────────────────────
951            "area" => {
952                let (data_args, style, path) = extract_style_and_file_arg(args)?;
953                let (x, y) = extract_xy("area", &data_args)?;
954                if FIGURE_STATE.with(|f| is_accumulating(&f.borrow())) {
955                    FIGURE_STATE.with(|f| {
956                        f.borrow_mut()
957                            .pending_series
958                            .push(PendingSeries::Area(x, y, style));
959                    });
960                    Ok(Value::Void)
961                } else {
962                    let state = FIGURE_STATE.with(|f| f.take());
963                    render_area_xy(&x, &y, path.as_deref(), style, state)
964                }
965            }
966
967            // ── polar ─────────────────────────────────────────────────
968            "polar" => {
969                let (data_args, _style, path) = extract_style_and_file_arg(args)?;
970                let (theta, r) = extract_xy("polar", &data_args)?;
971                let (px, py): (Vec<f64>, Vec<f64>) = theta
972                    .iter()
973                    .zip(r.iter())
974                    .map(|(&t, &rv)| (rv * t.cos(), rv * t.sin()))
975                    .unzip();
976                let state = FIGURE_STATE.with(|f| f.take());
977                render_line_xy("polar", &px, &py, path.as_deref(), state)
978            }
979
980            // ── quiver ────────────────────────────────────────────────
981            "quiver" => {
982                let (data_args, style, path) = extract_style_and_file_arg_min(args, 4)?;
983                if data_args.len() != 4 {
984                    return Err(format!(
985                        "quiver: expected 4 data arguments (x, y, u, v), got {}",
986                        data_args.len()
987                    ));
988                }
989                let x = extract_flat(&data_args[0])
990                    .map_err(|_| "quiver: x must be a numeric array".to_string())?;
991                let y = extract_flat(&data_args[1])
992                    .map_err(|_| "quiver: y must be a numeric array".to_string())?;
993                let u = extract_flat(&data_args[2])
994                    .map_err(|_| "quiver: u must be a numeric array".to_string())?;
995                let v = extract_flat(&data_args[3])
996                    .map_err(|_| "quiver: v must be a numeric array".to_string())?;
997                if x.len() != y.len() || x.len() != u.len() || x.len() != v.len() {
998                    return Err(format!(
999                        "quiver: x, y, u, v must have the same length \
1000                         ({}, {}, {}, {})",
1001                        x.len(),
1002                        y.len(),
1003                        u.len(),
1004                        v.len()
1005                    ));
1006                }
1007                if FIGURE_STATE.with(|f| is_accumulating(&f.borrow())) {
1008                    FIGURE_STATE.with(|f| {
1009                        f.borrow_mut()
1010                            .pending_series
1011                            .push(PendingSeries::Quiver(x, y, u, v, style));
1012                    });
1013                    Ok(Value::Void)
1014                } else {
1015                    let state = FIGURE_STATE.with(|f| f.take());
1016                    render_quiver(&x, &y, &u, &v, path.as_deref(), style, state)
1017                }
1018            }
1019
1020            // ── text ──────────────────────────────────────────────────
1021            "text" => {
1022                let (data_args, _path) = extract_file_arg(args);
1023                match data_args.as_slice() {
1024                    [xval, yval, Value::Str(s) | Value::StringObj(s)] => {
1025                        let x = match xval {
1026                            Value::Scalar(f) => *f,
1027                            _ => return Err("text: x must be a scalar".into()),
1028                        };
1029                        let y = match yval {
1030                            Value::Scalar(f) => *f,
1031                            _ => return Err("text: y must be a scalar".into()),
1032                        };
1033                        let label = s.clone();
1034                        FIGURE_STATE.with(|f| {
1035                            f.borrow_mut().annotations.push((x, y, label));
1036                        });
1037                        Ok(Value::Void)
1038                    }
1039                    _ => Err("text: expected text(x, y, 'string')".into()),
1040                }
1041            }
1042
1043            _ => Err(format!("plot plugin: unknown function '{name}'")),
1044        }
1045    }
1046}
1047
1048// ── Dispatch helpers ───────────────────────────────────────────────────────
1049
1050fn render_ascii_or_file(
1051    name: &str,
1052    data_args: &[Value],
1053    path: Option<&str>,
1054    state: FigureState,
1055) -> Result<Value, String> {
1056    match path {
1057        None | Some("ascii") => render_ascii(name, data_args, state),
1058        Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
1059            render_file(name, data_args, p, state)
1060        }
1061        Some(p) => Err(format!("{name}: unknown output target '{p}'")),
1062    }
1063}
1064
1065#[cfg(feature = "plot-svg")]
1066fn render_file(
1067    name: &str,
1068    data_args: &[Value],
1069    path: &str,
1070    state: FigureState,
1071) -> Result<Value, String> {
1072    let (x, y) = extract_xy(name, data_args)?;
1073    let (x, y) = if name == "stairs" {
1074        make_step_data(&x, &y)
1075    } else {
1076        (x, y)
1077    };
1078    let result = match name {
1079        "plot" | "stairs" => file::render_line(&x, &y, path, state),
1080        "scatter" => file::render_scatter(&x, &y, path, state),
1081        _ => unreachable!(),
1082    };
1083    result.map_err(|e| format!("{name}: {e}"))?;
1084    Ok(Value::Void)
1085}
1086
1087#[cfg(not(feature = "plot-svg"))]
1088fn render_file(
1089    name: &str,
1090    _data_args: &[Value],
1091    _path: &str,
1092    _state: FigureState,
1093) -> Result<Value, String> {
1094    Err(format!(
1095        "{name}: SVG/PNG export requires the 'plot-svg' feature — \
1096         rebuild with: cargo build --features plot-svg"
1097    ))
1098}
1099
1100#[cfg(feature = "plot")]
1101fn render_ascii(name: &str, data_args: &[Value], state: FigureState) -> Result<Value, String> {
1102    let (x, y) = extract_xy(name, data_args)?;
1103    let (x, y) = if name == "stairs" {
1104        make_step_data(&x, &y)
1105    } else {
1106        (x, y)
1107    };
1108    match name {
1109        "plot" | "stairs" => ascii::render_line(&x, &y, state),
1110        "scatter" => ascii::render_scatter(&x, &y, state),
1111        "bar" => ascii::render_bar(&x, &y, state),
1112        "stem" => ascii::render_stem(&x, &y, state),
1113        _ => unreachable!(),
1114    }
1115    Ok(Value::Void)
1116}
1117
1118#[cfg(not(feature = "plot"))]
1119fn render_ascii(name: &str, _data_args: &[Value], _state: FigureState) -> Result<Value, String> {
1120    Err(format!(
1121        "{name}: ASCII rendering requires the 'plot' feature flag. \
1122         Rebuild with: cargo build --features plot"
1123    ))
1124}
1125
1126// ── contour / contourf dispatch ────────────────────────────────────────────
1127
1128#[allow(clippy::too_many_arguments)]
1129fn render_contour(
1130    filled: bool,
1131    x_vals: &[f64],
1132    y_vals: &[f64],
1133    z: &[f64],
1134    nrows: usize,
1135    ncols: usize,
1136    n_levels: usize,
1137    path: Option<&str>,
1138    state: FigureState,
1139) -> Result<Value, String> {
1140    match path {
1141        None | Some("ascii") => render_contour_ascii_tier(z, nrows, ncols, n_levels, state),
1142        Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
1143            render_contour_file_tier(filled, x_vals, y_vals, z, nrows, ncols, n_levels, p, state)
1144        }
1145        Some(p) => Err(format!("contour: unknown output target '{p}'")),
1146    }
1147}
1148
1149#[cfg(feature = "plot")]
1150fn render_contour_ascii_tier(
1151    z: &[f64],
1152    nrows: usize,
1153    ncols: usize,
1154    n_levels: usize,
1155    state: FigureState,
1156) -> Result<Value, String> {
1157    let (z_min, z_max) = colormap::data_range(z);
1158    let levels = contour::compute_levels(z_min, z_max, n_levels);
1159    contour::render_contour_ascii(z, nrows, ncols, &levels, &state);
1160    Ok(Value::Void)
1161}
1162
1163#[cfg(not(feature = "plot"))]
1164fn render_contour_ascii_tier(
1165    _z: &[f64],
1166    _nrows: usize,
1167    _ncols: usize,
1168    _n_levels: usize,
1169    _state: FigureState,
1170) -> Result<Value, String> {
1171    Err(
1172        "contour: ASCII rendering requires the 'plot' feature flag — \
1173         rebuild with: cargo build --features plot"
1174            .into(),
1175    )
1176}
1177
1178#[cfg(feature = "plot-svg")]
1179#[allow(clippy::too_many_arguments)]
1180fn render_contour_file_tier(
1181    filled: bool,
1182    x_vals: &[f64],
1183    y_vals: &[f64],
1184    z: &[f64],
1185    nrows: usize,
1186    ncols: usize,
1187    n_levels: usize,
1188    path: &str,
1189    state: FigureState,
1190) -> Result<Value, String> {
1191    let (z_min, z_max) = colormap::data_range(z);
1192    let levels = contour::compute_levels(z_min, z_max, n_levels);
1193    let result = if filled {
1194        contour::render_contourf_file(x_vals, y_vals, z, nrows, ncols, &levels, path, state)
1195    } else {
1196        contour::render_contour_file(x_vals, y_vals, z, nrows, ncols, &levels, path, state)
1197    };
1198    result.map_err(|e| e.to_string())?;
1199    Ok(Value::Void)
1200}
1201
1202#[cfg(not(feature = "plot-svg"))]
1203#[allow(clippy::too_many_arguments)]
1204fn render_contour_file_tier(
1205    _filled: bool,
1206    _x_vals: &[f64],
1207    _y_vals: &[f64],
1208    _z: &[f64],
1209    _nrows: usize,
1210    _ncols: usize,
1211    _n_levels: usize,
1212    _path: &str,
1213    _state: FigureState,
1214) -> Result<Value, String> {
1215    Err("contour: SVG/PNG export requires the 'plot-svg' feature — \
1216         rebuild with: cargo build --features plot-svg"
1217        .into())
1218}
1219
1220// ── surf / mesh dispatch ───────────────────────────────────────────────────
1221
1222#[allow(clippy::too_many_arguments)]
1223fn render_surface(
1224    name: &str,
1225    x_vals: &[f64],
1226    y_vals: &[f64],
1227    z: &[f64],
1228    nrows: usize,
1229    ncols: usize,
1230    path: Option<&str>,
1231    state: FigureState,
1232) -> Result<Value, String> {
1233    let wireframe = name == "mesh";
1234    match path {
1235        None | Some("ascii") => render_surface_ascii_tier(x_vals, z, nrows, ncols, state),
1236        Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
1237            render_surface_file_tier(wireframe, x_vals, y_vals, z, nrows, ncols, p, state)
1238        }
1239        Some(p) => Err(format!("{name}: unknown output target '{p}'")),
1240    }
1241}
1242
1243#[cfg(feature = "plot")]
1244fn render_surface_ascii_tier(
1245    x_vals: &[f64],
1246    z: &[f64],
1247    nrows: usize,
1248    ncols: usize,
1249    state: FigureState,
1250) -> Result<Value, String> {
1251    surface::render_surf_ascii(x_vals, z, nrows, ncols, &state);
1252    Ok(Value::Void)
1253}
1254
1255#[cfg(not(feature = "plot"))]
1256fn render_surface_ascii_tier(
1257    _x_vals: &[f64],
1258    _z: &[f64],
1259    _nrows: usize,
1260    _ncols: usize,
1261    _state: FigureState,
1262) -> Result<Value, String> {
1263    Err(
1264        "surf/mesh: ASCII rendering requires the 'plot' feature flag — \
1265         rebuild with: cargo build --features plot"
1266            .into(),
1267    )
1268}
1269
1270#[cfg(feature = "plot-svg")]
1271#[allow(clippy::too_many_arguments)]
1272fn render_surface_file_tier(
1273    wireframe: bool,
1274    x_vals: &[f64],
1275    y_vals: &[f64],
1276    z: &[f64],
1277    nrows: usize,
1278    ncols: usize,
1279    path: &str,
1280    state: FigureState,
1281) -> Result<Value, String> {
1282    let result = if wireframe {
1283        surface::render_mesh_file(x_vals, y_vals, z, nrows, ncols, path, state)
1284    } else {
1285        surface::render_surf_file(x_vals, y_vals, z, nrows, ncols, path, state)
1286    };
1287    result.map_err(|e| e.to_string())?;
1288    Ok(Value::Void)
1289}
1290
1291#[cfg(not(feature = "plot-svg"))]
1292#[allow(clippy::too_many_arguments)]
1293fn render_surface_file_tier(
1294    _wireframe: bool,
1295    _x_vals: &[f64],
1296    _y_vals: &[f64],
1297    _z: &[f64],
1298    _nrows: usize,
1299    _ncols: usize,
1300    _path: &str,
1301    _state: FigureState,
1302) -> Result<Value, String> {
1303    Err(
1304        "surf/mesh: SVG/PNG export requires the 'plot-svg' feature — \
1305         rebuild with: cargo build --features plot-svg"
1306            .into(),
1307    )
1308}
1309
1310// ── imagesc dispatch ───────────────────────────────────────────────────────
1311
1312fn render_imagesc(
1313    z: &[f64],
1314    nrows: usize,
1315    ncols: usize,
1316    path: Option<&str>,
1317    state: FigureState,
1318) -> Result<Value, String> {
1319    match path {
1320        None | Some("ascii") => render_imagesc_ascii_tier(z, nrows, ncols, state),
1321        Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
1322            render_imagesc_file_tier(z, nrows, ncols, p, state)
1323        }
1324        Some(p) => Err(format!("imagesc: unknown output target '{p}'")),
1325    }
1326}
1327
1328#[cfg(feature = "plot")]
1329fn render_imagesc_ascii_tier(
1330    z: &[f64],
1331    nrows: usize,
1332    ncols: usize,
1333    state: FigureState,
1334) -> Result<Value, String> {
1335    colormap::render_imagesc_ascii(z, nrows, ncols, &state);
1336    Ok(Value::Void)
1337}
1338
1339#[cfg(not(feature = "plot"))]
1340fn render_imagesc_ascii_tier(
1341    _z: &[f64],
1342    _nrows: usize,
1343    _ncols: usize,
1344    _state: FigureState,
1345) -> Result<Value, String> {
1346    Err(
1347        "imagesc: ASCII rendering requires the 'plot' feature flag — \
1348         rebuild with: cargo build --features plot"
1349            .into(),
1350    )
1351}
1352
1353#[cfg(feature = "plot-svg")]
1354fn render_imagesc_file_tier(
1355    z: &[f64],
1356    nrows: usize,
1357    ncols: usize,
1358    path: &str,
1359    state: FigureState,
1360) -> Result<Value, String> {
1361    colormap::render_imagesc_file(z, nrows, ncols, path, state)
1362        .map_err(|e| format!("imagesc: {e}"))?;
1363    Ok(Value::Void)
1364}
1365
1366#[cfg(not(feature = "plot-svg"))]
1367fn render_imagesc_file_tier(
1368    _z: &[f64],
1369    _nrows: usize,
1370    _ncols: usize,
1371    _path: &str,
1372    _state: FigureState,
1373) -> Result<Value, String> {
1374    Err("imagesc: SVG/PNG export requires the 'plot-svg' feature — \
1375         rebuild with: cargo build --features plot-svg"
1376        .into())
1377}
1378
1379// ── Argument helpers ───────────────────────────────────────────────────────
1380
1381fn require_string(name: &str, args: &[Value]) -> Result<String, String> {
1382    match args {
1383        [Value::Str(s)] | [Value::StringObj(s)] => Ok(s.clone()),
1384        [_] => Err(format!("{name}: argument must be a string")),
1385        _ => Err(format!("{name}: expected exactly one string argument")),
1386    }
1387}
1388
1389fn require_string_list(args: &[Value]) -> Result<Vec<String>, String> {
1390    if args.is_empty() {
1391        return Err("legend: at least one string argument required".into());
1392    }
1393    args.iter()
1394        .map(|a| match a {
1395            Value::Str(s) | Value::StringObj(s) => Ok(s.clone()),
1396            _ => Err("legend: all arguments must be strings".into()),
1397        })
1398        .collect()
1399}
1400
1401fn extract_lim(name: &str, args: &[Value]) -> Result<(f64, f64), String> {
1402    let v = match args {
1403        [val] => extract_vector(val)
1404            .map_err(|_| format!("{name}: expected a 2-element vector [lo hi]"))?,
1405        _ => return Err(format!("{name}: expected exactly one argument [lo hi]")),
1406    };
1407    if v.len() != 2 {
1408        return Err(format!(
1409            "{name}: vector must have exactly 2 elements, got {}",
1410            v.len()
1411        ));
1412    }
1413    Ok((v[0], v[1]))
1414}
1415
1416// ── Stairs helpers ─────────────────────────────────────────────────────────
1417
1418/// Converts (x, y) data into step/staircase pairs for rendering.
1419fn make_step_data(x: &[f64], y: &[f64]) -> (Vec<f64>, Vec<f64>) {
1420    let n = x.len();
1421    if n == 0 {
1422        return (vec![], vec![]);
1423    }
1424    let mut sx = Vec::with_capacity(2 * n - 1);
1425    let mut sy = Vec::with_capacity(2 * n - 1);
1426    for i in 0..n - 1 {
1427        sx.push(x[i]);
1428        sy.push(y[i]);
1429        // Horizontal segment at y[i] until the next x position.
1430        sx.push(x[i + 1]);
1431        sy.push(y[i]);
1432    }
1433    sx.push(*x.last().unwrap());
1434    sy.push(*y.last().unwrap());
1435    (sx, sy)
1436}
1437
1438// ── Histogram helpers ──────────────────────────────────────────────────────
1439
1440/// Sturges rule: bins ≈ √n, minimum 1.
1441fn sturges_bins(n: usize) -> usize {
1442    (n as f64).sqrt().round() as usize
1443}
1444
1445/// Parses `hist` arguments: `(data_vec, n_bins)`.
1446/// Parses hist arguments and returns `(counts, edges)` ready for rendering.
1447///
1448/// Accepts `[v]` (Sturges default), `[v, n]` (explicit bin count), or
1449/// `[v, edges]` (explicit bin-edge vector).
1450fn parse_and_compute_hist(args: &[Value]) -> Result<(Vec<usize>, Vec<f64>), String> {
1451    match args.len() {
1452        0 => Err("hist: at least one argument required".into()),
1453        1 => {
1454            let vals = extract_vector(&args[0])
1455                .map_err(|_| "hist: first argument must be a numeric vector".to_string())?;
1456            let n = sturges_bins(vals.len()).max(1);
1457            Ok(compute_histogram_uniform(&vals, n))
1458        }
1459        2 => {
1460            let vals = extract_vector(&args[0])
1461                .map_err(|_| "hist: first argument must be a numeric vector".to_string())?;
1462            match &args[1] {
1463                Value::Scalar(v) => {
1464                    let n = *v as usize;
1465                    if n == 0 {
1466                        return Err("hist: bin count must be positive".into());
1467                    }
1468                    Ok(compute_histogram_uniform(&vals, n))
1469                }
1470                Value::Matrix(_) | Value::ComplexMatrix(_) => {
1471                    let edges = extract_vector(&args[1])
1472                        .map_err(|_| "hist: edge vector must be numeric".to_string())?;
1473                    if edges.len() < 2 {
1474                        return Err("hist: edge vector must have at least 2 elements".into());
1475                    }
1476                    Ok(compute_histogram_edges(&vals, &edges))
1477                }
1478                _ => Err("hist: second argument must be a bin count or an edge vector".into()),
1479            }
1480        }
1481        _ => Err("hist: too many arguments".into()),
1482    }
1483}
1484
1485/// Computes histogram counts with `n_bins` uniform bins spanning the data range.
1486fn compute_histogram_uniform(vals: &[f64], n_bins: usize) -> (Vec<usize>, Vec<f64>) {
1487    if vals.is_empty() {
1488        return (vec![0; n_bins], (0..=n_bins).map(|i| i as f64).collect());
1489    }
1490    let min_v = vals.iter().copied().fold(f64::INFINITY, f64::min);
1491    let max_v = vals.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1492    let range = if max_v > min_v { max_v - min_v } else { 1.0 };
1493    let mut counts = vec![0usize; n_bins];
1494    for &v in vals {
1495        let b = ((v - min_v) / range * n_bins as f64) as usize;
1496        counts[b.min(n_bins - 1)] += 1;
1497    }
1498    let edges: Vec<f64> = (0..=n_bins)
1499        .map(|i| min_v + range * (i as f64 / n_bins as f64))
1500        .collect();
1501    (counts, edges)
1502}
1503
1504/// Computes histogram counts using caller-supplied bin edges.
1505///
1506/// Values below `edges[0]` or above `edges[last]` are ignored.
1507fn compute_histogram_edges(vals: &[f64], edges: &[f64]) -> (Vec<usize>, Vec<f64>) {
1508    let n_bins = edges.len() - 1;
1509    let mut counts = vec![0usize; n_bins];
1510    for &v in vals {
1511        // Binary search for the bin: edges[b] <= v < edges[b+1]
1512        match edges.binary_search_by(|e| e.partial_cmp(&v).unwrap_or(std::cmp::Ordering::Less)) {
1513            Ok(b) => counts[b.min(n_bins - 1)] += 1,
1514            Err(b) if b > 0 && b <= n_bins => counts[b - 1] += 1,
1515            _ => {}
1516        }
1517    }
1518    (counts, edges.to_vec())
1519}
1520
1521/// Prints a character-art histogram to stdout (no feature flag required).
1522fn render_hist_ascii(counts: &[usize], edges: &[f64], state: &FigureState) {
1523    let n_bins = counts.len();
1524    let bar_cols: usize = term_cols().saturating_sub(26).max(10);
1525    let max_count = counts.iter().copied().max().unwrap_or(1).max(1);
1526    if let Some(t) = &state.title {
1527        println!("{t}");
1528    }
1529    for i in 0..n_bins {
1530        let lo = edges[i];
1531        let hi = edges[i + 1];
1532        let bar_len = counts[i] * bar_cols / max_count;
1533        println!(
1534            "{lo:8.4} {hi:8.4} |{bar:<width$}| {c}",
1535            bar = "#".repeat(bar_len),
1536            width = bar_cols,
1537            c = counts[i],
1538        );
1539    }
1540    if let Some(xl) = &state.xlabel {
1541        println!("x: {xl}");
1542    }
1543    if let Some(yl) = &state.ylabel {
1544        println!("y: {yl}");
1545    }
1546}
1547
1548#[cfg(feature = "plot-svg")]
1549fn render_hist_file(
1550    counts: &[usize],
1551    edges: &[f64],
1552    path: &str,
1553    style: Option<StyleSpec>,
1554    state: FigureState,
1555) -> Result<Value, String> {
1556    file::render_hist(counts, edges, path, style, state).map_err(|e| format!("hist: {e}"))?;
1557    Ok(Value::Void)
1558}
1559
1560#[cfg(not(feature = "plot-svg"))]
1561fn render_hist_file(
1562    _counts: &[usize],
1563    _edges: &[f64],
1564    _path: &str,
1565    _style: Option<StyleSpec>,
1566    _state: FigureState,
1567) -> Result<Value, String> {
1568    Err("hist: SVG/PNG export requires the 'plot-svg' feature — \
1569         rebuild with: cargo build --features plot-svg"
1570        .into())
1571}
1572
1573// ── Multi-series dispatch ──────────────────────────────────────────────────
1574
1575fn render_multi_series(
1576    x: &[f64],
1577    ys: &[Vec<f64>],
1578    path: Option<&str>,
1579    state: FigureState,
1580) -> Result<Value, String> {
1581    match path {
1582        None | Some("ascii") => render_multi_series_ascii(x, ys, &state),
1583        Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
1584            render_multi_series_file(x, ys, p, state)
1585        }
1586        Some(p) => Err(format!("plot: unknown output target '{p}'")),
1587    }
1588}
1589
1590#[cfg(feature = "plot")]
1591fn render_multi_series_ascii(
1592    x: &[f64],
1593    ys: &[Vec<f64>],
1594    _state: &FigureState,
1595) -> Result<Value, String> {
1596    // Render first series only; note remaining series.
1597    ascii::render_line(x, &ys[0], FigureState::default());
1598    println!("% {} series total — use file export for all", ys.len());
1599    Ok(Value::Void)
1600}
1601
1602#[cfg(not(feature = "plot"))]
1603fn render_multi_series_ascii(
1604    _x: &[f64],
1605    _ys: &[Vec<f64>],
1606    _state: &FigureState,
1607) -> Result<Value, String> {
1608    Err("plot: ASCII rendering requires the 'plot' feature flag — \
1609         rebuild with: cargo build --features plot"
1610        .into())
1611}
1612
1613#[cfg(feature = "plot-svg")]
1614fn render_multi_series_file(
1615    x: &[f64],
1616    ys: &[Vec<f64>],
1617    path: &str,
1618    state: FigureState,
1619) -> Result<Value, String> {
1620    file::render_multi_line(x, ys, path, state).map_err(|e| format!("plot: {e}"))?;
1621    Ok(Value::Void)
1622}
1623
1624#[cfg(not(feature = "plot-svg"))]
1625fn render_multi_series_file(
1626    _x: &[f64],
1627    _ys: &[Vec<f64>],
1628    _path: &str,
1629    _state: FigureState,
1630) -> Result<Value, String> {
1631    Err("plot: SVG/PNG export requires the 'plot-svg' feature — \
1632         rebuild with: cargo build --features plot-svg"
1633        .into())
1634}
1635
1636// ── Pre-transformed line dispatch (loglog / semilogx / semilogy) ───────────
1637
1638/// Dispatch a pre-processed (x, y) pair to ASCII or file, rendering a line.
1639fn render_line_xy(
1640    name: &str,
1641    x: &[f64],
1642    y: &[f64],
1643    path: Option<&str>,
1644    state: FigureState,
1645) -> Result<Value, String> {
1646    match path {
1647        None | Some("ascii") => render_line_xy_ascii(name, x, y, state),
1648        Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
1649            render_line_xy_file(name, x, y, p, state)
1650        }
1651        Some(p) => Err(format!("{name}: unknown output target '{p}'")),
1652    }
1653}
1654
1655#[cfg(feature = "plot")]
1656fn render_line_xy_ascii(
1657    _name: &str,
1658    x: &[f64],
1659    y: &[f64],
1660    state: FigureState,
1661) -> Result<Value, String> {
1662    ascii::render_line(x, y, state);
1663    Ok(Value::Void)
1664}
1665
1666#[cfg(not(feature = "plot"))]
1667fn render_line_xy_ascii(
1668    name: &str,
1669    _x: &[f64],
1670    _y: &[f64],
1671    _state: FigureState,
1672) -> Result<Value, String> {
1673    Err(format!(
1674        "{name}: ASCII rendering requires the 'plot' feature flag — \
1675         rebuild with: cargo build --features plot"
1676    ))
1677}
1678
1679#[cfg(feature = "plot-svg")]
1680fn render_line_xy_file(
1681    name: &str,
1682    x: &[f64],
1683    y: &[f64],
1684    path: &str,
1685    state: FigureState,
1686) -> Result<Value, String> {
1687    file::render_line(x, y, path, state).map_err(|e| format!("{name}: {e}"))?;
1688    Ok(Value::Void)
1689}
1690
1691#[cfg(not(feature = "plot-svg"))]
1692fn render_line_xy_file(
1693    name: &str,
1694    _x: &[f64],
1695    _y: &[f64],
1696    _path: &str,
1697    _state: FigureState,
1698) -> Result<Value, String> {
1699    Err(format!(
1700        "{name}: SVG/PNG export requires the 'plot-svg' feature — \
1701         rebuild with: cargo build --features plot-svg"
1702    ))
1703}
1704
1705// ── fill / area dispatch ───────────────────────────────────────────────────
1706
1707fn render_fill_xy(
1708    x: &[f64],
1709    y: &[f64],
1710    path: Option<&str>,
1711    style: Option<StyleSpec>,
1712    state: FigureState,
1713) -> Result<Value, String> {
1714    match path {
1715        None | Some("ascii") => render_fill_ascii(x, y, state),
1716        Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
1717            render_fill_file(x, y, p, style, state)
1718        }
1719        Some(p) => Err(format!("fill: unknown output target '{p}'")),
1720    }
1721}
1722
1723fn render_area_xy(
1724    x: &[f64],
1725    y: &[f64],
1726    path: Option<&str>,
1727    style: Option<StyleSpec>,
1728    state: FigureState,
1729) -> Result<Value, String> {
1730    match path {
1731        None | Some("ascii") => render_area_ascii(x, y, state),
1732        Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
1733            render_area_file(x, y, p, style, state)
1734        }
1735        Some(p) => Err(format!("area: unknown output target '{p}'")),
1736    }
1737}
1738
1739#[cfg(feature = "plot")]
1740fn render_fill_ascii(x: &[f64], y: &[f64], state: FigureState) -> Result<Value, String> {
1741    ascii::render_fill(x, y, state);
1742    Ok(Value::Void)
1743}
1744
1745#[cfg(not(feature = "plot"))]
1746fn render_fill_ascii(_x: &[f64], _y: &[f64], _state: FigureState) -> Result<Value, String> {
1747    Err("fill: ASCII rendering requires the 'plot' feature flag — \
1748         rebuild with: cargo build --features plot"
1749        .into())
1750}
1751
1752#[cfg(feature = "plot")]
1753fn render_area_ascii(x: &[f64], y: &[f64], state: FigureState) -> Result<Value, String> {
1754    ascii::render_area(x, y, state);
1755    Ok(Value::Void)
1756}
1757
1758#[cfg(not(feature = "plot"))]
1759fn render_area_ascii(_x: &[f64], _y: &[f64], _state: FigureState) -> Result<Value, String> {
1760    Err("area: ASCII rendering requires the 'plot' feature flag — \
1761         rebuild with: cargo build --features plot"
1762        .into())
1763}
1764
1765#[cfg(feature = "plot-svg")]
1766fn render_fill_file(
1767    x: &[f64],
1768    y: &[f64],
1769    path: &str,
1770    style: Option<StyleSpec>,
1771    state: FigureState,
1772) -> Result<Value, String> {
1773    file::render_fill(x, y, path, style, state).map_err(|e| format!("fill: {e}"))?;
1774    Ok(Value::Void)
1775}
1776
1777#[cfg(not(feature = "plot-svg"))]
1778fn render_fill_file(
1779    _x: &[f64],
1780    _y: &[f64],
1781    _path: &str,
1782    _style: Option<StyleSpec>,
1783    _state: FigureState,
1784) -> Result<Value, String> {
1785    Err("fill: SVG/PNG export requires the 'plot-svg' feature — \
1786         rebuild with: cargo build --features plot-svg"
1787        .into())
1788}
1789
1790#[cfg(feature = "plot-svg")]
1791fn render_area_file(
1792    x: &[f64],
1793    y: &[f64],
1794    path: &str,
1795    style: Option<StyleSpec>,
1796    state: FigureState,
1797) -> Result<Value, String> {
1798    file::render_area(x, y, path, style, state).map_err(|e| format!("area: {e}"))?;
1799    Ok(Value::Void)
1800}
1801
1802#[cfg(not(feature = "plot-svg"))]
1803fn render_area_file(
1804    _x: &[f64],
1805    _y: &[f64],
1806    _path: &str,
1807    _style: Option<StyleSpec>,
1808    _state: FigureState,
1809) -> Result<Value, String> {
1810    Err("area: SVG/PNG export requires the 'plot-svg' feature — \
1811         rebuild with: cargo build --features plot-svg"
1812        .into())
1813}
1814
1815// ── bar / stem dispatch ────────────────────────────────────────────────────
1816
1817fn render_bar_xy(
1818    x: &[f64],
1819    y: &[f64],
1820    path: Option<&str>,
1821    style: Option<StyleSpec>,
1822    state: FigureState,
1823) -> Result<Value, String> {
1824    match path {
1825        None | Some("ascii") => render_bar_ascii(x, y, state),
1826        Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
1827            render_bar_file(x, y, p, style, state)
1828        }
1829        Some(p) => Err(format!("bar: unknown output target '{p}'")),
1830    }
1831}
1832
1833fn render_stem_xy(
1834    x: &[f64],
1835    y: &[f64],
1836    path: Option<&str>,
1837    style: Option<StyleSpec>,
1838    state: FigureState,
1839) -> Result<Value, String> {
1840    match path {
1841        None | Some("ascii") => render_stem_ascii(x, y, state),
1842        Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
1843            render_stem_file(x, y, p, style, state)
1844        }
1845        Some(p) => Err(format!("stem: unknown output target '{p}'")),
1846    }
1847}
1848
1849#[cfg(feature = "plot")]
1850fn render_bar_ascii(x: &[f64], y: &[f64], state: FigureState) -> Result<Value, String> {
1851    ascii::render_bar(x, y, state);
1852    Ok(Value::Void)
1853}
1854
1855#[cfg(not(feature = "plot"))]
1856fn render_bar_ascii(_x: &[f64], _y: &[f64], _state: FigureState) -> Result<Value, String> {
1857    Err("bar: ASCII rendering requires the 'plot' feature flag — \
1858         rebuild with: cargo build --features plot"
1859        .into())
1860}
1861
1862#[cfg(feature = "plot")]
1863fn render_stem_ascii(x: &[f64], y: &[f64], state: FigureState) -> Result<Value, String> {
1864    ascii::render_stem(x, y, state);
1865    Ok(Value::Void)
1866}
1867
1868#[cfg(not(feature = "plot"))]
1869fn render_stem_ascii(_x: &[f64], _y: &[f64], _state: FigureState) -> Result<Value, String> {
1870    Err("stem: ASCII rendering requires the 'plot' feature flag — \
1871         rebuild with: cargo build --features plot"
1872        .into())
1873}
1874
1875#[cfg(feature = "plot-svg")]
1876fn render_bar_file(
1877    x: &[f64],
1878    y: &[f64],
1879    path: &str,
1880    style: Option<StyleSpec>,
1881    state: FigureState,
1882) -> Result<Value, String> {
1883    file::render_bar(x, y, path, style, state).map_err(|e| format!("bar: {e}"))?;
1884    Ok(Value::Void)
1885}
1886
1887#[cfg(not(feature = "plot-svg"))]
1888fn render_bar_file(
1889    _x: &[f64],
1890    _y: &[f64],
1891    _path: &str,
1892    _style: Option<StyleSpec>,
1893    _state: FigureState,
1894) -> Result<Value, String> {
1895    Err("bar: SVG/PNG export requires the 'plot-svg' feature — \
1896         rebuild with: cargo build --features plot-svg"
1897        .into())
1898}
1899
1900#[cfg(feature = "plot-svg")]
1901fn render_stem_file(
1902    x: &[f64],
1903    y: &[f64],
1904    path: &str,
1905    style: Option<StyleSpec>,
1906    state: FigureState,
1907) -> Result<Value, String> {
1908    file::render_stem(x, y, path, style, state).map_err(|e| format!("stem: {e}"))?;
1909    Ok(Value::Void)
1910}
1911
1912#[cfg(not(feature = "plot-svg"))]
1913fn render_stem_file(
1914    _x: &[f64],
1915    _y: &[f64],
1916    _path: &str,
1917    _style: Option<StyleSpec>,
1918    _state: FigureState,
1919) -> Result<Value, String> {
1920    Err("stem: SVG/PNG export requires the 'plot-svg' feature — \
1921         rebuild with: cargo build --features plot-svg"
1922        .into())
1923}
1924
1925// ── quiver dispatch ────────────────────────────────────────────────────────
1926
1927fn render_quiver(
1928    x: &[f64],
1929    y: &[f64],
1930    u: &[f64],
1931    v: &[f64],
1932    path: Option<&str>,
1933    style: Option<StyleSpec>,
1934    state: FigureState,
1935) -> Result<Value, String> {
1936    match path {
1937        None | Some("ascii") => render_quiver_ascii_tier(x, y, u, v, state),
1938        Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
1939            render_quiver_file_tier(x, y, u, v, p, style, state)
1940        }
1941        Some(p) => Err(format!("quiver: unknown output target '{p}'")),
1942    }
1943}
1944
1945fn render_quiver_ascii_tier(
1946    x: &[f64],
1947    y: &[f64],
1948    u: &[f64],
1949    v: &[f64],
1950    state: FigureState,
1951) -> Result<Value, String> {
1952    render_quiver_ascii(x, y, u, v, &state);
1953    Ok(Value::Void)
1954}
1955
1956#[cfg(feature = "plot-svg")]
1957fn render_quiver_file_tier(
1958    x: &[f64],
1959    y: &[f64],
1960    u: &[f64],
1961    v: &[f64],
1962    path: &str,
1963    style: Option<StyleSpec>,
1964    state: FigureState,
1965) -> Result<Value, String> {
1966    file::render_quiver(x, y, u, v, path, style, state).map_err(|e| format!("quiver: {e}"))?;
1967    Ok(Value::Void)
1968}
1969
1970#[cfg(not(feature = "plot-svg"))]
1971fn render_quiver_file_tier(
1972    _x: &[f64],
1973    _y: &[f64],
1974    _u: &[f64],
1975    _v: &[f64],
1976    _path: &str,
1977    _style: Option<StyleSpec>,
1978    _state: FigureState,
1979) -> Result<Value, String> {
1980    Err("quiver: SVG/PNG export requires the 'plot-svg' feature — \
1981         rebuild with: cargo build --features plot-svg"
1982        .into())
1983}
1984
1985/// ASCII quiver: Unicode directional arrows placed on a character grid.
1986fn render_quiver_ascii(xs: &[f64], ys: &[f64], us: &[f64], vs: &[f64], state: &FigureState) {
1987    let n = xs.len();
1988    if n == 0 {
1989        return;
1990    }
1991    let w = term_cols().saturating_sub(4).max(20);
1992    let h = (term_rows() / 2).max(10);
1993
1994    let x_min = state
1995        .xlim
1996        .map(|(lo, _)| lo)
1997        .unwrap_or_else(|| xs.iter().copied().fold(f64::INFINITY, f64::min));
1998    let x_max = state
1999        .xlim
2000        .map(|(_, hi)| hi)
2001        .unwrap_or_else(|| xs.iter().copied().fold(f64::NEG_INFINITY, f64::max));
2002    let y_min = state
2003        .ylim
2004        .map(|(lo, _)| lo)
2005        .unwrap_or_else(|| ys.iter().copied().fold(f64::INFINITY, f64::min));
2006    let y_max = state
2007        .ylim
2008        .map(|(_, hi)| hi)
2009        .unwrap_or_else(|| ys.iter().copied().fold(f64::NEG_INFINITY, f64::max));
2010
2011    let x_span = if (x_max - x_min).abs() < f64::EPSILON {
2012        2.0
2013    } else {
2014        x_max - x_min
2015    };
2016    let y_span = if (y_max - y_min).abs() < f64::EPSILON {
2017        2.0
2018    } else {
2019        y_max - y_min
2020    };
2021
2022    let mut grid: Vec<Vec<char>> = vec![vec![' '; w]; h];
2023
2024    for i in 0..n {
2025        let col = ((xs[i] - x_min) / x_span * (w - 1) as f64).round() as isize;
2026        let row = ((y_max - ys[i]) / y_span * (h - 1) as f64).round() as isize;
2027        if col >= 0 && (col as usize) < w && row >= 0 && (row as usize) < h {
2028            let angle = vs[i].atan2(us[i]);
2029            grid[row as usize][col as usize] = arrow_char(angle);
2030        }
2031    }
2032
2033    if let Some(t) = &state.title {
2034        println!("{t}");
2035    }
2036    for row in &grid {
2037        println!("|{}|", row.iter().collect::<String>());
2038    }
2039    if let Some(xl) = &state.xlabel {
2040        println!("x: {xl}");
2041    }
2042    if let Some(yl) = &state.ylabel {
2043        println!("y: {yl}");
2044    }
2045}
2046
2047/// Maps an angle in radians to one of 8 Unicode directional arrow characters.
2048fn arrow_char(angle: f64) -> char {
2049    use std::f64::consts::PI;
2050    let a = (angle + 2.0 * PI).rem_euclid(2.0 * PI);
2051    let octant = ((a + PI / 8.0) / (PI / 4.0)) as usize % 8;
2052    match octant {
2053        0 => '\u{2192}', // →
2054        1 => '\u{2197}', // ↗
2055        2 => '\u{2191}', // ↑
2056        3 => '\u{2196}', // ↖
2057        4 => '\u{2190}', // ←
2058        5 => '\u{2199}', // ↙
2059        6 => '\u{2193}', // ↓
2060        _ => '\u{2198}', // ↘
2061    }
2062}
2063
2064// ── 3D dispatch ────────────────────────────────────────────────────────────
2065
2066fn render_3d(
2067    name: &str,
2068    data_args: &[Value],
2069    path: Option<&str>,
2070    state: FigureState,
2071) -> Result<Value, String> {
2072    extract_xyz(name, data_args)?;
2073    match path {
2074        None | Some("ascii") => render_3d_ascii(name, data_args, state),
2075        Some(p) if p.ends_with(".svg") || p.ends_with(".png") => {
2076            render_3d_file(name, data_args, p, state)
2077        }
2078        Some(p) => Err(format!("{name}: unknown output target '{p}'")),
2079    }
2080}
2081
2082#[cfg(feature = "plot")]
2083fn render_3d_ascii(name: &str, data_args: &[Value], state: FigureState) -> Result<Value, String> {
2084    let (x, y, z) = extract_xyz(name, data_args)?;
2085    let (px, py) = proj3d::project_ortho(&x, &y, &z);
2086    // Pass only title and axis limits to the 2D ASCII renderer.
2087    // Labels are printed below as a footer to avoid misleading axis descriptions.
2088    let state_2d = FigureState {
2089        title: state.title.clone(),
2090        xlim: state.xlim,
2091        ylim: state.ylim,
2092        ..FigureState::default()
2093    };
2094    match name {
2095        "plot3" => ascii::render_line(&px, &py, state_2d),
2096        "scatter3" => ascii::render_scatter(&px, &py, state_2d),
2097        _ => unreachable!(),
2098    }
2099    if let Some(xl) = &state.xlabel {
2100        println!("x: {xl}");
2101    }
2102    if let Some(yl) = &state.ylabel {
2103        println!("y: {yl}");
2104    }
2105    if let Some(zl) = &state.zlabel {
2106        println!("z: {zl}");
2107    }
2108    Ok(Value::Void)
2109}
2110
2111#[cfg(not(feature = "plot"))]
2112fn render_3d_ascii(name: &str, _data_args: &[Value], _state: FigureState) -> Result<Value, String> {
2113    Err(format!(
2114        "{name}: ASCII rendering requires the 'plot' feature flag — \
2115         rebuild with: cargo build --features plot"
2116    ))
2117}
2118
2119#[cfg(feature = "plot-svg")]
2120fn render_3d_file(
2121    name: &str,
2122    data_args: &[Value],
2123    path: &str,
2124    state: FigureState,
2125) -> Result<Value, String> {
2126    let (x, y, z) = extract_xyz(name, data_args)?;
2127    let result = match name {
2128        "plot3" => file::render_plot3(&x, &y, &z, path, state),
2129        "scatter3" => file::render_scatter3(&x, &y, &z, path, state),
2130        _ => unreachable!(),
2131    };
2132    result.map_err(|e| format!("{name}: {e}"))?;
2133    Ok(Value::Void)
2134}
2135
2136#[cfg(not(feature = "plot-svg"))]
2137fn render_3d_file(
2138    name: &str,
2139    _data_args: &[Value],
2140    _path: &str,
2141    _state: FigureState,
2142) -> Result<Value, String> {
2143    Err(format!(
2144        "{name}: SVG/PNG export requires the 'plot-svg' feature — \
2145         rebuild with: cargo build --features plot-svg"
2146    ))
2147}
2148
2149// ── subplot / hold / savefig render ───────────────────────────────────────
2150
2151/// Renders all series in a panel to ASCII stdout (used by `hold('off')`).
2152///
2153/// Each series is printed sequentially; a `---` divider separates them.
2154#[cfg(feature = "plot")]
2155fn render_panel_ascii(panel: &Panel) -> Result<Value, String> {
2156    if panel.series.is_empty() {
2157        return Ok(Value::Void);
2158    }
2159    let base_state = FigureState {
2160        xlabel: panel.xlabel.clone(),
2161        ylabel: panel.ylabel.clone(),
2162        title: panel.title.clone(),
2163        xlim: panel.xlim,
2164        ylim: panel.ylim,
2165        ..FigureState::default()
2166    };
2167    for (i, series) in panel.series.iter().enumerate() {
2168        if i > 0 {
2169            println!("---");
2170        }
2171        match series {
2172            PendingSeries::Line(x, y, _style) => ascii::render_line(x, y, base_state.clone()),
2173            PendingSeries::Scatter(x, y, _style) => {
2174                ascii::render_scatter(x, y, base_state.clone());
2175            }
2176            PendingSeries::Bar(x, y, _style) => ascii::render_bar(x, y, base_state.clone()),
2177            PendingSeries::Stem(x, y, _style) => ascii::render_stem(x, y, base_state.clone()),
2178            PendingSeries::Hist {
2179                counts,
2180                edges,
2181                style: _,
2182            } => {
2183                render_hist_ascii(counts, edges, &base_state);
2184            }
2185            PendingSeries::Fill(x, y, _style) => ascii::render_fill(x, y, base_state.clone()),
2186            PendingSeries::Area(x, y, _style) => ascii::render_area(x, y, base_state.clone()),
2187            PendingSeries::Quiver(x, y, u, v, _style) => {
2188                render_quiver_ascii(x, y, u, v, &base_state);
2189            }
2190        }
2191    }
2192    for (ax, ay, label) in &panel.annotations {
2193        println!("  ({ax:.4}, {ay:.4}): {label}");
2194    }
2195    Ok(Value::Void)
2196}
2197
2198#[cfg(not(feature = "plot"))]
2199fn render_panel_ascii(_panel: &Panel) -> Result<Value, String> {
2200    Err("hold: ASCII rendering requires the 'plot' feature flag — \
2201         rebuild with: cargo build --features plot"
2202        .into())
2203}
2204
2205#[cfg(feature = "plot-svg")]
2206fn render_panels_file(
2207    panels: &[Panel],
2208    path: &str,
2209    canvas: (u32, u32),
2210    theme: &style::Theme,
2211    bg_override: Option<style::StyleColor>,
2212) -> Result<Value, String> {
2213    use plotters::style::RGBColor;
2214    let bg = bg_override
2215        .map(|c| RGBColor(c.0, c.1, c.2))
2216        .unwrap_or_else(|| {
2217            let c = theme.bg;
2218            RGBColor(c.0, c.1, c.2)
2219        });
2220    file::render_subplot_panels(panels, path, canvas, theme, bg)
2221        .map_err(|e| format!("savefig: {e}"))?;
2222    Ok(Value::Void)
2223}
2224
2225#[cfg(not(feature = "plot-svg"))]
2226fn render_panels_file(
2227    _panels: &[Panel],
2228    _path: &str,
2229    _canvas: (u32, u32),
2230    _theme: &style::Theme,
2231    _bg_override: Option<style::StyleColor>,
2232) -> Result<Value, String> {
2233    Err("savefig: SVG/PNG export requires the 'plot-svg' feature — \
2234         rebuild with: cargo build --features plot-svg"
2235        .into())
2236}
2237
2238// ── Argument helpers (continued) ───────────────────────────────────────────
2239
2240/// Extracts three equal-length numeric vectors from `plot3`/`scatter3` args.
2241#[cfg_attr(not(any(feature = "plot", feature = "plot-svg")), allow(dead_code))]
2242#[allow(clippy::type_complexity)]
2243fn extract_xyz(name: &str, args: &[Value]) -> Result<(Vec<f64>, Vec<f64>, Vec<f64>), String> {
2244    match args {
2245        [xv, yv, zv] => {
2246            let x = extract_vector(xv).map_err(|e| format!("{name}: {e}"))?;
2247            let y = extract_vector(yv).map_err(|e| format!("{name}: {e}"))?;
2248            let z = extract_vector(zv).map_err(|e| format!("{name}: {e}"))?;
2249            if x.len() != y.len() || x.len() != z.len() {
2250                return Err(format!(
2251                    "{name}: x, y, z must have the same length \
2252                     (got {}, {}, {})",
2253                    x.len(),
2254                    y.len(),
2255                    z.len()
2256                ));
2257            }
2258            Ok((x, y, z))
2259        }
2260        _ => Err(format!(
2261            "{name}: expected 3 arguments (x, y, z), got {}",
2262            args.len()
2263        )),
2264    }
2265}
2266
2267#[cfg_attr(not(any(feature = "plot", feature = "plot-svg")), allow(dead_code))]
2268fn extract_xy(name: &str, args: &[Value]) -> Result<(Vec<f64>, Vec<f64>), String> {
2269    match args.len() {
2270        0 => Err(format!("{name}: at least one argument required")),
2271        1 => {
2272            let y = extract_vector(&args[0])?;
2273            let x: Vec<f64> = (1..=y.len()).map(|i| i as f64).collect();
2274            Ok((x, y))
2275        }
2276        2 => {
2277            let x = extract_vector(&args[0])?;
2278            let y = extract_vector(&args[1])?;
2279            if x.len() != y.len() {
2280                return Err(format!(
2281                    "{name}: x and y must have the same length ({} vs {})",
2282                    x.len(),
2283                    y.len()
2284                ));
2285            }
2286            Ok((x, y))
2287        }
2288        _ => Err(format!("{name}: too many arguments")),
2289    }
2290}
2291
2292/// Extracts x and one or more y series from plot arguments.
2293///
2294/// When y is an M×N matrix with M > 1, returns M separate row-series.
2295/// Otherwise behaves identically to `extract_xy`.
2296#[cfg_attr(not(any(feature = "plot", feature = "plot-svg")), allow(dead_code))]
2297fn extract_xy_multi(name: &str, args: &[Value]) -> Result<(Vec<f64>, Vec<Vec<f64>>), String> {
2298    match args.len() {
2299        0 => Err(format!("{name}: at least one argument required")),
2300        1 => {
2301            let y = extract_vector(&args[0])?;
2302            let x: Vec<f64> = (1..=y.len()).map(|i| i as f64).collect();
2303            Ok((x, vec![y]))
2304        }
2305        2 => {
2306            let x = extract_vector(&args[0])?;
2307            match &args[1] {
2308                Value::Matrix(m) if m.nrows() > 1 => {
2309                    // Each row is one series.
2310                    let n_cols = m.ncols();
2311                    if n_cols != x.len() {
2312                        return Err(format!(
2313                            "{name}: x has {} elements but Y has {} columns",
2314                            x.len(),
2315                            n_cols
2316                        ));
2317                    }
2318                    let ys = (0..m.nrows())
2319                        .map(|r| m.row(r).iter().copied().collect())
2320                        .collect();
2321                    Ok((x, ys))
2322                }
2323                other => {
2324                    let y = extract_vector(other)?;
2325                    if x.len() != y.len() {
2326                        return Err(format!(
2327                            "{name}: x and y must have the same length ({} vs {})",
2328                            x.len(),
2329                            y.len()
2330                        ));
2331                    }
2332                    Ok((x, vec![y]))
2333                }
2334            }
2335        }
2336        _ => Err(format!("{name}: too many arguments")),
2337    }
2338}
2339
2340// ── Tests ──────────────────────────────────────────────────────────────────
2341
2342#[cfg(test)]
2343mod tests {
2344    use ccalc_engine::env::{Env, Value};
2345    use ndarray::Array2;
2346
2347    use super::*;
2348
2349    // ── term_cols / term_rows ─────────────────────────────────────────
2350
2351    #[test]
2352    fn test_term_cols_default() {
2353        // Without $COLUMNS set, must return the 80-column fallback.
2354        unsafe { std::env::remove_var("COLUMNS") };
2355        assert_eq!(term_cols(), 80);
2356    }
2357
2358    #[test]
2359    fn test_term_rows_default() {
2360        unsafe { std::env::remove_var("LINES") };
2361        assert_eq!(term_rows(), 24);
2362    }
2363
2364    #[test]
2365    fn test_term_cols_env_override() {
2366        unsafe { std::env::set_var("COLUMNS", "132") };
2367        let cols = term_cols();
2368        unsafe { std::env::remove_var("COLUMNS") };
2369        assert_eq!(cols, 132);
2370    }
2371
2372    fn f64_vec(vals: &[f64]) -> Value {
2373        Value::Matrix(Array2::from_shape_vec((1, vals.len()), vals.to_vec()).unwrap())
2374    }
2375
2376    // ── extract_xy ────────────────────────────────────────────────────
2377
2378    #[test]
2379    fn test_extract_xy_infer_x() {
2380        let y = f64_vec(&[1.0, 4.0, 9.0]);
2381        let (x, yv) = extract_xy("plot", &[y]).unwrap();
2382        assert_eq!(x, vec![1.0, 2.0, 3.0]);
2383        assert_eq!(yv, vec![1.0, 4.0, 9.0]);
2384    }
2385
2386    #[test]
2387    fn test_extract_xy_explicit() {
2388        let x = f64_vec(&[10.0, 20.0]);
2389        let y = f64_vec(&[1.0, 2.0]);
2390        let (xv, yv) = extract_xy("plot", &[x, y]).unwrap();
2391        assert_eq!(xv, vec![10.0, 20.0]);
2392        assert_eq!(yv, vec![1.0, 2.0]);
2393    }
2394
2395    #[test]
2396    fn test_extract_xy_mismatch() {
2397        let x = f64_vec(&[1.0, 2.0]);
2398        let y = f64_vec(&[1.0, 2.0, 3.0]);
2399        assert!(extract_xy("plot", &[x, y]).is_err());
2400    }
2401
2402    #[test]
2403    fn test_extract_xy_scalar_promoted() {
2404        let y = Value::Scalar(5.0);
2405        let (x, yv) = extract_xy("plot", &[y]).unwrap();
2406        assert_eq!(x, vec![1.0]);
2407        assert_eq!(yv, vec![5.0]);
2408    }
2409
2410    // ── Annotation setters ────────────────────────────────────────────
2411
2412    #[test]
2413    fn test_xlabel_sets_state() {
2414        let plugin = PlotPlugin;
2415        let env = Env::new();
2416        plugin
2417            .call("xlabel", &[Value::Str("time".into())], &env)
2418            .unwrap();
2419        let label = FIGURE_STATE.with(|f| f.borrow().xlabel.clone());
2420        assert_eq!(label, Some("time".into()));
2421        // Clean up so other tests start fresh.
2422        FIGURE_STATE.with(|f| f.take());
2423    }
2424
2425    #[test]
2426    fn test_title_sets_state() {
2427        let plugin = PlotPlugin;
2428        let env = Env::new();
2429        plugin
2430            .call("title", &[Value::Str("My Chart".into())], &env)
2431            .unwrap();
2432        let title = FIGURE_STATE.with(|f| f.borrow().title.clone());
2433        assert_eq!(title, Some("My Chart".into()));
2434        FIGURE_STATE.with(|f| f.take());
2435    }
2436
2437    #[test]
2438    fn test_annotation_requires_string() {
2439        let plugin = PlotPlugin;
2440        let env = Env::new();
2441        let result = plugin.call("xlabel", &[Value::Scalar(1.0)], &env);
2442        assert!(result.is_err());
2443    }
2444
2445    // ── Render dispatch ───────────────────────────────────────────────
2446
2447    #[test]
2448    fn test_plot_no_feature_returns_error_without_feature() {
2449        // When compiled WITHOUT --features plot, calling plot should give a
2450        // helpful error rather than silently doing nothing.
2451        #[cfg(not(feature = "plot"))]
2452        {
2453            let plugin = PlotPlugin;
2454            let env = Env::new();
2455            let y = f64_vec(&[1.0, 2.0, 3.0]);
2456            let result = plugin.call("plot", &[y], &env);
2457            assert!(result.is_err());
2458            let msg = result.unwrap_err();
2459            assert!(msg.contains("plot"), "error should mention 'plot'");
2460        }
2461        // With the feature enabled this path is dead code — that's fine.
2462        #[cfg(feature = "plot")]
2463        let _ = ();
2464    }
2465
2466    #[test]
2467    fn test_hist_single_value_no_error() {
2468        let plugin = PlotPlugin;
2469        let env = Env::new();
2470        let result = plugin.call("hist", &[Value::Scalar(1.0)], &env);
2471        assert!(result.is_ok());
2472    }
2473
2474    #[test]
2475    fn test_hist_vector_returns_void() {
2476        let plugin = PlotPlugin;
2477        let env = Env::new();
2478        let v = f64_vec(&[1.0, 2.0, 3.0, 4.0, 5.0]);
2479        let result = plugin.call("hist", &[v], &env).unwrap();
2480        assert_eq!(result, Value::Void);
2481    }
2482
2483    #[test]
2484    fn test_hist_custom_bins_returns_void() {
2485        let plugin = PlotPlugin;
2486        let env = Env::new();
2487        let v = f64_vec(&[1.0, 2.0, 3.0, 4.0, 5.0]);
2488        let result = plugin.call("hist", &[v, Value::Scalar(3.0)], &env).unwrap();
2489        assert_eq!(result, Value::Void);
2490    }
2491
2492    #[test]
2493    fn test_hist_zero_bins_errors() {
2494        let plugin = PlotPlugin;
2495        let env = Env::new();
2496        let v = f64_vec(&[1.0, 2.0, 3.0]);
2497        let result = plugin.call("hist", &[v, Value::Scalar(0.0)], &env);
2498        assert!(result.is_err());
2499    }
2500
2501    // ── Multi-series extract_xy_multi ─────────────────────────────────────
2502
2503    #[test]
2504    fn test_extract_xy_multi_single_series() {
2505        let x = f64_vec(&[1.0, 2.0, 3.0]);
2506        let y = f64_vec(&[1.0, 4.0, 9.0]);
2507        let (xv, ys) = extract_xy_multi("plot", &[x, y]).unwrap();
2508        assert_eq!(xv, vec![1.0, 2.0, 3.0]);
2509        assert_eq!(ys.len(), 1);
2510        assert_eq!(ys[0], vec![1.0, 4.0, 9.0]);
2511    }
2512
2513    #[test]
2514    fn test_extract_xy_multi_matrix_y() {
2515        let x = f64_vec(&[1.0, 2.0, 3.0]);
2516        // 2×3 matrix → 2 series of 3 points each
2517        let y = Value::Matrix(
2518            Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap(),
2519        );
2520        let (xv, ys) = extract_xy_multi("plot", &[x, y]).unwrap();
2521        assert_eq!(xv, vec![1.0, 2.0, 3.0]);
2522        assert_eq!(ys.len(), 2);
2523        assert_eq!(ys[0], vec![1.0, 2.0, 3.0]);
2524        assert_eq!(ys[1], vec![4.0, 5.0, 6.0]);
2525    }
2526
2527    #[test]
2528    fn test_extract_xy_multi_column_count_mismatch() {
2529        let x = f64_vec(&[1.0, 2.0]);
2530        let y = Value::Matrix(
2531            Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap(),
2532        );
2533        let result = extract_xy_multi("plot", &[x, y]);
2534        assert!(result.is_err());
2535    }
2536
2537    // ── Log-scale plots ───────────────────────────────────────────────────
2538
2539    #[test]
2540    fn test_loglog_non_positive_all_filtered_errors() {
2541        let plugin = PlotPlugin;
2542        let env = Env::new();
2543        let x = f64_vec(&[-1.0, 0.0, -2.0]);
2544        let y = f64_vec(&[1.0, 2.0, 3.0]);
2545        let result = plugin.call("loglog", &[x, y], &env);
2546        assert!(result.is_err());
2547        let msg = result.unwrap_err();
2548        assert!(msg.contains("finite"), "error should mention finite: {msg}");
2549    }
2550
2551    #[test]
2552    fn test_semilogx_valid_data() {
2553        let plugin = PlotPlugin;
2554        let env = Env::new();
2555        // Without plot feature → feature error; with plot feature → ok.
2556        let x = f64_vec(&[1.0, 10.0, 100.0]);
2557        let y = f64_vec(&[1.0, 2.0, 3.0]);
2558        let result = plugin.call("semilogx", &[x, y], &env);
2559        // Should not say "not yet implemented" regardless of features.
2560        if let Err(msg) = &result {
2561            assert!(
2562                !msg.contains("not yet implemented"),
2563                "should not say 'not yet implemented': {msg}"
2564            );
2565        }
2566    }
2567
2568    #[test]
2569    fn test_semilogy_label_annotation() {
2570        // After calling semilogy, ylabel should be cleared (consumed by render).
2571        // This test verifies that the state is consumed and ylabel is annotated
2572        // before rendering (requires plot feature to actually render).
2573        FIGURE_STATE.with(|f| f.take());
2574    }
2575
2576    #[test]
2577    fn test_stairs_stub_is_gone() {
2578        // stairs should succeed (not stub-error) when called with valid data
2579        let plugin = PlotPlugin;
2580        let env = Env::new();
2581        // Without plot feature this should error about missing feature (not "not implemented").
2582        // With plot feature this should succeed.
2583        #[cfg(feature = "plot")]
2584        {
2585            let y = f64_vec(&[1.0, 4.0, 9.0, 16.0]);
2586            let result = plugin.call("stairs", &[y], &env);
2587            assert!(result.is_ok(), "stairs should succeed: {result:?}");
2588        }
2589        #[cfg(not(feature = "plot"))]
2590        {
2591            let y = f64_vec(&[1.0, 4.0, 9.0]);
2592            let result = plugin.call("stairs", &[y], &env);
2593            // Should error about missing feature, not "not implemented".
2594            let msg = result.unwrap_err();
2595            assert!(
2596                !msg.contains("not yet implemented"),
2597                "should not say 'not yet implemented': {msg}"
2598            );
2599        }
2600    }
2601
2602    // ── 29c.1 annotation setters ──────────────────────────────────────
2603
2604    #[test]
2605    fn test_xlim_sets_state() {
2606        FIGURE_STATE.with(|f| f.take());
2607        let plugin = PlotPlugin;
2608        let env = Env::new();
2609        let lim = Value::Matrix(Array2::from_shape_vec((1, 2), vec![0.0, 10.0]).unwrap());
2610        plugin.call("xlim", &[lim], &env).unwrap();
2611        let xlim = FIGURE_STATE.with(|f| f.borrow().xlim);
2612        assert_eq!(xlim, Some((0.0, 10.0)));
2613        FIGURE_STATE.with(|f| f.take());
2614    }
2615
2616    #[test]
2617    fn test_ylim_sets_state() {
2618        FIGURE_STATE.with(|f| f.take());
2619        let plugin = PlotPlugin;
2620        let env = Env::new();
2621        let lim = Value::Matrix(Array2::from_shape_vec((1, 2), vec![-1.0, 1.0]).unwrap());
2622        plugin.call("ylim", &[lim], &env).unwrap();
2623        let ylim = FIGURE_STATE.with(|f| f.borrow().ylim);
2624        assert_eq!(ylim, Some((-1.0, 1.0)));
2625        FIGURE_STATE.with(|f| f.take());
2626    }
2627
2628    #[test]
2629    fn test_legend_sets_state() {
2630        FIGURE_STATE.with(|f| f.take());
2631        let plugin = PlotPlugin;
2632        let env = Env::new();
2633        plugin
2634            .call(
2635                "legend",
2636                &[Value::Str("a".into()), Value::Str("b".into())],
2637                &env,
2638            )
2639            .unwrap();
2640        let legend = FIGURE_STATE.with(|f| f.borrow().legend.clone());
2641        assert_eq!(legend, vec!["a".to_string(), "b".to_string()]);
2642        FIGURE_STATE.with(|f| f.take());
2643    }
2644
2645    #[test]
2646    fn test_legend_requires_strings() {
2647        let plugin = PlotPlugin;
2648        let env = Env::new();
2649        let result = plugin.call("legend", &[Value::Scalar(1.0)], &env);
2650        assert!(result.is_err());
2651    }
2652
2653    #[test]
2654    fn test_legend_requires_at_least_one_arg() {
2655        let plugin = PlotPlugin;
2656        let env = Env::new();
2657        let result = plugin.call("legend", &[], &env);
2658        assert!(result.is_err());
2659    }
2660
2661    #[test]
2662    fn test_grid_toggles_state() {
2663        FIGURE_STATE.with(|f| f.take());
2664        let plugin = PlotPlugin;
2665        let env = Env::new();
2666        // Initially false.
2667        assert!(!FIGURE_STATE.with(|f| f.borrow().grid));
2668        plugin.call("grid", &[], &env).unwrap();
2669        assert!(FIGURE_STATE.with(|f| f.borrow().grid));
2670        plugin.call("grid", &[], &env).unwrap();
2671        assert!(!FIGURE_STATE.with(|f| f.borrow().grid));
2672        FIGURE_STATE.with(|f| f.take());
2673    }
2674
2675    #[test]
2676    fn test_grid_on_off_string_args() {
2677        FIGURE_STATE.with(|f| f.take());
2678        let plugin = PlotPlugin;
2679        let env = Env::new();
2680        plugin
2681            .call("grid", &[Value::Str("on".into())], &env)
2682            .unwrap();
2683        assert!(FIGURE_STATE.with(|f| f.borrow().grid));
2684        plugin
2685            .call("grid", &[Value::Str("off".into())], &env)
2686            .unwrap();
2687        assert!(!FIGURE_STATE.with(|f| f.borrow().grid));
2688        // Invalid string arg should still error.
2689        let result = plugin.call("grid", &[Value::Str("maybe".into())], &env);
2690        assert!(result.is_err());
2691        FIGURE_STATE.with(|f| f.take());
2692    }
2693
2694    #[test]
2695    fn test_zlabel_sets_state() {
2696        FIGURE_STATE.with(|f| f.take());
2697        let plugin = PlotPlugin;
2698        let env = Env::new();
2699        plugin
2700            .call("zlabel", &[Value::Str("depth".into())], &env)
2701            .unwrap();
2702        let zlabel = FIGURE_STATE.with(|f| f.borrow().zlabel.clone());
2703        assert_eq!(zlabel, Some("depth".into()));
2704        FIGURE_STATE.with(|f| f.take());
2705    }
2706
2707    #[test]
2708    fn test_xlim_wrong_length() {
2709        let plugin = PlotPlugin;
2710        let env = Env::new();
2711        let v = Value::Matrix(Array2::from_shape_vec((1, 3), vec![1.0, 2.0, 3.0]).unwrap());
2712        let result = plugin.call("xlim", &[v], &env);
2713        assert!(result.is_err());
2714    }
2715
2716    #[test]
2717    #[cfg(not(feature = "plot-svg"))]
2718    fn test_svg_without_feature() {
2719        let plugin = PlotPlugin;
2720        let env = Env::new();
2721        let y = f64_vec(&[1.0, 2.0, 3.0]);
2722        let path = Value::Str("out.svg".into());
2723        let result = plugin.call("plot", &[y, path], &env);
2724        assert!(result.is_err());
2725    }
2726
2727    // ── ASCII rendering (requires --features plot) ────────────────────
2728
2729    #[test]
2730    #[cfg(feature = "plot")]
2731    fn test_plot_ascii_no_error() {
2732        let plugin = PlotPlugin;
2733        let env = Env::new();
2734        let y = f64_vec(&[1.0, 4.0, 9.0, 16.0, 25.0]);
2735        assert!(plugin.call("plot", &[y], &env).is_ok());
2736    }
2737
2738    #[test]
2739    #[cfg(feature = "plot")]
2740    fn test_scatter_ascii_no_error() {
2741        let plugin = PlotPlugin;
2742        let env = Env::new();
2743        let x = f64_vec(&[1.0, 2.0, 3.0, 4.0]);
2744        let y = f64_vec(&[1.0, 4.0, 9.0, 16.0]);
2745        assert!(plugin.call("scatter", &[x, y], &env).is_ok());
2746    }
2747
2748    #[test]
2749    #[cfg(feature = "plot")]
2750    fn test_figure_state_cleared_after_render() {
2751        let plugin = PlotPlugin;
2752        let env = Env::new();
2753        plugin
2754            .call("title", &[Value::Str("Temp".into())], &env)
2755            .unwrap();
2756        let y = f64_vec(&[1.0, 2.0, 3.0]);
2757        plugin.call("plot", &[y], &env).unwrap();
2758        // State should be cleared after render.
2759        let title = FIGURE_STATE.with(|f| f.borrow().title.clone());
2760        assert!(
2761            title.is_none(),
2762            "FigureState should be cleared after plot()"
2763        );
2764    }
2765
2766    // ── 29d: plot3 / scatter3 ─────────────────────────────────────────
2767
2768    #[test]
2769    fn test_plot3_length_mismatch_error() {
2770        let plugin = PlotPlugin;
2771        let env = Env::new();
2772        let x = f64_vec(&[1.0, 2.0, 3.0]);
2773        let y = f64_vec(&[1.0, 2.0]);
2774        let z = f64_vec(&[0.0, 0.0, 0.0]);
2775        let result = plugin.call("plot3", &[x, y, z], &env);
2776        assert!(result.is_err());
2777        let msg = result.unwrap_err();
2778        assert!(
2779            msg.contains("same length"),
2780            "error should mention length: {msg}"
2781        );
2782    }
2783
2784    #[test]
2785    fn test_scatter3_wrong_arg_count_error() {
2786        let plugin = PlotPlugin;
2787        let env = Env::new();
2788        let x = f64_vec(&[1.0, 2.0]);
2789        let y = f64_vec(&[1.0, 2.0]);
2790        // Only two args — missing z.
2791        let result = plugin.call("scatter3", &[x, y], &env);
2792        assert!(result.is_err());
2793        let msg = result.unwrap_err();
2794        assert!(
2795            msg.contains("3 arguments"),
2796            "error should mention 3 args: {msg}"
2797        );
2798    }
2799
2800    #[test]
2801    #[cfg(feature = "plot")]
2802    fn test_plot3_ascii_no_error() {
2803        let plugin = PlotPlugin;
2804        let env = Env::new();
2805        let x = f64_vec(&[0.0, 1.0, 2.0, 3.0]);
2806        let y = f64_vec(&[0.0, 1.0, 0.0, -1.0]);
2807        let z = f64_vec(&[0.0, 0.5, 1.0, 0.5]);
2808        let result = plugin.call("plot3", &[x, y, z], &env);
2809        assert!(result.is_ok(), "plot3 ASCII should succeed: {result:?}");
2810    }
2811
2812    #[test]
2813    #[cfg(feature = "plot")]
2814    fn test_scatter3_ascii_no_error() {
2815        let plugin = PlotPlugin;
2816        let env = Env::new();
2817        let x = f64_vec(&[0.0, 1.0, 2.0]);
2818        let y = f64_vec(&[0.0, 1.0, 0.0]);
2819        let z = f64_vec(&[1.0, 2.0, 3.0]);
2820        let result = plugin.call("scatter3", &[x, y, z], &env);
2821        assert!(result.is_ok(), "scatter3 ASCII should succeed: {result:?}");
2822    }
2823
2824    #[test]
2825    #[cfg(feature = "plot")]
2826    fn test_plot3_state_cleared_after_render() {
2827        FIGURE_STATE.with(|f| f.take());
2828        let plugin = PlotPlugin;
2829        let env = Env::new();
2830        plugin
2831            .call("zlabel", &[Value::Str("depth".into())], &env)
2832            .unwrap();
2833        let x = f64_vec(&[0.0, 1.0, 2.0]);
2834        let y = f64_vec(&[0.0, 1.0, 2.0]);
2835        let z = f64_vec(&[0.0, 1.0, 2.0]);
2836        plugin.call("plot3", &[x, y, z], &env).unwrap();
2837        let zlabel = FIGURE_STATE.with(|f| f.borrow().zlabel.clone());
2838        assert!(
2839            zlabel.is_none(),
2840            "FigureState.zlabel should be cleared after plot3()"
2841        );
2842    }
2843
2844    #[test]
2845    #[cfg(not(feature = "plot-svg"))]
2846    fn test_plot3_svg_without_feature() {
2847        let plugin = PlotPlugin;
2848        let env = Env::new();
2849        let x = f64_vec(&[0.0, 1.0]);
2850        let y = f64_vec(&[0.0, 1.0]);
2851        let z = f64_vec(&[0.0, 1.0]);
2852        let path = Value::Str("out.svg".into());
2853        let result = plugin.call("plot3", &[x, y, z, path], &env);
2854        assert!(result.is_err());
2855        let msg = result.unwrap_err();
2856        assert!(
2857            msg.contains("plot-svg"),
2858            "error should mention plot-svg feature: {msg}"
2859        );
2860    }
2861
2862    // ── 30a: colormap / colorbar / imagesc ────────────────────────────
2863
2864    #[test]
2865    fn test_colormap_sets_state() {
2866        FIGURE_STATE.with(|f| f.take());
2867        let plugin = PlotPlugin;
2868        let env = Env::new();
2869        plugin
2870            .call("colormap", &[Value::Str("hot".into())], &env)
2871            .unwrap();
2872        let cmap = FIGURE_STATE.with(|f| f.borrow().colormap.clone());
2873        assert_eq!(cmap, Some(colormap::ColormapSpec::Named("hot".to_string())));
2874        FIGURE_STATE.with(|f| f.take());
2875    }
2876
2877    #[test]
2878    fn test_colorbar_sets_state() {
2879        FIGURE_STATE.with(|f| f.take());
2880        let plugin = PlotPlugin;
2881        let env = Env::new();
2882        plugin.call("colorbar", &[], &env).unwrap();
2883        let cb = FIGURE_STATE.with(|f| f.borrow().colorbar);
2884        assert!(cb, "colorbar should set FigureState.colorbar = true");
2885        FIGURE_STATE.with(|f| f.take());
2886    }
2887
2888    // ── 30.5b: extended style strings ─────────────────────────────────────
2889
2890    #[test]
2891    fn test_style_rgb_matrix_dispatch() {
2892        FIGURE_STATE.with(|f| f.take());
2893        let plugin = PlotPlugin;
2894        let env = Env::new();
2895        plugin
2896            .call("hold", &[Value::Str("on".into())], &env)
2897            .unwrap();
2898        let x = f64_vec(&[1.0, 2.0]);
2899        let y = f64_vec(&[1.0, 2.0]);
2900        let m = Value::Matrix(Array2::from_shape_vec((1, 3), vec![1.0, 0.0, 0.0]).unwrap());
2901        plugin.call("plot", &[x, y, m], &env).unwrap();
2902        let series = FIGURE_STATE.with(|f| f.borrow().pending_series.clone());
2903        assert_eq!(series.len(), 1, "should have one pending series");
2904        if let PendingSeries::Line(_, _, style) = &series[0] {
2905            assert_eq!(
2906                style.as_ref().and_then(|s| s.color),
2907                Some(style::StyleColor(255, 0, 0))
2908            );
2909        } else {
2910            panic!("expected PendingSeries::Line");
2911        }
2912        FIGURE_STATE.with(|f| f.take());
2913    }
2914
2915    #[test]
2916    fn test_style_color_named_arg_bar() {
2917        FIGURE_STATE.with(|f| f.take());
2918        let plugin = PlotPlugin;
2919        let env = Env::new();
2920        plugin
2921            .call("hold", &[Value::Str("on".into())], &env)
2922            .unwrap();
2923        let v = f64_vec(&[1.0, 2.0, 3.0]);
2924        plugin
2925            .call(
2926                "bar",
2927                &[v, Value::Str("color".into()), Value::Str("blue".into())],
2928                &env,
2929            )
2930            .expect("bar with 'color' named arg should succeed");
2931        let series = FIGURE_STATE.with(|f| f.borrow().pending_series.clone());
2932        assert_eq!(series.len(), 1);
2933        if let PendingSeries::Bar(_, _, style) = &series[0] {
2934            assert_eq!(
2935                style.as_ref().and_then(|s| s.color),
2936                Some(style::StyleColor(0, 0, 255)),
2937                "bar should carry blue style"
2938            );
2939        } else {
2940            panic!("expected PendingSeries::Bar");
2941        }
2942        FIGURE_STATE.with(|f| f.take());
2943    }
2944
2945    #[test]
2946    fn test_style_color_named_arg_hex() {
2947        FIGURE_STATE.with(|f| f.take());
2948        let plugin = PlotPlugin;
2949        let env = Env::new();
2950        plugin
2951            .call("hold", &[Value::Str("on".into())], &env)
2952            .unwrap();
2953        let v = f64_vec(&[1.0, 2.0, 3.0]);
2954        plugin
2955            .call(
2956                "bar",
2957                &[v, Value::Str("color".into()), Value::Str("#FF4400".into())],
2958                &env,
2959            )
2960            .expect("bar with hex color should succeed");
2961        let series = FIGURE_STATE.with(|f| f.borrow().pending_series.clone());
2962        assert_eq!(series.len(), 1);
2963        if let PendingSeries::Bar(_, _, style) = &series[0] {
2964            assert_eq!(
2965                style.as_ref().and_then(|s| s.color),
2966                Some(style::StyleColor(0xFF, 0x44, 0x00)),
2967                "bar should carry #FF4400 style"
2968            );
2969        } else {
2970            panic!("expected PendingSeries::Bar");
2971        }
2972        FIGURE_STATE.with(|f| f.take());
2973    }
2974
2975    #[test]
2976    fn test_colormap_matrix_dispatch() {
2977        FIGURE_STATE.with(|f| f.take());
2978        let plugin = PlotPlugin;
2979        let env = Env::new();
2980        let m = Array2::from_shape_vec((2, 3), vec![1.0, 0.0, 0.0, 0.0, 1.0, 0.0]).unwrap();
2981        let result = plugin.call("colormap", &[Value::Matrix(m)], &env);
2982        assert!(
2983            result.is_ok(),
2984            "colormap(N×3 matrix) should succeed: {result:?}"
2985        );
2986        let spec = FIGURE_STATE.with(|f| f.borrow().colormap.clone());
2987        assert!(
2988            matches!(spec, Some(colormap::ColormapSpec::Custom(_))),
2989            "should store ColormapSpec::Custom"
2990        );
2991        FIGURE_STATE.with(|f| f.take());
2992    }
2993
2994    #[test]
2995    fn test_colormap_matrix_wrong_cols() {
2996        let plugin = PlotPlugin;
2997        let env = Env::new();
2998        let m = Array2::from_shape_vec((2, 2), vec![1.0, 0.0, 0.0, 1.0]).unwrap();
2999        let result = plugin.call("colormap", &[Value::Matrix(m)], &env);
3000        assert!(result.is_err());
3001        let msg = result.unwrap_err();
3002        assert!(msg.contains("N×3"), "error should mention N×3: {msg}");
3003    }
3004
3005    // ── 30.5c: Option<StyleSpec> for Bar / Stem / Hist / Quiver ─────────────
3006
3007    #[test]
3008    fn test_bar_accumulates_with_style_red() {
3009        FIGURE_STATE.with(|f| f.take());
3010        let plugin = PlotPlugin;
3011        let env = Env::new();
3012        plugin
3013            .call("hold", &[Value::Str("on".into())], &env)
3014            .unwrap();
3015        let x = f64_vec(&[1.0, 2.0, 3.0]);
3016        let y = f64_vec(&[4.0, 5.0, 6.0]);
3017        plugin
3018            .call("bar", &[x, y, Value::Str("r".into())], &env)
3019            .unwrap();
3020        let series = FIGURE_STATE.with(|f| f.borrow().pending_series.clone());
3021        assert_eq!(series.len(), 1, "should have one bar series");
3022        if let PendingSeries::Bar(_, _, style) = &series[0] {
3023            assert_eq!(
3024                style.as_ref().and_then(|s| s.color),
3025                Some(style::StyleColor(255, 0, 0)),
3026                "bar should carry red style"
3027            );
3028        } else {
3029            panic!("expected PendingSeries::Bar");
3030        }
3031        FIGURE_STATE.with(|f| f.take());
3032    }
3033
3034    #[test]
3035    fn test_stem_accumulates_with_style_blue() {
3036        FIGURE_STATE.with(|f| f.take());
3037        let plugin = PlotPlugin;
3038        let env = Env::new();
3039        plugin
3040            .call("hold", &[Value::Str("on".into())], &env)
3041            .unwrap();
3042        let x = f64_vec(&[1.0, 2.0, 3.0]);
3043        let y = f64_vec(&[1.0, 2.0, 3.0]);
3044        plugin
3045            .call("stem", &[x, y, Value::Str("blue".into())], &env)
3046            .unwrap();
3047        let series = FIGURE_STATE.with(|f| f.borrow().pending_series.clone());
3048        assert_eq!(series.len(), 1, "should have one stem series");
3049        if let PendingSeries::Stem(_, _, style) = &series[0] {
3050            assert_eq!(
3051                style.as_ref().and_then(|s| s.color),
3052                Some(style::StyleColor(0, 0, 255)),
3053                "stem should carry blue style"
3054            );
3055        } else {
3056            panic!("expected PendingSeries::Stem");
3057        }
3058        FIGURE_STATE.with(|f| f.take());
3059    }
3060
3061    #[test]
3062    fn test_hist_accumulates_with_style_hex() {
3063        FIGURE_STATE.with(|f| f.take());
3064        let plugin = PlotPlugin;
3065        let env = Env::new();
3066        plugin
3067            .call("hold", &[Value::Str("on".into())], &env)
3068            .unwrap();
3069        let data = f64_vec(&[1.0, 2.0, 3.0, 4.0, 5.0]);
3070        plugin
3071            .call("hist", &[data, Value::Str("#FF8800".into())], &env)
3072            .unwrap();
3073        let series = FIGURE_STATE.with(|f| f.borrow().pending_series.clone());
3074        assert_eq!(series.len(), 1, "should have one hist series");
3075        if let PendingSeries::Hist { style, .. } = &series[0] {
3076            assert_eq!(
3077                style.as_ref().and_then(|s| s.color),
3078                Some(style::StyleColor(0xFF, 0x88, 0x00)),
3079                "hist should carry hex colour style"
3080            );
3081        } else {
3082            panic!("expected PendingSeries::Hist");
3083        }
3084        FIGURE_STATE.with(|f| f.take());
3085    }
3086
3087    #[test]
3088    fn test_quiver_accumulates_with_style_green() {
3089        FIGURE_STATE.with(|f| f.take());
3090        let plugin = PlotPlugin;
3091        let env = Env::new();
3092        plugin
3093            .call("hold", &[Value::Str("on".into())], &env)
3094            .unwrap();
3095        let x = f64_vec(&[0.0, 1.0]);
3096        let y = f64_vec(&[0.0, 1.0]);
3097        let u = f64_vec(&[1.0, 0.0]);
3098        let v = f64_vec(&[0.0, 1.0]);
3099        plugin
3100            .call("quiver", &[x, y, u, v, Value::Str("g".into())], &env)
3101            .unwrap();
3102        let series = FIGURE_STATE.with(|f| f.borrow().pending_series.clone());
3103        assert_eq!(series.len(), 1, "should have one quiver series");
3104        if let PendingSeries::Quiver(_, _, _, _, style) = &series[0] {
3105            assert_eq!(
3106                style.as_ref().and_then(|s| s.color),
3107                Some(style::StyleColor(0, 128, 0)),
3108                "quiver should carry green style"
3109            );
3110        } else {
3111            panic!("expected PendingSeries::Quiver");
3112        }
3113        FIGURE_STATE.with(|f| f.take());
3114    }
3115
3116    #[test]
3117    fn test_bar_no_style_stores_none() {
3118        FIGURE_STATE.with(|f| f.take());
3119        let plugin = PlotPlugin;
3120        let env = Env::new();
3121        plugin
3122            .call("hold", &[Value::Str("on".into())], &env)
3123            .unwrap();
3124        let x = f64_vec(&[1.0, 2.0]);
3125        let y = f64_vec(&[3.0, 4.0]);
3126        plugin.call("bar", &[x, y], &env).unwrap();
3127        let series = FIGURE_STATE.with(|f| f.borrow().pending_series.clone());
3128        if let PendingSeries::Bar(_, _, style) = &series[0] {
3129            assert!(style.is_none(), "unstyled bar should have None style");
3130        } else {
3131            panic!("expected PendingSeries::Bar");
3132        }
3133        FIGURE_STATE.with(|f| f.take());
3134    }
3135
3136    #[test]
3137    #[cfg(feature = "plot-svg")]
3138    fn test_bar_svg_with_red_style() {
3139        FIGURE_STATE.with(|f| f.take());
3140        let plugin = PlotPlugin;
3141        let env = Env::new();
3142        let tmp = std::env::temp_dir().join("bar_red_30_5c.svg");
3143        let path = tmp.to_string_lossy().to_string();
3144        let x = f64_vec(&[1.0, 2.0, 3.0]);
3145        let y = f64_vec(&[4.0, 5.0, 3.0]);
3146        let result = plugin.call(
3147            "bar",
3148            &[x, y, Value::Str("r".into()), Value::Str(path.clone())],
3149            &env,
3150        );
3151        assert!(
3152            result.is_ok(),
3153            "bar with red style to SVG should succeed: {result:?}"
3154        );
3155        assert!(
3156            std::path::Path::new(&path).exists(),
3157            "SVG file should be created"
3158        );
3159        let _ = std::fs::remove_file(&path);
3160        FIGURE_STATE.with(|f| f.take());
3161    }
3162
3163    // ── figure() tests ───────────────────────────────────────────────────────
3164
3165    #[test]
3166    fn test_figure_sets_canvas_size() {
3167        FIGURE_STATE.with(|f| f.take());
3168        let plugin = PlotPlugin;
3169        let env = Env::new();
3170        plugin
3171            .call(
3172                "figure",
3173                &[Value::Scalar(1200.0), Value::Scalar(400.0)],
3174                &env,
3175            )
3176            .unwrap();
3177        let size = FIGURE_STATE.with(|f| f.borrow().figure_size);
3178        assert_eq!(size, Some((1200, 400)));
3179        FIGURE_STATE.with(|f| f.take());
3180    }
3181
3182    #[test]
3183    fn test_figure_default_canvas_size() {
3184        FIGURE_STATE.with(|f| f.take());
3185        let st = FIGURE_STATE.with(|f| f.take());
3186        assert_eq!(st.canvas_size(), (800, 600));
3187    }
3188
3189    #[test]
3190    fn test_figure_wrong_arg_count_errors() {
3191        let plugin = PlotPlugin;
3192        let env = Env::new();
3193        let result = plugin.call("figure", &[Value::Scalar(800.0)], &env);
3194        assert!(result.is_err());
3195        let result = plugin.call("figure", &[], &env);
3196        assert!(result.is_err());
3197    }
3198
3199    #[test]
3200    fn test_figure_invalid_size_errors() {
3201        let plugin = PlotPlugin;
3202        let env = Env::new();
3203        let result = plugin.call("figure", &[Value::Scalar(0.0), Value::Scalar(600.0)], &env);
3204        assert!(result.is_err(), "width 0 should error");
3205        let result = plugin.call(
3206            "figure",
3207            &[Value::Scalar(800.0), Value::Scalar(20000.0)],
3208            &env,
3209        );
3210        assert!(result.is_err(), "height > 16384 should error");
3211    }
3212
3213    #[test]
3214    fn test_figure_in_builtin_names() {
3215        use ccalc_engine::eval::builtin_names;
3216        assert!(
3217            builtin_names().contains(&"figure"),
3218            "figure missing from builtin_names"
3219        );
3220    }
3221
3222    #[test]
3223    fn test_colormap_invalid_name_errors() {
3224        let plugin = PlotPlugin;
3225        let env = Env::new();
3226        let result = plugin.call("colormap", &[Value::Str("notacolormap".into())], &env);
3227        assert!(result.is_err());
3228        let msg = result.unwrap_err();
3229        assert!(
3230            msg.contains("colormap"),
3231            "error should mention colormap: {msg}"
3232        );
3233    }
3234
3235    #[test]
3236    fn test_apply_colormap_gray_extremes() {
3237        let (r, g, b) = colormap::apply_colormap(0.0, "gray");
3238        assert_eq!((r, g, b), (0, 0, 0));
3239        let (r, g, b) = colormap::apply_colormap(1.0, "gray");
3240        assert_eq!((r, g, b), (255, 255, 255));
3241    }
3242
3243    #[test]
3244    fn test_imagesc_non_matrix_errors() {
3245        let plugin = PlotPlugin;
3246        let env = Env::new();
3247        let result = plugin.call("imagesc", &[Value::Str("notamatrix".into())], &env);
3248        assert!(result.is_err());
3249    }
3250
3251    #[test]
3252    fn test_imagesc_no_args_errors() {
3253        let plugin = PlotPlugin;
3254        let env = Env::new();
3255        let result = plugin.call("imagesc", &[], &env);
3256        assert!(result.is_err());
3257    }
3258
3259    #[test]
3260    #[cfg(not(feature = "plot-svg"))]
3261    fn test_imagesc_svg_without_feature_errors() {
3262        let plugin = PlotPlugin;
3263        let env = Env::new();
3264        let z = Value::Matrix(Array2::from_shape_vec((2, 2), vec![1.0, 2.0, 3.0, 4.0]).unwrap());
3265        let path = Value::Str("out.svg".into());
3266        let result = plugin.call("imagesc", &[z, path], &env);
3267        assert!(result.is_err());
3268        let msg = result.unwrap_err();
3269        assert!(
3270            msg.contains("plot-svg"),
3271            "error should mention plot-svg feature: {msg}"
3272        );
3273    }
3274
3275    #[test]
3276    #[cfg(feature = "plot")]
3277    fn test_imagesc_ascii_no_error() {
3278        FIGURE_STATE.with(|f| f.take());
3279        let plugin = PlotPlugin;
3280        let env = Env::new();
3281        let z = Value::Matrix(
3282            Array2::from_shape_vec((4, 4), (0..16).map(|i| i as f64).collect()).unwrap(),
3283        );
3284        let result = plugin.call("imagesc", &[z], &env);
3285        assert!(result.is_ok(), "imagesc ASCII should succeed: {result:?}");
3286    }
3287
3288    #[test]
3289    #[cfg(feature = "plot")]
3290    fn test_imagesc_ascii_with_colorbar_no_error() {
3291        FIGURE_STATE.with(|f| f.take());
3292        let plugin = PlotPlugin;
3293        let env = Env::new();
3294        plugin
3295            .call("colormap", &[Value::Str("jet".into())], &env)
3296            .unwrap();
3297        plugin.call("colorbar", &[], &env).unwrap();
3298        let z = Value::Matrix(
3299            Array2::from_shape_vec((3, 3), (0..9).map(|i| i as f64).collect()).unwrap(),
3300        );
3301        let result = plugin.call("imagesc", &[z], &env);
3302        assert!(
3303            result.is_ok(),
3304            "imagesc with colorbar should succeed: {result:?}"
3305        );
3306    }
3307
3308    // ── 30b: surf / mesh ───────────────────────────────────────────────────
3309
3310    #[allow(dead_code)]
3311    fn make_xyz(rows: usize, cols: usize) -> (Value, Value, Value) {
3312        let x = Value::Matrix(Array2::from_shape_fn((rows, cols), |(_r, c)| c as f64));
3313        let y = Value::Matrix(Array2::from_shape_fn((rows, cols), |(r, _c)| r as f64));
3314        let z = Value::Matrix(Array2::from_shape_fn((rows, cols), |(r, c)| (r + c) as f64));
3315        (x, y, z)
3316    }
3317
3318    #[test]
3319    fn test_surf_dimension_mismatch_error() {
3320        FIGURE_STATE.with(|f| f.take());
3321        let plugin = PlotPlugin;
3322        let env = Env::new();
3323        let x = Value::Matrix(Array2::from_shape_vec((2, 3), vec![1.0; 6]).unwrap());
3324        let y = Value::Matrix(Array2::from_shape_vec((3, 2), vec![1.0; 6]).unwrap());
3325        let z = Value::Matrix(Array2::from_shape_vec((2, 3), vec![0.0; 6]).unwrap());
3326        let err = plugin.call("surf", &[x, y, z], &env).unwrap_err();
3327        assert!(
3328            err.contains("same dimensions"),
3329            "error should mention dimensions: {err}"
3330        );
3331    }
3332
3333    #[test]
3334    fn test_mesh_dimension_mismatch_error() {
3335        FIGURE_STATE.with(|f| f.take());
3336        let plugin = PlotPlugin;
3337        let env = Env::new();
3338        let x = Value::Matrix(Array2::from_shape_vec((2, 3), vec![1.0; 6]).unwrap());
3339        let y = Value::Matrix(Array2::from_shape_vec((2, 2), vec![1.0; 4]).unwrap());
3340        let z = Value::Matrix(Array2::from_shape_vec((2, 3), vec![0.0; 6]).unwrap());
3341        let err = plugin.call("mesh", &[x, y, z], &env).unwrap_err();
3342        assert!(
3343            err.contains("same dimensions"),
3344            "error should mention dimensions: {err}"
3345        );
3346    }
3347
3348    #[test]
3349    fn test_surf_missing_args_error() {
3350        FIGURE_STATE.with(|f| f.take());
3351        let plugin = PlotPlugin;
3352        let env = Env::new();
3353        let x = Value::Matrix(Array2::from_shape_vec((2, 2), vec![1.0; 4]).unwrap());
3354        let err = plugin.call("surf", &[x], &env).unwrap_err();
3355        assert!(
3356            err.contains("requires"),
3357            "error should mention requires: {err}"
3358        );
3359    }
3360
3361    #[test]
3362    #[cfg(feature = "plot")]
3363    fn test_surf_ascii_no_error() {
3364        FIGURE_STATE.with(|f| f.take());
3365        let plugin = PlotPlugin;
3366        let env = Env::new();
3367        let (x, y, z) = make_xyz(5, 8);
3368        let result = plugin.call("surf", &[x, y, z], &env);
3369        assert!(result.is_ok(), "surf ASCII should succeed: {result:?}");
3370    }
3371
3372    #[test]
3373    #[cfg(feature = "plot")]
3374    fn test_mesh_ascii_no_error() {
3375        FIGURE_STATE.with(|f| f.take());
3376        let plugin = PlotPlugin;
3377        let env = Env::new();
3378        let (x, y, z) = make_xyz(5, 8);
3379        let result = plugin.call("mesh", &[x, y, z], &env);
3380        assert!(result.is_ok(), "mesh ASCII should succeed: {result:?}");
3381    }
3382
3383    #[test]
3384    #[cfg(feature = "plot-svg")]
3385    fn test_surf_svg_creates_file() {
3386        FIGURE_STATE.with(|f| f.take());
3387        let plugin = PlotPlugin;
3388        let env = Env::new();
3389        let (x, y, z) = make_xyz(4, 5);
3390        let path = ".debug/test_surf.svg";
3391        std::fs::create_dir_all(".debug").ok();
3392        let result = plugin.call("surf", &[x, y, z, Value::Str(path.into())], &env);
3393        assert!(result.is_ok(), "surf SVG should succeed: {result:?}");
3394        let content = std::fs::read_to_string(path).unwrap();
3395        assert!(
3396            content.contains("<svg"),
3397            "output should be SVG: starts with {}",
3398            &content[..50.min(content.len())]
3399        );
3400        std::fs::remove_file(path).ok();
3401    }
3402
3403    #[test]
3404    #[cfg(feature = "plot-svg")]
3405    fn test_mesh_png_creates_file() {
3406        FIGURE_STATE.with(|f| f.take());
3407        let plugin = PlotPlugin;
3408        let env = Env::new();
3409        let (x, y, z) = make_xyz(4, 5);
3410        let path = ".debug/test_mesh.png";
3411        std::fs::create_dir_all(".debug").ok();
3412        let result = plugin.call("mesh", &[x, y, z, Value::Str(path.into())], &env);
3413        assert!(result.is_ok(), "mesh PNG should succeed: {result:?}");
3414        let bytes = std::fs::read(path).unwrap();
3415        // PNG magic bytes: 0x89 P N G
3416        assert_eq!(
3417            &bytes[0..4],
3418            &[0x89, 0x50, 0x4E, 0x47],
3419            "output should be PNG"
3420        );
3421        std::fs::remove_file(path).ok();
3422    }
3423
3424    // ── 30c: contour / contourf ────────────────────────────────────────────
3425
3426    #[allow(dead_code)]
3427    fn make_contour_xyz(rows: usize, cols: usize) -> (Value, Value, Value) {
3428        // X, Y from meshgrid; Z = Gaussian bell centred at (0,0)
3429        let x = Value::Matrix(Array2::from_shape_fn((rows, cols), |(_r, c)| {
3430            -2.0 + 4.0 * c as f64 / (cols - 1).max(1) as f64
3431        }));
3432        let y = Value::Matrix(Array2::from_shape_fn((rows, cols), |(r, _c)| {
3433            -2.0 + 4.0 * r as f64 / (rows - 1).max(1) as f64
3434        }));
3435        let z = Value::Matrix(Array2::from_shape_fn((rows, cols), |(r, c)| {
3436            let xi = -2.0 + 4.0 * c as f64 / (cols - 1).max(1) as f64;
3437            let yi = -2.0 + 4.0 * r as f64 / (rows - 1).max(1) as f64;
3438            (-xi * xi - yi * yi).exp()
3439        }));
3440        (x, y, z)
3441    }
3442
3443    #[test]
3444    fn test_contour_non_matrix_x_errors() {
3445        FIGURE_STATE.with(|f| f.take());
3446        let plugin = PlotPlugin;
3447        let env = Env::new();
3448        let x = Value::Str("notamatrix".into());
3449        let y = f64_vec(&[0.0, 1.0]);
3450        let z = f64_vec(&[0.0, 1.0]);
3451        let result = plugin.call("contour", &[x, y, z], &env);
3452        assert!(result.is_err(), "non-matrix X should error");
3453        let msg = result.unwrap_err();
3454        assert!(msg.contains("X"), "error should mention X: {msg}");
3455    }
3456
3457    #[test]
3458    fn test_contour_mismatched_dimensions_errors() {
3459        FIGURE_STATE.with(|f| f.take());
3460        let plugin = PlotPlugin;
3461        let env = Env::new();
3462        let x = Value::Matrix(Array2::from_shape_vec((2, 3), vec![0.0; 6]).unwrap());
3463        let y = Value::Matrix(Array2::from_shape_vec((3, 2), vec![0.0; 6]).unwrap());
3464        let z = Value::Matrix(Array2::from_shape_vec((2, 3), vec![0.0; 6]).unwrap());
3465        let result = plugin.call("contour", &[x, y, z], &env);
3466        assert!(result.is_err(), "mismatched dimensions should error");
3467        let msg = result.unwrap_err();
3468        assert!(
3469            msg.contains("same dimensions"),
3470            "error should mention dimensions: {msg}"
3471        );
3472    }
3473
3474    #[test]
3475    fn test_contour_missing_args_errors() {
3476        FIGURE_STATE.with(|f| f.take());
3477        let plugin = PlotPlugin;
3478        let env = Env::new();
3479        let x = Value::Matrix(Array2::from_shape_vec((2, 2), vec![0.0; 4]).unwrap());
3480        let result = plugin.call("contour", &[x], &env);
3481        assert!(result.is_err());
3482        let msg = result.unwrap_err();
3483        assert!(
3484            msg.contains("requires"),
3485            "error should mention requires: {msg}"
3486        );
3487    }
3488
3489    #[test]
3490    #[cfg(feature = "plot")]
3491    fn test_contour_ascii_no_error() {
3492        FIGURE_STATE.with(|f| f.take());
3493        let plugin = PlotPlugin;
3494        let env = Env::new();
3495        let (x, y, z) = make_contour_xyz(10, 12);
3496        let result = plugin.call("contour", &[x, y, z, Value::Scalar(5.0)], &env);
3497        assert!(result.is_ok(), "contour ASCII should succeed: {result:?}");
3498    }
3499
3500    #[test]
3501    #[cfg(feature = "plot")]
3502    fn test_contourf_ascii_no_error() {
3503        FIGURE_STATE.with(|f| f.take());
3504        let plugin = PlotPlugin;
3505        let env = Env::new();
3506        let (x, y, z) = make_contour_xyz(10, 12);
3507        let result = plugin.call("contourf", &[x, y, z, Value::Scalar(5.0)], &env);
3508        assert!(result.is_ok(), "contourf ASCII should succeed: {result:?}");
3509    }
3510
3511    #[test]
3512    #[cfg(feature = "plot-svg")]
3513    fn test_contour_svg_creates_file() {
3514        FIGURE_STATE.with(|f| f.take());
3515        let plugin = PlotPlugin;
3516        let env = Env::new();
3517        let (x, y, z) = make_contour_xyz(15, 20);
3518        let path = ".debug/test_contour.svg";
3519        std::fs::create_dir_all(".debug").ok();
3520        let result = plugin.call(
3521            "contour",
3522            &[x, y, z, Value::Scalar(5.0), Value::Str(path.into())],
3523            &env,
3524        );
3525        assert!(result.is_ok(), "contour SVG should succeed: {result:?}");
3526        let content = std::fs::read_to_string(path).unwrap();
3527        assert!(
3528            content.contains("<svg"),
3529            "output should be SVG: starts with {}",
3530            &content[..50.min(content.len())]
3531        );
3532        std::fs::remove_file(path).ok();
3533    }
3534
3535    #[test]
3536    #[cfg(feature = "plot-svg")]
3537    fn test_contourf_png_magic_bytes() {
3538        FIGURE_STATE.with(|f| f.take());
3539        let plugin = PlotPlugin;
3540        let env = Env::new();
3541        let (x, y, z) = make_contour_xyz(15, 20);
3542        let path = ".debug/test_contourf.png";
3543        std::fs::create_dir_all(".debug").ok();
3544        let result = plugin.call(
3545            "contourf",
3546            &[x, y, z, Value::Scalar(5.0), Value::Str(path.into())],
3547            &env,
3548        );
3549        assert!(result.is_ok(), "contourf PNG should succeed: {result:?}");
3550        let bytes = std::fs::read(path).unwrap();
3551        assert_eq!(
3552            &bytes[0..4],
3553            &[0x89, 0x50, 0x4E, 0x47],
3554            "output should be PNG"
3555        );
3556        std::fs::remove_file(path).ok();
3557    }
3558
3559    // ── Phase 30d: subplot + hold + savefig ──────────────────────────
3560
3561    #[test]
3562    fn test_subplot_sets_state() {
3563        FIGURE_STATE.with(|f| f.take());
3564        let plugin = PlotPlugin;
3565        let env = Env::new();
3566        plugin
3567            .call(
3568                "subplot",
3569                &[Value::Scalar(2.0), Value::Scalar(2.0), Value::Scalar(1.0)],
3570                &env,
3571            )
3572            .unwrap();
3573        let subplot = FIGURE_STATE.with(|f| f.borrow().subplot);
3574        assert_eq!(subplot, Some((2, 2, 1)));
3575        FIGURE_STATE.with(|f| f.take());
3576    }
3577
3578    #[test]
3579    fn test_hold_on_sets_flag() {
3580        FIGURE_STATE.with(|f| f.take());
3581        let plugin = PlotPlugin;
3582        let env = Env::new();
3583        plugin
3584            .call("hold", &[Value::Str("on".into())], &env)
3585            .unwrap();
3586        let hold = FIGURE_STATE.with(|f| f.borrow().hold);
3587        assert!(hold, "hold flag should be true after hold('on')");
3588        FIGURE_STATE.with(|f| f.take());
3589    }
3590
3591    #[test]
3592    fn test_hold_off_clears_flag_and_series() {
3593        FIGURE_STATE.with(|f| f.take());
3594        let plugin = PlotPlugin;
3595        let env = Env::new();
3596        // Prime hold + a series so hold('off') has something to flush.
3597        FIGURE_STATE.with(|f| {
3598            let mut st = f.borrow_mut();
3599            st.hold = true;
3600            st.pending_series
3601                .push(PendingSeries::Line(vec![1.0, 2.0], vec![1.0, 4.0], None));
3602        });
3603        // State is mutated before ASCII rendering; ignore the render result so
3604        // this test passes regardless of which feature flags are enabled.
3605        let _ = plugin.call("hold", &[Value::Str("off".into())], &env);
3606        let (hold, series_empty) = FIGURE_STATE.with(|f| {
3607            let st = f.borrow();
3608            (st.hold, st.pending_series.is_empty())
3609        });
3610        assert!(!hold, "hold should be false after hold('off')");
3611        assert!(
3612            series_empty,
3613            "pending_series should be cleared after hold('off')"
3614        );
3615        FIGURE_STATE.with(|f| f.take());
3616    }
3617
3618    #[test]
3619    fn test_plot_accumulates_under_hold() {
3620        FIGURE_STATE.with(|f| f.take());
3621        let plugin = PlotPlugin;
3622        let env = Env::new();
3623        plugin
3624            .call("hold", &[Value::Str("on".into())], &env)
3625            .unwrap();
3626        let y1 = f64_vec(&[1.0, 2.0, 3.0]);
3627        let y2 = f64_vec(&[3.0, 2.0, 1.0]);
3628        plugin.call("plot", &[y1], &env).unwrap();
3629        plugin.call("plot", &[y2], &env).unwrap();
3630        let count = FIGURE_STATE.with(|f| f.borrow().pending_series.len());
3631        assert_eq!(count, 2, "two plot calls should accumulate 2 series");
3632        FIGURE_STATE.with(|f| f.take());
3633    }
3634
3635    #[test]
3636    fn test_subplot_then_plot_accumulates() {
3637        FIGURE_STATE.with(|f| f.take());
3638        let plugin = PlotPlugin;
3639        let env = Env::new();
3640        plugin
3641            .call(
3642                "subplot",
3643                &[Value::Scalar(2.0), Value::Scalar(1.0), Value::Scalar(1.0)],
3644                &env,
3645            )
3646            .unwrap();
3647        let y = f64_vec(&[1.0, 2.0, 3.0]);
3648        plugin.call("plot", &[y], &env).unwrap();
3649        let count = FIGURE_STATE.with(|f| f.borrow().pending_series.len());
3650        assert_eq!(
3651            count, 1,
3652            "plot under subplot should accumulate into pending_series"
3653        );
3654        FIGURE_STATE.with(|f| f.take());
3655    }
3656
3657    #[test]
3658    fn test_second_subplot_commits_first_panel() {
3659        FIGURE_STATE.with(|f| f.take());
3660        let plugin = PlotPlugin;
3661        let env = Env::new();
3662        plugin
3663            .call(
3664                "subplot",
3665                &[Value::Scalar(2.0), Value::Scalar(1.0), Value::Scalar(1.0)],
3666                &env,
3667            )
3668            .unwrap();
3669        plugin.call("plot", &[f64_vec(&[1.0, 2.0])], &env).unwrap();
3670        // Move to panel 2 — should commit panel 1
3671        plugin
3672            .call(
3673                "subplot",
3674                &[Value::Scalar(2.0), Value::Scalar(1.0), Value::Scalar(2.0)],
3675                &env,
3676            )
3677            .unwrap();
3678        let (panels_len, pending_len) = FIGURE_STATE.with(|f| {
3679            let st = f.borrow();
3680            (st.panels.len(), st.pending_series.len())
3681        });
3682        assert_eq!(panels_len, 1, "panel 1 should be committed");
3683        assert_eq!(
3684            pending_len, 0,
3685            "pending_series should be empty after commit"
3686        );
3687        FIGURE_STATE.with(|f| f.take());
3688    }
3689
3690    #[test]
3691    fn test_subplot_invalid_index_errors() {
3692        FIGURE_STATE.with(|f| f.take());
3693        let plugin = PlotPlugin;
3694        let env = Env::new();
3695        let result = plugin.call(
3696            "subplot",
3697            &[Value::Scalar(2.0), Value::Scalar(2.0), Value::Scalar(5.0)],
3698            &env,
3699        );
3700        assert!(result.is_err(), "index 5 in a 2×2 grid should error");
3701        FIGURE_STATE.with(|f| f.take());
3702    }
3703
3704    #[test]
3705    fn test_savefig_with_no_panels_errors() {
3706        FIGURE_STATE.with(|f| f.take());
3707        let plugin = PlotPlugin;
3708        let env = Env::new();
3709        let result = plugin.call("savefig", &[Value::Str("out.svg".into())], &env);
3710        assert!(result.is_err(), "savefig with no panels should error");
3711        FIGURE_STATE.with(|f| f.take());
3712    }
3713
3714    // ── Phase 30f: quiver + text ───────────────────────────────────────────
3715
3716    #[test]
3717    fn test_quiver_mismatch_error() {
3718        FIGURE_STATE.with(|f| f.take());
3719        let plugin = PlotPlugin;
3720        let env = Env::new();
3721        let x = f64_vec(&[0.0, 1.0, 2.0]);
3722        let y = f64_vec(&[0.0, 1.0, 2.0]);
3723        let u = f64_vec(&[1.0, 0.0]);
3724        let v = f64_vec(&[0.0, 1.0, 0.0]);
3725        let result = plugin.call("quiver", &[x, y, u, v], &env);
3726        assert!(result.is_err(), "length mismatch should produce an error");
3727        let msg = result.unwrap_err();
3728        assert!(
3729            msg.contains("same length"),
3730            "error should mention 'same length': {msg}"
3731        );
3732    }
3733
3734    #[test]
3735    fn test_text_stores_annotation() {
3736        FIGURE_STATE.with(|f| f.take());
3737        let plugin = PlotPlugin;
3738        let env = Env::new();
3739        plugin
3740            .call(
3741                "text",
3742                &[
3743                    Value::Scalar(0.0),
3744                    Value::Scalar(1.0),
3745                    Value::Str("label".into()),
3746                ],
3747                &env,
3748            )
3749            .unwrap();
3750        let ann = FIGURE_STATE.with(|f| f.borrow().annotations.clone());
3751        assert_eq!(ann.len(), 1, "one annotation should be stored");
3752        assert_eq!(ann[0], (0.0, 1.0, "label".to_string()));
3753        FIGURE_STATE.with(|f| f.take());
3754    }
3755
3756    #[test]
3757    #[cfg(feature = "plot-svg")]
3758    fn test_quiver_svg_creates_file() {
3759        FIGURE_STATE.with(|f| f.take());
3760        let plugin = PlotPlugin;
3761        let env = Env::new();
3762        let x = f64_vec(&[0.0, 1.0, 0.0, 1.0]);
3763        let y = f64_vec(&[0.0, 0.0, 1.0, 1.0]);
3764        let u = f64_vec(&[1.0, 0.0, -1.0, 0.0]);
3765        let v = f64_vec(&[0.0, 1.0, 0.0, -1.0]);
3766        let path = ".debug/test_quiver.svg";
3767        std::fs::create_dir_all(".debug").ok();
3768        let result = plugin.call("quiver", &[x, y, u, v, Value::Str(path.into())], &env);
3769        assert!(result.is_ok(), "quiver SVG should succeed: {result:?}");
3770        let content = std::fs::read_to_string(path).unwrap();
3771        assert!(
3772            content.contains("<svg"),
3773            "output should be SVG: starts with {}",
3774            &content[..50.min(content.len())]
3775        );
3776        std::fs::remove_file(path).ok();
3777    }
3778
3779    #[test]
3780    #[cfg(feature = "plot-svg")]
3781    fn test_subplot_savefig_creates_svg() {
3782        FIGURE_STATE.with(|f| f.take());
3783        let plugin = PlotPlugin;
3784        let env = Env::new();
3785        let path = ".debug/test_subplot_grid.svg";
3786        std::fs::create_dir_all(".debug").ok();
3787        plugin
3788            .call(
3789                "subplot",
3790                &[Value::Scalar(2.0), Value::Scalar(1.0), Value::Scalar(1.0)],
3791                &env,
3792            )
3793            .unwrap();
3794        plugin
3795            .call("plot", &[f64_vec(&[1.0, 2.0, 3.0])], &env)
3796            .unwrap();
3797        plugin
3798            .call(
3799                "subplot",
3800                &[Value::Scalar(2.0), Value::Scalar(1.0), Value::Scalar(2.0)],
3801                &env,
3802            )
3803            .unwrap();
3804        plugin
3805            .call("plot", &[f64_vec(&[3.0, 2.0, 1.0])], &env)
3806            .unwrap();
3807        plugin
3808            .call("savefig", &[Value::Str(path.into())], &env)
3809            .unwrap();
3810        let content = std::fs::read_to_string(path).unwrap();
3811        assert!(
3812            content.contains("<svg"),
3813            "savefig should produce an SVG file"
3814        );
3815        std::fs::remove_file(path).ok();
3816    }
3817
3818    #[cfg(feature = "plot-svg")]
3819    #[test]
3820    fn test_figure_size_applied_to_svg() {
3821        FIGURE_STATE.with(|f| f.take());
3822        let plugin = PlotPlugin;
3823        let env = Env::new();
3824        let path = ".debug/test_figure_size.svg";
3825        std::fs::create_dir_all(".debug").ok();
3826        plugin
3827            .call(
3828                "figure",
3829                &[Value::Scalar(1024.0), Value::Scalar(300.0)],
3830                &env,
3831            )
3832            .unwrap();
3833        plugin
3834            .call(
3835                "plot",
3836                &[
3837                    f64_vec(&[1.0, 2.0, 3.0]),
3838                    f64_vec(&[1.0, 4.0, 9.0]),
3839                    Value::Str(path.into()),
3840                ],
3841                &env,
3842            )
3843            .unwrap();
3844        let content = std::fs::read_to_string(path).unwrap();
3845        assert!(
3846            content.contains("1024"),
3847            "SVG should contain requested width"
3848        );
3849        assert!(
3850            content.contains("300"),
3851            "SVG should contain requested height"
3852        );
3853        std::fs::remove_file(path).ok();
3854    }
3855
3856    // ── Phase 30.6a — Theme + bgcolor ─────────────────────────────────
3857
3858    #[test]
3859    #[cfg(feature = "plot-svg")]
3860    fn test_theme_dark_svg_contains_dark_bg() {
3861        let plugin = PlotPlugin;
3862        let env = Env::new();
3863        FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
3864
3865        let path = ".debug/test_theme_dark.svg";
3866        plugin
3867            .call("theme", &[Value::Str("dark".into())], &env)
3868            .unwrap();
3869        plugin
3870            .call(
3871                "plot",
3872                &[
3873                    f64_vec(&[1.0, 2.0]),
3874                    f64_vec(&[1.0, 2.0]),
3875                    Value::Str(path.into()),
3876                ],
3877                &env,
3878            )
3879            .unwrap();
3880        let content = std::fs::read_to_string(path).unwrap();
3881        // Dark theme background is #1E1E2E.
3882        assert!(
3883            content.contains("1E1E2E") || content.contains("1e1e2e"),
3884            "SVG must contain the dark theme background colour"
3885        );
3886        std::fs::remove_file(path).ok();
3887    }
3888
3889    #[test]
3890    fn test_theme_light_is_default() {
3891        let light = style::Theme::light();
3892        // Default FigureState has no theme → resolve_theme returns light.
3893        let st = FigureState::default();
3894        let resolved = st.resolve_theme();
3895        assert_eq!(resolved.bg, light.bg);
3896        assert_eq!(resolved.text, light.text);
3897    }
3898
3899    #[test]
3900    fn test_theme_unknown_name_errors() {
3901        let plugin = PlotPlugin;
3902        let env = Env::new();
3903        let result = plugin.call("theme", &[Value::Str("rainbow".into())], &env);
3904        assert!(result.is_err());
3905        assert!(result.unwrap_err().contains("unknown theme"));
3906    }
3907
3908    #[test]
3909    #[cfg(feature = "plot-svg")]
3910    fn test_bgcolor_overrides_theme_bg() {
3911        let plugin = PlotPlugin;
3912        let env = Env::new();
3913        FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
3914
3915        let path = ".debug/test_bgcolor_override.svg";
3916        plugin
3917            .call("theme", &[Value::Str("dark".into())], &env)
3918            .unwrap();
3919        // Override with a bright red background.
3920        plugin
3921            .call("bgcolor", &[Value::Str("red".into())], &env)
3922            .unwrap();
3923        plugin
3924            .call(
3925                "plot",
3926                &[
3927                    f64_vec(&[1.0, 2.0]),
3928                    f64_vec(&[1.0, 2.0]),
3929                    Value::Str(path.into()),
3930                ],
3931                &env,
3932            )
3933            .unwrap();
3934        let content = std::fs::read_to_string(path).unwrap();
3935        // Red = #FF0000; dark theme bg #1E1E2E must NOT be the fill.
3936        assert!(
3937            !content.contains("1E1E2E") && !content.contains("1e1e2e"),
3938            "Dark theme bg should not appear when bgcolor overrides it"
3939        );
3940        std::fs::remove_file(path).ok();
3941    }
3942
3943    #[test]
3944    fn test_bgcolor_hex_accepted() {
3945        let plugin = PlotPlugin;
3946        let env = Env::new();
3947        FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
3948        plugin
3949            .call("bgcolor", &[Value::Str("#AABBCC".into())], &env)
3950            .unwrap();
3951        let bg = FIGURE_STATE.with(|f| f.borrow().bg_color);
3952        assert_eq!(bg, Some(style::StyleColor(0xAA, 0xBB, 0xCC)));
3953    }
3954
3955    #[test]
3956    fn test_bgcolor_rgb_matrix() {
3957        use ndarray::Array2;
3958        let plugin = PlotPlugin;
3959        let env = Env::new();
3960        FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
3961        // [0.0, 0.5, 1.0] as 1×3 matrix → RGB(0, 128, 255).
3962        let m = Value::Matrix(Array2::from_shape_vec((1, 3), vec![0.0_f64, 0.5, 1.0]).unwrap());
3963        plugin.call("bgcolor", &[m], &env).unwrap();
3964        let bg = FIGURE_STATE.with(|f| f.borrow().bg_color);
3965        assert_eq!(bg, Some(style::StyleColor(0, 128, 255)));
3966    }
3967
3968    // ── Phase 30.6b tests ──────────────────────────────────────────────────
3969
3970    #[test]
3971    fn test_linewidth_named_arg_plot() {
3972        let plugin = PlotPlugin;
3973        let env = Env::new();
3974        FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
3975        plugin
3976            .call("hold", &[Value::Str("on".into())], &env)
3977            .unwrap();
3978        plugin
3979            .call(
3980                "plot",
3981                &[
3982                    f64_vec(&[0.0, 1.0]),
3983                    f64_vec(&[0.0, 1.0]),
3984                    Value::Str("r--".into()),
3985                    Value::Str("linewidth".into()),
3986                    Value::Scalar(2.5),
3987                ],
3988                &env,
3989            )
3990            .unwrap();
3991        let lw = FIGURE_STATE.with(|f| {
3992            if let Some(PendingSeries::Line(_, _, Some(sp))) = f.borrow().pending_series.first() {
3993                sp.line_width
3994            } else {
3995                None
3996            }
3997        });
3998        assert_eq!(lw, Some(2.5_f32));
3999    }
4000
4001    #[test]
4002    fn test_markersize_named_arg_scatter() {
4003        let plugin = PlotPlugin;
4004        let env = Env::new();
4005        FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
4006        plugin
4007            .call("hold", &[Value::Str("on".into())], &env)
4008            .unwrap();
4009        plugin
4010            .call(
4011                "scatter",
4012                &[
4013                    f64_vec(&[1.0, 2.0]),
4014                    f64_vec(&[1.0, 2.0]),
4015                    Value::Str("markersize".into()),
4016                    Value::Scalar(7.0),
4017                ],
4018                &env,
4019            )
4020            .unwrap();
4021        let ms = FIGURE_STATE.with(|f| {
4022            if let Some(PendingSeries::Scatter(_, _, Some(sp))) = f.borrow().pending_series.first()
4023            {
4024                sp.marker_size
4025            } else {
4026                None
4027            }
4028        });
4029        assert_eq!(ms, Some(7_u32));
4030    }
4031
4032    #[test]
4033    fn test_linewidth_and_markersize_combined() {
4034        let plugin = PlotPlugin;
4035        let env = Env::new();
4036        FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
4037        plugin
4038            .call("hold", &[Value::Str("on".into())], &env)
4039            .unwrap();
4040        plugin
4041            .call(
4042                "plot",
4043                &[
4044                    f64_vec(&[0.0, 1.0]),
4045                    f64_vec(&[0.0, 1.0]),
4046                    Value::Str("b.".into()),
4047                    Value::Str("linewidth".into()),
4048                    Value::Scalar(1.5),
4049                    Value::Str("markersize".into()),
4050                    Value::Scalar(8.0),
4051                ],
4052                &env,
4053            )
4054            .unwrap();
4055        let (lw, ms) = FIGURE_STATE.with(|f| {
4056            if let Some(PendingSeries::Line(_, _, Some(sp))) = f.borrow().pending_series.first() {
4057                (sp.line_width, sp.marker_size)
4058            } else {
4059                (None, None)
4060            }
4061        });
4062        assert_eq!(lw, Some(1.5_f32));
4063        assert_eq!(ms, Some(8_u32));
4064    }
4065
4066    #[test]
4067    fn test_fontsize_global_setter() {
4068        let plugin = PlotPlugin;
4069        let env = Env::new();
4070        FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
4071        plugin
4072            .call("fontsize", &[Value::Scalar(18.0)], &env)
4073            .unwrap();
4074        let fs = FIGURE_STATE.with(|f| f.borrow().font_size);
4075        assert_eq!(fs, Some(18_u32));
4076    }
4077
4078    #[test]
4079    fn test_linewidth_global_setter() {
4080        let plugin = PlotPlugin;
4081        let env = Env::new();
4082        FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
4083        plugin
4084            .call("linewidth", &[Value::Scalar(3.0)], &env)
4085            .unwrap();
4086        let lw = FIGURE_STATE.with(|f| f.borrow().line_width);
4087        assert_eq!(lw, Some(3.0_f32));
4088    }
4089
4090    #[test]
4091    fn test_markersize_global_setter() {
4092        let plugin = PlotPlugin;
4093        let env = Env::new();
4094        FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
4095        plugin
4096            .call("markersize", &[Value::Scalar(5.0)], &env)
4097            .unwrap();
4098        let ms = FIGURE_STATE.with(|f| f.borrow().marker_size);
4099        assert_eq!(ms, Some(5_u32));
4100    }
4101
4102    // ── Phase 30.6c — grid style ────────────────────────────────────────
4103
4104    #[test]
4105    fn test_gridcolor_named_color() {
4106        let plugin = PlotPlugin;
4107        let env = Env::new();
4108        FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
4109        plugin
4110            .call("gridcolor", &[Value::Str("red".into())], &env)
4111            .unwrap();
4112        let gc = FIGURE_STATE.with(|f| f.borrow().grid_color);
4113        assert_eq!(gc, Some(StyleColor(255, 0, 0)));
4114    }
4115
4116    #[test]
4117    fn test_gridcolor_rgb_matrix() {
4118        let plugin = PlotPlugin;
4119        let env = Env::new();
4120        FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
4121        use ccalc_engine::env::Value;
4122        use ndarray::arr2;
4123        let m = Value::Matrix(arr2(&[[0.0_f64, 1.0, 0.0]]));
4124        plugin.call("gridcolor", &[m], &env).unwrap();
4125        let gc = FIGURE_STATE.with(|f| f.borrow().grid_color);
4126        assert_eq!(gc, Some(StyleColor(0, 255, 0)));
4127    }
4128
4129    #[test]
4130    fn test_gridwidth_global_setter() {
4131        let plugin = PlotPlugin;
4132        let env = Env::new();
4133        FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
4134        plugin
4135            .call("gridwidth", &[Value::Scalar(2.0)], &env)
4136            .unwrap();
4137        let gw = FIGURE_STATE.with(|f| f.borrow().grid_width);
4138        assert_eq!(gw, Some(2.0_f32));
4139    }
4140
4141    // ── 30.6d: axis mode ─────────────────────────────────────────────────────
4142
4143    #[test]
4144    fn test_axis_equal_sets_state() {
4145        FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
4146        let plugin = PlotPlugin;
4147        let env = Env::new();
4148        plugin
4149            .call("axis", &[Value::Str("equal".into())], &env)
4150            .unwrap();
4151        let mode = FIGURE_STATE.with(|f| f.borrow().axis_mode);
4152        assert_eq!(mode, Some(style::AxisMode::Equal));
4153        FIGURE_STATE.with(|f| f.take());
4154    }
4155
4156    #[test]
4157    fn test_axis_tight_sets_state() {
4158        FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
4159        let plugin = PlotPlugin;
4160        let env = Env::new();
4161        plugin
4162            .call("axis", &[Value::Str("tight".into())], &env)
4163            .unwrap();
4164        let mode = FIGURE_STATE.with(|f| f.borrow().axis_mode);
4165        assert_eq!(mode, Some(style::AxisMode::Tight));
4166        FIGURE_STATE.with(|f| f.take());
4167    }
4168
4169    #[test]
4170    fn test_axis_off_sets_state() {
4171        FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
4172        let plugin = PlotPlugin;
4173        let env = Env::new();
4174        plugin
4175            .call("axis", &[Value::Str("off".into())], &env)
4176            .unwrap();
4177        let mode = FIGURE_STATE.with(|f| f.borrow().axis_mode);
4178        assert_eq!(mode, Some(style::AxisMode::Off));
4179        FIGURE_STATE.with(|f| f.take());
4180    }
4181
4182    #[test]
4183    fn test_axis_on_clears_mode() {
4184        FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
4185        let plugin = PlotPlugin;
4186        let env = Env::new();
4187        plugin
4188            .call("axis", &[Value::Str("equal".into())], &env)
4189            .unwrap();
4190        plugin
4191            .call("axis", &[Value::Str("on".into())], &env)
4192            .unwrap();
4193        let mode = FIGURE_STATE.with(|f| f.borrow().axis_mode);
4194        assert_eq!(mode, None, "axis('on') should clear the axis mode");
4195        FIGURE_STATE.with(|f| f.take());
4196    }
4197
4198    #[test]
4199    fn test_axis_invalid_arg_errors() {
4200        let plugin = PlotPlugin;
4201        let env = Env::new();
4202        let result = plugin.call("axis", &[Value::Str("square".into())], &env);
4203        assert!(result.is_err());
4204        let msg = result.unwrap_err();
4205        assert!(
4206            msg.contains("expected"),
4207            "error should describe valid options: {msg}"
4208        );
4209    }
4210
4211    #[test]
4212    fn test_axis_mode_carried_into_panel() {
4213        FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
4214        let plugin = PlotPlugin;
4215        let env = Env::new();
4216        plugin
4217            .call("axis", &[Value::Str("tight".into())], &env)
4218            .unwrap();
4219        plugin
4220            .call("hold", &[Value::Str("on".into())], &env)
4221            .unwrap();
4222        plugin
4223            .call("plot", &[f64_vec(&[0.0, 1.0]), f64_vec(&[0.0, 1.0])], &env)
4224            .unwrap();
4225        // commit_current_panel via subplot
4226        plugin
4227            .call(
4228                "subplot",
4229                &[Value::Scalar(1.0), Value::Scalar(2.0), Value::Scalar(2.0)],
4230                &env,
4231            )
4232            .unwrap();
4233        let mode = FIGURE_STATE.with(|f| f.borrow().panels.first().and_then(|p| p.axis_mode));
4234        assert_eq!(
4235            mode,
4236            Some(style::AxisMode::Tight),
4237            "axis_mode should be carried into the committed panel"
4238        );
4239        FIGURE_STATE.with(|f| f.take());
4240    }
4241
4242    #[test]
4243    #[cfg(feature = "plot-svg")]
4244    fn test_axis_off_svg_no_error() {
4245        FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
4246        let plugin = PlotPlugin;
4247        let env = Env::new();
4248        plugin
4249            .call("axis", &[Value::Str("off".into())], &env)
4250            .unwrap();
4251        let tmp = std::env::temp_dir().join("axis_off_30_6d.svg");
4252        let path = tmp.to_string_lossy().to_string();
4253        let x = f64_vec(&[1.0, 2.0, 3.0]);
4254        let y = f64_vec(&[1.0, 4.0, 9.0]);
4255        let result = plugin.call("plot", &[x, y, Value::Str(path.clone())], &env);
4256        assert!(
4257            result.is_ok(),
4258            "axis('off') + plot to SVG should succeed: {result:?}"
4259        );
4260        let content = std::fs::read_to_string(&path).unwrap_or_default();
4261        assert!(content.contains("<svg"), "output should contain <svg");
4262        let _ = std::fs::remove_file(&path);
4263        FIGURE_STATE.with(|f| f.take());
4264    }
4265
4266    #[test]
4267    fn test_gridcolor_carried_into_panel() {
4268        let plugin = PlotPlugin;
4269        let env = Env::new();
4270        FIGURE_STATE.with(|f| *f.borrow_mut() = FigureState::default());
4271        plugin
4272            .call("gridcolor", &[Value::Str("blue".into())], &env)
4273            .unwrap();
4274        plugin
4275            .call("gridwidth", &[Value::Scalar(3.0)], &env)
4276            .unwrap();
4277        plugin
4278            .call("hold", &[Value::Str("on".into())], &env)
4279            .unwrap();
4280        plugin
4281            .call("plot", &[f64_vec(&[0.0, 1.0]), f64_vec(&[0.0, 1.0])], &env)
4282            .unwrap();
4283        // commit_current_panel via subplot call
4284        plugin
4285            .call(
4286                "subplot",
4287                &[Value::Scalar(1.0), Value::Scalar(2.0), Value::Scalar(2.0)],
4288                &env,
4289            )
4290            .unwrap();
4291        let (gc, gw) = FIGURE_STATE.with(|f| {
4292            f.borrow()
4293                .panels
4294                .first()
4295                .map(|p| (p.grid_color, p.grid_width))
4296                .unwrap_or((None, None))
4297        });
4298        assert_eq!(gc, Some(StyleColor(0, 0, 255)));
4299        assert_eq!(gw, Some(3.0_f32));
4300    }
4301}