From fb5a5e9ac0900268d090c53aa4b5c408b095967d Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 12:05:36 +0200 Subject: [PATCH] #315: Front / Bevel / Side / Back Color for extruded shapes and text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: In Advanced 3D, an extruded shape or text layer was one mesh in its paint colour. After Effects colours the front, bevel, side and back of an extrusion separately (shape Contents ▸ Add ▸ Front/Bevel/Side/Back, text Animate ▸ Front/Bevel/Side/Back), and EffectCraft had none of it. Fix: The extruder can split its mesh by surface (front cap, bevel rings, walls, back cap). Shape layers get Front / Bevel / Side / Back contents items (layer.addShapeItem kinds frontColor…backColor, one item per group, colouring every paint of the group and its nested groups; the innermost wins). Text animators get Front / Bevel / Side / Back Color, blended over the character's fill or stroke by the selection. Paints with a surface colour render as one mesh per surface. Menus, the Contents Add: pop-up and the menu translations list them. The other surface material properties (Ambient, Diffuse, Specular, Metal, Reflection, ...) remain. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/layer.rs | 49 +++++++++++---- crates/engine/src/commands/paths.rs | 2 +- crates/engine/src/commands/text_anim.rs | 2 +- crates/engine/src/menus.rs | 8 +++ crates/engine/src/tests_model3d.rs | 38 ++++++++++++ crates/model/src/extrude.rs | 66 ++++++++++++++++++--- crates/model/src/tests.rs | 29 +++++++++ crates/project/src/build.rs | 39 ++++++++++++ crates/render/src/shapes.rs | 40 +++++++++++-- crates/render/src/text.rs | 25 ++++++++ crates/render/src/three_d/adv/scene.rs | 43 +++++++++----- crates/render/src/three_d/adv/tests.rs | 79 +++++++++++++++++++++++++ crates/ui-egui/src/i18n.rs | 30 ++++++++++ crates/ui-egui/src/panels/timeline.rs | 5 ++ docs/gaps.md | 2 +- 15 files changed, 413 insertions(+), 44 deletions(-) diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 09bad38f..1bfb3734 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -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 { 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 { }; 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 { 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) -> 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 { 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 { "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 ), diff --git a/crates/engine/src/commands/paths.rs b/crates/engine/src/commands/paths.rs index 8219306a..c74c3bb5 100644 --- a/crates/engine/src/commands/paths.rs +++ b/crates/engine/src/commands/paths.rs @@ -284,7 +284,7 @@ fn free_transform(s: &mut Session, p: &Value) -> Result { 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). diff --git a/crates/engine/src/commands/text_anim.rs b/crates/engine/src/commands/text_anim.rs index 1496af84..1904fe19 100644 --- a/crates/engine/src/commands/text_anim.rs +++ b/crates/engine/src/commands/text_anim.rs @@ -431,7 +431,7 @@ pub fn specs() -> Vec { "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 ), diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index e98e29f6..ca5f541e 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -1021,6 +1021,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"]} diff --git a/crates/engine/src/tests_model3d.rs b/crates/engine/src/tests_model3d.rs index d142e5f7..40a16579 100644 --- a/crates/engine/src/tests_model3d.rs +++ b/crates/engine/src/tests_model3d.rs @@ -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(); diff --git a/crates/model/src/extrude.rs b/crates/model/src/extrude.rs index 058be762..114606b9 100644 --- a/crates/model/src/extrude.rs +++ b/crates/model/src/extrude.rs @@ -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 { + 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 } diff --git a/crates/model/src/tests.rs b/crates/model/src/tests.rs index 032ca47d..0b179749 100644 --- a/crates/model/src/tests.rs +++ b/crates/model/src/tests.rs @@ -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::(), 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]]; diff --git a/crates/project/src/build.rs b/crates/project/src/build.rs index 71edc7ff..935c03b0 100644 --- a/crates/project/src/build.rs +++ b/crates/project/src/build.rs @@ -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 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 { + 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 { + 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 { 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)), diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs index e3cbca4a..aa46fa50 100644 --- a/crates/render/src/shapes.rs +++ b/crates/render/src/shapes.rs @@ -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 = Vec::new(); let mut live: Vec = 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 { @@ -236,6 +246,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec { @@ -258,6 +269,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec { @@ -386,7 +398,24 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec {} + 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, [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, [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() } diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 7d7f7615..11318faf 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -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 diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index 4abbdddd..5b5ae956 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -543,10 +543,11 @@ pub fn extrusion_params(ctx: &EvalCtx, layer: &Layer) -> Option Vec<(Primitive, [f32; 4], Mat4)> { - // (paths, colour, mesh → layer, stacking level) - let mut groups: Vec<(Vec, [f32; 4], Mat4, usize)> = vec![]; + // (paths, colour, mesh → layer, stacking level, surface colours) + let mut groups: Vec<(Vec, [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> = 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> = 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) { diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index 67eb9ed6..d276a0f0 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -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, ¶ms); + // 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, ¶ms); + // 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 { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 4bbac2f7..f7e949ca 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -682,6 +682,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", "行距"), @@ -1306,6 +1311,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", "行距"), @@ -1927,6 +1937,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", "行間"), @@ -2551,6 +2566,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", "Міжрядковий інтервал"), @@ -3176,6 +3196,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"), @@ -3799,6 +3824,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"), diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 61b2d22d..0cdd15e7 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3257,6 +3257,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"), diff --git a/docs/gaps.md b/docs/gaps.md index acb22a63..95896477 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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 no Working Gamma 2.4; #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 and OBJ | 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) |