Files
effectcraft/crates/model/src/extrude.rs
vangel76andClaude Opus 5.5 fb5a5e9ac0 #315: Front / Bevel / Side / Back Color for extruded shapes and text
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 <noreply@anthropic.com>
2026-10-11 12:05:36 +02:00

368 lines
14 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Extruded, bevelled meshes from 2D outlines (Advanced 3D text and shape layers' Geometry
//! Options: Bevel Style, Bevel Depth, Hole Bevel Depth, Extrusion Depth).
//!
//! Input outlines are closed polylines in layer space (x right, y down). The mesh is in layer
//! space too: the front cap lies on the layer plane (z = 0, facing the camera, −z), the back cap
//! at z = depth. Each contour gets a ring of vertices per profile step (inset from the outline
//! by the bevel, at a depth along the profile); consecutive rings are joined by quads, the caps
//! are triangulated from the innermost front/back rings with holes. Caps and walls share
//! positions exactly, so the mesh is watertight (welded by position).
use serde::{Deserialize, Serialize};
use crate::Primitive;
use crate::triangulate::{signed_area, triangulate};
type P = [f64; 2];
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum BevelStyle {
#[default]
None,
Angular,
Concave,
Convex,
}
impl BevelStyle {
pub const ALL: [BevelStyle; 4] = [BevelStyle::None, BevelStyle::Angular, BevelStyle::Concave, BevelStyle::Convex];
pub fn label(self) -> &'static str {
match self {
BevelStyle::None => "None",
BevelStyle::Angular => "Angular",
BevelStyle::Concave => "Concave",
BevelStyle::Convex => "Convex",
}
}
pub fn from_index(i: u32) -> BevelStyle {
BevelStyle::ALL.get(i as usize).copied().unwrap_or_default()
}
/// (inset fraction 1 → 0, depth fraction 0 → 1) along the profile at `t` in 0..1.
fn profile(self, t: f64) -> (f64, f64) {
let th = t * std::f64::consts::FRAC_PI_2;
match self {
BevelStyle::None | BevelStyle::Angular => (1.0 - t, t),
// Quarter circle bulging out towards the outline corner.
BevelStyle::Convex => (1.0 - th.sin(), 1.0 - th.cos()),
// Quarter circle scooped in.
BevelStyle::Concave => (th.cos(), th.sin()),
}
}
fn steps(self) -> usize {
match self {
BevelStyle::None => 0,
BevelStyle::Angular => 1,
_ => 6,
}
}
}
/// Geometry Options of an extruded layer.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ExtrudeParams {
pub bevel: BevelStyle,
/// Pixels.
pub bevel_depth: f64,
/// Hole bevels as a fraction of the bevel depth (0..1).
pub hole_bevel: f64,
/// Pixels (> 0).
pub depth: f64,
}
impl Default for ExtrudeParams {
fn default() -> Self {
ExtrudeParams { bevel: BevelStyle::None, bevel_depth: 2.0, hole_bevel: 1.0, depth: 50.0 }
}
}
/// An outer contour with its holes.
#[derive(Clone, Debug, PartialEq)]
pub struct Outline {
pub outer: Vec<P>,
pub holes: Vec<Vec<P>>,
}
fn point_in(p: P, c: &[P]) -> bool {
let n = c.len();
let mut inside = false;
let mut j = n - 1;
for i in 0..n {
let (a, b) = (c[i], c[j]);
if (a[1] > p[1]) != (b[1] > p[1]) && p[0] < (b[0] - a[0]) * (p[1] - a[1]) / (b[1] - a[1]) + a[0] {
inside = !inside;
}
j = i;
}
inside
}
/// Group closed contours into outlines by nesting depth (even depth = outer, odd = hole of the
/// innermost containing outer contour). Degenerate contours are dropped.
pub fn group_contours(contours: Vec<Vec<P>>) -> Vec<Outline> {
let cs: Vec<Vec<P>> = contours
.into_iter()
.map(|mut c| {
c.dedup();
while c.len() > 1 && c.first() == c.last() {
c.pop();
}
c
})
.filter(|c| c.len() >= 3 && signed_area(c).abs() > 1e-9)
.collect();
let n = cs.len();
let area: Vec<f64> = cs.iter().map(|c| signed_area(c).abs()).collect();
// Containers of each contour (by a vertex inside; nested contours do not cross).
let parents: Vec<Vec<usize>> = (0..n)
.map(|i| {
let probe = cs[i][0];
(0..n).filter(|&j| j != i && area[j] > area[i] && point_in(probe, &cs[j])).collect()
})
.collect();
let mut outlines: Vec<Outline> = vec![];
let mut slot = vec![usize::MAX; n];
let mut order: Vec<usize> = (0..n).collect();
order.sort_by_key(|&i| parents[i].len());
for i in order {
let depth = parents[i].len();
if depth.is_multiple_of(2) {
slot[i] = outlines.len();
outlines.push(Outline { outer: cs[i].clone(), holes: vec![] });
} else if let Some(&p) = parents[i].iter().filter(|&&j| parents[j].len() == depth - 1).min_by(|&&a, &&b| area[a].total_cmp(&area[b]))
&& slot[p] != usize::MAX
{
outlines[slot[p]].holes.push(cs[i].clone());
}
}
outlines
}
struct Contour {
pts: Vec<P>,
/// Outward unit normal of edge i (pts[i] → pts[i+1]).
edge_n: Vec<P>,
/// Miter offset direction per vertex (outward, length ≥ 1).
miter: Vec<P>,
/// Smooth-shaded vertex (shallow corner).
smooth: Vec<bool>,
bevel: f64,
}
fn contour(mut pts: Vec<P>, outer: bool, bevel: f64) -> Contour {
// Outer counter-clockwise (in x/y), holes clockwise: the outward normal is (dy, −dx).
let ccw = signed_area(&pts) > 0.0;
if ccw != outer {
pts.reverse();
}
let n = pts.len();
let edge_n: Vec<P> = (0..n)
.map(|i| {
let (a, b) = (pts[i], pts[(i + 1) % n]);
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
let l = dx.hypot(dy).max(1e-12);
[dy / l, -dx / l]
})
.collect();
let mut miter = vec![[0.0; 2]; n];
let mut smooth = vec![false; n];
for i in 0..n {
let (n0, n1) = (edge_n[(i + n - 1) % n], edge_n[i]);
let d = n0[0] * n1[0] + n0[1] * n1[1];
let s = [n0[0] + n1[0], n0[1] + n1[1]];
let k = 1.0 + d;
miter[i] = if k < 0.25 {
// Very sharp corner: limit the miter (offset along the bisector, length 2.8).
let l = s[0].hypot(s[1]).max(1e-12);
[s[0] / l * 2.8, s[1] / l * 2.8]
} else {
[s[0] / k, s[1] / k]
};
smooth[i] = d > 0.82; // < ~35° turn
}
Contour { pts, edge_n, miter, smooth, bevel }
}
/// Rings of a contour: (inset, z) from the front cap to the back cap.
fn rings(style: BevelStyle, bevel: f64, depth: f64) -> Vec<(f64, f64)> {
let b = bevel.clamp(0.0, depth * 0.5);
let steps = if b > 1e-9 { style.steps() } else { 0 };
if steps == 0 {
return vec![(0.0, 0.0), (0.0, depth)];
}
let front: Vec<(f64, f64)> = (0..=steps)
.map(|k| {
let (f, g) = style.profile(k as f64 / steps as f64);
(b * f, b * g)
})
.collect();
let mut out = front.clone();
let back: Vec<(f64, f64)> = front.iter().rev().map(|&(i, z)| (i, depth - z)).collect();
// Skip a duplicate ring where the walls have zero height (bevel = depth / 2).
let start = if (back[0].1 - out[out.len() - 1].1).abs() < 1e-9 { 1 } else { 0 };
out.extend_from_slice(&back[start..]);
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 {
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]);
let l = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt().max(1e-12);
prim.normals.push([(n[0] / l) as f32, (n[1] / l) as f32, (n[2] / l) as f32]);
prim.uvs.push([0.0, 0.0]);
(prim.positions.len() - 1) as u32
};
for o in outlines {
let mut cs = vec![contour(o.outer.clone(), true, p.bevel_depth)];
for h in &o.holes {
cs.push(contour(h.clone(), false, p.bevel_depth * p.hole_bevel.clamp(0.0, 1.0)));
}
// Positions per contour per ring.
let mut ring_pos: Vec<Vec<Vec<[f64; 3]>>> = vec![];
let mut ring_par: Vec<Vec<(f64, f64)>> = vec![];
for c in &cs {
let rs = rings(p.bevel, c.bevel, depth);
let pos: Vec<Vec<[f64; 3]>> =
rs.iter().map(|&(inset, z)| c.pts.iter().zip(&c.miter).map(|(q, m)| [q[0] - m[0] * inset, q[1] - m[1] * inset, z]).collect()).collect();
ring_pos.push(pos);
ring_par.push(rs);
}
// Walls and bevels.
for (ci, c) in cs.iter().enumerate() {
let rs = &ring_par[ci];
let n = c.pts.len();
let curved = matches!(p.bevel, BevelStyle::Concave | BevelStyle::Convex);
// Profile normal (outward weight, z weight) of segment k.
let seg_n = |k: usize| -> (f64, f64) {
let (i0, z0) = rs[k];
let (i1, z1) = rs[k + 1];
let (di, dz) = (i1 - i0, z1 - z0);
let l = di.hypot(dz).max(1e-12);
(dz / l, di / l)
};
for k in 0..rs.len() - 1 {
let is_wall = (rs[k].0 - rs[k + 1].0).abs() < 1e-12;
let (a0, a1) = (seg_n(k), seg_n(k));
// Curved bevels: average with the neighbouring bevel segments at shared rings.
let blend = |k2: Option<usize>, base: (f64, f64)| -> (f64, f64) {
match k2 {
Some(k2) if curved && !is_wall && k2 < rs.len() - 1 && (rs[k2].0 - rs[k2 + 1].0).abs() >= 1e-12 => {
let o = seg_n(k2);
(base.0 + o.0, base.1 + o.1)
}
_ => base,
}
};
let pa = blend(k.checked_sub(1), a0);
let pb = blend(Some(k + 1), a1);
for i in 0..n {
let j = (i + 1) % n;
let en = c.edge_n[i];
let vn = |v: usize| -> P {
if c.smooth[v] {
let m = c.miter[v];
let l = m[0].hypot(m[1]).max(1e-12);
[m[0] / l, m[1] / l]
} else {
en
}
};
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 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]);
}
}
}
// Caps: triangulate in x/y (counter-clockwise → +z normal: the back cap; the front cap
// is reversed to face the camera).
let outer2: Vec<P> = ring_pos[0][0].iter().map(|q| [q[0], q[1]]).collect();
let holes2: Vec<Vec<P>> = ring_pos[1..].iter().map(|r| r[0].iter().map(|q| [q[0], q[1]]).collect()).collect();
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(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);
if front { prim.indices.extend([a, c, b]) } else { prim.indices.extend([a, b, c]) }
}
}
}
prims
}
/// Gap (pixels) between stacked extrusions of one layer: paint drawn later (a stroke over its
/// fill, an upper shape group) sits this much nearer the camera per level, so coplanar front
/// caps never fight in the depth buffer and the stacking order of the 2D layer is kept.
pub const STACK_GAP: f64 = 0.02;
/// [`extrude`] at stacking level `level` (0 = bottom paint of the layer): the mesh moves
/// `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;
}
}