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:
BFlat
2026-10-23 02:07:07 +07:00
committed by GitHub
2 changed files with 144 additions and 5 deletions
+98 -4
View File
@@ -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 {
+46 -1
View File
@@ -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);
}
}