use crate::framebuffer::Framebuffer;
use crate::scanline::{FillRule, Rasterizer};
use oxiui_core::Color;
pub type Point = (f32, f32);
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Join {
Miter,
Bevel,
Round,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Cap {
Butt,
Round,
Square,
}
#[derive(Clone, Debug)]
pub struct StrokeStyle {
pub width: f32,
pub join: Join,
pub cap: Cap,
pub miter_limit: f32,
}
impl Default for StrokeStyle {
fn default() -> Self {
Self {
width: 1.0,
join: Join::Miter,
cap: Cap::Butt,
miter_limit: 4.0,
}
}
}
#[derive(Clone, Debug)]
enum PathCmd {
MoveTo(Point),
LineTo(Point),
QuadTo(Point, Point), CubicTo(Point, Point, Point), Close,
}
#[derive(Clone, Debug, Default)]
pub struct Path {
cmds: Vec<PathCmd>,
fill_rule: FillRule,
}
impl Path {
pub fn new() -> Self {
Self {
cmds: Vec::new(),
fill_rule: FillRule::default(),
}
}
pub fn with_fill_rule(mut self, rule: FillRule) -> Self {
self.fill_rule = rule;
self
}
pub fn move_to(&mut self, p: Point) -> &mut Self {
self.cmds.push(PathCmd::MoveTo(p));
self
}
pub fn line_to(&mut self, p: Point) -> &mut Self {
self.cmds.push(PathCmd::LineTo(p));
self
}
pub fn quad_to(&mut self, ctrl: Point, end: Point) -> &mut Self {
self.cmds.push(PathCmd::QuadTo(ctrl, end));
self
}
pub fn cubic_to(&mut self, c1: Point, c2: Point, end: Point) -> &mut Self {
self.cmds.push(PathCmd::CubicTo(c1, c2, end));
self
}
pub fn close(&mut self) -> &mut Self {
self.cmds.push(PathCmd::Close);
self
}
pub fn flatten(&self, tolerance: f32) -> Vec<Vec<Point>> {
let tol = tolerance.max(0.01);
let mut contours: Vec<Vec<Point>> = Vec::new();
let mut current: Vec<Point> = Vec::new();
let mut start: Option<Point> = None;
let mut cursor: Point = (0.0, 0.0);
for cmd in &self.cmds {
match *cmd {
PathCmd::MoveTo(p) => {
if !current.is_empty() {
contours.push(core::mem::take(&mut current));
}
start = Some(p);
cursor = p;
current.push(p);
}
PathCmd::LineTo(p) => {
current.push(p);
cursor = p;
}
PathCmd::QuadTo(ctrl, end) => {
flatten_quad(cursor, ctrl, end, tol, &mut current);
cursor = end;
}
PathCmd::CubicTo(c1, c2, end) => {
flatten_cubic(cursor, c1, c2, end, tol, &mut current);
cursor = end;
}
PathCmd::Close => {
if let Some(s) = start {
if let Some(&last) = current.last() {
if dist2(last, s) > f32::EPSILON {
current.push(s);
}
}
}
if !current.is_empty() {
contours.push(core::mem::take(&mut current));
}
if let Some(s) = start {
cursor = s;
}
}
}
}
if !current.is_empty() {
contours.push(current);
}
contours
}
pub fn fill(&self, fb: &mut Framebuffer, color: Color) {
let tolerance = 0.25_f32;
let contours = self.flatten(tolerance);
let mut ras = Rasterizer::new();
for contour in contours {
if contour.len() >= 3 {
ras.fill_polygon(fb, &contour, color, self.fill_rule, true);
}
}
}
pub fn stroke(&self, fb: &mut Framebuffer, style: &StrokeStyle, color: Color) {
let half = (style.width * 0.5).max(0.5);
let tolerance = 0.25_f32;
let contours = self.flatten(tolerance);
let mut ras = Rasterizer::new();
for contour in &contours {
if contour.len() < 2 {
continue;
}
stroke_contour_with_ras(fb, contour, half, style, color, &mut ras);
}
}
pub fn fill_clipped(&self, fb: &mut Framebuffer, color: Color, clip: crate::clip::ClipRect) {
self.fill_clipped_aa(fb, color, clip, true);
}
pub fn stroke_clipped(
&self,
fb: &mut Framebuffer,
style: &StrokeStyle,
color: Color,
clip: crate::clip::ClipRect,
) {
self.stroke_clipped_aa(fb, style, color, clip, true);
}
pub fn fill_clipped_aa(
&self,
fb: &mut Framebuffer,
color: Color,
clip: crate::clip::ClipRect,
aa: bool,
) {
let tolerance = 0.25_f32;
let contours = self.flatten(tolerance);
let mut ras = Rasterizer::new();
for contour in contours {
if contour.len() >= 3 {
ras.fill_polygon_clipped(fb, &contour, color, self.fill_rule, aa, clip);
}
}
}
pub fn stroke_clipped_aa(
&self,
fb: &mut Framebuffer,
style: &StrokeStyle,
color: Color,
clip: crate::clip::ClipRect,
aa: bool,
) {
let half = (style.width * 0.5).max(0.5);
let tolerance = 0.25_f32;
let contours = self.flatten(tolerance);
let mut ras = Rasterizer::new();
for contour in &contours {
if contour.len() < 2 {
continue;
}
stroke_contour_clipped_inner_with_ras(
fb, contour, half, style, color, clip, aa, &mut ras,
);
}
}
}
#[derive(Clone, Debug, Default)]
pub struct PathBuilder {
path: Path,
}
impl PathBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn move_to(mut self, p: Point) -> Self {
self.path.move_to(p);
self
}
pub fn line_to(mut self, p: Point) -> Self {
self.path.line_to(p);
self
}
pub fn quad_to(mut self, ctrl: Point, end: Point) -> Self {
self.path.quad_to(ctrl, end);
self
}
pub fn cubic_to(mut self, c1: Point, c2: Point, end: Point) -> Self {
self.path.cubic_to(c1, c2, end);
self
}
pub fn close(mut self) -> Self {
self.path.close();
self
}
pub fn fill_rule(mut self, rule: FillRule) -> Self {
self.path.fill_rule = rule;
self
}
pub fn build(self) -> Path {
self.path
}
}
#[inline]
fn dist2(a: Point, b: Point) -> f32 {
let dx = b.0 - a.0;
let dy = b.1 - a.1;
dx * dx + dy * dy
}
#[inline]
fn mid(a: Point, b: Point) -> Point {
((a.0 + b.0) * 0.5, (a.1 + b.1) * 0.5)
}
pub fn flatten_quad(p0: Point, p1: Point, p2: Point, tol: f32, out: &mut Vec<Point>) {
let chord_dev = {
let mx = (p0.0 + p2.0) * 0.5;
let my = (p0.1 + p2.1) * 0.5;
let dx = p1.0 - mx;
let dy = p1.1 - my;
dx * dx + dy * dy
};
if chord_dev <= tol * tol * 4.0 {
out.push(p2);
return;
}
let q0 = mid(p0, p1);
let q1 = mid(p1, p2);
let r0 = mid(q0, q1);
flatten_quad(p0, q0, r0, tol, out);
flatten_quad(r0, q1, p2, tol, out);
}
pub fn flatten_cubic(p0: Point, p1: Point, p2: Point, p3: Point, tol: f32, out: &mut Vec<Point>) {
let chord_dev = {
let d1 = {
let mx = (2.0 * p0.0 + p3.0) / 3.0;
let my = (2.0 * p0.1 + p3.1) / 3.0;
let dx = p1.0 - mx;
let dy = p1.1 - my;
dx * dx + dy * dy
};
let d2 = {
let mx = (p0.0 + 2.0 * p3.0) / 3.0;
let my = (p0.1 + 2.0 * p3.1) / 3.0;
let dx = p2.0 - mx;
let dy = p2.1 - my;
dx * dx + dy * dy
};
d1.max(d2)
};
if chord_dev <= tol * tol {
out.push(p3);
return;
}
let q0 = mid(p0, p1);
let q1 = mid(p1, p2);
let q2 = mid(p2, p3);
let r0 = mid(q0, q1);
let r1 = mid(q1, q2);
let s0 = mid(r0, r1);
flatten_cubic(p0, q0, r0, s0, tol, out);
flatten_cubic(s0, r1, q2, p3, tol, out);
}
#[inline]
fn normal(a: Point, b: Point, half_w: f32) -> (f32, f32) {
let dx = b.0 - a.0;
let dy = b.1 - a.1;
let len = (dx * dx + dy * dy).sqrt();
if len < f32::EPSILON {
return (0.0, half_w);
}
let nx = -dy / len * half_w;
let ny = dx / len * half_w;
(nx, ny)
}
#[inline]
fn offset(p: Point, n: (f32, f32)) -> Point {
(p.0 + n.0, p.1 + n.1)
}
#[inline]
fn offset_neg(p: Point, n: (f32, f32)) -> Point {
(p.0 - n.0, p.1 - n.1)
}
fn stroke_contour_with_ras(
fb: &mut Framebuffer,
pts: &[Point],
half_w: f32,
style: &StrokeStyle,
color: Color,
ras: &mut Rasterizer,
) {
let poly = build_stroke_poly(pts, half_w, style);
if poly.len() >= 3 {
ras.fill_polygon(fb, &poly, color, FillRule::NonZero, true);
}
}
fn build_stroke_poly(pts: &[Point], half_w: f32, style: &StrokeStyle) -> Vec<Point> {
let n = pts.len();
if n < 2 {
return Vec::new();
}
let closed = dist2(pts[0], pts[n - 1]) < 1e-4;
let effective_n = if closed { n - 1 } else { n };
if effective_n < 2 {
return Vec::new();
}
let seg_count = effective_n - 1;
let mut normals: Vec<(f32, f32)> = Vec::with_capacity(seg_count);
for i in 0..seg_count {
normals.push(normal(pts[i], pts[i + 1], half_w));
}
let mut left: Vec<Point> = Vec::with_capacity(effective_n + 8);
let mut right: Vec<Point> = Vec::with_capacity(effective_n + 8);
if !closed {
let n0 = normals[0];
match style.cap {
Cap::Butt => {
left.push(offset(pts[0], n0));
right.push(offset_neg(pts[0], n0));
}
Cap::Square => {
let dir = direction(pts[0], pts[1], half_w);
left.push(offset(offset_neg(pts[0], dir), n0));
right.push(offset_neg(offset_neg(pts[0], dir), n0));
}
Cap::Round => {
add_round_cap(&mut left, pts[0], n0, true);
right.push(offset_neg(pts[0], n0));
}
}
} else {
let n_last = normals[seg_count - 1];
let n_first = normals[0];
if style.join == Join::Round {
add_round_join(&mut left, &mut right, pts[0], n_last, n_first);
} else {
let (jl, jr) = compute_join(pts[0], n_last, n_first, style.join, style.miter_limit);
left.push(jl);
right.push(jr);
}
}
for i in 1..effective_n - 1 {
let n_prev = normals[i - 1];
let n_next = normals[i];
match style.join {
Join::Round => {
add_round_join(&mut left, &mut right, pts[i], n_prev, n_next);
}
_ => {
let (jl, jr) = compute_join(pts[i], n_prev, n_next, style.join, style.miter_limit);
left.push(jl);
right.push(jr);
}
}
}
if !closed {
let n_last = normals[seg_count - 1];
let end = pts[effective_n - 1];
match style.cap {
Cap::Butt => {
left.push(offset(end, n_last));
right.push(offset_neg(end, n_last));
}
Cap::Square => {
let dir = direction(pts[effective_n - 2], end, half_w);
left.push(offset(offset(end, dir), n_last));
right.push(offset_neg(offset(end, dir), n_last));
}
Cap::Round => {
left.push(offset(end, n_last));
add_round_cap(&mut right, end, n_last, false);
}
}
} else {
let n_prev = normals[seg_count - 1];
let n_first = normals[0];
if style.join == Join::Round {
add_round_join(&mut left, &mut right, pts[effective_n - 1], n_prev, n_first);
} else {
let (jl, jr) = compute_join(
pts[effective_n - 1],
n_prev,
n_first,
style.join,
style.miter_limit,
);
left.push(jl);
right.push(jr);
}
}
let mut poly: Vec<Point> = Vec::with_capacity(left.len() + right.len());
poly.extend_from_slice(&left);
for &p in right.iter().rev() {
poly.push(p);
}
poly
}
#[allow(clippy::too_many_arguments)]
fn stroke_contour_clipped_inner_with_ras(
fb: &mut Framebuffer,
pts: &[Point],
half_w: f32,
style: &StrokeStyle,
color: Color,
clip: crate::clip::ClipRect,
aa: bool,
ras: &mut Rasterizer,
) {
let poly = build_stroke_poly(pts, half_w, style);
if poly.len() >= 3 {
ras.fill_polygon_clipped(fb, &poly, color, FillRule::NonZero, aa, clip);
}
}
#[inline]
fn direction(a: Point, b: Point, scale: f32) -> (f32, f32) {
let dx = b.0 - a.0;
let dy = b.1 - a.1;
let len = (dx * dx + dy * dy).sqrt();
if len < f32::EPSILON {
return (scale, 0.0);
}
(dx / len * scale, dy / len * scale)
}
fn compute_join(
pt: Point,
n_prev: (f32, f32),
n_next: (f32, f32),
join: Join,
miter_limit: f32,
) -> (Point, Point) {
match join {
Join::Bevel | Join::Round => (offset(pt, n_next), offset_neg(pt, n_next)),
Join::Miter => miter_join(pt, n_prev, n_next, miter_limit),
}
}
fn miter_join(
pt: Point,
n_prev: (f32, f32),
n_next: (f32, f32),
miter_limit: f32,
) -> (Point, Point) {
let avg_x = (n_prev.0 + n_next.0) * 0.5;
let avg_y = (n_prev.1 + n_next.1) * 0.5;
let len_sq = avg_x * avg_x + avg_y * avg_y;
if len_sq < f32::EPSILON {
return (offset(pt, n_next), offset_neg(pt, n_next));
}
let scale = 1.0 / len_sq;
let half_w_sq = n_prev.0 * n_prev.0 + n_prev.1 * n_prev.1;
let miter_len_sq = half_w_sq * scale;
if miter_len_sq > miter_limit * miter_limit * half_w_sq {
return (offset(pt, n_next), offset_neg(pt, n_next));
}
let mx = avg_x * scale * half_w_sq;
let my = avg_y * scale * half_w_sq;
let left = (pt.0 + mx, pt.1 + my);
let right = (pt.0 - mx, pt.1 - my);
(left, right)
}
fn add_round_join(
left: &mut Vec<Point>,
right: &mut Vec<Point>,
pt: Point,
n_prev: (f32, f32),
n_next: (f32, f32),
) {
let half_w = (n_prev.0 * n_prev.0 + n_prev.1 * n_prev.1).sqrt();
if half_w < f32::EPSILON {
return;
}
let cross = n_prev.0 * n_next.1 - n_prev.1 * n_next.0;
let start_angle = n_prev.1.atan2(n_prev.0);
let end_angle = n_next.1.atan2(n_next.0);
let mut sweep = end_angle - start_angle;
while sweep > std::f32::consts::PI {
sweep -= 2.0 * std::f32::consts::PI;
}
while sweep < -std::f32::consts::PI {
sweep += 2.0 * std::f32::consts::PI;
}
const STEPS: usize = 8;
let fan_pts: Vec<Point> = (0..=STEPS)
.map(|step| {
let a = start_angle + sweep * (step as f32 / STEPS as f32);
(pt.0 + a.cos() * half_w, pt.1 + a.sin() * half_w)
})
.collect();
if cross >= 0.0 {
for &p in &fan_pts {
left.push(p);
}
right.push(offset_neg(pt, n_next));
} else {
left.push(offset(pt, n_next));
for &p in fan_pts.iter().rev() {
let dx = p.0 - pt.0;
let dy = p.1 - pt.1;
right.push((pt.0 - dx, pt.1 - dy));
}
}
}
fn add_round_cap(target: &mut Vec<Point>, center: Point, n: (f32, f32), forward: bool) {
const STEPS: usize = 8;
let (nx, ny) = n;
let half_w = (nx * nx + ny * ny).sqrt();
if half_w < f32::EPSILON {
return;
}
let ux = nx / half_w;
let uy = ny / half_w;
let start_angle = uy.atan2(ux);
let sign = if forward { 1.0_f32 } else { -1.0_f32 };
for i in 0..=STEPS {
let a = start_angle + sign * std::f32::consts::PI * (i as f32 / STEPS as f32);
let px = center.0 + a.cos() * half_w;
let py = center.1 + a.sin() * half_w;
target.push((px, py));
}
}
pub fn flatten_quad_bezier(p0: Point, p1: Point, p2: Point, tolerance: f32) -> Vec<Point> {
let mut pts = vec![p0];
flatten_quad(p0, p1, p2, tolerance.max(0.01), &mut pts);
pts
}
pub fn flatten_cubic_bezier(
p0: Point,
p1: Point,
p2: Point,
p3: Point,
tolerance: f32,
) -> Vec<Point> {
let mut pts = vec![p0];
flatten_cubic(p0, p1, p2, p3, tolerance.max(0.01), &mut pts);
pts
}
#[cfg(test)]
mod tests {
use super::*;
use crate::framebuffer::Framebuffer;
fn fresh(w: u32, h: u32) -> Framebuffer {
Framebuffer::with_fill(w, h, Color(0, 0, 0, 255))
}
#[test]
fn bezier_flatten_endpoints() {
let p0 = (0.0f32, 0.0);
let p1 = (10.0, 20.0);
let p2 = (20.0, -10.0);
let p3 = (30.0, 0.0);
let pts = flatten_cubic_bezier(p0, p1, p2, p3, 0.25);
assert!(pts.len() >= 2);
let first = pts[0];
let last = *pts.last().expect("at least one point");
assert!((first.0 - p0.0).abs() < 0.01 && (first.1 - p0.1).abs() < 0.01);
assert!((last.0 - p3.0).abs() < 0.01 && (last.1 - p3.1).abs() < 0.01);
}
#[test]
fn quad_flatten_endpoints() {
let p0 = (0.0f32, 0.0);
let p1 = (5.0, 10.0);
let p2 = (10.0, 0.0);
let pts = flatten_quad_bezier(p0, p1, p2, 0.25);
let last = *pts.last().expect("at least one point");
assert!((last.0 - p2.0).abs() < 0.01 && (last.1 - p2.1).abs() < 0.01);
}
#[test]
fn path_fill_triangle() {
let mut fb = fresh(20, 20);
let mut path = Path::new();
path.move_to((0.0, 0.0))
.line_to((20.0, 0.0))
.line_to((10.0, 20.0))
.close();
path.fill(&mut fb, Color(255, 0, 0, 255));
let (r, _, _, _) = fb.get_rgba(10, 10).unwrap_or((0, 0, 0, 0));
assert!(r > 0, "centre should be painted");
}
#[test]
fn path_fill_rule_cases() {
let cx = 35.0f32;
let cy = 35.0f32;
let r = 30.0f32;
let star_pts: Vec<(f32, f32)> = (0..5)
.map(|i| {
let angle = std::f32::consts::PI * (2.0 * (2 * i) as f32 / 5.0 - 0.5);
(cx + r * angle.cos(), cy + r * angle.sin())
})
.collect();
let build_star = |rule: FillRule| {
let mut p = Path::new().with_fill_rule(rule);
p.move_to(star_pts[0]);
for &pt in &star_pts[1..] {
p.line_to(pt);
}
p.close();
p
};
let path_eo = build_star(FillRule::EvenOdd);
let path_nz = build_star(FillRule::NonZero);
let mut fb_eo = fresh(70, 70);
let mut fb_nz = fresh(70, 70);
path_eo.fill(&mut fb_eo, Color(255, 0, 0, 255));
path_nz.fill(&mut fb_nz, Color(255, 0, 0, 255));
let (r_eo_tip, _, _, _) = fb_eo.get_rgba(35, 8).unwrap_or((0, 0, 0, 0));
assert!(r_eo_tip > 0, "EvenOdd: star arm should be painted");
let (r_nz_ctr, _, _, _) = fb_nz.get_rgba(35, 35).unwrap_or((0, 0, 0, 0));
assert!(r_nz_ctr > 0, "NonZero: star center should be filled");
let (r_eo_ctr, _, _, _) = fb_eo.get_rgba(35, 35).unwrap_or((0, 0, 0, 0));
assert_eq!(
r_eo_ctr, 0,
"EvenOdd: star center should be a hole (r={r_eo_ctr})"
);
}
#[test]
fn path_builder_works() {
let path = PathBuilder::new()
.move_to((0.0, 0.0))
.line_to((10.0, 0.0))
.line_to((5.0, 10.0))
.close()
.build();
let mut fb = fresh(15, 15);
path.fill(&mut fb, Color(0, 255, 0, 255));
let (_, g, _, _) = fb.get_rgba(5, 3).unwrap_or((0, 0, 0, 0));
assert!(g > 0, "builder path: interior should be green");
}
#[test]
fn path_stroke_produces_pixels() {
let mut fb = fresh(30, 30);
let mut path = Path::new();
path.move_to((5.0, 15.0)).line_to((25.0, 15.0));
let style = StrokeStyle {
width: 4.0,
join: Join::Miter,
cap: Cap::Butt,
miter_limit: 4.0,
};
path.stroke(&mut fb, &style, Color(0, 0, 255, 255));
let mut found = false;
for x in 0..30 {
let (_, _, b, _) = fb.get_rgba(x, 15).unwrap_or((0, 0, 0, 0));
if b > 0 {
found = true;
break;
}
}
assert!(found, "stroke should produce blue pixels along y=15");
}
#[test]
fn round_join_differs_from_bevel() {
let mut fb_round = fresh(40, 40);
let mut fb_bevel = fresh(40, 40);
let mut path_r = Path::new();
path_r
.move_to((5.0, 20.0))
.line_to((20.0, 20.0))
.line_to((20.0, 5.0));
let mut path_b = Path::new();
path_b
.move_to((5.0, 20.0))
.line_to((20.0, 20.0))
.line_to((20.0, 5.0));
let style_round = StrokeStyle {
width: 6.0,
join: Join::Round,
cap: Cap::Butt,
miter_limit: 4.0,
};
let style_bevel = StrokeStyle {
width: 6.0,
join: Join::Bevel,
cap: Cap::Butt,
miter_limit: 4.0,
};
path_r.stroke(&mut fb_round, &style_round, Color(255, 0, 0, 255));
path_b.stroke(&mut fb_bevel, &style_bevel, Color(255, 0, 0, 255));
let count = |fb: &Framebuffer| -> u32 {
(0..40u32)
.flat_map(|y| (0..40u32).map(move |x| (x, y)))
.filter(|&(x, y)| fb.get_rgba(x, y).is_some_and(|(r, _, _, _)| r > 0))
.count() as u32
};
let round_px = count(&fb_round);
let bevel_px = count(&fb_bevel);
assert!(
round_px > 0,
"Round join should produce some pixels (got {round_px})"
);
assert!(
bevel_px > 0,
"Bevel join should produce some pixels (got {bevel_px})"
);
assert_ne!(
round_px, bevel_px,
"Round and Bevel joins should differ in pixel count (both={round_px})"
);
}
#[test]
fn miter_limit_prevents_spike() {
let pt = (10.0f32, 10.0);
let n_prev = (0.0f32, 2.0); let n_next = (0.0f32, -2.0); let (left, right) = miter_join(pt, n_prev, n_next, 4.0);
assert!(left.0.is_finite() && left.1.is_finite());
assert!(right.0.is_finite() && right.1.is_finite());
}
}