diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 5b139423..91c304e3 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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; diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 3a736707..13a6149e 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -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 { + 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, @@ -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, } } diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index 478fd400..245ab297 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -337,6 +337,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result { 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 { runs: head.clone(), para: Default::default(), area: Default::default(), + path_effect: Default::default(), cached_bounds: None, }; for r in &mut h.runs { diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index c6b18fc7..57105635 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -52,7 +52,7 @@ pub fn specs() -> Vec { "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 { 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 { if flip { path.reverse(); } + if let Some(e) = effect { + t.path_effect = e; + } n += 1; refresh_bounds(t); } diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index a64f2a0c..239aed31 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -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::().ok()) { diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 903fabb4..65746017 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -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, ¶s, &t.para, Some(®ions)); } - TextKind::OnPath { path, start } => on_path(&mut cx, ¶s, &t.para, &path.to_bezpath(), *start, path.is_closed()), + TextKind::OnPath { path, start } => on_path(&mut cx, ¶s, &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], para: &ParaStyle, path: &BezPath, start: f64, closed: bool) { +#[allow(clippy::too_many_arguments)] +fn on_path(cx: &mut Ctx<'_>, paras: &[Range], 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], 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], 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; } diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 0283c387..f267e28a 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -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); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 2858fa5b..115cb5b5 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -400,6 +400,15 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { "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)> { 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})), ],