Type Object Selection by Path Only: every baseline, Type tool and Eyedropper (review of #475)

- Type is picked on the baseline of any of its lines (point type and area
  type, besides area type's frame), as in Illustrator, not only the first
  one: the layout's baselines are cached with its bounds
  (`TextObject::cached_baselines`, from `TextLayout::baselines`), with the
  first one across the bounds as the fallback. Vertical type's runs down
  each column's centre line.
- Type in perspective is picked where it is drawn (through its projection).
- The bounds rule type out before its path is built, so hovering stays
  free of allocations away from type.
- The Type tools still edit type clicked among its characters (they made
  new type there with the preference on), and the Eyedropper still samples
  it there, as it does inside fills with Object Selection by Path Only.
- Tests for all of it; docs/mcp.md and ROADMAP say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-08 18:59:47 +07:00
co-authored by Claude Opus 5.5
parent beea693ee1
commit afb438367d
15 changed files with 208 additions and 32 deletions
+12
View File
@@ -158,6 +158,18 @@ impl TextLayout {
pub fn physical_point(&self, p: Point) -> Point {
if self.vertical { self.line_xf * p } else { p }
}
/// Each line's baseline in text space, start to end (a vertical column's centre line), for
/// the lines that hold characters; none for type on a path, which its path stands for.
pub fn baselines(&self) -> Vec<(Point, Point)> {
if self.on_path {
return vec![];
}
let line = |l: &LineInfo| {
let y = if self.vertical { l.baseline + (l.descent - l.ascent) / 2.0 } else { l.baseline };
(self.physical_point(Point::new(l.x0, y)), self.physical_point(Point::new(l.x1, y)))
};
self.lines.iter().filter(|l| l.x1 > l.x0).map(line).collect()
}
/// All glyph outlines combined (e.g. for Create Outlines).
pub fn to_bezpath(&self) -> BezPath {
let mut p = BezPath::new();
+35
View File
@@ -73,6 +73,41 @@ fn area_type_starts_at_the_right_edge_and_wraps_to_the_left() {
assert!(l.glyphs.iter().all(|g| g.outline.bounding_box().y1 <= 105.0 + 1e-6), "inside the frame");
}
/// `baselines`: one per line that holds characters, along it for horizontal type and down the
/// column's centre line for vertical type (where point type's anchor is); none on a path.
#[test]
fn baselines_run_along_lines_and_down_column_centres() {
let h = layout(
FontDb::global(),
&TextObject::point(
Point::ZERO,
"§§
§",
CharStyle { size: 20.0, ..CharStyle::default() },
),
);
let hb = h.baselines();
assert_eq!(hb.len(), 2, "the empty line has none: {hb:?}");
assert!(hb[0].0 == Point::ZERO && hb[0].1.y == 0.0 && hb[0].1.x > 15.0, "the first baseline runs from the anchor: {hb:?}");
assert!(hb[1].0.y > 40.0 && hb[1].0.y == hb[1].1.y && hb[1].1.x > hb[1].0.x, "the third line's, lower: {hb:?}");
let v = layout(
FontDb::global(),
&vertical(
"§§
§",
),
);
let vb = v.baselines();
let (a, c) = (ink(&v, 0), ink(&v, 2));
assert_eq!(vb.len(), 2, "{vb:?}");
assert!(vb[0].0.x.abs() < 4.0 && vb[0].0.x == vb[0].1.x && vb[0].1.y > vb[0].0.y + 30.0, "down the first column's centre: {vb:?}");
assert!((vb[0].0.x - a.center().x).abs() < 4.0 && (vb[1].0.x - c.center().x).abs() < 4.0, "through the glyphs: {vb:?} {a:?} {c:?}");
let mut on_path = vertical("ab");
on_path.kind = TextKind::OnPath { path: PathData::from_bezpath(&kurbo::Line::new((0.0, 0.0), (100.0, 0.0)).to_path(0.1)), start: 0.0, end: None };
assert!(layout(FontDb::global(), &on_path).baselines().is_empty());
}
#[test]
fn type_on_a_path_stays_horizontal() {
let mut t = vertical("ab");