runmat-runtime 0.4.9

Core runtime for RunMat with builtins, BLAS/LAPACK integration, and execution APIs
Documentation
//! MATLAB-compatible `fill3` builtin.

use runmat_builtins::{Tensor, Value};
use runmat_macros::runtime_builtin;

use crate::builtins::common::spec::{
    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
    ReductionNaN, ResidencyPolicy, ShapeRequirements,
};
use crate::builtins::plotting::type_resolvers::handle_scalar_type;

use super::op_common::{apply_axes_target, split_leading_axes_handle};
use super::plotting_error;
use super::state::{render_active_plot, PlotRenderOptions};

const BUILTIN_NAME: &str = "fill3";

#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::plotting::fill3")]
pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
    name: "fill3",
    op_kind: GpuOpKind::PlotRender,
    supported_precisions: &[],
    broadcast: BroadcastSemantics::None,
    provider_hooks: &[],
    constant_strategy: ConstantStrategy::InlineLiteral,
    residency: ResidencyPolicy::GatherImmediately,
    nan_mode: ReductionNaN::Include,
    two_pass_threshold: None,
    workgroup_size: None,
    accepts_nan_mode: false,
    notes: "fill3 is a plotting sink. It lowers MATLAB fill3 argument groups to patch plots and renders through the shared patch machinery.",
};

#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::plotting::fill3")]
pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
    name: "fill3",
    shape: ShapeRequirements::Any,
    constant_strategy: ConstantStrategy::InlineLiteral,
    elementwise: None,
    reduction: None,
    emits_nan: false,
    notes: "fill3 performs rendering and terminates fusion graphs.",
};

#[runtime_builtin(
    name = "fill3",
    category = "plotting",
    summary = "Create MATLAB-compatible 3-D filled polygon patches.",
    keywords = "fill3,patch,plotting,polygon,3d",
    sink = true,
    suppress_auto_output = true,
    type_resolver(handle_scalar_type),
    builtin_path = "crate::builtins::plotting::fill3"
)]
pub fn fill3_builtin(args: Vec<Value>) -> crate::BuiltinResult<Value> {
    let (axes_target, args) = split_leading_axes_handle(args, BUILTIN_NAME)?;
    apply_axes_target(axes_target, BUILTIN_NAME)?;

    let patch_arg_groups = parse_fill3_patch_arg_groups(args)?;
    let mut plots = patch_arg_groups
        .into_iter()
        .map(|args| {
            let mut plot = super::patch::parse_patch_plot(args)?;
            plot.set_force_3d(true);
            Ok(plot)
        })
        .collect::<crate::BuiltinResult<Vec<_>>>()?;

    let mut plots_opt = Some(std::mem::take(&mut plots));
    let plot_indices_out = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
    let plot_indices_slot = std::rc::Rc::clone(&plot_indices_out);
    let figure_handle = crate::builtins::plotting::current_figure_handle();
    let render_result = render_active_plot(
        BUILTIN_NAME,
        PlotRenderOptions {
            title: "3-D Filled Polygons",
            x_label: "X",
            y_label: "Y",
            axis_equal: true,
            ..Default::default()
        },
        move |figure, axes| {
            let plots = plots_opt.take().expect("fill3 plots consumed once");
            let mut plot_indices = Vec::with_capacity(plots.len());
            for plot in plots {
                let plot_index = figure.add_patch_plot_on_axes(plot, axes);
                plot_indices.push((axes, plot_index));
            }
            figure.set_axes_view(axes, -37.5, 30.0);
            *plot_indices_slot.borrow_mut() = plot_indices;
            Ok(())
        },
    );

    let plot_indices = plot_indices_out.borrow().clone();
    if plot_indices.is_empty() {
        return render_result.map(|_| Value::Num(f64::NAN));
    }
    if let Err(err) = render_result {
        let lower = err.to_string().to_lowercase();
        if !lower.contains("plotting is unavailable") && !lower.contains("non-main thread") {
            return Err(err);
        }
    }
    let handles = plot_indices
        .into_iter()
        .map(|(axes, plot_index)| {
            crate::builtins::plotting::state::register_patch_handle(figure_handle, axes, plot_index)
        })
        .collect::<Vec<_>>();
    Ok(handles_value(handles))
}

