use std::cmp::Ordering;
use crate::brush::Brush;
use crate::color::Color;
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::bspline_eval::{
build_polyline_fallback, build_spline_flatten, de_boor, project_ctrl_pts, InterpolationSpace,
};
use super::marks::{build_marks_from_column, MarkSlot};
use super::outline::{draw_curve_outline, resolve_outline_spec, OutlineChannels, OutlineScales};
use super::resolve::{
apply_per_row_offsets, override_alpha, resolve_color_channel_or_theme, resolve_number_channel,
resolve_number_channel_or, resolve_pick_id, resolve_position, resolve_str_channel_or,
ChannelBind,
};
use super::ribbon::{append_cap_fan_to_mesh, resolve_b_row, CapDirection, Orientation, RowFill};
use super::state::{finalize_state, require_x_and_siblings, GeomState, KeysStrategy};
use super::{BuildableGeom, Channel, ExpectedOutput, Geom, GeomBuilder, GeomContext, Keys};
const DEFAULT_DEGREE: usize = 3;
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),
("degree", ExpectedOutput::Numbers),
("interpolation", ExpectedOutput::Strings),
("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),
];
pub struct RibbonBSplineGeom {
pub(crate) state: GeomState,
pub(crate) marks: Vec<MarkSlot>,
pub(crate) orientation: Orientation,
}
crate::impl_geom_inherents_grouped!(RibbonBSplineGeom);
impl RibbonBSplineGeom {
pub(crate) fn build_marks(&self) -> Vec<MarkSlot> {
super::marks::build_marks(&self.state.keys)
}
}
impl BuildableGeom for RibbonBSplineGeom {
fn build_from(builder: GeomBuilder<Self>) -> Self {
let (keys_opt, channels) = builder.into_parts();
let n = require_x_and_siblings(&channels, &["y"], "RibbonBSplineGeom");
let has_x2 = channels.contains_key("x2");
let has_y2 = channels.contains_key("y2");
let orientation = match (has_x2, has_y2) {
(false, false) => panic!(
"RibbonBSplineGeom::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,
"RibbonBSplineGeom",
);
RibbonBSplineGeom {
state,
marks: Vec::new(),
orientation,
}
}
}
#[derive(Clone, Copy)]
struct RibbonBSplineDrawCtx<'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>,
degree: ChannelBind<'a>,
interpolation: 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> RibbonBSplineDrawCtx<'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"),
degree: b("degree"),
interpolation: b("interpolation"),
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 RibbonBSplineGeom {
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-bspline")
}
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),
"RibbonBSplineGeom",
);
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 RibbonBSplineDrawCtx::build(&self.state.channels, ctx, self.orientation) {
Some(dc) => dc,
None => return,
};
for mark in marks.iter() {
draw_one_ribbon_bspline_mark(scene, ctx, panel, dc, mark);
}
}
}
fn draw_one_ribbon_bspline_mark(
scene: &mut dyn SceneBuilder,
ctx: &GeomContext<'_>,
panel: Rect,
dc: RibbonBSplineDrawCtx<'_>,
mark: &MarkSlot,
) {
let RibbonBSplineDrawCtx {
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,
degree: ChannelBind {
ch: degree_ch,
scale: degree_scale,
},
interpolation:
ChannelBind {
ch: interpolation_ch,
scale: interpolation_scale,
},
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_bspline.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_bspline).into(),
&outline_a_ch,
&outline_a_scales,
ChannelBind::default(),
i0,
pick,
);
let outline_b_spec = resolve_outline_spec(
ctx,
(&ctx.theme.geom.ribbon_bspline).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 degree_req =
resolve_number_channel_or(degree_ch, degree_scale, i0, DEFAULT_DEGREE as f64) as usize;
let interp_mode_str =
resolve_str_channel_or(interpolation_ch, interpolation_scale, i0, "domain");
let interpolation_mode = match interp_mode_str.as_str() {
"panel" => InterpolationSpace::Panel,
_ => InterpolationSpace::Domain,
};
let mut ctrl_a: Vec<Point> = Vec::with_capacity(mark.rows.len());
let mut ctrl_b: Vec<Point> = Vec::with_capacity(mark.rows.len());
let mut row_for_ctrl: Vec<usize> = Vec::with_capacity(mark.rows.len());
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,
};
ctrl_a.push(Point::new(x_frac, y_frac));
ctrl_b.push(Point::new(b_x_frac, b_y_frac));
row_for_ctrl.push(i);
}
let n_ctrl = ctrl_a.len();
if n_ctrl < 2 {
return;
}
let degenerate = n_ctrl < degree_req.max(1) + 1;
let samples_a = if degenerate {
build_polyline_fallback(&ctrl_a, panel, ctx)
} else {
build_spline_flatten(&ctrl_a, degree_req, panel, ctx, interpolation_mode)
};
let samples_b = if degenerate {
build_polyline_fallback(&ctrl_b, panel, ctx)
} else {
build_spline_flatten(&ctrl_b, degree_req, panel, ctx, interpolation_mode)
};
if samples_a.len() < 2 || samples_b.len() < 2 {
return;
}
let mut curve_a_pts: Vec<Point> = samples_a.iter().map(|(_, p)| *p).collect();
let mut curve_b_pts: Vec<Point> = samples_b.iter().map(|(_, p)| *p).collect();
let us_a: Vec<f64> = samples_a.iter().map(|(u, _)| *u).collect();
let us_b: Vec<f64> = samples_b.iter().map(|(u, _)| *u).collect();
apply_per_row_offsets(
&mut curve_a_pts,
&us_a,
&row_for_ctrl,
x_offset_ch,
x_offset_scale,
y_offset_ch,
y_offset_scale,
ctx.dpi,
);
apply_per_row_offsets(
&mut curve_b_pts,
&us_b,
&row_for_ctrl,
x2_offset_ch,
x2_offset_scale,
y2_offset_ch,
y2_offset_scale,
ctx.dpi,
);
let is_linear = ctx.projection.is_linear();
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 {
let first_a = [ctrl_a[0].x, ctrl_a[0].y];
let first_b = [ctrl_b[0].x, ctrl_b[0].y];
let last_a = [ctrl_a[n_ctrl - 1].x, ctrl_a[n_ctrl - 1].y];
let last_b = [ctrl_b[n_ctrl - 1].x, ctrl_b[n_ctrl - 1].y];
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 = super::resolve::channel_varies_across(fill.ch, fill.scale, &row_for_ctrl)
|| super::resolve::channel_varies_across(
fill_opacity.ch,
fill_opacity.scale,
&row_for_ctrl,
);
if varies {
let (mut curve_a_merged, mut curve_b_merged, merged_u) = build_merged_grid(
&samples_a,
&samples_b,
&ctrl_a,
&ctrl_b,
degree_req,
n_ctrl,
degenerate,
panel,
ctx,
interpolation_mode,
);
apply_per_row_offsets(
&mut curve_a_merged,
&merged_u,
&row_for_ctrl,
x_offset_ch,
x_offset_scale,
y_offset_ch,
y_offset_scale,
ctx.dpi,
);
apply_per_row_offsets(
&mut curve_b_merged,
&merged_u,
&row_for_ctrl,
x2_offset_ch,
x2_offset_scale,
y2_offset_ch,
y2_offset_scale,
ctx.dpi,
);
if curve_a_merged.len() >= 2 {
let colors = build_per_vertex_colors(
&merged_u,
&row_for_ctrl,
&RowFill::new(fill, fill_opacity, mark_color),
);
let mut mesh = crate::primitives::ribbon_band_mesh(
&curve_a_merged,
&curve_b_merged,
&colors,
&colors,
);
if !mesh.vertices.is_empty() {
let last = curve_a_merged.len() - 1;
let start_neighbor = Point::new(
(curve_a_merged[1].x + curve_b_merged[1].x) * 0.5,
(curve_a_merged[1].y + curve_b_merged[1].y) * 0.5,
);
let end_neighbor = Point::new(
(curve_a_merged[last - 1].x + curve_b_merged[last - 1].x) * 0.5,
(curve_a_merged[last - 1].y + curve_b_merged[last - 1].y) * 0.5,
);
append_cap_fan_to_mesh(
&mut mesh,
curve_a_merged[0],
curve_b_merged[0],
start_neighbor,
&start_cap_samples,
colors[0],
CapDirection::Start,
);
append_cap_fan_to_mesh(
&mut mesh,
curve_a_merged[last],
curve_b_merged[last],
end_neighbor,
&end_cap_samples,
*colors.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 {
scene.fill(
FillRule::NonZero,
Affine::IDENTITY,
&Brush::Solid(mark_color),
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,
);
}
}
#[allow(clippy::too_many_arguments)]
fn build_merged_grid(
samples_a: &[(f64, Point)],
samples_b: &[(f64, Point)],
ctrl_a: &[Point],
ctrl_b: &[Point],
degree_req: usize,
n_ctrl: usize,
degenerate: bool,
panel: crate::geometry::Rect,
ctx: &GeomContext<'_>,
mode: InterpolationSpace,
) -> (Vec<Point>, Vec<Point>, Vec<f64>) {
if degenerate {
let a_pts: Vec<Point> = samples_a.iter().map(|(_, p)| *p).collect();
let b_pts: Vec<Point> = samples_b.iter().map(|(_, p)| *p).collect();
let merged_u: Vec<f64> = (0..n_ctrl).map(|i| i as f64).collect();
return (a_pts, b_pts, merged_u);
}
let mut merged_u: Vec<f64> = Vec::with_capacity(samples_a.len() + samples_b.len());
merged_u.extend(samples_a.iter().map(|(u, _)| *u));
merged_u.extend(samples_b.iter().map(|(u, _)| *u));
merged_u.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
merged_u.dedup_by(|a, b| (*a - *b).abs() < 1e-9);
let n_ctrl_minus_1 = (n_ctrl - 1) as f64;
let t_end = (n_ctrl - degree_req) as f64;
let to_t = |u: f64| -> f64 {
if n_ctrl_minus_1 > 0.0 {
u * t_end / n_ctrl_minus_1
} else {
0.0
}
};
let mut curve_a_merged = Vec::with_capacity(merged_u.len());
let mut curve_b_merged = Vec::with_capacity(merged_u.len());
match mode {
InterpolationSpace::Panel => {
let ctrl_a_px = project_ctrl_pts(ctrl_a, panel, ctx);
let ctrl_b_px = project_ctrl_pts(ctrl_b, panel, ctx);
for &u in &merged_u {
let t = to_t(u);
curve_a_merged.push(de_boor(&ctrl_a_px, degree_req, t));
curve_b_merged.push(de_boor(&ctrl_b_px, degree_req, t));
}
}
InterpolationSpace::Domain => {
for &u in &merged_u {
let t = to_t(u);
let p_a = de_boor(ctrl_a, degree_req, t);
let p_b = de_boor(ctrl_b, degree_req, t);
let (apx, apy) = ctx.projection.project_to_panel_px(panel, &[p_a.x, p_a.y]);
let (bpx, bpy) = ctx.projection.project_to_panel_px(panel, &[p_b.x, p_b.y]);
curve_a_merged.push(Point::new(apx, apy));
curve_b_merged.push(Point::new(bpx, bpy));
}
}
}
(curve_a_merged, curve_b_merged, merged_u)
}
fn build_per_vertex_colors(
merged_u: &[f64],
row_for_ctrl: &[usize],
fill: &RowFill<'_>,
) -> Vec<Color> {
let n_rows = row_for_ctrl.len();
merged_u
.iter()
.map(|&u| {
let u_clamped = u.clamp(0.0, (n_rows - 1) as f64);
let lo = u_clamped.floor() as usize;
let hi = (lo + 1).min(n_rows - 1);
let t = u_clamped - lo as f64;
if lo == hi || t.abs() < 1e-9 {
fill.at(row_for_ctrl[lo])
} else {
fill.between(row_for_ctrl[lo], row_for_ctrl[hi], t)
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::geometry::Rect;
use crate::plot::geom::{DirectScaleResolver, Raw};
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 draw_and_record(mut g: RibbonBSplineGeom) -> 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, 200.0, 200.0), &shapes, &scales),
);
scene
}
#[test]
fn fill_opacity_and_per_curve_stroke_opacity_act_independently() {
let scene = draw_and_record(
RibbonBSplineGeom::builder()
.set("x", Raw(vec![0.1_f64, 0.4, 0.6, 0.9]))
.set("y", Raw(vec![0.8_f64, 0.7, 0.9, 0.8]))
.set("y2", Raw(vec![0.2_f64, 0.3, 0.1, 0.2]))
.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 mut strokes: Vec<f32> = Vec::new();
for op in &scene.ops {
match op {
Op::Fill {
brush: crate::brush::Brush::Solid(c),
..
} => assert!((c.components[3] - 0.3).abs() < 1e-6, "band fill {c:?}"),
Op::Stroke {
brush: crate::brush::Brush::Solid(c),
..
} => strokes.push(c.components[3]),
_ => {}
}
}
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 explicit_y2_selects_horizontal() {
let g = RibbonBSplineGeom::builder()
.set("x", vec![0.0_f64, 0.25, 0.5, 0.75, 1.0])
.set("y", vec![0.8_f64, 0.7, 0.9, 0.6, 0.8])
.set("y2", 0.2_f64)
.build();
assert_eq!(g.orientation, Orientation::Horizontal);
}
#[test]
fn x2_selects_vertical_mode() {
let g = RibbonBSplineGeom::builder()
.set("x", vec![0.5_f64, 0.6, 0.5, 0.4, 0.5])
.set("y", vec![0.0_f64, 0.25, 0.5, 0.75, 1.0])
.set("x2", 0.2_f64)
.build();
assert_eq!(g.orientation, Orientation::Vertical);
}
#[test]
fn both_x2_and_y2_selects_free() {
let g = RibbonBSplineGeom::builder()
.set("x", vec![0.0_f64, 0.5, 1.0])
.set("y", vec![0.0_f64, 1.0, 0.0])
.set("x2", vec![0.2_f64, 0.5, 0.8])
.set("y2", vec![0.2_f64, 0.7, 0.2])
.build();
assert_eq!(g.orientation, Orientation::Free);
}
#[test]
#[should_panic(expected = "needs at least one")]
fn no_curve_b_channel_panics() {
RibbonBSplineGeom::builder()
.set("x", vec![0.0_f64, 1.0])
.set("y", vec![0.0_f64, 1.0])
.build();
}
#[test]
fn solid_fill_emits_one_path_fill() {
let g = RibbonBSplineGeom::builder()
.set("x", Raw(vec![0.1_f64, 0.3, 0.5, 0.7, 0.9]))
.set("y", Raw(vec![0.6_f64, 0.8, 0.9, 0.7, 0.6]))
.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 meshes = scene
.ops
.iter()
.filter(|op| matches!(op, Op::DrawMesh { .. }))
.count();
assert_eq!(solid_fills, 1);
assert_eq!(meshes, 0);
}
#[test]
fn varying_fill_uses_mesh() {
let g = RibbonBSplineGeom::builder()
.set("x", Raw(vec![0.1_f64, 0.3, 0.5, 0.7, 0.9]))
.set("y", Raw(vec![0.6_f64, 0.8, 0.9, 0.7, 0.6]))
.set("y2", Raw(0.2_f64))
.set("fill", vec![red(), blue(), red(), blue(), red()])
.build();
let scene = draw_and_record(g);
let meshes = scene
.ops
.iter()
.filter(|op| matches!(op, Op::DrawMesh { .. }))
.count();
let fills = scene
.ops
.iter()
.filter(|op| matches!(op, Op::Fill { .. }))
.count();
assert_eq!(meshes, 1, "expected mesh dispatch for varying fill");
assert_eq!(fills, 0, "fill should not be emitted alongside mesh");
}
#[test]
fn four_point_cubic_passes_through_endpoints() {
let g = RibbonBSplineGeom::builder()
.set("x", Raw(vec![0.1_f64, 0.3, 0.7, 0.9]))
.set("y", Raw(vec![0.8_f64, 0.9, 0.9, 0.8]))
.set("y2", Raw(vec![0.2_f64, 0.1, 0.1, 0.2]))
.set("fill", red())
.build();
let scene = draw_and_record(g);
for op in &scene.ops {
if let Op::Fill { path, .. } = op {
if let Some(crate::path::PathEl::MoveTo(start)) = path.elements().first() {
assert!((start.x - 20.0).abs() < 1.0);
assert!((start.y - 40.0).abs() < 1.0);
return;
}
}
}
panic!("no fill emitted");
}
#[test]
fn two_control_points_per_curve_renders_as_quad() {
let g = RibbonBSplineGeom::builder()
.set("x", Raw(vec![0.1_f64, 0.9]))
.set("y", Raw(vec![0.8_f64, 0.7]))
.set("y2", Raw(vec![0.2_f64, 0.3]))
.set("fill", red())
.build();
let scene = draw_and_record(g);
for op in &scene.ops {
if let Op::Fill { path, .. } = op {
let lines = path
.elements()
.iter()
.filter(|el| matches!(el, crate::path::PathEl::LineTo(_)))
.count();
assert_eq!(lines, 3);
return;
}
}
panic!("no fill emitted");
}
#[test]
fn single_row_emits_nothing() {
let g = RibbonBSplineGeom::builder()
.set("x", Raw(vec![0.5_f64]))
.set("y", Raw(vec![0.5_f64]))
.set("y2", Raw(0.2_f64))
.set("fill", red())
.build();
let scene = draw_and_record(g);
assert!(scene.ops.is_empty());
}
#[test]
fn no_fill_no_stroke_emits_nothing() {
let g = RibbonBSplineGeom::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 stroke_only_curve_a_emits_one_stroke() {
let g = RibbonBSplineGeom::builder()
.set("x", Raw(vec![0.1_f64, 0.3, 0.7, 0.9]))
.set("y", Raw(vec![0.6_f64, 0.8, 0.9, 0.7]))
.set("y2", Raw(0.2_f64))
.set("stroke", red())
.set("linewidth", 2.0_f64)
.build();
let scene = draw_and_record(g);
let strokes = scene
.ops
.iter()
.filter(|op| matches!(op, Op::Stroke { .. }))
.count();
assert_eq!(strokes, 1);
}
#[test]
fn stroke_both_curves_emits_two_strokes() {
let g = RibbonBSplineGeom::builder()
.set("x", Raw(vec![0.1_f64, 0.3, 0.7, 0.9]))
.set("y", Raw(vec![0.6_f64, 0.8, 0.9, 0.7]))
.set("y2", Raw(vec![0.2_f64, 0.3, 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 = scene
.ops
.iter()
.filter(|op| matches!(op, Op::Stroke { .. }))
.count();
assert_eq!(strokes, 2);
}
#[test]
fn polar_band_caps_are_densified_in_panel_mode() {
use crate::plot::projection::Projection;
let polar = Projection::polar();
let mut g = RibbonBSplineGeom::builder()
.set("x", Raw(vec![0.1_f64, 0.5, 0.9]))
.set("y", Raw(vec![0.8_f64, 0.85, 0.8]))
.set("x2", Raw(vec![0.2_f64, 0.5, 0.8]))
.set("y2", Raw(vec![0.4_f64, 0.45, 0.4]))
.set("interpolation", "panel")
.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 mut all_pts: Vec<crate::geometry::Point> = Vec::new();
for el in path.elements().iter() {
match el {
crate::path::PathEl::MoveTo(p) | crate::path::PathEl::LineTo(p) => {
all_pts.push(*p)
}
_ => {}
}
}
let mut off_chord = 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() > 0.5 {
off_chord += 1;
}
}
assert!(
off_chord > 0,
"expected at least one off-chord vertex in the polar cap"
);
return;
}
}
panic!("no fill emitted");
}
#[test]
fn polar_mesh_path_appends_cap_fan_triangles_for_outward_bulge() {
use crate::plot::projection::Projection;
let polar = Projection::polar();
let mut g = RibbonBSplineGeom::builder()
.set("x", Raw(vec![0.1_f64, 0.1, 0.9, 0.9]))
.set("y", Raw(vec![0.9_f64, 0.0, 0.0, 0.9]))
.set("x2", Raw(vec![0.2_f64, 0.2, 0.8, 0.8]))
.set("y2", Raw(vec![0.9_f64, 0.0, 0.0, 0.9]))
.set("fill", vec![red(), red(), blue(), blue()])
.build();
g.rebuild_diff_against_previous();
let shapes = shapes();
let scales = DirectScaleResolver::new();
let mut scene = RecordingScene::default();
let panel = crate::geometry::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 mesh = scene
.ops
.iter()
.find_map(|op| match op {
Op::DrawMesh { mesh, .. } => Some(mesh),
_ => None,
})
.expect("no mesh emitted");
let strip_n_pairs = mesh.vertices.len() / 4; let strip_triangles = 2 * strip_n_pairs.saturating_sub(1);
assert!(
mesh.triangle_count() > strip_triangles,
"expected cap fan to add triangles beyond the bare strip (got {} triangles, {} from strip)",
mesh.triangle_count(),
strip_triangles
);
}
#[test]
fn polar_mesh_path_wraps_in_clip_layer() {
use crate::plot::projection::Projection;
let polar = Projection::polar();
let mut g = RibbonBSplineGeom::builder()
.set("x", Raw(vec![0.1_f64, 0.5, 0.9]))
.set("y", Raw(vec![0.8_f64, 0.85, 0.8]))
.set("x2", Raw(vec![0.2_f64, 0.5, 0.8]))
.set("y2", Raw(vec![0.4_f64, 0.45, 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 = crate::geometry::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 mut saw_push = false;
let mut saw_mesh_after_push = false;
let mut saw_pop_after_mesh = false;
for op in &scene.ops {
match op {
Op::PushLayer { .. } => saw_push = true,
Op::DrawMesh { .. } if saw_push => saw_mesh_after_push = true,
Op::PopLayer if saw_mesh_after_push => saw_pop_after_mesh = true,
_ => {}
}
}
assert!(
saw_push && saw_mesh_after_push && saw_pop_after_mesh,
"expected push_layer → draw_mesh → pop_layer sequence under polar"
);
}
#[test]
fn polar_mesh_path_skips_cap_fan_when_cap_bulges_into_strip() {
use crate::plot::projection::Projection;
let polar = Projection::polar();
let mut g = RibbonBSplineGeom::builder()
.set("x", Raw(vec![0.1_f64, 0.5, 0.9]))
.set("y", Raw(vec![0.8_f64, 0.85, 0.8]))
.set("x2", Raw(vec![0.2_f64, 0.5, 0.8]))
.set("y2", Raw(vec![0.4_f64, 0.45, 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 = crate::geometry::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 mesh = scene
.ops
.iter()
.find_map(|op| match op {
Op::DrawMesh { mesh, .. } => Some(mesh),
_ => None,
})
.expect("no mesh emitted");
assert_eq!(
mesh.vertices.len() % 4,
0,
"skip case should leave only quad-pair vertices, got {}",
mesh.vertices.len()
);
assert_eq!(
mesh.indices.len() % 6,
0,
"skip case should leave only quad-pair indices, got {}",
mesh.indices.len()
);
}
#[test]
fn declared_channels_alphabetical() {
let g = RibbonBSplineGeom::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);
}
}