mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 13:08:07 -06:00
Merge pull request #2610 from storytold/fix/text-line-join
Text: stroke Line Join — Miter, Round or Bevel (#2077)
This commit is contained in:
@@ -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
|
||||
),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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;
|
||||
@@ -191,6 +191,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.
|
||||
@@ -233,7 +247,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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user