M7.3: Type → Type on a Path effects (Rainbow, Skew, 3D Ribbon, Stair Step, Gravity): TextObject::path_effect, type.pathOptions {effect}, checked menu items

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 22:21:58 -04:00
parent 8434ef4404
commit 0ae572be93
8 changed files with 139 additions and 13 deletions

View File

@ -35,7 +35,7 @@ pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, Gradient
pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind, OpacityMask};
pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType};
pub use selection::{AnchorRef, Selection};
pub use text::{AreaOptions, CharStyle, FirstBaseline, Justify, ParaStyle, TextKind, TextObject, TextRun, TextStyleDef};
pub use text::{AreaOptions, CharStyle, FirstBaseline, Justify, ParaStyle, PathEffect, TextKind, TextObject, TextRun, TextStyleDef};
pub use vectorcraft_color as color;
pub use vectorcraft_geom as geom;

View File

@ -136,6 +136,41 @@ pub enum FirstBaseline {
Fixed,
}
/// Type on a Path effect: how each glyph is oriented on the path (Type → Type on a Path).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PathEffect {
/// Glyphs rotate with the path (the default).
#[default]
Rainbow,
/// Vertical edges stay vertical; the baseline follows the path.
Skew,
/// Horizontal edges stay horizontal; vertical edges are perpendicular to the path.
#[serde(rename = "3dRibbon")]
Ribbon3d,
/// No rotation: the left end of each glyph's baseline sits on the path.
StairStep,
/// The baseline centre sits on the path and glyphs point away from the path's centre.
Gravity,
}
impl PathEffect {
pub const ALL: [PathEffect; 5] = [PathEffect::Rainbow, PathEffect::Skew, PathEffect::Ribbon3d, PathEffect::StairStep, PathEffect::Gravity];
pub fn id(self) -> &'static str {
match self {
PathEffect::Rainbow => "rainbow",
PathEffect::Skew => "skew",
PathEffect::Ribbon3d => "3dRibbon",
PathEffect::StairStep => "stairStep",
PathEffect::Gravity => "gravity",
}
}
pub fn parse(s: &str) -> Option<Self> {
let k = s.to_ascii_lowercase().replace([' ', '-', '_'], "");
Self::ALL.into_iter().find(|e| e.id().to_ascii_lowercase() == k || (k == "ribbon3d" && *e == PathEffect::Ribbon3d))
}
}
/// Area Type Options: rows and columns, gutters, inset and first baseline of area type.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
@ -196,6 +231,9 @@ pub struct TextObject {
/// Area Type Options (area type only).
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub area: AreaOptions,
/// Type on a Path effect (type on a path only).
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub path_effect: PathEffect,
/// Cached layout bounds in text space, filled in by the layout engine (not serialized).
#[serde(skip)]
pub cached_bounds: Option<Rect>,
@ -209,6 +247,7 @@ impl TextObject {
runs: vec![TextRun { text: text.into(), style }],
para: ParaStyle::default(),
area: AreaOptions::default(),
path_effect: PathEffect::default(),
cached_bounds: None,
}
}

View File

