mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Import: type on a closed path keeps its wrapped end bracket
Round a closed path the editing data's text document writes the end of the type's range unwrapped, past the last segment (the start plus the run), or wrapped the other way. The importer clamped it to the path's end, so the type kept only the stretch from its start bracket to the path's end and overflowed. Both brackets are now wrapped onto the loop (non-finite values fall back to the start); an open path is clamped as before. The object's own path that then replaces the frame is written open, ending on its start: such a path is now closed where its ends meet, so the span is measured round the loop. Hand-written tests: an unwrapped, a reversed and a negative range on a closed circle, a range inside it, an exact full turn that fits a short line, an open path still clamped, and a path ending on its start closed.
This commit is contained in:
@@ -373,8 +373,17 @@ impl Texts {
|
||||
let range = carries.get("6").and_then(Val::nums).unwrap_or_default();
|
||||
let segments = segments()?;
|
||||
let n = segments.len() as f64;
|
||||
let start = range.first().copied().unwrap_or(0.0).clamp(0.0, n);
|
||||
let end = range.get(1).copied().unwrap_or(n).clamp(start, n);
|
||||
let finite = |v: Option<&f64>| v.copied().filter(|v| v.is_finite());
|
||||
let (start, end) = if closed(&segments) {
|
||||
// Round a closed path the end is written unwrapped (the start plus the run, so
|
||||
// past the last segment), or wrapped the other way: both brackets are places on
|
||||
// the loop.
|
||||
let start = finite(range.first()).unwrap_or(0.0).rem_euclid(n);
|
||||
(start, finite(range.get(1)).map_or(start, |e| e.rem_euclid(n)))
|
||||
} else {
|
||||
let start = finite(range.first()).unwrap_or(0.0).clamp(0.0, n);
|
||||
(start, finite(range.get(1)).unwrap_or(n).clamp(start, n))
|
||||
};
|
||||
Some(FrameKind::OnPath { segments, start, end })
|
||||
}
|
||||
_ => None,
|
||||
@@ -530,13 +539,18 @@ fn path_of(segments: &[[f64; 8]], template: (f64, f64), to_doc: Affine) -> PathD
|
||||
}
|
||||
bp.curve_to(at(*x1, *y1), at(*x2, *y2), at(*x3, *y3));
|
||||
}
|
||||
let closed = matches!((segments.first(), segments.last()), (Some(f), Some(l)) if (f[0] - l[6]).abs() < 1e-3 && (f[1] - l[7]).abs() < 1e-3);
|
||||
let closed = closed(segments);
|
||||
if closed {
|
||||
bp.close_path();
|
||||
}
|
||||
PathData::from_bezpath(&bp)
|
||||
}
|
||||
|
||||
/// Do `segments` end where they start (a closed path)?
|
||||
fn closed(segments: &[[f64; 8]]) -> bool {
|
||||
matches!((segments.first(), segments.last()), (Some(f), Some(l)) if (f[0] - l[6]).abs() < 1e-3 && (f[1] - l[7]).abs() < 1e-3)
|
||||
}
|
||||
|
||||
/// Where on `segments` the place `t` (segment index and the place in it) is, as a fraction of their
|
||||
/// length.
|
||||
fn length_fraction(segments: &[[f64; 8]], t: f64) -> f64 {
|
||||
@@ -638,7 +652,9 @@ impl Story {
|
||||
FrameKind::Area(s) => (1.0, Affine::IDENTITY, TextKind::Area { frame: path_of(s, template, to_doc) }),
|
||||
FrameKind::OnPath { segments, start, end } => {
|
||||
let n = segments.len() as f64;
|
||||
let end = (*end < n - 0.02).then(|| length_fraction(segments, *end));
|
||||
// A closed path keeps its end bracket wherever it is (the same place as the start: a
|
||||
// full turn, see `TextKind::path_span`); an open one's end at its end is none.
|
||||
let end = (closed(segments) || *end < n - 0.02).then(|| length_fraction(segments, *end));
|
||||
(1.0, Affine::IDENTITY, TextKind::OnPath { path: path_of(segments, template, to_doc), start: length_fraction(segments, *start), end })
|
||||
}
|
||||
};
|
||||
@@ -842,6 +858,84 @@ mod tests {
|
||||
assert!(read(deep.as_bytes()).is_none());
|
||||
}
|
||||
|
||||
/// A text document's frame on a path of `segments` (each `x0 y0 x1 y1 x2 y2 x3 y3`) with the
|
||||
/// range `range`, written by hand.
|
||||
fn on_path_frame(segments: &[[f64; 8]], range: [f64; 2]) -> Option<FrameKind> {
|
||||
let nums = |v: &[f64]| v.iter().map(|n| format!("{n}")).collect::<Vec<_>>().join(" ");
|
||||
let all: Vec<f64> = segments.iter().flatten().copied().collect();
|
||||
let doc = format!("/0 << /1 << /0 [ {} ] >> /2 << /0 2 /6 [ {} ] >> >>", nums(&all), nums(&range));
|
||||
let texts = Texts {
|
||||
stories: vec![],
|
||||
fonts: vec![],
|
||||
defaults: Val::Dict(BTreeMap::new()),
|
||||
para_defaults: Val::Dict(BTreeMap::new()),
|
||||
frames: vec![read(doc.as_bytes()).unwrap()],
|
||||
};
|
||||
texts.frame(0)
|
||||
}
|
||||
|
||||
/// A circle of radius 100 round the origin in four cubics, from its right, closed.
|
||||
fn circle() -> Vec<[f64; 8]> {
|
||||
let k = 55.228475;
|
||||
vec![
|
||||
[100.0, 0.0, 100.0, k, k, 100.0, 0.0, 100.0],
|
||||
[0.0, 100.0, -k, 100.0, -100.0, k, -100.0, 0.0],
|
||||
[-100.0, 0.0, -100.0, -k, -k, -100.0, 0.0, -100.0],
|
||||
[0.0, -100.0, k, -100.0, 100.0, -k, 100.0, 0.0],
|
||||
]
|
||||
}
|
||||
|
||||
/// The text object of type `text` on `frame`, and how much of its path its brackets span.
|
||||
fn on_path(text: &str, frame: FrameKind) -> (TextObject, f64) {
|
||||
let mut st = story(text, &[0.0], &Para::default());
|
||||
st.frames = vec![frame];
|
||||
let node = st.node(NodeId(1), 0, (0.0, 0.0), Affine::IDENTITY).unwrap();
|
||||
let NodeKind::Text(t) = node.kind else { panic!("not type") };
|
||||
let (a, b) = t.kind.path_span().unwrap();
|
||||
(*t, b - a)
|
||||
}
|
||||
|
||||
/// Round a closed path the file writes the end bracket unwrapped, past the last segment (the
|
||||
/// start plus the run): the type keeps that run, nearly the whole circle here, not the bit
|
||||
/// from the start to the path's end.
|
||||
#[test]
|
||||
fn type_on_a_closed_path_keeps_its_unwrapped_end() {
|
||||
let Some(FrameKind::OnPath { start, end, .. }) = on_path_frame(&circle(), [3.86426, 7.85426]) else { panic!("on a path") };
|
||||
assert!((start - 3.86426).abs() < 1e-9 && (end - 3.85426).abs() < 1e-9, "{start} {end}");
|
||||
let (t, span) = on_path("type", on_path_frame(&circle(), [3.86426, 7.85426]).unwrap());
|
||||
assert!((span - 0.9975).abs() < 1e-3, "{span}");
|
||||
assert_eq!(t.plain_text(), "type");
|
||||
// Wrapped the other way, or below zero, the brackets are the same places.
|
||||
for range in [[3.86426, 3.85426], [-0.13574, 3.85426]] {
|
||||
let (_, other) = on_path("type", on_path_frame(&circle(), range).unwrap());
|
||||
assert!((other - span).abs() < 1e-6, "{range:?}: {other}");
|
||||
}
|
||||
// Values that aren't numbers fall back to the start of the path, a full turn.
|
||||
let Some(FrameKind::OnPath { start, end, .. }) = on_path_frame(&circle(), [f64::NAN, f64::INFINITY]) else { panic!("on a path") };
|
||||
assert!(start.is_finite() && end.is_finite());
|
||||
}
|
||||
|
||||
/// A range inside the loop is kept as it is, and a run of exactly the loop is a full turn,
|
||||
/// where a short line of type doesn't overflow.
|
||||
#[test]
|
||||
fn type_on_a_closed_path_keeps_a_range_inside_it_and_a_full_turn() {
|
||||
let (_, half) = on_path("type", on_path_frame(&circle(), [0.5, 2.5]).unwrap());
|
||||
assert!((half - 0.5).abs() < 1e-3, "{half}");
|
||||
let (t, full) = on_path("type", on_path_frame(&circle(), [1.0, 5.0]).unwrap());
|
||||
assert!((full - 1.0).abs() < 1e-9, "{full}");
|
||||
assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "a short line fits a full turn");
|
||||
}
|
||||
|
||||
/// An open path keeps clamping: a range past its end stops at it.
|
||||
#[test]
|
||||
fn type_on_an_open_path_clamps_its_range() {
|
||||
let open: Vec<[f64; 8]> = circle().into_iter().take(2).collect();
|
||||
let Some(FrameKind::OnPath { start, end, .. }) = on_path_frame(&open, [0.5, 7.0]) else { panic!("on a path") };
|
||||
assert_eq!((start, end), (0.5, 2.0));
|
||||
let (_, span) = on_path("type", on_path_frame(&open, [0.5, 7.0]).unwrap());
|
||||
assert!((span - 0.75).abs() < 1e-3, "{span}");
|
||||
}
|
||||
|
||||
/// A point-type story of `text` whose lines start at the heights `ys` (the layout's own: where
|
||||
/// the paragraph spacing has put them), every paragraph styled `para`.
|
||||
fn story(text: &str, ys: &[f64], para: &Para) -> Story {
|
||||
|
||||
@@ -556,7 +556,7 @@ fn plan(
|
||||
region = path.bounds();
|
||||
}
|
||||
TextKind::OnPath { path: on, .. } => {
|
||||
*on = path.clone();
|
||||
*on = closed_where_ends_meet(path.clone());
|
||||
region = path.bounds().map(|r| r.inflate(size * 1.5, size * 1.5));
|
||||
}
|
||||
TextKind::Point => {}
|
||||
@@ -1651,3 +1651,48 @@ mod tests {
|
||||
assert!(view.difference(&vector, shifted).any > MAX_DIFFERENCE, "wrong framing remains an error");
|
||||
}
|
||||
}
|
||||
|
||||
/// `path` with each open subpath whose last anchor lies on its first (within a thousandth of a
|
||||
/// point) closed there: the object's own path of type round a closed path is written open, ending
|
||||
/// on its start, and type on an open path stops at its end, so it had no room to run past it.
|
||||
fn closed_where_ends_meet(mut path: PathData) -> PathData {
|
||||
for sp in &mut path.subpaths {
|
||||
let (Some(first), Some(last)) = (sp.anchors.first(), sp.anchors.last()) else { continue };
|
||||
if sp.closed || sp.anchors.len() < 3 || first.p.distance(last.p) >= 1e-3 {
|
||||
continue;
|
||||
}
|
||||
if let Some(l) = sp.anchors.pop()
|
||||
&& let Some(a0) = sp.anchors.first_mut()
|
||||
{
|
||||
a0.h_in = l.h_in;
|
||||
}
|
||||
sp.closed = true;
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod closed_ends_tests {
|
||||
use super::*;
|
||||
|
||||
/// A path drawn round to its start (the last anchor on the first) is closed there, its first
|
||||
/// anchor taking the last one's incoming handle; one whose ends are apart stays open.
|
||||
#[test]
|
||||
fn a_path_ending_on_its_start_is_closed() {
|
||||
let mut bp = kurbo::BezPath::new();
|
||||
bp.move_to((100.0, 0.0));
|
||||
bp.curve_to((100.0, 55.0), (55.0, 100.0), (0.0, 100.0));
|
||||
bp.curve_to((-55.0, 100.0), (-100.0, 55.0), (-100.0, 0.0));
|
||||
bp.curve_to((-100.0, -55.0), (-55.0, -100.0), (0.0, -100.0));
|
||||
bp.curve_to((55.0, -100.0), (100.0, -55.0), (100.0, 0.0001));
|
||||
let closed = closed_where_ends_meet(PathData::from_bezpath(&bp));
|
||||
let sp = &closed.subpaths[0];
|
||||
assert!(sp.closed && sp.anchors.len() == 4, "{sp:?}");
|
||||
assert_eq!(sp.anchors[0].h_in, Point::new(100.0, -55.0));
|
||||
let mut open = kurbo::BezPath::new();
|
||||
open.move_to((0.0, 0.0));
|
||||
open.line_to((50.0, 0.0));
|
||||
open.line_to((50.0, 50.0));
|
||||
assert!(!closed_where_ends_meet(PathData::from_bezpath(&open)).subpaths[0].closed);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user