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:
parent
8434ef4404
commit
0ae572be93
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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<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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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})),
|
||||
],
|
||||
|
|
|
|||
Loading…
Reference in New Issue