mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 02:37:01 -06:00
Type on a path could only be repositioned by Type on a Path Options, whose menu item sent
`{start: 0}`, so OK threw the text to the start of its path; nothing showed or dragged its
brackets. Now, as in Illustrator:
- Type on a path flows between a start and an end bracket (`TextKind::OnPath { end }`, None:
the end of the path or once round a closed one; `TextKind::path_span`). Flipping
(`TextObject::flip_on_path`) reverses the path keeping a closed path's first anchor and swaps
the brackets, so the type keeps its stretch of the path.
- Type on a Path Options gains Align to Path (ascender, descender, center, baseline) and
Spacing (glyphs spaced as if set that far above the path: closed up round the outside of
curves, opened up round the inside), laid out by the text engine.
- `type.pathOptions {start?, end?, flip?, effect?, alignToPath?, spacing?, ids?}` sets them in
one undo step; with none of them it queries. The menu item opens the dialog with the selected
type's Effect, Flip, Align to Path and Spacing (dropdowns for the choices) and leaves the
brackets alone, so OK as opened changes nothing.
- The Selection and Direct Selection tools show selected type on a path's path and its start,
centre and end brackets (`tools::pathtype`, with a bracket cursor): dragging the start or end
bracket sets where the type runs, dragging the centre bracket slides it along the path and,
across the path, flips it (Cmd/Ctrl held: it only slides). Each drag is one undo step.
- Arc-length measuring moved to `vectorcraft_geom::ArcPath` (layout, the tools and
`text.createInPath` share it). SVG export outlines path type whose end bracket, alignment or
spacing `<textPath>` can't express.
- docs/mcp.md documents the command and the drags; ROADMAP updated; dialog strings translated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
102 lines
3.6 KiB
Rust
102 lines
3.6 KiB
Rust
//! Arc-length parametrization of a path: the point and direction at a distance along it, and the
|
|
//! distance along it of the point nearest another (type on a path lays its glyphs out and the
|
|
//! selection tools drag its brackets by distance along the path).
|
|
|
|
use kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest, PathSeg, Point, Vec2};
|
|
|
|
use crate::PathData;
|
|
|
|
const ACCURACY: f64 = 1e-4;
|
|
|
|
/// A path's segments with their lengths, measured once.
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct ArcPath {
|
|
/// Segments of some length, each with the distance to its start and its length.
|
|
segs: Vec<(PathSeg, f64, f64)>,
|
|
len: f64,
|
|
closed: bool,
|
|
}
|
|
|
|
impl ArcPath {
|
|
pub fn new(path: &PathData) -> Self {
|
|
let mut segs = Vec::new();
|
|
let mut cum = 0.0;
|
|
for s in path.to_bezpath().segments() {
|
|
let l = s.arclen(ACCURACY);
|
|
if l.is_finite() && l > 1e-9 {
|
|
segs.push((s, cum, l));
|
|
cum += l;
|
|
}
|
|
}
|
|
Self { segs, len: cum, closed: path.is_closed() }
|
|
}
|
|
|
|
/// The path's length.
|
|
pub fn len(&self) -> f64 {
|
|
self.len
|
|
}
|
|
|
|
/// No segment has any length.
|
|
pub fn is_empty(&self) -> bool {
|
|
self.segs.is_empty()
|
|
}
|
|
|
|
pub fn is_closed(&self) -> bool {
|
|
self.closed
|
|
}
|
|
|
|
/// Point and unit tangent at distance `s` along the path: round and round a closed path, held
|
|
/// at an open one's ends.
|
|
pub fn at(&self, s: f64) -> (Point, Vec2) {
|
|
let s = if self.closed && self.len > 0.0 { s.rem_euclid(self.len) } else { s.clamp(0.0, self.len) };
|
|
let i = self.segs.partition_point(|(_, c, _)| *c <= s).saturating_sub(1);
|
|
let Some(&(seg, c, l)) = self.segs.get(i) else { return (Point::ZERO, Vec2::new(1.0, 0.0)) };
|
|
let t = seg.inv_arclen((s - c).min(l), ACCURACY).clamp(0.0, 1.0);
|
|
let p = seg.eval(t);
|
|
let (t0, t1) = ((t - 1e-4).max(0.0), (t + 1e-4).min(1.0));
|
|
let d = seg.eval(t1) - seg.eval(t0);
|
|
let len = d.hypot();
|
|
(p, if len > 1e-12 { d / len } else { Vec2::new(1.0, 0.0) })
|
|
}
|
|
|
|
/// The distance along the path of its point nearest `p`.
|
|
pub fn nearest(&self, p: Point) -> Option<f64> {
|
|
self.segs
|
|
.iter()
|
|
.map(|(seg, c, l)| {
|
|
let n = seg.nearest(p, ACCURACY);
|
|
(n.distance_sq, c + seg.subsegment(0.0..n.t).arclen(ACCURACY).min(*l))
|
|
})
|
|
.min_by(|a, b| a.0.total_cmp(&b.0))
|
|
.map(|(_, s)| s)
|
|
}
|
|
|
|
/// [`Self::nearest`] as a fraction (0..1) of the path's length.
|
|
pub fn fraction_at(&self, p: Point) -> Option<f64> {
|
|
(self.len > 0.0).then(|| self.nearest(p)).flatten().map(|s| (s / self.len).clamp(0.0, 1.0))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::shapes;
|
|
use kurbo::{Rect, Shape};
|
|
|
|
#[test]
|
|
fn points_and_nearest_distances_along_a_path() {
|
|
let line = PathData::from_bezpath(&kurbo::Line::new((0.0, 0.0), (100.0, 0.0)).to_path(0.1));
|
|
let a = ArcPath::new(&line);
|
|
assert!((a.len() - 100.0).abs() < 1e-6 && !a.is_closed());
|
|
assert!(a.at(25.0).0.distance(Point::new(25.0, 0.0)) < 1e-6);
|
|
// Held at an open path's ends.
|
|
assert!(a.at(150.0).0.distance(Point::new(100.0, 0.0)) < 1e-6);
|
|
assert!((a.nearest(Point::new(40.0, 30.0)).unwrap() - 40.0).abs() < 1e-6);
|
|
// Round a closed one.
|
|
let sq = ArcPath::new(&shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)));
|
|
assert!(sq.is_closed() && (sq.len() - 40.0).abs() < 1e-6);
|
|
assert!(sq.at(45.0).0.distance(sq.at(5.0).0) < 1e-6);
|
|
assert!(ArcPath::new(&PathData::default()).is_empty());
|
|
}
|
|
}
|