mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-15 14:52:35 +00:00
317 lines
13 KiB
Rust
317 lines
13 KiB
Rust
//! Convert the simple vector network used by text-on-path layers in `.fig` files into a native text path.
|
|
|
|
use figcraft_doc::{
|
|
TextPath,
|
|
geom::{
|
|
BezPath,
|
|
kurbo::{CubicBez, Line, ParamCurve, ParamCurveArclen, Point},
|
|
},
|
|
};
|
|
|
|
use super::network::{Network, Segment};
|
|
|
|
const START_SEGMENT_STYLE: u32 = 0x01ff_ffff;
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
struct Use {
|
|
segment: usize,
|
|
forward: bool,
|
|
}
|
|
|
|
/// Build a native path from a validated connected chain or cycle. The marked source segment is
|
|
/// oriented a→b; `t` is local to that line/cubic. Coordinates are scaled into layer space.
|
|
pub(super) fn from_network(net: &Network, sx: f64, sy: f64, t: f64, forward: bool) -> Result<TextPath, &'static str> {
|
|
if !t.is_finite() || !(0.0..=1.0).contains(&t) {
|
|
return Err("invalid start parameter");
|
|
}
|
|
if !sx.is_finite() || !sy.is_finite() || sx <= 0.0 || sy <= 0.0 {
|
|
return Err("invalid path scale");
|
|
}
|
|
let markers: Vec<usize> = net.segments.iter().enumerate().filter_map(|(i, s)| (s.style == START_SEGMENT_STYLE).then_some(i)).collect();
|
|
let [marked] = markers.as_slice() else {
|
|
return Err("missing or ambiguous start segment marker");
|
|
};
|
|
|
|
let (uses, closed) = ordered_chain(net, *marked)?;
|
|
let mut d = BezPath::new();
|
|
let mut total = 0.0;
|
|
let mut prefix = 0.0;
|
|
let mut start_segment_length = 0.0;
|
|
for use_ in &uses {
|
|
let (p0, p1, p2, p3) = cubic(net, *use_, sx, sy)?;
|
|
if d.elements().is_empty() {
|
|
d.move_to((p0.0, p0.1));
|
|
}
|
|
let line = p1 == p0 && p2 == p3;
|
|
let (length, partial) = if line {
|
|
d.line_to((p3.0, p3.1));
|
|
let line = Line::new(Point::new(p0.0, p0.1), Point::new(p3.0, p3.1));
|
|
(line.arclen(1e-9), line.arclen(1e-9) * t)
|
|
} else {
|
|
d.curve_to((p1.0, p1.1), (p2.0, p2.1), (p3.0, p3.1));
|
|
let curve = CubicBez::new((p0.0, p0.1), (p1.0, p1.1), (p2.0, p2.1), (p3.0, p3.1));
|
|
let partial = if t <= 0.0 { 0.0 } else { curve.subsegment(0.0..t).arclen(1e-9) };
|
|
(curve.arclen(1e-9), partial)
|
|
};
|
|
if !length.is_finite() || length <= 1e-10 {
|
|
return Err("zero-length path segment");
|
|
}
|
|
if use_.segment == *marked {
|
|
start_segment_length = partial;
|
|
if !start_segment_length.is_finite() {
|
|
return Err("invalid path length");
|
|
}
|
|
prefix = total;
|
|
}
|
|
total += length;
|
|
}
|
|
if closed {
|
|
d.close_path();
|
|
}
|
|
if !total.is_finite() || total <= 1e-10 {
|
|
return Err("zero-length path");
|
|
}
|
|
let mut start = ((prefix + start_segment_length) / total).clamp(0.0, 1.0);
|
|
let flip = !forward;
|
|
if flip {
|
|
start = 1.0 - start;
|
|
}
|
|
let path = TextPath { d: d.to_svg(), start, flip };
|
|
let lines = figcraft_doc::geom::measure::polylines(&path.path()).ok_or("cannot be measured by text renderer")?;
|
|
if lines.first().is_none_or(|line| line.length() <= 1e-9) {
|
|
return Err("cannot be measured by text renderer");
|
|
}
|
|
Ok(path)
|
|
}
|
|
|
|
/// Return every segment in chain order. Open chains are oriented so the marked segment runs a→b;
|
|
/// cycles start at the marked segment's a vertex and traverse it first.
|
|
fn ordered_chain(net: &Network, marked: usize) -> Result<(Vec<Use>, bool), &'static str> {
|
|
let marker = net.segments.get(marked).ok_or("invalid start segment index")?;
|
|
let mut adjacency = vec![Vec::<usize>::new(); net.vertices.len()];
|
|
for (i, s) in net.segments.iter().enumerate() {
|
|
if s.a >= adjacency.len() || s.b >= adjacency.len() {
|
|
return Err("invalid path vertex index");
|
|
}
|
|
if s.a == s.b {
|
|
return Err("path contains a segment that starts and ends at the same vertex");
|
|
}
|
|
adjacency[s.a].push(i);
|
|
adjacency[s.b].push(i);
|
|
}
|
|
let mut endpoints = Vec::new();
|
|
for edges in &adjacency {
|
|
if edges.len() > 2 {
|
|
return Err("branched path network");
|
|
}
|
|
if edges.len() == 1 {
|
|
endpoints.push(edges[0]);
|
|
}
|
|
}
|
|
if !endpoints.is_empty() && endpoints.len() != 2 {
|
|
return Err("disconnected or malformed path network");
|
|
}
|
|
let cycle = endpoints.is_empty();
|
|
let mut used = vec![false; net.segments.len()];
|
|
let mut out = Vec::with_capacity(net.segments.len());
|
|
if cycle {
|
|
out.push(Use { segment: marked, forward: true });
|
|
used[marked] = true;
|
|
let mut at = marker.b;
|
|
let mut visited = vec![false; net.vertices.len()];
|
|
visited[marker.a] = true;
|
|
visited[marker.b] = true;
|
|
while at != marker.a {
|
|
let next = adjacency[at].iter().copied().find(|i| !used[*i]).ok_or("disconnected path cycle")?;
|
|
used[next] = true;
|
|
let s = &net.segments[next];
|
|
let is_forward = s.a == at;
|
|
at = if is_forward { s.b } else { s.a };
|
|
if at != marker.a && std::mem::replace(&mut visited[at], true) {
|
|
return Err("path cycle is not simple");
|
|
}
|
|
out.push(Use { segment: next, forward: is_forward });
|
|
if out.len() > net.segments.len() {
|
|
return Err("invalid path cycle");
|
|
}
|
|
}
|
|
} else {
|
|
let endpoint = adjacency.iter().position(|e| e.len() == 1).ok_or("missing path endpoint")?;
|
|
let mut at = endpoint;
|
|
while let Some(next) = adjacency[at].iter().copied().find(|i| !used[*i]) {
|
|
used[next] = true;
|
|
let s = &net.segments[next];
|
|
let is_forward = s.a == at;
|
|
at = if is_forward { s.b } else { s.a };
|
|
out.push(Use { segment: next, forward: is_forward });
|
|
}
|
|
let Some(marker_use) = out.iter().find(|u| u.segment == marked) else { return Err("disconnected start segment") };
|
|
if !marker_use.forward {
|
|
out.reverse();
|
|
for u in &mut out {
|
|
u.forward = !u.forward;
|
|
}
|
|
}
|
|
}
|
|
if !used.iter().all(|x| *x) {
|
|
return Err("disconnected path network");
|
|
}
|
|
Ok((out, cycle))
|
|
}
|
|
|
|
type P = (f64, f64);
|
|
|
|
fn cubic(net: &Network, use_: Use, sx: f64, sy: f64) -> Result<(P, P, P, P), &'static str> {
|
|
let s: &Segment = net.segments.get(use_.segment).ok_or("invalid path segment index")?;
|
|
let a = net.vertices.get(s.a).ok_or("invalid path vertex index")?;
|
|
let b = net.vertices.get(s.b).ok_or("invalid path vertex index")?;
|
|
let p0 = (a.x * sx, a.y * sy);
|
|
let p3 = (b.x * sx, b.y * sy);
|
|
let ta = (s.ta.0 * sx, s.ta.1 * sy);
|
|
let tb = (s.tb.0 * sx, s.tb.1 * sy);
|
|
if [p0.0, p0.1, p3.0, p3.1, ta.0, ta.1, tb.0, tb.1].iter().any(|x| !x.is_finite()) {
|
|
return Err("invalid path coordinates");
|
|
}
|
|
let (p0, p1, p2, p3) =
|
|
if ta == (0.0, 0.0) && tb == (0.0, 0.0) { (p0, p0, p3, p3) } else { (p0, (p0.0 + ta.0, p0.1 + ta.1), (p3.0 + tb.0, p3.1 + tb.1), p3) };
|
|
if use_.forward { Ok((p0, p1, p2, p3)) } else { Ok((p3, p2, p1, p0)) }
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::fig::network::{Segment, Vertex};
|
|
use figcraft_doc::geom::kurbo::PathEl;
|
|
|
|
fn vertex(x: f64, y: f64) -> Vertex {
|
|
Vertex { style: 0, x, y }
|
|
}
|
|
|
|
fn segment(style: u32, a: usize, b: usize, ta: (f64, f64), tb: (f64, f64)) -> Segment {
|
|
Segment { style, a, ta, b, tb }
|
|
}
|
|
|
|
fn curved() -> Network {
|
|
Network {
|
|
vertices: vec![vertex(0.0, 82.5), vertex(300.0, 82.5), vertex(600.0, 82.5)],
|
|
segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (70.0, -110.0), (-70.0, -110.0)), segment(0, 1, 2, (70.0, 110.0), (-70.0, 110.0))],
|
|
..Network::default()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn local_cubic_parameter_converts_to_path_distance() {
|
|
let path = from_network(&curved(), 1.0, 1.0, 0.0086954795, true).unwrap();
|
|
assert!((path.start - 0.004744078).abs() < 0.00001);
|
|
// Cubic t is not its fraction of total path length.
|
|
assert!((path.start - 0.0086954795).abs() > 0.003);
|
|
let elements = path.path().elements().to_vec();
|
|
assert!(matches!(elements.first(), Some(PathEl::MoveTo(p)) if p.x == 0.0 && p.y == 82.5));
|
|
assert!(matches!(elements.get(1), Some(PathEl::CurveTo(..))));
|
|
}
|
|
|
|
#[test]
|
|
fn second_segment_marker_and_t_are_respected() {
|
|
let mut net = curved();
|
|
net.segments[0].style = 0;
|
|
net.segments[1].style = START_SEGMENT_STYLE;
|
|
let path = from_network(&net, 1.0, 1.0, 0.49800667, true).unwrap();
|
|
assert!((path.start - 0.7490379757).abs() < 0.00002);
|
|
}
|
|
|
|
#[test]
|
|
fn reverse_direction_inverts_normalized_start() {
|
|
let net = Network {
|
|
vertices: vec![vertex(0.0, 0.0), vertex(600.0, 0.0)],
|
|
segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))],
|
|
..Network::default()
|
|
};
|
|
let path = from_network(&net, 1.0, 1.0, 0.83986014, false).unwrap();
|
|
assert!(path.flip);
|
|
assert!((path.start - 0.16013986).abs() < 1e-8);
|
|
assert!(matches!(path.path().elements().get(1), Some(PathEl::LineTo(p)) if p.x == 600.0 && p.y == 0.0));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_missing_duplicate_and_zero_length_segments() {
|
|
let mut net = curved();
|
|
net.segments[0].style = 0;
|
|
assert_eq!(from_network(&net, 1.0, 1.0, 0.5, true), Err("missing or ambiguous start segment marker"));
|
|
net.segments[1].style = START_SEGMENT_STYLE;
|
|
net.segments[0].style = START_SEGMENT_STYLE;
|
|
assert_eq!(from_network(&net, 1.0, 1.0, 0.5, true), Err("missing or ambiguous start segment marker"));
|
|
let zero = Network {
|
|
vertices: vec![vertex(0.0, 0.0), vertex(0.0, 0.0)],
|
|
segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))],
|
|
..Network::default()
|
|
};
|
|
assert_eq!(from_network(&zero, 1.0, 1.0, 0.5, true), Err("zero-length path segment"));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_branching_and_disconnected_segments() {
|
|
let branch = Network {
|
|
vertices: vec![vertex(0.0, 0.0), vertex(1.0, 0.0), vertex(2.0, 0.0), vertex(1.0, 1.0)],
|
|
segments: vec![
|
|
segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0)),
|
|
segment(0, 1, 2, (0.0, 0.0), (0.0, 0.0)),
|
|
segment(0, 1, 3, (0.0, 0.0), (0.0, 0.0)),
|
|
],
|
|
..Network::default()
|
|
};
|
|
assert_eq!(from_network(&branch, 1.0, 1.0, 0.5, true), Err("branched path network"));
|
|
let disconnected = Network {
|
|
vertices: vec![vertex(0.0, 0.0), vertex(1.0, 0.0), vertex(2.0, 0.0), vertex(3.0, 0.0)],
|
|
segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0)), segment(0, 2, 3, (0.0, 0.0), (0.0, 0.0))],
|
|
..Network::default()
|
|
};
|
|
assert_eq!(from_network(&disconnected, 1.0, 1.0, 0.5, true), Err("disconnected or malformed path network"));
|
|
}
|
|
|
|
#[test]
|
|
fn scales_geometry_and_accepts_closed_cycles() {
|
|
let cycle = Network {
|
|
vertices: vec![vertex(0.0, 0.0), vertex(100.0, 0.0), vertex(100.0, 100.0)],
|
|
segments: vec![
|
|
segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0)),
|
|
segment(0, 1, 2, (0.0, 0.0), (0.0, 0.0)),
|
|
segment(0, 2, 0, (0.0, 0.0), (0.0, 0.0)),
|
|
],
|
|
..Network::default()
|
|
};
|
|
let path = from_network(&cycle, 2.0, 3.0, 0.5, true).unwrap();
|
|
let elements = path.path().elements().to_vec();
|
|
assert!(matches!(elements.first(), Some(PathEl::MoveTo(p)) if p.x == 0.0 && p.y == 0.0));
|
|
assert!(matches!(elements.get(1), Some(PathEl::LineTo(p)) if p.x == 200.0 && p.y == 0.0));
|
|
let total = 200.0 + 300.0 + 130_000.0_f64.sqrt();
|
|
assert!((path.start - 100.0 / total).abs() < 1e-8);
|
|
assert!(matches!(elements.last(), Some(PathEl::ClosePath)));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_paths_the_native_renderer_cannot_measure() {
|
|
let count = 40_000usize;
|
|
let many_lines = Network {
|
|
vertices: (0..=count).map(|i| vertex(i as f64, 0.0)).collect(),
|
|
segments: (0..count).map(|i| segment(if i == 0 { START_SEGMENT_STYLE } else { 0 }, i, i + 1, (0.0, 0.0), (0.0, 0.0))).collect(),
|
|
..Network::default()
|
|
};
|
|
assert_eq!(from_network(&many_lines, 1.0, 1.0, 0.5, true), Err("cannot be measured by text renderer"));
|
|
|
|
// Past the cap the builders hold their sizes to (`figcraft_geom::MAX_COORD`, inclusive).
|
|
let huge = Network {
|
|
vertices: vec![vertex(0.0, 0.0), vertex(2e9, 0.0)],
|
|
segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))],
|
|
..Network::default()
|
|
};
|
|
assert_eq!(from_network(&huge, 1.0, 1.0, 0.5, true), Err("cannot be measured by text renderer"));
|
|
|
|
let tiny = Network {
|
|
vertices: vec![vertex(0.0, 0.0), vertex(5e-10, 0.0)],
|
|
segments: vec![segment(START_SEGMENT_STYLE, 0, 1, (0.0, 0.0), (0.0, 0.0))],
|
|
..Network::default()
|
|
};
|
|
assert_eq!(from_network(&tiny, 1.0, 1.0, 0.5, true), Err("cannot be measured by text renderer"));
|
|
}
|
|
}
|