Fill graphic shapes and text with a linear gradient.

Appearance › Fill Type paints a ramp across the layer bounds, and the ramp turns with the layer. A per-character fill colour still paints those characters as a solid.
This commit is contained in:
Vikram Bhaskaran 2026-10-10 12:05:03 +11:00
parent 7b6c134472
commit d55ebe94e5
No known key found for this signature in database
GPG Key ID: 5CFB5F821425464B
7 changed files with 183 additions and 6 deletions

View File

@ -190,6 +190,10 @@ fn param_id(k: &str) -> &str {
"fontSize" => "size",
"fontStyle" | "style" => "font_style",
"fillColor" | "color" => "fill_color",
"fillKind" => "fill_kind",
"gradientStart" => "gradient_start",
"gradientEnd" => "gradient_end",
"gradientAngle" => "gradient_angle",
"strokeColor" => "stroke_color",
"strokeWidth" => "stroke_width",
"backgroundColor" => "background_color",
@ -212,6 +216,7 @@ pub(crate) fn to_param(template: &ParamValue, id: &str, v: &Value) -> Option<Par
"align" => graphic::ALIGN_OPTS,
"caps" => graphic::CAPS_OPTS,
"stroke_type" | "stroke2_type" => graphic::STROKE_OPTS,
"fill_kind" => graphic::FILL_KIND_OPTS,
"shape" => SHAPE_OPTS,
_ => &[],
};

View File

