Merge pull request #754 from vangel76/gap-surface-colors

#315: Front / Bevel / Side / Back Color for extruded shapes and text

# Conflicts:
#	docs/gaps.md
This commit is contained in:
Brandon Thomas committed 2026-10-12 03:20:01 -04:00
commit 261a5b125b
15 files changed
+413 -44

No files matched your search

+37 -12
View File
@@ -4,7 +4,7 @@ use effectcraft_color::{BlendMode, Label};
use effectcraft_keyframe::{Justify, TextDoc, Value as KV};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{
Comp, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MaskMode, MatteKind, Project, PropGroup, Quality, Solid, TrackMatte,
Comp, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MaskMode, MatteKind, Node, Project, PropGroup, Quality, Solid, TrackMatte,
};
use effectcraft_time::Tick;
use serde_json::{Value, json};
@@ -1186,6 +1186,26 @@ fn add_shape_item(s: &mut Session, p: &Value) -> Result<Value> {
let uid = s.edit("Add Shape Item", None, |proj, _| {
let mut next = proj.next_id;
let mut ids = Ids(&mut next);
// Front / Bevel / Side / Back ▸ property: into the group's surface item (one per group).
if let Some((surface, prop)) = build::shape_surface_kind(&kind) {
let target = shape_item_target(proj, cid, lid, group_uid)?;
let item = match target.children.iter().position(|c| matches!(c, Node::Group(g) if g.match_id == surface)) {
Some(i) => i,
None => {
let g = build::shape_surface(&mut ids, surface).ok_or_else(|| bad("layer.addShapeItem", format!("unknown kind `{kind}`")))?;
target.children.push(g.into());
target.children.len() - 1
}
};
let Some(Node::Group(g)) = target.children.get_mut(item) else { return Err(bad("layer.addShapeItem", "no such group")) };
if g.get(prop).is_none() {
let p = build::shape_surface_prop(&mut ids, prop).ok_or_else(|| bad("layer.addShapeItem", format!("unknown kind `{kind}`")))?;
g.children.push(p.into());
}
let uid = g.uid;
proj.next_id = next;
return Ok(uid);
}
let g = match kind.as_str() {
"group" => build::shape_group(&mut ids, "Group 1", vec![]),
"rect" | "rectangle" => build::shape_rect(&mut ids, [200.0, 200.0], [0.0, 0.0], 0.0),
@@ -1203,16 +1223,7 @@ fn add_shape_item(s: &mut Session, p: &Value) -> Result<Value> {
};
let uid = g.uid;
proj.next_id = next;
let l = layer_mut(proj, cid, lid)?;
let contents = l.props.sub_mut("contents").ok_or_else(|| bad("layer.addShapeItem", "not a shape layer"))?;
let target = match group_uid {
Some(u) => contents
.find_group_mut(u)
.and_then(|g| if g.match_id == "contents" { Some(g) } else { g.sub_mut("contents") })
.ok_or_else(|| bad("layer.addShapeItem", "no such group"))?,
None => contents,
};
target.children.push(g.into());
shape_item_target(proj, cid, lid, group_uid)?.children.push(g.into());
Ok(uid)
})?;
// The new item's property path, ready for prop.set / add_keyframe (`contents/trim#2/end`).
@@ -1220,6 +1231,20 @@ fn add_shape_item(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"uid": uid, "path": path}))
}
/// The contents a new shape item goes into: the shape group `group_uid` (its Contents), else the
/// layer's Contents.
fn shape_item_target(proj: &mut Project, cid: ItemId, lid: LayerId, group_uid: Option<u64>) -> Result<&mut PropGroup> {
let l = layer_mut(proj, cid, lid)?;
let contents = l.props.sub_mut("contents").ok_or_else(|| bad("layer.addShapeItem", "not a shape layer"))?;
Ok(match group_uid {
Some(u) => contents
.find_group_mut(u)
.and_then(|g| if g.match_id == "contents" { Some(g) } else { g.sub_mut("contents") })
.ok_or_else(|| bad("layer.addShapeItem", "no such group"))?,
None => contents,
})
}
fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "layer.setText")?;
// The Character panel's recent fonts (Settings ▸ Type ▸ Number of Recent Fonts to Display).
@@ -1570,7 +1595,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add (Shape)",
[],
None,
"{layer?, kind: group|rect|ellipse|star|polygon|path|fill|stroke|gfill|gstroke|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|merge, group?: uid|path} → {uid, path}",
"{layer?, kind: group|rect|ellipse|star|polygon|path|fill|stroke|gfill|gstroke|trim|repeater|round|offset|pucker|twist|zigzag|wiggle|merge|frontColor|bevelColor|sideColor|backColor, group?: uid|path} → {uid, path} (frontColor…backColor: the group's Front / Bevel / Side / Back item, which colours that surface of every extruded paint in the group in Advanced 3D)",
has_layers,
add_shape_item
),
+1 -1
View File
@@ -284,7 +284,7 @@ fn free_transform(s: &mut Session, p: &Value) -> Result<Value> {
const SHAPE_ITEMS: &[&str] = &[
"group", "rect", "ellipse", "star", "path", "fill", "stroke", "gfill", "gstroke", "trim", "repeater", "round", "offset", "pucker", "twist", "zigzag",
"wiggle", "merge",
"wiggle", "merge", "front", "bevel", "side", "back",
];
/// Selected shape items (contents entries) of shape layers: (layer, uid).
+1 -1
View File
@@ -431,7 +431,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Animate Text",
[],
None,
"{layer?, properties: [anchor|position|scale|skew|rotation|opacity|transformAll|lineAnchor|lineSpacing|characterOffset|characterValue|blur|fillColor|fillHue|fillSaturation|fillBrightness|fillOpacity|strokeColor|strokeHue|strokeSaturation|strokeBrightness|strokeOpacity|strokeWidth|tracking|perChar3d], name?}",
"{layer?, properties: [anchor|position|scale|skew|rotation|opacity|transformAll|lineAnchor|lineSpacing|characterOffset|characterValue|blur|fillColor|fillHue|fillSaturation|fillBrightness|fillOpacity|strokeColor|strokeHue|strokeSaturation|strokeBrightness|strokeOpacity|strokeWidth|tracking|frontColor|bevelColor|sideColor|backColor|perChar3d], name?}",
has_text_layer,
add_animator
),
+8
View File
@@ -1022,6 +1022,14 @@ Animation
Brightness | layer.addTextAnimator {"properties":["strokeBrightness"]}
Opacity | layer.addTextAnimator {"properties":["strokeOpacity"]}
Stroke Width | layer.addTextAnimator {"properties":["strokeWidth"]}
Front
Color | layer.addTextAnimator {"properties":["frontColor"]}
Bevel
Color | layer.addTextAnimator {"properties":["bevelColor"]}
Side
Color | layer.addTextAnimator {"properties":["sideColor"]}
Back
Color | layer.addTextAnimator {"properties":["backColor"]}
---
Tracking | layer.addTextAnimator {"properties":["tracking"]}
Line Anchor | layer.addTextAnimator {"properties":["lineAnchor"]}
+38
View File
@@ -151,6 +151,44 @@ fn renderer_switch_adds_geometry_options() {
assert_eq!(layer(&s, t).props.prop("geometryOptions/extrusionDepth").unwrap().value.as_f64(), 40.0);
}
/// Contents ▸ Add ▸ Front / Bevel / Side / Back ▸ Color (#315): one surface item per group,
/// reused by later adds, one undo step each; text gets the same as animator properties.
#[test]
fn surface_colors_for_extruded_shapes_and_text() {
let mut s = session();
s.execute("comp.renderer", json!({"renderer": "advanced3d"})).unwrap();
let sh = s.execute("layer.newShape", json!({})).unwrap()["layer"].as_u64().unwrap();
let r = s.execute("layer.addShapeItem", json!({"layer": sh, "kind": "sideColor"})).unwrap();
assert_eq!(r["path"], "contents/side");
let side = r["uid"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": sh, "path": "contents/side/color", "value": [0.0, 1.0, 0.0, 1.0]})).unwrap();
// Adding Side ▸ Color again reuses the item (and keeps its colour).
assert_eq!(s.execute("layer.addShapeItem", json!({"layer": sh, "kind": "sideColor"})).unwrap()["uid"].as_u64(), Some(side));
let l = layer(&s, sh);
let contents = l.props.sub("contents").unwrap();
assert_eq!(contents.groups().filter(|g| g.match_id == "side").count(), 1);
assert_eq!(l.props.prop("contents/side/color").unwrap().value, KV::Color([0.0, 1.0, 0.0, 1.0]));
// Into a shape group.
s.execute("layer.addShapeItem", json!({"layer": sh, "kind": "group"})).unwrap();
let r = s.execute("layer.addShapeItem", json!({"layer": sh, "kind": "frontColor", "group": "contents/group"})).unwrap();
assert_eq!(r["path"], "contents/group/contents/front");
assert!(s.execute("layer.addShapeItem", json!({"layer": sh, "kind": "sideShininess"})).is_err());
s.undo();
assert!(layer(&s, sh).props.group("contents/group/contents/front").is_none());
let cid = s.active_comp_id().unwrap();
s.execute("prop.set", json!({"layer": sh, "path": "geometryOptions/extrusionDepth", "value": 40})).unwrap();
let _ = s.render(cid, s.time(), Default::default());
// Text: Animate ▸ Front / Bevel / Side / Back ▸ Color.
let t = s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap();
for k in ["frontColor", "bevelColor", "sideColor", "backColor"] {
s.execute("layer.addTextAnimator", json!({"layer": t, "property": k})).unwrap();
}
let l = layer(&s, t);
assert!(l.props.prop("text/animators/#3/properties/sideColor").is_some());
s.execute("prop.set", json!({"layer": t, "path": "geometryOptions/extrusionDepth", "value": 20})).unwrap();
let _ = s.render(cid, s.time(), Default::default());
}
#[test]
fn environment_layer_toggle() {
let mut s = session();
+58 -8
View File
@@ -203,9 +203,44 @@ fn rings(style: BevelStyle, bevel: f64, depth: f64) -> Vec<(f64, f64)> {
out
}
/// A surface of an extruded mesh, which After Effects colours and shades separately (the
/// Front / Bevel / Side / Back material groups).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Surface {
/// The front cap (facing the camera, on the layer plane).
Front,
/// The bevelled rings between the caps and the walls (front and back).
Bevel,
/// The straight walls.
Side,
/// The back cap.
Back,
}
impl Surface {
pub const ALL: [Surface; 4] = [Surface::Front, Surface::Bevel, Surface::Side, Surface::Back];
/// Index into [`Surface::ALL`] (and into the primitives of [`extrude_surfaces`]).
pub fn index(self) -> usize {
self as usize
}
}
/// Build the extruded mesh of outlines (one primitive, material 0).
pub fn extrude(outlines: &[Outline], p: &ExtrudeParams) -> Primitive {
let mut prim = Primitive { material: Some(0), ..Default::default() };
build(outlines, p, false).into_iter().next().unwrap_or_default()
}
/// [`extrude`] split by surface: one primitive (material 0) per [`Surface`], in
/// [`Surface::ALL`] order. Together they hold the same triangles as [`extrude`].
pub fn extrude_surfaces(outlines: &[Outline], p: &ExtrudeParams) -> [Primitive; 4] {
let mut v = build(outlines, p, true).into_iter();
std::array::from_fn(|_| v.next().unwrap_or_default())
}
/// The extruded mesh in one primitive (`split` false) or one per [`Surface`].
fn build(outlines: &[Outline], p: &ExtrudeParams, split: bool) -> Vec<Primitive> {
let mut prims = vec![Primitive { material: Some(0), ..Default::default() }; if split { 4 } else { 1 }];
let slot = |s: Surface| if split { s.index() } else { 0 };
let depth = p.depth.max(1e-3);
let push = |prim: &mut Primitive, pos: [f64; 3], n: [f64; 3]| -> u32 {
prim.positions.push([pos[0] as f32, pos[1] as f32, pos[2] as f32]);
@@ -271,10 +306,11 @@ pub fn extrude(outlines: &[Outline], p: &ExtrudeParams) -> Primitive {
};
let (ni, nj) = (vn(i), vn(j));
let nrm = |xy: P, pr: (f64, f64)| [xy[0] * pr.0, xy[1] * pr.0, pr.1];
let ai = push(&mut prim, ring_pos[ci][k][i], nrm(ni, pa));
let aj = push(&mut prim, ring_pos[ci][k][j], nrm(nj, pa));
let bj = push(&mut prim, ring_pos[ci][k + 1][j], nrm(nj, pb));
let bi = push(&mut prim, ring_pos[ci][k + 1][i], nrm(ni, pb));
let Some(prim) = prims.get_mut(slot(if is_wall { Surface::Side } else { Surface::Bevel })) else { continue };
let ai = push(prim, ring_pos[ci][k][i], nrm(ni, pa));
let aj = push(prim, ring_pos[ci][k][j], nrm(nj, pa));
let bj = push(prim, ring_pos[ci][k + 1][j], nrm(nj, pb));
let bi = push(prim, ring_pos[ci][k + 1][i], nrm(ni, pb));
prim.indices.extend([ai, aj, bj, ai, bj, bi]);
}
}
@@ -286,10 +322,11 @@ pub fn extrude(outlines: &[Outline], p: &ExtrudeParams) -> Primitive {
let tris = triangulate(&outer2, &holes2);
let flat: Vec<(usize, usize)> = (0..cs.len()).flat_map(|ci| (0..cs[ci].pts.len()).map(move |i| (ci, i))).collect();
for (front, nz) in [(true, -1.0), (false, 1.0)] {
let Some(prim) = prims.get_mut(slot(if front { Surface::Front } else { Surface::Back })) else { continue };
let base = prim.positions.len() as u32;
for &(ci, i) in &flat {
let r = if front { 0 } else { ring_pos[ci].len() - 1 };
push(&mut prim, ring_pos[ci][r][i], [0.0, 0.0, nz]);
push(prim, ring_pos[ci][r][i], [0.0, 0.0, nz]);
}
for t in &tris {
let (a, b, c) = (base + t[0] as u32, base + t[1] as u32, base + t[2] as u32);
@@ -297,7 +334,7 @@ pub fn extrude(outlines: &[Outline], p: &ExtrudeParams) -> Primitive {
}
}
}
prim
prims
}
/// Gap (pixels) between stacked extrusions of one layer: paint drawn later (a stroke over its
@@ -309,9 +346,22 @@ pub const STACK_GAP: f64 = 0.02;
/// `level × STACK_GAP` towards the camera (−z).
pub fn extrude_stacked(outlines: &[Outline], p: &ExtrudeParams, level: usize) -> Primitive {
let mut prim = extrude(outlines, p);
stack(&mut prim, level);
prim
}
/// [`extrude_surfaces`] at stacking level `level` (see [`extrude_stacked`]).
pub fn extrude_surfaces_stacked(outlines: &[Outline], p: &ExtrudeParams, level: usize) -> [Primitive; 4] {
let mut prims = extrude_surfaces(outlines, p);
for prim in &mut prims {
stack(prim, level);
}
prims
}
fn stack(prim: &mut Primitive, level: usize) {
let dz = (level as f64 * STACK_GAP) as f32;
for v in &mut prim.positions {
v[2] -= dz;
}
prim
}
+29
View File
@@ -308,6 +308,35 @@ fn bevels_shrink_the_caps() {
assert!(c.positions.len() > m.positions.len());
}
#[test]
fn extrusions_split_into_front_bevel_side_and_back() {
use super::extrude::{Surface, extrude_surfaces, extrude_surfaces_stacked};
let square = Outline { outer: vec![[0.0, 0.0], [100.0, 0.0], [100.0, 100.0], [0.0, 100.0]], holes: vec![] };
let p = ExtrudeParams { bevel: BevelStyle::Angular, bevel_depth: 10.0, hole_bevel: 1.0, depth: 50.0 };
let whole = extrude(std::slice::from_ref(&square), &p);
let parts = extrude_surfaces(std::slice::from_ref(&square), &p);
// The parts hold exactly the triangles of the whole mesh.
assert_eq!(parts.iter().map(|m| m.triangle_count()).sum::<usize>(), whole.triangle_count());
let [front, bevel, side, back] = &parts;
// Caps: flat, at the layer plane (front, facing −z) and at the extrusion depth (back, +z).
assert!(front.positions.iter().all(|q| q[2] == 0.0) && front.normals.iter().all(|n| n[2] < -0.99));
assert!(back.positions.iter().all(|q| q[2] == 50.0) && back.normals.iter().all(|n| n[2] > 0.99));
// Walls: the full outline, between the bevels (z 10..40), with sideways normals.
assert!(side.positions.iter().all(|q| (10.0..=40.0).contains(&q[2])) && side.normals.iter().all(|n| n[2].abs() < 1e-6));
assert!(side.positions.iter().any(|q| q[0] == 0.0));
// Bevels: slanted between the caps and the walls, front and back.
assert!(bevel.triangle_count() > 0 && bevel.normals.iter().all(|n| n[2].abs() > 0.1 && n[2].abs() < 0.99));
assert!(bevel.positions.iter().any(|q| q[2] == 0.0) && bevel.positions.iter().any(|q| q[2] == 50.0));
assert_eq!(Surface::ALL.map(Surface::index), [0, 1, 2, 3]);
// Without a bevel there is nothing on the Bevel surface.
let flat = extrude_surfaces(std::slice::from_ref(&square), &ExtrudeParams { bevel: BevelStyle::None, ..p });
assert_eq!(flat[Surface::Bevel.index()].triangle_count(), 0);
assert!(flat[Surface::Side.index()].triangle_count() > 0);
// Stacked like `extrude_stacked`.
let up = extrude_surfaces_stacked(std::slice::from_ref(&square), &p, 2);
assert!(up[0].positions.iter().all(|q| (q[2] + 2.0 * super::extrude::STACK_GAP as f32).abs() < 1e-6));
}
#[test]
fn contours_group_into_outlines_by_nesting() {
let sq = |x: f64, y: f64, s: f64| vec![[x, y], [x + s, y], [x + s, y + s], [x, y + s]];
+39
View File
@@ -265,6 +265,11 @@ pub const TEXT_ANIMATOR_KINDS: &[(&str, &str)] = &[
("strokeOpacity", "Stroke Color: Opacity"),
("strokeWidth", "Stroke Width"),
("tracking", "Tracking"),
("-", "-"),
("frontColor", "Front: Color"),
("bevelColor", "Bevel: Color"),
("sideColor", "Side: Color"),
("backColor", "Back: Color"),
];
fn vec3_prop(ids: &mut Ids, m: &str, name: &str, v: [f64; 3], ui: ParamUi, three_d: bool) -> Property {
@@ -318,6 +323,11 @@ pub fn text_anim_props(ids: &mut Ids, kind: &str, three_d: bool) -> Vec<Property
"strokeBrightness" => vec![pct_slider(ids, "strokeBrightness", "Stroke Brightness", 0.0, -100.0)],
"strokeOpacity" => vec![pct_slider(ids, "strokeOpacity", "Stroke Opacity", 100.0, 0.0)],
"strokeWidth" => vec![ids.prop("strokeWidth", "Stroke Width", Value::Scalar(0.0)).with_ui(ParamUi::Pixels)],
// Colours of the extruded characters' surfaces (Advanced 3D).
"frontColor" => vec![ids.prop("frontColor", "Front Color", Value::Color([1.0, 0.0, 0.0, 1.0])).with_ui(ParamUi::Color)],
"bevelColor" => vec![ids.prop("bevelColor", "Bevel Color", Value::Color([1.0, 0.0, 0.0, 1.0])).with_ui(ParamUi::Color)],
"sideColor" => vec![ids.prop("sideColor", "Side Color", Value::Color([1.0, 0.0, 0.0, 1.0])).with_ui(ParamUi::Color)],
"backColor" => vec![ids.prop("backColor", "Back Color", Value::Color([1.0, 0.0, 0.0, 1.0])).with_ui(ParamUi::Color)],
"tracking" => {
let tt = fixed(ids.prop("trackingType", "Tracking Type", Value::Enum(0)).with_ui(popup(&["Before & After", "Before", "After"])));
vec![tt, ids.prop("tracking", "Tracking Amount", Value::Scalar(0.0))]
@@ -530,6 +540,35 @@ pub fn shape_repeater(ids: &mut Ids, copies: f64, offset_pos: [f64; 2]) -> PropG
.with(tr)
}
/// Shape contents items that colour the surfaces of an extruded shape (Advanced 3D): (match
/// id, name), in Front, Bevel, Side, Back order. Each applies to every paint of its group and
/// the groups nested in it.
pub const SHAPE_SURFACES: [(&str, &str); 4] = [("front", "Front"), ("bevel", "Bevel"), ("side", "Side"), ("back", "Back")];
/// The `layer.addShapeItem` kind of a surface property (`sideColor`): (surface match id,
/// property match id).
pub fn shape_surface_kind(kind: &str) -> Option<(&'static str, &'static str)> {
let (surface, prop) = SHAPE_SURFACES.iter().find_map(|(m, _)| kind.strip_prefix(m).map(|rest| (*m, rest)))?;
match prop {
"Color" => Some((surface, "color")),
_ => None,
}
}
/// A surface property of a shape surface item (see [`SHAPE_SURFACES`]).
pub fn shape_surface_prop(ids: &mut Ids, prop: &str) -> Option<Property> {
match prop {
"color" => Some(ids.prop("color", "Color", Value::Color([1.0, 0.0, 0.0, 1.0])).with_ui(ParamUi::Color)),
_ => None,
}
}
/// An empty shape surface item (`front`, `bevel`, `side` or `back`).
pub fn shape_surface(ids: &mut Ids, surface: &str) -> Option<PropGroup> {
let (m, name) = SHAPE_SURFACES.iter().find(|(m, _)| *m == surface)?;
Some(ids.group(m, name))
}
pub fn shape_simple_op(ids: &mut Ids, kind: &str) -> Option<PropGroup> {
let g = match kind {
"round" => indexed(ids.group("round", "Round Corners 1")).with(ids.prop("radius", "Radius", Value::Scalar(10.0)).with_ui(ParamUi::Pixels)),
+35 -5
View File
@@ -6,7 +6,9 @@
//! the paths of nested groups;
//! - paint items (fills, strokes, gradient fills/strokes) paint the paths above them, *as modified
//! by every operator in the stack* — a Trim Paths below a Stroke still trims it;
//! - items higher in the list draw on top.
//! - items higher in the list draw on top;
//! - surface items (Front, Bevel, Side, Back) colour those surfaces of the Advanced 3D
//! extrusion of every paint in their group and its nested groups (the innermost wins).
use effectcraft_color::{BlendMode, blend_pixel};
use effectcraft_effects::Buf;
@@ -53,8 +55,14 @@ struct Draw {
xf: Mat3,
opacity: f32,
blend: BlendMode,
/// Front / Bevel / Side / Back colours of the paint's extrusion (Advanced 3D).
surfaces: SurfaceColors,
}
/// Straight RGB colours of an extrusion's surfaces, by [`effectcraft_model::extrude::Surface`]
/// index (Front, Bevel, Side, Back); `None` keeps the paint's colour.
pub type SurfaceColors = [Option<[f32; 3]>; 4];
fn mat_path(path: &BezPath, m: &Mat3) -> BezPath {
effectcraft_path::transform(std::slice::from_ref(path), m).pop().unwrap_or_default()
}
@@ -179,6 +187,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
let mut draws: Vec<Draw> = Vec::new();
let mut live: Vec<usize> = Vec::new();
let opacity = |g: &PropGroup| (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32;
let mut surfaces: SurfaceColors = [None; 4];
for node in &contents.children {
let Node::Group(g) = node else { continue };
if !g.enabled {
@@ -224,6 +233,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
xf: Mat3::IDENTITY,
opacity: opacity(g),
blend: blend_mode(ctx, layer, g),
surfaces: [None; 4],
});
}
"stroke" => {
@@ -236,6 +246,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
xf: Mat3::IDENTITY,
opacity: opacity(g),
blend: blend_mode(ctx, layer, g),
surfaces: [None; 4],
});
}
"gfill" | "gstroke" => {
@@ -258,6 +269,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
xf: Mat3::IDENTITY,
opacity: opacity(g),
blend: blend_mode(ctx, layer, g),
surfaces: [None; 4],
});
}
"trim" => {
@@ -386,7 +398,24 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
}
}
}
_ => {}
m => {
if let Some(i) = effectcraft_project::build::SHAPE_SURFACES.iter().position(|(s, _)| *s == m)
&& g.get("color").is_some()
&& let Some(slot) = surfaces.get_mut(i)
{
let c = ctx.color(layer, g, "color");
*slot = Some([c[0], c[1], c[2]]);
}
}
}
}
// Surface items colour every paint of the group, wherever they sit in it; nested groups'
// own surface items were applied first and win.
if surfaces.iter().any(Option::is_some) {
for d in &mut draws {
for (s, g) in d.surfaces.iter_mut().zip(surfaces) {
*s = s.or(g);
}
}
}
(draws, live)
@@ -622,8 +651,9 @@ pub fn fill_outlines(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> Vec<
/// The painted regions of a shape layer for Advanced 3D extrusion, bottom paint first: fills
/// (gradient fills: the colour half way along) and strokes, each stroke turned into the region
/// it covers (outline of the stroke with its width, joins, caps and dashes, overlaps resolved).
pub fn extrusion_outlines(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> Vec<(Vec<BezPath>, [f32; 4])> {
/// it covers (outline of the stroke with its width, joins, caps and dashes, overlaps resolved),
/// with the colours of their extrusion's surfaces.
pub fn extrusion_outlines(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> Vec<(Vec<BezPath>, [f32; 4], SurfaceColors)> {
let mut arena = Vec::new();
let (draws, _) = collect(ctx, layer, contents, &mut arena);
draws
@@ -641,7 +671,7 @@ pub fn extrusion_outlines(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) ->
Paint::Stroke { color, style } => (*color, vec![stroke_region(&paths, style)?]),
Paint::Gradient { g, stroke: Some(style), .. } => (g.sample(0.5), vec![stroke_region(&paths, style)?]),
};
Some((region, [c[0], c[1], c[2], c[3] * d.opacity]))
Some((region, [c[0], c[1], c[2], c[3] * d.opacity], d.surfaces))
})
.collect()
}
+25
View File
@@ -58,9 +58,21 @@ pub struct CharXf {
/// Variable Font Axes offsets (axis tag, user units), the first `n_axes` used.
pub axes: [([u8; 4], f64); 4],
pub n_axes: u8,
/// Front / Bevel / Side / Back Color of the extruded character (Advanced 3D), by
/// `effectcraft_model::extrude::Surface` index, and the selection each was set with.
pub surface: [Option<[f32; 4]>; 4],
pub surface_k: [f32; 4],
}
impl CharXf {
/// The surface colours of an extrusion painted `base` (the character's fill or stroke):
/// each animated surface colour blended over `base` by its selection.
pub fn surface_colors(&self, base: [f32; 4]) -> crate::shapes::SurfaceColors {
std::array::from_fn(|i| {
let (c, k) = (self.surface.get(i).copied().flatten()?, self.surface_k.get(i).copied().unwrap_or(0.0));
Some(std::array::from_fn(|d| base[d] + (c[d] - base[d]) * k))
})
}
/// The variable-font axis offsets that move the outline.
pub fn axis_deltas(&self) -> Vec<(String, f32)> {
self.axes[..self.n_axes as usize]
@@ -79,6 +91,10 @@ impl CharXf {
}
}
/// Animator property match ids of Front / Bevel / Side / Back Color, in
/// `effectcraft_model::extrude::Surface::ALL` order.
pub const SURFACE_ANIM_PROPS: [&str; 4] = ["frontColor", "bevelColor", "sideColor", "backColor"];
/// Animator property match id of a Variable Font Axes property (`axis_wght`).
pub const AXIS_PREFIX: &str = "axis_";
@@ -115,6 +131,8 @@ impl Default for CharXf {
blur: [0.0; 2],
axes: [([0; 4], 0.0); 4],
n_axes: 0,
surface: [None; 4],
surface_k: [0.0; 4],
}
}
}
@@ -274,6 +292,7 @@ pub fn char_transforms(ctx: &EvalCtx, layer: &Layer, text: &PropGroup, lay: &Tex
let char_range = props.get("characterRange").map(|p| ctx.value(layer, p).as_enum());
let char_align = props.get("characterAlignment").map(|p| ctx.value(layer, p).as_enum());
let blur = props.get("blur").map(|p| ctx.value(layer, p).as_vec2());
let surface = SURFACE_ANIM_PROPS.map(|m| props.get(m).map(|p| ctx.value(layer, p).as_color()));
let axes: Vec<([u8; 4], f64)> = props
.props()
.filter_map(|p| {
@@ -385,6 +404,12 @@ pub fn char_transforms(ctx: &EvalCtx, layer: &Layer, text: &PropGroup, lay: &Tex
for (tag, v) in &axes {
c.add_axis(*tag, v * k);
}
for ((s, sk), v) in c.surface.iter_mut().zip(&mut c.surface_k).zip(surface) {
if let Some(v) = v {
*s = Some(v);
*sk = (*sk + k as f32).clamp(0.0, 1.0);
}
}
}
}
out
+27 -16
View File
@@ -543,10 +543,11 @@ pub fn extrusion_params(ctx: &EvalCtx, layer: &Layer) -> Option<model::extrude::
/// space). Fills and strokes are both extruded (a stroke as the region it covers, bevelled like
/// the fill), stacked in the layer's paint order (see [`model::extrude::extrude_stacked`]).
/// Text extrudes each character in its own space, so per-character 3D animation (position,
/// rotation, scale in Z) carries over.
/// rotation, scale in Z) carries over. A paint whose Front / Bevel / Side / Back are coloured
/// (shape surface items, text animators) comes as one mesh per surface, in that order.
pub fn extruded_meshes(ctx: &EvalCtx, layer: &Layer, p: &model::extrude::ExtrudeParams) -> Vec<(Primitive, [f32; 4], Mat4)> {
// (paths, colour, mesh → layer, stacking level)
let mut groups: Vec<(Vec<kurbo::BezPath>, [f32; 4], Mat4, usize)> = vec![];
// (paths, colour, mesh → layer, stacking level, surface colours)
let mut groups: Vec<(Vec<kurbo::BezPath>, [f32; 4], Mat4, usize, crate::shapes::SurfaceColors)> = vec![];
match layer.source {
LayerSource::Text => {
if let Some(tg) = crate::text::text_geom(ctx, layer) {
@@ -561,13 +562,13 @@ pub fn extruded_meshes(ctx: &EvalCtx, layer: &Layer, p: &model::extrude::Extrude
let op = (g.xf.opacity / 100.0).clamp(0.0, 1.0) as f32;
if g.apply_fill && g.fill[3] * op > 0.0 {
let c = [g.fill[0], g.fill[1], g.fill[2], g.fill[3] * op];
groups.push((vec![g.local.clone()], c, g.m, usize::from(!stroke_on_top)));
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() };
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)));
groups.push((vec![region], c, g.m, usize::from(stroke_on_top), g.xf.surface_colors(c)));
}
}
}
@@ -575,22 +576,32 @@ pub fn extruded_meshes(ctx: &EvalCtx, layer: &Layer, p: &model::extrude::Extrude
}
LayerSource::Shape => {
if let Some(c) = layer.props.sub("contents") {
groups = crate::shapes::extrusion_outlines(ctx, layer, c).into_iter().enumerate().map(|(k, (ps, c))| (ps, c, Mat4::IDENTITY, k)).collect();
groups =
crate::shapes::extrusion_outlines(ctx, layer, c).into_iter().enumerate().map(|(k, (ps, c, s))| (ps, c, Mat4::IDENTITY, k, s)).collect();
}
}
_ => {}
}
groups
.into_iter()
.filter_map(|(paths, color, m, level)| {
let cs: Vec<Vec<[f64; 2]>> = paths.iter().flat_map(|bp| contours(bp, 0.25)).collect();
let outlines = model::extrude::group_contours(cs);
if outlines.is_empty() {
return None;
let mut out = vec![];
for (paths, color, m, level, surfaces) in groups {
let cs: Vec<Vec<[f64; 2]>> = paths.iter().flat_map(|bp| contours(bp, 0.25)).collect();
let outlines = model::extrude::group_contours(cs);
if outlines.is_empty() {
continue;
}
if surfaces.iter().all(Option::is_none) {
out.push((model::extrude::extrude_stacked(&outlines, p, level), color, m));
continue;
}
for (mesh, s) in model::extrude::extrude_surfaces_stacked(&outlines, p, level).into_iter().zip(surfaces) {
if mesh.indices.is_empty() {
continue;
}
Some((model::extrude::extrude_stacked(&outlines, p, level), color, m))
})
.collect()
let c = s.map_or(color, |s| [s[0], s[1], s[2], color[3]]);
out.push((mesh, c, m));
}
}
out
}
fn extruded_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, p: &model::extrude::ExtrudeParams, b: &mut Builder) {
+79
View File
@@ -313,6 +313,85 @@ fn extruded_text_renders_as_a_mesh() {
assert!(img.data.iter().filter(|q| q[3] > 0.5).count() > 200);
}
#[test]
fn shape_surface_items_colour_the_extruded_surfaces() {
let (mut p, cid) = project(200, 200);
add(&mut p, cid, LayerSource::Shape, |l| {
l.switches.three_d = true;
let mut next = 10_000u64;
let ids = &mut build::Ids(&mut next);
let surface = |ids: &mut build::Ids, m: &str, c: [f64; 4]| {
let mut g = build::shape_surface(ids, m).unwrap();
g.children.push(build::shape_surface_prop(ids, "color").unwrap().into());
if let Some(effectcraft_project::Node::Prop(pr)) = g.children.last_mut() {
pr.value = Value::Color(c);
}
g
};
// A white square whose group colours its sides green; the layer's Contents colour every
// front blue and every side red, but the group's own Side wins.
let rect = build::shape_rect(ids, [80.0, 80.0], [0.0, 0.0], 0.0);
let fill = build::shape_fill(ids, [1.0, 1.0, 1.0, 1.0]);
let side = surface(ids, "side", [0.0, 1.0, 0.0, 1.0]);
let group = build::shape_group(ids, "Group 1", vec![rect, fill, side]);
let front = surface(ids, "front", [0.0, 0.0, 1.0, 1.0]);
let outer_side = surface(ids, "side", [1.0, 0.0, 0.0, 1.0]);
let contents = l.props.sub_mut("contents").unwrap();
contents.children.extend([group.into(), front.into(), outer_side.into()]);
l.props.children.push(build::extrusion_geometry_options(ids).into());
set(l, "geometryOptions/extrusionDepth", Value::Scalar(40.0));
set(l, "transform/position", Value::Vec3([100.0, 100.0, 0.0]));
});
let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None };
let l = &ctx.comp.layers[0];
let params = scene::extrusion_params(&ctx, l).unwrap();
let meshes = scene::extruded_meshes(&ctx, l, &params);
// No bevel: Front, Side and Back (the Back keeps the fill's white).
let colours: Vec<[f32; 4]> = meshes.iter().map(|m| m.1).collect();
assert_eq!(colours, [[0.0, 0.0, 1.0, 1.0], [0.0, 1.0, 0.0, 1.0], [1.0, 1.0, 1.0, 1.0]]);
assert!(meshes[0].0.normals.iter().all(|n| n[2] < -0.99), "front cap");
assert!(meshes[1].0.normals.iter().all(|n| n[2].abs() < 1e-6), "walls");
// Rendered (unlit: base colours) at an angle: the blue face and the green side both show.
p.comp_mut(cid).unwrap().layers[0].props.prop_mut("transform/rotationY").unwrap().value = Value::Scalar(50.0);
let img = render(&p, cid);
let blue = img.data.iter().filter(|q| q[3] > 0.99 && q[2] > 0.9 && q[0] < 0.1 && q[1] < 0.1).count();
let green = img.data.iter().filter(|q| q[3] > 0.99 && q[1] > 0.9 && q[0] < 0.1 && q[2] < 0.1).count();
assert!(blue > 200 && green > 50, "blue {blue}, green {green}");
}
#[test]
fn text_animators_colour_the_extruded_surfaces() {
let (mut p, cid) = project(200, 100);
add(&mut p, cid, LayerSource::Text, |l| {
l.switches.three_d = true;
let doc = effectcraft_keyframe::TextDoc { text: "IO".into(), size: 60.0, fill: [1.0, 1.0, 1.0, 1.0], apply_fill: true, ..Default::default() };
set(l, "text/sourceText", Value::Text(Box::new(doc)));
let mut next = 10_000u64;
let ids = &mut build::Ids(&mut next);
let props = ["sideColor", "bevelColor"].iter().flat_map(|k| build::text_anim_props(ids, k, false)).collect();
let anim = build::text_animator(ids, "Animator 1", props);
l.props.group_mut("text/animators").unwrap().children.push(anim.into());
set(l, "text/animators/#1/properties/sideColor", Value::Color([0.0, 1.0, 0.0, 1.0]));
// Half the selection: the bevel colour blends half way over the fill.
set(l, "text/animators/#1/selectors/#1/advanced/amount", Value::Scalar(50.0));
l.props.children.push(build::extrusion_geometry_options(ids).into());
set(l, "geometryOptions/extrusionDepth", Value::Scalar(30.0));
set(l, "geometryOptions/bevelStyle", Value::Enum(1));
});
let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None };
let l = &ctx.comp.layers[0];
let params = scene::extrusion_params(&ctx, l).unwrap();
let meshes = scene::extruded_meshes(&ctx, l, &params);
// Two characters × (Front, Bevel, Side, Back).
assert_eq!(meshes.len(), 8);
for m in meshes.chunks(4) {
assert_eq!(m[0].1, [1.0, 1.0, 1.0, 1.0], "front keeps the fill");
assert_eq!(m[1].1, [1.0, 0.5, 0.5, 1.0], "bevel: red half way over white");
assert_eq!(m[2].1, [0.5, 1.0, 0.5, 1.0], "side: green half way over white");
assert_eq!(m[3].1, [1.0, 1.0, 1.0, 1.0], "back keeps the fill");
}
}
// ---------------------------------------------------------------- motion blur, blend modes, mattes
fn solid(p: &mut Project, color: [f32; 3], w: u32, h: u32) -> ItemId {
+30
View File
@@ -683,6 +683,11 @@ const SIMPLIFIED_CHINESE: &[(&str, &str, &str)] = &[
("layer.addTextAnimator", "Brightness", "亮度"),
("", "Stroke Color", "描边颜色"),
("layer.addTextAnimator", "Stroke Width", "描边宽度"),
("", "Front", "正面"),
("", "Bevel", "斜面"),
("", "Side", "侧面"),
("", "Back", "背面"),
("layer.addTextAnimator", "Color", "颜色"),
("layer.addTextAnimator", "Tracking", "字符间距"),
("layer.addTextAnimator", "Line Anchor", "行锚点"),
("layer.addTextAnimator", "Line Spacing", "行距"),
@@ -1308,6 +1313,11 @@ const TRADITIONAL_CHINESE: &[(&str, &str, &str)] = &[
("layer.addTextAnimator", "Brightness", "亮度"),
("", "Stroke Color", "筆畫色彩"),
("layer.addTextAnimator", "Stroke Width", "筆畫寬度"),
("", "Front", "正面"),
("", "Bevel", "斜面"),
("", "Side", "側面"),
("", "Back", "背面"),
("layer.addTextAnimator", "Color", "顏色"),
("layer.addTextAnimator", "Tracking", "字距"),
("layer.addTextAnimator", "Line Anchor", "行錨點"),
("layer.addTextAnimator", "Line Spacing", "行距"),
@@ -1930,6 +1940,11 @@ const JAPANESE: &[(&str, &str, &str)] = &[
("layer.addTextAnimator", "Brightness", "明度"),
("", "Stroke Color", "線のカラー"),
("layer.addTextAnimator", "Stroke Width", "線幅"),
("", "Front", "前面"),
("", "Bevel", "ベベル"),
("", "Side", "側面"),
("", "Back", "背面"),
("layer.addTextAnimator", "Color", "カラー"),
("layer.addTextAnimator", "Tracking", "トラッキング"),
("layer.addTextAnimator", "Line Anchor", "行のアンカー"),
("layer.addTextAnimator", "Line Spacing", "行間"),
@@ -2555,6 +2570,11 @@ const UKRAINIAN: &[(&str, &str, &str)] = &[
("layer.addTextAnimator", "Brightness", "Яскравість"),
("", "Stroke Color", "Колір обведення"),
("layer.addTextAnimator", "Stroke Width", "Товщина обведення"),
("", "Front", "Передня грань"),
("", "Bevel", "Фаска"),
("", "Side", "Бічна грань"),
("", "Back", "Задня грань"),
("layer.addTextAnimator", "Color", "Колір"),
("layer.addTextAnimator", "Tracking", "Міжлітерний інтервал"),
("layer.addTextAnimator", "Line Anchor", "Опорна точка рядка"),
("layer.addTextAnimator", "Line Spacing", "Міжрядковий інтервал"),
@@ -3181,6 +3201,11 @@ const SPANISH: &[(&str, &str, &str)] = &[
("layer.addTextAnimator", "Brightness", "Brillo"),
("", "Stroke Color", "Color de trazo"),
("layer.addTextAnimator", "Stroke Width", "Ancho de trazo"),
("", "Front", "Frente"),
("", "Bevel", "Bisel"),
("", "Side", "Lado"),
("", "Back", "Parte posterior"),
("layer.addTextAnimator", "Color", "Color"),
("layer.addTextAnimator", "Tracking", "Espaciado"),
("layer.addTextAnimator", "Line Anchor", "Anclaje de línea"),
("layer.addTextAnimator", "Line Spacing", "Interlineado"),
@@ -3805,6 +3830,11 @@ const PORTUGUESE: &[(&str, &str, &str)] = &[
("layer.addTextAnimator", "Brightness", "Brilho"),
("", "Stroke Color", "Cor do traçado"),
("layer.addTextAnimator", "Stroke Width", "Largura do traçado"),
("", "Front", "Frente"),
("", "Bevel", "Chanfro"),
("", "Side", "Lateral"),
("", "Back", "Verso"),
("layer.addTextAnimator", "Color", "Cor"),
("layer.addTextAnimator", "Tracking", "Tracking"),
("layer.addTextAnimator", "Line Anchor", "Âncora da linha"),
("layer.addTextAnimator", "Line Spacing", "Espaçamento entre linhas"),
+5
View File
@@ -3268,6 +3268,11 @@ const SHAPE_ADD_ITEMS: &[(&str, &str)] = &[
("gfill", "Gradient Fill"),
("gstroke", "Gradient Stroke"),
("-", ""),
("frontColor", "Front: Color"),
("bevelColor", "Bevel: Color"),
("sideColor", "Side: Color"),
("backColor", "Back: Color"),
("-", ""),
("merge", "Merge Paths"),
("offset", "Offset Paths"),
("pucker", "Pucker & Bloat"),
+1 -1
View File
@@ -68,7 +68,7 @@ are grouped by area; the issue tracker has the rest.
| 9 | **Effect behaviour bugs** | #580, #526 Fractal reset, #514 Apply Color LUT, #463 Curl Noise, #369 Motion Tile output size, #473 Cryptomatte selection, #474 multi-layer EXR decoded twice, #534–#536 community fixes pending | Each one breaks a look someone built | 10–20 h | G1/G2 · [effects-parity.md](effects-parity.md) |
| 10 | **Colour pipeline differences** | #494 32-bpc blend modes clamp and apply opacity differently; #495 Working Gamma 2.4 (in, also for float footage: #750); #340 ProRes 4444 tagged bt709 but sRGB-encoded; ACES 2.0 / OCIO 2.5 configs (26.5) missing; #546 / #547 viewer colour | Wrong colour in delivery is a client-visible failure | 15–30 h | G1 · [file-format-parity.md](file-format-parity.md) |
| 11 | **Text rendering and layout** | #453 CJK text in comps, #316 paragraph alignment inverted, #483 squeezed at 4:3, #417 soft at Fit, #583 font search, #550 toolbar font options | Text is in almost every motion graphic | 10–20 h | G2 · [ui-parity.md](ui-parity.md) |
| 12 | **3D interaction and depth** | No 3D transform gizmos (#392, #347); Z position (#489); camera aperture (#260); reflections (#261); bevel options (#315); 3D issue reopened (#307) | Advanced 3D is After Effects' growth area since 2023 | 20–35 h | G2 · [ui-parity.md](ui-parity.md) |
| 12 | **3D interaction and depth** | No 3D transform gizmos (#392, #347); Z position (#489); camera aperture (#260); reflections (#261); extruded surface materials beyond Front / Bevel / Side / Back Color (#315); 3D issue reopened (#307) | Advanced 3D is After Effects' growth area since 2023 | 20–35 h | G2 · [ui-parity.md](ui-parity.md) |
| 13 | **3D model formats and materials** | After Effects 26 imports FBX, USD/USDZ, STL besides glTF and OBJ (`usd_plugins`), and Substance 3D materials with mesh displacement (26.0–26.2); we read glTF, OBJ, STL (binary and ASCII, with facet colours) and FBX 7.x (binary and ASCII: meshes, hierarchy, Lambert/Phong materials and textures; no FBX animation or skinning yet) | Product shots and 3D scenes come as FBX/USDZ | 25–45 h | — · [file-format-parity.md](file-format-parity.md) |
| 14 | **Object Matte** (26.2, disk cache in 26.5) | Not present; MobileSAM is already in `effectcraft-segment` and could back it | The new one-click roto; Roto Brush covers part of it | 15–30 h | G7 · [target-app-parity.md](target-app-parity.md) |
| 15 | **Hardware video decode and encode** | No VideoToolbox, NVENC, Quick Sync or VA-API; #498 asks for it; FilmCraft has VideoToolbox / NVENC / VA-API paths to borrow | 4K playback and export speed | 25–45 h | G5/G6 · [hardware-parity.md](hardware-parity.md) |