fn parse_fill3_patch_arg_groups(args: Vec<Value>) -> crate::BuiltinResult<Vec<Vec<Value>>> {
    if args.len() < 4 {
        return Err(plotting_error(
            BUILTIN_NAME,
            "fill3: expected X, Y, Z, and C data",
        ));
    }

    let property_start = find_trailing_property_start(&args);
    let (positional, properties) = args.split_at(property_start);
    if positional.len() < 4 {
        return Err(plotting_error(
            BUILTIN_NAME,
            "fill3: expected X, Y, Z, and C data",
        ));
    }

    let mut groups = Vec::new();
    let mut offset = 0;
    while offset < positional.len() {
        let remaining = positional.len() - offset;
        if remaining < 4 {
            return Err(plotting_error(
                BUILTIN_NAME,
                "fill3: each polygon group must include X, Y, Z, and C data",
            ));
        }

        let mut group = vec![
            Value::String("XData".into()),
            positional[offset].clone(),
            Value::String("YData".into()),
            positional[offset + 1].clone(),
            Value::String("ZData".into()),
            positional[offset + 2].clone(),
            Value::String("FaceColor".into()),
            positional[offset + 3].clone(),
        ];
        offset += 4;

        group.extend_from_slice(properties);
        groups.push(group);
    }

    Ok(groups)
}

fn find_trailing_property_start(args: &[Value]) -> usize {
    for idx in (4..=args.len()).step_by(4) {
        if args[idx..].iter().step_by(2).all(is_string_like) {
            return idx;
        }
    }
    args.len()
}

fn is_string_like(value: &Value) -> bool {
    matches!(value, Value::CharArray(_) | Value::String(_))
}