@ -149,6 +149,24 @@ fn align_and_distribute_layers() {
let _ = w;
}
#[test]
fn linear_gradient_fill_sets_and_evaluates() {
let mut s = demo();
let r = s.execute("graphics.newShape", json!({"shape": "rectangle", "position": [200, 200], "size": [80, 40]})).unwrap();
let clip = ClipId(r["clip"].as_u64().unwrap());
s.execute(
"graphics.set",
json!({"clip": clip.0, "props": {"fill_kind": "linear gradient", "gradient_start": "#ff0000", "gradient_end": "#0000ff", "gradient_angle": 0}}),
)
.unwrap();
let g = layers(&s, clip)[0].appearance.gradient.clone().expect("linear fill");
assert_eq!(g.start[0], 1.0);
assert!(g.start[1] < 0.01 && g.end[2] > 0.9 && g.end[0] < 0.01, "{g:?}");
assert_eq!(g.angle, 0.0);
s.execute("graphics.set", json!({"clip": clip.0, "props": {"fill_kind": "solid"}})).unwrap();
assert!(layers(&s, clip)[0].appearance.gradient.is_none());
}
#[test]
fn graphic_renders_in_the_program_and_survives_save() {
let mut s = demo();

View File

@ -24,6 +24,7 @@ pub const ALIGN_OPTS: &[&str] = &["Left", "Center", "Right", "Justify"];
pub const CAPS_OPTS: &[&str] = &["Normal", "All Caps", "Small Caps"];
pub const STROKE_OPTS: &[&str] = &["Outer", "Center", "Inner"];
pub const SHAPE_OPTS: &[&str] = &["Rectangle", "Ellipse", "Polygon", "Path"];
pub const FILL_KIND_OPTS: &[&str] = &["Solid", "Linear Gradient"];
/// Whether an effect instance is a graphic layer.
pub fn is_layer_id(id: &str) -> bool {
@ -58,6 +59,10 @@ fn appearance() -> Vec<ParamDef> {
vec![
bo("fill", "Fill", true, A),
co("fill_color", "Fill Color", [1.0, 1.0, 1.0, 1.0], A),
chc("fill_kind", "Fill Type", FILL_KIND_OPTS, 0, A),
co("gradient_start", "Gradient Start", [1.0, 1.0, 1.0, 1.0], A),
co("gradient_end", "Gradient End", [0.15, 0.15, 0.15, 1.0], A),
p("gradient_angle", "Gradient Angle", ParamKind::Angle, ParamValue::Float(0.0), true, A),
bo("stroke", "Stroke", false, A),
co("stroke_color", "Stroke Color", [0.0, 0.0, 0.0, 1.0], A),
fl("stroke_width", "Stroke Width", 4.0, (0.0, 1000.0), (0.0, 100.0), "", A),
@ -144,10 +149,21 @@ pub(crate) fn layer_defs() -> Vec<EffectDef> {
]
}
/// A linear fill across the layer's local bounds. Colours are sRGB-encoded straight RGBA.
/// Angle is degrees clockwise on screen (y down); 0 runs left to right.
#[derive(Clone, Debug, PartialEq)]
pub struct LinearGradient {
pub start: [f32; 4],
pub end: [f32; 4],
pub angle: f32,
}
/// Fill / strokes / background / shadow of a layer (colours are sRGB-encoded straight RGBA).
#[derive(Clone, Debug, PartialEq)]
pub struct Appearance {
pub fill: Option<[f32; 4]>,
/// Set when Fill Type is Linear Gradient. Replaces the solid fill for the whole layer.
pub gradient: Option<LinearGradient>,
/// (colour, width px, type: 0 outer, 1 centre, 2 inner), outermost last.
pub strokes: Vec<([f32; 4], f32, u32)>,
/// (colour with opacity in alpha, padding px, corner radius px).
@ -334,7 +350,17 @@ pub fn eval_layer(e: &EffectInstance, t: Tick, frame: (u32, u32)) -> Option<Laye
uid: e.layer.as_ref().map_or(0, |x| x.uid),
pin: e.layer.as_ref().and_then(|x| x.pin.clone()).filter(|p| p.any()),
content,
appearance: Appearance { fill: bb(e, "fill", t).then(|| cc(e, "fill_color", t)), strokes, background, shadow },
appearance: Appearance {
fill: bb(e, "fill", t).then(|| cc(e, "fill_color", t)),
gradient: (bb(e, "fill", t) && ch(e, "fill_kind", t) == 1).then(|| LinearGradient {
start: cc(e, "gradient_start", t),
end: cc(e, "gradient_end", t),
angle: ff(e, "gradient_angle", t),
}),
strokes,
background,
shadow,
},
transform: LayerTransform {
position,
anchor: vv(e, "anchor", t),
@ -457,8 +483,17 @@ mod tests {
assert_eq!(t.size, 72.0);
assert_eq!(t.font, "Inter");
assert_eq!(s.appearance.fill, Some([1.0, 0.0, 0.0, 1.0]));
assert!(s.appearance.gradient.is_none(), "solid fill stays solid");
assert_eq!(s.transform.position, Vec2::new(100.0, 200.0));
assert!(s.appearance.strokes.is_empty() && s.appearance.shadow.is_none());
e.params.get_mut("fill_kind").unwrap().value = ParamValue::Choice(1);
e.params.get_mut("gradient_start").unwrap().value = ParamValue::Color([1.0, 0.0, 0.0, 1.0]);
e.params.get_mut("gradient_end").unwrap().value = ParamValue::Color([0.0, 0.0, 1.0, 1.0]);
e.params.get_mut("gradient_angle").unwrap().value = ParamValue::Float(90.0);
let g = eval_layer(&e, Tick::ZERO, (1920, 1080)).unwrap().appearance.gradient.expect("linear fill");
assert_eq!(g.start, [1.0, 0.0, 0.0, 1.0]);
assert_eq!(g.end, [0.0, 0.0, 1.0, 1.0]);
assert_eq!(g.angle, 90.0);
assert!(is_layer(&e));
assert_eq!(layer_display_name(&e, 0), "Hello");
}

View File

@ -12,7 +12,7 @@ use std::hash::{Hash, Hasher};
use std::sync::{Arc, Mutex, OnceLock};
use filmcraft_geom::{Affine, Vec2};
use filmcraft_project::graphic::{LayerContent, LayerSpec, ShapeProps, TextProps, eval_layer};
use filmcraft_project::graphic::{LayerContent, LayerSpec, LinearGradient, ShapeProps, TextProps, eval_layer};
use filmcraft_project::graphic_design::{GraphicMeta, PinNode, remap_time, resolve_pins, roll_offset};
use filmcraft_project::{EffectInstance, TrackItem};
use filmcraft_text::raster::{Xform, apply, fill_into, xform_scale};
@ -191,6 +191,55 @@ fn over_mask(px: &mut [f32], m: &Mask, c: [f32; 4]) {
});
}
fn invert_xform(m: &Xform) -> Option<Xform> {
let det = m[0] * m[3] - m[1] * m[2];
if det.abs() < 1e-8 {
return None;
}
let inv = 1.0 / det;
let a = m[3] * inv;
let b = -m[1] * inv;
let c = -m[2] * inv;
let d = m[0] * inv;
Some([a, b, c, d, -(a * m[4] + c * m[5]), -(b * m[4] + d * m[5])])
}
/// Paint `grad` through `msk`. 0° runs left to right across `lb` in layer space; the angle is
/// clockwise on screen and turns with `inv` (the device→layer transform).
fn over_linear_gradient(px: &mut [f32], msk: &Mask, grad: &LinearGradient, lb: [f32; 4], inv: &Xform, origin: (i32, i32)) {
if msk.w == 0 {
return;
}
let (cx, cy) = ((lb[0] + lb[2]) * 0.5, (lb[1] + lb[3]) * 0.5);
let (sin, cos) = grad.angle.to_radians().sin_cos();
let (ax, ay) = (cos, sin);
let corners = [(lb[0], lb[1]), (lb[2], lb[1]), (lb[0], lb[3]), (lb[2], lb[3])];
let half = corners.iter().map(|(x, y)| ((x - cx) * ax + (y - cy) * ay).abs()).fold(1e-3_f32, f32::max);
let w = msk.w;
let (start, end) = (grad.start, grad.end);
px.par_chunks_mut(4 * w).enumerate().for_each(|(row, dest)| {
let py = origin.1 as f32 + row as f32 + 0.5;
let mrow = &msk.a[row * w..(row + 1) * w];
for (col, (p, &cov)) in dest.as_chunks_mut::<4>().0.iter_mut().zip(mrow).enumerate() {
if cov <= 0.0 {
continue;
}
let (lx, ly) = apply(inv, origin.0 as f32 + col as f32 + 0.5, py);
let t = (((((lx - cx) * ax + (ly - cy) * ay) / half) + 1.0) * 0.5).clamp(0.0, 1.0);
let c = premul_linear([
start[0] * (1.0 - t) + end[0] * t,
start[1] * (1.0 - t) + end[1] * t,
start[2] * (1.0 - t) + end[2] * t,
start[3] * (1.0 - t) + end[3] * t,
]);
let k = 1.0 - c[3] * cov;
for i in 0..4 {
p[i] = c[i] * cov + p[i] * k;
}
}
});
}
/// Rasterise one layer with layer→device transform `m`, clipped to a `cw`×`ch` canvas.
pub fn raster_layer(spec: &LayerSpec, m: &Xform, cw: usize, ch: usize) -> Option<LayerRaster> {
if !spec.enabled || spec.transform.opacity <= 0.0 {
@ -294,7 +343,15 @@ pub fn raster_layer(spec: &LayerSpec, m: &Xform, cw: usize, ch: usize) -> Option
over_mask(&mut px, sm, premul_linear(*c));
}
}
if let Some(c) = ap.fill {
if let Some(grad) = &ap.gradient
&& !matches!(&spec.content, LayerContent::Text(t) if t.runs.iter().any(|(_, s)| s.fill.is_some()))
{
if let Some(inv) = invert_xform(m) {
over_linear_gradient(&mut px, &cover, grad, lb, &inv, (x0, y0));
} else if let Some(c) = ap.fill {
over_mask(&mut px, &cover, premul_linear(c));
}
} else if let Some(c) = ap.fill {
// per-character fills: each style run with its own colour is drawn on its own
let run_fills: Vec<Option<[f32; 4]>> = match &spec.content {
LayerContent::Text(t) => t.runs.iter().map(|(_, s)| s.fill).collect(),
@ -702,6 +759,45 @@ mod tests {
assert_eq!(img.get(20, 20)[3], 0.0);
}
#[test]
fn linear_gradient_runs_left_to_right_on_a_shape_and_on_text() {
let mut shape = new_shape_layer(0, Vec2::new(100.0, 100.0), Vec2::new(80.0, 40.0), vec![]);
set(&mut shape, "fill_kind", ParamValue::Choice(1));
set(&mut shape, "gradient_start", ParamValue::Color([1.0, 0.0, 0.0, 1.0]));
set(&mut shape, "gradient_end", ParamValue::Color([0.0, 0.0, 1.0, 1.0]));
set(&mut shape, "gradient_angle", ParamValue::Float(0.0));
let mut img = Image::new(200, 200);
render_graphic_layers(&[shape], Tick::ZERO, (200, 200), &Affine::IDENTITY, &mut img);
let left = img.get(70, 100);
let right = img.get(130, 100);
assert!(left[3] > 0.9 && left[0] > left[2], "redder on the left {left:?}");
assert!(right[3] > 0.9 && right[2] > right[0], "bluer on the right {right:?}");
let mut text = new_text_layer("HHHHHHHH", Vec2::new(16.0, 90.0), 64.0);
set(&mut text, "fill_kind", ParamValue::Choice(1));
set(&mut text, "gradient_start", ParamValue::Color([1.0, 0.0, 0.0, 1.0]));
set(&mut text, "gradient_end", ParamValue::Color([0.0, 0.0, 1.0, 1.0]));
let mut img = Image::new(500, 180);
render_graphic_layers(&[text], Tick::ZERO, (500, 180), &Affine::IDENTITY, &mut img);
let ink: Vec<(usize, usize)> = (0..img.h).flat_map(|y| (0..img.w).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[3] > 0.8).collect();
let minx = ink.iter().map(|p| p.0).min().unwrap();
let maxx = ink.iter().map(|p| p.0).max().unwrap();
let span = (maxx - minx).max(1);
let (mut ln, mut rn, mut ls, mut rs) = (0.0_f32, 0.0, 0.0, 0.0);
for &(x, y) in &ink {
let p = img.get(x, y)[0];
if x < minx + span / 3 {
ls += p;
ln += 1.0;
} else if x > maxx - span / 3 {
rs += p;
rn += 1.0;
}
}
assert!(ln > 30.0 && rn > 30.0, "ink on both ends of the title: {ln} {rn} x {minx}..{maxx}");
assert!(ls / ln > rs / rn + 0.15, "text ramps from red to blue: {} {}", ls / ln, rs / rn);
}
#[test]
fn inner_and_centre_strokes() {
for (kind, inside, outside) in [(2u32, true, false), (1, true, true)] {

View File

@ -1031,6 +1031,7 @@
Corner Radius Radio de esquina
Polygon Sides Lados del polígono
Fill Relleno
Gradient Degradado
Stroke Trazo
Stroke 2 Trazo 2
Background Fondo

Can't render this file because it contains an unexpected character in line 3 and column 95.

View File

@ -1536,8 +1536,22 @@ pub fn properties(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
// ---- appearance
if section(ui, app, "Appearance", &t) {
let (_, mut vui) = cx.row(ui, tl!("Fill"));
cx.check(&mut vui, "fill", "");
let fill_on = cx.check(&mut vui, "fill", "");
cx.color(&mut vui, "fill_color");
if fill_on {
vui.add_space(6.0);
cx.choice(&mut vui, "fill_kind", graphic::FILL_KIND_OPTS, 130.0);
}
let fill_kind = match pv(e, "fill_kind", mt) {
ParamValue::Choice(c) => c,
_ => 0,
};
if fill_on && fill_kind == 1 {
let (_, mut vui) = cx.row(ui, tl!("Gradient"));
cx.color(&mut vui, "gradient_start");
cx.color(&mut vui, "gradient_end");
cx.number(ui, tl!(" Angle"), "gradient_angle", 0.5, (-3600.0, 3600.0), 0, " °");
}
for (on, col, w, kind, label) in [
("stroke", "stroke_color", "stroke_width", "stroke_type", tl!("Stroke")),
("stroke2", "stroke2_color", "stroke2_width", "stroke2_type", tl!("Stroke 2")),

View File

@ -248,7 +248,15 @@ panel state and the Type-tool selection.
| `file.replaceFonts` | Graphics and Titles ▸ Replace Fonts in Projects… | `from` (family or {family, style}), `to`, `toStyle`: graphic layers, character styles, source graphics and caption tracks |
| `graphics.fonts.used` | (query) | fonts in use with counts and whether they are missing |
## Gradient fills
Appearance › Fill Type › Linear Gradient paints the shape or the text with a ramp from Gradient
Start to Gradient End. The angle is degrees clockwise on screen; 0° runs left to right across the
layer's own bounds, and the ramp turns with the layer. `graphics.set` accepts `fill_kind`
(`solid` or `linear gradient`), `gradient_start`, `gradient_end` and `gradient_angle`. A
per-character fill colour still paints those characters as a solid.
## Not yet
Mask-with-text, gradient fills, per-layer blend modes, Bézier curves in the pen tool (paths are
polygons), media layers inside graphics, and template controls grouped into folders.
Mask-with-text, per-layer blend modes, Bézier curves in the pen tool (paths are polygons), media
layers inside graphics, and template controls grouped into folders.