#729: import STL 3D models (binary and ASCII)

Problem: After Effects 26 imports STL models besides glTF and OBJ; we
refused .stl files, and the Media Browser and import dialog did not list
them as models.

Fix: a pure-Rust STL reader in effectcraft-model (binary and ASCII,
detected from the contents; several ASCII solids and polygon loops;
VisCAM/SolidView and Materialise Magics facet colours as plain
materials; Z-up turned to the model's Y-up; faceted normals from the
winding). Truncated, empty or non-finite input is an error, never a
crash. .stl is now a model extension for import, probing, the Media
Browser, the import dialog and the system report.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
vangel76andClaude Opus 5.5 committed 2026-10-11 11:32:11 +02:00
1 parent 9a7d3e7699
commit 5661d5111c
13 files changed
+321 -12

No files matched your search

+1
View File
@@ -316,6 +316,7 @@ pub const IMPORT_FORMATS: &[&str] = &[
"Opus",
"glTF/GLB",
"OBJ",
"STL",
"Lottie JSON",
];
+2 -2
View File
@@ -1,4 +1,4 @@
//! Advanced 3D: model layers (File ▸ Import of glTF/OBJ, Layer ▸ New ▸ 3D primitives), the
//! Advanced 3D: model layers (File ▸ Import of glTF/OBJ/STL, Layer ▸ New ▸ 3D primitives), the
//! comp renderer switch, Layer ▸ Environment Layer, Geometry Options for extruded text and
//! shapes, and Layer ▸ Material commands.
@@ -376,7 +376,7 @@ pub fn specs() -> Vec<CommandSpec> {
"3D Model Layer",
[],
None,
"{item?: id or name of a 3D model footage item, path?: a .gltf, .glb or .obj file to import, name?, time? (s), index? (1-based stack position), position? ([x, y] comp px)}; switches a Classic 3D comp to Advanced 3D",
"{item?: id or name of a 3D model footage item, path?: a .gltf, .glb, .obj or .stl file to import, name?, time? (s), index? (1-based stack position), position? ([x, y] comp px)}; switches a Classic 3D comp to Advanced 3D",
has_comp,
new_model
),
+1 -1
View File
@@ -197,7 +197,7 @@ const VIDEO: &[&str] = &["mp4", "m4v", "mov", "webm", "mkv", "avi", "mxf", "gif"
const IMAGE: &[&str] = &["png", "jpg", "jpeg", "tif", "tiff", "exr", "bmp", "webp", "psd", "psb", "svg", "tga", "dpx", "hdr"];
const AUDIO: &[&str] = &["wav", "aif", "aiff", "mp3", "m4a", "aac", "flac", "ogg", "opus"];
const DATA: &[&str] = &["json", "csv", "tsv", "mgjson"];
const MODEL: &[&str] = &["gltf", "glb", "obj"];
const MODEL: &[&str] = &["gltf", "glb", "obj", "stl"];
/// Kind of file from its extension.
pub fn kind_of(path: &str) -> Option<&'static str> {
+1 -1
View File
@@ -58,7 +58,7 @@ pub const AUDIO_EXTENSIONS: &[&str] = filmcraft_media::AUDIO_EXTENSIONS;
pub const VIDEO_EXTENSIONS: &[&str] = &["mp4", "m4v", "mov", "mkv", "webm"];
/// File extensions recognised as 3D models (Advanced 3D model layers).
pub const MODEL_EXTENSIONS: &[&str] = &["gltf", "glb", "obj"];
pub const MODEL_EXTENSIONS: &[&str] = &["gltf", "glb", "obj", "stl"];
/// Whether `path` has an extension we can import.
pub fn is_importable(path: &std::path::Path) -> bool {
+2 -1
View File
@@ -23,7 +23,7 @@ pub fn probe(path: impl AsRef<Path>) -> Result<Footage> {
probe_bytes(&path.to_string_lossy(), bytes.into())
}
/// Probe a 3D model (glTF 2.0 / OBJ): a [`FootageKind::Model`] item whose duration is its
/// Probe a 3D model (glTF 2.0 / OBJ / STL): a [`FootageKind::Model`] item whose duration is its
/// longest animation. `resolve` reads sibling resources.
pub fn probe_model(path: &str, bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<Footage> {
let m = effectcraft_model::load(path, bytes, resolve).map_err(|e| MediaError::Decode(format!("{path}: {e}")))?;
@@ -31,6 +31,7 @@ pub fn probe_model(path: &str, bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Ve
let dur = s.animations.iter().map(|a| a.1).fold(0.0f64, f64::max);
let codec = match effectcraft_model::format_of(path) {
Some("obj") => "OBJ",
Some("stl") => "STL",
Some("glb") => "GLB",
_ => "glTF",
};
+10
View File
@@ -674,3 +674,13 @@ fn conformed_flac_audio_reads_back_like_the_decoder() {
assert_eq!(pool.audio_samples(&f, at, 4800, 48_000), decoded);
let _ = std::fs::remove_dir_all(&folder);
}
/// File ▸ Import of an STL part: importable by extension and probed as a 3D model item.
#[test]
fn stl_imports_as_a_model() {
assert!(effectcraft_media::is_importable(Path::new("part.stl")));
let ascii = "solid part\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid part\n";
let f = effectcraft_media::probe_bytes("part.stl", Arc::from(ascii.as_bytes())).expect("probe");
assert_eq!(f.kind, FootageKind::Model);
assert_eq!(f.codec, "STL (1 meshes, 1 triangles)");
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "effectcraft-model"
description = "3D models: glTF 2.0 (.gltf/.glb) and Wavefront OBJ/MTL import, PBR metallic-roughness materials, node hierarchy, skinning and animation, parametric primitives, and extruded/bevelled outline meshes"
description = "3D models: glTF 2.0 (.gltf/.glb), Wavefront OBJ/MTL and STL import, PBR metallic-roughness materials, node hierarchy, skinning and animation, parametric primitives, and extruded/bevelled outline meshes"
version.workspace = true
edition.workspace = true
license.workspace = true
+6 -2
View File
@@ -5,6 +5,7 @@
//! metallic-roughness, normal, occlusion and emissive maps), the node hierarchy, skins and
//! animations.
//! - [`obj`]: Wavefront OBJ geometry with MTL materials (mapped onto the metallic-roughness model).
//! - [`stl`]: STL meshes (binary and ASCII, with the binary per-facet and part colours).
//! - [`prim`]: parametric primitives (cube, sphere, plane, torus, cone, cylinder).
//! - [`extrude`]: extruded, bevelled meshes from 2D outlines (text and shape layers), with
//! [`triangulate`] for the caps.
@@ -18,6 +19,7 @@ pub mod extrude;
pub mod gltf;
pub mod obj;
pub mod prim;
pub mod stl;
pub mod triangulate;
use std::sync::Arc;
@@ -720,13 +722,14 @@ pub fn skin_point(inst: &Instance, p: &Primitive, k: usize, v: [f32; 3]) -> [f64
[q.x, q.y, q.z]
}
/// Which loader handles a file name (`gltf`, `glb`, `obj`), by extension.
/// Which loader handles a file name (`gltf`, `glb`, `obj`, `stl`), by extension.
pub fn format_of(path: &str) -> Option<&'static str> {
let ext = path.rsplit('.').next()?.to_ascii_lowercase();
match ext.as_str() {
"gltf" => Some("gltf"),
"glb" => Some("glb"),
"obj" => Some("obj"),
"stl" => Some("stl"),
_ => None,
}
}
@@ -736,9 +739,10 @@ pub fn format_of(path: &str) -> Option<&'static str> {
pub fn load(path: &str, bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<Model> {
match format_of(path) {
Some("obj") => obj::parse(bytes, resolve),
Some("stl") => stl::parse(bytes),
Some(_) => gltf::parse(bytes, resolve),
None if bytes.starts_with(b"glTF") => gltf::parse(bytes, resolve),
None => Err(ModelError::Unsupported(format!("{path}: not a glTF or OBJ file"))),
None => Err(ModelError::Unsupported(format!("{path}: not a glTF, OBJ or STL file"))),
}
}
+203
View File
@@ -0,0 +1,203 @@
//! STL (stereolithography) meshes, binary and ASCII.
//!
//! Written from the public description of the format (3D Systems' StereoLithography Interface
//! Specification, 1989, and its widely documented colour extensions):
//!
//! - Binary: an 80-byte header, a little-endian `u32` facet count, then 50 bytes per facet: a
//! normal and three vertices (12 `f32`) and a 16-bit attribute word. Bytes after the last
//! facet are ignored.
//! - ASCII: `solid name`, then `facet normal nx ny nz` / `outer loop` / `vertex x y z`… /
//! `endloop` / `endfacet`, closed by `endsolid`; several solids may follow each other.
//! Loops with more than three vertices are fan-triangulated.
//! - Colour (binary only): with `COLOR=r g b a` (four bytes) in the header (Materialise Magics),
//! that is the part's colour and a facet whose attribute has bit 15 clear has its own colour
//! (red in bits 0–4, green 5–9, blue 10–14); otherwise (VisCAM / SolidView) a facet with bit 15
//! set has its own colour (blue in bits 0–4, green 5–9, red 10–14). Each colour becomes a
//! plain dielectric material; uncoloured files get none (the renderer's default material).
//!
//! STL has no units, no UVs and no shared vertices: every facet keeps its own three vertices
//! with the face normal from its counter-clockwise winding (the stored normal is only used when
//! the triangle is degenerate), which gives the faceted look of an STL part. STL files are
//! Z-up (3D printing and CAD); they are turned to the +Y-up convention of [`crate::Model`]
//! (`(x, y, z)` → `(x, z, −y)`, a proper rotation, so winding is kept). Facets with non-finite
//! coordinates are skipped.
use std::collections::HashMap;
use crate::{Material, Mesh, Model, Primitive, Result, cross, normalize, obj::srgb_to_linear, perr, sub};
/// Header (80) plus facet count (4).
const HEADER: usize = 84;
/// Bytes per binary facet.
const FACET: usize = 50;
/// Parse an STL file (binary or ASCII, detected from the contents).
pub fn parse(bytes: &[u8]) -> Result<Model> {
let count = bytes.get(80..HEADER).and_then(|b| <[u8; 4]>::try_from(b).ok()).map(u32::from_le_bytes);
let needed = count.and_then(|n| (n as usize).checked_mul(FACET)).and_then(|n| n.checked_add(HEADER));
// A binary file's size is exact; many binary headers still begin with `solid`, so the size
// decides first, then the ASCII keyword.
if needed.is_some_and(|n| n == bytes.len()) {
return binary(bytes);
}
if looks_ascii(bytes) {
return ascii(bytes);
}
if needed.is_some_and(|n| n <= bytes.len()) {
return binary(bytes);
}
Err(perr("not an STL file (truncated binary facets or no `solid` keyword)"))
}
fn looks_ascii(bytes: &[u8]) -> bool {
let head = bytes.get(..bytes.len().min(512)).unwrap_or(&[]);
let text = String::from_utf8_lossy(head);
let t = text.trim_start();
t.get(..5).is_some_and(|k| k.eq_ignore_ascii_case("solid")) && (t.contains("facet") || t.contains("endsolid"))
}
/// The Z-up → Y-up turn.
fn y_up(v: [f32; 3]) -> [f32; 3] {
[v[0], v[2], -v[1]]
}
/// Facets grouped into primitives by colour, in order of first use.
#[derive(Default)]
struct Builder {
/// (colour key, primitive); `None` = uncoloured.
prims: Vec<(Option<[u8; 3]>, Primitive)>,
/// Colour → index into `prims` (up to 32 768 colours, so not a linear search).
slots: HashMap<Option<[u8; 3]>, usize>,
facets: usize,
}
impl Builder {
/// Add one triangle (file coordinates); `stored` is the file's facet normal.
fn tri(&mut self, color: Option<[u8; 3]>, stored: [f32; 3], v: [[f32; 3]; 3]) {
if v.iter().flatten().any(|c| !c.is_finite()) {
return;
}
let v = v.map(y_up);
let n = normalize(cross(sub(v[1], v[0]), sub(v[2], v[0])))
.or_else(|| stored.iter().all(|c| c.is_finite()).then(|| normalize(y_up(stored))).flatten())
.unwrap_or([0.0, 1.0, 0.0]);
let slot = *self.slots.entry(color).or_insert_with(|| {
self.prims.push((color, Primitive::default()));
self.prims.len() - 1
});
let Some((_, p)) = self.prims.get_mut(slot) else { return };
for c in v {
p.indices.push(p.positions.len() as u32);
p.positions.push(c);
p.normals.push(n);
}
self.facets += 1;
}
fn finish(self, name: String) -> Result<Model> {
if self.facets == 0 {
return Err(perr("STL file without facets"));
}
let mut materials = vec![];
let mut mesh = Mesh { name, primitives: vec![] };
for (color, mut p) in self.prims {
if let Some(c) = color {
let lin = c.map(|x| srgb_to_linear(x as f32 / 255.0));
let name = format!("#{:02X}{:02X}{:02X}", c[0], c[1], c[2]);
materials.push(Material { name, ..Material::plain([lin[0], lin[1], lin[2], 1.0], 0.0, 0.5) });
p.material = Some(materials.len() - 1);
}
mesh.primitives.push(p);
}
// Uncoloured facets next to coloured ones: a neutral grey of their own.
if !materials.is_empty() {
for p in mesh.primitives.iter_mut().filter(|p| p.material.is_none()) {
materials.push(Material { name: "STL".into(), ..Material::plain([0.6, 0.6, 0.6, 1.0], 0.0, 0.5) });
p.material = Some(materials.len() - 1);
}
}
Ok(Model::single(mesh, materials))
}
}
fn f32_at(b: &[u8], at: usize) -> f32 {
b.get(at..at + 4).and_then(|s| <[u8; 4]>::try_from(s).ok()).map_or(f32::NAN, f32::from_le_bytes)
}
/// A 5-bit channel expanded to 8 bits.
fn five(x: u16) -> u8 {
let x = (x & 31) as u8;
(x << 3) | (x >> 2)
}
/// The Magics `COLOR=` part colour in the header (RGB; alpha is ignored).
fn header_color(header: &[u8]) -> Option<[u8; 3]> {
let at = header.windows(6).position(|w| w == b"COLOR=")?;
let c = header.get(at + 6..at + 10)?;
Some([*c.first()?, *c.get(1)?, *c.get(2)?])
}
fn binary(bytes: &[u8]) -> Result<Model> {
let header = bytes.get(..80).ok_or_else(|| perr("STL header truncated"))?;
let magics = header_color(header);
let body = bytes.get(HEADER..).unwrap_or(&[]);
let count = bytes.get(80..HEADER).and_then(|b| <[u8; 4]>::try_from(b).ok()).map_or(0, u32::from_le_bytes) as usize;
let mut b = Builder::default();
for f in body.as_chunks::<FACET>().0.iter().take(count) {
let n = [f32_at(f, 0), f32_at(f, 4), f32_at(f, 8)];
let v = [0usize, 1, 2].map(|k| [f32_at(f, 12 + 12 * k), f32_at(f, 16 + 12 * k), f32_at(f, 20 + 12 * k)]);
let attr = f.get(48..50).and_then(|a| <[u8; 2]>::try_from(a).ok()).map_or(0, u16::from_le_bytes);
let color = match magics {
Some(part) => Some(if attr & 0x8000 == 0 { [five(attr), five(attr >> 5), five(attr >> 10)] } else { part }),
None => (attr & 0x8000 != 0).then(|| [five(attr >> 10), five(attr >> 5), five(attr)]),
};
b.tri(color, n, v);
}
let text = String::from_utf8_lossy(header);
let name = text.trim_matches(|c: char| c == '\0' || c.is_whitespace());
b.finish(name.strip_prefix("solid").unwrap_or(name).trim().to_string())
}
/// Three numbers from the rest of an ASCII line.
fn three<'a>(it: &mut impl Iterator<Item = &'a str>) -> Option<[f32; 3]> {
Some([it.next()?.parse().ok()?, it.next()?.parse().ok()?, it.next()?.parse().ok()?])
}
fn ascii(bytes: &[u8]) -> Result<Model> {
let text = String::from_utf8_lossy(bytes);
let mut b = Builder::default();
let mut name: Option<String> = None;
let mut normal = [0.0f32; 3];
let mut loop_v: Vec<[f32; 3]> = vec![];
for (ln, line) in text.lines().enumerate() {
let line = line.trim();
let mut it = line.split_whitespace();
let Some(key) = it.next() else { continue };
let bad = || perr(format!("STL line {}: expected three numbers", ln + 1));
match key.to_ascii_lowercase().as_str() {
"solid" => {
if name.is_none() {
name = Some(line.get(5..).unwrap_or("").trim().to_string());
}
}
"facet" => {
// `facet normal nx ny nz`
normal = it.next().and_then(|_| three(&mut it)).unwrap_or([0.0; 3]);
loop_v.clear();
}
"vertex" => loop_v.push(three(&mut it).ok_or_else(bad)?),
"endloop" | "endfacet" => {
if let Some((&first, rest)) = loop_v.split_first() {
for w in rest.windows(2) {
if let [a, c] = w {
b.tri(None, normal, [first, *a, *c]);
}
}
}
loop_v.clear();
}
_ => {}
}
}
b.finish(name.unwrap_or_default())
}
+90
View File
@@ -373,3 +373,93 @@ fn stacked_extrusions_move_towards_the_camera() {
assert_eq!((a[0], a[1]), (b[0], b[1]));
}
}
/// A binary STL: `header` (padded to 80 bytes) and facets of (normal, vertices, attribute).
fn binary_stl(header: &[u8], facets: &[([f32; 3], [[f32; 3]; 3], u16)]) -> Vec<u8> {
let mut b = header.to_vec();
b.resize(80, 0);
b.extend((facets.len() as u32).to_le_bytes());
for (n, v, attr) in facets {
for c in n.iter().chain(v.iter().flatten()) {
b.extend(c.to_le_bytes());
}
b.extend(attr.to_le_bytes());
}
b
}
/// The unit square in the XY plane (Z-up file), facing +Z, as two facets.
const SQUARE: [[[f32; 3]; 3]; 2] = [[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0]], [[0.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]]];
#[test]
fn stl_binary_is_faceted_and_turned_y_up() {
// A header that begins with `solid`, as many binary exporters write it.
let bytes = binary_stl(b"solid part", &[([0.0, 0.0, 1.0], SQUARE[0], 0), ([0.0, 0.0, 1.0], SQUARE[1], 0)]);
let m = load("part.STL", &bytes, &|_| None).unwrap();
assert_eq!(format_of("part.STL"), Some("stl"));
assert_eq!(m.meshes[0].name, "part");
assert!(m.materials.is_empty());
let p = &m.meshes[0].primitives[0];
assert_eq!((p.triangle_count(), p.positions.len()), (2, 6));
assert!(p.uvs.is_empty());
// Z-up +Z becomes Y-up +Y; file Y becomes −Z.
assert_eq!(p.normals, vec![[0.0, 1.0, 0.0]; 6]);
assert_eq!(p.positions[2], [1.0, 0.0, -1.0]);
// Counter-clockwise from outside after the turn.
let (a, b, c) = (p.positions[0], p.positions[1], p.positions[2]);
assert!(dot(cross(sub(b, a), sub(c, a)), p.normals[0]) > 0.0);
// Trailing bytes after the last facet are ignored.
let mut longer = bytes.clone();
longer.extend([0u8; 7]);
assert_eq!(stl::parse(&longer).unwrap().meshes, m.meshes);
}
#[test]
fn stl_ascii_with_polygon_loops_and_several_solids() {
let src = "solid Bracket\n facet normal 0 0 1\n outer loop\n vertex 0 0 0\n vertex 1 0 0\n vertex 1 1 0\n vertex 0 1 0\n endloop\n endfacet\nendsolid Bracket\n\
solid second\nfacet normal 0 0 0\nouter loop\nvertex 0 0 1\nvertex 1 0 1\nvertex 1E0 1 1\nendloop\nendfacet\nendsolid\n";
let m = load("bracket.stl", src.as_bytes(), &|_| None).unwrap();
assert_eq!(m.meshes[0].name, "Bracket");
// The quad fans into two triangles; the second solid adds one.
assert_eq!(m.triangle_count(), 3);
let (lo, hi) = m.bounds().unwrap();
assert_eq!((lo, hi), ([0.0, 0.0, -1.0], [1.0, 1.0, 0.0]));
assert!(stl::parse(b"solid x\nfacet normal 0 0 1\nouter loop\nvertex 0 zero 0\n").is_err());
}
#[test]
fn stl_binary_colours() {
// VisCAM / SolidView: bit 15 set, red in bits 10–14.
let red = 0x8000 | (31 << 10);
let m = stl::parse(&binary_stl(b"", &[([0.0; 3], SQUARE[0], red), ([0.0; 3], SQUARE[1], 0)])).unwrap();
assert_eq!(m.meshes[0].primitives.len(), 2);
assert_eq!(m.materials.len(), 2);
assert!(m.materials[0].base_color.iter().zip([1.0, 0.0, 0.0, 1.0]).all(|(x, y)| (x - y).abs() < 1e-5));
assert_eq!(m.materials[0].metallic, 0.0);
assert_eq!(m.meshes[0].primitives[1].material, Some(1));
// Materialise Magics: a part colour in the header; bit 15 clear = the facet's own colour
// with red in bits 0–4.
let mut header = b"COLOR=".to_vec();
header.extend([0, 255, 0, 255]);
let m = stl::parse(&binary_stl(&header, &[([0.0; 3], SQUARE[0], 31), ([0.0; 3], SQUARE[1], 0x8000)])).unwrap();
let close = |a: [f32; 4], b: [f32; 4]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5);
assert!(close(m.materials[0].base_color, [1.0, 0.0, 0.0, 1.0]), "{:?}", m.materials[0]);
assert!(close(m.materials[1].base_color, [0.0, 1.0, 0.0, 1.0]), "{:?}", m.materials[1]);
}
#[test]
fn stl_hostile_input_is_an_error() {
// Claims four billion facets but has one: truncated.
let mut bytes = binary_stl(b"", &[([0.0; 3], SQUARE[0], 0)]);
bytes.splice(80..84, u32::MAX.to_le_bytes());
assert!(stl::parse(&bytes).is_err());
assert!(stl::parse(b"").is_err());
assert!(stl::parse(&[0u8; 83]).is_err());
// No facets, or only non-finite ones.
assert!(stl::parse(&binary_stl(b"", &[])).is_err());
assert!(stl::parse(&binary_stl(b"", &[([0.0; 3], [[f32::NAN, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], 0)])).is_err());
assert!(stl::parse(b"solid empty\nendsolid empty\n").is_err());
// A degenerate facet keeps its stored normal.
let m = stl::parse(&binary_stl(b"", &[([1.0, 0.0, 0.0], [[0.0; 3]; 3], 0)])).unwrap();
assert_eq!(m.meshes[0].primitives[0].normals[0], [1.0, 0.0, 0.0]);
}
+1 -1
View File
@@ -1226,7 +1226,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res
}
const MEDIA: &[&str] = &[
"mp4", "mov", "m4v", "mkv", "webm", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "wav", "aif", "aiff", "mp3", "flac", "ogg",
"opus", "svg", "pdf", "ai", "eps", "psd", "psb", "gltf", "glb", "obj", "json", "csv", "tsv",
"opus", "svg", "pdf", "ai", "eps", "psd", "psb", "gltf", "glb", "obj", "stl", "json", "csv", "tsv",
];
// A host whose modal dialogs would run a nested event loop inside this frame (macOS: a
// drag over the window then re-entered winit's event handler and aborted, #511) shows the
+2 -2
View File
@@ -80,7 +80,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na
| OBJ / MTL | R | yes | good |
| FBX | R (`usdFbx`) | none | gap 13 |
| USD / USDZ | R (`usd_plugins`) | none | gap 13 |
| STL | R (`usdStl`) | none | gap 13 |
| STL | R (`usdStl`) | yes (binary and ASCII, VisCAM and Magics facet colours; Z-up turned to Y-up) | good |
| Substance 3D materials `.sbsar` | R (26.0) | none | gap 13 |
## Interchange and data
@@ -107,7 +107,7 @@ encoders ([hardware-parity.md](hardware-parity.md)), and rendering safely over a
## Estimate
**≈ 130–230 h**: `.aep` / `.aepx` import 80–160 h (after the owner decision), wiring TGA / DPX /
HDR / MXF / AVI / DNx import and DPX/TGA output 10–20 h, FBX / USD / STL and Substance 25–45 h,
HDR / MXF / AVI / DNx import and DPX/TGA output 10–20 h, FBX / USD and Substance 25–45 h,
relinking 6–12 h, `.mogrt`-compatible templates 15–30 h (partly parallel with the rest).
## Revision history
+1 -1
View File
@@ -69,7 +69,7 @@ are grouped by area; the issue tracker has the rest.
| 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) |
| 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) |
| 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 and STL (binary and ASCII, with facet colours), not FBX, USD/USDZ or Substance | 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) |
| 16 | **Localization depth** | Menus in 6 languages; panels in 4; effect names and ~1,650 effect parameter names untranslated; French, German, Korean, Italian, Russian (After Effects ships them) absent; #599 Hangul boxes on macOS | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) |