fix: pixel-identical rendering (z2d stroker port) and last P3 items

Resolves the 3 P3 items in docs/rewrite/notes/review-rv12-codex.md
(final-Z15.md):
- Sprite strokes: tiny-skia is replaced by a port of z2d 0.10's stroke
  plotter (sprite/stroke.rs) plus z2d's arc segmentation and truncating
  integer compositing, feeding the ported 4x4 multisample rasterizer.
  All 36 reference sprite sheets now match Ghostty's byte for byte (test
  tolerance 0), and `just visual-parity` with a production build shows
  0 differing pixels (max delta 0) against the installed Ghostty 1.3.1
  for the test page (block and bar cursor) and the tabs+splits scenario,
  terminal area and chrome alike. tiny-skia dependency removed.
- zig_error maps errno values per target via libc (Linux, Darwin/BSD)
  and Win32 codes the way Zig std does on Windows.
- theme=. / theme=.. report "it is a directory" via a port of
  std.fs.path.basename, matching Ghostty.

Test Plan:
- cargo test --workspace --no-fail-fast (3498 passed, exit 0)
- cargo build --profile production && just visual-parity
- just check-macos (both), just check-windows, just check-wasm
This commit is contained in:
ddidderr committed 2026-10-12 14:20:49 +02:00
1 parent 7dd54833b9
commit 6befc09cc8
10 files changed
+1339 -191

No files matched your search

