use glam::{Vec2, Vec3, Vec4};
use log::warn;
use runmat_accelerate_api::{self, GpuTensorHandle, ProviderPrecision};
use runmat_builtins::{Tensor, Value};
use runmat_macros::runtime_builtin;
use runmat_plot::core::Vertex;
use runmat_plot::gpu::contour_fill;
use runmat_plot::gpu::ScalarType;
use runmat_plot::plots::contour::contour_bounds;
use runmat_plot::plots::{ColorMap, ContourFillPlot, ContourPlot};
use super::common::{numeric_vector, tensor_to_surface_grid, value_as_f64, SurfaceDataInput};
use super::op_common::surface_inputs::{extract_meshgrid_axes_from_xy_matrices, AxisSource};
use super::style::{parse_color_value, value_as_string, LineStyleParseOptions};
use crate::builtins::common::spec::{
BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
ReductionNaN, ResidencyPolicy, ShapeRequirements,
};
use super::gpu_helpers::axis_bounds;
use super::plotting_error;
use super::state::{render_active_plot, PlotRenderOptions};
use super::surf::build_color_lut;
use crate::builtins::plotting::type_resolvers::handle_scalar_type;
use crate::BuiltinResult;
const BUILTIN_NAME: &str = "contour";
const DEFAULT_LEVELS: usize = 10;
#[derive(Clone, Debug, Default)]
pub(crate) enum ContourLevelSpec {
#[default]
Auto,
Count(usize),
Values(Vec<f64>),
Step(f32),
}
impl ContourLevelSpec {
fn resolve(&self, name: &'static str, min_z: f32, max_z: f32) -> BuiltinResult<Vec<f32>> {
match self {
ContourLevelSpec::Auto => Ok(evenly_spaced_levels(DEFAULT_LEVELS, min_z, max_z)),
ContourLevelSpec::Count(count) => {
if *count == 0 {
Err(plotting_error(
name,
format!("{name}: level count must be positive"),
))
} else {
Ok(evenly_spaced_levels(*count, min_z, max_z))
}
}
ContourLevelSpec::Values(values) => {
if values.is_empty() {
return Err(plotting_error(
name,
format!("{name}: level vector must contain at least one value"),
));
}
let mut last = None;
for &value in values {
if !value.is_finite() {
return Err(plotting_error(
name,
format!("{name}: level values must be finite"),
));
}
if let Some(prev) = last {
if value <= prev {
return Err(plotting_error(
name,
format!("{name}: level values must be strictly increasing"),
));
}
}
last = Some(value);
}
Ok(values.iter().map(|&v| v as f32).collect())
}
ContourLevelSpec::Step(step) => {
if *step <= 0.0 || !step.is_finite() {
return Err(plotting_error(
name,
format!("{name}: LevelStep must be a positive, finite number"),
));
}
let mut levels = Vec::new();
let mut value = min_z;
let max = if max_z <= min_z { min_z + 1.0 } else { max_z };
while value <= max {
levels.push(value);
value += *step;
}
if levels.is_empty() {
levels.push(min_z);
} else if *levels.last().unwrap() < max {
levels.push(max);
}
Ok(levels)
}
}
}
}
#[derive(Clone, Debug, Default)]
pub(crate) enum ContourLineColor {
#[default]
Auto,
Color(Vec4),
None,
}
#[derive(Clone)]
pub(crate) struct ContourArgs {
pub name: &'static str,
pub x_axis: Vec<f64>,
pub y_axis: Vec<f64>,
pub z_input: SurfaceDataInput,
pub level_spec: ContourLevelSpec,
pub line_color: ContourLineColor,
pub line_width: f32,
}
pub(crate) fn parse_contour_args(
name: &'static str,
first: Value,
rest: Vec<Value>,
) -> BuiltinResult<ContourArgs> {
if rest.is_empty() {
return from_implicit_args(name, first, None, &[]);
}
if is_option_token(&rest[0]) {
return from_implicit_args(name, first, None, &rest);
}
match rest.len() {
1 => from_implicit_args(name, first, Some(rest[0].clone()), &rest[1..]),
2 => from_explicit_args(
name,
first,
rest[0].clone(),
rest[1].clone(),
None,
&rest[2..],
),
3 => from_explicit_args(
name,
first,
rest[0].clone(),
rest[1].clone(),
Some(rest[2].clone()),
&rest[3..],
),
_ => from_explicit_args(
name,
first,
rest[0].clone(),
rest[1].clone(),
Some(rest[2].clone()),
&rest[3..],
),
}
}
fn from_implicit_args(
name: &'static str,
z_value: Value,
level_value: Option<Value>,
options: &[Value],
) -> BuiltinResult<ContourArgs> {
let z_input = SurfaceDataInput::from_value(z_value, name)?;
let (rows, cols) = z_input.grid_shape(name)?;
if rows < 2 || cols < 2 {
return Err(plotting_error(
name,
format!("{name}: Z must be at least 2x2"),
));
}
let x_axis = implicit_axis(rows);
let y_axis = implicit_axis(cols);
let level_spec = match level_value {
Some(value) => parse_level_spec(value, name)?,
None => ContourLevelSpec::Auto,
};
let mut args = ContourArgs {
name,
x_axis,
y_axis,
z_input,
level_spec,
line_color: ContourLineColor::default(),
line_width: 1.0,
};
apply_contour_options(&mut args, options)?;
Ok(args)
}
fn from_explicit_args(
name: &'static str,
x_value: Value,
y_value: Value,
z_value: Value,
level_value: Option<Value>,
options: &[Value],
) -> BuiltinResult<ContourArgs> {
let x_tensor = match &x_value {
Value::GpuTensor(handle) => super::common::gather_tensor_from_gpu(handle.clone(), name)?,
_ => {
Tensor::try_from(&x_value).map_err(|e| plotting_error(name, format!("{name}: {e}")))?
}
};
let y_tensor = match &y_value {
Value::GpuTensor(handle) => super::common::gather_tensor_from_gpu(handle.clone(), name)?,
_ => {
Tensor::try_from(&y_value).map_err(|e| plotting_error(name, format!("{name}: {e}")))?
}
};
let z_input = SurfaceDataInput::from_value(z_value, name)?;
let (rows, cols) = z_input.grid_shape(name)?;
let (x_axis, y_axis) = if x_tensor.rows == rows
&& x_tensor.cols == cols
&& y_tensor.rows == rows
&& y_tensor.cols == cols
{
extract_meshgrid_axes_from_xy_matrices(&x_tensor, &y_tensor, rows, cols, name)?
} else {
let x_axis = numeric_vector(x_tensor);
let y_axis = numeric_vector(y_tensor);
if x_axis.len() < 2 || y_axis.len() < 2 {
return Err(plotting_error(
name,
format!("{name}: axis vectors must contain at least two elements"),
));
}
(x_axis, y_axis)
};
if rows != x_axis.len() || cols != y_axis.len() {
return Err(plotting_error(
name,
format!(
"{name}: Z must be {}x{} to match axis vectors",
x_axis.len(),
y_axis.len()
),
));
}
let level_spec = match level_value {
Some(value) => parse_level_spec(value, name)?,
None => ContourLevelSpec::Auto,
};
let mut args = ContourArgs {
name,
x_axis,
y_axis,
z_input,
level_spec,
line_color: ContourLineColor::default(),
line_width: 1.0,
};
apply_contour_options(&mut args, options)?;
Ok(args)
}
pub(crate) fn default_level_count() -> usize {
DEFAULT_LEVELS
}
#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::plotting::contour")]
pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
name: "contour",
op_kind: GpuOpKind::PlotRender,
supported_precisions: &[],
broadcast: BroadcastSemantics::None,
provider_hooks: &[],
constant_strategy: ConstantStrategy::InlineLiteral,
residency: ResidencyPolicy::InheritInputs,
nan_mode: ReductionNaN::Include,
two_pass_threshold: None,
workgroup_size: None,
accepts_nan_mode: false,
notes: "Contour rendering terminates fusion graphs; gpuArray inputs may remain on device when shared plotting context is installed.",
};
#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::plotting::contour")]
pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
name: "contour",
shape: ShapeRequirements::Any,
constant_strategy: ConstantStrategy::InlineLiteral,
elementwise: None,
reduction: None,
emits_nan: false,
notes: "contour performs I/O and stops fusion.",
};
#[runtime_builtin(
name = "contour",
category = "plotting",
summary = "Render MATLAB-compatible contour plots.",
keywords = "contour,plotting,isolines",
sink = true,
suppress_auto_output = true,
type_resolver(handle_scalar_type),
builtin_path = "crate::builtins::plotting::contour"
)]
pub fn contour_builtin(first: Value, rest: Vec<Value>) -> crate::BuiltinResult<f64> {
let mut call = Some(ContourCall::parse(BUILTIN_NAME, first, rest)?);
let opts = PlotRenderOptions {
title: "Contour Plot",
x_label: "X",
y_label: "Y",
axis_equal: true,
..Default::default()
};
let plot_index_out = std::rc::Rc::new(std::cell::RefCell::new(None));
let plot_index_slot = std::rc::Rc::clone(&plot_index_out);
let figure_handle = crate::builtins::plotting::current_figure_handle();
let render_result = render_active_plot(BUILTIN_NAME, opts, move |figure, axes| {
let current = call.take().expect("contour call consumed once");
let before = figure.plots().count();
current.render(figure, axes)?;
let after = figure.plots().count();
if after > before {
*plot_index_slot.borrow_mut() = Some((axes, after - 1));
}
Ok(())
});
let Some((axes, plot_index)) = *plot_index_out.borrow() else {
return render_result.map(|_| f64::NAN);
};
let handle =
crate::builtins::plotting::state::register_contour_handle(figure_handle, axes, plot_index);
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 Ok(handle);
}
return Err(err);
}
Ok(handle)
}
struct ContourCall {
args: ContourArgs,
color_map: ColorMap,
base_z: f32,
}
impl ContourCall {
fn parse(name: &'static str, first: Value, rest: Vec<Value>) -> BuiltinResult<Self> {
let args = parse_contour_args(name, first, rest)?;
Ok(Self {
args,
color_map: ColorMap::Parula,
base_z: 0.0,
})
}
fn render(self, figure: &mut runmat_plot::plots::Figure, axes: usize) -> BuiltinResult<()> {
let ContourCall {
args,
color_map,
base_z,
} = self;
let ContourArgs {
name,
x_axis,
y_axis,
z_input,
level_spec,
line_color,
line_width,
} = args;
if matches!(line_color, ContourLineColor::None) {
return Ok(());
}
if let SurfaceDataInput::Gpu(handle) = &z_input {
match build_contour_gpu_plot(
name,
&x_axis,
&y_axis,
handle,
color_map,
base_z,
&level_spec,
&line_color,
) {
Ok(contour) => {
figure.add_contour_plot_on_axes(contour.with_line_width(line_width), axes);
return Ok(());
}
Err(err) => {
warn!("{name} GPU path unavailable: {err}");
}
}
}
let grid =
tensor_to_surface_grid(z_input.into_tensor(name)?, x_axis.len(), y_axis.len(), name)?;
if let ContourLineColor::None = line_color {
return Ok(());
}
let contour = build_contour_plot(
name,
&x_axis,
&y_axis,
&grid,
color_map,
base_z,
&level_spec,
&line_color,
)?
.with_line_width(line_width);
figure.add_contour_plot_on_axes(contour, axes);
Ok(())
}
}
fn parse_level_spec(value: Value, context: &str) -> BuiltinResult<ContourLevelSpec> {
match value {
Value::Tensor(tensor) => parse_tensor_levels(tensor, context),
other => {
if let Some(count) = value_as_f64(&other) {
parse_scalar_level_count(count, context)
} else {
let tensor = Tensor::try_from(&other)
.map_err(|e| plotting_error(context, format!("{context}: {e}")))?;
parse_tensor_levels(tensor, context)
}
}
}
}
fn parse_scalar_level_count(value: f64, context: &str) -> BuiltinResult<ContourLevelSpec> {
if !value.is_finite() {
return Err(plotting_error(
context,
format!("{context}: level count must be finite"),
));
}
if value < 1.0 {
return Err(plotting_error(
context,
format!("{context}: level count must be positive"),
));
}
let rounded = value.round();
if rounded < 1.0 {
return Err(plotting_error(
context,
format!("{context}: level count must be positive"),
));
}
Ok(ContourLevelSpec::Count(rounded as usize))
}
fn parse_tensor_levels(tensor: Tensor, context: &str) -> BuiltinResult<ContourLevelSpec> {
if tensor.data.is_empty() {
return Err(plotting_error(
context,
format!("{context}: level vector must be non-empty"),
));
}
if tensor.data.len() == 1 {
return parse_scalar_level_count(tensor.data[0], context);
}
if tensor.data.iter().all(|value| *value == tensor.data[0]) {
return Ok(ContourLevelSpec::Values(vec![tensor.data[0]]));
}
for pair in tensor.data.windows(2) {
if pair[1] <= pair[0] {
return Err(plotting_error(
context,
format!("{context}: level values must be strictly increasing"),
));
}
}
Ok(ContourLevelSpec::Values(tensor.data))
}
fn apply_contour_options(args: &mut ContourArgs, options: &[Value]) -> BuiltinResult<()> {
if options.is_empty() {
return Ok(());
}
if !options.len().is_multiple_of(2) {
return Err(plotting_error(
args.name,
format!("{}: name-value arguments must come in pairs", args.name),
));
}
let opts = LineStyleParseOptions::generic(args.name);
let mut level_override_seen = !matches!(args.level_spec, ContourLevelSpec::Auto);
let mut manual_requested = false;
for pair in options.chunks_exact(2) {
let Some(key) = value_as_string(&pair[0]) else {
return Err(plotting_error(
args.name,
format!("{}: option names must be char arrays or strings", args.name),
));
};
let lower = key.trim().to_ascii_lowercase();
match lower.as_str() {
"levellist" | "levels" => {
args.level_spec = parse_level_spec(pair[1].clone(), args.name)?;
level_override_seen = true;
}
"levelstep" => {
let step = value_as_f64(&pair[1]).ok_or_else(|| {
plotting_error(
args.name,
format!("{}: LevelStep must be numeric", args.name),
)
})?;
if !step.is_finite() || step <= 0.0 {
return Err(plotting_error(
args.name,
format!("{}: LevelStep must be a positive, finite number", args.name),
));
}
args.level_spec = ContourLevelSpec::Step(step as f32);
level_override_seen = true;
}
"linecolor" => {
args.line_color = parse_line_color_option(&opts, &pair[1])?;
}
"linewidth" => {
let width = value_as_f64(&pair[1]).ok_or_else(|| {
plotting_error(
args.name,
format!("{}: LineWidth must be numeric", args.name),
)
})?;
if !width.is_finite() || width <= 0.0 {
return Err(plotting_error(
args.name,
format!("{}: LineWidth must be a positive, finite number", args.name),
));
}
args.line_width = width as f32;
}
"levellistmode" => {
let Some(mode) = value_as_string(&pair[1]) else {
return Err(plotting_error(
args.name,
format!(
"{}: LevelListMode must be the string 'auto' or 'manual'",
args.name
),
));
};
let normalized = mode.trim().to_ascii_lowercase();
match normalized.as_str() {
"auto" => {
args.level_spec = ContourLevelSpec::Auto;
level_override_seen = false;
manual_requested = false;
}
"manual" => {
manual_requested = true;
}
other => {
return Err(plotting_error(
args.name,
format!("{}: unsupported LevelListMode `{other}` (expected 'auto' or 'manual')", args.name),
));
}
}
}
other => {
return Err(plotting_error(
args.name,
format!("{}: unsupported option `{other}`", args.name),
));
}
}
}
if manual_requested && !level_override_seen {
return Err(plotting_error(
args.name,
format!(
"{}: LevelListMode 'manual' requires LevelList or LevelStep in the same call",
args.name
),
));
}
Ok(())
}
fn parse_line_color_option(
opts: &LineStyleParseOptions,
value: &Value,
) -> BuiltinResult<ContourLineColor> {
if let Some(text) = value_as_string(value) {
let lower = text.trim().to_ascii_lowercase();
return match lower.as_str() {
"auto" | "flat" => Ok(ContourLineColor::Auto),
"none" => Ok(ContourLineColor::None),
_ => {
let color = parse_color_value(opts, value)?;
Ok(ContourLineColor::Color(color))
}
};
}
let color = parse_color_value(opts, value)?;
Ok(ContourLineColor::Color(color))
}
fn is_option_token(value: &Value) -> bool {
value_as_string(value)
.map(|s| is_contour_option_name(&s))
.unwrap_or(false)
}
fn is_contour_option_name(token: &str) -> bool {
matches!(
token.trim().to_ascii_lowercase().as_str(),
"levellist" | "levels" | "levelstep" | "linecolor" | "linewidth" | "levellistmode"
)
}
pub(crate) fn build_contour_gpu_plot(
name: &'static str,
x_axis: &[f64],
y_axis: &[f64],
z: &GpuTensorHandle,
color_map: ColorMap,
base_z: f32,
level_spec: &ContourLevelSpec,
line_color: &ContourLineColor,
) -> BuiltinResult<ContourPlot> {
build_contour_gpu_plot_with_axes(
name,
&AxisSource::Host(x_axis.to_vec()),
&AxisSource::Host(y_axis.to_vec()),
z,
color_map,
base_z,
level_spec,
line_color,
)
}
pub(crate) fn build_contour_gpu_plot_with_axes(
name: &'static str,
x_axis: &AxisSource,
y_axis: &AxisSource,
z: &GpuTensorHandle,
color_map: ColorMap,
base_z: f32,
level_spec: &ContourLevelSpec,
line_color: &ContourLineColor,
) -> BuiltinResult<ContourPlot> {
let context = super::gpu_helpers::ensure_shared_wgpu_context(name)?;
let z_ref = runmat_accelerate_api::export_wgpu_buffer(z)
.ok_or_else(|| plotting_error(name, format!("{name}: unable to export GPU Z data")))?;
let (min_z, max_z) = axis_bounds(z, name)?;
let levels = level_spec.resolve(name, min_z, max_z)?;
if levels.is_empty() {
return Err(plotting_error(
name,
format!("{name}: no contour levels available"),
));
}
let scalar = ScalarType::from_is_f64(z_ref.precision == ProviderPrecision::F64);
let color_table = match line_color {
ContourLineColor::Auto => build_color_lut(color_map, 512, 1.0),
ContourLineColor::Color(color) => vec![color.to_array()],
ContourLineColor::None => vec![[0.0, 0.0, 0.0, 0.0]],
};
let (min_x, max_x) = match x_axis {
AxisSource::Host(v) => (
v.iter().fold(f32::INFINITY, |acc, &v| acc.min(v as f32)),
v.iter()
.fold(f32::NEG_INFINITY, |acc, &v| acc.max(v as f32)),
),
AxisSource::Gpu(h) => axis_bounds(h, name)?,
};
let (min_y, max_y) = match y_axis {
AxisSource::Host(v) => (
v.iter().fold(f32::INFINITY, |acc, &v| acc.min(v as f32)),
v.iter()
.fold(f32::NEG_INFINITY, |acc, &v| acc.max(v as f32)),
),
AxisSource::Gpu(h) => axis_bounds(h, name)?,
};
let bounds = contour_bounds(min_x, max_x, min_y, max_y, base_z);
let mut x_f32_storage: Vec<f32> = Vec::new();
let x_axis_data = match x_axis {
AxisSource::Gpu(h) => {
let exported = runmat_accelerate_api::export_wgpu_buffer(h).ok_or_else(|| {
plotting_error(name, format!("{name}: unable to export GPU X axis buffer"))
})?;
if exported.len as usize != x_axis.len() {
return Err(plotting_error(
name,
format!("{name}: X axis length mismatch"),
));
}
if exported.precision != z_ref.precision {
return Err(plotting_error(
name,
format!("{name}: X axis precision must match Z precision"),
));
}
runmat_plot::gpu::axis::AxisData::Buffer(exported.buffer.clone())
}
AxisSource::Host(v) => {
if scalar == ScalarType::F32 {
x_f32_storage = v.iter().map(|&val| val as f32).collect();
runmat_plot::gpu::axis::AxisData::F32(x_f32_storage.as_slice())
} else {
runmat_plot::gpu::axis::AxisData::F64(v.as_slice())
}
}
};
let mut y_f32_storage: Vec<f32> = Vec::new();
let y_axis_data = match y_axis {
AxisSource::Gpu(h) => {
let exported = runmat_accelerate_api::export_wgpu_buffer(h).ok_or_else(|| {
plotting_error(name, format!("{name}: unable to export GPU Y axis buffer"))
})?;
if exported.len as usize != y_axis.len() {
return Err(plotting_error(
name,
format!("{name}: Y axis length mismatch"),
));
}
if exported.precision != z_ref.precision {
return Err(plotting_error(
name,
format!("{name}: Y axis precision must match Z precision"),
));
}
runmat_plot::gpu::axis::AxisData::Buffer(exported.buffer.clone())
}
AxisSource::Host(v) => {
if scalar == ScalarType::F32 {
y_f32_storage = v.iter().map(|&val| val as f32).collect();
runmat_plot::gpu::axis::AxisData::F32(y_f32_storage.as_slice())
} else {
runmat_plot::gpu::axis::AxisData::F64(v.as_slice())
}
}
};
let _keep_alive = (&x_f32_storage, &y_f32_storage);
let inputs = runmat_plot::gpu::contour::ContourGpuInputs {
x_axis: x_axis_data,
y_axis: y_axis_data,
z_buffer: z_ref.buffer.clone(),
color_table: &color_table,
level_values: &levels,
x_len: x_axis.len() as u32,
y_len: y_axis.len() as u32,
scalar,
};
let params = runmat_plot::gpu::contour::ContourGpuParams {
min_z,
max_z,
base_z,
level_count: levels.len() as u32,
};
let gpu_vertices = runmat_plot::gpu::contour::pack_contour_vertices(
&context.device,
&context.queue,
&inputs,
¶ms,
)
.map_err(|e| plotting_error(name, format!("{name}: failed to build GPU vertices: {e}")))?;
let vertex_count = gpu_vertices.vertex_count;
Ok(
ContourPlot::from_gpu_buffer(gpu_vertices, vertex_count, base_z, bounds)
.with_label("Contours"),
)
}
pub(crate) fn build_contour_plot(
name: &'static str,
x_axis: &[f64],
y_axis: &[f64],
grid: &[Vec<f64>],
color_map: ColorMap,
base_z: f32,
level_spec: &ContourLevelSpec,
line_color: &ContourLineColor,
) -> BuiltinResult<ContourPlot> {
let (min_z, max_z) = grid_extents(name, grid)?;
let levels = level_spec.resolve(name, min_z, max_z)?;
let color_table = match line_color {
ContourLineColor::Auto => build_color_lut(color_map, 512, 1.0),
ContourLineColor::Color(color) => vec![color.to_array()],
ContourLineColor::None => vec![[0.0, 0.0, 0.0, 0.0]],
};
let mut vertices = Vec::new();
for (level_idx, level) in levels.iter().enumerate() {
let color = match line_color {
ContourLineColor::Auto => sample_color(&color_table, level_idx, levels.len()),
ContourLineColor::Color(color) => *color,
ContourLineColor::None => Vec4::new(0.0, 0.0, 0.0, 0.0),
};
march_cells(x_axis, y_axis, grid, *level, color, base_z, &mut vertices);
}
let bounds = contour_bounds(
x_axis
.iter()
.fold(f32::INFINITY, |acc, &v| acc.min(v as f32)),
x_axis
.iter()
.fold(f32::NEG_INFINITY, |acc, &v| acc.max(v as f32)),
y_axis
.iter()
.fold(f32::INFINITY, |acc, &v| acc.min(v as f32)),
y_axis
.iter()
.fold(f32::NEG_INFINITY, |acc, &v| acc.max(v as f32)),
base_z,
);
Ok(ContourPlot::from_vertices(vertices, base_z, bounds).with_label("Contours"))
}
pub(crate) fn build_contour_fill_gpu_plot(
name: &'static str,
x_axis: &[f64],
y_axis: &[f64],
z: &GpuTensorHandle,
color_map: ColorMap,
base_z: f32,
level_spec: &ContourLevelSpec,
) -> BuiltinResult<ContourFillPlot> {
let context = super::gpu_helpers::ensure_shared_wgpu_context(name)?;
let z_ref = runmat_accelerate_api::export_wgpu_buffer(z)
.ok_or_else(|| plotting_error(name, format!("{name}: unable to export GPU Z data")))?;
let (min_z, max_z) = axis_bounds(z, name)?;
let levels = ensure_fill_levels(name, level_spec, min_z, max_z)?;
let palette = build_color_lut(color_map, palette_size(&levels), 0.95);
let scalar = ScalarType::from_is_f64(z_ref.precision == ProviderPrecision::F64);
let x_f32 = if scalar == ScalarType::F32 {
Some(x_axis.iter().map(|&v| v as f32).collect::<Vec<f32>>())
} else {
None
};
let y_f32 = if scalar == ScalarType::F32 {
Some(y_axis.iter().map(|&v| v as f32).collect::<Vec<f32>>())
} else {
None
};
let (min_x, max_x) = (
x_axis
.iter()
.fold(f32::INFINITY, |acc, &v| acc.min(v as f32)),
x_axis
.iter()
.fold(f32::NEG_INFINITY, |acc, &v| acc.max(v as f32)),
);
let (min_y, max_y) = (
y_axis
.iter()
.fold(f32::INFINITY, |acc, &v| acc.min(v as f32)),
y_axis
.iter()
.fold(f32::NEG_INFINITY, |acc, &v| acc.max(v as f32)),
);
let bounds = contour_bounds(min_x, max_x, min_y, max_y, base_z);
let x_axis_data = if let Some(values) = x_f32.as_ref() {
runmat_plot::gpu::axis::AxisData::F32(values.as_slice())
} else {
runmat_plot::gpu::axis::AxisData::F64(x_axis)
};
let y_axis_data = if let Some(values) = y_f32.as_ref() {
runmat_plot::gpu::axis::AxisData::F32(values.as_slice())
} else {
runmat_plot::gpu::axis::AxisData::F64(y_axis)
};
let inputs = contour_fill::ContourFillGpuInputs {
x_axis: x_axis_data,
y_axis: y_axis_data,
z_buffer: z_ref.buffer.clone(),
color_table: &palette,
level_values: &levels,
x_len: x_axis.len() as u32,
y_len: y_axis.len() as u32,
scalar,
};
let params = contour_fill::ContourFillGpuParams { base_z, alpha: 1.0 };
let gpu_vertices =
contour_fill::pack_contour_fill_vertices(&context.device, &context.queue, &inputs, ¶ms)
.map_err(|e| {
plotting_error(name, format!("{name}: failed to build GPU vertices: {e}"))
})?;
let vertex_count = gpu_vertices.vertex_count;
Ok(
ContourFillPlot::from_gpu_buffer(gpu_vertices, vertex_count, bounds)
.with_label("Filled Contours"),
)
}
pub(crate) fn build_contour_fill_plot(
name: &'static str,
x_axis: &[f64],
y_axis: &[f64],
grid: &[Vec<f64>],
color_map: ColorMap,
base_z: f32,
level_spec: &ContourLevelSpec,
) -> BuiltinResult<ContourFillPlot> {
let (min_z, max_z) = grid_extents(name, grid)?;
let levels = ensure_fill_levels(name, level_spec, min_z, max_z)?;
let palette_raw = build_color_lut(color_map, palette_size(&levels), 0.95);
let palette: Vec<Vec4> = palette_raw.iter().map(|c| Vec4::from_array(*c)).collect();
let nx = x_axis.len();
let ny = y_axis.len();
if nx < 2 || ny < 2 {
return Err(plotting_error(
name,
format!("{name}: axis vectors must contain at least two elements"),
));
}
let band_count = levels.len() - 1;
let mut vertices = Vec::with_capacity((nx - 1) * (ny - 1) * band_count * 12);
for col in 0..ny - 1 {
for row in 0..nx - 1 {
let p0 = ScalarPoint2 {
pos: Vec2::new(x_axis[row] as f32, y_axis[col] as f32),
value: grid[row][col] as f32,
};
let p1 = ScalarPoint2 {
pos: Vec2::new(x_axis[row + 1] as f32, y_axis[col] as f32),
value: grid[row + 1][col] as f32,
};
let p2 = ScalarPoint2 {
pos: Vec2::new(x_axis[row + 1] as f32, y_axis[col + 1] as f32),
value: grid[row + 1][col + 1] as f32,
};
let p3 = ScalarPoint2 {
pos: Vec2::new(x_axis[row] as f32, y_axis[col + 1] as f32),
value: grid[row][col + 1] as f32,
};
for band_idx in 0..band_count {
let lo = levels[band_idx];
let hi = levels[band_idx + 1];
let include_hi = band_idx + 1 == band_count;
let color = palette[band_idx.min(palette.len() - 1)];
let cell_tris = [[p0, p1, p2], [p0, p2, p3]];
for tri in cell_tris {
triangulate_band_triangle(
tri,
lo,
hi,
include_hi,
color,
base_z,
&mut vertices,
);
}
}
}
}
let bounds = contour_bounds(
x_axis
.iter()
.fold(f32::INFINITY, |acc, &v| acc.min(v as f32)),
x_axis
.iter()
.fold(f32::NEG_INFINITY, |acc, &v| acc.max(v as f32)),
y_axis
.iter()
.fold(f32::INFINITY, |acc, &v| acc.min(v as f32)),
y_axis
.iter()
.fold(f32::NEG_INFINITY, |acc, &v| acc.max(v as f32)),
base_z,
);
Ok(ContourFillPlot::from_vertices(vertices, bounds).with_label("Filled Contours"))
}
fn grid_extents(name: &'static str, grid: &[Vec<f64>]) -> BuiltinResult<(f32, f32)> {
let mut min_v = f64::INFINITY;
let mut max_v = f64::NEG_INFINITY;
for row in grid {
for &value in row {
if value.is_finite() {
min_v = min_v.min(value);
max_v = max_v.max(value);
}
}
}
if !min_v.is_finite() || !max_v.is_finite() {
Err(plotting_error(
name,
format!("{name}: unable to determine data range"),
))
} else {
Ok((min_v as f32, max_v as f32))
}
}
fn evenly_spaced_levels(count: usize, min_z: f32, max_z: f32) -> Vec<f32> {
if count <= 1 || (max_z - min_z).abs() < f32::EPSILON {
return vec![min_z];
}
let mut levels = Vec::with_capacity(count);
let step = (max_z - min_z) / (count - 1) as f32;
for i in 0..count {
levels.push(min_z + step * i as f32);
}
levels
}
fn sample_color(table: &[[f32; 4]], idx: usize, total_levels: usize) -> Vec4 {
if table.is_empty() {
return Vec4::ONE;
}
if total_levels <= 1 {
let c = table[0];
return Vec4::from_array(c);
}
let t = idx as f32 / (total_levels as f32 - 1.0);
let scaled = t * (table.len() as f32 - 1.0);
let lower = scaled.floor() as usize;
let upper = scaled.ceil() as usize;
if lower >= table.len() {
return Vec4::from_array(*table.last().unwrap());
}
if upper >= table.len() {
return Vec4::from_array(table[lower]);
}
let frac = scaled - lower as f32;
let a = Vec4::from_array(table[lower]);
let b = Vec4::from_array(table[upper]);
a.lerp(b, frac)
}
fn march_cells(
x_axis: &[f64],
y_axis: &[f64],
grid: &[Vec<f64>],
level: f32,
color: Vec4,
base_z: f32,
out: &mut Vec<Vertex>,
) {
let nx = x_axis.len();
let ny = y_axis.len();
for col in 0..ny - 1 {
for row in 0..nx - 1 {
let z00 = grid[row][col] as f32;
let z10 = grid[row + 1][col] as f32;
let z11 = grid[row + 1][col + 1] as f32;
let z01 = grid[row][col + 1] as f32;
let corners = [
Vec2::new(x_axis[row] as f32, y_axis[col] as f32),
Vec2::new(x_axis[row + 1] as f32, y_axis[col] as f32),
Vec2::new(x_axis[row + 1] as f32, y_axis[col + 1] as f32),
Vec2::new(x_axis[row] as f32, y_axis[col + 1] as f32),
];
let values = [z00, z10, z11, z01];
let case_index = ((z00 > level) as u32)
| (((z10 > level) as u32) << 1)
| (((z11 > level) as u32) << 2)
| (((z01 > level) as u32) << 3);
let mut segments: [Vec2; 4] = [Vec2::ZERO; 4];
let mut segment_count = 0u32;
match case_index {
0 | 15 => {}
1 | 14 => add_segment(
3,
0,
&corners,
&values,
level,
&mut segments,
&mut segment_count,
),
2 | 13 => add_segment(
0,
1,
&corners,
&values,
level,
&mut segments,
&mut segment_count,
),
3 | 12 => add_segment(
3,
1,
&corners,
&values,
level,
&mut segments,
&mut segment_count,
),
4 | 11 => add_segment(
1,
2,
&corners,
&values,
level,
&mut segments,
&mut segment_count,
),
5 | 10 => add_ambiguous_segments(
case_index,
&corners,
&values,
level,
&mut segments,
&mut segment_count,
),
6 | 9 => add_segment(
0,
2,
&corners,
&values,
level,
&mut segments,
&mut segment_count,
),
7 | 8 => add_segment(
3,
2,
&corners,
&values,
level,
&mut segments,
&mut segment_count,
),
_ => {}
}
for idx in 0..segment_count {
let start = segments[(idx * 2) as usize];
let end = segments[(idx * 2 + 1) as usize];
push_segment(start, end, color, base_z, out);
}
}
}
}
fn add_segment(
edge_a: u32,
edge_b: u32,
corners: &[Vec2; 4],
values: &[f32; 4],
level: f32,
io_segments: &mut [Vec2; 4],
io_count: &mut u32,
) {
if *io_count >= 2 {
return;
}
let idx = *io_count * 2;
io_segments[idx as usize] = interpolate_edge(edge_a, corners, values, level);
io_segments[idx as usize + 1] = interpolate_edge(edge_b, corners, values, level);
*io_count += 1;
}
fn add_ambiguous_segments(
case_index: u32,
corners: &[Vec2; 4],
values: &[f32; 4],
level: f32,
io_segments: &mut [Vec2; 4],
io_count: &mut u32,
) {
let f00 = values[0] - level;
let f10 = values[1] - level;
let f11 = values[2] - level;
let f01 = values[3] - level;
let q = f00 * f11 - f10 * f01;
let use_default = q > 0.0 || (q.abs() <= 1e-6 && case_index == 5);
match (case_index, use_default) {
(5, true) | (10, true) => {
add_segment(3, 2, corners, values, level, io_segments, io_count);
add_segment(0, 1, corners, values, level, io_segments, io_count);
}
(5, false) | (10, false) => {
add_segment(3, 0, corners, values, level, io_segments, io_count);
add_segment(1, 2, corners, values, level, io_segments, io_count);
}
_ => {}
}
}
fn interpolate_edge(edge: u32, corners: &[Vec2; 4], values: &[f32; 4], level: f32) -> Vec2 {
let (a_idx, b_idx) = match edge {
0 => (0, 1),
1 => (1, 2),
2 => (2, 3),
_ => (3, 0),
};
let a = corners[a_idx];
let b = corners[b_idx];
let va = values[a_idx];
let vb = values[b_idx];
let delta = vb - va;
let t = if delta.abs() <= 1e-6 {
0.5
} else {
((level - va) / delta).clamp(0.0, 1.0)
};
a + (b - a) * t
}
fn push_segment(start: Vec2, end: Vec2, color: Vec4, base_z: f32, out: &mut Vec<Vertex>) {
let z = Vec3::new(0.0, 0.0, 1.0);
out.push(Vertex {
position: Vec3::new(start.x, start.y, base_z).to_array(),
color: color.to_array(),
normal: z.to_array(),
tex_coords: [0.0, 0.0],
});
out.push(Vertex {
position: Vec3::new(end.x, end.y, base_z).to_array(),
color: color.to_array(),
normal: z.to_array(),
tex_coords: [0.0, 0.0],
});
}
fn implicit_axis(len: usize) -> Vec<f64> {
(0..len).map(|idx| (idx + 1) as f64).collect()
}
fn ensure_fill_levels(
name: &'static str,
level_spec: &ContourLevelSpec,
min_z: f32,
max_z: f32,
) -> BuiltinResult<Vec<f32>> {
let mut levels = level_spec.resolve(name, min_z, max_z)?;
if levels.len() < 2 {
let second = if (max_z - min_z).abs() < f32::EPSILON {
min_z + 1.0
} else {
max_z
};
levels = vec![min_z, second];
}
Ok(levels)
}
fn palette_size(levels: &[f32]) -> usize {
levels.len().saturating_sub(1).max(1)
}
fn push_fill_triangle(vertices: &mut Vec<Vertex>, positions: [Vec3; 3], colors: [Vec4; 3]) {
let normal = Vec3::new(0.0, 0.0, 1.0);
for i in 0..3 {
vertices.push(Vertex {
position: positions[i].to_array(),
color: colors[i].to_array(),
normal: normal.to_array(),
tex_coords: [0.0, 0.0],
});
}
}
#[derive(Clone, Copy, Debug)]
struct ScalarPoint2 {
pos: Vec2,
value: f32,
}
fn interpolate_scalar_point(a: ScalarPoint2, b: ScalarPoint2, threshold: f32) -> ScalarPoint2 {
let delta = b.value - a.value;
let t = if delta.abs() <= 1e-6 {
0.5
} else {
((threshold - a.value) / delta).clamp(0.0, 1.0)
};
ScalarPoint2 {
pos: a.pos + (b.pos - a.pos) * t,
value: threshold,
}
}
fn clip_polygon_lower(poly: &[ScalarPoint2], threshold: f32) -> Vec<ScalarPoint2> {
clip_polygon(poly, |v| v >= threshold, threshold)
}
fn clip_polygon_upper(poly: &[ScalarPoint2], threshold: f32, inclusive: bool) -> Vec<ScalarPoint2> {
if inclusive {
clip_polygon(poly, |v| v <= threshold, threshold)
} else {
clip_polygon(poly, |v| v < threshold, threshold)
}
}
fn clip_polygon<F>(poly: &[ScalarPoint2], inside: F, threshold: f32) -> Vec<ScalarPoint2>
where
F: Fn(f32) -> bool,
{
if poly.is_empty() {
return Vec::new();
}
let mut out = Vec::new();
let mut prev = *poly.last().unwrap();
let mut prev_inside = inside(prev.value);
for &curr in poly {
let curr_inside = inside(curr.value);
if curr_inside != prev_inside {
out.push(interpolate_scalar_point(prev, curr, threshold));
}
if curr_inside {
out.push(curr);
}
prev = curr;
prev_inside = curr_inside;
}
out
}
fn triangulate_band_triangle(
tri: [ScalarPoint2; 3],
lo: f32,
hi: f32,
include_hi: bool,
color: Vec4,
base_z: f32,
out: &mut Vec<Vertex>,
) {
let poly = clip_polygon_upper(&clip_polygon_lower(&tri, lo), hi, include_hi);
if poly.len() < 3 {
return;
}
let p0 = poly[0].pos;
for idx in 1..poly.len() - 1 {
push_fill_triangle(
out,
[
Vec3::new(p0.x, p0.y, base_z),
Vec3::new(poly[idx].pos.x, poly[idx].pos.y, base_z),
Vec3::new(poly[idx + 1].pos.x, poly[idx + 1].pos.y, base_z),
],
[color, color, color],
);
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use crate::builtins::plotting::tests::ensure_plot_test_env;
use runmat_builtins::NumericDType;
use runmat_builtins::{ResolveContext, Type};
fn setup_plot_tests() {
ensure_plot_test_env();
}
fn tensor_from(data: &[f64], rows: usize, cols: usize) -> Tensor {
let mut shape = vec![rows];
if cols > 1 {
shape.push(cols);
}
Tensor {
data: data.to_vec(),
shape,
rows,
cols,
dtype: NumericDType::F64,
}
}
fn assert_contour_vertices_within_bounds(vertices: &[Vertex], x_axis: &[f64], y_axis: &[f64]) {
assert_eq!(vertices.len() % 2, 0);
let min_x = x_axis.iter().copied().fold(f64::INFINITY, f64::min) as f32;
let max_x = x_axis.iter().copied().fold(f64::NEG_INFINITY, f64::max) as f32;
let min_y = y_axis.iter().copied().fold(f64::INFINITY, f64::min) as f32;
let max_y = y_axis.iter().copied().fold(f64::NEG_INFINITY, f64::max) as f32;
for vertex in vertices {
assert!(vertex.position[0].is_finite());
assert!(vertex.position[1].is_finite());
assert!(vertex.position[2].is_finite());
assert!(vertex.position[0] >= min_x - 1e-4 && vertex.position[0] <= max_x + 1e-4);
assert!(vertex.position[1] >= min_y - 1e-4 && vertex.position[1] <= max_y + 1e-4);
}
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn explicit_axes_must_match_grid() {
setup_plot_tests();
let x = Value::Tensor(tensor_from(&[0.0, 1.0], 2, 1));
let y = Value::Tensor(tensor_from(&[0.0, 1.0, 2.0], 3, 1));
let z = Value::Tensor(tensor_from(&[0.0, 1.0, 2.0, 3.0], 2, 2));
let result = parse_contour_args("contour", x, vec![y, z]);
assert!(result.is_err());
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn implicit_axes_respect_tensor_shape() {
setup_plot_tests();
let z = Value::Tensor(tensor_from(&[0.0, 1.0, 2.0, 3.0], 2, 2));
let args = parse_contour_args("contour", z, Vec::new()).unwrap();
assert_eq!(args.x_axis, vec![1.0, 2.0]);
assert_eq!(args.y_axis, vec![1.0, 2.0]);
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn explicit_axes_plus_scalar_level_count_parse_correctly() {
setup_plot_tests();
let x = Value::Tensor(tensor_from(&[0.0, 1.0], 2, 1));
let y = Value::Tensor(tensor_from(&[0.0, 1.0], 2, 1));
let z = Value::Tensor(tensor_from(&[0.0, 1.0, 1.0, 0.0], 2, 2));
let args = parse_contour_args("contour", x, vec![y, z, Value::Num(12.0)]).unwrap();
assert_eq!(args.x_axis, vec![0.0, 1.0]);
assert_eq!(args.y_axis, vec![0.0, 1.0]);
match args.level_spec {
ContourLevelSpec::Count(count) => assert_eq!(count, 12),
other => panic!("expected scalar level count, found {other:?}"),
}
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn explicit_meshgrid_axes_parse_correctly() {
setup_plot_tests();
let x = Value::Tensor(tensor_from(&[10.0, 10.0, 20.0, 20.0], 2, 2));
let y = Value::Tensor(tensor_from(&[1.0, 2.0, 1.0, 2.0], 2, 2));
let z = Value::Tensor(tensor_from(&[0.0, 1.0, 1.0, 0.0], 2, 2));
let args = parse_contour_args("contour", x, vec![y, z]).unwrap();
assert_eq!(args.x_axis, vec![10.0, 20.0]);
assert_eq!(args.y_axis, vec![1.0, 2.0]);
}
#[test]
fn interpolate_edge_handles_descending_values() {
let corners = [
Vec2::new(0.0, 0.0),
Vec2::new(2.0, 0.0),
Vec2::new(2.0, 1.0),
Vec2::new(0.0, 1.0),
];
let values = [2.0, 0.0, 0.0, 2.0];
let point = interpolate_edge(0, &corners, &values, 1.0);
assert!((point.x - 1.0).abs() < 1e-6);
assert!(point.y.abs() < 1e-6);
}
#[test]
fn ambiguous_case_uses_asymptotic_decider() {
let corners = [
Vec2::new(0.0, 0.0),
Vec2::new(1.0, 0.0),
Vec2::new(1.0, 1.0),
Vec2::new(0.0, 1.0),
];
let values = [2.0, 0.8, 2.0, 0.0];
let mut segments = [Vec2::ZERO; 4];
let mut count = 0;
add_ambiguous_segments(5, &corners, &values, 1.0, &mut segments, &mut count);
assert_eq!(count, 2);
assert!(segments[0].x.abs() < 1e-6 || (segments[0].y - 1.0).abs() < 1e-6);
}
#[test]
fn contour_cpu_matrix_handles_nonuniform_axes_fixture() {
let x_axis = vec![-3.0, -1.0, 0.5, 2.0];
let y_axis = vec![-2.0, -0.25, 1.5, 3.0];
let grid = vec![
vec![0.0, 0.3, 0.9, 1.2],
vec![-0.4, 0.2, 0.8, 1.0],
vec![-0.8, -0.1, 0.4, 0.9],
vec![-1.0, -0.5, 0.1, 0.6],
];
let mut plot = build_contour_plot(
"contour",
&x_axis,
&y_axis,
&grid,
ColorMap::Parula,
0.0,
&ContourLevelSpec::Values(vec![-0.5, 0.0, 0.5, 1.0]),
&ContourLineColor::Auto,
)
.expect("contour plot");
let render = plot.render_data();
assert!(!render.vertices.is_empty());
assert_contour_vertices_within_bounds(&render.vertices, &x_axis, &y_axis);
}
#[test]
fn contour_cpu_saddle_fixture_emits_finite_segments() {
let x_axis = vec![0.0, 1.0, 2.0];
let y_axis = vec![0.0, 1.0, 2.0];
let grid = vec![
vec![1.0, -1.0, 1.0],
vec![-1.0, 1.0, -1.0],
vec![1.0, -1.0, 1.0],
];
let mut plot = build_contour_plot(
"contour",
&x_axis,
&y_axis,
&grid,
ColorMap::Parula,
0.0,
&ContourLevelSpec::Values(vec![0.0]),
&ContourLineColor::Auto,
)
.expect("contour plot");
let render = plot.render_data();
assert!(!render.vertices.is_empty());
assert_contour_vertices_within_bounds(&render.vertices, &x_axis, &y_axis);
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn level_vector_must_increase() {
setup_plot_tests();
let bad_levels = Value::Tensor(tensor_from(&[0.0, 0.0], 1, 2));
let repeated = parse_level_spec(bad_levels, "contour").unwrap();
match repeated {
ContourLevelSpec::Values(values) => assert_eq!(values, vec![0.0]),
other => panic!("expected repeated single contour level, found {other:?}"),
}
let good_levels = Value::Tensor(tensor_from(&[0.0, 1.0, 2.0], 1, 3));
let spec = parse_level_spec(good_levels, "contour").unwrap();
match spec {
ContourLevelSpec::Values(values) => assert_eq!(values, vec![0.0, 1.0, 2.0]),
_ => panic!("expected explicit levels"),
}
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn level_step_option_generates_sequence() {
setup_plot_tests();
let z = Value::Tensor(tensor_from(&[0.0, 1.0, 2.0, 3.0], 2, 2));
let args = parse_contour_args(
"contour",
z,
vec![Value::String("LevelStep".into()), Value::Num(0.5)],
)
.unwrap();
match args.level_spec {
ContourLevelSpec::Step(step) => assert!((step - 0.5).abs() < f32::EPSILON),
other => panic!("expected LevelStep, found {other:?}"),
}
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn line_color_option_parses_literal() {
setup_plot_tests();
let z = Value::Tensor(tensor_from(&[0.0, 1.0, 2.0, 3.0], 2, 2));
let args = parse_contour_args(
"contour",
z,
vec![Value::String("LineColor".into()), Value::String("r".into())],
)
.unwrap();
match args.line_color {
ContourLineColor::Color(color) => {
assert!((color.x - 1.0).abs() < f32::EPSILON);
assert!((color.y).abs() < f32::EPSILON);
}
other => panic!("expected explicit color, found {other:?}"),
}
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn linewidth_option_parses() {
setup_plot_tests();
let z = Value::Tensor(tensor_from(&[0.0, 1.0, 2.0, 3.0], 2, 2));
let args = parse_contour_args(
"contour",
z,
vec![Value::String("LineWidth".into()), Value::Num(2.0)],
)
.unwrap();
assert!((args.line_width - 2.0).abs() < f32::EPSILON);
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn level_list_option_accepts_explicit_vector() {
setup_plot_tests();
let z = Value::Tensor(tensor_from(&[0.0, 1.0, 2.0, 3.0], 2, 2));
let args = parse_contour_args(
"contour",
z,
vec![
Value::String("LevelList".into()),
Value::Tensor(tensor_from(&[0.0, 0.5, 1.0], 1, 3)),
],
)
.unwrap();
match args.level_spec {
ContourLevelSpec::Values(values) => {
assert_eq!(values, vec![0.0, 0.5, 1.0]);
}
other => panic!("expected explicit level list, found {other:?}"),
}
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn line_color_none_suppresses_overlays() {
setup_plot_tests();
let z = Value::Tensor(tensor_from(&[0.0, 1.0, 2.0, 3.0], 2, 2));
let args = parse_contour_args(
"contour",
z,
vec![
Value::String("LineColor".into()),
Value::String("none".into()),
],
)
.unwrap();
match args.line_color {
ContourLineColor::None => {}
other => panic!("expected LineColor none, found {other:?}"),
}
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[test]
fn level_list_mode_manual_requires_explicit_levels() {
setup_plot_tests();
let z = Value::Tensor(tensor_from(&[0.0, 1.0, 2.0, 3.0], 2, 2));
let err = parse_contour_args(
"contour",
z.clone(),
vec![
Value::String("LevelListMode".into()),
Value::String("manual".into()),
],
);
assert!(err.is_err());
let ok = parse_contour_args(
"contour",
z,
vec![
Value::String("LevelListMode".into()),
Value::String("manual".into()),
Value::String("LevelStep".into()),
Value::Num(0.25),
],
);
assert!(ok.is_ok());
}
#[test]
fn contour_type_is_numeric_handle() {
assert_eq!(
handle_scalar_type(&[Type::tensor()], &ResolveContext::new(Vec::new())),
Type::Num
);
}
#[test]
fn contour_returns_handle() {
setup_plot_tests();
let handle = contour_builtin(
Value::Tensor(tensor_from(&[0.0, 1.0, 1.0, 0.0], 2, 2)),
Vec::new(),
)
.expect("contour should return handle");
assert!(handle.is_finite());
}
}