use std::f64::consts::TAU;
use crate::brush::Brush;
use crate::geometry::{Affine, Point};
use crate::path::FillRule;
use crate::primitives::{
annular_wedge as annular_wedge_path, offset_polygon, path_to_rings, round_path_corners,
wedge as wedge_path, CornerRounding,
};
use crate::scene::SceneBuilder;
use super::resolve::{
band_width_at, draw_stroke_with_linetype, override_alpha, pt_to_px, resolve_angle_channel,
resolve_cap_channel, resolve_color_channel_or_theme, resolve_join_channel,
resolve_linetype_channel, resolve_number_channel, resolve_number_channel_or, resolve_pick_id,
resolve_position, smallest_nonzero,
};
use super::state::{finalize_state, require_x_and_siblings, GeomState, KeysStrategy};
use super::{BuildableGeom, Channel, ExpectedOutput, Geom, GeomBuilder, GeomContext};
const WEDGE_OFFSET_TOL: f64 = 0.1;
const MITER_LIMIT: f64 = 4.0;
const DEFAULT_RADIUS2_PT: f64 = 0.0;
const DEFAULT_THETA: f64 = 0.0;
const DEFAULT_THETA2: f64 = TAU;
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),
("radius", ExpectedOutput::Numbers),
("radius2", ExpectedOutput::Numbers),
("radius_band", ExpectedOutput::Numbers),
("radius2_band", ExpectedOutput::Numbers),
("theta", ExpectedOutput::Numbers),
("theta2", ExpectedOutput::Numbers),
("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 WedgeGeom {
pub(crate) state: GeomState,
}
crate::impl_geom_inherents!(WedgeGeom);
impl BuildableGeom for WedgeGeom {
fn build_from(builder: GeomBuilder<Self>) -> Self {
let (keys_opt, channels) = builder.into_parts();
let n = require_x_and_siblings(&channels, &["y"], "WedgeGeom");
let state = finalize_state(
keys_opt,
channels,
n,
KeysStrategy::PerRow,
CHANNELS,
"WedgeGeom",
);
WedgeGeom { state }
}
}
impl Geom for WedgeGeom {
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("wedge")
}
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 n = self.len();
if n == 0 {
return;
}
let x_scale_bound = ctx.scale_for("x");
let y_scale_bound = ctx.scale_for("y");
let x_offset_scale = ctx.scale_for("x_offset");
let y_offset_scale = ctx.scale_for("y_offset");
let x_band_scale = ctx.scale_for("x_band");
let y_band_scale = ctx.scale_for("y_band");
let radius_scale = ctx.scale_for("radius");
let radius2_scale = ctx.scale_for("radius2");
let radius_band_scale = ctx.scale_for("radius_band");
let radius2_band_scale = ctx.scale_for("radius2_band");
let theta_scale = ctx.scale_for("theta");
let theta2_scale = ctx.scale_for("theta2");
let fill_scale = ctx.scale_for("fill");
let stroke_scale = ctx.scale_for("stroke");
let fill_opacity_scale = ctx.scale_for("fill_opacity");
let stroke_opacity_scale = ctx.scale_for("stroke_opacity");
let linewidth_scale = ctx.scale_for("linewidth");
let linetype_scale = ctx.scale_for("linetype");
let dash_offset_scale = ctx.scale_for("dash_offset");
let cap_scale = ctx.scale_for("cap");
let join_scale = ctx.scale_for("join");
let pick_id_scale = ctx.scale_for("pick_id");
let expand_scale = ctx.scale_for("expand");
let corner_radius_scale = ctx.scale_for("corner_radius");
let corner_max_angle_scale = ctx.scale_for("corner_max_angle");
let angle_scale = ctx.scale_for("angle");
let channels = &self.state.channels;
let (x_col, x_scale) = match channels.get("x") {
Some(Channel::Data(c)) => (c, x_scale_bound),
Some(Channel::RawData(c)) => (c, None),
_ => return,
};
let (y_col, y_scale) = match channels.get("y") {
Some(Channel::Data(c)) => (c, y_scale_bound),
Some(Channel::RawData(c)) => (c, None),
_ => return,
};
let x_offset_ch = channels.get("x_offset");
let y_offset_ch = channels.get("y_offset");
let x_band_ch = channels.get("x_band");
let y_band_ch = channels.get("y_band");
let radius_ch = channels.get("radius");
let radius2_ch = channels.get("radius2");
let radius_band_ch = channels.get("radius_band");
let radius2_band_ch = channels.get("radius2_band");
let theta_ch = channels.get("theta");
let theta2_ch = channels.get("theta2");
let fill_ch = channels.get("fill");
let stroke_ch = channels.get("stroke");
let fill_opacity_ch = channels.get("fill_opacity");
let stroke_opacity_ch = channels.get("stroke_opacity");
let linewidth_ch = channels.get("linewidth");
let linetype_ch = channels.get("linetype");
let dash_offset_ch = channels.get("dash_offset");
let cap_ch = channels.get("cap");
let join_ch = channels.get("join");
let pick_id_ch = channels.get("pick_id");
let expand_ch = channels.get("expand");
let corner_radius_ch = channels.get("corner_radius");
let corner_max_angle_ch = channels.get("corner_max_angle");
let angle_ch = channels.get("angle");
for i in 0..n {
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 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 (cx0, cy0) = ctx.projection.project_to_panel_px(panel, &[x_frac, y_frac]);
let mut cx = cx0;
let mut cy = cy0;
if let Some(off) = resolve_number_channel(x_offset_ch, x_offset_scale, i) {
cx += pt_to_px(off, ctx.dpi);
}
if let Some(off) = resolve_number_channel(y_offset_ch, y_offset_scale, i) {
cy -= pt_to_px(off, ctx.dpi);
}
let x_raw = x_col.get(i);
let y_raw = y_col.get(i);
let x_band_px = band_width_at(x_scale, &x_raw) * panel_w;
let y_band_px = band_width_at(y_scale, &y_raw) * panel_h;
let band_px = smallest_nonzero(x_band_px, y_band_px);
let radius_pt = resolve_number_channel_or(radius_ch, radius_scale, i, 0.0);
let r_b = resolve_number_channel_or(radius_band_ch, radius_band_scale, i, 0.0);
let radius_px = pt_to_px(radius_pt, ctx.dpi) + r_b * band_px;
if !radius_px.is_finite() || radius_px <= 0.0 {
continue;
}
let radius2_pt =
resolve_number_channel_or(radius2_ch, radius2_scale, i, DEFAULT_RADIUS2_PT);
let r2_b = resolve_number_channel_or(radius2_band_ch, radius2_band_scale, i, 0.0);
let radius2_px = (pt_to_px(radius2_pt, ctx.dpi) + r2_b * band_px).clamp(0.0, radius_px);
let theta = resolve_number_channel_or(theta_ch, theta_scale, i, DEFAULT_THETA);
let theta2 = resolve_number_channel_or(theta2_ch, theta2_scale, i, DEFAULT_THETA2);
if !theta.is_finite() || !theta2.is_finite() {
continue;
}
let prim_start = -theta;
let prim_sweep = -(theta2 - theta);
if prim_sweep == 0.0 {
continue;
}
let fill_color = override_alpha(
resolve_color_channel_or_theme(
fill_ch,
fill_scale,
i,
ctx.theme.geom.wedge.fill.as_ref(),
&ctx.theme.palette,
),
resolve_number_channel(fill_opacity_ch, fill_opacity_scale, i),
);
let stroke_color = override_alpha(
resolve_color_channel_or_theme(
stroke_ch,
stroke_scale,
i,
ctx.theme.geom.wedge.stroke.as_ref(),
&ctx.theme.palette,
),
resolve_number_channel(stroke_opacity_ch, stroke_opacity_scale, i),
);
if fill_color.is_none() && stroke_color.is_none() {
continue;
}
let centre = Point::new(cx, cy);
let base_path = if radius2_px > 0.0 {
annular_wedge_path(centre, radius2_px, radius_px, prim_start, prim_sweep)
} else {
wedge_path(centre, radius_px, prim_start, prim_sweep)
};
let expand_pt = resolve_number_channel_or(expand_ch, expand_scale, i, 0.0);
let expand_px = pt_to_px(expand_pt, ctx.dpi);
let path = if expand_px != 0.0 && expand_px.is_finite() {
let rings = path_to_rings(&base_path, WEDGE_OFFSET_TOL);
let refs: Vec<&[Point]> = rings.iter().map(|r| r.as_slice()).collect();
let offset_rings = offset_polygon(&refs, expand_px, MITER_LIMIT);
let mut p = crate::path::Path::new();
for ring in &offset_rings {
if ring.len() < 3 {
continue;
}
p.move_to(ring[0]);
for v in &ring[1..] {
p.line_to(*v);
}
p.close_path();
}
if p.is_empty() {
continue;
}
p
} else {
base_path
};
let corner_radius_pt =
resolve_number_channel_or(corner_radius_ch, corner_radius_scale, i, 0.0);
let path = if corner_radius_pt > 0.0 {
let max_angle_deg = resolve_number_channel_or(
corner_max_angle_ch,
corner_max_angle_scale,
i,
f64::INFINITY,
);
let opts = CornerRounding {
max_cut: pt_to_px(corner_radius_pt, ctx.dpi),
max_angle_deg,
};
round_path_corners(&path, opts)
} else {
path
};
let pick = resolve_pick_id(pick_id_ch, pick_id_scale, i);
let angle = resolve_angle_channel(angle_ch, angle_scale, i);
let xform = if angle == 0.0 {
Affine::IDENTITY
} else {
Affine::rotate_about(-angle, centre)
};
if let Some(fc) = fill_color {
scene.fill(
FillRule::NonZero,
xform,
&Brush::Solid(fc),
None,
&path,
pick,
);
}
if let Some(sc) = stroke_color {
let linewidth_pt = resolve_number_channel_or(
linewidth_ch,
linewidth_scale,
i,
ctx.theme.geom.wedge.linewidth_pt,
);
let linewidth_px = pt_to_px(linewidth_pt, ctx.dpi);
let dash_pattern_pt = resolve_linetype_channel(linetype_ch, linetype_scale, i);
let dash_offset_pt =
resolve_number_channel_or(dash_offset_ch, dash_offset_scale, i, 0.0);
let cap = resolve_cap_channel(cap_ch, cap_scale, i, ctx.theme.geom.wedge.cap);
let join = resolve_join_channel(join_ch, join_scale, i, ctx.theme.geom.wedge.join);
draw_stroke_with_linetype(
scene,
&path,
true,
sc,
sc,
linewidth_px,
linewidth_pt,
cap,
join,
&dash_pattern_pt,
dash_offset_pt,
xform,
pick,
ctx.shapes,
ctx.theme.geom.marker_outline_pt,
ctx.dpi,
);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::f64::consts::{PI, TAU};
use std::sync::Arc;
use crate::color::Color;
use crate::geometry::Rect;
use crate::geometry::Shape as _;
use crate::plot::geom::{linetype, DirectScaleResolver};
use crate::plot::scale;
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]
#[should_panic(expected = "missing required channel \"x\"")]
fn missing_x_panics() {
WedgeGeom::builder()
.set("y", vec![0.5_f64])
.set("radius", vec![10.0_f64])
.build();
}
#[test]
fn missing_radius_is_no_op() {
let g = WedgeGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("fill", red())
.build();
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]
#[should_panic(expected = "does not match")]
fn length_mismatch_panics() {
WedgeGeom::builder()
.set("x", vec![0.5_f64, 0.7])
.set("y", vec![0.5_f64])
.set("radius", vec![10.0_f64, 10.0])
.build();
}
fn first_fill_bbox(scene: &RecordingScene) -> Option<Rect> {
for op in &scene.ops {
if let Op::Fill { path, .. } = op {
let bb = path.bounding_box();
return Some(Rect::new(bb.x0, bb.y0, bb.x1, bb.y1));
}
}
None
}
#[test]
fn full_circle_with_default_angles() {
let g = WedgeGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("radius", vec![24.0_f64])
.set("fill", red())
.build();
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 bb = first_fill_bbox(&scene).expect("fill");
let cx = (bb.x0 + bb.x1) * 0.5;
let cy = (bb.y0 + bb.y1) * 0.5;
assert!((cx - 50.0).abs() < 1e-3, "cx = {cx}");
assert!((cy - 50.0).abs() < 1e-3, "cy = {cy}");
assert!((bb.width() - 64.0).abs() < 1.0, "width = {}", bb.width());
assert!((bb.height() - 64.0).abs() < 1.0, "height = {}", bb.height());
}
#[test]
fn top_right_quarter_via_math_convention() {
let g = WedgeGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("radius", vec![24.0_f64])
.set("theta", vec![0.0_f64])
.set("theta2", vec![PI / 2.0])
.set("fill", red())
.build();
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 bb = first_fill_bbox(&scene).expect("fill");
assert!((bb.x0 - 50.0).abs() < 1.0, "x0 = {}", bb.x0);
assert!((bb.x1 - 82.0).abs() < 1.0, "x1 = {}", bb.x1);
assert!((bb.y0 - 18.0).abs() < 1.0, "y0 = {}", bb.y0);
assert!((bb.y1 - 50.0).abs() < 1.0, "y1 = {}", bb.y1);
}
#[test]
fn annular_wedge_when_radius2_set() {
let g = WedgeGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("radius", vec![24.0_f64])
.set("radius2", vec![12.0_f64])
.set("fill", red())
.build();
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 move_count = path
.elements()
.iter()
.filter(|el| matches!(el, crate::path::PathEl::MoveTo(_)))
.count();
assert!(
move_count >= 1,
"annular wedge should have at least one move; got {move_count}"
);
return;
}
}
panic!("no fill emitted");
}
#[test]
fn radius2_clamped_to_radius() {
let g = WedgeGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("radius", vec![12.0_f64])
.set("radius2", vec![100.0_f64]) .set("fill", red())
.build();
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 _ = scene;
}
#[test]
fn zero_radius_skips_row() {
let g = WedgeGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("radius", vec![0.0_f64])
.set("fill", red())
.build();
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 zero_sweep_skips_row() {
let g = WedgeGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("radius", vec![24.0_f64])
.set("theta", vec![0.5_f64])
.set("theta2", vec![0.5_f64])
.set("fill", red())
.build();
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 nonfinite_position_skips_row() {
let g = WedgeGeom::builder()
.set("x", vec![0.5_f64, f64::NAN])
.set("y", vec![0.5_f64, 0.5])
.set("radius", vec![24.0_f64, 24.0])
.set("fill", red())
.build();
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 no_fill_no_stroke_emits_nothing() {
let g = WedgeGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("radius", vec![24.0_f64])
.build();
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 pie_slice_three_wedges_share_centre() {
let third = TAU / 3.0;
let g = WedgeGeom::builder()
.set("x", vec![0.5_f64, 0.5, 0.5])
.set("y", vec![0.5_f64, 0.5, 0.5])
.set("radius", vec![24.0_f64, 24.0, 24.0])
.set("theta", vec![0.0_f64, third, 2.0 * third])
.set("theta2", vec![third, 2.0 * third, TAU])
.set("fill", red())
.build();
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, 3);
}
#[test]
fn stroke_uses_linewidth_pt_to_px() {
let g = WedgeGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("radius", vec![24.0_f64])
.set("stroke", red())
.set("linewidth", 2.0_f64)
.build();
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 widths: Vec<f64> = scene
.ops
.iter()
.filter_map(|op| match op {
Op::Stroke { stroke, .. } => Some(stroke.width),
_ => None,
})
.collect();
assert_eq!(widths.len(), 1);
assert!((widths[0] - 2.0 * 96.0 / 72.0).abs() < 1e-9);
}
#[test]
fn linetype_dashes_stroke() {
let g = WedgeGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("radius", vec![24.0_f64])
.set("stroke", red())
.set("linetype", Value::Linetype(linetype::dashed()))
.build();
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 dashed = scene.ops.iter().any(|op| match op {
Op::Stroke { stroke, .. } => !stroke.dash_pattern.is_empty(),
_ => false,
});
assert!(dashed);
}
#[test]
fn declared_channels_alphabetical() {
let g = WedgeGeom::builder()
.set("x", vec![0.0_f64])
.set("y", vec![0.0_f64])
.set("radius", vec![10.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);
assert!(names.contains(&"radius"));
assert!(!names.contains(&"x2"));
assert!(!names.contains(&"y2"));
}
#[test]
fn pick_id_channel_passes_through_per_row() {
let g = WedgeGeom::builder()
.set("x", vec![0.3_f64, 0.5, 0.7])
.set("y", vec![0.5_f64, 0.5, 0.5])
.set("radius", vec![15.0_f64, 15.0, 15.0])
.set("fill", red())
.set("pick_id", vec![7_i64, 8, 9])
.build();
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![7, 8, 9]);
}
#[test]
fn radius_band_sizes_to_discrete_x_band() {
let x_scale = scale::discrete(["A", "B"].into_iter().map(|s| Value::String(Arc::from(s))));
let resolver = DirectScaleResolver::new().with("x", &x_scale);
let g = WedgeGeom::builder()
.set("x", vec!["B"])
.set("y", vec![0.5_f64])
.set("radius_band", vec![0.5_f64])
.set("fill", red())
.build();
let shapes = shapes();
let mut scene = RecordingScene::default();
g.draw(
&mut scene,
&ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &resolver),
);
let bb = first_fill_bbox(&scene).expect("fill");
let cx = (bb.x0 + bb.x1) * 0.5;
assert!((cx - 75.0).abs() < 1.0, "cx = {cx}");
assert!((bb.width() - 50.0).abs() < 1.0, "width = {}", bb.width());
}
#[test]
fn radius_band_no_op_on_continuous_axes() {
let x_scale = scale::continuous(0.0..=10.0);
let resolver = DirectScaleResolver::new().with("x", &x_scale);
let g = WedgeGeom::builder()
.set("x", vec![5.0_f64])
.set("y", vec![0.5_f64])
.set("radius_band", vec![0.5_f64])
.set("fill", red())
.build();
let shapes = shapes();
let mut scene = RecordingScene::default();
g.draw(
&mut scene,
&ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &resolver),
);
assert!(scene.ops.is_empty(), "expected no draw on continuous x");
}
#[test]
fn radius_pt_and_band_sum() {
let x_scale = scale::discrete(["A", "B"].into_iter().map(|s| Value::String(Arc::from(s))));
let resolver = DirectScaleResolver::new().with("x", &x_scale);
let g = WedgeGeom::builder()
.set("x", vec!["A"])
.set("y", vec![0.5_f64])
.set("radius", vec![9.0_f64])
.set("radius_band", vec![0.5_f64])
.set("fill", red())
.build();
let shapes = shapes();
let mut scene = RecordingScene::default();
g.draw(
&mut scene,
&ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &resolver),
);
let bb = first_fill_bbox(&scene).expect("fill");
let radius_px = bb.width() * 0.5;
assert!((radius_px - 37.0).abs() < 1.0, "radius_px = {radius_px}");
}
#[test]
fn radius2_band_creates_band_sized_donut() {
let x_scale = scale::discrete(["A", "B"].into_iter().map(|s| Value::String(Arc::from(s))));
let resolver = DirectScaleResolver::new().with("x", &x_scale);
let g = WedgeGeom::builder()
.set("x", vec!["A"])
.set("y", vec![0.5_f64])
.set("radius_band", vec![0.5_f64])
.set("radius2_band", vec![0.25_f64])
.set("fill", red())
.build();
let shapes = shapes();
let mut scene = RecordingScene::default();
g.draw(
&mut scene,
&ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &resolver),
);
let bb = first_fill_bbox(&scene).expect("fill");
assert!((bb.width() - 50.0).abs() < 1.5);
for op in &scene.ops {
if let Op::Fill { path, .. } = op {
let moves = path
.elements()
.iter()
.filter(|el| matches!(el, crate::path::PathEl::MoveTo(_)))
.count();
assert!(moves >= 1, "annular path expected");
return;
}
}
panic!("no fill");
}
#[test]
fn x_offset_translates_centre_in_pt() {
let g = WedgeGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("radius", vec![24.0_f64])
.set("x_offset", vec![9.0_f64]) .set("fill", red())
.build();
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 bb = first_fill_bbox(&scene).expect("fill");
let cx = (bb.x0 + bb.x1) * 0.5;
assert!((cx - 62.0).abs() < 1e-3, "cx = {cx}");
}
#[test]
fn corner_radius_adds_curves_to_wedge() {
let g = WedgeGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("radius", vec![20.0_f64])
.set("theta", vec![0.0_f64])
.set("theta2", vec![std::f64::consts::FRAC_PI_2])
.set("fill", red())
.set("corner_radius", 4.0_f64)
.build();
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 curves = scene
.ops
.iter()
.find_map(|op| match op {
Op::Fill { path, .. } => Some(
path.elements()
.iter()
.filter(|el| matches!(el, crate::path::PathEl::CurveTo(_, _, _)))
.count(),
),
_ => None,
})
.expect("fill");
assert!(curves >= 3, "expected ≥ 3 curves, got {curves}");
}
#[test]
fn expand_grows_wedge_outward() {
let g_base = WedgeGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("radius", vec![20.0_f64])
.set("fill", red())
.build();
let shapes = shapes();
let scales = DirectScaleResolver::new();
let mut s1 = RecordingScene::default();
g_base.draw(
&mut s1,
&ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &scales),
);
let bb1 = first_fill_bbox(&s1).expect("fill");
let g_expanded = WedgeGeom::builder()
.set("x", vec![0.5_f64])
.set("y", vec![0.5_f64])
.set("radius", vec![20.0_f64])
.set("fill", red())
.set("expand", 6.0_f64)
.build();
let mut s2 = RecordingScene::default();
g_expanded.draw(
&mut s2,
&ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &scales),
);
let bb2 = first_fill_bbox(&s2).expect("fill expanded");
let expand_px = 6.0 * 96.0 / 72.0;
assert!(
bb2.width() - bb1.width() > 2.0 * expand_px - 1.0,
"expanded width {} vs base {} (expand_px = {})",
bb2.width(),
bb1.width(),
expand_px
);
}
}