mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 10:17:02 -06:00
Merge pull request #3685 from ham9zah/fix/ai-closed-path-type-range
Import: type on a closed path keeps its wrapped end bracket
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