use crate::color::Color;
use crate::geometry::{Affine, Point};
#[cfg(test)]
use crate::path::FillRule;
use crate::plot::scale::Scale;
use crate::plot::value::DataColumn;
use crate::primitives::{polygon_ribbon_full, CornerRounding, RibbonOptions};
use crate::scene::SceneBuilder;
use super::outline::{draw_polygon_fill_and_stroke, expand_polygons, PolygonSpec};
use super::project::{project_and_densify_one, PathOptions, PathVertex, VertexAttrs};
use super::resolve::{
channel_color_space, channel_varies_across, offset_px, override_alpha, pt_to_px,
resolve_angle_channel, resolve_cap_channel, resolve_color_channel,
resolve_color_channel_or_theme, resolve_join_channel, resolve_linetype_channel,
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 MITER_LIMIT: f64 = 4.0;
const CHANNELS: &[(&str, ExpectedOutput)] = &[
("x", ExpectedOutput::Numbers),
("y", ExpectedOutput::Numbers),
("x_offset", ExpectedOutput::Numbers),
("y_offset", ExpectedOutput::Numbers),
("x_band", ExpectedOutput::Numbers),
("y_band", ExpectedOutput::Numbers),
("ring", ExpectedOutput::Any),
("fill", ExpectedOutput::Colors),
("stroke", ExpectedOutput::Colors),
("fill_opacity", ExpectedOutput::Numbers),
("stroke_opacity", ExpectedOutput::Numbers),
("linewidth", ExpectedOutput::Numbers),
("linetype", ExpectedOutput::Linetypes),
("dash_offset", ExpectedOutput::Numbers),
("cap", ExpectedOutput::Strings),
("join", ExpectedOutput::Strings),
("expand", ExpectedOutput::Numbers),
("corner_radius", ExpectedOutput::Numbers),
("corner_max_angle", ExpectedOutput::Numbers),
("angle", ExpectedOutput::Numbers),
("pick_id", ExpectedOutput::Numbers),
];
pub struct PolygonGeom {
pub(crate) state: GeomState,
pub(crate) marks: Vec<PolygonMarkSlot>,
}
#[derive(Clone, Debug)]
pub(crate) struct PolygonMarkSlot {
pub(crate) first_row: usize,
pub(crate) rings: Vec<Vec<usize>>,
}
crate::impl_geom_inherents_grouped!(PolygonGeom);
impl PolygonGeom {
pub(crate) fn build_marks(&self) -> Vec<PolygonMarkSlot> {
let ring_ch = self.state.channels.get("ring");
match &self.state.keys {
Keys::Positional(n) => (0..*n)
.map(|i| PolygonMarkSlot {
first_row: i,
rings: vec![vec![i]],
})
.collect(),
Keys::Explicit(col) => build_marks_from_columns(col, ring_ch),
}
}
}
fn build_marks_from_columns(keys: &DataColumn, ring_ch: Option<&Channel>) -> Vec<PolygonMarkSlot> {
let n = keys.len();
struct MarkBucket {
first_row: usize,
rows: Vec<usize>,
}
let mut marks: Vec<MarkBucket> = Vec::new();
for i in 0..n {
let key_i = keys.get(i);
let mut found = false;
for bucket in marks.iter_mut() {
if keys.get(bucket.first_row).key_eq(&key_i) {
bucket.rows.push(i);
found = true;
break;
}
}
if !found {
marks.push(MarkBucket {
first_row: i,
rows: vec![i],
});
}
}
let ring_data = match ring_ch {
Some(Channel::Data(col)) | Some(Channel::RawData(col)) => Some(col),
_ => None, };
marks
.into_iter()
.map(|bucket| {
let rings = bucket_rows_by_ring(&bucket.rows, ring_data);
PolygonMarkSlot {
first_row: bucket.first_row,
rings,
}
})
.collect()
}
fn bucket_rows_by_ring(rows: &[usize], ring_col: Option<&DataColumn>) -> Vec<Vec<usize>> {
let col = match ring_col {
None => return vec![rows.to_vec()],
Some(c) => c,
};
struct RingBucket {
first_row: usize,
rows: Vec<usize>,
}
let mut buckets: Vec<RingBucket> = Vec::new();
for &i in rows {
let r_i = col.get(i);
let mut found = false;
for b in buckets.iter_mut() {
if col.get(b.first_row).key_eq(&r_i) {
b.rows.push(i);
found = true;
break;
}
}
if !found {
buckets.push(RingBucket {
first_row: i,
rows: vec![i],
});
}
}
buckets.into_iter().map(|b| b.rows).collect()
}
impl BuildableGeom for PolygonGeom {
fn build_from(builder: GeomBuilder<Self>) -> Self {
let (keys_opt, channels) = builder.into_parts();
let n = require_x_and_siblings(&channels, &["y"], "PolygonGeom");
let state = finalize_state(
keys_opt,
channels,
n,
KeysStrategy::OneMark,
CHANNELS,
"PolygonGeom",
);
PolygonGeom {
state,
marks: Vec::new(),
}
}
}
#[derive(Clone, Copy)]
struct PolygonDrawCtx<'a> {
x_col: &'a DataColumn,
y_col: &'a DataColumn,
x_scale: Option<&'a Scale>,
y_scale: Option<&'a Scale>,
x_offset: ChannelBind<'a>,
y_offset: ChannelBind<'a>,
x_band: ChannelBind<'a>,
y_band: ChannelBind<'a>,
fill: ChannelBind<'a>,
stroke: ChannelBind<'a>,
fill_opacity: ChannelBind<'a>,
stroke_opacity: ChannelBind<'a>,
linewidth: ChannelBind<'a>,
linetype: ChannelBind<'a>,
dash_offset: ChannelBind<'a>,
cap: ChannelBind<'a>,
join: ChannelBind<'a>,
pick_id: ChannelBind<'a>,
expand: ChannelBind<'a>,
corner_radius: ChannelBind<'a>,
corner_max_angle: ChannelBind<'a>,
angle: ChannelBind<'a>,
}
impl<'a> PolygonDrawCtx<'a> {
fn build(
channels: &'a std::collections::HashMap<String, Channel>,
ctx: &'a GeomContext<'a>,
) -> 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 {
x_col,
y_col,
x_scale,
y_scale,
x_offset: b("x_offset"),
y_offset: b("y_offset"),
x_band: b("x_band"),
y_band: b("y_band"),
fill: b("fill"),
stroke: b("stroke"),
fill_opacity: b("fill_opacity"),
stroke_opacity: b("stroke_opacity"),
linewidth: b("linewidth"),
linetype: b("linetype"),
dash_offset: b("dash_offset"),
cap: b("cap"),
join: b("join"),
pick_id: b("pick_id"),
expand: b("expand"),
corner_radius: b("corner_radius"),
corner_max_angle: b("corner_max_angle"),
angle: b("angle"),
})
}
}
impl Geom for PolygonGeom {
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 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() => {
let prev_ring = self.state.prev_channels.get("ring");
build_marks_from_columns(col, prev_ring)
}
_ => Vec::new(),
};
let first_rows =
|ms: &[PolygonMarkSlot]| -> Vec<usize> { ms.iter().map(|m| m.first_row).collect() };
self.state.rebuild_grouped_diff(
&first_rows(&prev_marks),
&first_rows(&next_marks),
"PolygonGeom",
);
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: &[PolygonMarkSlot] = 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 PolygonDrawCtx::build(&self.state.channels, ctx) {
Some(dc) => dc,
None => return,
};
for mark in marks.iter() {
draw_one_polygon_mark(scene, ctx, dc, mark);
}
}
}
fn draw_one_polygon_mark(
scene: &mut dyn SceneBuilder,
ctx: &GeomContext<'_>,
dc: PolygonDrawCtx<'_>,
mark: &PolygonMarkSlot,
) {
let PolygonDrawCtx {
x_col,
y_col,
x_scale,
y_scale,
x_offset:
ChannelBind {
ch: x_offset_ch,
scale: x_offset_scale,
},
y_offset:
ChannelBind {
ch: y_offset_ch,
scale: y_offset_scale,
},
x_band: ChannelBind {
ch: x_band_ch,
scale: x_band_scale,
},
y_band: ChannelBind {
ch: y_band_ch,
scale: y_band_scale,
},
fill: ChannelBind {
ch: fill_ch,
scale: fill_scale,
},
stroke: ChannelBind {
ch: stroke_ch,
scale: stroke_scale,
},
fill_opacity:
ChannelBind {
ch: fill_opacity_ch,
scale: fill_opacity_scale,
},
stroke_opacity:
ChannelBind {
ch: stroke_opacity_ch,
scale: stroke_opacity_scale,
},
linewidth:
ChannelBind {
ch: linewidth_ch,
scale: linewidth_scale,
},
linetype:
ChannelBind {
ch: linetype_ch,
scale: linetype_scale,
},
dash_offset:
ChannelBind {
ch: dash_offset_ch,
scale: dash_offset_scale,
},
cap: ChannelBind {
ch: cap_ch,
scale: cap_scale,
},
join: ChannelBind {
ch: join_ch,
scale: join_scale,
},
pick_id:
ChannelBind {
ch: pick_id_ch,
scale: pick_id_scale,
},
expand: ChannelBind {
ch: expand_ch,
scale: expand_scale,
},
corner_radius:
ChannelBind {
ch: corner_radius_ch,
scale: corner_radius_scale,
},
corner_max_angle:
ChannelBind {
ch: corner_max_angle_ch,
scale: corner_max_angle_scale,
},
angle: ChannelBind {
ch: angle_ch,
scale: angle_scale,
},
} = dc;
let i0 = mark.first_row;
let fill_color = override_alpha(
resolve_color_channel_or_theme(
fill_ch,
fill_scale,
i0,
ctx.theme.geom.polygon.fill.as_ref(),
&ctx.theme.palette,
),
resolve_number_channel(fill_opacity_ch, fill_opacity_scale, i0),
);
let stroke_color = override_alpha(
resolve_color_channel_or_theme(
stroke_ch,
stroke_scale,
i0,
ctx.theme.geom.polygon.stroke.as_ref(),
&ctx.theme.palette,
),
resolve_number_channel(stroke_opacity_ch, stroke_opacity_scale, i0),
);
if fill_color.is_none() && stroke_color.is_none() {
return;
}
let expand_pt = resolve_number_channel_or(expand_ch, expand_scale, i0, 0.0);
let expand_px = pt_to_px(expand_pt, ctx.dpi);
let corner_radius_pt =
resolve_number_channel_or(corner_radius_ch, corner_radius_scale, i0, 0.0);
let corner_radius_px = pt_to_px(corner_radius_pt, ctx.dpi);
let corner_max_angle_deg = resolve_number_channel_or(
corner_max_angle_ch,
corner_max_angle_scale,
i0,
f64::INFINITY,
);
let dash_pattern_pt = resolve_linetype_channel(linetype_ch, linetype_scale, i0);
let all_rows: Vec<usize> = mark.rings.iter().flatten().copied().collect();
let linewidth_varies = channel_varies_across(linewidth_ch, linewidth_scale, &all_rows);
let stroke_varies = channel_varies_across(stroke_ch, stroke_scale, &all_rows)
|| channel_varies_across(stroke_opacity_ch, stroke_opacity_scale, &all_rows);
let ribbon_mode = stroke_color.is_some()
&& dash_pattern_pt.is_empty()
&& expand_pt == 0.0
&& corner_radius_pt == 0.0
&& (linewidth_varies || stroke_varies);
let mut rings_pts: Vec<Vec<Point>> = Vec::with_capacity(mark.rings.len());
let mut rings_widths: Vec<Vec<f64>> = if ribbon_mode {
Vec::with_capacity(mark.rings.len())
} else {
Vec::new()
};
let mut rings_colors: Vec<Vec<Color>> = if ribbon_mode {
Vec::with_capacity(mark.rings.len())
} else {
Vec::new()
};
let fallback_stroke = stroke_color.unwrap_or_else(|| Color::new([0.0, 0.0, 0.0, 1.0]));
let stroke_space = channel_color_space(stroke_scale);
let opts = PathOptions::ring(3).with_color_space(stroke_space);
for ring in &mark.rings {
let run = project_and_densify_one(ctx, ring.len(), &opts, |k| {
let i = ring[k];
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 attrs = ribbon_mode.then(|| {
let w_pt = resolve_number_channel_or(
linewidth_ch,
linewidth_scale,
i,
ctx.theme.geom.polygon.linewidth_pt,
);
VertexAttrs {
half_width_px: (pt_to_px(w_pt, ctx.dpi) * 0.5).max(0.0),
color: override_alpha(
resolve_color_channel(stroke_ch, stroke_scale, i),
resolve_number_channel(stroke_opacity_ch, stroke_opacity_scale, i),
)
.unwrap_or(fallback_stroke),
}
});
PathVertex {
frac: [
resolve_position(x_col.get(i), x_scale, x_band),
resolve_position(y_col.get(i), y_scale, y_band),
],
offset_px: (
offset_px(x_offset_ch, x_offset_scale, i, ctx.dpi),
offset_px(y_offset_ch, y_offset_scale, i, ctx.dpi),
),
attrs,
}
});
if !run.points.is_empty() {
rings_pts.push(run.points);
if ribbon_mode {
rings_widths.push(run.widths);
rings_colors.push(run.colors);
}
}
}
if rings_pts.is_empty() {
return;
}
let angle = resolve_angle_channel(angle_ch, angle_scale, i0);
let xform = if angle == 0.0 || rings_pts.is_empty() {
Affine::IDENTITY
} else {
let outer = &rings_pts[0];
let n_pts = outer.len() as f64;
let cx = outer.iter().map(|p| p.x).sum::<f64>() / n_pts;
let cy = outer.iter().map(|p| p.y).sum::<f64>() / n_pts;
Affine::rotate_about(-angle, Point::new(cx, cy))
};
let offset_rings = expand_polygons(rings_pts, None, expand_px, MITER_LIMIT);
if offset_rings.iter().all(|r| r.len() < 3) {
return;
}
let pick = resolve_pick_id(pick_id_ch, pick_id_scale, i0);
let linewidth_pt = resolve_number_channel_or(
linewidth_ch,
linewidth_scale,
i0,
ctx.theme.geom.polygon.linewidth_pt,
);
let dash_offset_pt = resolve_number_channel_or(dash_offset_ch, dash_offset_scale, i0, 0.0);
let cap = resolve_cap_channel(cap_ch, cap_scale, i0, ctx.theme.geom.polygon.cap);
let join = resolve_join_channel(join_ch, join_scale, i0, ctx.theme.geom.polygon.join);
let corner_rounding = (corner_radius_px > 0.0).then_some(CornerRounding {
max_cut: corner_radius_px,
max_angle_deg: corner_max_angle_deg,
});
let spec = PolygonSpec {
fill_color,
stroke_color: if ribbon_mode { None } else { stroke_color },
linewidth_pt,
dash_pattern_pt: dash_pattern_pt.clone(),
dash_offset_pt,
cap,
join,
corner_rounding,
marker_fill: stroke_color.unwrap_or(Color::new([0.0, 0.0, 0.0, 1.0])),
xform,
pick,
};
draw_polygon_fill_and_stroke(scene, ctx, &offset_rings, &spec);
if ribbon_mode {
let opts = RibbonOptions {
half_width: 0.0,
cap,
join,
miter_limit: MITER_LIMIT,
};
for (r, ring) in offset_rings.iter().enumerate() {
if ring.len() < 3 {
continue;
}
let widths = &rings_widths[r];
let colors = &rings_colors[r];
let mesh = polygon_ribbon_full(ring, Some(colors), Some(widths), &opts);
scene.draw_mesh(&mesh, xform, pick);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
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])
}
#[test]
fn no_keys_synthesises_single_mark() {
let g = PolygonGeom::builder()
.set("x", vec![0.0_f64, 1.0, 1.0, 0.0])
.set("y", vec![0.0_f64, 0.0, 1.0, 1.0])
.build();
assert_eq!(g.len(), 4);
assert_eq!(g.mark_count(), 1);
}
#[test]
fn explicit_keys_define_marks() {
let g = PolygonGeom::builder()
.keys(vec!["A", "A", "A", "B", "B", "B"])
.set("x", vec![0.0_f64, 1.0, 0.0, 2.0, 3.0, 2.0])
.set("y", vec![0.0_f64, 0.0, 1.0, 0.0, 0.0, 1.0])
.build();
assert_eq!(g.mark_count(), 2);
}
#[test]
fn ring_channel_buckets_within_mark() {
let g = PolygonGeom::builder()
.set("x", vec![0.0_f64, 1.0, 1.0, 0.0, 0.25, 0.75, 0.75, 0.25])
.set("y", vec![0.0_f64, 0.0, 1.0, 1.0, 0.25, 0.25, 0.75, 0.75])
.set("ring", vec![0_i32, 0, 0, 0, 1, 1, 1, 1])
.build();
let marks = g.build_marks();
assert_eq!(marks.len(), 1);
assert_eq!(marks[0].rings.len(), 2);
assert_eq!(marks[0].rings[0], vec![0, 1, 2, 3]);
assert_eq!(marks[0].rings[1], vec![4, 5, 6, 7]);
}
#[test]
fn unset_ring_means_single_ring_per_mark() {
let g = PolygonGeom::builder()
.keys(vec!["A", "A", "A", "B", "B", "B"])
.set("x", vec![0.0_f64, 1.0, 0.0, 2.0, 3.0, 2.0])
.set("y", vec![0.0_f64, 0.0, 1.0, 0.0, 0.0, 1.0])
.build();
let marks = g.build_marks();
assert_eq!(marks.len(), 2);
for m in &marks {
assert_eq!(
m.rings.len(),
1,
"mark should have one ring when ring is unset"
);
}
}
#[test]
#[should_panic(expected = "missing required channel")]
fn missing_x_panics() {
PolygonGeom::builder()
.set("y", vec![0.0_f64, 1.0, 0.0])
.build();
}
#[test]
#[should_panic(expected = "does not match")]
fn length_mismatch_panics() {
PolygonGeom::builder()
.set("x", vec![0.0_f64, 1.0, 0.0])
.set("y", vec![0.0_f64, 1.0])
.build();
}
#[test]
fn fills_one_subpath_when_one_ring() {
let mut g = PolygonGeom::builder()
.set("x", vec![0.1_f64, 0.5, 0.9, 0.5])
.set("y", vec![0.5_f64, 0.1, 0.5, 0.9])
.set("fill", red())
.build();
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),
);
let fills = scene
.ops
.iter()
.filter(|op| matches!(op, Op::Fill { .. }))
.count();
assert_eq!(fills, 1);
}
#[test]
fn fills_with_even_odd_rule() {
let mut g = PolygonGeom::builder()
.set("x", vec![0.1_f64, 0.9, 0.9, 0.1])
.set("y", vec![0.1_f64, 0.1, 0.9, 0.9])
.set("fill", red())
.build();
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),
);
let rule = scene
.ops
.iter()
.find_map(|op| match op {
Op::Fill { rule, .. } => Some(*rule),
_ => None,
})
.expect("fill");
assert!(matches!(rule, FillRule::EvenOdd));
}
#[test]
fn polygon_with_hole_produces_two_closed_subpaths() {
let mut g = PolygonGeom::builder()
.set("x", vec![0.1_f64, 0.9, 0.9, 0.1, 0.35, 0.65, 0.65, 0.35])
.set("y", vec![0.1_f64, 0.1, 0.9, 0.9, 0.35, 0.35, 0.65, 0.65])
.set("ring", vec![0_i32, 0, 0, 0, 1, 1, 1, 1])
.set("fill", red())
.build();
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),
);
for op in &scene.ops {
if let Op::Fill { path, .. } = op {
let close_count = path
.elements()
.iter()
.filter(|el| matches!(el, crate::path::PathEl::ClosePath))
.count();
assert_eq!(close_count, 2);
let move_count = path
.elements()
.iter()
.filter(|el| matches!(el, crate::path::PathEl::MoveTo(_)))
.count();
assert_eq!(move_count, 2);
return;
}
}
panic!("no fill op emitted");
}
#[test]
fn nonfinite_vertex_skipped_within_ring() {
let mut g = PolygonGeom::builder()
.set("x", vec![0.1_f64, f64::NAN, 0.9, 0.1])
.set("y", vec![0.1_f64, 0.1, 0.9, 0.9])
.set("fill", red())
.build();
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),
);
let fills = scene
.ops
.iter()
.filter(|op| matches!(op, Op::Fill { .. }))
.count();
assert_eq!(fills, 1);
}
#[test]
fn radar_ring_closes_across_the_seam() {
let panel = Rect::new(0.0, 0.0, 400.0, 400.0);
let proj = crate::plot::projection::Projection::radar(5);
let mut g = PolygonGeom::builder()
.set("x", Raw(vec![0.1_f64, 0.3, 0.5, 0.7, 0.9]))
.set("y", Raw(vec![0.5_f64, 0.2, 0.4, 0.7, 0.6]))
.set("fill", red())
.build();
g.rebuild_diff_against_previous();
let shapes = shapes();
let scales = DirectScaleResolver::new();
let mut scene = RecordingScene::default();
g.draw(
&mut scene,
&GeomContext::with_projection(panel, 96.0, &shapes, &scales, &proj),
);
for op in &scene.ops {
if let Op::Fill { path, .. } = op {
let vertices = path
.elements()
.iter()
.filter(|el| {
matches!(
el,
crate::path::PathEl::MoveTo(_) | crate::path::PathEl::LineTo(_)
)
})
.count();
assert_eq!(vertices, 5, "ring should hold only its own vertices");
return;
}
}
panic!("no fill op emitted");
}
#[test]
fn ring_with_under_three_finite_vertices_dropped() {
let mut g = PolygonGeom::builder()
.set("x", vec![0.1_f64, 0.9, 0.9, 0.1, 0.4, 0.6])
.set("y", vec![0.1_f64, 0.1, 0.9, 0.9, 0.5, 0.5])
.set("ring", vec![0_i32, 0, 0, 0, 1, 1])
.set("fill", red())
.build();
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),
);
for op in &scene.ops {
if let Op::Fill { path, .. } = op {
let close_count = path
.elements()
.iter()
.filter(|el| matches!(el, crate::path::PathEl::ClosePath))
.count();
assert_eq!(close_count, 1, "only outer ring should render");
return;
}
}
panic!("no fill op emitted");
}
#[test]
fn no_fill_no_stroke_emits_nothing() {
let mut g = PolygonGeom::builder()
.set("x", vec![0.1_f64, 0.9, 0.5])
.set("y", vec![0.1_f64, 0.1, 0.9])
.build();
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),
);
assert!(scene.ops.is_empty());
}
#[test]
fn stroke_only_traces_outline() {
let mut g = PolygonGeom::builder()
.set("x", vec![0.1_f64, 0.9, 0.5])
.set("y", vec![0.1_f64, 0.1, 0.9])
.set("stroke", red())
.set("linewidth", 2.0_f64)
.build();
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),
);
let (fills, strokes) = scene.ops.iter().fold((0, 0), |(f, s), op| match op {
Op::Fill { .. } => (f + 1, s),
Op::Stroke { .. } => (f, s + 1),
_ => (f, s),
});
assert_eq!(fills, 0);
assert_eq!(strokes, 1);
}
#[test]
fn within_mark_fill_divergence_uses_first_row() {
let red_solid = Color::new([1.0, 0.0, 0.0, 1.0]);
let blue_solid = Color::new([0.0, 0.0, 1.0, 1.0]);
let mut g = PolygonGeom::builder()
.keys(vec!["A", "A", "A"])
.set("x", vec![0.1_f64, 0.9, 0.5])
.set("y", vec![0.1_f64, 0.1, 0.9])
.set("fill", vec![red_solid, blue_solid, blue_solid])
.build();
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),
);
for op in &scene.ops {
if let Op::Fill {
brush: crate::brush::Brush::Solid(c),
..
} = op
{
assert_eq!(*c, red_solid);
return;
}
}
panic!("no fill op emitted");
}
#[test]
fn diff_marks_enter_on_first_draw() {
let mut g = PolygonGeom::builder()
.keys(vec!["A", "A", "A", "B", "B", "B"])
.set("x", vec![0.0_f64, 1.0, 0.0, 2.0, 3.0, 2.0])
.set("y", vec![0.0_f64, 0.0, 1.0, 0.0, 0.0, 1.0])
.build();
g.rebuild_diff_against_previous();
assert_eq!(g.state.enter.len(), 2);
assert_eq!(g.state.exit.len(), 0);
}
#[test]
fn diff_mark_exits_when_removed() {
let mut g = PolygonGeom::builder()
.keys(vec!["A", "A", "A", "B", "B", "B"])
.set("x", vec![0.0_f64, 1.0, 0.0, 2.0, 3.0, 2.0])
.set("y", vec![0.0_f64, 0.0, 1.0, 0.0, 0.0, 1.0])
.build();
g.rebuild_diff_against_previous();
g.update(|b| {
b.keys(vec!["A", "A", "A"]);
b.set("x", vec![0.0_f64, 1.0, 0.0]);
b.set("y", vec![0.0_f64, 0.0, 1.0]);
});
g.rebuild_diff_against_previous();
assert_eq!(g.state.exit.len(), 1);
assert!(g.state.exit[0].key_eq(&Value::String(Arc::from("B"))));
}
#[test]
fn pick_id_per_mark_resolves_from_first_row() {
let mut g = PolygonGeom::builder()
.keys(vec!["A", "A", "A", "B", "B", "B", "C", "C", "C"])
.set("x", vec![0.0_f64, 0.2, 0.1, 0.4, 0.6, 0.5, 0.7, 0.9, 0.8])
.set("y", vec![0.0_f64, 0.0, 0.2, 0.0, 0.0, 0.2, 0.0, 0.0, 0.2])
.set("fill", red())
.set("pick_id", vec![1001_i64, 0, 0, 2002, 0, 0, 3003, 0, 0])
.build();
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),
);
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, 3003]);
}
#[test]
fn declared_channels_alphabetical() {
let g = PolygonGeom::builder()
.set("x", vec![0.0_f64, 1.0, 0.0])
.set("y", vec![0.0_f64, 0.0, 1.0])
.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);
}
fn fill_path(scene: &RecordingScene) -> Option<crate::path::Path> {
scene.ops.iter().find_map(|op| match op {
Op::Fill { path, .. } => Some(path.clone()),
_ => None,
})
}
#[test]
fn corner_radius_produces_fillets_per_corner() {
let mut g = PolygonGeom::builder()
.set("x", Raw(vec![0.2_f64, 0.8, 0.8, 0.2]))
.set("y", Raw(vec![0.2_f64, 0.2, 0.8, 0.8]))
.set("fill", red())
.set("corner_radius", 5.0_f64)
.build();
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),
);
let path = fill_path(&scene).expect("fill");
let curves = path
.elements()
.iter()
.filter(|el| matches!(el, crate::path::PathEl::CurveTo(_, _, _)))
.count();
assert_eq!(curves, 4);
}
#[test]
fn expand_grows_polygon_bbox() {
use crate::geometry::Shape as _;
let mut g = PolygonGeom::builder()
.set("x", Raw(vec![0.2_f64, 0.8, 0.8, 0.2]))
.set("y", Raw(vec![0.2_f64, 0.2, 0.8, 0.8]))
.set("fill", red())
.set("expand", 5.0_f64)
.build();
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),
);
let path = fill_path(&scene).expect("fill");
let bb = path.bounding_box();
let expected_width = 60.0 + 2.0 * 5.0 * 96.0 / 72.0;
assert!(
(bb.width() - expected_width).abs() < 0.5,
"width = {}, expected ~{}",
bb.width(),
expected_width
);
}
#[test]
fn expand_then_corner_radius_order_is_fixed() {
use crate::geometry::Shape as _;
let mut g = PolygonGeom::builder()
.set("x", Raw(vec![0.2_f64, 0.8, 0.8, 0.2]))
.set("y", Raw(vec![0.2_f64, 0.2, 0.8, 0.8]))
.set("fill", red())
.set("expand", 5.0_f64)
.set("corner_radius", 3.0_f64)
.build();
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),
);
let path = fill_path(&scene).expect("fill");
let curves = path
.elements()
.iter()
.filter(|el| matches!(el, crate::path::PathEl::CurveTo(_, _, _)))
.count();
assert!(
curves >= 4,
"expected ≥ 4 curves after expand+round, got {curves}"
);
let bb = path.bounding_box();
assert!(bb.width() > 65.0, "width = {}", bb.width());
}
#[test]
fn linetype_marker_stamps_around_closed_perimeter() {
use crate::plot::geom::linetype;
let pat = linetype::pattern([linetype::marker("circle"), linetype::gap(5.0)]);
let blue = Color::new([0.0, 0.0, 1.0, 1.0]);
let mut g = PolygonGeom::builder()
.set("x", Raw(vec![0.0_f64, 1.0, 1.0, 0.0]))
.set("y", Raw(vec![0.0_f64, 0.0, 1.0, 1.0]))
.set("stroke", red())
.set("fill", blue)
.set("linewidth", 4.0_f64)
.set("linetype", Value::Linetype(pat))
.build();
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),
);
let strokes = scene
.ops
.iter()
.filter(|op| matches!(op, Op::Stroke { .. }))
.count();
assert_eq!(strokes, 0, "marker-only pattern emits no Dash strokes");
let fill_colors: Vec<crate::color::Color> = scene
.ops
.iter()
.filter_map(|op| match op {
Op::Fill {
brush: crate::brush::Brush::Solid(c),
..
} => Some(*c),
_ => None,
})
.collect();
assert!(fill_colors.len() >= 2, "expected interior + markers");
assert_eq!(fill_colors[0], blue, "first fill = polygon interior");
for c in &fill_colors[1..] {
assert_eq!(
*c,
red(),
"marker fill defaults to stroke colour on closed shapes"
);
}
assert_eq!(
fill_colors.len() - 1,
33,
"got {} markers around perimeter",
fill_colors.len() - 1
);
}
fn stroke_count(scene: &RecordingScene) -> usize {
scene
.ops
.iter()
.filter(|op| matches!(op, Op::Stroke { .. }))
.count()
}
fn mesh_count(scene: &RecordingScene) -> usize {
scene
.ops
.iter()
.filter(|op| matches!(op, Op::DrawMesh { .. }))
.count()
}
#[test]
fn polygon_constant_outline_stays_on_stroke_path() {
let mut g = PolygonGeom::builder()
.set("x", Raw(vec![0.0_f64, 1.0, 1.0, 0.0]))
.set("y", Raw(vec![0.0_f64, 0.0, 1.0, 1.0]))
.set("fill", red())
.set("stroke", red())
.set("linewidth", 2.0_f64)
.build();
g.rebuild_diff_against_previous();
let r = 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), &r, &scales),
);
assert_eq!(mesh_count(&scene), 0);
assert_eq!(stroke_count(&scene), 1);
}
#[test]
fn polygon_varying_linewidth_upgrades_outline_to_mesh() {
let mut g = PolygonGeom::builder()
.set("x", Raw(vec![0.0_f64, 1.0, 1.0, 0.0]))
.set("y", Raw(vec![0.0_f64, 0.0, 1.0, 1.0]))
.set("fill", red())
.set("stroke", red())
.set("linewidth", vec![2.0_f64, 6.0, 2.0, 6.0])
.build();
g.rebuild_diff_against_previous();
let r = 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), &r, &scales),
);
assert_eq!(stroke_count(&scene), 0);
assert_eq!(mesh_count(&scene), 1);
}
#[test]
fn polygon_varying_stroke_color_upgrades_outline() {
let red_c = Color::new([1.0, 0.0, 0.0, 1.0]);
let blue_c = Color::new([0.0, 0.0, 1.0, 1.0]);
let mut g = PolygonGeom::builder()
.set("x", Raw(vec![0.0_f64, 1.0, 1.0, 0.0]))
.set("y", Raw(vec![0.0_f64, 0.0, 1.0, 1.0]))
.set("fill", red_c)
.set("stroke", vec![red_c, blue_c, red_c, blue_c])
.set("linewidth", 2.0_f64)
.build();
g.rebuild_diff_against_previous();
let r = 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), &r, &scales),
);
assert_eq!(stroke_count(&scene), 0);
assert_eq!(mesh_count(&scene), 1);
}
#[test]
fn polygon_zero_linewidth_at_first_row_still_renders_outline() {
let mut g = PolygonGeom::builder()
.set("x", Raw(vec![0.0_f64, 1.0, 1.0, 0.0]))
.set("y", Raw(vec![0.0_f64, 0.0, 1.0, 1.0]))
.set("fill", red())
.set("stroke", red())
.set("linewidth", vec![0.0_f64, 10.0, 20.0, 30.0])
.build();
g.rebuild_diff_against_previous();
let r = 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), &r, &scales),
);
assert_eq!(stroke_count(&scene), 0);
assert_eq!(mesh_count(&scene), 1);
}
#[test]
fn polygon_expand_blocks_ribbon_upgrade() {
let mut g = PolygonGeom::builder()
.set("x", Raw(vec![0.0_f64, 1.0, 1.0, 0.0]))
.set("y", Raw(vec![0.0_f64, 0.0, 1.0, 1.0]))
.set("fill", red())
.set("stroke", red())
.set("linewidth", vec![2.0_f64, 6.0, 2.0, 6.0])
.set("expand", 2.0_f64)
.build();
g.rebuild_diff_against_previous();
let r = 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), &r, &scales),
);
assert_eq!(stroke_count(&scene), 1);
assert_eq!(mesh_count(&scene), 0);
}
#[test]
fn polygon_ribbon_does_not_affect_fill() {
let mut g = PolygonGeom::builder()
.set("x", Raw(vec![0.0_f64, 1.0, 1.0, 0.0]))
.set("y", Raw(vec![0.0_f64, 0.0, 1.0, 1.0]))
.set("fill", red())
.set("stroke", red())
.set("linewidth", vec![2.0_f64, 6.0, 2.0, 6.0])
.build();
g.rebuild_diff_against_previous();
let r = 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), &r, &scales),
);
let fills = scene
.ops
.iter()
.filter(|op| matches!(op, Op::Fill { .. }))
.count();
assert_eq!(fills, 1);
}
}