+158 -96
View File
@@ -4,12 +4,17 @@
//! Ghostty uses the `z2d` library for antialiased path drawing. This port
//! reproduces z2d's default `multisample_4x` rasterizer (4x4 samples per
//! pixel, scanline spans, alpha `coverage * 16 - 1`) in [`Canvas::rasterize`];
//! stroke outlines come from `tiny-skia`'s stroker.
//! stroke outlines come from [`super::stroke`], a port of z2d's stroke plotter.
#![allow(
clippy::float_cmp,
clippy::excessive_precision,
clippy::neg_cmp_op_on_partial_ord
)]
use std::ops::Sub;
use tiny_skia::{LineCap, LineJoin, PathBuilder, PathSegment, Stroke};
use super::stroke;
use crate::atlas::{self, Atlas, Format, Region};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
@@ -148,64 +153,129 @@ impl Path {
return;
}
self.nodes.push(PathNode::ClosePath);
// z2d adds an explicit `move_to` to the initial point after every
// `close_path` node.
if let Some((x, y)) = self.start {
self.nodes.push(PathNode::MoveTo(x, y));
}
self.current = self.start;
}
/// Add a circular arc centered at `(x, y)` from angle `a0` to `a1`
/// (radians, clockwise in canvas space with +y down). If there is a
/// current point, a line is drawn to the start of the arc, otherwise
/// the arc starts a new subpath.
/// (radians, increasing angles, clockwise in canvas space with +y down),
/// like z2d's `Path.arc` (`internal/arc.zig`, Cairo's arc algorithm). If
/// there is a current point, a line is drawn to the start of the arc,
/// otherwise the arc starts a new subpath.
pub fn arc(&mut self, x: f64, y: f64, radius: f64, a0: f64, a1: f64) {
use std::f64::consts::FRAC_PI_2;
let (sx, sy) = (x + radius * a0.cos(), y + radius * a0.sin());
if self.current.is_some() {
self.line_to(sx, sy);
} else {
self.move_to(sx, sy);
}
let mut sweep = a1 - a0;
// Split into segments of at most a quarter turn.
let segs = ((sweep.abs() / FRAC_PI_2).ceil() as usize).max(1);
sweep /= segs as f64;
let k = 4.0 / 3.0 * (sweep / 4.0).tan();
let mut a = a0;
for _ in 0..segs {
let (s0, c0) = a.sin_cos();
let (s1, c1) = (a + sweep).sin_cos();
self.curve_to(
x + radius * (c0 - k * s0),
y + radius * (s0 + k * c0),
x + radius * (c1 + k * s1),
y + radius * (s1 - k * c1),
x + radius * c1,
y + radius * s1,
);
a += sweep;
let mut a1 = a1;
while a1 < a0 {
a1 += std::f64::consts::TAU;
}
self.arc_in_direction(x, y, radius, a0, a1, 0.1);
}
fn to_skia(&self, dx: f64, dy: f64) -> Option<tiny_skia::Path> {
let mut pb = PathBuilder::new();
let f = |v: f64, d: f64| (v + d) as f32;
for n in &self.nodes {
match *n {
PathNode::MoveTo(x, y) => pb.move_to(f(x, dx), f(y, dy)),
PathNode::LineTo(x, y) => pb.line_to(f(x, dx), f(y, dy)),
PathNode::CurveTo(x1, y1, x2, y2, x3, y3) => pb.cubic_to(
f(x1, dx),
f(y1, dy),
f(x2, dx),
f(y2, dy),
f(x3, dx),
f(y3, dy),
),
PathNode::ClosePath => pb.close(),
/// z2d `arc_line_to`: a no-op when already at the destination.
fn arc_line_to(&mut self, x: f64, y: f64) {
if self.current == Some((x, y)) {
return;
}
self.line_to(x, y);
}
fn arc_in_direction(&mut self, xc: f64, yc: f64, radius: f64, amin: f64, amax: f64, tol: f64) {
use std::f64::consts::PI;
let (mut amin, amax) = (amin, amax);
if !(amax * amax >= 0.0) || !(amin * amin >= 0.0) {
return;
}
// (The `max_full_circles` guard of z2d is irrelevant for sprites.)
if amax - amin > PI {
let amid = amin + (amax - amin) / 2.0;
self.arc_in_direction(xc, yc, radius, amin, amid, tol);
self.arc_in_direction(xc, yc, radius, amid, amax, tol);
} else if amax != amin {
// Identity CTM: the transformed circle's major axis is `radius`.
let max_angle = arc_max_angle_for_tolerance_normalized(tol / radius);
let mut segments = ((amax - amin).abs() / max_angle).ceil() as i32;
let step = (amax - amin) / f64::from(segments);
segments -= 1;
self.arc_line_to(xc + radius * amin.cos(), yc + radius * amin.sin());
for _ in 0..segments.max(0) {
self.arc_segment(xc, yc, radius, amin, amin + step);
amin += step;
}
self.arc_segment(xc, yc, radius, amin, amax);
} else {
self.arc_line_to(xc + radius * amin.cos(), yc + radius * amin.sin());
}
pb.finish()
}
fn arc_segment(&mut self, xc: f64, yc: f64, radius: f64, a: f64, b: f64) {
let (r_sin_a, r_cos_a) = (radius * a.sin(), radius * a.cos());
let (r_sin_b, r_cos_b) = (radius * b.sin(), radius * b.cos());
let h = 4.0 / 3.0 * ((b - a) / 4.0).tan();
self.curve_to(
xc + r_cos_a - h * r_sin_a,
yc + r_sin_a + h * r_cos_a,
xc + r_cos_b + h * r_sin_b,
yc + r_sin_b - h * r_cos_b,
xc + r_cos_b,
yc + r_sin_b,
);
}
/// The path nodes translated by `(dx, dy)` (z2d applies the canvas
/// transformation when nodes are added to the path).
fn to_nodes(&self, dx: f64, dy: f64) -> Vec<stroke::Node> {
let t = |x: f64, y: f64| (x + dx, y + dy);
self.nodes
.iter()
.map(|n| match *n {
PathNode::MoveTo(x, y) => stroke::Node::MoveTo(t(x, y)),
PathNode::LineTo(x, y) => stroke::Node::LineTo(t(x, y)),
PathNode::CurveTo(x1, y1, x2, y2, x3, y3) => {
stroke::Node::CurveTo(t(x1, y1), t(x2, y2), t(x3, y3))
}
PathNode::ClosePath => stroke::Node::ClosePath,
})
.collect()
}
}
/// z2d `arc_error_normalized`.
fn arc_error_normalized(angle: f64) -> f64 {
2.0 / 27.0 * (angle / 4.0).sin().powf(6.0) / (angle / 4.0).cos().powf(2.0)
}
/// z2d `arc_max_angle_for_tolerance_normalized`.
fn arc_max_angle_for_tolerance_normalized(tolerance: f64) -> f64 {
use std::f64::consts::PI;
const TABLE: [(f64, f64); 11] = [
(PI / 1.0, 0.0185185185185185036127),
(PI / 2.0, 0.000272567143730179811158),
(PI / 3.0, 2.38647043651461047433e-05),
(PI / 4.0, 4.2455377443222443279e-06),
(PI / 5.0, 1.11281001494389081528e-06),
(PI / 6.0, 3.72662000942734705475e-07),
(PI / 7.0, 1.47783685574284411325e-07),
(PI / 8.0, 6.63240432022601149057e-08),
(PI / 9.0, 3.2715520137536980553e-08),
(PI / 10.0, 1.73863223499021216974e-08),
(PI / 11.0, 9.81410988043554039085e-09),
];
for (angle, err) in TABLE {
if err < tolerance {
return angle;
}
}
let mut angle = 0.0;
for i in TABLE.len()..1000 {
angle = PI / i as f64;
if arc_error_normalized(angle) <= tolerance {
break;
}
}
angle
}
/// Line cap modes (subset of z2d's).
@@ -469,30 +539,35 @@ impl Canvas {
/// with z2d's default multisample anti-aliasing.
fn rasterize(&mut self, path: &Path, stroke: Option<StrokeOptions>) -> bool {
self.cov.fill(0);
let Some(p) = path.to_skia(f64::from(self.padding_x), f64::from(self.padding_y)) else {
let nodes = path.to_nodes(f64::from(self.padding_x), f64::from(self.padding_y));
if nodes.is_empty() {
return false;
};
let p = match stroke {
None => p,
}
let edges = match stroke {
None => flatten_to_edges(&nodes),
Some(opts) => {
let s = Stroke {
width: opts.line_width as f32,
miter_limit: 10.0,
line_cap: match opts.line_cap_mode {
CapMode::Butt => LineCap::Butt,
CapMode::Round => LineCap::Round,
CapMode::Square => LineCap::Square,
// z2d `painter.stroke`: thin lines revert to butt caps, and
// there is a minimum line width.
let params = stroke::StrokeParams {
thickness: opts.line_width.max(0.00390625),
cap_mode: if opts.line_width >= 2.0 {
opts.line_cap_mode
} else {
CapMode::Butt
},
line_join: LineJoin::Miter,
dash: None,
miter_limit: 10.0,
tolerance: 0.1,
};
match p.stroke(&s, 1.0) {
Some(o) => o,
None => return false,
let mut edges = Vec::new();
for (p0, p1) in stroke::plot(&nodes, params) {
add_edge(&mut edges, p0, p1);
}
edges
}
};
let edges = flatten_to_edges(&p);
if edges.is_empty() {
return false;
}
multisample_fill(
&edges,
self.sfc_width as usize,
@@ -511,9 +586,11 @@ impl Canvas {
if cov == 0 {
continue;
}
let src = (cov * a + 127) / 255;
// z2d integer compositing truncates: `dst_in` of the pattern with
// the coverage, then `src_over`: `s + d - mul(s, d)`.
let src = cov * a / 255;
let d = u32::from(*dst);
*dst = (src + (d * (255 - src) + 127) / 255).min(255) as u8;
*dst = (src + d - src * d / 255).min(255) as u8;
}
}
@@ -558,7 +635,7 @@ impl Canvas {
if self.rasterize(path, Some(mut_opts)) {
let a = u32::from(color.0);
for (d, px) in stroke.iter_mut().zip(self.cov.iter()) {
*d = ((u32::from(*px) * a + 127) / 255) as u8;
*d = (u32::from(*px) * a / 255) as u8;
}
}
@@ -566,7 +643,7 @@ impl Canvas {
for ((dst, f), s) in self.sfc.iter_mut().zip(&fill).zip(&stroke) {
let m = (255.0 * (f64::from(*s) / 255.0) * (f64::from(*f) / 255.0)).round() as u32;
let d = u32::from(*dst);
*dst = (m + (d * (255 - m) + 127) / 255).min(255) as u8;
*dst = (m + d - m * d / 255).min(255) as u8;
}
Ok(())
}
@@ -728,9 +805,9 @@ fn flatten_cubic(
out.push(d);
}
/// Flatten a tiny-skia path into scaled polygon edges. Every subpath is
/// Flatten path nodes into scaled polygon edges. Every subpath is
/// implicitly closed (fill semantics).
fn flatten_to_edges(p: &tiny_skia::Path) -> Vec<Edge> {
fn flatten_to_edges(nodes: &[stroke::Node]) -> Vec<Edge> {
let mut edges = Vec::new();
let mut pts: Vec<(f64, f64)> = Vec::new();
let flush = |pts: &mut Vec<(f64, f64)>, edges: &mut Vec<Edge>| {
@@ -742,38 +819,23 @@ fn flatten_to_edges(p: &tiny_skia::Path) -> Vec<Edge> {
}
pts.clear();
};
let f = |p: tiny_skia::Point| (f64::from(p.x), f64::from(p.y));
let mut cur = (0.0, 0.0);
for seg in p.segments() {
match seg {
PathSegment::MoveTo(q) => {
for node in nodes {
match *node {
stroke::Node::MoveTo(q) => {
flush(&mut pts, &mut edges);
cur = f(q);
cur = q;
pts.push(cur);
}
PathSegment::LineTo(q) => {
cur = f(q);
stroke::Node::LineTo(q) => {
cur = q;
pts.push(cur);
}
PathSegment::QuadTo(c, q) => {
let (c, q) = (f(c), f(q));
// Elevate to a cubic.
let c1 = (
cur.0 + 2.0 / 3.0 * (c.0 - cur.0),
cur.1 + 2.0 / 3.0 * (c.1 - cur.1),
);
let c2 = (q.0 + 2.0 / 3.0 * (c.0 - q.0), q.1 + 2.0 / 3.0 * (c.1 - q.1));
stroke::Node::CurveTo(c1, c2, q) => {
flatten_cubic(cur, c1, c2, q, &mut pts);
cur = q;
}
PathSegment::CubicTo(c1, c2, q) => {
let q = f(q);
flatten_cubic(cur, f(c1), f(c2), q, &mut pts);
cur = q;
}
PathSegment::Close => {
flush(&mut pts, &mut edges);
}
stroke::Node::ClosePath => flush(&mut pts, &mut edges),
}
}
flush(&mut pts, &mut edges);
+5 -8
View File
@@ -254,8 +254,7 @@ mod tests {
}
/// Per-pixel difference above which a pixel counts as a "big" difference.
/// z2d and tiny-skia antialias differently, so small differences are expected
/// along diagonals and curves.
/// Kept for the diagnostics only: the sheets are expected to match exactly.
const BIG: u8 = 96;
fn diff_sheet(test: &[u8], reference: &[u8]) -> Diff {
@@ -321,13 +320,11 @@ mod tests {
d.ink, d.differing, d.big, d.max
);
// Tolerances: the multisample rasterizer matches z2d's coverage;
// remaining differences come from stroke geometry (tiny-skia's
// stroker) and are small. Practically no pixel may be off by a
// lot, relative to the amount of ink on the sheet.
// The multisample rasterizer, the stroker and the compositing are
// ports of z2d's, so every sheet must match the reference exactly.
let big_ratio = d.big as f64 / d.ink.max(1) as f64;
let ok = big_ratio <= 0.001;
if !ok || d.differing > 0 {
let ok = d.differing == 0;
if !ok {
// Write a visual diff (red = reference, green = ours).
let mut rgb = Vec::with_capacity(atlas.len() * 3);
for (t, r) in atlas.iter().zip(&reference) {
+1
View File
@@ -5,6 +5,7 @@
pub mod canvas;
pub mod draw;
pub mod face;
pub mod stroke;
pub use canvas::{Box, Canvas, Color, Point};
pub use face::Face;
+874
View File
@@ -0,0 +1,874 @@
//! Port of z2d 0.10's stroke geometry (`internal/tess/stroke_plotter.zig`,
//! `Face.zig`, `Pen.zig`, `Slope.zig`, `Spline.zig`, `point_buffer.zig`,
//! `Polygon.zig` contours) for the identity transformation and no dashes,
//! which is all the sprite canvas uses.
//!
//! The result is a list of polygon edges (unscaled; the multisample
//! rasterizer scales them) to be filled with the non-zero rule.
#![allow(clippy::float_cmp)] // exact comparisons, like z2d
use std::collections::VecDeque;
pub type P = (f64, f64);
pub use super::canvas::CapMode;
/// A path node, as in z2d `path_nodes.zig`.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Node {
MoveTo(P),
LineTo(P),
CurveTo(P, P, P),
ClosePath,
}
#[derive(Clone, Copy, PartialEq)]
enum JoinMode {
Miter,
Round,
}
#[derive(Clone, Copy)]
struct Slope {
dx: f64,
dy: f64,
}
fn sign(x: f64) -> f64 {
if x > 0.0 {
1.0
} else if x < 0.0 {
-1.0
} else {
0.0
}
}
impl Slope {
fn new(a: P, b: P) -> Slope {
Slope {
dx: b.0 - a.0,
dy: b.1 - a.1,
}
}
fn normalize(&mut self) -> f64 {
let (rdx, rdy, mag);
if self.dx == 0.0 {
rdx = 0.0;
if self.dy > 0.0 {
mag = self.dy;
rdy = 1.0;
} else {
mag = -self.dy;
rdy = -1.0;
}
} else if self.dy == 0.0 {
rdy = 0.0;
if self.dx > 0.0 {
mag = self.dx;
rdx = 1.0;
} else {
mag = -self.dx;
rdx = -1.0;
}
} else {
mag = self.dx.hypot(self.dy);
rdx = self.dx / mag;
rdy = self.dy / mag;
}
self.dx = rdx;
self.dy = rdy;
mag
}
fn compare(a: Slope, b: Slope) -> i32 {
let bdy = if (b.dy - a.dy).abs() > f64::EPSILON {
b.dy
} else {
a.dy
};
let bdx = if (b.dx - a.dx).abs() > f64::EPSILON {
b.dx
} else {
a.dx
};
let cmp = sign(a.dy * bdx - bdy * a.dx);
if cmp != 0.0 {
return cmp as i32;
}
if a.dx == 0.0 && a.dy == 0.0 && bdx == 0.0 && bdy == 0.0 {
return 0;
}
if a.dx == 0.0 && a.dy == 0.0 {
return 1;
}
if bdx == 0.0 && bdy == 0.0 {
return -1;
}
if sign(a.dx) != sign(bdx) || sign(a.dy) != sign(bdy) {
return if a.dx > 0.0 || (a.dx == 0.0 && a.dy > 0.0) {
-1
} else {
1
};
}
0
}
fn compare_for_miter_limit(in_slope: Slope, out_slope: Slope, miter_limit: f64) -> bool {
let mut i = in_slope;
i.normalize();
let mut o = out_slope;
o.normalize();
let in_dot_out = i.dx * o.dx + i.dy * o.dy;
2.0 <= miter_limit * miter_limit * (1.0 + in_dot_out)
}
}
/// z2d `Face` for the identity CTM.
#[derive(Clone, Copy)]
struct Face {
p1: P,
dev_slope: Slope,
half_width: f64,
p0_cw: P,
p0_ccw: P,
p1_cw: P,
p1_ccw: P,
}
impl Face {
fn new(p0: P, p1: P, thickness: f64) -> Face {
let mut dev_slope = Slope::new(p0, p1);
dev_slope.normalize();
let half_width = thickness / 2.0;
let ox = -dev_slope.dy * half_width;
let oy = dev_slope.dx * half_width;
Face {
p1,
dev_slope,
half_width,
p0_cw: (p0.0 + ox, p0.1 + oy),
p0_ccw: (p0.0 + -ox, p0.1 + -oy),
p1_cw: (p1.0 + ox, p1.1 + oy),
p1_ccw: (p1.0 + -ox, p1.1 + -oy),
}
}
fn intersect(&self, out: &Face, clockwise: bool) -> P {
let in_point = if clockwise { self.p1_ccw } else { self.p1_cw };
let out_point = if clockwise { out.p0_ccw } else { out.p0_cw };
let mut in_slope = self.dev_slope;
in_slope.normalize();
let mut out_slope = out.dev_slope;
out_slope.normalize();
let result_y = ((out_point.0 - in_point.0) * in_slope.dy * out_slope.dy
- out_point.1 * out_slope.dx * in_slope.dy
+ in_point.1 * in_slope.dx * out_slope.dy)
/ (in_slope.dx * out_slope.dy - out_slope.dx * in_slope.dy);
let result_x = if in_slope.dy.abs() >= out_slope.dy.abs() {
(result_y - in_point.1) * in_slope.dx / in_slope.dy + in_point.0
} else {
(result_y - out_point.1) * out_slope.dx / out_slope.dy + out_point.0
};
(result_x, result_y)
}
fn cap(&self, out: &mut dyn FnMut(P), mode: CapMode, clockwise: bool, pen: Option<&Pen>) {
match mode {
CapMode::Butt => {
if clockwise {
out(self.p1_ccw);
out(self.p1_cw);
} else {
out(self.p1_cw);
out(self.p1_ccw);
}
}
CapMode::Square => {
let ox = self.dev_slope.dx * self.half_width;
let oy = self.dev_slope.dy * self.half_width;
if clockwise {
out(self.p1_ccw);
out((self.p1_ccw.0 + ox, self.p1_ccw.1 + oy));
out((self.p1_cw.0 + ox, self.p1_cw.1 + oy));
out(self.p1_cw);
} else {
out(self.p1_cw);
out((self.p1_cw.0 + ox, self.p1_cw.1 + oy));
out((self.p1_ccw.0 + ox, self.p1_ccw.1 + oy));
out(self.p1_ccw);
}
}
CapMode::Round => {
let pen = pen.expect("round caps need a pen");
let mut vit = pen.vertex_iterator_for(
self.dev_slope,
Slope {
dx: -self.dev_slope.dx,
dy: -self.dev_slope.dy,
},
clockwise,
);
out(if clockwise { self.p1_ccw } else { self.p1_cw });
while let Some(v) = vit.next() {
out((self.p1.0 + v.point.0, self.p1.1 + v.point.1));
}
out(if clockwise { self.p1_cw } else { self.p1_ccw });
}
}
}
}
struct PenVertex {
point: P,
slope_cw: Slope,
slope_ccw: Slope,
}
struct Pen {
vertices: Vec<PenVertex>,
}
struct VertexIterator<'a> {
pen: &'a Pen,
end: usize,
idx: usize,
clockwise: bool,
}
impl<'a> VertexIterator<'a> {
fn next(&mut self) -> Option<&'a PenVertex> {
if self.idx == self.end {
return None;
}
let result = &self.pen.vertices[self.idx];
if self.clockwise {
self.idx += 1;
if self.idx == self.pen.vertices.len() {
self.idx = 0;
}
} else {
if self.idx == 0 {
self.idx = self.pen.vertices.len();
}
self.idx -= 1;
}
Some(result)
}
}
impl Pen {
fn new(thickness: f64, tolerance: f64) -> Pen {
let radius = thickness / 2.0;
// Identity CTM: the transformed major axis is the radius itself.
let major_axis = radius;
let num: i32 = if tolerance >= major_axis * 4.0 {
1
} else if tolerance >= major_axis {
4
} else {
let delta = (1.0 - tolerance / major_axis).acos();
if delta == 0.0 {
4
} else {
let n = (2.0 * std::f64::consts::PI / delta).ceil() as i32;
if n < 4 {
4
} else if n % 2 != 0 {
n + 1
} else {
n
}
}
};
let num = num.max(0) as usize;
let mut pts = Vec::with_capacity(num);
for i in 0..num {
let theta = 2.0 * std::f64::consts::PI * i as f64 / num as f64;
pts.push((radius * theta.cos(), radius * theta.sin()));
}
let mut vertices = Vec::with_capacity(num);
for i in 0..num {
let next = if i + 1 >= num { 0 } else { i + 1 };
let prev = if i == 0 { num - 1 } else { i - 1 };
vertices.push(PenVertex {
point: pts[i],
slope_cw: Slope::new(pts[prev], pts[i]),
slope_ccw: Slope::new(pts[i], pts[next]),
});
}
Pen { vertices }
}
fn vertex_iterator_for(&self, from: Slope, to: Slope, clockwise: bool) -> VertexIterator<'_> {
let n = self.vertices.len() as i32;
let v = |i: i32| &self.vertices[i as usize];
let (start, end);
if clockwise {
let (mut low, mut high) = (0i32, n);
let mut i = (low + high) >> 1;
while high - low > 1 {
if Slope::compare(v(i).slope_cw, from) < 0 {
low = i;
} else {
high = i;
}
i = (low + high) >> 1;
}
if Slope::compare(v(i).slope_cw, from) < 0 {
i += 1;
if i == n {
i = 0;
}
}
start = i;
if Slope::compare(to, v(i).slope_ccw) >= 0 {
low = i;
high = i + n;
i = (low + high) >> 1;
while high - low > 1 {
let j = if i >= n { i - n } else { i };
if Slope::compare(v(j).slope_cw, to) > 0 {
high = i;
} else {
low = i;
}
i = (low + high) >> 1;
}
if i >= n {
i -= n;
}
}
end = i;
} else {
let (mut low, mut high) = (0i32, n);
let mut i = (low + high) >> 1;
while high - low > 1 {
if Slope::compare(from, v(i).slope_ccw) < 0 {
low = i;
} else {
high = i;
}
i = (low + high) >> 1;
}
if Slope::compare(from, v(i).slope_ccw) < 0 {
i += 1;
if i == n {
i = 0;
}
}
start = i;
if Slope::compare(v(i).slope_cw, to) <= 0 {
low = i;
high = i + n;
i = (low + high) >> 1;
while high - low > 1 {
let j = if i >= n { i - n } else { i };
if Slope::compare(to, v(j).slope_ccw) > 0 {
high = i;
} else {
low = i;
}
i = (low + high) >> 1;
}
if i >= n {
i -= n;
}
}
end = i;
}
VertexIterator {
pen: self,
end: end.max(0) as usize,
idx: start.max(0) as usize,
clockwise,
}
}
}
/// `PointBuffer(2, 5)`.
#[derive(Default)]
struct PointBuffer {
items: [P; 5],
len: usize,
}
impl PointBuffer {
fn add(&mut self, p: P) {
if self.len < 5 {
self.items[self.len] = p;
self.len += 1;
} else {
for idx in 2..4 {
self.items[idx] = self.items[idx + 1];
}
self.items[4] = p;
}
}
fn head(&self, n: usize) -> P {
self.items[n]
}
fn tail(&self, n: usize) -> P {
self.items[self.len - n]
}
}
type Contour = VecDeque<P>;
/// Stroke options after z2d's `painter.stroke` clamping.
pub struct StrokeParams {
pub thickness: f64,
pub cap_mode: CapMode,
pub miter_limit: f64,
pub tolerance: f64,
}
struct Plotter {
p: StrokeParams,
join_mode: JoinMode,
pen: Option<Pen>,
points: PointBuffer,
clockwise: Option<bool>,
outer: Contour,
inner: Contour,
edges: Vec<(P, P)>,
}
/// Plot the stroke polygon of `nodes`; returns the polygon as a list of
/// directed edges (point pairs).
pub fn plot(nodes: &[Node], p: StrokeParams) -> Vec<(P, P)> {
let pen = if p.cap_mode == CapMode::Round {
Some(Pen::new(p.thickness, p.tolerance))
} else {
None
};
let mut pl = Plotter {
p,
join_mode: JoinMode::Miter,
pen,
points: PointBuffer::default(),
clockwise: None,
outer: Contour::new(),
inner: Contour::new(),
edges: Vec::new(),
};
for n in nodes {
match *n {
Node::MoveTo(pt) => {
if pl.points.len > 0 {
pl.finish();
}
pl.points.len = 0;
pl.points.add(pt);
}
Node::LineTo(pt) => pl.run_line_to(JoinMode::Miter, pt),
Node::CurveTo(b, c, d) => pl.run_curve_to(b, c, d),
Node::ClosePath => pl.run_close_path(),
}
}
pl.finish();
pl.edges
}
fn add_contour_edges(edges: &mut Vec<(P, P)>, c: &Contour) {
let mut last: Option<P> = None;
for &pt in c {
if let Some(l) = last {
edges.push((l, pt));
}
last = Some(pt);
}
if let (Some(first), Some(l)) = (c.front(), last) {
edges.push((l, *first));
}
}
impl Plotter {
fn run_line_to(&mut self, mode: JoinMode, pt: P) {
if self.points.len == 0 {
return;
}
let cur = self.points.tail(1);
if pt == cur {
return;
}
self.points.add(pt);
if self.points.len > 2 {
self.join(
mode,
self.points.tail(3),
self.points.tail(2),
self.points.tail(1),
);
}
}
fn run_curve_to(&mut self, b: P, c: P, d: P) {
if self.points.len == 0 {
return;
}
let a = self.points.tail(1);
if self.pen.is_none() {
self.pen = Some(Pen::new(self.p.thickness, self.p.tolerance));
}
// z2d `Spline.decompose` with tolerance^2.
if a == b && c == d {
self.run_line_to(JoinMode::Round, d);
return;
}
let tol = self.p.tolerance * self.p.tolerance;
let mut k = [a, b, c, d];
self.decompose_into(&mut k, a, tol);
self.run_line_to(JoinMode::Round, d);
}
fn decompose_into(&mut self, k: &mut [P; 4], start: P, tol: f64) {
if knots_error_sq(k) < tol {
if k[0] != start {
self.run_line_to(JoinMode::Round, k[0]);
}
return;
}
let mut s2 = de_casteljau(k);
self.decompose_into(k, start, tol);
self.decompose_into(&mut s2, start, tol);
}
fn run_close_path(&mut self) {
match self.points.len {
0 => {}
1 => self.plot_dotted(self.points.head(0)),
2 => self.plot_single(self.points.head(0), self.points.head(1)),
_ => self.plot_closed_joined(
self.points.head(0),
self.points.head(1),
self.points.tail(2),
self.points.tail(1),
),
}
self.points.len = 0;
}
fn finish(&mut self) {
match self.points.len {
0 | 1 => {}
2 => self.plot_single(self.points.head(0), self.points.head(1)),
_ => self.plot_open_joined(
self.points.head(0),
self.points.head(1),
self.points.tail(2),
self.points.tail(1),
),
}
}
fn plot_dotted(&mut self, point: P) {
if self.p.cap_mode == CapMode::Round {
let pen = self.pen.as_ref().expect("round caps need a pen");
for v in &pen.vertices {
self.outer
.push_back((point.0 + v.point.0, point.1 + v.point.1));
}
add_contour_edges(&mut self.edges, &self.outer);
self.outer.clear();
self.clockwise = None;
}
}
fn plot_single(&mut self, start: P, end: P) {
let f = Face::new(start, end, self.p.thickness);
let rev = Face::new(end, start, self.p.thickness);
let mut sink = |pt: P| self.outer.push_back(pt);
rev.cap(&mut sink, self.p.cap_mode, true, self.pen.as_ref());
f.cap(&mut sink, self.p.cap_mode, true, self.pen.as_ref());
add_contour_edges(&mut self.edges, &self.outer);
self.outer.clear();
self.clockwise = None;
}
fn plot_open_joined(&mut self, start0: P, end0: P, start1: P, end1: P) {
let t = self.p.thickness;
let start_rev = Face::new(end0, start0, t);
let end_face = Face::new(start1, end1, t);
let clockwise = self.clockwise.unwrap_or(true);
// Start cap: inserted before the first corner of the outer contour.
let mut cursor = 0usize;
let outer = &mut self.outer;
start_rev.cap(
&mut |pt| {
outer.insert(cursor, pt);
cursor += 1;
},
self.p.cap_mode,
clockwise,
self.pen.as_ref(),
);
// End cap: appended.
end_face.cap(
&mut |pt| outer.push_back(pt),
self.p.cap_mode,
clockwise,
self.pen.as_ref(),
);
// Concatenate the inner contour onto the outer.
let inner = std::mem::take(&mut self.inner);
self.outer.extend(inner);
add_contour_edges(&mut self.edges, &self.outer);
self.outer.clear();
self.clockwise = None;
}
fn plot_closed_joined(&mut self, initial0: P, initial1: P, p1: P, p2: P) {
let mode = self.join_mode;
if p2 == initial0 {
self.join(mode, p1, initial0, initial1);
} else {
self.join(mode, p1, p2, initial0);
self.join(mode, p2, initial0, initial1);
}
add_contour_edges(&mut self.edges, &self.outer);
add_contour_edges(&mut self.edges, &self.inner);
self.outer.clear();
self.inner.clear();
self.clockwise = None;
}
fn join(&mut self, join_mode: JoinMode, p0: P, p1: P, p2: P) {
if p0 == p1 || p1 == p2 {
if self.clockwise.is_none() {
self.clockwise = Some(false);
}
return;
}
let t = self.p.thickness;
let inf = Face::new(p0, p1, t);
let out = Face::new(p1, p2, t);
let join_clockwise = Slope::compare(inf.dev_slope, out.dev_slope) < 0;
let poly_clockwise = self.clockwise.unwrap_or(join_clockwise);
let switched = join_clockwise != poly_clockwise;
// `outer_joiner` plots to the outer contour (append) unless the
// direction switched, in which case it plots to the inner (prepend).
let plot_outer = |pl: &mut Plotter, pt: P, to_outer: bool| {
if to_outer {
pl.outer.push_back(pt);
} else {
pl.inner.push_front(pt);
}
};
if Slope::compare(inf.dev_slope, out.dev_slope) == 0 {
plot_outer(
self,
if join_clockwise {
inf.p1_ccw
} else {
inf.p1_cw
},
!switched,
);
plot_outer(
self,
if join_clockwise {
inf.p1_cw
} else {
inf.p1_ccw
},
switched,
);
if self.clockwise.is_none() {
self.clockwise = Some(poly_clockwise);
}
return;
}
match join_mode {
JoinMode::Miter => {
if Slope::compare_for_miter_limit(inf.dev_slope, out.dev_slope, self.p.miter_limit)
{
let pt = inf.intersect(&out, join_clockwise);
plot_outer(self, pt, !switched);
} else {
plot_outer(
self,
if join_clockwise {
inf.p1_ccw
} else {
inf.p1_cw
},
!switched,
);
plot_outer(
self,
if join_clockwise {
out.p0_ccw
} else {
out.p0_cw
},
!switched,
);
}
}
JoinMode::Round => {
plot_outer(
self,
if join_clockwise {
inf.p1_ccw
} else {
inf.p1_cw
},
!switched,
);
let pen = self.pen.take().expect("round joins need a pen");
let mut vit = pen.vertex_iterator_for(inf.dev_slope, out.dev_slope, join_clockwise);
while let Some(v) = vit.next() {
plot_outer(self, (p1.0 + v.point.0, p1.1 + v.point.1), !switched);
}
self.pen = Some(pen);
plot_outer(
self,
if join_clockwise {
out.p0_ccw
} else {
out.p0_cw
},
!switched,
);
}
}
plot_outer(
self,
if join_clockwise {
inf.p1_cw
} else {
inf.p1_ccw
},
switched,
);
plot_outer(self, p1, switched);
plot_outer(
self,
if join_clockwise {
out.p0_cw
} else {
out.p0_ccw
},
switched,
);
if self.clockwise.is_none() {
self.clockwise = Some(poly_clockwise);
}
}
}
fn lerp_half(a: P, b: P) -> P {
(a.0 + (b.0 - a.0) / 2.0, a.1 + (b.1 - a.1) / 2.0)
}
/// z2d `Spline.Knots.errorSq`.
#[allow(clippy::float_cmp)] // exact comparison, like z2d
pub fn knots_error_sq(k: &[P; 4]) -> f64 {
let [a, b, c, d] = *k;
let (mut bx, mut by) = (b.0 - a.0, b.1 - a.1);
let (mut cx, mut cy) = (c.0 - a.0, c.1 - a.1);
if a.0 != d.0 || a.1 != d.1 {
let (dx, dy) = (d.0 - a.0, d.1 - a.1);
let dd = dx * dx + dy * dy;
let bd = bx * dx + by * dy;
if bd >= dd {
bx -= dx;
by -= dy;
} else {
bx -= bd / dd * dx;
by -= bd / dd * dy;
}
let cd = cx * dx + cy * dy;
if cd >= dd {
cx -= dx;
cy -= dy;
} else {
cx -= cd / dd * dx;
cy -= cd / dd * dy;
}
}
let (be, ce) = (bx * bx + by * by, cx * cx + cy * cy);
if be > ce { be } else { ce }
}
/// z2d `Spline.Knots.deCasteljau`: `k` becomes the first half, the second
/// half is returned.
pub fn de_casteljau(k: &mut [P; 4]) -> [P; 4] {
let [a, b, c, d] = *k;
let ab = lerp_half(a, b);
let bc = lerp_half(b, c);
let cd = lerp_half(c, d);
let abbc = lerp_half(ab, bc);
let bccd = lerp_half(bc, cd);
let fin = lerp_half(abbc, bccd);
*k = [a, ab, abbc, fin];
[fin, bccd, cd, d]
}
#[cfg(test)]
mod tests {
use super::*;
fn params() -> StrokeParams {
StrokeParams {
thickness: 2.0,
cap_mode: CapMode::Butt,
miter_limit: 10.0,
tolerance: 0.01,
}
}
// Ports of the z2d `stroke_plotter` tests.
#[test]
fn degenerate_move_line_then_good_line() {
let nodes = [
Node::MoveTo((10.0, 10.0)),
Node::LineTo((10.0, 10.0)),
Node::LineTo((20.0, 20.0)),
];
assert_eq!(plot(&nodes, params()).len(), 4);
let nodes = [
Node::MoveTo((10.0, 10.0)),
Node::LineTo((20.0, 20.0)),
Node::LineTo((20.0, 20.0)),
];
assert_eq!(plot(&nodes, params()).len(), 4);
}
#[test]
fn slope_difference_below_epsilon_is_finite() {
let nodes = [
Node::MoveTo((2.8641015625e2, 1.43154296875e1)),
Node::LineTo((2.822548828125e2, 1.6e1)),
Node::LineTo((2.80990234375e2, 1.65126953125e1)),
];
let edges = plot(&nodes, params());
assert_eq!(edges.len(), 6);
for (a, b) in edges {
assert!(a.0.is_finite() && a.1.is_finite() && b.0.is_finite() && b.1.is_finite());
}
}
#[test]
fn round_cap_dot_and_curve_do_not_panic() {
let mut p = params();
p.cap_mode = CapMode::Round;
let nodes = [Node::MoveTo((5.0, 5.0)), Node::ClosePath];
assert!(plot(&nodes, p).len() > 4);
let nodes = [
Node::MoveTo((0.0, 0.0)),
Node::CurveTo((5.0, 0.0), (10.0, 5.0), (10.0, 10.0)),
];
assert!(plot(&nodes, params()).len() > 6);
}
}