@ -337,6 +337,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
runs: vec![TextRun { text, style }],
para: Default::default(),
area: Default::default(),
path_effect: Default::default(),
cached_bounds: None,
};
refresh_bounds(&mut t);
@ -371,6 +372,7 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option<f64> {
runs: head.clone(),
para: Default::default(),
area: Default::default(),
path_effect: Default::default(),
cached_bounds: None,
};
for r in &mut h.runs {

View File

@ -52,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Type on a Path Options…",
["Type", "Type on a Path"],
None,
"{start?: 0..1 (fraction of the path length), flip?: bool (reverse the path), ids?}",
"{start?: 0..1 (fraction of the path length), flip?: bool (reverse the path), effect?: rainbow|skew|3dRibbon|stairStep|gravity, ids?}",
has_selection,
path_options
),
@ -367,6 +367,10 @@ fn path_options(s: &mut Session, p: &Value) -> Result<Value> {
let ids = texts(s, p, C)?;
let start = p.get("start").and_then(Value::as_f64);
let flip = bool_or(p, "flip", false);
let effect = match str_param(p, "effect") {
Some(e) => Some(vectorcraft_doc::PathEffect::parse(e).ok_or_else(|| bad(C, format!("unknown effect `{e}`")))?),
None => None,
};
let n = s.edit("Type on a Path Options", |d, _| {
let mut n = 0;
for id in &ids {
@ -378,6 +382,9 @@ fn path_options(s: &mut Session, p: &Value) -> Result<Value> {
if flip {
path.reverse();
}
if let Some(e) = effect {
t.path_effect = e;
}
n += 1;
refresh_bounds(t);
}

View File

@ -690,8 +690,15 @@ fn text_fallback(im: &mut Importer, svg: &str, w: f64, h: f64, layer_mode: bool)
Some("end") => Justify::Right,
_ => Justify::Left,
};
let mut obj =
TextObject { kind: TextKind::Point, xf, runs: rb.runs, para: Default::default(), area: Default::default(), cached_bounds: None };
let mut obj = TextObject {
kind: TextKind::Point,
xf,
runs: rb.runs,
para: Default::default(),
area: Default::default(),
path_effect: Default::default(),
cached_bounds: None,
};
obj.para.justify = justify;
let mut node = im.named(t.attribute("id").unwrap_or(""), NodeKind::Text(Box::new(obj)));
if let Some(o) = ctx.own(t, "opacity").and_then(|o| o.trim().parse::<f32>().ok()) {

View File

@ -3,7 +3,7 @@
use std::ops::Range;
use kurbo::{Affine, BezPath, ParamCurve, ParamCurveArclen, PathEl, PathSeg, Point, Rect, Shape, Vec2};
use vectorcraft_doc::{CharStyle, Justify, ParaStyle, TextKind, TextObject};
use vectorcraft_doc::{CharStyle, Justify, ParaStyle, PathEffect, TextKind, TextObject};
use crate::composer::{Breakpoint, compose};
use crate::fontdb::FontDb;
@ -93,7 +93,7 @@ pub fn layout_with(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLay
cx.out.frames = regions.iter().map(|r| r.cell).collect();
flow(&mut cx, &paras, &t.para, Some(&regions));
}
TextKind::OnPath { path, start } => on_path(&mut cx, &paras, &t.para, &path.to_bezpath(), *start, path.is_closed()),
TextKind::OnPath { path, start } => on_path(&mut cx, &paras, &t.para, &path.to_bezpath(), *start, path.is_closed(), t.path_effect),
}
finish_bounds(&mut cx.out);
cx.out
@ -624,7 +624,8 @@ impl ArcPath {
}
}
fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, path: &BezPath, start: f64, closed: bool) {
#[allow(clippy::too_many_arguments)]
fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, path: &BezPath, start: f64, closed: bool, effect: PathEffect) {
cx.out.on_path = true;
let mut sg = Vec::new();
for pr in paras {
@ -652,6 +653,7 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, path: &Be
});
return;
}
let centre = path.bounding_box().center();
let s_start = start.clamp(0.0, 1.0) * ap.len;
let avail = if closed { ap.len } else { ap.len - s_start };
let w: f64 = sg.iter().map(|g| g.adv).sum();
@ -673,7 +675,37 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, path: &Be
}
let (p, dir) = ap.at(mid);
let angle = dir.y.atan2(dir.x);
let pre = Affine::translate(p.to_vec2()) * Affine::rotate(angle) * Affine::translate((-g.adv * 0.5, 0.0));
let half = Affine::translate((-g.adv * 0.5, 0.0));
// Glyph space: x along the advance, y down from the baseline; `pre` maps it onto the path.
let pre = match effect {
PathEffect::Rainbow => Affine::translate(p.to_vec2()) * Affine::rotate(angle) * half,
// x axis along the tangent, y axis stays vertical.
PathEffect::Skew => Affine::translate(p.to_vec2()) * Affine::new([dir.x, dir.y, 0.0, 1.0, 0.0, 0.0]) * half,
// x axis stays horizontal (facing the path's direction), y axis perpendicular to the path.
PathEffect::Ribbon3d => {
let sx = if dir.x < 0.0 { -1.0 } else { 1.0 };
Affine::translate(p.to_vec2()) * Affine::new([sx, 0.0, -dir.y * sx, dir.x * sx, 0.0, 0.0]) * half
}
PathEffect::StairStep => {
let s_left = if closed { s.rem_euclid(ap.len) } else { s };
Affine::translate(ap.at(s_left).0.to_vec2())
}
// x axis along the tangent; vertical edges point at the path's centre (kept on the glyph's
// up side, and never closer than ~17° to the baseline so glyphs stay legible).
PathEffect::Gravity => {
let n = Vec2::new(dir.y, -dir.x);
let mut up = p - centre;
up = if up.hypot() < 1e-9 { n } else { up / up.hypot() };
if up.dot(n) < 0.0 {
up = -up;
}
if up.dot(n) < 0.3 {
let t = up - n * up.dot(n);
up = n * 0.3 + t / t.hypot().max(1e-9) * (1.0 - 0.09f64).sqrt();
}
Affine::translate(p.to_vec2()) * Affine::new([dir.x, dir.y, -up.x, -up.y, 0.0, 0.0]) * half
}
};
cx.emit(g, pre, p - dir * (g.adv * 0.5), angle, g.adv, 0);
x += g.adv;
}

