182 lines
6.8 KiB
Rust
182 lines
6.8 KiB
Rust
//! Text on a path: arc-length parameterisation of a (mask) path and character placement along it
|
|
//! (Path Options: Reverse Path, Perpendicular To Path, Force Alignment, First / Last Margin).
|
|
|
|
use kurbo::{BezPath, PathEl, Point, Vec2};
|
|
|
|
/// A flattened path measured by arc length.
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct PathMeasure {
|
|
pts: Vec<Point>,
|
|
/// Cumulative length at each point.
|
|
cum: Vec<f64>,
|
|
pub closed: bool,
|
|
}
|
|
|
|
impl PathMeasure {
|
|
/// Measure the first subpath of `path` (reversed when `reverse`).
|
|
pub fn new(path: &BezPath, reverse: bool) -> PathMeasure {
|
|
let mut pts: Vec<Point> = Vec::new();
|
|
let mut closed = false;
|
|
let mut started = false;
|
|
let mut done = false;
|
|
kurbo::flatten(path.iter(), 0.05, |el| {
|
|
if done {
|
|
return;
|
|
}
|
|
match el {
|
|
PathEl::MoveTo(p) => {
|
|
if started {
|
|
done = true;
|
|
} else {
|
|
started = true;
|
|
pts.push(p);
|
|
}
|
|
}
|
|
PathEl::LineTo(p) => {
|
|
if pts.last().is_none_or(|q| q.distance(p) > 1e-9) {
|
|
pts.push(p);
|
|
}
|
|
}
|
|
PathEl::ClosePath => {
|
|
closed = true;
|
|
if let (Some(&a), Some(&b)) = (pts.first(), pts.last())
|
|
&& a.distance(b) > 1e-9
|
|
{
|
|
pts.push(a);
|
|
}
|
|
done = true;
|
|
}
|
|
_ => {}
|
|
}
|
|
});
|
|
if reverse {
|
|
pts.reverse();
|
|
}
|
|
let mut cum = Vec::with_capacity(pts.len());
|
|
let mut acc = 0.0;
|
|
for (i, p) in pts.iter().enumerate() {
|
|
if i > 0 {
|
|
acc += pts[i - 1].distance(*p);
|
|
}
|
|
cum.push(acc);
|
|
}
|
|
PathMeasure { pts, cum, closed }
|
|
}
|
|
|
|
pub fn length(&self) -> f64 {
|
|
self.cum.last().copied().unwrap_or(0.0)
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.pts.len() < 2 || self.length() <= 1e-9
|
|
}
|
|
|
|
/// Point and unit tangent at arc length `s`. Closed paths wrap; open paths continue straight
|
|
/// along the end tangents beyond either end.
|
|
pub fn at(&self, s: f64) -> (Point, Vec2) {
|
|
if self.is_empty() {
|
|
return (self.pts.first().copied().unwrap_or(Point::ZERO), Vec2::new(1.0, 0.0));
|
|
}
|
|
let len = self.length();
|
|
let s = if self.closed { s.rem_euclid(len) } else { s };
|
|
let k = match self.cum.binary_search_by(|c| c.total_cmp(&s)) {
|
|
Ok(i) => i.min(self.pts.len() - 2),
|
|
Err(i) => i.clamp(1, self.pts.len() - 1) - 1,
|
|
};
|
|
let (a, b) = (self.pts[k], self.pts[k + 1]);
|
|
let seg = self.cum[k + 1] - self.cum[k];
|
|
let d = b - a;
|
|
let tan = if seg > 0.0 { d / seg } else { Vec2::new(1.0, 0.0) };
|
|
(a + tan * (s - self.cum[k]), tan)
|
|
}
|
|
}
|
|
|
|
/// Where a character pivot lands on the path: the point and the tangent angle in degrees.
|
|
pub fn place(pm: &PathMeasure, s: f64, baseline_offset: f64, perpendicular: bool) -> (Point, f64) {
|
|
let (p, t) = pm.at(s);
|
|
// y down: the normal pointing "below" the baseline is the tangent turned clockwise.
|
|
let n = Vec2::new(-t.y, t.x);
|
|
let angle = if perpendicular { t.y.atan2(t.x).to_degrees() } else { 0.0 };
|
|
let off = if perpendicular { n * baseline_offset } else { Vec2::new(0.0, baseline_offset) };
|
|
(p + off, angle)
|
|
}
|
|
|
|
/// Arc-length positions for character pivots of one line, given each pivot's x along the
|
|
/// line, the line's [x0, x1] extent and alignment (0 = left, 1 = centre, 2 = right).
|
|
pub fn arc_positions(xs: &[f64], line: [f64; 2], align: u8, length: f64, first_margin: f64, last_margin: f64, force: bool) -> Vec<f64> {
|
|
if xs.is_empty() {
|
|
return vec![];
|
|
}
|
|
if force {
|
|
let (a, b) = (first_margin, length - last_margin);
|
|
let (x0, x1) = (xs[0], xs[xs.len() - 1]);
|
|
if (x1 - x0).abs() < 1e-9 {
|
|
return vec![a; xs.len()];
|
|
}
|
|
return xs.iter().map(|x| a + (b - a) * (x - x0) / (x1 - x0)).collect();
|
|
}
|
|
match align {
|
|
1 => {
|
|
let mid = (first_margin + length - last_margin) * 0.5;
|
|
let c = (line[0] + line[1]) * 0.5;
|
|
xs.iter().map(|x| mid + x - c).collect()
|
|
}
|
|
2 => xs.iter().map(|x| length - last_margin - (line[1] - x)).collect(),
|
|
_ => xs.iter().map(|x| first_margin + x - line[0]).collect(),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn measures_a_line_and_a_circle() {
|
|
let mut p = BezPath::new();
|
|
p.move_to((0.0, 0.0));
|
|
p.line_to((100.0, 0.0));
|
|
p.line_to((100.0, 50.0));
|
|
let m = PathMeasure::new(&p, false);
|
|
assert!((m.length() - 150.0).abs() < 1e-9);
|
|
let (q, t) = m.at(120.0);
|
|
assert!((q.x - 100.0).abs() < 1e-9 && (q.y - 20.0).abs() < 1e-9);
|
|
assert!((t.y - 1.0).abs() < 1e-9);
|
|
// Past the end: straight along the end tangent.
|
|
let (q, _) = m.at(160.0);
|
|
assert!((q.y - 60.0).abs() < 1e-9);
|
|
let r = PathMeasure::new(&p, true);
|
|
assert!((r.at(0.0).0.y - 50.0).abs() < 1e-9);
|
|
|
|
let c = kurbo::Circle::new((0.0, 0.0), 100.0);
|
|
let path = kurbo::Shape::to_path(&c, 0.01);
|
|
let m = PathMeasure::new(&path, false);
|
|
assert!(m.closed);
|
|
assert!((m.length() - 2.0 * std::f64::consts::PI * 100.0).abs() < 0.5, "{}", m.length());
|
|
// Every point is on the circle; wraps.
|
|
for k in 0..10 {
|
|
let (q, _) = m.at(k as f64 * 97.0);
|
|
assert!((q.to_vec2().hypot() - 100.0).abs() < 0.05);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn placement_perpendicular_and_margins() {
|
|
let mut p = BezPath::new();
|
|
p.move_to((0.0, 100.0));
|
|
p.line_to((0.0, 0.0)); // straight up
|
|
let m = PathMeasure::new(&p, false);
|
|
let (q, a) = place(&m, 30.0, 0.0, true);
|
|
assert!((q.y - 70.0).abs() < 1e-9 && (a + 90.0).abs() < 1e-9);
|
|
// Baseline offset below the path (y down) turns with the path: to the right of travel... up = -y → normal = (1, 0).
|
|
let (q, _) = place(&m, 30.0, 10.0, true);
|
|
assert!((q.x - 10.0).abs() < 1e-9, "{q:?}");
|
|
let (q, a) = place(&m, 30.0, 10.0, false);
|
|
assert!(a == 0.0 && (q.y - 80.0).abs() < 1e-9);
|
|
let xs = [0.0, 10.0, 20.0];
|
|
assert_eq!(arc_positions(&xs, [0.0, 20.0], 0, 100.0, 5.0, 0.0, false), vec![5.0, 15.0, 25.0]);
|
|
assert_eq!(arc_positions(&xs, [0.0, 20.0], 2, 100.0, 0.0, 10.0, false), vec![70.0, 80.0, 90.0]);
|
|
assert_eq!(arc_positions(&xs, [0.0, 20.0], 1, 100.0, 0.0, 0.0, false), vec![40.0, 50.0, 60.0]);
|
|
assert_eq!(arc_positions(&xs, [0.0, 20.0], 0, 100.0, 10.0, 10.0, true), vec![10.0, 50.0, 90.0]);
|
|
}
|
|
}
|