use crate::brush::Brush;
use crate::color::{Color, ColorSpace};
use crate::geometry::{Affine, Point, Rect};
use crate::path::{FillRule, Path};
use crate::plot::scale::Scale;
use crate::plot::value::DataColumn;
use crate::scene::SceneBuilder;
use super::marks::{build_marks_from_column, MarkSlot};
use super::outline::{draw_curve_outline, resolve_outline_spec, OutlineChannels, OutlineScales};
use super::resolve::{
channel_color_space, channel_varies_across, override_alpha, pt_to_px, resolve_color_channel,
resolve_color_channel_or_theme, resolve_number_channel, resolve_number_channel_or,
resolve_pick_id, resolve_position, ChannelBind,
};
use super::state::{finalize_state, require_x_and_siblings, GeomState, KeysStrategy};
use super::{BuildableGeom, Channel, ExpectedOutput, Geom, GeomBuilder, GeomContext, Keys};
const CHANNELS: &[(&str, ExpectedOutput)] = &[
("x", ExpectedOutput::Numbers),
("y", ExpectedOutput::Numbers),
("x2", ExpectedOutput::Numbers),
("y2", ExpectedOutput::Numbers),
("x_offset", ExpectedOutput::Numbers),
("y_offset", ExpectedOutput::Numbers),
("x2_offset", ExpectedOutput::Numbers),
("y2_offset", ExpectedOutput::Numbers),
("x_band", ExpectedOutput::Numbers),
("y_band", ExpectedOutput::Numbers),
("x2_band", ExpectedOutput::Numbers),
("y2_band", ExpectedOutput::Numbers),
("fill", ExpectedOutput::Colors),
("fill_opacity", ExpectedOutput::Numbers),
("pick_id", ExpectedOutput::Numbers),
("stroke", ExpectedOutput::Colors),
("stroke_opacity", ExpectedOutput::Numbers),
("linewidth", ExpectedOutput::Numbers),
("linetype", ExpectedOutput::Linetypes),
("dash_offset", ExpectedOutput::Numbers),
("cap", ExpectedOutput::Strings),
("join", ExpectedOutput::Strings),
("clip_start_radius", ExpectedOutput::Numbers),
("clip_end_radius", ExpectedOutput::Numbers),
("start_marker", ExpectedOutput::Strings),
("end_marker", ExpectedOutput::Strings),
("start_marker_size", ExpectedOutput::Numbers),
("end_marker_size", ExpectedOutput::Numbers),
("start_marker_fill", ExpectedOutput::Colors),
("end_marker_fill", ExpectedOutput::Colors),
("start_marker_invert", ExpectedOutput::Any),
("end_marker_invert", ExpectedOutput::Any),
("stroke2", ExpectedOutput::Colors),
("stroke_opacity2", ExpectedOutput::Numbers),
("linewidth2", ExpectedOutput::Numbers),
("linetype2", ExpectedOutput::Linetypes),
("dash_offset2", ExpectedOutput::Numbers),
("cap2", ExpectedOutput::Strings),
("join2", ExpectedOutput::Strings),
("clip_start_radius2", ExpectedOutput::Numbers),
("clip_end_radius2", ExpectedOutput::Numbers),
("start_marker2", ExpectedOutput::Strings),
("end_marker2", ExpectedOutput::Strings),
("start_marker_size2", ExpectedOutput::Numbers),
("end_marker_size2", ExpectedOutput::Numbers),
("start_marker_fill2", ExpectedOutput::Colors),
("end_marker_fill2", ExpectedOutput::Colors),
("start_marker_invert2", ExpectedOutput::Any),
("end_marker_invert2", ExpectedOutput::Any),
];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Orientation {
Horizontal,
Vertical,
Free,
}
pub struct RibbonGeom {
pub(crate) state: GeomState,
pub(crate) marks: Vec<MarkSlot>,
pub(crate) orientation: Orientation,
}
crate::impl_geom_inherents_grouped!(RibbonGeom);
impl RibbonGeom {
pub(crate) fn build_marks(&self) -> Vec<MarkSlot> {
super::marks::build_marks(&self.state.keys)
}
}
impl BuildableGeom for RibbonGeom {
fn build_from(builder: GeomBuilder<Self>) -> Self {
let (keys_opt, channels) = builder.into_parts();
let n = require_x_and_siblings(&channels, &["y"], "RibbonGeom");
let has_x2 = channels.contains_key("x2");
let has_y2 = channels.contains_key("y2");
let orientation = match (has_x2, has_y2) {
(false, false) => panic!(
"RibbonGeom::build: needs at least one of \"x2\" or \"y2\" \
(use a constant baseline, e.g. y2 = 0.0, for an area-to-axis ribbon)"
),
(true, false) => Orientation::Vertical,
(false, true) => Orientation::Horizontal,
(true, true) => Orientation::Free,
};
let state = finalize_state(
keys_opt,
channels,
n,
KeysStrategy::OneMark,
CHANNELS,
"RibbonGeom",
);
RibbonGeom {
state,
marks: Vec::new(),
orientation,
}
}
}
#[derive(Clone, Copy)]
struct RibbonDrawCtx<'a> {
orientation: Orientation,
x_col: &'a DataColumn,
y_col: &'a DataColumn,
x_scale: Option<&'a Scale>,
y_scale: Option<&'a Scale>,
x2: ChannelBind<'a>,
y2: ChannelBind<'a>,
x_offset: ChannelBind<'a>,
y_offset: ChannelBind<'a>,
x2_offset: ChannelBind<'a>,
y2_offset: ChannelBind<'a>,
x_band: ChannelBind<'a>,
y_band: ChannelBind<'a>,
x2_band: ChannelBind<'a>,
y2_band: ChannelBind<'a>,
fill: ChannelBind<'a>,
fill_opacity: ChannelBind<'a>,
pick_id: ChannelBind<'a>,
outline_a_ch: OutlineChannels<'a>,
outline_b_ch: OutlineChannels<'a>,
outline_a_scales: OutlineScales<'a>,
outline_b_scales: OutlineScales<'a>,
}
impl<'a> RibbonDrawCtx<'a> {
fn build(
channels: &'a std::collections::HashMap<String, Channel>,
ctx: &'a GeomContext<'a>,
orientation: Orientation,
) -> Option<Self> {
let (x_col, x_scale) = match channels.get("x")? {
Channel::Data(c) => (c, ctx.scale_for("x")),
Channel::RawData(c) => (c, None),
_ => return None,
};
let (y_col, y_scale) = match channels.get("y")? {
Channel::Data(c) => (c, ctx.scale_for("y")),
Channel::RawData(c) => (c, None),
_ => return None,
};
let b = |name: &str| ChannelBind::from_ctx(channels, ctx, name);
Some(Self {
orientation,
x_col,
y_col,
x_scale,
y_scale,
x2: b("x2"),
y2: b("y2"),
x_offset: b("x_offset"),
y_offset: b("y_offset"),
x2_offset: b("x2_offset"),
y2_offset: b("y2_offset"),
x_band: b("x_band"),
y_band: b("y_band"),
x2_band: b("x2_band"),
y2_band: b("y2_band"),
fill: b("fill"),
fill_opacity: b("fill_opacity"),
pick_id: b("pick_id"),
outline_a_ch: OutlineChannels::from_map(channels, ""),
outline_b_ch: OutlineChannels::from_map(channels, "2"),
outline_a_scales: OutlineScales::from_ctx(ctx, ""),
outline_b_scales: OutlineScales::from_ctx(ctx, "2"),
})
}
}
impl Geom for RibbonGeom {
fn state(&self) -> &GeomState {
&self.state
}
fn state_mut(&mut self) -> &mut GeomState {
&mut self.state
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn kind(&self) -> Option<&'static str> {
Some("ribbon")
}
fn mark_count(&self) -> usize {
if self.marks.is_empty() && !self.is_empty() {
return self.build_marks().len();
}
self.marks.len()
}
fn invalidate_caches(&mut self) {
self.marks.clear();
}
fn rebuild_diff_against_previous(&mut self) {
if !self.state.dirty {
return;
}
let next_marks = self.build_marks();
let prev_marks = match &self.state.prev_keys {
Keys::Explicit(col) if !col.is_empty() => build_marks_from_column(col),
_ => Vec::new(),
};
let first_rows =
|ms: &[MarkSlot]| -> Vec<usize> { ms.iter().map(|m| m.first_row).collect() };
self.state.rebuild_grouped_diff(
&first_rows(&prev_marks),
&first_rows(&next_marks),
"RibbonGeom",
);
self.marks = next_marks;
}
fn draw(&self, scene: &mut dyn SceneBuilder, ctx: &GeomContext<'_>) {
let panel = ctx.panel_rect;
let panel_w = panel.x1 - panel.x0;
let panel_h = panel.y1 - panel.y0;
if panel_w <= 0.0 || panel_h <= 0.0 {
return;
}
let owned_marks;
let marks: &[MarkSlot] = if self.marks.is_empty() && !self.is_empty() {
owned_marks = self.build_marks();
&owned_marks
} else {
&self.marks
};
if marks.is_empty() {
return;
}
let dc = match RibbonDrawCtx::build(&self.state.channels, ctx, self.orientation) {
Some(dc) => dc,
None => return,
};
for mark in marks.iter() {
draw_one_ribbon_mark(scene, ctx, panel, dc, mark);
}
}
}
fn draw_one_ribbon_mark(
scene: &mut dyn SceneBuilder,
ctx: &GeomContext<'_>,
panel: Rect,
dc: RibbonDrawCtx<'_>,
mark: &MarkSlot,
) {
let RibbonDrawCtx {
orientation,
x_col,
y_col,
x_scale,
y_scale,
x2: ChannelBind {
ch: x2_ch,
scale: x2_scale_bound,
},
y2: ChannelBind {
ch: y2_ch,
scale: y2_scale_bound,
},
x_offset:
ChannelBind {
ch: x_offset_ch,
scale: x_offset_scale,
},
y_offset:
ChannelBind {
ch: y_offset_ch,
scale: y_offset_scale,
},
x2_offset:
ChannelBind {
ch: x2_offset_ch,
scale: x2_offset_scale,
},
y2_offset:
ChannelBind {
ch: y2_offset_ch,
scale: y2_offset_scale,
},
x_band: ChannelBind {
ch: x_band_ch,
scale: x_band_scale,
},
y_band: ChannelBind {
ch: y_band_ch,
scale: y_band_scale,
},
x2_band:
ChannelBind {
ch: x2_band_ch,
scale: x2_band_scale,
},
y2_band:
ChannelBind {
ch: y2_band_ch,
scale: y2_band_scale,
},
fill,
fill_opacity,
pick_id:
ChannelBind {
ch: pick_id_ch,
scale: pick_id_scale,
},
outline_a_ch,
outline_b_ch,
outline_a_scales,
outline_b_scales,
} = dc;
let i0 = mark.first_row;
let mark_fill = override_alpha(
resolve_color_channel_or_theme(
fill.ch,
fill.scale,
i0,
ctx.theme.geom.ribbon.fill.as_ref(),
&ctx.theme.palette,
),
resolve_number_channel(fill_opacity.ch, fill_opacity.scale, i0),
);
let pick = resolve_pick_id(pick_id_ch, pick_id_scale, i0);
let outline_a_spec = resolve_outline_spec(
ctx,
(&ctx.theme.geom.ribbon).into(),
&outline_a_ch,
&outline_a_scales,
ChannelBind::default(),
i0,
pick,
);
let outline_b_spec = resolve_outline_spec(
ctx,
(&ctx.theme.geom.ribbon).into(),
&outline_b_ch,
&outline_b_scales,
ChannelBind::default(),
i0,
pick,
);
if mark_fill.is_none() && outline_a_spec.is_none() && outline_b_spec.is_none() {
return;
}
let is_linear = ctx.projection.is_linear();
let mut samples_a: Vec<crate::plot::projection::InteriorSample> = Vec::new();
let mut samples_b: Vec<crate::plot::projection::InteriorSample> = Vec::new();
let mut merged_t: Vec<f64> = Vec::new();
let mut curve_a_pts: Vec<Point> = Vec::with_capacity(mark.rows.len());
let mut curve_b_pts: Vec<Point> = Vec::with_capacity(mark.rows.len());
let mut row_for_vertex: Vec<usize> = Vec::with_capacity(mark.rows.len());
let mut vertex_origins: Vec<VertexOrigin> = Vec::with_capacity(mark.rows.len());
let mut prev_real: Option<(usize, [f64; 2], [f64; 2])> = None;
let mut first_real: Option<([f64; 2], [f64; 2])> = None;
for &i in &mark.rows {
let x_band = resolve_number_channel_or(x_band_ch, x_band_scale, i, 0.0);
let y_band = resolve_number_channel_or(y_band_ch, y_band_scale, i, 0.0);
let x2_band = resolve_number_channel_or(x2_band_ch, x2_band_scale, i, 0.0);
let y2_band = resolve_number_channel_or(y2_band_ch, y2_band_scale, i, 0.0);
let x_frac = resolve_position(x_col.get(i), x_scale, x_band);
let y_frac = resolve_position(y_col.get(i), y_scale, y_band);
if !x_frac.is_finite() || !y_frac.is_finite() {
continue;
}
let (b_x_frac, b_y_frac) = match resolve_b_row(
orientation,
x2_ch,
y2_ch,
x2_scale_bound,
y2_scale_bound,
i,
x_frac,
y_frac,
x2_band,
y2_band,
) {
Some(b) => b,
None => continue,
};
let a_ch = [x_frac, y_frac];
let b_ch = [b_x_frac, b_y_frac];
if !is_linear {
if let Some((prev_row, prev_a, prev_b)) = prev_real {
samples_a.clear();
samples_b.clear();
ctx.projection
.interpolate_segment_with_t(panel, &prev_a, &a_ch, &mut samples_a);
ctx.projection
.interpolate_segment_with_t(panel, &prev_b, &b_ch, &mut samples_b);
merged_t.clear();
merged_t.extend(samples_a.iter().map(|s| s.t));
merged_t.extend(samples_b.iter().map(|s| s.t));
merged_t.sort_by(|x, y| x.partial_cmp(y).unwrap_or(std::cmp::Ordering::Equal));
merged_t.dedup_by(|x, y| (*x - *y).abs() < 1e-9);
for &t in &merged_t {
let a_lerp = [
(1.0 - t) * prev_a[0] + t * a_ch[0],
(1.0 - t) * prev_a[1] + t * a_ch[1],
];
let b_lerp = [
(1.0 - t) * prev_b[0] + t * b_ch[0],
(1.0 - t) * prev_b[1] + t * b_ch[1],
];
let (apx, apy) = ctx.projection.project_to_panel_px(panel, &a_lerp);
let (bpx, bpy) = ctx.projection.project_to_panel_px(panel, &b_lerp);
curve_a_pts.push(Point::new(apx, apy));
curve_b_pts.push(Point::new(bpx, bpy));
vertex_origins.push(VertexOrigin {
prev_row,
next_row: i,
t,
});
}
}
}
let (mut apx, mut apy) = ctx.projection.project_to_panel_px(panel, &a_ch);
let (mut bpx, mut bpy) = ctx.projection.project_to_panel_px(panel, &b_ch);
if let Some(off) = resolve_number_channel(x_offset_ch, x_offset_scale, i) {
apx += pt_to_px(off, ctx.dpi);
}
if let Some(off) = resolve_number_channel(y_offset_ch, y_offset_scale, i) {
apy -= pt_to_px(off, ctx.dpi);
}
if let Some(off) = resolve_number_channel(x2_offset_ch, x2_offset_scale, i) {
bpx += pt_to_px(off, ctx.dpi);
}
if let Some(off) = resolve_number_channel(y2_offset_ch, y2_offset_scale, i) {
bpy -= pt_to_px(off, ctx.dpi);
}
curve_a_pts.push(Point::new(apx, apy));
curve_b_pts.push(Point::new(bpx, bpy));
row_for_vertex.push(i);
vertex_origins.push(VertexOrigin {
prev_row: i,
next_row: i,
t: 1.0,
});
if first_real.is_none() {
first_real = Some((a_ch, b_ch));
}
prev_real = Some((i, a_ch, b_ch));
}
if row_for_vertex.len() < 2 {
return;
}
debug_assert_eq!(curve_a_pts.len(), curve_b_pts.len());
debug_assert_eq!(curve_a_pts.len(), vertex_origins.len());
let mut start_cap_samples: Vec<crate::plot::projection::InteriorSample> = Vec::new();
let mut end_cap_samples: Vec<crate::plot::projection::InteriorSample> = Vec::new();
if !is_linear {
if let (Some((first_a, first_b)), Some((_, last_a, last_b))) = (first_real, prev_real) {
ctx.projection.interpolate_segment_with_t(
panel,
&last_a,
&last_b,
&mut end_cap_samples,
);
ctx.projection.interpolate_segment_with_t(
panel,
&first_b,
&first_a,
&mut start_cap_samples,
);
}
}
let mut path = Path::new();
path.move_to(curve_a_pts[0]);
for p in &curve_a_pts[1..] {
path.line_to(*p);
}
for s in &end_cap_samples {
path.line_to(Point::new(s.px, s.py));
}
for p in curve_b_pts.iter().rev() {
path.line_to(*p);
}
for s in &start_cap_samples {
path.line_to(Point::new(s.px, s.py));
}
path.close_path();
if let Some(mark_color) = mark_fill {
let varies = channel_varies_across(fill.ch, fill.scale, &row_for_vertex)
|| channel_varies_across(fill_opacity.ch, fill_opacity.scale, &row_for_vertex);
let axis_aligned = matches!(orientation, Orientation::Horizontal | Orientation::Vertical);
let use_mesh = varies && (!axis_aligned || !is_linear);
let row_fill = RowFill::new(fill, fill_opacity, mark_color);
if use_mesh {
let (colors_a, colors_b) = build_per_vertex_colors(&vertex_origins, &row_fill);
let mut mesh = crate::primitives::ribbon_band_mesh(
&curve_a_pts,
&curve_b_pts,
&colors_a,
&colors_b,
);
if !mesh.vertices.is_empty() && curve_a_pts.len() >= 2 {
let last = curve_a_pts.len() - 1;
let start_neighbor = Point::new(
(curve_a_pts[1].x + curve_b_pts[1].x) * 0.5,
(curve_a_pts[1].y + curve_b_pts[1].y) * 0.5,
);
let end_neighbor = Point::new(
(curve_a_pts[last - 1].x + curve_b_pts[last - 1].x) * 0.5,
(curve_a_pts[last - 1].y + curve_b_pts[last - 1].y) * 0.5,
);
append_cap_fan_to_mesh(
&mut mesh,
curve_a_pts[0],
curve_b_pts[0],
start_neighbor,
&start_cap_samples,
colors_a[0],
CapDirection::Start,
);
append_cap_fan_to_mesh(
&mut mesh,
curve_a_pts[last],
curve_b_pts[last],
end_neighbor,
&end_cap_samples,
*colors_a.last().unwrap(),
CapDirection::End,
);
scene.push_layer(
crate::blend::BlendMode::NORMAL,
1.0,
Affine::IDENTITY,
&path,
);
scene.draw_mesh(&mesh, Affine::IDENTITY, pick);
scene.pop_layer();
}
} else {
let brush = if varies {
build_gradient_brush(orientation, &curve_a_pts, &vertex_origins, &row_fill)
.map(Brush::Gradient)
.unwrap_or_else(|| Brush::Solid(mark_color))
} else {
Brush::Solid(mark_color)
};
scene.fill(
FillRule::NonZero,
Affine::IDENTITY,
&brush,
None,
&path,
pick,
);
}
}
if let Some(ref spec) = outline_a_spec {
draw_curve_outline(
scene,
ctx.shapes,
ctx.dpi,
ctx.theme.geom.marker_outline_pt,
&curve_a_pts,
spec,
);
}
if let Some(ref spec) = outline_b_spec {
draw_curve_outline(
scene,
ctx.shapes,
ctx.dpi,
ctx.theme.geom.marker_outline_pt,
&curve_b_pts,
spec,
);
}
}
fn build_gradient_brush(
orientation: Orientation,
curve_a_pts: &[Point],
vertex_origins: &[VertexOrigin],
fill: &RowFill<'_>,
) -> Option<crate::brush::Gradient> {
if matches!(orientation, Orientation::Free) {
return None;
}
let real: Vec<(usize, Point)> = vertex_origins
.iter()
.zip(curve_a_pts)
.filter(|(o, _)| o.prev_row == o.next_row)
.map(|(o, p)| (o.prev_row, *p))
.collect();
let n = real.len();
if n < 2 {
return None;
}
let pick_coord = |p: &Point| match orientation {
Orientation::Horizontal => p.x,
Orientation::Vertical => p.y,
Orientation::Free => 0.0,
};
let coords: Vec<f64> = real.iter().map(|(_, p)| pick_coord(p)).collect();
let min_c = coords.iter().cloned().fold(f64::INFINITY, f64::min);
let max_c = coords.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let span = max_c - min_c;
if !span.is_finite() || span.abs() < f64::EPSILON {
return None;
}
let (start, end) = match orientation {
Orientation::Horizontal => {
let mid_y = (real[0].1.y + real[n - 1].1.y) * 0.5;
(Point::new(min_c, mid_y), Point::new(max_c, mid_y))
}
Orientation::Vertical => {
let mid_x = (real[0].1.x + real[n - 1].1.x) * 0.5;
(Point::new(mid_x, min_c), Point::new(mid_x, max_c))
}
Orientation::Free => return None,
};
let mut pairs: Vec<(f64, Color)> = Vec::with_capacity(n);
for (k, &(i, _)) in real.iter().enumerate() {
let offset = ((coords[k] - min_c) / span).clamp(0.0, 1.0);
pairs.push((offset, fill.at(i)));
}
pairs.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
let mut stops: Vec<crate::brush::ColorStop> = Vec::with_capacity(pairs.len());
let mut last_offset = f64::NEG_INFINITY;
for (offset, color) in pairs {
if offset <= last_offset {
continue;
}
stops.push(crate::brush::ColorStop {
offset: offset as f32,
color: color.into(),
});
last_offset = offset;
}
if stops.len() < 2 {
return None;
}
Some(crate::brush::Gradient::new_linear(start, end).with_stops(stops.as_slice()))
}
#[derive(Clone, Copy, Debug)]
struct VertexOrigin {
prev_row: usize,
next_row: usize,
t: f64,
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn resolve_b_row(
orientation: Orientation,
x2_ch: Option<&Channel>,
y2_ch: Option<&Channel>,
x2_scale_bound: Option<&crate::plot::scale::Scale>,
y2_scale_bound: Option<&crate::plot::scale::Scale>,
row: usize,
x_frac: f64,
y_frac: f64,
x2_band: f64,
y2_band: f64,
) -> Option<(f64, f64)> {
let b_x = match orientation {
Orientation::Horizontal => x_frac,
Orientation::Vertical | Orientation::Free => {
resolve_optional_position(x2_ch, x2_scale_bound, row, x2_band)?
}
};
let b_y = match orientation {
Orientation::Vertical => y_frac,
Orientation::Horizontal | Orientation::Free => {
resolve_optional_position(y2_ch, y2_scale_bound, row, y2_band)?
}
};
Some((b_x, b_y))
}
#[derive(Clone, Copy, Debug)]
pub(crate) enum CapDirection {
Start,
End,
}
pub(crate) fn append_cap_fan_to_mesh(
mesh: &mut crate::mesh::Mesh,
pivot: Point,
other: Point,
neighbor: Point,
cap_samples: &[crate::plot::projection::InteriorSample],
cap_color: Color,
direction: CapDirection,
) {
if cap_samples.is_empty() {
return;
}
let chord_mid = Point::new((pivot.x + other.x) * 0.5, (pivot.y + other.y) * 0.5);
let sweep_x = neighbor.x - chord_mid.x;
let sweep_y = neighbor.y - chord_mid.y;
let mut bulge_x = 0.0;
let mut bulge_y = 0.0;
for s in cap_samples {
bulge_x += s.px - chord_mid.x;
bulge_y += s.py - chord_mid.y;
}
let inv_n = 1.0 / cap_samples.len() as f64;
bulge_x *= inv_n;
bulge_y *= inv_n;
if bulge_x * sweep_x + bulge_y * sweep_y > 0.0 {
return;
}
let base = mesh.vertices.len() as u32;
mesh.vertices.push(pivot);
mesh.colors.push(cap_color);
let cap_arc_iter: Vec<Point> = match direction {
CapDirection::Start => cap_samples
.iter()
.rev()
.map(|s| Point::new(s.px, s.py))
.chain(std::iter::once(other))
.collect(),
CapDirection::End => cap_samples
.iter()
.map(|s| Point::new(s.px, s.py))
.chain(std::iter::once(other))
.collect(),
};
for p in &cap_arc_iter {
mesh.vertices.push(*p);
mesh.colors.push(cap_color);
}
for i in 0..cap_arc_iter.len() - 1 {
mesh.indices.push(base);
mesh.indices.push(base + 1 + i as u32);
mesh.indices.push(base + 2 + i as u32);
}
}
fn resolve_optional_position(
ch: Option<&Channel>,
scale_bound: Option<&crate::plot::scale::Scale>,
row: usize,
band: f64,
) -> Option<f64> {
let value = match ch? {
Channel::Constant(v) | Channel::RawConstant(v) => v.clone(),
Channel::Data(col) | Channel::RawData(col) => col.get(row),
};
let scale = match ch? {
Channel::RawConstant(_) | Channel::RawData(_) => None,
_ => scale_bound,
};
let f = resolve_position(value, scale, band);
if f.is_finite() {
Some(f)
} else {
None
}
}
#[derive(Clone, Copy)]
pub(crate) struct RowFill<'a> {
fill: ChannelBind<'a>,
fill_opacity: ChannelBind<'a>,
fallback: Color,
space: ColorSpace,
}
impl<'a> RowFill<'a> {
pub(crate) fn new(
fill: ChannelBind<'a>,
fill_opacity: ChannelBind<'a>,
fallback: Color,
) -> Self {
RowFill {
fill,
fill_opacity,
fallback,
space: channel_color_space(fill.scale),
}
}
pub(crate) fn at(&self, row: usize) -> Color {
override_alpha(
resolve_color_channel(self.fill.ch, self.fill.scale, row),
resolve_number_channel(self.fill_opacity.ch, self.fill_opacity.scale, row),
)
.unwrap_or(self.fallback)
}
pub(crate) fn between(&self, row_a: usize, row_b: usize, t: f64) -> Color {
crate::color::lerp_color(self.at(row_a), self.at(row_b), t, self.space)
}
}
fn build_per_vertex_colors(
vertex_origins: &[VertexOrigin],
fill: &RowFill<'_>,
) -> (Vec<Color>, Vec<Color>) {
let mut colors: Vec<Color> = Vec::with_capacity(vertex_origins.len());
for origin in vertex_origins {
let c = if origin.prev_row == origin.next_row {
fill.at(origin.prev_row)
} else {
fill.between(origin.prev_row, origin.next_row, origin.t)
};
colors.push(c);
}
let colors_b = colors.clone();
(colors, colors_b)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::color::Color;
use crate::geometry::Rect;
use crate::plot::geom::{DirectScaleResolver, Raw};
use crate::plot::value::Value;
use crate::scene::recording::{Op, RecordingScene};
fn shapes() -> crate::shape::ShapeRegistry {
crate::shape::ShapeRegistry::with_builtins()
}
fn ctx<'a>(
panel: Rect,
registry: &'a crate::shape::ShapeRegistry,
scales: &'a DirectScaleResolver<'a>,
) -> GeomContext<'a> {
GeomContext::new(panel, 96.0, registry, scales)
}
fn red() -> Color {
Color::new([1.0, 0.0, 0.0, 1.0])
}
fn blue() -> Color {
Color::new([0.0, 0.0, 1.0, 1.0])
}
fn painted_alphas(g: &mut RibbonGeom) -> (Vec<f32>, Vec<f32>) {
g.rebuild_diff_against_previous();
let panel = Rect::new(0.0, 0.0, 200.0, 200.0);
let registry = shapes();
let scales = DirectScaleResolver::new();
let mut scene = RecordingScene::default();
g.draw(&mut scene, &ctx(panel, ®istry, &scales));
let (mut fills, mut strokes) = (Vec::new(), Vec::new());
for op in &scene.ops {
match op {
Op::Fill {
brush: crate::brush::Brush::Solid(c),
..
} => fills.push(c.components[3]),
Op::Stroke {
brush: crate::brush::Brush::Solid(c),
..
} => strokes.push(c.components[3]),
_ => {}
}
}
(fills, strokes)
}
#[test]
fn fill_opacity_and_per_curve_stroke_opacity_act_independently() {
let mut g = RibbonGeom::builder()
.set("x", Raw(vec![0.1_f64, 0.5, 0.9]))
.set("y", Raw(vec![0.8_f64, 0.7, 0.9]))
.set("y2", Raw(vec![0.2_f64, 0.3, 0.1]))
.set("fill", red())
.set("fill_opacity", 0.3_f64)
.set("stroke", blue())
.set("stroke_opacity", 0.6_f64)
.set("stroke2", blue())
.set("stroke_opacity2", 0.9_f64)
.build();
let (fills, mut strokes) = painted_alphas(&mut g);
assert!(
fills.iter().all(|a| (a - 0.3).abs() < 1e-6),
"band fill alphas {fills:?}"
);
strokes.sort_by(f32::total_cmp);
strokes.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
assert_eq!(strokes.len(), 2, "one alpha per outline: {strokes:?}");
assert!((strokes[0] - 0.6).abs() < 1e-6, "curve A {:?}", strokes[0]);
assert!((strokes[1] - 0.9).abs() < 1e-6, "curve B {:?}", strokes[1]);
}
#[test]
fn no_keys_synthesises_single_mark() {
let g = RibbonGeom::builder()
.set("x", vec![0.0_f64, 1.0, 2.0])
.set("y", vec![1.0_f64, 2.0, 1.0])
.set("y2", 0.0_f64)
.build();
assert_eq!(g.len(), 3);
assert_eq!(g.mark_count(), 1);
}
#[test]
fn explicit_keys_define_marks() {
let g = RibbonGeom::builder()
.keys(vec!["A", "A", "A", "B", "B", "B"])
.set("x", vec![0.0_f64, 1.0, 2.0, 0.0, 1.0, 2.0])
.set("y", vec![1.0_f64, 2.0, 1.0, 0.5, 1.5, 0.5])
.set("y2", 0.0_f64)
.build();
assert_eq!(g.mark_count(), 2);
}
#[test]
fn explicit_y2_selects_horizontal() {
let g = RibbonGeom::builder()
.set("x", vec![0.0_f64, 1.0, 2.0])
.set("y", vec![1.0_f64, 2.0, 1.0])
.set("y2", vec![0.2_f64, 0.4, 0.3])
.build();
assert_eq!(g.orientation, Orientation::Horizontal);
}
#[test]
fn x2_selects_vertical_mode() {
let g = RibbonGeom::builder()
.set("x", vec![0.0_f64, 0.5, 1.0])
.set("y", vec![0.0_f64, 0.5, 1.0])
.set("x2", vec![0.2_f64, 0.7, 1.2])
.build();
assert_eq!(g.orientation, Orientation::Vertical);
}
#[test]
fn both_x2_and_y2_selects_free() {
let g = RibbonGeom::builder()
.set("x", vec![0.0_f64, 1.0])
.set("y", vec![0.0_f64, 1.0])
.set("x2", vec![0.2_f64, 0.8])
.set("y2", vec![0.2_f64, 0.8])
.build();
assert_eq!(g.orientation, Orientation::Free);
}
#[test]
#[should_panic(expected = "needs at least one")]
fn no_curve_b_channel_panics() {
RibbonGeom::builder()
.set("x", vec![0.0_f64, 1.0])
.set("y", vec![0.0_f64, 1.0])
.build();
}
#[test]
#[should_panic(expected = "missing required channel")]
fn missing_x_panics() {
RibbonGeom::builder().set("y", vec![0.0_f64, 1.0]).build();
}
#[test]
#[should_panic(expected = "missing required channel")]
fn missing_y_panics() {
RibbonGeom::builder().set("x", vec![0.0_f64, 1.0]).build();
}
#[test]
#[should_panic(expected = "does not match")]
fn length_mismatch_panics() {
RibbonGeom::builder()
.set("x", vec![0.0_f64, 1.0, 2.0])
.set("y", vec![1.0_f64, 2.0])
.build();
}
fn draw_and_record(mut g: RibbonGeom) -> RecordingScene {
g.rebuild_diff_against_previous();
let shapes = shapes();
let scales = DirectScaleResolver::new();
let mut scene = RecordingScene::default();
g.draw(
&mut scene,
&ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &scales),
);
scene
}
#[test]
fn constant_fill_uses_solid_brush() {
let g = RibbonGeom::builder()
.set("x", Raw(vec![0.1_f64, 0.5, 0.9]))
.set("y", Raw(vec![0.5_f64, 0.7, 0.5]))
.set("y2", Raw(0.2_f64))
.set("fill", red())
.build();
let scene = draw_and_record(g);
let solid_fills = scene
.ops
.iter()
.filter(|op| {
matches!(
op,
Op::Fill {
brush: Brush::Solid(_),
..
}
)
})
.count();
let gradient_fills = scene
.ops
.iter()
.filter(|op| {
matches!(
op,
Op::Fill {
brush: Brush::Gradient(_),
..
}
)
})
.count();
assert_eq!(solid_fills, 1);
assert_eq!(gradient_fills, 0);
}
#[test]
fn varying_fill_uses_gradient_brush_horizontal() {
let g = RibbonGeom::builder()
.set("x", Raw(vec![0.1_f64, 0.5, 0.9]))
.set("y", Raw(vec![0.5_f64, 0.7, 0.5]))
.set("y2", Raw(0.2_f64))
.set("fill", vec![red(), blue(), red()])
.build();
let scene = draw_and_record(g);
for op in &scene.ops {
if let Op::Fill {
brush: Brush::Gradient(g),
..
} = op
{
if let crate::brush::GradientKind::Linear(crate::brush::LinearGradientPosition {
start,
end,
}) = g.kind
{
assert!((start.y - end.y).abs() < f64::EPSILON);
assert!(start.x < end.x);
} else {
panic!("expected linear gradient");
}
assert!(g.stops.len() >= 2);
return;
}
}
panic!("no gradient fill emitted");
}
#[test]
fn varying_fill_uses_gradient_brush_vertical() {
let g = RibbonGeom::builder()
.set("x", Raw(vec![0.5_f64, 0.7, 0.5]))
.set("y", Raw(vec![0.1_f64, 0.5, 0.9]))
.set("x2", Raw(vec![0.3_f64, 0.3, 0.3]))
.set("fill", vec![red(), blue(), red()])
.build();
let scene = draw_and_record(g);
for op in &scene.ops {
if let Op::Fill {
brush: Brush::Gradient(g),
..
} = op
{
if let crate::brush::GradientKind::Linear(crate::brush::LinearGradientPosition {
start,
end,
}) = g.kind
{
assert!((start.x - end.x).abs() < f64::EPSILON);
assert!(start.y < end.y);
} else {
panic!("expected linear gradient");
}
return;
}
}
panic!("no gradient fill emitted");
}
#[test]
fn stroke_only_curve_a() {
let g = RibbonGeom::builder()
.set("x", Raw(vec![0.1_f64, 0.5, 0.9]))
.set("y", Raw(vec![0.5_f64, 0.7, 0.5]))
.set("y2", Raw(0.2_f64))
.set("stroke", red())
.set("linewidth", 2.0_f64)
.build();
let scene = draw_and_record(g);
let strokes: Vec<&Op> = scene
.ops
.iter()
.filter(|op| matches!(op, Op::Stroke { .. }))
.collect();
assert_eq!(strokes.len(), 1);
}
#[test]
fn stroke_only_curve_b() {
let g = RibbonGeom::builder()
.set("x", Raw(vec![0.1_f64, 0.5, 0.9]))
.set("y", Raw(vec![0.5_f64, 0.7, 0.5]))
.set("y2", Raw(0.2_f64))
.set("stroke2", blue())
.set("linewidth2", 2.0_f64)
.build();
let scene = draw_and_record(g);
let strokes: Vec<&Op> = scene
.ops
.iter()
.filter(|op| matches!(op, Op::Stroke { .. }))
.collect();
assert_eq!(strokes.len(), 1);
}
#[test]
fn curve_b_independent_linetype_dashes() {
use crate::plot::value::LinetypeStep;
use std::sync::Arc;
let dashed: Arc<[LinetypeStep]> =
Arc::from(vec![LinetypeStep::Dash(4.0), LinetypeStep::Gap(2.0)]);
let g = RibbonGeom::builder()
.set("x", Raw(vec![0.1_f64, 0.5, 0.9]))
.set("y", Raw(vec![0.5_f64, 0.7, 0.5]))
.set("y2", Raw(vec![0.2_f64, 0.3, 0.2]))
.set("stroke", red())
.set("stroke2", blue())
.set("linewidth", 2.0_f64)
.set("linewidth2", 2.0_f64)
.set("linetype2", Value::Linetype(dashed))
.build();
let scene = draw_and_record(g);
let strokes: Vec<&Op> = scene
.ops
.iter()
.filter(|op| matches!(op, Op::Stroke { .. }))
.collect();
assert_eq!(strokes.len(), 2);
let mut found_solid_red = false;
let mut found_dashed_blue = false;
for op in &strokes {
if let Op::Stroke {
brush: Brush::Solid(c),
stroke,
..
} = op
{
let is_dashed = !stroke.dash_pattern.is_empty();
let is_red = c.components[0] > 0.99 && c.components[2] < 0.01;
let is_blue = c.components[0] < 0.01 && c.components[2] > 0.99;
if is_red && !is_dashed {
found_solid_red = true;
}
if is_blue && is_dashed {
found_dashed_blue = true;
}
}
}
assert!(found_solid_red, "expected solid red stroke on curve A");
assert!(found_dashed_blue, "expected dashed blue stroke on curve B");
}
#[test]
fn clip_start_radius2_clips_curve_b_only() {
let g = RibbonGeom::builder()
.set("x", Raw(vec![0.1_f64, 0.5, 0.9]))
.set("y", Raw(vec![0.5_f64, 0.7, 0.5]))
.set("y2", Raw(vec![0.2_f64, 0.3, 0.2]))
.set("stroke", red())
.set("stroke2", blue())
.set("linewidth", 2.0_f64)
.set("linewidth2", 2.0_f64)
.set("clip_start_radius2", 5.0_f64)
.build();
let scene = draw_and_record(g);
let strokes_count = scene
.ops
.iter()
.filter(|op| matches!(op, Op::Stroke { .. }))
.count();
assert_eq!(strokes_count, 2);
let mut curve_a_first_x = None;
let mut curve_b_first_x = None;
for op in &scene.ops {
if let Op::Stroke {
brush: Brush::Solid(c),
path,
..
} = op
{
let first_x = path
.elements()
.iter()
.find_map(|el| match el {
crate::path::PathEl::MoveTo(p) => Some(p.x),
_ => None,
})
.unwrap();
let is_red = c.components[0] > 0.99 && c.components[2] < 0.01;
let is_blue = c.components[0] < 0.01 && c.components[2] > 0.99;
if is_red {
curve_a_first_x = Some(first_x);
} else if is_blue {
curve_b_first_x = Some(first_x);
}
}
}
let a_x = curve_a_first_x.expect("curve A stroke missing");
let b_x = curve_b_first_x.expect("curve B stroke missing");
assert!(
b_x > a_x + 1.0,
"curve B should be clipped forward of curve A's first vertex (a_x={a_x}, b_x={b_x})"
);
}
#[test]
fn curve_b_independent_endpoint_markers() {
let g = RibbonGeom::builder()
.set("x", Raw(vec![0.1_f64, 0.5, 0.9]))
.set("y", Raw(vec![0.5_f64, 0.7, 0.5]))
.set("y2", Raw(vec![0.2_f64, 0.3, 0.2]))
.set("stroke", red())
.set("stroke2", blue())
.set("linewidth", 2.0_f64)
.set("linewidth2", 2.0_f64)
.set("start_marker2", "circle")
.set("end_marker2", "circle")
.build();
let scene = draw_and_record(g);
let strokes = scene
.ops
.iter()
.filter(|op| matches!(op, Op::Stroke { .. }))
.count();
let fills = scene
.ops
.iter()
.filter(|op| matches!(op, Op::Fill { .. }))
.count();
assert_eq!(strokes, 2, "expected two curve strokes");
assert_eq!(fills, 2, "expected one fill per curve-B marker");
}
#[test]
fn stroke_both_curves() {
let g = RibbonGeom::builder()
.set("x", Raw(vec![0.1_f64, 0.5, 0.9]))
.set("y", Raw(vec![0.5_f64, 0.7, 0.5]))
.set("y2", Raw(vec![0.2_f64, 0.3, 0.2]))
.set("stroke", red())
.set("stroke2", blue())
.set("linewidth", 2.0_f64)
.set("linewidth2", 2.0_f64)
.build();
let scene = draw_and_record(g);
let strokes: Vec<&Op> = scene
.ops
.iter()
.filter(|op| matches!(op, Op::Stroke { .. }))
.collect();
assert_eq!(strokes.len(), 2);
}
#[test]
fn no_fill_no_stroke_emits_nothing() {
let g = RibbonGeom::builder()
.set("x", Raw(vec![0.1_f64, 0.5, 0.9]))
.set("y", Raw(vec![0.5_f64, 0.7, 0.5]))
.set("y2", Raw(0.2_f64))
.build();
let scene = draw_and_record(g);
assert!(scene.ops.is_empty());
}
#[test]
fn nonfinite_row_dropped() {
let g = RibbonGeom::builder()
.set("x", Raw(vec![0.1_f64, 0.4, 0.7, 0.9]))
.set("y", Raw(vec![0.5_f64, f64::NAN, 0.8, 0.5]))
.set("y2", Raw(0.2_f64))
.set("fill", red())
.build();
let scene = draw_and_record(g);
let fills = scene
.ops
.iter()
.filter(|op| matches!(op, Op::Fill { .. }))
.count();
assert_eq!(fills, 1);
}
#[test]
fn closed_contour_has_one_close() {
let g = RibbonGeom::builder()
.set("x", Raw(vec![0.1_f64, 0.5, 0.9]))
.set("y", Raw(vec![0.5_f64, 0.7, 0.5]))
.set("y2", Raw(0.2_f64))
.set("fill", red())
.build();
let scene = draw_and_record(g);
for op in &scene.ops {
if let Op::Fill { path, .. } = op {
let closes = path
.elements()
.iter()
.filter(|el| matches!(el, crate::path::PathEl::ClosePath))
.count();
assert_eq!(closes, 1);
return;
}
}
panic!("no fill emitted");
}
#[test]
fn fill_path_walks_a_forward_then_b_reversed() {
let g = RibbonGeom::builder()
.set("x", Raw(vec![0.1_f64, 0.5, 0.9]))
.set("y", Raw(vec![0.8_f64, 0.8, 0.8]))
.set("y2", Raw(vec![0.2_f64, 0.2, 0.2]))
.set("fill", red())
.build();
let scene = draw_and_record(g);
for op in &scene.ops {
if let Op::Fill { path, .. } = op {
let elements: Vec<_> = path.elements().iter().collect();
if let crate::path::PathEl::MoveTo(start) = &elements[0] {
assert!((start.y - 20.0).abs() < 1.0);
assert!((start.x - 10.0).abs() < 1.0);
} else {
panic!("first element not MoveTo");
}
return;
}
}
panic!("no fill emitted");
}
#[test]
fn declared_channels_alphabetical() {
let g = RibbonGeom::builder()
.set("x", vec![0.0_f64, 1.0, 2.0])
.set("y", vec![1.0_f64, 2.0, 1.0])
.set("y2", 0.0_f64)
.set("fill", red())
.build();
let names: Vec<&str> = g.declared_channels().iter().map(|d| d.name).collect();
let mut sorted = names.clone();
sorted.sort();
assert_eq!(names, sorted);
}
#[test]
fn diff_marks_enter_on_first_draw() {
let mut g = RibbonGeom::builder()
.keys(vec!["A", "A", "A", "B", "B", "B"])
.set("x", vec![0.0_f64, 1.0, 2.0, 0.0, 1.0, 2.0])
.set("y", vec![1.0_f64, 2.0, 1.0, 0.5, 1.5, 0.5])
.set("y2", 0.0_f64)
.build();
g.rebuild_diff_against_previous();
assert_eq!(g.state.enter.len(), 2);
assert_eq!(g.state.exit.len(), 0);
}
#[test]
fn polar_band_densifies_edges() {
use crate::plot::projection::Projection;
let polar = Projection::polar();
let mut g = RibbonGeom::builder()
.set("x", Raw(vec![0.1_f64, 0.5, 0.9]))
.set("y", Raw(vec![0.8_f64, 0.8, 0.8]))
.set("y2", Raw(vec![0.4_f64, 0.4, 0.4]))
.set("fill", red())
.build();
g.rebuild_diff_against_previous();
let shapes = shapes();
let scales = DirectScaleResolver::new();
let mut scene = RecordingScene::default();
let panel = Rect::new(0.0, 0.0, 200.0, 200.0);
let ctx = GeomContext::with_projection(panel, 96.0, &shapes, &scales, &polar);
g.draw(&mut scene, &ctx);
for op in &scene.ops {
if let Op::Fill { path, .. } = op {
let line_count = path
.elements()
.iter()
.filter(|el| matches!(el, crate::path::PathEl::LineTo(_)))
.count();
assert!(
line_count > 6,
"expected densified line count > 6, got {line_count}"
);
return;
}
}
panic!("no fill emitted");
}
#[test]
fn polar_band_caps_are_densified() {
use crate::plot::projection::Projection;
let polar = Projection::polar();
let mut g = RibbonGeom::builder()
.set("x", Raw(vec![0.1_f64, 0.5, 0.9]))
.set("y", Raw(vec![0.8_f64, 0.8, 0.8]))
.set("x2", Raw(vec![0.2_f64, 0.5, 0.8]))
.set("y2", Raw(vec![0.4_f64, 0.4, 0.4]))
.set("fill", red())
.build();
g.rebuild_diff_against_previous();
let shapes = shapes();
let scales = DirectScaleResolver::new();
let mut scene = RecordingScene::default();
let panel = Rect::new(0.0, 0.0, 200.0, 200.0);
let ctx = GeomContext::with_projection(panel, 96.0, &shapes, &scales, &polar);
g.draw(&mut scene, &ctx);
for op in &scene.ops {
if let Op::Fill { path, .. } = op {
let lines: Vec<crate::geometry::Point> = path
.elements()
.iter()
.filter_map(|el| match el {
crate::path::PathEl::LineTo(p) => Some(*p),
_ => None,
})
.collect();
let move_to = path
.elements()
.iter()
.find_map(|el| match el {
crate::path::PathEl::MoveTo(p) => Some(*p),
_ => None,
})
.expect("expected a MoveTo");
let mut all_pts = vec![move_to];
all_pts.extend(lines.iter().copied());
let total_lines = lines.len();
assert!(
total_lines > 6,
"expected densified polygon, got {total_lines} line segments"
);
let collinear_eps = 0.5_f64; let mut cap_arc_count = 0usize;
for w in all_pts.windows(3) {
let (a, b, c) = (w[0], w[1], w[2]);
let area2 = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
if area2.abs() > collinear_eps {
cap_arc_count += 1;
}
}
assert!(
cap_arc_count > 0,
"expected at least one off-chord (curved) interior sample, got {cap_arc_count}"
);
return;
}
}
panic!("no fill emitted");
}
#[test]
fn free_orientation_solid_fill_emits_path() {
let g = RibbonGeom::builder()
.set("x", Raw(vec![0.1_f64, 0.5, 0.9]))
.set("y", Raw(vec![0.5_f64, 0.7, 0.5]))
.set("x2", Raw(vec![0.1_f64, 0.5, 0.9]))
.set("y2", Raw(vec![0.2_f64, 0.4, 0.2]))
.set("fill", red())
.build();
assert_eq!(g.orientation, Orientation::Free);
let scene = draw_and_record(g);
let fills = scene
.ops
.iter()
.filter(|op| matches!(op, Op::Fill { .. }))
.count();
let meshes = scene
.ops
.iter()
.filter(|op| matches!(op, Op::DrawMesh { .. }))
.count();
assert_eq!(fills, 1);
assert_eq!(meshes, 0);
}
#[test]
fn free_orientation_varying_fill_uses_mesh() {
let g = RibbonGeom::builder()
.set("x", Raw(vec![0.1_f64, 0.5, 0.9]))
.set("y", Raw(vec![0.5_f64, 0.7, 0.5]))
.set("x2", Raw(vec![0.1_f64, 0.5, 0.9]))
.set("y2", Raw(vec![0.2_f64, 0.4, 0.2]))
.set("fill", vec![red(), blue(), red()])
.build();
let scene = draw_and_record(g);
let mesh_op = scene.ops.iter().find_map(|op| match op {
Op::DrawMesh { mesh, .. } => Some(mesh),
_ => None,
});
let mesh = mesh_op.expect("expected mesh draw for Free + varying fill");
assert_eq!(mesh.triangle_count(), 4);
assert_eq!(&mesh.indices[0..6], &[0, 1, 2, 0, 2, 3]);
}
#[test]
fn axis_aligned_varying_fill_under_polar_uses_mesh() {
use crate::plot::projection::Projection;
let polar = Projection::polar();
let mut g = RibbonGeom::builder()
.set("x", Raw(vec![0.1_f64, 0.5, 0.9]))
.set("y", Raw(vec![0.8_f64, 0.8, 0.8]))
.set("y2", Raw(vec![0.4_f64, 0.4, 0.4]))
.set("fill", vec![red(), blue(), red()])
.build();
g.rebuild_diff_against_previous();
let shapes = shapes();
let scales = DirectScaleResolver::new();
let mut scene = RecordingScene::default();
let panel = Rect::new(0.0, 0.0, 200.0, 200.0);
let ctx = GeomContext::with_projection(panel, 96.0, &shapes, &scales, &polar);
g.draw(&mut scene, &ctx);
let meshes = scene
.ops
.iter()
.filter(|op| matches!(op, Op::DrawMesh { .. }))
.count();
let gradient_fills = scene
.ops
.iter()
.filter(|op| {
matches!(
op,
Op::Fill {
brush: Brush::Gradient(_),
..
}
)
})
.count();
assert_eq!(
meshes, 1,
"expected mesh dispatch under polar + varying fill"
);
assert_eq!(
gradient_fills, 0,
"gradient brush should not run under non-linear projection"
);
}
#[test]
fn pick_id_per_mark_resolves_from_first_row() {
let g = RibbonGeom::builder()
.keys(vec!["A", "A", "A", "B", "B", "B"])
.set("x", Raw(vec![0.1_f64, 0.3, 0.5, 0.6, 0.7, 0.9]))
.set("y", Raw(vec![0.5_f64, 0.7, 0.5, 0.4, 0.6, 0.4]))
.set("y2", Raw(0.2_f64))
.set("fill", red())
.set("pick_id", vec![1001_i64, 0, 0, 2002, 0, 0])
.build();
let scene = draw_and_record(g);
let picks: Vec<u32> = scene
.ops
.iter()
.filter_map(|op| match op {
Op::Fill {
pick_id: crate::pick::PickId::Id(n),
..
} => Some(*n),
_ => None,
})
.collect();
assert_eq!(picks, vec![1001, 2002]);
}
}