899 lines
29 KiB
Rust

use crate::math::is_close_to;
use crate::shapes::fills::{Fill, SolidColor};
use skia_safe::{self as skia, Rect};
use super::Corners;
use super::StrokeLineCap;
use super::StrokeLineJoin;
use super::SvgAttrs;
/// Soft floor in device pixels for dropping dash/dotted PathEffects when the
/// pattern period is effectively invisible.
pub const STROKE_MIN_DEVICE_PX: f32 = 0.75;
/// When Inner/Outer doubled-width footprint is below this (device px), paint
/// as Center to avoid save_layer / Clear paths.
pub const STROKE_INNER_OUTER_SIMPLIFY_DEVICE_PX: f32 = 2.0;
#[derive(Debug, Clone, PartialEq, Copy)]
pub enum StrokeStyle {
Solid,
Dotted,
Dashed,
Mixed,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum StrokeCap {
LineArrow,
TriangleArrow,
SquareMarker,
CircleMarker,
DiamondMarker,
Round,
Square,
}
/// A rect side, in the order `Stroke::widths` stores them. Side `n` runs
/// clockwise from corner `n` in `Corners` order, sharing its index.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Side {
Top,
Right,
Bottom,
Left,
}
impl Side {
pub const ALL: [Side; 4] = [Side::Top, Side::Right, Side::Bottom, Side::Left];
pub fn index(self) -> usize {
self as usize
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum StrokeKind {
Inner,
Outer,
Center,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Stroke {
pub fill: Fill,
pub width: f32,
pub style: StrokeStyle,
pub cap_end: Option<StrokeCap>,
pub cap_start: Option<StrokeCap>,
pub kind: StrokeKind,
// Dash and gap overrides for the `Dashed` style. `None` falls back to the
// default `width + 10` pattern to keep existing designs visually identical.
pub dash: Option<f32>,
pub gap: Option<f32>,
// Per-side widths in `Side` order. `None` means the uniform `width` applies
// to all sides.
pub widths: Option<[f32; 4]>,
}
impl Stroke {
// Strokes for open shapes should be rendered as if they were centered.
pub fn render_kind(&self, is_open: bool) -> StrokeKind {
if is_open {
StrokeKind::Center
} else {
self.kind
}
}
/// Widens the band by `spread` on each side. Inner and Outer bands only
/// grow away from the path; the `2·spread` center stroke added by
/// `Shape::apply_shadow_spread` covers the other side.
pub fn grow_by_spread(&mut self, spread: f32, is_open: bool) {
let growth = match self.render_kind(is_open) {
StrokeKind::Center => 2.0 * spread,
StrokeKind::Inner | StrokeKind::Outer => spread,
};
self.width += growth;
if let Some(widths) = self.widths.as_mut() {
widths.iter_mut().for_each(|w| *w += growth);
}
}
/// The widest side of the stroke: the uniform `width` unless per-side
/// widths are set, in which case the maximum of the four sides.
pub fn max_width(&self) -> f32 {
match self.widths {
Some(widths) => widths.into_iter().reduce(f32::max).unwrap_or(self.width),
None => self.width,
}
}
/// Inner/Outer use a doubled-width Center stroke plus clip/clear. When that
/// footprint is thin on screen, fall back to a plain Center stroke.
#[inline]
pub fn simplified_kind_at_scale(&self, is_open: bool, scale: f32) -> StrokeKind {
let kind = self.render_kind(is_open);
match kind {
StrokeKind::Inner | StrokeKind::Outer
if 2.0 * self.max_width() * scale < STROKE_INNER_OUTER_SIMPLIFY_DEVICE_PX =>
{
StrokeKind::Center
}
other => other,
}
}
/// Drop dash/dotted PathEffects when the pattern period is subpixel.
#[inline]
pub fn style_at_scale(&self, scale: f32) -> StrokeStyle {
if self.style == StrokeStyle::Solid {
return StrokeStyle::Solid;
}
let period = match self.style {
StrokeStyle::Dotted => self.width + 5.0,
StrokeStyle::Dashed => {
let dash = self.dash.unwrap_or(self.width + 10.);
let gap = self.gap.unwrap_or(self.width + 10.);
dash.min(gap)
}
StrokeStyle::Mixed => self.width + 1.0,
StrokeStyle::Solid => return StrokeStyle::Solid,
};
if period * scale < STROKE_MIN_DEVICE_PX {
StrokeStyle::Solid
} else {
self.style
}
}
/// Path/Bool overview LOD: simplify Inner/Outer and dash/dotted at low
/// scale. Never skips painting; stroke-only icons would otherwise go blank.
pub fn path_lod_at_scale(&self, is_open: bool, scale: f32) -> Stroke {
let kind = self.simplified_kind_at_scale(is_open, scale);
let style = self.style_at_scale(scale);
let kind_unchanged = kind == self.render_kind(is_open);
let style_unchanged = style == self.style;
if kind_unchanged && style_unchanged {
return self.clone();
}
let mut stroke = self.clone();
if !is_open {
stroke.kind = kind;
}
stroke.style = style;
stroke
}
/// Per-side widths [top, right, bottom, left] when they actually differ.
/// Returns `None` when unset or when all sides are equal, so the uniform
/// render path (which supports dashed/dotted styles) keeps handling that
/// case.
pub fn per_side_widths(&self) -> Option<[f32; 4]> {
let widths = self.widths?;
let [top, right, bottom, left] = widths;
if top == right && right == bottom && bottom == left {
None
} else {
Some(widths)
}
}
/// The widest each side reaches across a shape's per-side strokes, which is
/// the border box they miter against. `None` when at most one stroke needs it.
pub fn per_side_profile<'a>(strokes: impl Iterator<Item = &'a Stroke>) -> Option<[f32; 4]> {
let mut profile = [0.0f32; 4];
let mut per_side_strokes = 0;
for stroke in strokes {
let Some(widths) = stroke.per_side_widths() else {
continue;
};
per_side_strokes += 1;
for (side, width) in profile.iter_mut().zip(widths) {
*side = side.max(width);
}
}
(per_side_strokes > 1).then_some(profile)
}
pub fn bounds_width(&self, is_open: bool) -> f32 {
match self.render_kind(is_open) {
StrokeKind::Inner => 0.,
StrokeKind::Center => self.max_width() / 2.,
StrokeKind::Outer => self.max_width(),
}
}
pub fn max_bounds_width<'a>(strokes: impl Iterator<Item = &'a Stroke>, is_open: bool) -> f32 {
strokes
.map(|stroke| stroke.bounds_width(is_open))
.fold(0.0, f32::max)
}
pub fn new_center_stroke(
width: f32,
style: StrokeStyle,
cap_start: Option<StrokeCap>,
cap_end: Option<StrokeCap>,
dash: Option<f32>,
gap: Option<f32>,
) -> Self {
Stroke {
fill: Fill::Solid(SolidColor(skia::Color::TRANSPARENT)),
width,
style,
cap_end,
cap_start,
kind: StrokeKind::Center,
dash,
gap,
widths: None,
}
}
pub fn new_inner_stroke(
width: f32,
style: StrokeStyle,
cap_start: Option<StrokeCap>,
cap_end: Option<StrokeCap>,
dash: Option<f32>,
gap: Option<f32>,
) -> Self {
Stroke {
fill: Fill::Solid(SolidColor(skia::Color::TRANSPARENT)),
width,
style,
cap_end,
cap_start,
kind: StrokeKind::Inner,
dash,
gap,
widths: None,
}
}
pub fn new_outer_stroke(
width: f32,
style: StrokeStyle,
cap_start: Option<StrokeCap>,
cap_end: Option<StrokeCap>,
dash: Option<f32>,
gap: Option<f32>,
) -> Self {
Stroke {
fill: Fill::Solid(SolidColor(skia::Color::TRANSPARENT)),
width,
style,
cap_end,
cap_start,
kind: StrokeKind::Outer,
dash,
gap,
widths: None,
}
}
pub fn scale_content(&mut self, value: f32) {
self.width *= value;
if let Some(widths) = &mut self.widths {
for width in widths.iter_mut() {
*width *= value;
}
}
if let Some(dash) = self.dash {
self.dash = Some(dash * value);
}
if let Some(gap) = self.gap {
self.gap = Some(gap * value);
}
}
/// Returns the clip operation for dotted inner/outer strokes.
/// Returns `None` when no clipping is needed (center or non-dotted).
pub fn clip_op(&self) -> Option<skia::ClipOp> {
if self.style != StrokeStyle::Dotted || self.kind == StrokeKind::Center {
return None;
}
match self.kind {
StrokeKind::Inner => Some(skia::ClipOp::Intersect),
StrokeKind::Outer => Some(skia::ClipOp::Difference),
StrokeKind::Center => None,
}
}
pub fn delta(&self) -> f32 {
match self.kind {
StrokeKind::Inner => 0.,
StrokeKind::Center => self.width,
StrokeKind::Outer => self.width * 2.,
}
}
pub fn outer_rect(&self, rect: &Rect) -> Rect {
match (self.kind, self.style) {
(StrokeKind::Inner, StrokeStyle::Dotted) | (StrokeKind::Outer, StrokeStyle::Dotted) => {
// Boundary so circles center on it and semicircles match after clipping
*rect
}
_ => match self.kind {
StrokeKind::Inner => Rect::from_xywh(
rect.left + (self.width / 2.),
rect.top + (self.width / 2.),
rect.width() - self.width,
rect.height() - self.width,
),
StrokeKind::Center => {
Rect::from_xywh(rect.left, rect.top, rect.width(), rect.height())
}
StrokeKind::Outer => Rect::from_xywh(
rect.left - (self.width / 2.),
rect.top - (self.width / 2.),
rect.width() + self.width,
rect.height() + self.width,
),
},
}
}
pub fn aligned_rect(&self, rect: &Rect, scale: f32) -> Rect {
let stroke_rect = self.outer_rect(rect);
if self.kind != StrokeKind::Center {
return stroke_rect;
}
align_rect_to_half_pixel(&stroke_rect, self.width, scale)
}
pub fn outer_corners(&self, corners: &Corners) -> Corners {
if matches!(self.style, StrokeStyle::Dotted | StrokeStyle::Dashed) {
// Path at boundary so no corner offset
return *corners;
}
let offset = match self.kind {
StrokeKind::Center => 0.0,
StrokeKind::Inner => -self.width / 2.0,
StrokeKind::Outer => self.width / 2.0,
};
let mut outer = *corners;
for corner in outer.iter_mut() {
if corner.x > 0.0 {
corner.offset((offset, offset))
}
}
outer
}
pub fn to_paint(
&self,
rect: &Rect,
svg_attrs: Option<&SvgAttrs>,
antialias: bool,
) -> skia::Paint {
let mut paint = self.fill.to_paint(rect, antialias);
paint.set_style(skia::PaintStyle::Stroke);
let width = match self.kind {
StrokeKind::Inner => self.width,
StrokeKind::Center => self.width,
StrokeKind::Outer => self.width,
};
paint.set_stroke_width(width);
paint.set_anti_alias(antialias);
if let Some(svg_attrs) = svg_attrs {
match svg_attrs.stroke_linecap {
StrokeLineCap::Round => {
paint.set_stroke_cap(skia::paint::Cap::Round);
}
StrokeLineCap::Square => {
paint.set_stroke_cap(skia::paint::Cap::Square);
}
StrokeLineCap::Butt => {} // Skia default
}
match svg_attrs.stroke_linejoin {
StrokeLineJoin::Round => {
paint.set_stroke_join(skia::paint::Join::Round);
}
StrokeLineJoin::Bevel => {
paint.set_stroke_join(skia::paint::Join::Bevel);
}
StrokeLineJoin::Miter => {} // Skia default
}
}
if self.style != StrokeStyle::Solid {
paint.set_path_effect(self.path_effect());
}
paint
}
/// On/off run lengths of this stroke's dash pattern; `None` for solid and
/// dotted. The defaults are width-derived, so each side gets its own.
pub fn dash_pattern(&self) -> Option<Vec<f32>> {
match self.style {
StrokeStyle::Dashed => {
let dash = self.dash.unwrap_or(self.width + 10.);
let gap = self.gap.unwrap_or(self.width + 10.);
Some(vec![dash, gap])
}
StrokeStyle::Mixed => Some(vec![
self.width + 5.,
self.width + 5.,
self.width + 1.,
self.width + 5.,
]),
_ => None,
}
}
/// Circle radius and centre-to-centre spacing of a dotted stroke; `None`
/// for every other style.
pub fn dot_pattern(&self) -> Option<(f32, f32)> {
if self.style != StrokeStyle::Dotted {
return None;
}
let radius = match self.kind {
StrokeKind::Inner | StrokeKind::Outer => self.width,
StrokeKind::Center => self.width / 2.0,
};
Some((radius, self.width + 5.0))
}
/// The dash/dot `PathEffect` for this stroke's style.
pub fn path_effect(&self) -> Option<skia::PathEffect> {
match self.style {
StrokeStyle::Solid => None,
StrokeStyle::Dotted => {
let (radius, advance) = self.dot_pattern()?;
Self::dot_effect(radius, advance)
}
_ => skia::PathEffect::dash(&self.dash_pattern()?, 0.),
}
}
/// The dash/dot `PathEffect` stretched so a run of `length` begins and ends
/// on a dash, as a browser fits a border. Runs too short come out solid.
pub fn path_effect_fitted(&self, length: f32) -> Option<skia::PathEffect> {
if length <= 0.0 {
return self.path_effect();
}
match self.style {
StrokeStyle::Solid => None,
StrokeStyle::Dotted => {
let (radius, _) = self.dot_pattern()?;
Self::dot_effect(radius, self.fitted_dot_advance(length)?)
}
_ => skia::PathEffect::dash(&self.fitted_dash_pattern(length)?, 0.),
}
}
/// The dash pattern scaled so `length` holds a whole number of dashes and
/// both ends land on one, instead of being cut wherever the run stops.
pub fn fitted_dash_pattern(&self, length: f32) -> Option<Vec<f32>> {
let pattern = self.dash_pattern()?;
let period: f32 = pattern.iter().sum();
let first = pattern[0];
if length <= 0.0 || period <= 0.0 || first <= 0.0 {
return Some(pattern);
}
// Whole periods that fit before the closing dash.
let repeats = ((length - first) / period).round().max(0.0);
let scale = length / (repeats * period + first);
Some(pattern.iter().map(|run| run * scale).collect())
}
/// Dot spacing scaled so a dot lands on both ends of a `length` run.
pub fn fitted_dot_advance(&self, length: f32) -> Option<f32> {
let (_, advance) = self.dot_pattern()?;
if length <= 0.0 || advance <= 0.0 {
return Some(advance);
}
Some(length / (length / advance).round().max(1.0))
}
fn dot_effect(radius: f32, advance: f32) -> Option<skia::PathEffect> {
let circle_path = {
let mut pb = skia::PathBuilder::new();
pb.add_circle((0.0, 0.0), radius, None);
pb.detach()
};
skia::PathEffect::path_1d(
&circle_path,
advance,
0.0,
skia::path_1d_path_effect::Style::Translate,
)
}
/// The same stroke narrowed to a single side's width, so that side's
/// geometry and width-derived dash pattern come out of the shared style.
pub fn with_width(&self, width: f32) -> Stroke {
Stroke {
width,
widths: None,
..self.clone()
}
}
pub fn to_stroked_paint(
&self,
is_open: bool,
rect: &Rect,
svg_attrs: Option<&SvgAttrs>,
antialias: bool,
) -> skia::Paint {
let mut paint = self.to_paint(rect, svg_attrs, antialias);
match self.render_kind(is_open) {
StrokeKind::Inner => {
paint.set_stroke_width(2. * paint.stroke_width());
}
StrokeKind::Center => {}
StrokeKind::Outer => {
paint.set_stroke_width(2. * paint.stroke_width());
}
}
if let Some(cap) = self.to_skia_linecap() {
paint.set_stroke_cap(cap);
}
paint
}
// Render text paths (unused)
#[allow(dead_code)]
pub fn to_text_stroked_paint(
&self,
is_open: bool,
rect: &Rect,
svg_attrs: Option<&SvgAttrs>,
antialias: bool,
) -> skia::Paint {
let mut paint = self.to_paint(rect, svg_attrs, antialias);
match self.render_kind(is_open) {
StrokeKind::Inner => {
paint.set_stroke_width(2. * paint.stroke_width());
}
StrokeKind::Center => {}
StrokeKind::Outer => {
paint.set_stroke_width(2. * paint.stroke_width());
}
}
paint
}
pub fn is_transparent(&self) -> bool {
match &self.fill {
Fill::Solid(SolidColor(color)) => color.a() == 0,
_ => false,
}
}
pub fn cap_bounds_margin(&self) -> f32 {
cap_margin_for_cap(self.cap_start, self.width)
.max(cap_margin_for_cap(self.cap_end, self.width))
}
/// Returns a Skia `PaintCap` to apply natively on the stroke paint when
/// both ends share the same simple line cap (`Round/Round` or
/// `Square/Square`). Skia only emits cap geometry at sub-path endpoints,
/// so this is a no-op on closed paths and avoids the extra fill draw the
/// manual caps would otherwise require on open paths.
pub fn to_skia_linecap(&self) -> Option<skia::paint::Cap> {
if self.style != StrokeStyle::Solid {
return None;
}
match (self.cap_start, self.cap_end) {
(Some(StrokeCap::Round), Some(StrokeCap::Round)) => Some(skia::paint::Cap::Round),
(Some(StrokeCap::Square), Some(StrokeCap::Square)) => Some(skia::paint::Cap::Square),
_ => None,
}
}
}
fn align_rect_to_half_pixel(rect: &Rect, stroke_width: f32, scale: f32) -> Rect {
if scale <= 0.0 {
return *rect;
}
let stroke_pixels = stroke_width * scale;
let stroke_pixels_rounded = stroke_pixels.round();
if !is_close_to(stroke_pixels, stroke_pixels_rounded) {
return *rect;
}
if (stroke_pixels_rounded as i32) % 2 == 0 {
return *rect;
}
let left_px = rect.left * scale;
let top_px = rect.top * scale;
let target_frac = 0.5;
let dx_px = target_frac - (left_px - left_px.floor());
let dy_px = target_frac - (top_px - top_px.floor());
if is_close_to(dx_px, 0.0) && is_close_to(dy_px, 0.0) {
return *rect;
}
Rect::from_xywh(
rect.left + (dx_px / scale),
rect.top + (dy_px / scale),
rect.width(),
rect.height(),
)
}
fn cap_margin_for_cap(cap: Option<StrokeCap>, width: f32) -> f32 {
match cap {
Some(StrokeCap::LineArrow)
| Some(StrokeCap::TriangleArrow)
| Some(StrokeCap::SquareMarker)
| Some(StrokeCap::DiamondMarker) => width * 4.0,
Some(StrokeCap::CircleMarker) => width * 2.0,
Some(StrokeCap::Square) => width,
Some(StrokeCap::Round) => width * 0.5,
_ => 0.0,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn stroke_with_widths(widths: Option<[f32; 4]>) -> Stroke {
let mut stroke = Stroke::new_inner_stroke(2.0, StrokeStyle::Solid, None, None, None, None);
stroke.widths = widths;
stroke
}
#[test]
fn grow_by_spread_moves_both_band_edges_by_the_spread() {
let mut center = Stroke::new_center_stroke(4.0, StrokeStyle::Solid, None, None, None, None);
center.grow_by_spread(3.0, false);
assert_eq!(center.width, 10.0);
let mut inner = stroke_with_widths(Some([2.0, 0.0, 2.0, 0.0]));
inner.grow_by_spread(3.0, false);
assert_eq!(inner.width, 5.0);
assert_eq!(inner.widths, Some([5.0, 3.0, 5.0, 3.0]));
// Open paths render every stroke centered.
let mut open_inner = stroke_with_widths(None);
open_inner.grow_by_spread(3.0, true);
assert_eq!(open_inner.width, 8.0);
}
#[test]
fn per_side_profile_needs_two_strokes_to_miter_against() {
let one = [stroke_with_widths(Some([10.0, 0.0, 0.0, 0.0]))];
assert_eq!(Stroke::per_side_profile(one.iter()), None);
// A uniform stroke has no side of its own, so it never contributes.
let uniform = [
stroke_with_widths(Some([10.0, 0.0, 0.0, 0.0])),
stroke_with_widths(Some([4.0, 4.0, 4.0, 4.0])),
stroke_with_widths(None),
];
assert_eq!(Stroke::per_side_profile(uniform.iter()), None);
}
#[test]
fn per_side_profile_takes_the_widest_side_of_each() {
let strokes = [
stroke_with_widths(Some([10.0, 0.0, 0.0, 0.0])),
stroke_with_widths(Some([0.0, 20.0, 0.0, 0.0])),
stroke_with_widths(Some([0.0, 0.0, 15.0, 5.0])),
stroke_with_widths(Some([6.0, 0.0, 0.0, 0.0])),
];
assert_eq!(
Stroke::per_side_profile(strokes.iter()),
Some([10.0, 20.0, 15.0, 5.0])
);
}
#[test]
fn max_width_falls_back_to_uniform_width() {
let stroke = stroke_with_widths(None);
assert_eq!(stroke.max_width(), 2.0);
}
#[test]
fn max_width_uses_widest_side() {
let stroke = stroke_with_widths(Some([1.0, 8.0, 3.0, 0.0]));
assert_eq!(stroke.max_width(), 8.0);
}
#[test]
fn per_side_widths_none_when_all_sides_equal() {
let stroke = stroke_with_widths(Some([4.0, 4.0, 4.0, 4.0]));
assert_eq!(stroke.per_side_widths(), None);
}
#[test]
fn per_side_widths_returns_differing_sides() {
let stroke = stroke_with_widths(Some([1.0, 2.0, 3.0, 4.0]));
assert_eq!(stroke.per_side_widths(), Some([1.0, 2.0, 3.0, 4.0]));
}
#[test]
fn scale_content_scales_per_side_widths() {
let mut stroke = stroke_with_widths(Some([1.0, 2.0, 3.0, 4.0]));
stroke.scale_content(2.0);
assert_eq!(stroke.widths, Some([2.0, 4.0, 6.0, 8.0]));
assert_eq!(stroke.width, 4.0);
}
#[test]
fn with_width_keeps_style_and_drops_per_side_widths() {
let mut stroke = stroke_with_widths(Some([1.0, 2.0, 3.0, 4.0]));
stroke.style = StrokeStyle::Dashed;
stroke.dash = Some(7.0);
let side = stroke.with_width(3.0);
assert_eq!(side.width, 3.0);
assert_eq!(side.widths, None);
assert_eq!(side.style, StrokeStyle::Dashed);
assert_eq!(side.dash, Some(7.0));
assert_eq!(side.kind, stroke.kind);
}
#[test]
fn dash_pattern_follows_side_width() {
let mut stroke = solid_center(4.0);
assert_eq!(stroke.dash_pattern(), None);
stroke.style = StrokeStyle::Dashed;
// Defaults are width-relative, so each side gets its own pattern.
assert_eq!(stroke.dash_pattern(), Some(vec![14.0, 14.0]));
assert_eq!(
stroke.with_width(10.0).dash_pattern(),
Some(vec![20.0, 20.0])
);
// An explicit dash/gap is shared by every side.
stroke.dash = Some(6.0);
stroke.gap = Some(2.0);
assert_eq!(stroke.dash_pattern(), Some(vec![6.0, 2.0]));
assert_eq!(stroke.with_width(10.0).dash_pattern(), Some(vec![6.0, 2.0]));
stroke.style = StrokeStyle::Mixed;
assert_eq!(stroke.dash_pattern(), Some(vec![9.0, 9.0, 5.0, 9.0]));
}
#[test]
fn fitted_dash_pattern_ends_on_a_dash() {
let mut stroke = solid_center(10.0);
stroke.style = StrokeStyle::Dashed;
stroke.dash = Some(30.0);
stroke.gap = Some(30.0);
// 235 holds 4 dashes and 3 gaps once stretched; both ends are a dash.
let fitted = stroke.fitted_dash_pattern(235.0).unwrap();
assert!((4.0 * fitted[0] + 3.0 * fitted[1] - 235.0).abs() < 1e-3);
// 175 only fits 3, so the sides stretch by different amounts.
let fitted = stroke.fitted_dash_pattern(175.0).unwrap();
assert!((3.0 * fitted[0] + 2.0 * fitted[1] - 175.0).abs() < 1e-3);
// A side too short for a whole period becomes one dash, as in CSS.
let fitted = stroke.fitted_dash_pattern(40.0).unwrap();
assert!((fitted[0] - 40.0).abs() < 1e-3);
// Mixed keeps its four-run shape and still closes on its first run.
stroke.style = StrokeStyle::Mixed;
stroke.dash = None;
stroke.gap = None;
let fitted = stroke.fitted_dash_pattern(200.0).unwrap();
let period: f32 = fitted.iter().sum();
let repeats = ((200.0 - fitted[0]) / period).round();
assert!((repeats * period + fitted[0] - 200.0).abs() < 1e-3);
}
#[test]
fn fitted_dot_advance_lands_on_both_ends() {
let mut stroke = solid_center(8.0);
stroke.style = StrokeStyle::Dotted;
// Nominal advance is 13; 200 / 13 rounds to 15 steps of 13.33.
let advance = stroke.fitted_dot_advance(200.0).unwrap();
assert!((200.0 / advance - 15.0).abs() < 1e-3);
// Shorter than one advance still yields a single step, never zero.
assert!(stroke.fitted_dot_advance(4.0).unwrap() > 0.0);
}
#[test]
fn dot_pattern_halves_the_radius_only_when_centered() {
let mut stroke = solid_center(8.0);
assert_eq!(stroke.dot_pattern(), None);
stroke.style = StrokeStyle::Dotted;
assert_eq!(stroke.dot_pattern(), Some((4.0, 13.0)));
stroke.kind = StrokeKind::Inner;
assert_eq!(stroke.dot_pattern(), Some((8.0, 13.0)));
assert_eq!(stroke.with_width(2.0).dot_pattern(), Some((2.0, 7.0)));
}
#[test]
fn path_effect_is_none_only_for_solid() {
let mut stroke = solid_center(4.0);
assert!(stroke.path_effect().is_none());
for style in [StrokeStyle::Dashed, StrokeStyle::Dotted, StrokeStyle::Mixed] {
stroke.style = style;
assert!(stroke.path_effect().is_some(), "{style:?} needs an effect");
}
}
fn solid_center(width: f32) -> Stroke {
Stroke::new_center_stroke(width, StrokeStyle::Solid, None, None, None, None)
}
#[test]
fn inner_outer_simplify_to_center_when_thin() {
let inner = Stroke::new_inner_stroke(8.0, StrokeStyle::Solid, None, None, None, None);
// 2 * 8 * 0.1 = 1.6 < 2.0, simplify to Center
assert_eq!(
inner.simplified_kind_at_scale(false, 0.1),
StrokeKind::Center
);
// 2 * 8 * 0.2 = 3.2 >= 2.0, keep Inner
assert_eq!(
inner.simplified_kind_at_scale(false, 0.2),
StrokeKind::Inner
);
}
#[test]
fn dash_becomes_solid_when_period_subpixel() {
let dashed =
Stroke::new_center_stroke(2.0, StrokeStyle::Dashed, None, None, Some(20.0), Some(20.0));
// period 20 * 0.03 = 0.6 < 0.75, solid
assert_eq!(dashed.style_at_scale(0.03), StrokeStyle::Solid);
// period 20 * 0.05 = 1.0 >= 0.75, keep dashed
assert_eq!(dashed.style_at_scale(0.05), StrokeStyle::Dashed);
}
#[test]
fn native_linecap_is_used_for_solid_strokes() {
let solid = Stroke::new_center_stroke(
4.0,
StrokeStyle::Solid,
Some(StrokeCap::Round),
Some(StrokeCap::Round),
None,
None,
);
assert_eq!(solid.to_skia_linecap(), Some(skia::paint::Cap::Round));
}
#[test]
fn native_linecap_is_skipped_for_dashed_strokes() {
let dashed = Stroke::new_center_stroke(
4.0,
StrokeStyle::Dashed,
Some(StrokeCap::Round),
Some(StrokeCap::Round),
None,
None,
);
assert_eq!(dashed.to_skia_linecap(), None);
}
#[test]
fn path_lod_never_drops_thin_stroke() {
// Hairline strokes must still paint (stroke-only icons).
let thin = solid_center(1.0).path_lod_at_scale(false, 0.5);
assert_eq!(thin.width, 1.0);
assert_eq!(thin.kind, StrokeKind::Center);
}
#[test]
fn path_lod_simplifies_inner_at_overview() {
let inner = Stroke::new_inner_stroke(8.0, StrokeStyle::Solid, None, None, None, None);
let lod = inner.path_lod_at_scale(false, 0.1);
assert_eq!(lod.kind, StrokeKind::Center);
}
}