use crate::geometry::{Affine, Point};
use crate::plot::scale::Scale;
use crate::plot::value::{DataColumn, Value};
use crate::primitives::CornerRounding;
use crate::scene::SceneBuilder;
#[cfg(test)]
use crate::stroke::{Cap, Join};
use super::linetype;
use super::marks::{build_marks_from_column, MarkSlot};
use super::outline::{
draw_curve_outline, draw_ribbon_mode_curve, resolve_outline_spec, OutlineChannels,
OutlineScales,
};
use super::project::{project_and_densify, PathOptions, PathVertex, VertexAttrs};
use super::resolve::{
channel_color_space, channel_varies_across, offset_px, override_alpha, pt_to_px,
resolve_angle_channel, resolve_color_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 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),
("fill", ExpectedOutput::Colors),
("stroke", ExpectedOutput::Colors),
("stroke_opacity", ExpectedOutput::Numbers),
("linewidth", ExpectedOutput::Numbers),
("linetype", ExpectedOutput::Linetypes),
("dash_offset", ExpectedOutput::Numbers),
("cap", ExpectedOutput::Strings),
("join", ExpectedOutput::Strings),
("corner_radius", ExpectedOutput::Numbers),
("corner_max_angle", ExpectedOutput::Numbers),
("clip_start_radius", ExpectedOutput::Numbers),
("clip_end_radius", ExpectedOutput::Numbers),
("angle", ExpectedOutput::Numbers),
("pick_id", 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),
];
pub struct LineGeom {
pub(crate) state: GeomState,
pub(crate) marks: Vec<MarkSlot>,
}
crate::impl_geom_inherents_grouped!(LineGeom);
impl LineGeom {
pub(crate) fn build_marks(&self) -> Vec<MarkSlot> {
super::marks::build_marks(&self.state.keys)
}
}
impl BuildableGeom for LineGeom {
fn build_from(builder: GeomBuilder<Self>) -> Self {
let (keys_opt, channels) = builder.into_parts();
let n = require_x_and_siblings(&channels, &["y"], "LineGeom");
if let Some(ch) = channels.get("linetype") {
validate_linetype_channel(ch);
}
let state = finalize_state(
keys_opt,
channels,
n,
KeysStrategy::OneMark,
CHANNELS,
"LineGeom",
);
LineGeom {
state,
marks: Vec::new(),
}
}
}
fn validate_linetype_channel(ch: &Channel) {
match ch {
Channel::Constant(Value::Linetype(p)) | Channel::RawConstant(Value::Linetype(p)) => {
linetype::validate_pattern(p);
}
Channel::Constant(_) | Channel::RawConstant(_) => {} Channel::Data(DataColumn::Linetype(v)) | Channel::RawData(DataColumn::Linetype(v)) => {
for p in v.iter() {
linetype::validate_pattern(p);
}
}
Channel::Data(_) | Channel::RawData(_) => {} }
}
#[derive(Clone, Copy)]
struct LineDrawCtx<'a> {
x_col: &'a DataColumn,
y_col: &'a DataColumn,
x_scale: Option<&'a Scale>,
y_scale: Option<&'a Scale>,
fill: ChannelBind<'a>,
stroke: ChannelBind<'a>,
stroke_opacity: ChannelBind<'a>,
linewidth: ChannelBind<'a>,
pick_id: ChannelBind<'a>,
corner_radius: ChannelBind<'a>,
corner_max_angle: ChannelBind<'a>,
angle: ChannelBind<'a>,
x_offset: ChannelBind<'a>,
y_offset: ChannelBind<'a>,
x_band: ChannelBind<'a>,
y_band: ChannelBind<'a>,
outline_ch: OutlineChannels<'a>,
outline_sc: OutlineScales<'a>,
}
impl<'a> LineDrawCtx<'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,
fill: b("fill"),
stroke: b("stroke"),
stroke_opacity: b("stroke_opacity"),
linewidth: b("linewidth"),
pick_id: b("pick_id"),
corner_radius: b("corner_radius"),
corner_max_angle: b("corner_max_angle"),
angle: b("angle"),
x_offset: b("x_offset"),
y_offset: b("y_offset"),
x_band: b("x_band"),
y_band: b("y_band"),
outline_ch: OutlineChannels::from_map(channels, ""),
outline_sc: OutlineScales::from_ctx(ctx, ""),
})
}
}
impl Geom for LineGeom {
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("line")
}
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),
"LineGeom",
);
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 LineDrawCtx::build(&self.state.channels, ctx) {
Some(dc) => dc,
None => return,
};
for mark in marks.iter() {
draw_one_mark(scene, ctx, dc, mark);
}
}
}
fn draw_one_mark(
scene: &mut dyn SceneBuilder,
ctx: &GeomContext<'_>,
dc: LineDrawCtx<'_>,
mark: &MarkSlot,
) {
let LineDrawCtx {
x_col,
y_col,
x_scale,
y_scale,
fill,
stroke: ChannelBind {
ch: stroke_ch,
scale: stroke_scale,
},
stroke_opacity:
ChannelBind {
ch: stroke_opacity_ch,
scale: stroke_opacity_scale,
},
linewidth:
ChannelBind {
ch: linewidth_ch,
scale: linewidth_scale,
},
pick_id:
ChannelBind {
ch: pick_id_ch,
scale: pick_id_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,
},
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,
},
outline_ch,
outline_sc,
} = dc;
let i0 = mark.first_row;
let pick = resolve_pick_id(pick_id_ch, pick_id_scale, i0);
let Some(mut spec) = resolve_outline_spec(
ctx,
(&ctx.theme.geom.line).into(),
&outline_ch,
&outline_sc,
fill,
i0,
pick,
) else {
return; };
let stroke_color = spec.stroke_color;
let linewidth_px = pt_to_px(spec.linewidth_pt, ctx.dpi);
let corner_radius_pt =
resolve_number_channel_or(corner_radius_ch, corner_radius_scale, i0, 0.0);
let linewidth_varies = channel_varies_across(linewidth_ch, linewidth_scale, &mark.rows);
let stroke_varies = channel_varies_across(stroke_ch, stroke_scale, &mark.rows)
|| channel_varies_across(stroke_opacity_ch, stroke_opacity_scale, &mark.rows);
let ribbon_mode = spec.dash_pattern_pt.is_empty()
&& corner_radius_pt == 0.0
&& (linewidth_varies || stroke_varies);
let stroke_space = channel_color_space(stroke_scale);
if !ribbon_mode && (!linewidth_px.is_finite() || linewidth_px <= 0.0) {
return;
}
let runs = project_and_densify(
ctx,
mark.rows.len(),
&PathOptions::split(2).with_color_space(stroke_space),
|k| {
let i = mark.rows[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.line.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(stroke_color),
}
});
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 runs.is_empty() {
return;
}
let angle = resolve_angle_channel(angle_ch, angle_scale, i0);
spec.xform = if angle == 0.0 {
Affine::IDENTITY
} else {
let all = || runs.iter().flat_map(|r| r.points.iter());
let n_pts = all().count() as f64;
let cx = all().map(|p| p.x).sum::<f64>() / n_pts;
let cy = all().map(|p| p.y).sum::<f64>() / n_pts;
Affine::rotate_about(-angle, Point::new(cx, cy))
};
spec.corner_rounding = (corner_radius_pt > 0.0).then(|| {
let max_angle_deg = resolve_number_channel_or(
corner_max_angle_ch,
corner_max_angle_scale,
i0,
f64::INFINITY,
);
CornerRounding {
max_cut: pt_to_px(corner_radius_pt, ctx.dpi),
max_angle_deg,
}
});
let last_run = runs.len() - 1;
for (ri, run) in runs.iter().enumerate() {
let mut run_spec = spec.clone();
if ri != 0 {
run_spec.user_clip_start_pt = 0.0;
run_spec.start_marker.name.clear();
}
if ri != last_run {
run_spec.user_clip_end_pt = 0.0;
run_spec.end_marker.name.clear();
}
if ribbon_mode {
draw_ribbon_mode_curve(
scene,
ctx.shapes,
ctx.dpi,
&run.points,
&run.widths,
&run.colors,
stroke_space,
&run_spec,
run_spec.xform,
);
} else {
draw_curve_outline(
scene,
ctx.shapes,
ctx.dpi,
ctx.theme.geom.marker_outline_pt,
&run.points,
&run_spec,
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plot::value::LinetypeStep;
use std::sync::Arc;
use crate::color::Color;
use crate::geometry::Rect;
use crate::plot::geom::{linetype, DirectScaleResolver, Raw};
use crate::plot::scale;
use crate::scene::recording::{Op, RecordingScene};
fn registry() -> crate::shape::ShapeRegistry {
crate::shape::ShapeRegistry::with_builtins()
}
fn ctx<'a>(
panel: Rect,
shapes: &'a crate::shape::ShapeRegistry,
scales: &'a DirectScaleResolver<'a>,
) -> GeomContext<'a> {
GeomContext::new(panel, 96.0, shapes, scales)
}
#[test]
fn stroke_style_endpoint_marker_outline_lands_at_requested_width() {
let linewidth_pt = 1.5;
let mut g = LineGeom::builder()
.set("x", Raw(vec![0.1_f64, 0.9]))
.set("y", Raw(vec![0.5_f64, 0.5]))
.set("stroke", Color::new([0.0, 0.0, 1.0, 1.0]))
.set("linewidth", linewidth_pt)
.set("end_marker", "arrow-open")
.build();
g.rebuild_diff_against_previous();
let shapes = registry();
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),
);
let expected = pt_to_px(linewidth_pt, 96.0);
let widths: Vec<f64> = scene
.ops
.iter()
.filter_map(|op| match op {
Op::Stroke {
stroke, transform, ..
} => {
let c = transform.as_coeffs();
Some(stroke.width * c[0].abs().max(c[3].abs()))
}
_ => None,
})
.collect();
assert!(widths.len() > 1, "expected a body and a marker stroke");
for w in widths {
assert!(
(w - expected).abs() < 1e-9,
"effective stroke width {w} should be {expected}"
);
}
}
#[test]
fn angle_pivots_around_mark_centroid() {
use std::f64::consts::PI;
let mut g = LineGeom::builder()
.set("x", vec![0.0_f64, 0.02, 0.0])
.set("y", vec![0.0_f64, 0.0, 0.02])
.set("stroke", Color::new([1.0, 0.0, 0.0, 1.0]))
.set("angle", PI)
.build();
g.rebuild_diff_against_previous();
let panel = Rect::new(0.0, 0.0, 100.0, 100.0);
let shapes = registry();
let scales = DirectScaleResolver::new();
let mut scene = RecordingScene::default();
g.draw(&mut scene, &ctx(panel, &shapes, &scales));
let xform = scene
.ops
.iter()
.find_map(|op| match op {
Op::Stroke { transform, .. } => Some(*transform),
_ => None,
})
.expect("stroke op");
let pivot = crate::geometry::Point::new(2.0 / 3.0, (100.0 + 100.0 + 98.0) / 3.0);
let mapped = xform * pivot;
assert!((mapped.x - pivot.x).abs() < 1e-6, "pivot.x = {}", mapped.x);
assert!((mapped.y - pivot.y).abs() < 1e-6, "pivot.y = {}", mapped.y);
let v0 = xform * crate::geometry::Point::new(0.0, 100.0);
assert!((v0.x - 2.0 * pivot.x).abs() < 1e-6, "v0.x = {}", v0.x);
assert!(
(v0.y - (2.0 * pivot.y - 100.0)).abs() < 1e-6,
"v0.y = {}",
v0.y
);
}
#[test]
fn angle_zero_produces_identity_xform() {
let mut g = LineGeom::builder()
.set("x", vec![0.0_f64, 0.5, 1.0])
.set("y", vec![0.0_f64, 0.5, 0.0])
.set("stroke", Color::new([1.0, 0.0, 0.0, 1.0]))
.set("angle", 0.0_f64)
.build();
g.rebuild_diff_against_previous();
let panel = Rect::new(0.0, 0.0, 100.0, 100.0);
let shapes = registry();
let scales = DirectScaleResolver::new();
let mut scene = RecordingScene::default();
g.draw(&mut scene, &ctx(panel, &shapes, &scales));
let xform = scene
.ops
.iter()
.find_map(|op| match op {
Op::Stroke { transform, .. } => Some(*transform),
_ => None,
})
.expect("stroke op");
assert_eq!(xform.as_coeffs(), Affine::IDENTITY.as_coeffs());
}
#[test]
fn builder_no_keys_synthesises_single_mark() {
let g = LineGeom::builder()
.set("x", vec![0.0_f64, 1.0, 2.0])
.set("y", vec![0.0_f64, 1.0, 4.0])
.build();
assert_eq!(g.len(), 3);
assert_eq!(g.mark_count(), 1);
}
#[test]
fn builder_explicit_keys_define_marks() {
let g = LineGeom::builder()
.keys(vec!["A", "A", "B", "B"])
.set("x", vec![0.0_f64, 1.0, 0.0, 1.0])
.set("y", vec![0.0_f64, 1.0, 2.0, 3.0])
.build();
assert_eq!(g.len(), 4);
assert_eq!(g.mark_count(), 2);
}
#[test]
fn builder_non_contiguous_keys_bucket_correctly() {
let g = LineGeom::builder()
.keys(vec!["A", "B", "A", "C", "B"])
.set("x", vec![0.0_f64, 1.0, 2.0, 3.0, 4.0])
.set("y", vec![0.0_f64, 1.0, 2.0, 3.0, 4.0])
.build();
let marks = g.build_marks();
assert_eq!(marks.len(), 3);
assert_eq!(marks[0].first_row, 0);
assert_eq!(marks[0].rows, vec![0, 2]);
assert_eq!(marks[1].first_row, 1);
assert_eq!(marks[1].rows, vec![1, 4]);
assert_eq!(marks[2].first_row, 3);
assert_eq!(marks[2].rows, vec![3]);
}
#[test]
#[should_panic(expected = "missing required channel")]
fn builder_missing_x_panics() {
LineGeom::builder()
.set("y", vec![1.0_f64, 2.0, 3.0])
.build();
}
#[test]
#[should_panic(expected = "must be data, not constant")]
fn builder_x_constant_panics() {
LineGeom::builder()
.set("x", 5.0)
.set("y", vec![1.0_f64])
.build();
}
#[test]
#[should_panic(expected = "does not match")]
fn builder_mismatched_lengths_panic() {
LineGeom::builder()
.set("x", vec![0.0_f64, 1.0, 2.0])
.set("y", vec![0.0_f64, 1.0])
.build();
}
#[test]
#[should_panic(expected = "even length")]
fn builder_odd_length_linetype_constant_panics() {
LineGeom::builder()
.set("x", vec![0.0_f64, 1.0])
.set("y", vec![0.0_f64, 1.0])
.set(
"linetype",
Value::Linetype(Arc::from(vec![
LinetypeStep::Dash(1.0),
LinetypeStep::Gap(2.0),
LinetypeStep::Dash(3.0),
])),
)
.build();
}
#[test]
fn builder_empty_linetype_is_solid_no_panic() {
let _g = LineGeom::builder()
.set("x", vec![0.0_f64, 1.0])
.set("y", vec![0.0_f64, 1.0])
.set("linetype", Value::Linetype(linetype::solid()))
.build();
}
fn no_scales<'a>() -> DirectScaleResolver<'a> {
DirectScaleResolver::new()
}
fn red_solid() -> Color {
Color::new([1.0, 0.0, 0.0, 1.0])
}
#[test]
fn draw_one_mark_emits_one_stroke_op() {
let mut g = LineGeom::builder()
.set("x", vec![0.0_f64, 0.5, 1.0])
.set("y", vec![0.0_f64, 1.0, 0.0])
.set("stroke", red_solid())
.build();
g.rebuild_diff_against_previous();
let shapes = registry();
let scales = no_scales();
let c = ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &scales);
let mut scene = RecordingScene::default();
g.draw(&mut scene, &c);
let strokes = scene
.ops
.iter()
.filter(|op| matches!(op, Op::Stroke { .. }))
.count();
assert_eq!(strokes, 1);
}
#[test]
fn draw_three_marks_emit_three_strokes_with_first_row_pick_ids() {
let mut g = LineGeom::builder()
.keys(vec!["A", "A", "B", "B", "C", "C"])
.set("x", vec![0.0_f64, 1.0, 0.0, 1.0, 0.0, 1.0])
.set("y", vec![0.0_f64, 1.0, 2.0, 3.0, 4.0, 5.0])
.set("stroke", red_solid())
.set("pick_id", vec![100_i64, 0, 200, 0, 300, 0])
.build();
g.rebuild_diff_against_previous();
assert_eq!(g.mark_count(), 3);
let shapes = registry();
let scales = no_scales();
let c = ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &scales);
let mut scene = RecordingScene::default();
g.draw(&mut scene, &c);
let stroke_picks: Vec<u32> = scene
.ops
.iter()
.filter_map(|op| match op {
Op::Stroke {
pick_id: crate::pick::PickId::Id(n),
..
} => Some(*n),
_ => None,
})
.collect();
assert_eq!(stroke_picks, vec![100, 200, 300]);
}
#[test]
fn constant_linewidth_converts_pt_to_px() {
let mut g = LineGeom::builder()
.set("x", vec![0.0_f64, 1.0])
.set("y", vec![0.0_f64, 1.0])
.set("stroke", red_solid())
.set("linewidth", 2.0_f64)
.build();
g.rebuild_diff_against_previous();
let shapes = registry();
let scales = no_scales();
let c = ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &scales);
let mut scene = RecordingScene::default();
g.draw(&mut scene, &c);
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 within_mark_linewidth_divergence_upgrades_to_mesh() {
let mut g = LineGeom::builder()
.keys(vec!["A", "A"])
.set("x", vec![0.0_f64, 1.0])
.set("y", vec![0.0_f64, 1.0])
.set("linewidth", vec![4.0_f64, 12.0])
.set("stroke", red_solid())
.build();
g.rebuild_diff_against_previous();
let shapes = registry();
let scales = no_scales();
let c = ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &scales);
let mut scene = RecordingScene::default();
g.draw(&mut scene, &c);
let strokes = scene
.ops
.iter()
.filter(|op| matches!(op, Op::Stroke { .. }))
.count();
assert_eq!(strokes, 0);
let meshes: Vec<&crate::mesh::Mesh> = scene
.ops
.iter()
.filter_map(|op| match op {
Op::DrawMesh { mesh, .. } => Some(mesh),
_ => None,
})
.collect();
assert_eq!(meshes.len(), 1);
assert!(meshes[0].vertices.len() >= 4);
}
#[test]
fn linetype_constant_produces_dash_pattern() {
let mut g = LineGeom::builder()
.set("x", vec![0.0_f64, 1.0])
.set("y", vec![0.0_f64, 1.0])
.set("stroke", red_solid())
.set("linetype", Value::Linetype(linetype::dashed()))
.build();
g.rebuild_diff_against_previous();
let shapes = registry();
let scales = no_scales();
let c = ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &scales);
let mut scene = RecordingScene::default();
g.draw(&mut scene, &c);
for op in &scene.ops {
if let Op::Stroke { stroke, .. } = op {
let dashes = &stroke.dash_pattern;
assert!(!dashes.is_empty(), "expected dashes set");
let expected_a = 8.0 * 96.0 / 72.0;
let expected_b = 4.0 * 96.0 / 72.0;
assert!((dashes[0] - expected_a).abs() < 1e-9);
assert!((dashes[1] - expected_b).abs() < 1e-9);
return;
}
}
panic!("no stroke op emitted");
}
#[test]
fn dash_offset_shifts_phase() {
let mut g = LineGeom::builder()
.set("x", vec![0.0_f64, 1.0])
.set("y", vec![0.0_f64, 1.0])
.set("stroke", red_solid())
.set("linetype", Value::Linetype(linetype::dashed()))
.set("dash_offset", 3.0_f64)
.build();
g.rebuild_diff_against_previous();
let shapes = registry();
let scales = no_scales();
let c = ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &scales);
let mut scene = RecordingScene::default();
g.draw(&mut scene, &c);
for op in &scene.ops {
if let Op::Stroke { stroke, .. } = op {
let expected_offset = 3.0 * 96.0 / 72.0;
assert!((stroke.dash_offset - expected_offset).abs() < 1e-9);
return;
}
}
panic!("no stroke op emitted");
}
#[test]
fn dash_offset_no_effect_when_solid() {
let mut g = LineGeom::builder()
.set("x", vec![0.0_f64, 1.0])
.set("y", vec![0.0_f64, 1.0])
.set("stroke", red_solid())
.set("dash_offset", 5.0_f64)
.build();
g.rebuild_diff_against_previous();
let shapes = registry();
let scales = no_scales();
let c = ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &scales);
let mut scene = RecordingScene::default();
g.draw(&mut scene, &c);
for op in &scene.ops {
if let Op::Stroke { stroke, .. } = op {
assert!(stroke.dash_pattern.is_empty());
return;
}
}
panic!("no stroke op emitted");
}
#[test]
fn linetype_scale_per_mark_resolution() {
let s = scale::ordinal(["A", "B"]).range_linetypes([linetype::solid(), linetype::dashed()]);
let resolver = DirectScaleResolver::new().with("linetype", &s);
let mut g = LineGeom::builder()
.keys(vec!["A", "A", "B", "B"])
.set("x", vec![0.0_f64, 1.0, 0.0, 1.0])
.set("y", vec![0.0_f64, 1.0, 2.0, 3.0])
.set("linetype", vec!["A", "A", "B", "B"])
.set("stroke", red_solid())
.build();
g.rebuild_diff_against_previous();
let shapes = registry();
let c = ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &resolver);
let mut scene = RecordingScene::default();
g.draw(&mut scene, &c);
let dash_patterns: Vec<Vec<f64>> = scene
.ops
.iter()
.filter_map(|op| match op {
Op::Stroke { stroke, .. } => Some(stroke.dash_pattern.iter().copied().collect()),
_ => None,
})
.collect();
assert_eq!(dash_patterns.len(), 2);
assert!(dash_patterns[0].is_empty());
let expected_a = 8.0 * 96.0 / 72.0;
let expected_b = 4.0 * 96.0 / 72.0;
assert!((dash_patterns[1][0] - expected_a).abs() < 1e-9);
assert!((dash_patterns[1][1] - expected_b).abs() < 1e-9);
}
fn marker_pattern(name: &str, gap_pt: f64) -> std::sync::Arc<[LinetypeStep]> {
linetype::pattern([linetype::marker(name), linetype::gap(gap_pt)])
}
#[test]
fn linetype_solid_emits_one_stroke_op_no_markers() {
let mut g = LineGeom::builder()
.set("x", Raw(vec![0.0_f64, 1.0]))
.set("y", Raw(vec![0.5_f64, 0.5]))
.set("stroke", red_solid())
.build();
g.rebuild_diff_against_previous();
let shapes = registry();
let scales = no_scales();
let c = ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &scales);
let mut scene = RecordingScene::default();
g.draw(&mut scene, &c);
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, 1);
assert_eq!(fills, 0);
}
#[test]
fn linetype_pure_dashes_uses_fast_path_one_stroke_op() {
let mut g = LineGeom::builder()
.set("x", Raw(vec![0.0_f64, 1.0]))
.set("y", Raw(vec![0.5_f64, 0.5]))
.set("stroke", red_solid())
.set("linetype", Value::Linetype(linetype::dashed()))
.build();
g.rebuild_diff_against_previous();
let shapes = registry();
let scales = no_scales();
let c = ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &scales);
let mut scene = RecordingScene::default();
g.draw(&mut scene, &c);
let strokes: Vec<_> = scene
.ops
.iter()
.filter_map(|op| match op {
Op::Stroke { stroke, .. } => Some(stroke.clone()),
_ => None,
})
.collect();
assert_eq!(strokes.len(), 1, "fast path: one stroke op");
assert!(
!strokes[0].dash_pattern.is_empty(),
"kurbo dash pattern set"
);
}
#[test]
fn linetype_marker_pattern_emits_marker_fills() {
let mut g = LineGeom::builder()
.set("x", Raw(vec![0.0_f64, 100.0]))
.set("y", Raw(vec![50.0, 50.0]))
.set("stroke", red_solid())
.set("linewidth", 4.0_f64)
.set("linetype", Value::Linetype(marker_pattern("circle", 5.0)))
.build();
g.rebuild_diff_against_previous();
let shapes = registry();
let scales = no_scales();
let c = ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &scales);
let mut scene = RecordingScene::default();
g.draw(&mut scene, &c);
let fills = scene
.ops
.iter()
.filter(|op| matches!(op, Op::Fill { .. }))
.count();
let strokes: Vec<_> = scene
.ops
.iter()
.filter_map(|op| match op {
Op::Stroke { stroke, .. } => Some(stroke.clone()),
_ => None,
})
.collect();
assert!(fills >= 6, "expected several marker fills, got {fills}");
for s in &strokes {
assert!(
s.dash_pattern.is_empty(),
"marker walker should not emit kurbo dashes"
);
}
}
#[test]
fn linetype_marker_glyph_shape_emits_draw_glyphs() {
let blob = crate::brush::Blob::new(std::sync::Arc::new(Vec::<u8>::new()));
let font = crate::scene::Font::new(blob, 0);
let em_bbox = crate::geometry::Rect::new(0.0, 0.0, 1.0, 1.0);
let em_origin = crate::geometry::Point::new(0.0, 0.8);
let glyph_shape =
crate::shape::Shape::glyph(font, 1, em_bbox, em_origin, crate::geometry::Point::ZERO);
let mut shapes = registry();
shapes.insert("glyph-stamp", glyph_shape);
let mut g = LineGeom::builder()
.set("x", Raw(vec![0.0_f64, 100.0]))
.set("y", Raw(vec![50.0, 50.0]))
.set("stroke", red_solid())
.set("linewidth", 4.0_f64)
.set(
"linetype",
Value::Linetype(marker_pattern("glyph-stamp", 5.0)),
)
.build();
g.rebuild_diff_against_previous();
let scales = no_scales();
let c = ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &scales);
let mut scene = RecordingScene::default();
g.draw(&mut scene, &c);
let glyph_count = scene
.ops
.iter()
.filter(|op| matches!(op, Op::DrawGlyphs(_)))
.count();
let fill_count = scene
.ops
.iter()
.filter(|op| matches!(op, Op::Fill { .. }))
.count();
assert!(
glyph_count >= 3,
"expected several glyph stamps, got {glyph_count}"
);
assert_eq!(fill_count, 0, "glyph marker path should not emit Fill ops");
}
#[test]
fn linetype_marker_consumes_linewidth_of_arc() {
let mut g = LineGeom::builder()
.set("x", Raw(vec![0.0_f64, 100.0]))
.set("y", Raw(vec![50.0, 50.0]))
.set("stroke", red_solid())
.set("linewidth", 4.0_f64)
.set("linetype", Value::Linetype(marker_pattern("circle", 5.0)))
.build();
g.rebuild_diff_against_previous();
let shapes = registry();
let scales = no_scales();
let c = ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &scales);
let mut scene = RecordingScene::default();
g.draw(&mut scene, &c);
let translations: Vec<f64> = scene
.ops
.iter()
.filter_map(|op| match op {
Op::Fill { transform, .. } => Some(transform.translation().x),
_ => None,
})
.collect();
assert!(translations.len() >= 3);
let expected_period = (4.0 + 5.0) * 96.0 / 72.0;
for w in translations.windows(2) {
let delta = w[1] - w[0];
assert!(
(delta - expected_period).abs() < 1e-6,
"marker spacing {delta} ≠ expected {expected_period}",
);
}
}
#[test]
fn linetype_marker_rotates_to_tangent() {
let mut g = LineGeom::builder()
.set("x", Raw(vec![50.0, 50.0]))
.set("y", Raw(vec![100.0, 0.0])) .set("stroke", red_solid())
.set("linewidth", 4.0_f64)
.set("linetype", Value::Linetype(marker_pattern("circle", 5.0)))
.build();
g.rebuild_diff_against_previous();
let shapes = registry();
let scales = no_scales();
let c = ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &scales);
let mut scene = RecordingScene::default();
g.draw(&mut scene, &c);
let coeffs = scene
.ops
.iter()
.find_map(|op| match op {
Op::Fill { transform, .. } => Some(transform.as_coeffs()),
_ => None,
})
.expect("at least one marker fill");
let linewidth_px = 4.0 * 96.0 / 72.0;
let circle_bbox_h = 1.6;
let s = linewidth_px / circle_bbox_h;
assert!(coeffs[0].abs() < 1e-9, "a ≈ 0, got {}", coeffs[0]);
assert!((coeffs[1] - s).abs() < 1e-9, "b ≈ s, got {}", coeffs[1]);
assert!((coeffs[2] + s).abs() < 1e-9, "c ≈ -s, got {}", coeffs[2]);
assert!(coeffs[3].abs() < 1e-9, "d ≈ 0, got {}", coeffs[3]);
}
#[test]
fn linetype_marker_fill_defaults_to_stroke_color() {
let stroke = red_solid();
let mut g = LineGeom::builder()
.set("x", Raw(vec![0.0_f64, 100.0]))
.set("y", Raw(vec![50.0, 50.0]))
.set("stroke", stroke)
.set("linewidth", 4.0_f64)
.set("linetype", Value::Linetype(marker_pattern("circle", 5.0)))
.build();
g.rebuild_diff_against_previous();
let shapes = registry();
let scales = no_scales();
let c = ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &scales);
let mut scene = RecordingScene::default();
g.draw(&mut scene, &c);
let fill_colors: Vec<_> = scene
.ops
.iter()
.filter_map(|op| match op {
Op::Fill {
brush: crate::brush::Brush::Solid(c),
..
} => Some(*c),
_ => None,
})
.collect();
assert!(!fill_colors.is_empty());
for c in fill_colors {
assert_eq!(c, stroke, "marker fill defaults to stroke color");
}
}
#[test]
fn linetype_fill_channel_overrides_marker_color() {
let stroke = red_solid();
let blue = Color::new([0.0, 0.0, 1.0, 1.0]);
let mut g = LineGeom::builder()
.set("x", Raw(vec![0.0_f64, 100.0]))
.set("y", Raw(vec![50.0, 50.0]))
.set("stroke", stroke)
.set("fill", blue)
.set("linewidth", 4.0_f64)
.set("linetype", Value::Linetype(marker_pattern("circle", 5.0)))
.build();
g.rebuild_diff_against_previous();
let shapes = registry();
let scales = no_scales();
let c = ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &scales);
let mut scene = RecordingScene::default();
g.draw(&mut scene, &c);
let fill_color = scene
.ops
.iter()
.find_map(|op| match op {
Op::Fill {
brush: crate::brush::Brush::Solid(c),
..
} => Some(*c),
_ => None,
})
.expect("at least one marker fill");
assert_eq!(fill_color, blue);
}
#[test]
fn linetype_mixed_dash_and_marker() {
let pat = linetype::pattern([
linetype::dash(5.0),
linetype::gap(2.0),
linetype::marker("circle"),
linetype::gap(4.0),
]);
let mut g = LineGeom::builder()
.set("x", Raw(vec![0.0_f64, 200.0]))
.set("y", Raw(vec![50.0, 50.0]))
.set("stroke", red_solid())
.set("linewidth", 2.0_f64)
.set("linetype", Value::Linetype(pat))
.build();
g.rebuild_diff_against_previous();
let shapes = registry();
let scales = no_scales();
let c = ctx(Rect::new(0.0, 0.0, 200.0, 100.0), &shapes, &scales);
let mut scene = RecordingScene::default();
g.draw(&mut scene, &c);
let fills = scene
.ops
.iter()
.filter(|op| matches!(op, Op::Fill { .. }))
.count();
let strokes = scene
.ops
.iter()
.filter(|op| matches!(op, Op::Stroke { .. }))
.count();
assert!(fills > 0, "markers emit fills");
assert!(strokes > 0, "dashes emit strokes");
}
#[test]
fn linetype_marker_on_rounded_corner_follows_path() {
let mut g = LineGeom::builder()
.set("x", Raw(vec![0.0_f64, 50.0, 50.0]))
.set("y", Raw(vec![50.0, 50.0, 100.0]))
.set("stroke", red_solid())
.set("linewidth", 3.0_f64)
.set("corner_radius", 10.0_f64)
.set("linetype", Value::Linetype(marker_pattern("circle", 4.0)))
.build();
g.rebuild_diff_against_previous();
let shapes = registry();
let scales = no_scales();
let c = ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &scales);
let mut scene = RecordingScene::default();
g.draw(&mut scene, &c);
let fills = scene
.ops
.iter()
.filter(|op| matches!(op, Op::Fill { .. }))
.count();
assert!(fills > 0, "marker walker handled the rounded path");
}
#[test]
fn cap_join_constants() {
let mut g = LineGeom::builder()
.set("x", vec![0.0_f64, 1.0])
.set("y", vec![0.0_f64, 1.0])
.set("stroke", red_solid())
.set("cap", Value::String(Arc::from("round")))
.set("join", Value::String(Arc::from("bevel")))
.build();
g.rebuild_diff_against_previous();
let shapes = registry();
let scales = no_scales();
let c = ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &scales);
let mut scene = RecordingScene::default();
g.draw(&mut scene, &c);
for op in &scene.ops {
if let Op::Stroke { stroke, .. } = op {
assert!(matches!(stroke.start_cap, Cap::Round));
assert!(matches!(stroke.end_cap, Cap::Round));
assert!(matches!(stroke.join, Join::Bevel));
return;
}
}
panic!("no stroke op emitted");
}
#[test]
fn no_stroke_channel_means_no_draw() {
let mut g = LineGeom::builder()
.set("x", vec![0.0_f64, 1.0])
.set("y", vec![0.0_f64, 1.0])
.build();
g.rebuild_diff_against_previous();
let shapes = registry();
let scales = no_scales();
let c = ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &scales);
let mut scene = RecordingScene::default();
g.draw(&mut scene, &c);
let strokes = scene
.ops
.iter()
.filter(|op| matches!(op, Op::Stroke { .. }))
.count();
assert_eq!(strokes, 0);
}
#[test]
fn nonfinite_vertex_splits_the_line() {
let mut g = LineGeom::builder()
.set("x", Raw(vec![0.0_f64, 0.25, 0.5, 0.75, 1.0]))
.set("y", Raw(vec![0.5_f64, 0.5, f64::NAN, 0.5, 0.5]))
.set("stroke", red_solid())
.build();
g.rebuild_diff_against_previous();
let shapes = registry();
let scales = no_scales();
let c = ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &scales);
let mut scene = RecordingScene::default();
g.draw(&mut scene, &c);
let paths = stroke_paths(&scene);
assert_eq!(paths.len(), 2);
let first = path_points(&paths[0]);
let second = path_points(&paths[1]);
assert_eq!(first.len(), 2);
assert_eq!(second.len(), 2);
assert!((first[0].x - 0.0).abs() < 1e-6);
assert!((first[1].x - 25.0).abs() < 1e-6);
assert!((second[0].x - 75.0).abs() < 1e-6);
assert!((second[1].x - 100.0).abs() < 1e-6);
}
#[test]
fn split_run_of_one_vertex_draws_nothing() {
let mut g = LineGeom::builder()
.set("x", vec![0.0_f64, 0.5, 1.0])
.set("y", vec![0.0_f64, f64::NAN, 1.0])
.set("stroke", red_solid())
.build();
g.rebuild_diff_against_previous();
let shapes = registry();
let scales = no_scales();
let c = ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &scales);
let mut scene = RecordingScene::default();
g.draw(&mut scene, &c);
assert_eq!(stroke_paths(&scene).len(), 0);
}
#[test]
fn split_line_markers_stay_at_the_mark_terminals() {
let mut g = LineGeom::builder()
.set("x", Raw(vec![0.0_f64, 0.25, 0.5, 0.75, 1.0]))
.set("y", Raw(vec![0.5_f64, 0.5, f64::NAN, 0.5, 0.5]))
.set("stroke", red_solid())
.set("start_marker", "arrow-closed")
.set("end_marker", "arrow-closed")
.build();
g.rebuild_diff_against_previous();
let shapes = registry();
let scales = no_scales();
let c = ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &scales);
let mut scene = RecordingScene::default();
g.draw(&mut scene, &c);
let mut strokes_seen = 0;
let mut fill_positions = Vec::new();
for op in &scene.ops {
match op {
Op::Stroke { .. } => strokes_seen += 1,
Op::Fill { .. } => fill_positions.push(strokes_seen),
_ => {}
}
}
assert_eq!(strokes_seen, 2);
assert_eq!(fill_positions, vec![0, 2]);
}
#[test]
fn nonfinite_vertex_splits_ribbon_mode_mesh() {
let mut g = LineGeom::builder()
.set("x", Raw(vec![0.0_f64, 0.25, 0.5, 0.75, 1.0]))
.set("y", Raw(vec![0.5_f64, 0.5, f64::NAN, 0.5, 0.5]))
.set("stroke", red_solid())
.set("linewidth", Raw(vec![1.0_f64, 3.0, 3.0, 5.0, 7.0]))
.build();
g.rebuild_diff_against_previous();
let shapes = registry();
let scales = no_scales();
let c = ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &scales);
let mut scene = RecordingScene::default();
g.draw(&mut scene, &c);
assert_eq!(mesh_ops(&scene).len(), 2);
}
#[test]
fn diff_marks_enter_on_first_draw() {
let mut g = LineGeom::builder()
.keys(vec!["A", "A", "B", "B"])
.set("x", vec![0.0_f64, 1.0, 0.0, 1.0])
.set("y", vec![0.0_f64, 1.0, 2.0, 3.0])
.build();
g.rebuild_diff_against_previous();
assert_eq!(g.state.enter.len(), 2);
assert_eq!(g.state.exit.len(), 0);
}
#[test]
fn diff_marks_exit_when_mark_removed() {
let mut g = LineGeom::builder()
.keys(vec!["A", "A", "B", "B"])
.set("x", vec![0.0_f64, 1.0, 0.0, 1.0])
.set("y", vec![0.0_f64, 1.0, 2.0, 3.0])
.build();
g.rebuild_diff_against_previous();
g.update(|b| {
b.keys(vec!["A", "A"]);
b.set("x", vec![0.0_f64, 1.0]);
b.set("y", vec![0.0_f64, 1.0]);
});
g.rebuild_diff_against_previous();
assert_eq!(
g.state.exit.len(),
1,
"expected 1 exit (B), got {:?}",
g.state.exit
);
assert!(g.state.exit[0].key_eq(&Value::String(Arc::from("B"))));
}
fn stroke_path(scene: &RecordingScene) -> Option<crate::path::Path> {
scene.ops.iter().find_map(|op| match op {
Op::Stroke { path, .. } => Some(path.clone()),
_ => None,
})
}
fn stroke_paths(scene: &RecordingScene) -> Vec<crate::path::Path> {
scene
.ops
.iter()
.filter_map(|op| match op {
Op::Stroke { path, .. } => Some(path.clone()),
_ => None,
})
.collect()
}
fn path_points(path: &crate::path::Path) -> Vec<Point> {
path.elements()
.iter()
.filter_map(|el| match el {
crate::path::PathEl::MoveTo(p) | crate::path::PathEl::LineTo(p) => Some(*p),
_ => None,
})
.collect()
}
fn count_curves(path: &crate::path::Path) -> usize {
path.elements()
.iter()
.filter(|el| matches!(el, crate::path::PathEl::CurveTo(_, _, _)))
.count()
}
#[test]
fn corner_radius_produces_curves_at_each_vertex() {
let mut g = LineGeom::builder()
.set("x", vec![0.0_f64, 0.3, 0.6, 0.9])
.set("y", vec![0.0_f64, 0.5, 0.0, 0.5])
.set("stroke", red_solid())
.set("corner_radius", 5.0_f64)
.build();
g.rebuild_diff_against_previous();
let shapes = registry();
let scales = no_scales();
let c = ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &scales);
let mut scene = RecordingScene::default();
g.draw(&mut scene, &c);
let path = stroke_path(&scene).expect("stroke");
assert_eq!(count_curves(&path), 2);
}
#[test]
fn corner_radius_zero_keeps_path_polyline() {
let mut g = LineGeom::builder()
.set("x", vec![0.0_f64, 0.3, 0.6])
.set("y", vec![0.0_f64, 0.5, 0.0])
.set("stroke", red_solid())
.build();
g.rebuild_diff_against_previous();
let shapes = registry();
let scales = no_scales();
let mut scene = RecordingScene::default();
g.draw(
&mut scene,
&ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &scales),
);
let path = stroke_path(&scene).expect("stroke");
assert_eq!(count_curves(&path), 0);
}
#[test]
fn corner_max_angle_below_interior_skips_rounding() {
let pts_x: Vec<f64> = vec![0.0, 0.5, 0.5];
let pts_y: Vec<f64> = vec![0.5, 0.5, 1.0];
let mut g = LineGeom::builder()
.set("x", Raw(pts_x.clone()))
.set("y", Raw(pts_y.clone()))
.set("stroke", red_solid())
.set("corner_radius", 5.0_f64)
.set("corner_max_angle", 80.0_f64)
.build();
g.rebuild_diff_against_previous();
let shapes = registry();
let scales = no_scales();
let mut scene = RecordingScene::default();
g.draw(
&mut scene,
&ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &scales),
);
let path = stroke_path(&scene).expect("stroke");
assert_eq!(
count_curves(&path),
0,
"80° threshold should reject 90° corners"
);
let mut g2 = LineGeom::builder()
.set("x", Raw(pts_x))
.set("y", Raw(pts_y))
.set("stroke", red_solid())
.set("corner_radius", 5.0_f64)
.set("corner_max_angle", 95.0_f64)
.build();
g2.rebuild_diff_against_previous();
let mut scene2 = RecordingScene::default();
g2.draw(
&mut scene2,
&ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &scales),
);
let path2 = stroke_path(&scene2).expect("stroke");
assert_eq!(
count_curves(&path2),
1,
"95° threshold should accept 90° corners"
);
}
#[test]
fn clip_start_radius_trims_first_segment() {
let mut g = LineGeom::builder()
.set("x", Raw(vec![0.0_f64, 1.0]))
.set("y", Raw(vec![0.5_f64, 0.5]))
.set("stroke", red_solid())
.set("clip_start_radius", 20.0_f64)
.build();
g.rebuild_diff_against_previous();
let shapes = registry();
let scales = no_scales();
let mut scene = RecordingScene::default();
g.draw(
&mut scene,
&ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &scales),
);
let path = stroke_path(&scene).expect("stroke");
match path.elements().first() {
Some(crate::path::PathEl::MoveTo(p)) => {
let expected = 20.0 * 96.0 / 72.0;
assert!((p.x - expected).abs() < 1e-6, "start.x = {}", p.x);
}
other => panic!("expected MoveTo, got {other:?}"),
}
}
fn horizontal_line_with(
extra: impl FnOnce(&mut crate::plot::geom::GeomBuilder<LineGeom>),
) -> LineGeom {
let mut b = LineGeom::builder();
b.set("x", Raw(vec![0.0_f64, 1.0]))
.set("y", Raw(vec![0.5_f64, 0.5]))
.set("stroke", red_solid())
.set("linewidth", 2.0_f64);
extra(&mut b);
let mut g = b.build();
g.rebuild_diff_against_previous();
g
}
fn draw_into(g: &LineGeom) -> RecordingScene {
let shapes = registry();
let scales = no_scales();
let mut scene = RecordingScene::default();
g.draw(
&mut scene,
&ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &scales),
);
scene
}
fn fills_before_stroke(scene: &RecordingScene) -> Vec<Affine> {
let mut out = Vec::new();
for op in &scene.ops {
match op {
Op::Fill { transform, .. } => out.push(*transform),
Op::Stroke { .. } => break,
_ => {}
}
}
out
}
fn fills_after_stroke(scene: &RecordingScene) -> Vec<Affine> {
let mut seen_stroke = false;
let mut out = Vec::new();
for op in &scene.ops {
match op {
Op::Stroke { .. } => seen_stroke = true,
Op::Fill { transform, .. } if seen_stroke => out.push(*transform),
_ => {}
}
}
out
}
#[test]
fn line_endpoint_marker_unset_emits_no_extra_ops() {
let g = horizontal_line_with(|_| {});
let scene = draw_into(&g);
let fills = scene
.ops
.iter()
.filter(|op| matches!(op, Op::Fill { .. }))
.count();
assert_eq!(fills, 0);
}
#[test]
fn line_end_marker_anchor_lands_at_last_vertex() {
let g = horizontal_line_with(|b| {
b.set("end_marker", "arrow-closed")
.set("end_marker_size", 10.0_f64);
});
let scene = draw_into(&g);
let xforms = fills_after_stroke(&scene);
assert!(!xforms.is_empty());
let anchor = xforms[0] * Point::new(-1.0, 0.0);
let size_px = 10.0 * 96.0 / 72.0;
assert!((anchor.x - (100.0 - size_px)).abs() < 1e-6);
assert!((anchor.y - 50.0).abs() < 1e-6);
}
#[test]
fn line_start_marker_emitted_before_stroke() {
let g = horizontal_line_with(|b| {
b.set("start_marker", "arrow-closed")
.set("start_marker_size", 10.0_f64);
});
let scene = draw_into(&g);
let stroke_idx = scene
.ops
.iter()
.position(|op| matches!(op, Op::Stroke { .. }))
.expect("stroke op");
let fill_idx = scene
.ops
.iter()
.position(|op| matches!(op, Op::Fill { .. }))
.expect("start-marker fill");
assert!(
fill_idx < stroke_idx,
"start marker fill (idx {fill_idx}) must precede stroke (idx {stroke_idx})"
);
let xf = fills_before_stroke(&scene)[0];
let anchor = xf * Point::new(-1.0, 0.0);
let size_px = 10.0 * 96.0 / 72.0;
assert!((anchor.x - size_px).abs() < 1e-6);
assert!((anchor.y - 50.0).abs() < 1e-6);
}
#[test]
fn line_marker_size_default_is_three_times_linewidth() {
let g = horizontal_line_with(|b| {
b.set("end_marker", "arrow-closed");
});
let scene = draw_into(&g);
let xf = fills_after_stroke(&scene)[0];
let coeffs = xf.as_coeffs();
let det = coeffs[0] * coeffs[3] - coeffs[1] * coeffs[2];
let expected = 6.0 * 96.0 / 72.0;
assert!((det.abs() - expected * expected).abs() < 1e-6);
}
#[test]
fn line_marker_direction_is_chord_toward_original_endpoint() {
let g = horizontal_line_with(|b| {
b.set("x", Raw(vec![0.0_f64, 1.0, 1.0]))
.set("y", Raw(vec![1.0_f64, 1.0, 0.0]))
.set("end_marker", "arrow-closed")
.set("end_marker_size", 10.0_f64)
.set("clip_end_radius", 80.0_f64);
});
let scene = draw_into(&g);
let xforms = fills_after_stroke(&scene);
assert!(!xforms.is_empty(), "end marker emitted");
let xf = xforms[0];
let anchor = xf * Point::new(-1.0, 0.0);
let tip = xf * Point::new(0.0, 0.0);
let outward_x = tip.x - anchor.x;
let outward_y = tip.y - anchor.y;
let size_px = 10.0 * 96.0 / 72.0;
let original_last = Point::new(100.0, 100.0);
let clipped_last = anchor;
let dx = original_last.x - clipped_last.x;
let dy = original_last.y - clipped_last.y;
let len = (dx * dx + dy * dy).sqrt();
let exp_ox = dx / len * size_px;
let exp_oy = dy / len * size_px;
assert!(
(outward_x - exp_ox).abs() < 1e-3,
"outward.x mismatch: got {outward_x}, expected {exp_ox}"
);
assert!(
(outward_y - exp_oy).abs() < 1e-3,
"outward.y mismatch: got {outward_y}, expected {exp_oy}"
);
assert!(
outward_y.abs() > 0.1 * size_px,
"chord must carry a y component (was: {outward_y})"
);
}
#[test]
fn line_marker_respects_clip_start_radius() {
let g = horizontal_line_with(|b| {
b.set("start_marker", "arrow-closed")
.set("start_marker_size", 10.0_f64)
.set("clip_start_radius", 15.0_f64);
});
let scene = draw_into(&g);
let xf = fills_before_stroke(&scene)[0];
let anchor = xf * Point::new(-1.0, 0.0);
let user_clip_px = 15.0 * 96.0 / 72.0;
let auto_clip_px = 10.0 * 96.0 / 72.0;
let expected_anchor_x = user_clip_px + auto_clip_px;
assert!((anchor.x - expected_anchor_x).abs() < 1e-6);
}
#[test]
fn line_marker_invert_flips_rotation() {
let g = horizontal_line_with(|b| {
b.set("end_marker", "arrow-closed")
.set("end_marker_size", 10.0_f64)
.set("end_marker_invert", true);
});
let scene = draw_into(&g);
let xf = fills_after_stroke(&scene)[0];
let tip = xf * Point::new(0.0, 0.0);
let size_px = 10.0 * 96.0 / 72.0;
assert!(
(tip.x - (100.0 - size_px)).abs() < 1e-6,
"tip.x = {} (expected {})",
tip.x,
100.0 - size_px
);
}
#[test]
fn line_marker_unknown_name_is_silent_no_op() {
let g = horizontal_line_with(|b| {
b.set("end_marker", "no-such-shape");
});
let scene = draw_into(&g);
let fills = scene
.ops
.iter()
.filter(|op| matches!(op, Op::Fill { .. }))
.count();
assert_eq!(fills, 0);
}
#[test]
fn line_marker_fill_chain() {
let blue = Color::new([0.0, 0.0, 1.0, 1.0]);
let green = Color::new([0.0, 1.0, 0.0, 1.0]);
let g = horizontal_line_with(|b| {
b.set("end_marker", "arrow-closed");
});
let scene = draw_into(&g);
let c = scene
.ops
.iter()
.find_map(|op| match op {
Op::Fill {
brush: crate::brush::Brush::Solid(c),
..
} => Some(*c),
_ => None,
})
.unwrap();
assert_eq!(c, red_solid());
let g = horizontal_line_with(|b| {
b.set("end_marker", "arrow-closed").set("fill", blue);
});
let scene = draw_into(&g);
let c = scene
.ops
.iter()
.find_map(|op| match op {
Op::Fill {
brush: crate::brush::Brush::Solid(c),
..
} => Some(*c),
_ => None,
})
.unwrap();
assert_eq!(c, blue);
let g = horizontal_line_with(|b| {
b.set("end_marker", "arrow-closed")
.set("fill", blue)
.set("end_marker_fill", green);
});
let scene = draw_into(&g);
let c = scene
.ops
.iter()
.find_map(|op| match op {
Op::Fill {
brush: crate::brush::Brush::Solid(c),
..
} => Some(*c),
_ => None,
})
.unwrap();
assert_eq!(c, green);
}
#[test]
fn clip_radii_too_large_skip_the_line() {
let mut g = LineGeom::builder()
.set("x", Raw(vec![0.0_f64, 1.0]))
.set("y", Raw(vec![0.5_f64, 0.5]))
.set("stroke", red_solid())
.set("clip_start_radius", 100.0_f64)
.set("clip_end_radius", 100.0_f64)
.build();
g.rebuild_diff_against_previous();
let shapes = registry();
let scales = no_scales();
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);
}
fn mesh_ops(scene: &RecordingScene) -> Vec<&crate::mesh::Mesh> {
scene
.ops
.iter()
.filter_map(|op| match op {
Op::DrawMesh { mesh, .. } => Some(mesh),
_ => None,
})
.collect()
}
fn stroke_count(scene: &RecordingScene) -> usize {
scene
.ops
.iter()
.filter(|op| matches!(op, Op::Stroke { .. }))
.count()
}
#[test]
fn constant_linewidth_stays_on_stroke_path() {
let mut g = LineGeom::builder()
.keys(vec!["A", "A", "A"])
.set("x", Raw(vec![0.0_f64, 0.5, 1.0]))
.set("y", Raw(vec![0.5_f64, 0.5, 0.5]))
.set("stroke", red_solid())
.set("linewidth", 2.0_f64)
.build();
g.rebuild_diff_against_previous();
let shapes = registry();
let scales = no_scales();
let mut scene = RecordingScene::default();
g.draw(
&mut scene,
&ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &scales),
);
assert_eq!(stroke_count(&scene), 1);
assert!(mesh_ops(&scene).is_empty());
}
#[test]
fn varying_stroke_upgrades_to_mesh() {
let red = Color::new([1.0, 0.0, 0.0, 1.0]);
let blue = Color::new([0.0, 0.0, 1.0, 1.0]);
let mut g = LineGeom::builder()
.keys(vec!["A", "A", "A"])
.set("x", Raw(vec![0.0_f64, 0.5, 1.0]))
.set("y", Raw(vec![0.5_f64, 0.5, 0.5]))
.set("stroke", vec![red, blue, red])
.set("linewidth", 2.0_f64)
.build();
g.rebuild_diff_against_previous();
let shapes = registry();
let scales = no_scales();
let mut scene = RecordingScene::default();
g.draw(
&mut scene,
&ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &scales),
);
assert_eq!(stroke_count(&scene), 0);
let meshes = mesh_ops(&scene);
assert_eq!(meshes.len(), 1);
assert!(meshes[0]
.colors
.iter()
.any(|c| (c.components[2] - 1.0).abs() < 1e-3));
}
#[test]
fn dashed_linetype_blocks_upgrade() {
let mut g = LineGeom::builder()
.keys(vec!["A", "A", "A"])
.set("x", Raw(vec![0.0_f64, 0.5, 1.0]))
.set("y", Raw(vec![0.5_f64, 0.5, 0.5]))
.set("stroke", red_solid())
.set("linewidth", vec![2.0_f64, 6.0, 2.0])
.set("linetype", Value::Linetype(linetype::dashed()))
.build();
g.rebuild_diff_against_previous();
let shapes = registry();
let scales = no_scales();
let mut scene = RecordingScene::default();
g.draw(
&mut scene,
&ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &scales),
);
assert_eq!(stroke_count(&scene), 1);
assert!(mesh_ops(&scene).is_empty());
}
#[test]
fn corner_radius_blocks_upgrade() {
let mut g = LineGeom::builder()
.keys(vec!["A", "A", "A"])
.set("x", Raw(vec![0.0_f64, 0.5, 1.0]))
.set("y", Raw(vec![0.5_f64, 0.5, 0.5]))
.set("stroke", red_solid())
.set("linewidth", vec![2.0_f64, 6.0, 2.0])
.set("corner_radius", 2.0_f64)
.build();
g.rebuild_diff_against_previous();
let shapes = registry();
let scales = no_scales();
let mut scene = RecordingScene::default();
g.draw(
&mut scene,
&ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &scales),
);
assert_eq!(stroke_count(&scene), 1);
assert!(mesh_ops(&scene).is_empty());
}
#[test]
fn zero_linewidth_at_first_row_still_renders_ribbon() {
let mut g = LineGeom::builder()
.keys(vec!["A", "A", "A"])
.set("x", Raw(vec![0.0_f64, 0.5, 1.0]))
.set("y", Raw(vec![0.5_f64, 0.5, 0.5]))
.set("stroke", red_solid())
.set("linewidth", vec![0.0_f64, 15.0, 30.0])
.build();
g.rebuild_diff_against_previous();
let shapes = registry();
let scales = no_scales();
let mut scene = RecordingScene::default();
g.draw(
&mut scene,
&ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &scales),
);
assert_eq!(stroke_count(&scene), 0);
let meshes = mesh_ops(&scene);
assert_eq!(meshes.len(), 1);
let ys_at_start: Vec<f64> = meshes[0]
.vertices
.iter()
.filter(|v| v.x < 1.0)
.map(|v| v.y)
.collect();
assert!(
ys_at_start.iter().all(|y| (y - 50.0).abs() < 1e-6),
"expected pinched start shoulders at y = 50, got {ys_at_start:?}"
);
let max_y = meshes[0]
.vertices
.iter()
.map(|v| v.y)
.fold(f64::NEG_INFINITY, f64::max);
assert!(
(max_y - 70.0).abs() < 1e-6,
"expected widest shoulder at y = 70, got {max_y}"
);
}
#[test]
fn all_zero_linewidth_draws_nothing() {
let mut g = LineGeom::builder()
.keys(vec!["A", "A", "A"])
.set("x", Raw(vec![0.0_f64, 0.5, 1.0]))
.set("y", Raw(vec![0.5_f64, 0.5, 0.5]))
.set("stroke", red_solid())
.set("linewidth", vec![0.0_f64, 0.0, 0.0])
.build();
g.rebuild_diff_against_previous();
let shapes = registry();
let scales = no_scales();
let mut scene = RecordingScene::default();
g.draw(
&mut scene,
&ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &scales),
);
assert_eq!(stroke_count(&scene), 0);
assert!(mesh_ops(&scene).is_empty());
}
#[test]
fn negative_linewidth_pinches_instead_of_flipping() {
let mut g = LineGeom::builder()
.keys(vec!["A", "A"])
.set("x", Raw(vec![0.0_f64, 1.0]))
.set("y", Raw(vec![0.5_f64, 0.5]))
.set("stroke", red_solid())
.set("linewidth", vec![-10.0_f64, 30.0])
.build();
g.rebuild_diff_against_previous();
let shapes = registry();
let scales = no_scales();
let mut scene = RecordingScene::default();
g.draw(
&mut scene,
&ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &scales),
);
let meshes = mesh_ops(&scene);
assert_eq!(meshes.len(), 1);
let ys_at_start: Vec<f64> = meshes[0]
.vertices
.iter()
.filter(|v| v.x < 1.0)
.map(|v| v.y)
.collect();
assert!(
ys_at_start.iter().all(|y| (y - 50.0).abs() < 1e-6),
"expected clamped start shoulders at y = 50, got {ys_at_start:?}"
);
}
#[test]
fn ribbon_with_end_clip_produces_clipped_mesh() {
let mut g = LineGeom::builder()
.keys(vec!["A", "A"])
.set("x", Raw(vec![0.0_f64, 1.0]))
.set("y", Raw(vec![0.5_f64, 0.5]))
.set("stroke", red_solid())
.set("linewidth", vec![2.0_f64, 12.0])
.set("clip_end_radius", 18.0_f64)
.build();
g.rebuild_diff_against_previous();
let shapes = registry();
let scales = no_scales();
let mut scene = RecordingScene::default();
g.draw(
&mut scene,
&ctx(Rect::new(0.0, 0.0, 100.0, 100.0), &shapes, &scales),
);
assert_eq!(stroke_count(&scene), 0);
let meshes = mesh_ops(&scene);
assert_eq!(meshes.len(), 1);
let max_x = meshes[0]
.vertices
.iter()
.map(|v| v.x)
.fold(f64::NEG_INFINITY, f64::max);
assert!(
max_x < 100.0,
"expected clipped endpoint < 100 px, got max_x = {max_x}"
);
}
}