View File

@ -293,6 +293,36 @@ fn on_path_placement() {
assert_eq!(hit_byte(&l, mid), 1);
}
#[test]
fn on_path_effects_orient_glyphs() {
use vectorcraft_doc::PathEffect;
let mut diag = BezPath::new();
diag.move_to((0.0, 0.0));
diag.line_to((400.0, 400.0));
let mut t = point("H", style(40.0));
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&diag), start: 0.1 };
let bbox = |t: &TextObject| layout(db(), t).glyphs[0].outline.bounding_box();
let rainbow = bbox(&t);
t.path_effect = PathEffect::StairStep;
let stair = bbox(&t);
// Unrotated: as tall as the cap height, narrower than the rotated glyph's box.
assert!(stair.height() < rainbow.height() && stair.height() > 20.0, "{stair:?} {rainbow:?}");
t.path_effect = PathEffect::Skew;
let skew = bbox(&t);
// Vertical stems stay vertical: the box spans the cap height plus the slant of the baseline.
assert!(skew.height() > stair.height() && skew.width() < rainbow.width() + 1e-6, "{skew:?}");
// Gravity on a circle: glyphs point away from the centre (same as Rainbow on a circle).
let circle = kurbo::Circle::new((0.0, 0.0), 100.0).to_path(0.1);
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&circle), start: 0.0 };
t.path_effect = PathEffect::Rainbow;
let a = layout(db(), &t).glyphs[0].outline.bounding_box();
t.path_effect = PathEffect::Gravity;
let b = layout(db(), &t).glyphs[0].outline.bounding_box();
assert!((a.center() - b.center()).hypot() < 2.0, "{a:?} {b:?}");
assert_eq!(PathEffect::parse("3D Ribbon"), Some(PathEffect::Ribbon3d));
assert_eq!(PathEffect::parse("stair step"), Some(PathEffect::StairStep));
}
#[test]
fn on_path_circle_and_overflow() {
let circle = kurbo::Circle::new((0.0, 0.0), 100.0).to_path(0.1);

View File

@ -400,6 +400,15 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option<bool> {
"view.outline" => v.outline,
"view.pixelPreview" => v.pixel_preview,
"view.trimView" => v.trim_view,
// Type on a Path effect of the selected path type.
"type.pathOptions" if p.get("effect").is_some() => {
let st = app.session.active()?;
let t = st.selection.objects.iter().find_map(|id| match st.doc.node(*id).map(|n| &n.kind) {
Some(vectorcraft_engine::doc::NodeKind::Text(t)) if matches!(t.kind, vectorcraft_engine::doc::TextKind::OnPath { .. }) => Some(t),
_ => None,
})?;
p.get("effect").and_then(Value::as_str) == Some(t.path_effect.id())
}
"view.smartGuides" => v.smart_guides,
"view.grid" => v.grid,
"view.snapToGrid" => v.snap_to_grid,
@ -826,11 +835,11 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
sub(
"Type on a Path",
vec![
todo("Rainbow"),
todo("Skew"),
todo("3D Ribbon"),
todo("Stair Step"),
todo("Gravity"),
cp("Rainbow", "type.pathOptions", json!({"effect": "rainbow"})),
cp("Skew", "type.pathOptions", json!({"effect": "skew"})),
cp("3D Ribbon", "type.pathOptions", json!({"effect": "3dRibbon"})),
cp("Stair Step", "type.pathOptions", json!({"effect": "stairStep"})),
cp("Gravity", "type.pathOptions", json!({"effect": "gravity"})),
Sep,
cp("Type on a Path Options…", "type.pathOptions", json!({"start": 0})),
],