fn handles_value(handles: Vec<f64>) -> Value {
    if handles.len() == 1 {
        Value::Num(handles[0])
    } else {
        let len = handles.len();
        Value::Tensor(Tensor::new_2d(handles, 1, len).expect("valid handle vector"))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::builtins::plotting::get::get_builtin;
    use crate::builtins::plotting::tests::{ensure_plot_test_env, lock_plot_registry};
    use crate::builtins::plotting::{clear_figure, clone_figure, current_figure_handle};
    use glam::Vec4;
    use runmat_builtins::NumericDType;
    use runmat_plot::plots::figure::PlotElement;
    use runmat_plot::plots::{PatchEdgeColorMode, PatchFaceColorMode};

    fn setup() -> crate::builtins::plotting::state::PlotTestLockGuard {
        let guard = lock_plot_registry();
        ensure_plot_test_env();
        super::super::state::reset_hold_state_for_run();
        let _ = clear_figure(None);
        guard
    }

    fn tensor(rows: usize, cols: usize, data: &[f64]) -> Value {
        Value::Tensor(Tensor {
            rows,
            cols,
            shape: vec![rows, cols],
            data: data.to_vec(),
            dtype: NumericDType::F64,
        })
    }

    #[test]
    fn fill3_positional_color_string_builds_patch() {
        let _guard = setup();
        let handle = fill3_builtin(vec![
            tensor(3, 1, &[0.0, 1.0, 0.0]),
            tensor(3, 1, &[0.0, 0.0, 1.0]),
            tensor(3, 1, &[0.0, 0.5, 0.0]),
            Value::String("r".into()),
        ])
        .unwrap();

        assert!(matches!(handle, Value::Num(_)));
        let figure = clone_figure(current_figure_handle()).unwrap();
        assert_eq!(figure.len(), 1);
        let meta = figure.axes_metadata(0).unwrap();
        assert_eq!(meta.view_azimuth_deg, Some(-37.5));
        assert_eq!(meta.view_elevation_deg, Some(30.0));
        let Some(PlotElement::Patch(patch)) = figure.plots().next() else {
            panic!("expected patch plot");
        };
        assert!(patch.force_3d());
        assert_eq!(patch.faces().len(), 1);
        assert_eq!(patch.vertices()[1].z, 0.5);
        assert_eq!(patch.face_color(), Vec4::new(1.0, 0.0, 0.0, 1.0));
    }

    #[test]
    fn fill3_multiple_polygon_groups_return_handle_vector() {
        let _guard = setup();
        let handles = fill3_builtin(vec![
            tensor(3, 1, &[0.0, 1.0, 0.0]),
            tensor(3, 1, &[0.0, 0.0, 1.0]),
            tensor(3, 1, &[0.0, 0.0, 0.0]),
            Value::String("g".into()),
            tensor(4, 1, &[2.0, 3.0, 3.0, 2.0]),
            tensor(4, 1, &[0.0, 0.0, 1.0, 1.0]),
            tensor(4, 1, &[1.0, 1.0, 1.0, 1.0]),
            tensor(1, 3, &[0.0, 0.0, 1.0]),
        ])
        .unwrap();

        let Value::Tensor(handles) = handles else {
            panic!("expected vector of handles");
        };
        assert_eq!(handles.data.len(), 2);
        let figure = clone_figure(current_figure_handle()).unwrap();
        assert_eq!(figure.len(), 2);
        let patches = figure.plots().collect::<Vec<_>>();
        let PlotElement::Patch(first) = patches[0] else {
            panic!("expected first patch plot");
        };
        assert_eq!(first.vertices()[0].z, 0.0);
        assert_eq!(first.face_color(), Vec4::new(0.0, 1.0, 0.0, 1.0));
        let PlotElement::Patch(second) = patches[1] else {
            panic!("expected second patch plot");
        };
        assert_eq!(second.vertices()[0].z, 1.0);
        assert_eq!(second.face_color(), Vec4::new(0.0, 0.0, 1.0, 1.0));
    }

    #[test]
    fn fill3_trailing_patch_properties_apply_to_each_group() {
        let _guard = setup();
        let handle = fill3_builtin(vec![
            tensor(3, 1, &[0.0, 1.0, 0.0]),
            tensor(3, 1, &[0.0, 0.0, 1.0]),
            tensor(3, 1, &[0.25, 0.5, 0.75]),
            tensor(1, 3, &[0.25, 0.5, 0.75]),
            Value::String("EdgeColor".into()),
            Value::String("none".into()),
            Value::String("FaceAlpha".into()),
            Value::Num(0.5),
        ])
        .unwrap();

        let Value::Num(handle) = handle else {
            panic!("expected scalar handle");
        };
        assert_eq!(
            get_builtin(vec![Value::Num(handle), Value::String("Type".into())]).unwrap(),
            Value::String("patch".into())
        );
        let figure = clone_figure(current_figure_handle()).unwrap();
        let Some(PlotElement::Patch(patch)) = figure.plots().next() else {
            panic!("expected patch plot");
        };
        assert_eq!(patch.face_color(), Vec4::new(0.25, 0.5, 0.75, 1.0));
        assert_eq!(patch.edge_color_mode(), PatchEdgeColorMode::None);
        assert_eq!(patch.face_color_mode(), PatchFaceColorMode::Color);
        assert_eq!(patch.face_alpha(), 0.5);
    }

    #[test]
    fn fill3_unknown_trailing_string_keys_are_property_pairs() {
        let args = vec![
            tensor(3, 1, &[0.0, 1.0, 0.0]),
            tensor(3, 1, &[0.0, 0.0, 1.0]),
            tensor(3, 1, &[0.25, 0.5, 0.75]),
            tensor(1, 3, &[0.25, 0.5, 0.75]),
            Value::String("FuturePatchProperty".into()),
            Value::Num(1.0),
            Value::CharArray(runmat_builtins::CharArray::new_row("AnotherFutureProperty")),
            Value::String("value".into()),
        ];

        assert_eq!(find_trailing_property_start(&args), 4);
    }

    #[test]
    fn fill3_string_color_before_dangling_property_reports_pair_error() {
        let err = fill3_builtin(vec![
            tensor(3, 1, &[0.0, 1.0, 0.0]),
            tensor(3, 1, &[0.0, 0.0, 1.0]),
            tensor(3, 1, &[0.25, 0.5, 0.75]),
            Value::String("r".into()),
            Value::String("EdgeColor".into()),
        ])
        .unwrap_err()
        .to_string();

        assert!(err.contains("property/value arguments must come in pairs"));
    }
}