Text: stroke Line Join — Miter, Round or Bevel (#2077)

Text strokes were always drawn with round joins. The document gets a
Line Join (`lineJoin: miter|round|bevel` in layer.setText / newText),
Miter for new text as in After Effects, Round for documents saved before
it so their look is kept. The 2D and Advanced 3D (extruded) text strokes
use it, expressions' style copies carry it, and the Character panel has
Miter / Round / Bevel buttons under the stroke width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-30 04:25:27 +07:00
co-authored by Claude Opus 5.5
parent f868cc68f3
commit dc09ba58ae
10 changed files with 90 additions and 7 deletions
+2 -2
View File
@@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Text",
["Layer", "New"],
Some("Cmd+Alt+Shift+T"),
"{text?, name? (layer name; default the text), position? [x,y], box? [x,y,w,h] (paragraph text, comp space), vertical?, edit? (start editing), font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?, baselineShift?, hScale?, vScale?, tsume?, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?, superscript?, subscript?, kerning?, ligatures?, justify?, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?, composer?, hangingPunctuation?, strokeOverFill?} (attributes as layer.setText)",
"{text?, name? (layer name; default the text), position? [x,y], box? [x,y,w,h] (paragraph text, comp space), vertical?, edit? (start editing), font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?, baselineShift?, hScale?, vScale?, tsume?, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?, superscript?, subscript?, kerning?, ligatures?, justify?, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?, composer?, hangingPunctuation?, strokeOverFill?, lineJoin?} (attributes as layer.setText)",
has_comp,
new_text
),
@@ -192,7 +192,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Edit Text",
[],
None,
"{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, discretionaryLigatures? (OpenType), contextualAlternates?, stylisticAlternates?, stylisticSets?: [1–20], ss01?, ss02?, ss03?, ss04?, ss05?, ss06?, ss07?, ss08?, ss09?, ss10?, ss11?, ss12?, ss13?, ss14?, ss15?, ss16?, ss17?, ss18?, ss19?, ss20?, swash?, titling?, ordinals?, fractions?, allSmallCaps?, figureStyle?: default|lining|oldStyle, figureWidth?: default|proportional|tabular, figures?, variations?: {tag: value} (variable font axes; null resets), justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, box?: [x,y,w,h]|null (layer space), vertical?}",
"{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, discretionaryLigatures? (OpenType), contextualAlternates?, stylisticAlternates?, stylisticSets?: [1–20], ss01?, ss02?, ss03?, ss04?, ss05?, ss06?, ss07?, ss08?, ss09?, ss10?, ss11?, ss12?, ss13?, ss14?, ss15?, ss16?, ss17?, ss18?, ss19?, ss20?, swash?, titling?, ordinals?, fractions?, allSmallCaps?, figureStyle?: default|lining|oldStyle, figureWidth?: default|proportional|tabular, figures?, variations?: {tag: value} (variable font axes; null resets), justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, lineJoin?: miter|round|bevel, box?: [x,y,w,h]|null (layer space), vertical?}",
has_layers,
set_text
),
+3
View File
@@ -476,6 +476,9 @@ fn set_text_paragraph_fill_and_leading() {
s.execute("layer.setText", json!({"layer": t, "hScale": 120, "vScale": 80, "baselineShift": 4, "smallCaps": true, "strokeOverFill": true})).unwrap();
let d = doc(&s);
assert_eq!((d.h_scale, d.v_scale, d.baseline_shift, d.small_caps, d.stroke_over_fill), (120.0, 80.0, 4.0, true, true));
s.execute("layer.setText", json!({"layer": t, "lineJoin": "bevel"})).unwrap();
assert_eq!(doc(&s).line_join, effectcraft_keyframe::LineJoin::Bevel);
assert!(s.execute("layer.setText", json!({"layer": t, "lineJoin": "square"})).is_err());
}
#[test]
+1
View File
@@ -677,6 +677,7 @@ pub fn styled_doc(v: &serde_json::Value, own: &effectcraft_keyframe::TextDoc) ->
let p0 = src.para(0);
d.set_paras(vec![p0; d.para_count()]);
d.stroke_over_fill = src.stroke_over_fill;
d.line_join = src.line_join;
}
}
for op in v.get("ops").and_then(|o| o.as_array()).into_iter().flatten() {
+1 -1
View File
@@ -15,7 +15,7 @@ pub mod value;
use effectcraft_time::{TICKS_PER_SECOND, Tick};
use serde::{Deserialize, Serialize};
pub use text_doc::{BaselineOption, CharStyle, Composer, Direction, FigureStyle, FigureWidth, Kerning, OpenType, ParaStyle, StyleRun};
pub use text_doc::{BaselineOption, CharStyle, Composer, Direction, FigureStyle, FigureWidth, Kerning, LineJoin, OpenType, ParaStyle, StyleRun};
pub use value::{FeatherPoint, Gradient, Justify, ShapePath, TextDoc, Value};
/// Temporal interpolation on one side of a keyframe.
+35
View File
@@ -179,6 +179,31 @@ pub enum Composer {
fn single_line() -> Composer {
Composer::SingleLine
}
/// How the corners of a text stroke are drawn (Character panel Line Join).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum LineJoin {
/// Sharp corners (AE's default for new text).
#[default]
Miter,
Round,
Bevel,
}
impl LineJoin {
pub fn parse(s: &str) -> Option<LineJoin> {
Some(match s.to_ascii_lowercase().as_str() {
"miter" => LineJoin::Miter,
"round" => LineJoin::Round,
"bevel" => LineJoin::Bevel,
_ => return None,
})
}
}
fn round_join() -> LineJoin {
LineJoin::Round
}
fn yes() -> bool {
true
}
@@ -402,6 +427,9 @@ pub struct TextDoc {
pub apply_fill: bool,
pub apply_stroke: bool,
pub stroke_over_fill: bool,
/// Documents saved before line joins existed drew round joins.
#[serde(default = "round_join")]
pub line_join: LineJoin,
pub tracking: f64,
/// None = auto (120% of size).
pub leading: Option<f64>,
@@ -463,6 +491,7 @@ impl Default for TextDoc {
apply_fill: true,
apply_stroke: false,
stroke_over_fill: true,
line_join: LineJoin::Miter,
tracking: 0.0,
leading: None,
justify: Justify::Left,
@@ -909,6 +938,10 @@ impl TextDoc {
self.stroke_over_fill = v.as_bool().ok_or("strokeOverFill: expected a boolean")?;
return Ok(true);
}
"lineJoin" => {
self.line_join = v.as_str().and_then(LineJoin::parse).ok_or("lineJoin: expected miter|round|bevel")?;
return Ok(true);
}
"vertical" => {
self.vertical = v.as_bool().ok_or("vertical: expected a boolean")?;
return Ok(true);
@@ -1423,6 +1456,8 @@ mod tests {
assert!(d.is_uniform());
assert_eq!(d.style_at(2).size, 50.0);
assert_eq!(d.composer, Composer::SingleLine, "old box text keeps greedy breaking");
assert_eq!(d.line_join, LineJoin::Round, "old strokes keep their round corners");
assert_eq!(TextDoc::default().line_join, LineJoin::Miter);
assert!(d.ligatures);
assert_eq!(d.kerning, Kerning::Metrics);
// Round trip with runs.
+12 -1
View File
@@ -510,6 +510,7 @@ pub struct PlacedGlyph {
pub fill: [f32; 4],
pub stroke: [f32; 4],
pub stroke_width: f64,
pub join: effectcraft_path::Join,
/// Faux Bold (or synthetic bold for a family without a bold face): how far the outline grows
/// on every side, in character space (0 when not bold).
pub bold: f64,
@@ -838,6 +839,7 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
fill,
stroke,
stroke_width: (base_stroke_w + x.stroke_width).max(0.0),
join: path_join(doc.line_join),
bold: if g.synth_bold { FAUX_BOLD_RADIUS * g.size } else { 0.0 },
apply_fill: st.apply_fill,
char_index: g.char_index,
@@ -853,6 +855,15 @@ pub fn glyph_paths(ctx: &EvalCtx, layer: &Layer) -> Vec<(BezPath, CharXf)> {
text_geom(ctx, layer).map(|t| t.glyphs.iter().map(|g| (g.path(), g.xf)).collect()).unwrap_or_default()
}
fn path_join(j: effectcraft_keyframe::LineJoin) -> effectcraft_path::Join {
use effectcraft_keyframe::LineJoin;
match j {
LineJoin::Miter => effectcraft_path::Join::Miter,
LineJoin::Round => effectcraft_path::Join::Round,
LineJoin::Bevel => effectcraft_path::Join::Bevel,
}
}
/// One paint pass of a glyph: fill (false) or stroke (true).
type Pass = (usize, bool);
@@ -919,7 +930,7 @@ fn raster_pass(g: &PlacedGlyph, stroke: bool, paths: &[BezPath], m: &Mat3, rect:
}
outer
} else if stroke {
let st = StrokeStyle { width: g.stroke_width, join: effectcraft_path::Join::Round, ..Default::default() };
let st = StrokeStyle { width: g.stroke_width, join: g.join, ..Default::default() };
effectcraft_path::stroke_coverage(paths, &st, &mm, w, h)
} else {
grown_coverage(paths, &mm, w, h, bold)
+2
View File
@@ -598,6 +598,7 @@ fn touching_glyphs_join_without_a_seam() {
fill,
stroke: [0.0; 4],
stroke_width: 0.0,
join: effectcraft_path::Join::Miter,
bold: 0.0,
apply_fill: true,
char_index: 0,
@@ -668,6 +669,7 @@ fn faux_bold_under_a_non_uniform_scale() {
fill: [1.0; 4],
stroke: [0.0; 4],
stroke_width: 0.0,
join: effectcraft_path::Join::Miter,
bold: 2.0,
apply_fill: true,
char_index: 0,
+1 -1
View File
@@ -572,7 +572,7 @@ pub fn extruded_meshes(ctx: &EvalCtx, layer: &Layer, p: &model::extrude::Extrude
groups.push((vec![g.local.clone()], c, g.m, usize::from(!stroke_on_top), g.xf.surface_colors(c)));
}
if g.stroke_width > 0.0 && g.stroke[3] * op > 0.0 {
let st = effectcraft_path::StrokeStyle { width: g.stroke_width, join: effectcraft_path::Join::Round, ..Default::default() };
let st = effectcraft_path::StrokeStyle { width: g.stroke_width, join: g.join, ..Default::default() };
if let Some(region) = crate::shapes::stroke_region(std::slice::from_ref(&g.local), &st) {
let c = [g.stroke[0], g.stroke[1], g.stroke[2], g.stroke[3] * op];
groups.push((vec![region], c, g.m, usize::from(stroke_on_top), g.xf.surface_colors(c)));
+17 -2
View File
@@ -3,7 +3,7 @@
//! otherwise they apply to the whole selected text layer.
use crate::i18n::tr;
use effectcraft_engine::keyframe::{BaselineOption, Composer, Direction, FigureStyle, FigureWidth, Justify, Kerning, TextDoc};
use effectcraft_engine::keyframe::{BaselineOption, Composer, Direction, FigureStyle, FigureWidth, Justify, Kerning, LineJoin, TextDoc};
use effectcraft_engine::render::EvalCtx;
use egui::{Align2, Rect, Sense, pos2, vec2};
use serde_json::json;
@@ -182,6 +182,20 @@ fn stroke_order_field(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: Grid, doc:
}
}
/// Line Join of the stroke: Miter / Round / Bevel (grid row 5, under the stroke width).
fn line_join_field(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, g: Grid, doc: &TextDoc, actions: &mut Vec<serde_json::Value>) {
let Grid { x0, y, w, enabled } = g;
let bw = (w - 2.0 * 4.0) / 3.0;
for (i, (key, label, join)) in
[("miter", "Miter", LineJoin::Miter), ("round", "Round", LineJoin::Round), ("bevel", "Bevel", LineJoin::Bevel)].into_iter().enumerate()
{
let r = Rect::from_min_size(pos2(x0 + i as f32 * (bw + 4.0), y + 5.0 * 28.0), vec2(bw, 22.0));
if toggle(app, ui, p, r, &format!("character.lineJoin.{key}"), (label, doc.line_join == join, false)) && enabled {
actions.push(json!({"lineJoin": key}));
}
}
}
/// The Character panel for the text being edited (or the selected text layer): font family and
/// style, size, leading, tracking, stroke, scale, baseline shift and other numeric fields, kerning,
/// fill/stroke colours with eyedroppers, style toggles, OpenType features and variable-font axes.
@@ -224,7 +238,8 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
numeric_fields(app, ui, &p, grid, &doc, &mut actions);
kerning_field(app, ui, &p, grid, &doc, &mut actions);
stroke_order_field(app, ui, grid, &doc, &mut actions);
y += 5.0 * 28.0 + 8.0;
line_join_field(app, ui, &p, grid, &doc, &mut actions);
y += 6.0 * 28.0 + 8.0;
// Fill / stroke swatches with their enable boxes.
for (i, (key, label, on, c)) in [("fill", "Fill", doc.apply_fill, doc.fill), ("stroke", "Stroke", doc.apply_stroke, doc.stroke)].into_iter().enumerate() {
let fx = x0 + i as f32 * (w / 2.0);
+16
View File
@@ -1734,3 +1734,19 @@ fn same_caret_application_text_replacement_rejects_the_previous_context_fence()
h.run_steps(1);
assert_eq!(edited(&h).1, "Aがbcd");
}
/// #2077: the Character panel's Line Join buttons set the stroke's corner style (Miter for new text).
#[test]
fn character_panel_line_join_buttons_set_the_join() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Type", "width": 640, "height": 360, "duration": 2})).unwrap();
let lid = s.execute("layer.newText", json!({"text": "Join", "size": 80, "applyStroke": true, "strokeWidth": 8})).unwrap()["layer"].as_u64().unwrap();
let mut h = harness(EffectcraftApp::new(s));
h.state_mut().show_panel(PanelKind::Character);
h.run_steps(3);
let join = |h: &Harness<'_, EffectcraftApp>| layer_doc(&h.state().session, LayerId(lid)).unwrap().line_join;
assert_eq!(join(&h), effectcraft_engine::keyframe::LineJoin::Miter);
let bevel = rect(&h, "character.lineJoin.bevel").center();
click(&mut h, bevel, Modifiers::NONE);
assert_eq!(join(&h), effectcraft_engine::keyframe::LineJoin::Bevel);
}