use crate::flatten::{flatten_conic_adaptive, flatten_cubic_adaptive, flatten_quad_adaptive};
use crate::{Path, PathBuilder, PathElement};
use skia_rs_core::cast::{ceil_to_i32, scalar_from_i32};
use skia_rs_core::{Point, Scalar};
const STROKE_FLATTEN_TOLERANCE: Scalar = 0.25;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[repr(u8)]
pub enum StrokeCap {
#[default]
Butt = 0,
Round,
Square,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[repr(u8)]
pub enum StrokeJoin {
#[default]
Miter = 0,
Round,
Bevel,
}
#[derive(Debug, Clone)]
pub struct StrokeParams {
pub width: Scalar,
pub cap: StrokeCap,
pub join: StrokeJoin,
pub miter_limit: Scalar,
}
impl Default for StrokeParams {
fn default() -> Self {
Self {
width: 1.0,
cap: StrokeCap::Butt,
join: StrokeJoin::Miter,
miter_limit: 4.0,
}
}
}
impl StrokeParams {
#[must_use]
pub fn new(width: Scalar) -> Self {
Self {
width,
..Default::default()
}
}
#[must_use]
pub const fn with_cap(mut self, cap: StrokeCap) -> Self {
self.cap = cap;
self
}
#[must_use]
pub const fn with_join(mut self, join: StrokeJoin) -> Self {
self.join = join;
self
}
#[must_use]
pub const fn with_miter_limit(mut self, limit: Scalar) -> Self {
self.miter_limit = limit;
self
}
}
pub fn stroke_to_fill(path: &Path, params: &StrokeParams) -> Option<Path> {
if path.is_empty() || params.width <= 0.0 {
return None;
}
let half_width = params.width / 2.0;
let mut builder = PathBuilder::new();
let mut contours: Vec<(Vec<Point>, bool)> = Vec::new();
let mut current_contour: Vec<Point> = Vec::new();
let mut current_closed = false;
for element in path {
match element {
PathElement::Move(p) => {
if !current_contour.is_empty() {
contours.push((std::mem::take(&mut current_contour), current_closed));
}
current_contour.push(p);
current_closed = false;
}
PathElement::Line(p) => {
current_contour.push(p);
}
PathElement::Quad(ctrl, end) => {
if let Some(&start) = current_contour.last() {
flatten_quad_adaptive(
&mut current_contour,
start,
ctrl,
end,
STROKE_FLATTEN_TOLERANCE,
);
}
}
PathElement::Cubic(ctrl1, ctrl2, end) => {
if let Some(&start) = current_contour.last() {
flatten_cubic_adaptive(
&mut current_contour,
start,
ctrl1,
ctrl2,
end,
STROKE_FLATTEN_TOLERANCE,
);
}
}
PathElement::Conic(ctrl, end, weight) => {
if let Some(&start) = current_contour.last() {
flatten_conic_adaptive(
&mut current_contour,
start,
ctrl,
end,
weight,
STROKE_FLATTEN_TOLERANCE,
);
}
}
PathElement::Close => {
current_closed = true;
}
}
}
if !current_contour.is_empty() {
contours.push((current_contour, current_closed));
}
for (contour, is_closed) in &contours {
stroke_contour(&mut builder, contour, *is_closed, half_width, params);
}
Some(builder.build())
}
#[inline]
fn seg_normal(a: Point, b: Point) -> Point {
let dx = b.x - a.x;
let dy = b.y - a.y;
let len = dx.hypot(dy);
if len > 0.0 {
Point::new(-dy / len, dx / len)
} else {
Point::new(0.0, 1.0)
}
}
fn add_join(
left: &mut Vec<Point>,
right: &mut Vec<Point>,
vertex: Point,
n1: Point,
n2: Point,
half_width: Scalar,
params: &StrokeParams,
) {
let avg = Point::new(n1.x + n2.x, n1.y + n2.y);
let avg_len = avg.length();
if avg_len <= 0.001 {
left.push(Point::new(
n1.x.mul_add(half_width, vertex.x),
n1.y.mul_add(half_width, vertex.y),
));
right.push(Point::new(
n1.x.mul_add(-half_width, vertex.x),
n1.y.mul_add(-half_width, vertex.y),
));
return;
}
let scale = half_width / avg_len;
let offset = Point::new(avg.x * scale, avg.y * scale);
match params.join {
StrokeJoin::Miter => {
let miter_len = 1.0 / (avg_len / 2.0);
if miter_len <= params.miter_limit {
left.push(Point::new(
offset.x.mul_add(miter_len, vertex.x),
offset.y.mul_add(miter_len, vertex.y),
));
right.push(Point::new(
offset.x.mul_add(-miter_len, vertex.x),
offset.y.mul_add(-miter_len, vertex.y),
));
} else {
left.push(Point::new(
n1.x.mul_add(half_width, vertex.x),
n1.y.mul_add(half_width, vertex.y),
));
left.push(Point::new(
n2.x.mul_add(half_width, vertex.x),
n2.y.mul_add(half_width, vertex.y),
));
right.push(Point::new(
n1.x.mul_add(-half_width, vertex.x),
n1.y.mul_add(-half_width, vertex.y),
));
right.push(Point::new(
n2.x.mul_add(-half_width, vertex.x),
n2.y.mul_add(-half_width, vertex.y),
));
}
}
StrokeJoin::Bevel => {
left.push(Point::new(
n1.x.mul_add(half_width, vertex.x),
n1.y.mul_add(half_width, vertex.y),
));
left.push(Point::new(
n2.x.mul_add(half_width, vertex.x),
n2.y.mul_add(half_width, vertex.y),
));
right.push(Point::new(
n1.x.mul_add(-half_width, vertex.x),
n1.y.mul_add(-half_width, vertex.y),
));
right.push(Point::new(
n2.x.mul_add(-half_width, vertex.x),
n2.y.mul_add(-half_width, vertex.y),
));
}
StrokeJoin::Round => {
let start_angle = (n1.y * half_width).atan2(n1.x * half_width);
let end_angle = (n2.y * half_width).atan2(n2.x * half_width);
let mut delta = end_angle - start_angle;
if delta > std::f32::consts::PI {
delta -= std::f32::consts::TAU;
} else if delta < -std::f32::consts::PI {
delta += std::f32::consts::TAU;
}
let n_segs = ceil_to_i32(delta.abs() / std::f32::consts::FRAC_PI_4).max(4);
for k in 0..=n_segs {
let t = scalar_from_i32(k) / scalar_from_i32(n_segs);
let a = delta.mul_add(t, start_angle);
left.push(Point::new(
a.cos().mul_add(half_width, vertex.x),
a.sin().mul_add(half_width, vertex.y),
));
right.push(Point::new(
a.cos().mul_add(-half_width, vertex.x),
a.sin().mul_add(-half_width, vertex.y),
));
}
}
}
}
fn emit_ring(builder: &mut PathBuilder, ring: &[Point], reversed: bool) {
if ring.is_empty() {
return;
}
if reversed {
let last = ring.len() - 1;
builder.move_to(ring[last].x, ring[last].y);
for p in ring[..last].iter().rev() {
builder.line_to(p.x, p.y);
}
} else {
builder.move_to(ring[0].x, ring[0].y);
for p in &ring[1..] {
builder.line_to(p.x, p.y);
}
}
builder.close();
}
fn stroke_contour(
builder: &mut PathBuilder,
points: &[Point],
is_closed: bool,
half_width: Scalar,
params: &StrokeParams,
) {
let mut pts: Vec<Point> = Vec::with_capacity(points.len());
for &p in points {
if pts.last().is_none_or(|q: &Point| *q != p) {
pts.push(p);
}
}
if is_closed && pts.len() >= 2 && pts.first() == pts.last() {
pts.pop();
}
if is_closed {
stroke_closed(builder, &pts, half_width, params);
} else {
stroke_open(builder, &pts, half_width, params);
}
}
fn stroke_closed(
builder: &mut PathBuilder,
pts: &[Point],
half_width: Scalar,
params: &StrokeParams,
) {
let n = pts.len();
if n < 2 {
return;
}
let normals: Vec<Point> = (0..n)
.map(|i| seg_normal(pts[i], pts[(i + 1) % n]))
.collect();
let mut left: Vec<Point> = Vec::with_capacity(n);
let mut right: Vec<Point> = Vec::with_capacity(n);
for i in 0..n {
let n1 = normals[(i + n - 1) % n]; let n2 = normals[i]; add_join(&mut left, &mut right, pts[i], n1, n2, half_width, params);
}
emit_ring(builder, &left, false);
emit_ring(builder, &right, true);
}
fn stroke_open(
builder: &mut PathBuilder,
pts: &[Point],
half_width: Scalar,
params: &StrokeParams,
) {
let n = pts.len();
if n < 2 {
if n == 1 {
emit_cap_dot(builder, pts[0], half_width, params.cap);
}
return;
}
let normals: Vec<Point> = (0..n - 1).map(|i| seg_normal(pts[i], pts[i + 1])).collect();
let mut left: Vec<Point> = Vec::with_capacity(n);
let mut right: Vec<Point> = Vec::with_capacity(n);
let first_normal = normals[0];
left.push(Point::new(
first_normal.x.mul_add(half_width, pts[0].x),
first_normal.y.mul_add(half_width, pts[0].y),
));
right.push(Point::new(
first_normal.x.mul_add(-half_width, pts[0].x),
first_normal.y.mul_add(-half_width, pts[0].y),
));
for i in 1..n - 1 {
add_join(
&mut left,
&mut right,
pts[i],
normals[i - 1],
normals[i],
half_width,
params,
);
}
let last_normal = normals[normals.len() - 1];
left.push(Point::new(
last_normal.x.mul_add(half_width, pts[n - 1].x),
last_normal.y.mul_add(half_width, pts[n - 1].y),
));
right.push(Point::new(
last_normal.x.mul_add(-half_width, pts[n - 1].x),
last_normal.y.mul_add(-half_width, pts[n - 1].y),
));
builder.move_to(left[0].x, left[0].y);
add_cap(builder, pts[0], normals[0], half_width, params.cap, true);
for p in &left {
builder.line_to(p.x, p.y);
}
add_cap(
builder,
pts[n - 1],
last_normal,
half_width,
params.cap,
false,
);
for p in right.iter().rev() {
builder.line_to(p.x, p.y);
}
builder.close();
}
fn emit_cap_dot(builder: &mut PathBuilder, center: Point, half_width: Scalar, cap: StrokeCap) {
match cap {
StrokeCap::Butt => {}
StrokeCap::Square => {
builder.move_to(center.x - half_width, center.y - half_width);
builder.line_to(center.x + half_width, center.y - half_width);
builder.line_to(center.x + half_width, center.y + half_width);
builder.line_to(center.x - half_width, center.y + half_width);
builder.close();
}
StrokeCap::Round => {
let steps: i32 = 16;
builder.move_to(center.x + half_width, center.y);
for i in 1..steps {
let a = (scalar_from_i32(i) / scalar_from_i32(steps)) * std::f32::consts::TAU;
builder.line_to(
a.cos().mul_add(half_width, center.x),
a.sin().mul_add(half_width, center.y),
);
}
builder.close();
}
}
}
fn add_cap(
builder: &mut PathBuilder,
center: Point,
normal: Point,
half_width: Scalar,
cap: StrokeCap,
is_start: bool,
) {
match cap {
StrokeCap::Butt => {
}
StrokeCap::Square => {
let dir = if is_start {
Point::new(-normal.y, normal.x)
} else {
Point::new(normal.y, -normal.x)
};
let ext = Point::new(dir.x * half_width, dir.y * half_width);
builder.line_to(
normal.x.mul_add(half_width, center.x) + ext.x,
normal.y.mul_add(half_width, center.y) + ext.y,
);
builder.line_to(
normal.x.mul_add(-half_width, center.x) + ext.x,
normal.y.mul_add(-half_width, center.y) + ext.y,
);
}
StrokeCap::Round => {
let steps: i32 = 8;
let start_angle = if is_start {
normal.y.atan2(normal.x)
} else {
(-normal.y).atan2(-normal.x)
};
for i in 0..=steps {
let t = scalar_from_i32(i) / scalar_from_i32(steps);
let angle = t.mul_add(std::f32::consts::PI, start_angle);
let x = angle.cos().mul_add(half_width, center.x);
let y = angle.sin().mul_add(half_width, center.y);
builder.line_to(x, y);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::FillType;
use skia_rs_core::Rect;
#[test]
fn test_stroked_rect_is_frame_with_empty_middle() {
let mut b = PathBuilder::new();
b.add_rect(&Rect::new(0.0, 0.0, 100.0, 100.0));
let path = b.build();
let params = StrokeParams::new(2.0); let mut stroked = stroke_to_fill(&path, ¶ms).unwrap();
stroked.set_fill_type(FillType::Winding);
assert!(
stroked.contains(Point::new(0.0, 50.0)),
"point on the stroked edge should be filled"
);
assert!(
!stroked.contains(Point::new(50.0, 50.0)),
"the middle of a stroked rect must be empty, not a filled slab"
);
assert!(
!stroked.contains(Point::new(200.0, 200.0)),
"outside the stroke must be empty"
);
}
#[test]
fn test_stroked_closed_triangle_is_frame() {
let mut b = PathBuilder::new();
b.move_to(0.0, 0.0);
b.line_to(100.0, 0.0);
b.line_to(50.0, 100.0);
b.close();
let path = b.build();
let params = StrokeParams::new(4.0);
let mut stroked = stroke_to_fill(&path, ¶ms).unwrap();
stroked.set_fill_type(FillType::Winding);
assert!(
!stroked.contains(Point::new(50.0, 33.0)),
"interior of stroked closed triangle must be empty"
);
}
#[test]
fn test_stroke_to_fill_line() {
let mut builder = PathBuilder::new();
builder.move_to(0.0, 0.0);
builder.line_to(100.0, 0.0);
let path = builder.build();
let params = StrokeParams::new(10.0);
let stroked = stroke_to_fill(&path, ¶ms).unwrap();
assert!(!stroked.is_empty());
}
#[test]
fn test_stroke_to_fill_triangle() {
let mut builder = PathBuilder::new();
builder.move_to(0.0, 0.0);
builder.line_to(100.0, 0.0);
builder.line_to(50.0, 100.0);
builder.close();
let path = builder.build();
let params = StrokeParams::new(5.0).with_join(StrokeJoin::Round);
let stroked = stroke_to_fill(&path, ¶ms).unwrap();
assert!(!stroked.is_empty());
}
#[test]
fn test_stroke_to_fill_multi_contour_mixed_closed_open() {
let mut builder = PathBuilder::new();
builder.move_to(0.0, 0.0);
builder.line_to(10.0, 0.0);
builder.line_to(5.0, 10.0);
builder.close();
builder.move_to(20.0, 0.0);
builder.line_to(30.0, 0.0);
let path = builder.build();
let params = StrokeParams::new(2.0);
let result = stroke_to_fill(&path, ¶ms);
assert!(
result.is_some(),
"stroke_to_fill should succeed for valid input"
);
let stroked = result.unwrap();
assert!(
stroked.iter().count() > 0,
"stroked path should not be empty"
);
}
#[test]
fn test_stroke_params() {
let params = StrokeParams::new(2.0)
.with_cap(StrokeCap::Round)
.with_join(StrokeJoin::Bevel)
.with_miter_limit(10.0);
#[allow(
clippy::float_cmp,
reason = "exact test assertion, values round-trip literals"
)]
{
assert_eq!(params.width, 2.0);
}
assert_eq!(params.cap, StrokeCap::Round);
assert_eq!(params.join, StrokeJoin::Bevel);
#[allow(
clippy::float_cmp,
reason = "exact test assertion, values round-trip literals"
)]
{
assert_eq!(params.miter_limit, 10.0);
}
}
#[test]
fn test_stroke_to_fill_round_join_generates_arc() {
let mut builder = PathBuilder::new();
builder.move_to(0.0, 0.0);
builder.line_to(50.0, 0.0);
builder.line_to(50.0, 50.0);
let path = builder.build();
let params = StrokeParams::new(10.0).with_join(StrokeJoin::Round);
let result = stroke_to_fill(&path, ¶ms);
assert!(result.is_some());
let stroked = result.unwrap();
let count = stroked.iter().count();
assert!(
count > 10,
"Round join should generate arc segments, got {count} verbs"
);
}
}