M7.4: model import

New crate effectcraft-model (L2): glTF 2.0 (.gltf/.glb, data URIs, external buffers and
images, PBR metallic-roughness with base/MR/normal/occlusion/emissive textures, node
hierarchy, skins, step/linear/cubic animations) and OBJ/MTL import, parametric primitives,
extruded/bevelled outline meshes and polygon triangulation with holes.

Project: FootageKind::Model, LayerSource::Model/Primitive, Layer::environment, Geometry and
Material Options builders, Environment light Source. Media probes and loads models
(FootageSource::model). Engine: layer.newModel, layer.new3dPrimitive/newPrimitive,
comp.renderer, layer.environment, material.set/reset/duplicateAssign/revealSource (stubs
replaced); File > Import and layer.addItem pick up models. Hand-written fixtures with
attribution sidecars.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 21:04:47 -04:00
parent 4bcf1f447c
commit 83c7e14b94
42 changed files with 3676 additions and 17 deletions

View File

@ -36,6 +36,10 @@ iconography, images or other Adobe assets; open licences only; every asset attri
| `crates/lottie/tests/fixtures/shapes-gradient.json` | EffectCraft contributors | Original work: hand-written Lottie test fixture (star, rectangle, gradient fill, dashed stroke, trim paths) | MIT OR Apache-2.0 |
| `crates/lottie/tests/fixtures/precomp-matte-text.json` | EffectCraft contributors | Original work: hand-written Lottie test fixture (precomp, track matte, parented text) | MIT OR Apache-2.0 |
| `crates/lottie/tests/fixtures/legacy-masks.json` | EffectCraft contributors | Original work: hand-written Lottie test fixture (legacy keyframes, masks) | MIT OR Apache-2.0 |
| `crates/model/tests/fixtures/quad.gltf` | EffectCraft contributors | Original work: glTF 2.0 test fixture (textured quad, node hierarchy, translation animation; data URIs) | MIT OR Apache-2.0 |
| `crates/model/tests/fixtures/quad.glb` | EffectCraft contributors | Original work: binary glTF test fixture (the same quad with its texture in the BIN chunk) | MIT OR Apache-2.0 |
| `crates/model/tests/fixtures/cube.obj` | EffectCraft contributors | Original work: Wavefront OBJ test fixture (unit cube, two materials) | MIT OR Apache-2.0 |
| `crates/model/tests/fixtures/cube.mtl` | EffectCraft contributors | Original work: MTL test fixture (materials of cube.obj) | MIT OR Apache-2.0 |
## Screenshots

14
Cargo.lock generated
View File

@ -1195,6 +1195,7 @@ dependencies = [
"effectcraft-geom",
"effectcraft-keyframe",
"effectcraft-lottie",
"effectcraft-model",
"effectcraft-path",
"effectcraft-project",
"effectcraft-raster",
@ -1322,6 +1323,7 @@ dependencies = [
name = "effectcraft-media"
version = "0.1.0"
dependencies = [
"effectcraft-model",
"effectcraft-project",
"effectcraft-raster",
"effectcraft-render",
@ -1337,6 +1339,17 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "effectcraft-model"
version = "0.1.0"
dependencies = [
"effectcraft-geom",
"image",
"serde",
"serde_json",
"thiserror 2.0.21",
]
[[package]]
name = "effectcraft-path"
version = "0.1.0"
@ -1379,6 +1392,7 @@ dependencies = [
"effectcraft-effects",
"effectcraft-geom",
"effectcraft-keyframe",
"effectcraft-model",
"effectcraft-path",
"effectcraft-project",
"effectcraft-raster",

View File

@ -22,6 +22,7 @@ effectcraft-project = { path = "crates/project" }
effectcraft-text = { path = "crates/text" }
effectcraft-effects = { path = "crates/effects" }
effectcraft-track = { path = "crates/track" }
effectcraft-model = { path = "crates/model" }
effectcraft-render = { path = "crates/render" }
effectcraft-gpu = { path = "crates/gpu" }
effectcraft-media = { path = "crates/media" }

View File

@ -21,6 +21,7 @@ effectcraft-text = { workspace = true }
effectcraft-effects = { workspace = true }
effectcraft-render = { workspace = true }
effectcraft-track = { workspace = true }
effectcraft-model = { workspace = true }
effectcraft-lottie = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }

View File

@ -125,6 +125,7 @@ fn settings(s: &mut Session, p: &Value) -> Result<Value> {
}
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
apply_settings(c, p);
super::model3d::sync_geometry_options(proj, cid);
Ok(())
})?;
let c = s.project.comp(cid).ok_or(EngineError::NoComp)?;

View File

@ -110,7 +110,7 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
let label = match f.kind {
FootageKind::Still | FootageKind::Sequence => Label::Lavender,
FootageKind::Audio => Label::SeaFoam,
FootageKind::Video => Label::Aqua,
FootageKind::Video | FootageKind::Model => Label::Aqua,
};
let id = proj.add_item(&name, label, None, ItemKind::Footage(f));
ids.push(id.0);

View File

@ -24,7 +24,11 @@ pub(crate) fn color_p(p: &Value, k: &str) -> Option<[f32; 3]> {
}
/// Insert a new layer above the selection (or at the top) and select it.
pub(crate) fn insert_layer(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, layer: Layer) -> Result<LayerId> {
pub(crate) fn insert_layer(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, mut layer: Layer) -> Result<LayerId> {
// Text and shape layers in Advanced 3D comps get Geometry Options (extrusion, bevels).
if proj.comp(cid).is_some_and(|c| c.renderer == effectcraft_project::Renderer::Advanced3D) {
super::model3d::add_geometry_options(&mut proj.next_id, &mut layer);
}
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let at = st.selected_layers.first().and_then(|id| comp.layers.iter().position(|l| l.id == *id)).unwrap_or(0);
let id = layer.id;
@ -235,6 +239,9 @@ fn add_item(s: &mut Session, p: &Value) -> Result<Value> {
let d = (s.prefs.import.still_footage == "seconds").then(|| Tick::from_seconds_f64(s.prefs.import.still_seconds));
(LayerSource::Footage { item }, (f.width, f.height), d)
}
ItemKind::Footage(f) if f.kind == effectcraft_project::FootageKind::Model => {
return super::model3d::new_model(s, &serde_json::json!({"comp": cid.0, "item": item.0, "time": f_p(p, "time")}));
}
ItemKind::Footage(f) => (LayerSource::Footage { item }, (f.width, f.height), Some(f.duration)),
ItemKind::Solid(so) => (LayerSource::Solid { item }, (so.width, so.height), None),
ItemKind::Folder => return Err(bad("layer.addItem", "folders can't be layers")),

View File

@ -20,6 +20,7 @@ mod link;
mod lottie;
mod markers;
mod mask;
pub(crate) mod model3d;
pub mod paint;
mod paths;
mod project_items;
@ -99,6 +100,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(layer::specs());
v.extend(styles::specs());
v.extend(three_d::specs());
v.extend(model3d::specs());
v.extend(text_anim::specs());
v.extend(text_edit::specs());
v.extend(layer_time::specs());

View File

@ -0,0 +1,397 @@
//! Advanced 3D: model layers (File ▸ Import of glTF/OBJ, Layer ▸ New ▸ 3D primitives), the
//! comp renderer switch, Layer ▸ Environment Layer, Geometry Options for extruded text and
//! shapes, and Layer ▸ Material commands.
use effectcraft_keyframe::Value as KV;
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{FootageKind, ItemId, ItemKind, Layer, LayerSource, Node, PrimitiveKind, PropGroup, Renderer};
use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::layer::{color_p, insert_layer};
use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, has_layers, layer_mut, layer_p, layers_p, str_p};
use crate::{EngineError, Result, Session, cmd};
// ---------------------------------------------------------------- geometry options
/// Add Geometry Options (Bevel Style, Bevel Depth, Hole Bevel Depth, Extrusion Depth) to a text
/// or shape layer that has none.
pub(crate) fn add_geometry_options(next_id: &mut u64, l: &mut Layer) {
if !matches!(l.source, LayerSource::Text | LayerSource::Shape) || l.props.sub("geometryOptions").is_some() {
return;
}
let g = build::extrusion_geometry_options(&mut Ids(next_id));
// After Transform, like After Effects.
let at = l.props.children.iter().position(|c| c.match_id() == "transform").map_or(l.props.children.len(), |i| i + 1);
l.props.children.insert(at, g.into());
}
/// Advanced 3D comps: give every text and shape layer Geometry Options.
pub(crate) fn sync_geometry_options(proj: &mut effectcraft_project::Project, cid: ItemId) {
if proj.comp(cid).is_none_or(|c| c.renderer != Renderer::Advanced3D) {
return;
}
let mut next = proj.next_id;
if let Some(c) = proj.comp_mut(cid) {
for l in &mut c.layers {
add_geometry_options(&mut next, l);
}
}
proj.next_id = next;
}
fn renderer_p(p: &Value, cmd: &str) -> Result<Renderer> {
let v = str_p(p, "renderer").or_else(|| str_p(p, "value")).ok_or_else(|| bad(cmd, "missing `renderer` (advanced3d|classic3d)"))?;
match v.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() {
"advanced3d" | "advanced" => Ok(Renderer::Advanced3D),
"classic3d" | "classic" => Ok(Renderer::Classic3D),
_ => Err(bad(cmd, format!("renderer: advanced3d|classic3d, not `{v}`"))),
}
}
fn comp_renderer(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
if p.get("renderer").is_none() && p.get("value").is_none() {
let r = s.project.comp(cid).ok_or(EngineError::NoComp)?.renderer;
return Ok(json!({"renderer": renderer_id(r)}));
}
let r = renderer_p(p, "comp.renderer")?;
s.edit("Change Renderer", None, |proj, _| {
proj.comp_mut(cid).ok_or(EngineError::NoComp)?.renderer = r;
sync_geometry_options(proj, cid);
Ok(())
})?;
Ok(json!({"renderer": renderer_id(r)}))
}
fn renderer_id(r: Renderer) -> &'static str {
match r {
Renderer::Advanced3D => "advanced3d",
Renderer::Classic3D => "classic3d",
}
}
// ---------------------------------------------------------------- model layers
/// Set a property's value at comp time `t` (adds a key when it is animated).
fn set_at(l: &mut Layer, path: &str, t: Tick, v: KV) -> bool {
let lt = l.layer_time(t);
match l.props.prop_mut(path) {
Some(pr) => {
pr.set_value_at(lt, v);
true
}
None => false,
}
}
/// New 3D model layer from a model footage item (`item`) or a file (`path`, imported first).
pub(crate) fn new_model(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let item = match (p.get("item"), str_p(p, "path")) {
(Some(Value::Number(n)), _) => ItemId(n.as_u64().unwrap_or(0)),
(Some(Value::String(name)), _) => s.project.find_by_name(name).map(|i| i.id).ok_or_else(|| bad("layer.newModel", format!("no item `{name}`")))?,
(_, Some(path)) => {
let r = s.execute("file.import", json!({"paths": [path]}))?;
let id = r.get("items").and_then(|a| a.get(0)).and_then(Value::as_u64);
let err = r.get("errors").and_then(|e| e.get(0)).and_then(Value::as_str).unwrap_or("import failed").to_string();
ItemId(id.ok_or_else(|| bad("layer.newModel", err))?)
}
_ => *s.state.project_selection.first().ok_or_else(|| bad("layer.newModel", "missing `item` or `path`"))?,
};
let it = s.project.item(item).ok_or_else(|| bad("layer.newModel", "no such item"))?.clone();
let ItemKind::Footage(f) = &it.kind else { return Err(bad("layer.newModel", format!("`{}` is not a 3D model", it.name))) };
if f.kind != FootageKind::Model {
return Err(bad("layer.newModel", format!("`{}` is not a 3D model", it.name)));
}
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
// Fit the model to half the comp height, and list its animation clips.
let model = s.footage.model(item, f);
let (scale, clips) = match &model {
Some(m) => {
let ext = m.bounds().map_or(1.0, |(lo, hi)| (0..3).map(|k| hi[k] - lo[k]).fold(0.0f64, f64::max));
let scale = if ext > 1e-9 { comp.height as f64 * 0.5 / ext } else { 100.0 };
let clips = m.animations.iter().enumerate().map(|(i, a)| if a.name.is_empty() { format!("Animation {}", i + 1) } else { a.name.clone() }).collect();
(scale, clips)
}
None => (100.0, vec![]),
};
let name = str_p(p, "name").unwrap_or(&it.name).to_string();
let fr = comp.frame_rate;
let start = fr.snap_nearest(f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(Tick::ZERO));
let id = s.edit("New 3D Model Layer", None, |proj, st| {
let mut l = build::layer(proj, &comp, &name, LayerSource::Model { item }, (comp.width, comp.height), None);
let g = build::model_geometry_options(&mut Ids(&mut proj.next_id), scale, &clips);
if let Some(slot) = l.props.children.iter_mut().find(|c| c.match_id() == "geometryOptions") {
*slot = g.into();
}
l.start_time = start;
l.in_point = start;
l.out_point = comp.duration.max(start + fr.frame_duration());
insert_layer(proj, st, cid, l)
})?;
Ok(json!({"layer": id.0, "item": item.0}))
}
fn kind_p(p: &Value, cmd: &str) -> Result<PrimitiveKind> {
let k = str_p(p, "kind").or_else(|| str_p(p, "type")).ok_or_else(|| bad(cmd, "missing `kind` (cube|sphere|plane|torus|cone|cylinder)"))?;
PrimitiveKind::parse(k).ok_or_else(|| bad(cmd, format!("kind: cube|sphere|plane|torus|cone|cylinder, not `{k}`")))
}
/// Parse Material Options parameters onto a layer at comp time `t`.
fn apply_material(l: &mut Layer, p: &Value, t: Tick, cmd: &str) -> Result<usize> {
let mut n = 0;
let tri = |v: &Value| -> Option<u32> {
match v {
Value::Bool(b) => Some(*b as u32),
Value::Number(x) => x.as_u64().map(|x| x.min(2) as u32),
Value::String(s) => match s.to_ascii_lowercase().as_str() {
"off" | "false" => Some(0),
"on" | "true" => Some(1),
"only" => Some(2),
_ => None,
},
_ => None,
}
};
for k in ["castsShadows", "acceptsShadows"] {
if let Some(v) = p.get(k) {
let e = tri(v).ok_or_else(|| bad(cmd, format!("{k}: off|on|only")))?;
n += set_at(l, &format!("materialOptions/{k}"), t, KV::Enum(e)) as usize;
}
}
for k in ["acceptsLights", "appearsInReflections"] {
if let Some(v) = b_p(p, k) {
n += set_at(l, &format!("materialOptions/{k}"), t, KV::Bool(v)) as usize;
}
}
for k in ["lightTransmission", "ambient", "diffuse", "specularIntensity", "specularShininess", "metal", "metallic", "roughness"] {
if let Some(v) = f_p(p, k) {
n += set_at(l, &format!("materialOptions/{k}"), t, KV::Scalar(v)) as usize;
}
}
for k in ["baseColor", "emissive"] {
if let Some(c) = color_p(p, k) {
n += set_at(l, &format!("materialOptions/{k}"), t, KV::Color([c[0] as f64, c[1] as f64, c[2] as f64, 1.0])) as usize;
}
}
Ok(n)
}
fn new_primitive(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let kind = kind_p(p, "layer.newPrimitive")?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| kind.label().to_string());
let t = super::time_p(s, p, Some(&comp));
let id = s.edit(&format!("New {}", kind.label()), None, |proj, st| {
let mut l = build::layer(proj, &comp, &name, LayerSource::Primitive { kind }, (comp.width, comp.height), None);
for k in ["width", "height", "depth", "radius", "tubeRadius", "segments", "rings"] {
if let Some(v) = f_p(p, k) {
set_at(&mut l, &format!("geometryOptions/{k}"), t, KV::Scalar(v.max(0.0)));
}
}
if let Some(pos) = p.get("position").and_then(Value::as_array) {
let g = |i: usize| pos.get(i).and_then(Value::as_f64).unwrap_or(0.0);
set_at(&mut l, "transform/position", t, KV::Vec3([g(0), g(1), g(2)]));
}
apply_material(&mut l, p, t, "layer.newPrimitive")?;
insert_layer(proj, st, cid, l)
})?;
// Primitives are drawn by the Advanced 3D renderer.
let advanced = s.project.comp(cid).is_some_and(|c| c.renderer == Renderer::Advanced3D);
Ok(json!({"layer": id.0, "kind": kind.label(), "advanced3d": advanced}))
}
// ---------------------------------------------------------------- environment layer
fn environment(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
if ids.is_empty() {
return Err(bad("layer.environment", "select a footage or composition layer"));
}
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
for id in &ids {
let l = comp.layer(*id).ok_or(EngineError::NoComp)?;
if !l.source.is_av() || l.source.is_model() {
return Err(bad("layer.environment", format!("`{}` can't be an environment layer (use a footage, comp or solid layer)", l.name)));
}
}
let on = b_p(p, "on").unwrap_or_else(|| !comp.layer(ids[0]).is_some_and(|l| l.environment));
s.edit("Environment Layer", None, |proj, _| {
for id in &ids {
let l = layer_mut(proj, cid, *id)?;
l.environment = on;
if on {
l.switches.three_d = true;
}
}
Ok(())
})?;
Ok(json!({"environment": on, "layers": ids.iter().map(|l| l.0).collect::<Vec<_>>()}))
}
// ---------------------------------------------------------------- materials
fn material_set(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
if ids.is_empty() {
return Err(bad("material.set", "no `layers` given and no layer selected"));
}
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let t = super::time_p(s, p, Some(&comp));
let n = s.edit("Material Options", None, |proj, _| {
let mut n = 0;
for id in &ids {
n += apply_material(layer_mut(proj, cid, *id)?, p, t, "material.set")?;
}
Ok(n)
})?;
if n == 0 {
return Err(bad(
"material.set",
"no Material Options property matched (castsShadows, acceptsShadows, acceptsLights, baseColor, metallic, roughness, …)",
));
}
Ok(json!({"set": n}))
}
/// Fresh Material Options for a layer.
fn default_material(next: &mut u64, l: &Layer) -> Option<PropGroup> {
let ids = &mut Ids(next);
match l.source {
LayerSource::Primitive { .. } => Some(build::model_material_options(ids, true)),
LayerSource::Model { .. } => Some(build::model_material_options(ids, false)),
_ if l.source.is_av() => Some(build::material_options(ids)),
_ => None,
}
}
fn replace_material(l: &mut Layer, g: PropGroup) {
match l.props.children.iter_mut().find(|c| c.match_id() == "materialOptions") {
Some(slot) => *slot = Node::Group(g),
None => l.props.children.push(g.into()),
}
}
fn material_reset(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
if ids.is_empty() {
return Err(bad("material.reset", "no `layers` given and no layer selected"));
}
s.edit("Reset Material", None, |proj, _| {
let mut next = proj.next_id;
for id in &ids {
let l = layer_mut(proj, cid, *id)?;
if let Some(g) = default_material(&mut next, l) {
replace_material(l, g);
}
}
proj.next_id = next;
Ok(())
})?;
Ok(json!({"layers": ids.iter().map(|l| l.0).collect::<Vec<_>>()}))
}
/// Duplicate and Assign Material: the first layer's Material Options are copied onto the other
/// selected layers of the same kind.
fn material_duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, ids) = layers_p(s, p)?;
if ids.len() < 2 {
return Err(bad("material.duplicateAssign", "select the source layer first, then the layers to assign its material to"));
}
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let src = comp
.layer(ids[0])
.and_then(|l| l.props.sub("materialOptions"))
.cloned()
.ok_or_else(|| bad("material.duplicateAssign", "the first layer has no material"))?;
let n = s.edit("Duplicate and Assign Material", None, |proj, _| {
let mut n = 0;
for id in &ids[1..] {
let l = layer_mut(proj, cid, *id)?;
let Some(dst) = l.props.sub("materialOptions") else { continue };
// Property sets differ (models, primitives, cards): copy the values they share.
let mut g = dst.clone();
for c in g.children.iter_mut() {
if let Node::Prop(d) = c
&& let Some(sv) = src.get(&d.match_id)
{
d.value = sv.value.clone();
d.keys = sv.keys.clone();
d.expr = sv.expr.clone();
}
}
replace_material(l, g);
n += 1;
}
Ok(n)
})?;
Ok(json!({"assigned": n}))
}
fn material_reveal(s: &mut Session, p: &Value) -> Result<Value> {
let (cid, lid) = layer_p(s, p, "material.revealSource")?;
let l = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?;
let LayerSource::Model { item } = l.source else { return Err(bad("material.revealSource", "the layer's material is its own (not from a model file)")) };
s.state.project_selection = vec![item];
Ok(json!({"item": item.0}))
}
fn has_model_or_layers(s: &Session) -> std::result::Result<(), String> {
has_layers(s)
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"layer.newModel",
"3D Model Layer",
[],
None,
"{item?: id|name (a 3D model footage item) | path?: .gltf|.glb|.obj file, name?, time? (s)}",
has_comp,
new_model
),
cmd!(
"layer.new3dPrimitive",
"3D Primitive",
[],
None,
"{kind: cube|sphere|plane|torus|cone|cylinder, name?, width?, height?, depth?, radius?, tubeRadius?, segments?, rings?, position? [x,y,z], baseColor?, metallic?, roughness?, emissive?, castsShadows?, acceptsShadows?, acceptsLights?}",
has_comp,
new_primitive
),
cmd!(
"layer.newPrimitive",
"New 3D Primitive",
[],
None,
"{kind: cube|sphere|plane|torus|cone|cylinder, name?, width?, height?, depth?, radius?, tubeRadius?, segments?, rings?, position? [x,y,z], baseColor?, metallic?, roughness?, emissive?, castsShadows?, acceptsShadows?, acceptsLights?}",
has_comp,
new_primitive
),
cmd!("comp.renderer", "Renderer", [], None, "{renderer?: advanced3d|classic3d (omit to read), value?}", has_comp, comp_renderer),
cmd!("layer.environment", "Environment Layer", ["Layer"], None, "{layers?, on?}", has_layers, environment),
cmd!(
"material.set",
"Material Options",
[],
None,
"{layers?, castsShadows?: off|on|only, acceptsShadows?: off|on|only, acceptsLights?, appearsInReflections?, lightTransmission?, ambient?, diffuse?, specularIntensity?, specularShininess?, metal?, baseColor?, metallic?, roughness?, emissive?, time?}",
has_layers,
material_set
),
cmd!("material.revealSource", "Reveal Material Source in Project", ["Layer", "Material"], None, "{layer?}", has_layers, material_reveal),
cmd!("material.reset", "Reset Material", ["Layer", "Material"], None, "{layers?}", has_model_or_layers, material_reset),
cmd!(
"material.duplicateAssign",
"Duplicate and Assign Material",
["Layer", "Material"],
None,
"{layers? (first = source)}",
has_layers,
material_duplicate
),
]
}

View File

@ -29,11 +29,9 @@ pub fn specs() -> Vec<CommandSpec> {
stub!("keys.selectLabelGroup", "Select Keyframe Label Group", [], "{scope}"),
// Layer ▸ New (3D / content-aware fill).
stub!("layer.newContentAwareFill", "Content-Aware Fill Layer...", ["Layer", "New"], "{}"),
stub!("layer.new3dPrimitive", "3D Primitive", [], "{kind: cube|sphere|plane|torus|cone|cylinder}"),
// Mask options not in the mask model yet; pen / vertex editing (timeline milestone).
// Time (timeline milestone).
stub!("layer.alignVideoToData", "Align Video to Data", ["Layer", "Time"], "{}"),
stub!("layer.environment", "Environment Layer", ["Layer"], "{}"),
stub!("layer.create", "Create", [], "{op}"),
stub!("layer.autoTrace", "Auto-trace...", ["Layer"], "{}"),
stub!("layer.sceneEditDetection", "Scene Edit Detection...", ["Layer"], "{}"),
@ -47,9 +45,6 @@ pub fn specs() -> Vec<CommandSpec> {
stub!("light.fromModel", "Create Lights from 3D Model", ["Layer", "Light"], "{}"),
stub!("light.controlWithCamera", "Control Light with Camera", ["Layer", "Light"], "{}"),
stub!("light.environmentBackground", "Create Environment Light Background Layer", ["Layer", "Light"], "{}"),
stub!("material.revealSource", "Reveal Material Source in Project", ["Layer", "Material"], "{}"),
stub!("material.reset", "Reset Material", ["Layer", "Material"], "{}"),
stub!("material.duplicateAssign", "Duplicate and Assign Material", ["Layer", "Material"], "{}"),
stub!("view.3d.default", "Default", ["View", "Switch 3D View"], "{}"),
stub!("view.splitLockedViewer", "Split with New Locked Viewer", ["View"], "{}"),
// Color management.

View File

@ -556,6 +556,8 @@ mod tests_markers;
#[cfg(test)]
mod tests_menu_cmds;
#[cfg(test)]
mod tests_model3d;
#[cfg(test)]
mod tests_project_items;
#[cfg(test)]
mod tests_settings;

View File

@ -0,0 +1,194 @@
//! Advanced 3D commands: model import → model layers, primitives, the renderer switch with
//! Geometry Options, Environment Layer and Material commands (undo/redo, serde).
use std::sync::Arc;
use effectcraft_keyframe::Value as KV;
use effectcraft_project::{Footage, FootageKind, ItemId, LayerId, LayerSource, PrimitiveKind, Project, Renderer};
use effectcraft_render::FootageSource;
use effectcraft_time::{FrameRate, Tick};
use serde_json::json;
use crate::{Importer, Session};
fn fixture(name: &str) -> String {
format!("{}/../model/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"))
}
/// Loads models from disk (what `effectcraft-media` does in the app).
struct DiskModels;
impl FootageSource for DiskModels {
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<effectcraft_raster::Image>> {
None
}
fn model(&self, _: ItemId, f: &Footage) -> Option<Arc<effectcraft_model::Model>> {
let dir = std::path::Path::new(&f.path).parent()?.to_path_buf();
let bytes = std::fs::read(&f.path).ok()?;
effectcraft_model::load(&f.path, &bytes, &|u| std::fs::read(dir.join(u)).ok()).ok().map(Arc::new)
}
}
struct ModelImporter;
impl Importer for ModelImporter {
fn probe(&self, path: &str) -> Result<Footage, String> {
effectcraft_model::format_of(path).ok_or("not a model")?;
Ok(Footage {
path: path.into(),
kind: FootageKind::Model,
width: 0,
height: 0,
pixel_aspect: 1.0,
frame_rate: FrameRate::FPS_30,
native_rate: None,
duration: Tick::ZERO,
has_video: false,
has_audio: false,
alpha: Default::default(),
premul_color: [0.0; 3],
loop_count: 1,
codec: "glTF".into(),
missing: false,
sequence: vec![],
color_profile: None,
})
}
}
fn session() -> Session {
let mut s = Session { footage: Arc::new(DiskModels), importer: Some(Arc::new(ModelImporter)), ..Default::default() };
s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 2})).unwrap();
s
}
fn layer(s: &Session, id: u64) -> effectcraft_project::Layer {
s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone()
}
#[test]
fn import_gltf_and_add_model_layer() {
let mut s = session();
let r = s.execute("file.import", json!({"paths": [fixture("quad.gltf")]})).unwrap();
let item = r["items"][0].as_u64().unwrap();
assert_eq!(s.project.item(ItemId(item)).unwrap().type_name(), "3D Model");
// Adding the item to the comp makes a model layer (3D, fitted, with the clip popup).
let id = s.execute("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap();
let l = layer(&s, id);
assert_eq!(l.source, LayerSource::Model { item: ItemId(item) });
assert!(l.switches.three_d);
assert!(l.props.sub("masks").is_none() && l.props.sub("materialOptions").is_some());
// Bounds 2 units tall → half the comp height.
let scale = l.props.prop("geometryOptions/unitScale").unwrap().value.as_f64();
assert!((scale - 90.0).abs() < 1e-6, "{scale}");
let anim = l.props.prop("geometryOptions/animation").unwrap();
assert_eq!(anim.value, KV::Enum(1));
match &anim.ui {
effectcraft_project::ParamUi::Popup { options } => assert_eq!(options, &vec!["None".to_string(), "Move".to_string()]),
u => panic!("{u:?}"),
}
s.undo();
assert!(s.active_comp().unwrap().layers.is_empty());
s.redo();
assert_eq!(s.active_comp().unwrap().layers.len(), 1);
// layer.newModel imports and places in one step.
let r = s.execute("layer.newModel", json!({"path": fixture("cube.obj")})).unwrap();
assert!(r["layer"].as_u64().is_some());
assert!(s.execute("layer.newModel", json!({"path": "/nonexistent/thing.png"})).is_err());
}
#[test]
fn primitives_commands() {
let mut s = session();
for k in PrimitiveKind::ALL {
let r = s.execute("layer.new3dPrimitive", json!({"kind": k.label().to_lowercase()})).unwrap();
let l = layer(&s, r["layer"].as_u64().unwrap());
assert_eq!(l.source, LayerSource::Primitive { kind: k });
assert_eq!(l.name, k.label());
assert!(l.switches.three_d);
assert!(l.props.sub("geometryOptions").is_some());
}
let r = s
.execute("layer.newPrimitive", json!({"kind": "sphere", "radius": 80, "segments": 12, "baseColor": [1, 0, 0], "metallic": 100, "castsShadows": "on"}))
.unwrap();
let l = layer(&s, r["layer"].as_u64().unwrap());
assert_eq!(l.props.prop("geometryOptions/radius").unwrap().value.as_f64(), 80.0);
assert_eq!(l.props.prop("materialOptions/metallic").unwrap().value.as_f64(), 100.0);
assert_eq!(l.props.prop("materialOptions/castsShadows").unwrap().value, KV::Enum(1));
assert!(s.execute("layer.newPrimitive", json!({"kind": "teapot"})).is_err());
let n = s.active_comp().unwrap().layers.len();
s.undo();
assert_eq!(s.active_comp().unwrap().layers.len(), n - 1);
// Menu entries point at the real command now.
assert!(s.is_enabled("layer.new3dPrimitive"));
}
#[test]
fn renderer_switch_adds_geometry_options() {
let mut s = session();
let t = s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap();
assert!(layer(&s, t).props.sub("geometryOptions").is_none());
s.execute("comp.renderer", json!({"renderer": "advanced3d"})).unwrap();
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Advanced3D);
let g = layer(&s, t);
let g = g.props.sub("geometryOptions").unwrap();
let ids: Vec<&str> = g.children.iter().map(|c| c.match_id()).collect();
assert_eq!(ids, vec!["bevelStyle", "bevelDepth", "holeBevelDepth", "extrusionDepth"]);
// New shape layers in the Advanced 3D comp get them too.
let sh = s.execute("layer.newShape", json!({})).unwrap()["layer"].as_u64().unwrap();
assert!(layer(&s, sh).props.sub("geometryOptions").is_some());
assert_eq!(s.execute("comp.renderer", json!({})).unwrap()["renderer"], "advanced3d");
s.undo();
s.undo();
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D);
assert!(layer(&s, t).props.sub("geometryOptions").is_none());
s.redo();
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Advanced3D);
assert!(s.execute("comp.renderer", json!({"renderer": "raytraced"})).is_err());
// Extrusion values are ordinary properties.
s.execute("prop.set", json!({"layer": t, "path": "geometryOptions/extrusionDepth", "value": 40})).unwrap();
assert_eq!(layer(&s, t).props.prop("geometryOptions/extrusionDepth").unwrap().value.as_f64(), 40.0);
}
#[test]
fn environment_layer_toggle() {
let mut s = session();
let id = s.execute("layer.newSolid", json!({"width": 64, "height": 32})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.environment", json!({"layer": id})).unwrap();
assert!(layer(&s, id).environment && layer(&s, id).switches.three_d);
s.execute("layer.environment", json!({"layer": id})).unwrap();
assert!(!layer(&s, id).environment);
s.undo();
assert!(layer(&s, id).environment);
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
assert!(s.execute("layer.environment", json!({"layer": cam})).is_err());
// An Environment light sources a layer.
let li = s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap()["layer"].as_u64().unwrap();
assert!(layer(&s, li).props.prop("lightOptions/source").is_some());
}
#[test]
fn material_commands_and_serde() {
let mut s = session();
let a = s.execute("layer.newPrimitive", json!({"kind": "cube"})).unwrap()["layer"].as_u64().unwrap();
let b = s.execute("layer.newPrimitive", json!({"kind": "torus"})).unwrap()["layer"].as_u64().unwrap();
s.execute("material.set", json!({"layers": [a], "baseColor": "#3366ff", "roughness": 20, "acceptsLights": false})).unwrap();
assert_eq!(layer(&s, a).props.prop("materialOptions/roughness").unwrap().value.as_f64(), 20.0);
assert!(s.execute("material.set", json!({"layers": [a], "castsShadows": "sometimes"})).is_err());
s.execute("material.duplicateAssign", json!({"layers": [a, b]})).unwrap();
assert_eq!(layer(&s, b).props.prop("materialOptions/roughness").unwrap().value.as_f64(), 20.0);
assert_eq!(layer(&s, b).props.prop("materialOptions/acceptsLights").unwrap().value, KV::Bool(false));
s.execute("material.reset", json!({"layers": [a]})).unwrap();
assert_eq!(layer(&s, a).props.prop("materialOptions/roughness").unwrap().value.as_f64(), 50.0);
s.undo();
assert_eq!(layer(&s, a).props.prop("materialOptions/roughness").unwrap().value.as_f64(), 20.0);
assert!(s.execute("material.revealSource", json!({"layer": a})).is_err());
// The project (model and primitive sources, environment flag) round-trips through JSON.
let r = s.execute("file.import", json!({"paths": [fixture("quad.glb")]})).unwrap();
let item = r["items"][0].as_u64().unwrap();
let m = s.execute("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap();
assert_eq!(s.execute("material.revealSource", json!({"layer": m})).unwrap()["item"], item);
let json = s.project.to_json();
let back = Project::from_json(&json).unwrap();
assert_eq!(&back, &*s.project);
}

View File

@ -400,6 +400,10 @@ impl Doc<'_> {
self.ex.warn(format!("{name}: cameras and lights are not exported (3D; exported as a hidden null to keep parenting)"));
hidden = true;
}
LayerSource::Model { .. } | LayerSource::Primitive { .. } => {
self.ex.warn(format!("{name}: 3D model layers are not part of Lottie (layer exported as a hidden null)"));
hidden = true;
}
}
o.insert("ty".into(), json!(ty));
if let Some(p) = l.parent.and_then(|p| comp.index_of(p)) {

View File

@ -14,6 +14,7 @@ effectcraft-time = { workspace = true }
effectcraft-project = { workspace = true }
effectcraft-raster = { workspace = true }
effectcraft-render = { workspace = true }
effectcraft-model = { workspace = true }
filmcraft-codecs = { workspace = true }
filmcraft-media = { workspace = true }
filmcraft-frame = { workspace = true }

View File

@ -17,7 +17,7 @@ mod pool;
mod probe;
pub use pool::{DEFAULT_BUDGET, MediaPool, PoolStats};
pub use probe::{DEFAULT_SEQUENCE_RATE, probe, probe_bytes, probe_single, sequence_files};
pub use probe::{DEFAULT_SEQUENCE_RATE, probe, probe_bytes, probe_model, probe_single, sequence_files};
/// Errors from probing or decoding footage.
#[derive(Debug, thiserror::Error)]
@ -50,10 +50,16 @@ pub const AUDIO_EXTENSIONS: &[&str] = filmcraft_media::AUDIO_EXTENSIONS;
/// File extensions recognised as movies.
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"];
/// Whether `path` has an extension we can import.
pub fn is_importable(path: &std::path::Path) -> bool {
let ext = ext_of(path);
STILL_EXTENSIONS.contains(&ext.as_str()) || AUDIO_EXTENSIONS.contains(&ext.as_str()) || VIDEO_EXTENSIONS.contains(&ext.as_str())
MODEL_EXTENSIONS.contains(&ext.as_str())
|| STILL_EXTENSIONS.contains(&ext.as_str())
|| AUDIO_EXTENSIONS.contains(&ext.as_str())
|| VIDEO_EXTENSIONS.contains(&ext.as_str())
}
pub(crate) fn ext_of(path: &std::path::Path) -> String {

View File

@ -122,6 +122,8 @@ struct Inner {
sources: Mutex<HashMap<Arc<str>, Option<SharedSource>>>,
/// In-memory files registered with `add_bytes` (web builds, tests).
files: Mutex<HashMap<String, Arc<[u8]>>>,
/// Parsed 3D models by path (`None`: failed to load; not retried until `forget`).
models: Mutex<HashMap<String, Option<Arc<effectcraft_model::Model>>>>,
cache: Mutex<CacheState>,
done: Condvar,
budget: AtomicU64,
@ -183,6 +185,7 @@ impl MediaPool {
inner: Arc::new(Inner {
sources: Mutex::default(),
files: Mutex::default(),
models: Mutex::default(),
cache: Mutex::default(),
done: Condvar::new(),
budget: AtomicU64::new(bytes as u64),
@ -234,6 +237,7 @@ impl MediaPool {
/// Forget a file (after it changed on disk, or to retry a file that failed to open).
pub fn forget(&self, path: &str) {
lock(&self.inner.sources).remove(path);
lock(&self.inner.models).remove(path);
let mut c = lock(&self.inner.cache);
let keys: Vec<Key> = c.lru.map.keys().filter(|k| &*k.path == path).cloned().collect();
for k in keys {
@ -305,6 +309,29 @@ impl Inner {
self.budget.load(Ordering::Relaxed) as usize
}
/// A 3D model file and its sibling resources (buffers, textures, MTL files), parsed once.
fn model(&self, path: &str) -> Option<Arc<effectcraft_model::Model>> {
if let Some(m) = lock(&self.models).get(path) {
return m.clone();
}
let loaded = self.read(path).map_err(|e| e.to_string()).and_then(|bytes| {
let dir = std::path::Path::new(path).parent().map(|d| d.to_path_buf()).unwrap_or_default();
let resolve = |uri: &str| -> Option<Vec<u8>> {
let p = dir.join(uri);
self.read(&p.to_string_lossy()).ok().map(|b| b.to_vec())
};
effectcraft_model::load(path, &bytes, &resolve).map_err(|e| e.to_string())
});
let m = match loaded {
Ok(m) => Some(Arc::new(m)),
Err(e) => {
log::warn!("media: cannot load model {path}: {e}");
None
}
};
lock(&self.models).entry(path.to_string()).or_insert(m).clone()
}
fn read(&self, path: &str) -> Result<Arc<[u8]>> {
if let Some(b) = lock(&self.files).get(path) {
return Ok(b.clone());
@ -359,7 +386,7 @@ impl Inner {
let i = Self::frame_index(footage.frame_rate, t, footage.sequence.len() as i64, footage.loop_count);
Loc { key: key(&footage.sequence[i as usize], 0), media_t: None }
}
FootageKind::Still | FootageKind::Sequence => Loc { key: key(&footage.path, 0), media_t: None },
FootageKind::Still | FootageKind::Sequence | FootageKind::Model => Loc { key: key(&footage.path, 0), media_t: None },
FootageKind::Video | FootageKind::Audio => {
let rate = footage.frame_rate;
let n = Self::frame_count(rate, footage.duration);
@ -451,6 +478,12 @@ impl Inner {
}
impl FootageSource for MediaPool {
fn model(&self, _item: ItemId, footage: &Footage) -> Option<Arc<effectcraft_model::Model>> {
if footage.missing || footage.kind != FootageKind::Model {
return None;
}
self.inner.model(&footage.path)
}
fn set_cache_budget(&self, bytes: usize) {
self.set_budget(bytes);
}

View File

@ -28,11 +28,50 @@ pub fn probe(path: impl AsRef<Path>) -> Result<Footage> {
pub fn probe_single(path: impl AsRef<Path>) -> Result<Footage> {
let path = path.as_ref();
let bytes = std::fs::read(path).map_err(|e| MediaError::Io(format!("{}: {e}", path.display())))?;
if crate::MODEL_EXTENSIONS.contains(&ext_of(path).as_str()) {
let dir = path.parent().map(Path::to_path_buf).unwrap_or_default();
return probe_model(&path.to_string_lossy(), &bytes, &|uri| std::fs::read(dir.join(uri)).ok());
}
probe_bytes(&path.to_string_lossy(), bytes.into())
}
/// Probe a 3D model (glTF 2.0 / OBJ): 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}")))?;
let s = m.summary();
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("glb") => "GLB",
_ => "glTF",
};
Ok(Footage {
path: path.to_string(),
kind: FootageKind::Model,
width: 0,
height: 0,
pixel_aspect: 1.0,
frame_rate: DEFAULT_SEQUENCE_RATE,
native_rate: None,
duration: Tick::from_seconds_f64(dur),
has_video: false,
has_audio: false,
alpha: AlphaMode::Straight,
premul_color: [0.0; 3],
loop_count: 1,
codec: format!("{codec} ({} meshes, {} triangles)", s.meshes, s.triangles),
missing: false,
sequence: Vec::new(),
color_profile: None,
})
}
/// Probe in-memory file contents; `path` names the footage (and is stored as `Footage::path`).
pub fn probe_bytes(path: &str, bytes: Arc<[u8]>) -> Result<Footage> {
if crate::MODEL_EXTENSIONS.contains(&ext_of(Path::new(path)).as_str()) {
return probe_model(path, &bytes, &|_| None);
}
if let Ok(fmt) = image::guess_format(&bytes) {
return probe_still_bytes(path, &bytes, fmt);
}

17
crates/model/Cargo.toml Normal file
View File

@ -0,0 +1,17 @@
[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"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[lints]
workspace = true
[dependencies]
effectcraft-geom = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
image = { workspace = true }

301
crates/model/src/extrude.rs Normal file
View File

@ -0,0 +1,301 @@
//! 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
}
/// 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() };
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 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));
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 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]);
}
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]) }
}
}
}
prim
}

519
crates/model/src/gltf.rs Normal file
View File

@ -0,0 +1,519 @@
//! glTF 2.0 import, written from the Khronos glTF 2.0 specification.
//!
//! Supports `.gltf` (JSON with external `.bin`/image files or `data:` URIs) and `.glb` (binary
//! container: a JSON chunk and a BIN chunk). Imports the default scene's node hierarchy (TRS or
//! matrix), triangle primitives (`TRIANGLES`, `TRIANGLE_STRIP`, `TRIANGLE_FAN`) with POSITION,
//! NORMAL, TANGENT, TEXCOORD_0, JOINTS_0 and WEIGHTS_0, PBR metallic-roughness materials
//! (base colour, metallic-roughness, normal, occlusion and emissive textures; alpha modes;
//! double-sided; `KHR_materials_emissive_strength`, `KHR_materials_unlit`), embedded or
//! external PNG/JPEG images with sampler wrap modes, skins and animations (step, linear and
//! cubic-spline translation/rotation/scale). Sparse accessors, morph targets, cameras and
//! lights are ignored.
use std::sync::Arc;
use serde_json::Value;
use crate::{
AlphaMode, Animation, Channel, ChannelPath, Interp, Mat4, Material, Mesh, Model, ModelError, Node, Primitive, Result, Skin, TexRef, Texture, Wrap, perr,
};
const GLB_MAGIC: u32 = 0x4654_6C67;
const CHUNK_JSON: u32 = 0x4E4F_534A;
const CHUNK_BIN: u32 = 0x004E_4942;
/// Split a GLB container into its JSON text and BIN chunk.
pub fn split_glb(b: &[u8]) -> Result<(&[u8], Option<&[u8]>)> {
let u32_at = |o: usize| -> Option<u32> { b.get(o..o + 4).map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]])) };
if u32_at(0) != Some(GLB_MAGIC) {
return Err(perr("not a GLB file"));
}
if u32_at(4) != Some(2) {
return Err(ModelError::Unsupported(format!("GLB version {}", u32_at(4).unwrap_or(0))));
}
let total = (u32_at(8).unwrap_or(0) as usize).min(b.len());
let mut o = 12;
let mut json = None;
let mut bin = None;
while o + 8 <= total {
let len = u32_at(o).unwrap_or(0) as usize;
let ty = u32_at(o + 4).unwrap_or(0);
let data = b.get(o + 8..o + 8 + len).ok_or_else(|| perr("GLB chunk runs past the end of the file"))?;
match ty {
CHUNK_JSON if json.is_none() => json = Some(data),
CHUNK_BIN if bin.is_none() => bin = Some(data),
_ => {}
}
o += 8 + len;
}
Ok((json.ok_or_else(|| perr("GLB without a JSON chunk"))?, bin))
}
/// Decode standard base64 (with or without padding; whitespace ignored).
pub fn base64_decode(s: &str) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(s.len() * 3 / 4);
let mut acc = 0u32;
let mut bits = 0;
for c in s.bytes() {
let v = match c {
b'A'..=b'Z' => c - b'A',
b'a'..=b'z' => c - b'a' + 26,
b'0'..=b'9' => c - b'0' + 52,
b'+' | b'-' => 62,
b'/' | b'_' => 63,
b'=' => break,
b' ' | b'\n' | b'\r' | b'\t' => continue,
_ => return None,
} as u32;
acc = (acc << 6) | v;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((acc >> bits) as u8);
acc &= (1 << bits) - 1;
}
}
Some(out)
}
/// Percent-decode a relative URI.
pub fn uri_decode(s: &str) -> String {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
if b[i] == b'%'
&& i + 2 < b.len()
&& let Ok(v) = u8::from_str_radix(std::str::from_utf8(&b[i + 1..i + 3]).unwrap_or("zz"), 16)
{
out.push(v);
i += 3;
continue;
}
out.push(b[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
/// Bytes of a `data:` URI or an external resource.
fn load_uri(uri: &str, resolve: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<Vec<u8>> {
if let Some(rest) = uri.strip_prefix("data:") {
let (meta, data) = rest.split_once(',').ok_or_else(|| perr("malformed data URI"))?;
return if meta.ends_with(";base64") { base64_decode(data).ok_or_else(|| perr("bad base64 in data URI")) } else { Ok(uri_decode(data).into_bytes()) };
}
let name = uri_decode(uri);
resolve(&name).ok_or(ModelError::Missing(name))
}
struct Doc<'a> {
j: &'a Value,
buffers: Vec<Vec<u8>>,
}
fn arr<'v>(v: &'v Value, k: &str) -> &'v [Value] {
v.get(k).and_then(Value::as_array).map(Vec::as_slice).unwrap_or(&[])
}
fn idx(v: &Value, k: &str) -> Option<usize> {
v.get(k).and_then(Value::as_u64).map(|x| x as usize)
}
fn num(v: &Value, k: &str, d: f64) -> f64 {
v.get(k).and_then(Value::as_f64).unwrap_or(d)
}
fn nums<const N: usize>(v: &Value, k: &str, d: [f64; N]) -> [f64; N] {
let mut out = d;
if let Some(a) = v.get(k).and_then(Value::as_array) {
for (i, x) in a.iter().take(N).enumerate() {
out[i] = x.as_f64().unwrap_or(d[i]);
}
}
out
}
impl Doc<'_> {
/// An accessor as `f32` rows of `n` components (normalised integers mapped to 0..1 / -1..1).
fn floats(&self, a: usize) -> Result<(Vec<f32>, usize)> {
let acc = arr(self.j, "accessors").get(a).ok_or_else(|| perr(format!("no accessor {a}")))?;
let n = match acc.get("type").and_then(Value::as_str).unwrap_or("") {
"SCALAR" => 1,
"VEC2" => 2,
"VEC3" => 3,
"VEC4" => 4,
"MAT2" => 4,
"MAT3" => 9,
"MAT4" => 16,
t => return Err(perr(format!("accessor type `{t}`"))),
};
let count = idx(acc, "count").unwrap_or(0);
let ct = idx(acc, "componentType").unwrap_or(5126);
let norm = acc.get("normalized").and_then(Value::as_bool).unwrap_or(false);
let csize = match ct {
5120 | 5121 => 1,
5122 | 5123 => 2,
5125 | 5126 => 4,
_ => return Err(perr(format!("componentType {ct}"))),
};
let mut out = vec![0.0f32; count * n];
let Some(bv) = idx(acc, "bufferView") else {
// No buffer view: all zeros (sparse substitution is not supported).
return Ok((out, n));
};
let view = arr(self.j, "bufferViews").get(bv).ok_or_else(|| perr(format!("no bufferView {bv}")))?;
let buf = self.buffers.get(idx(view, "buffer").unwrap_or(0)).ok_or_else(|| perr("bufferView buffer out of range"))?;
let base = idx(view, "byteOffset").unwrap_or(0) + idx(acc, "byteOffset").unwrap_or(0);
let elem = csize * n;
// Matrices of 1/2-byte components pad columns to 4 bytes; ignored (rare).
let stride = idx(view, "byteStride").filter(|s| *s > 0).unwrap_or(elem);
for i in 0..count {
for c in 0..n {
let o = base + i * stride + c * csize;
let s = buf.get(o..o + csize).ok_or_else(|| perr("accessor reads past its buffer"))?;
out[i * n + c] = match ct {
5126 => f32::from_le_bytes([s[0], s[1], s[2], s[3]]),
5125 => u32::from_le_bytes([s[0], s[1], s[2], s[3]]) as f32,
5123 => {
let v = u16::from_le_bytes([s[0], s[1]]) as f32;
if norm { v / 65535.0 } else { v }
}
5122 => {
let v = i16::from_le_bytes([s[0], s[1]]) as f32;
if norm { (v / 32767.0).max(-1.0) } else { v }
}
5121 => {
let v = s[0] as f32;
if norm { v / 255.0 } else { v }
}
_ => {
let v = s[0] as i8 as f32;
if norm { (v / 127.0).max(-1.0) } else { v }
}
};
}
}
Ok((out, n))
}
/// Integer accessor (indices, joints) read exactly.
fn ints(&self, a: usize) -> Result<Vec<u32>> {
let acc = arr(self.j, "accessors").get(a).ok_or_else(|| perr(format!("no accessor {a}")))?;
let ct = idx(acc, "componentType").unwrap_or(5125);
if ct == 5126 {
return Ok(self.floats(a)?.0.into_iter().map(|v| v as u32).collect());
}
let n = match acc.get("type").and_then(Value::as_str).unwrap_or("SCALAR") {
"VEC4" => 4,
"VEC3" => 3,
"VEC2" => 2,
_ => 1,
};
let count = idx(acc, "count").unwrap_or(0);
let csize = match ct {
5120 | 5121 => 1,
5122 | 5123 => 2,
_ => 4,
};
let Some(bv) = idx(acc, "bufferView") else { return Ok(vec![0; count * n]) };
let view = arr(self.j, "bufferViews").get(bv).ok_or_else(|| perr(format!("no bufferView {bv}")))?;
let buf = self.buffers.get(idx(view, "buffer").unwrap_or(0)).ok_or_else(|| perr("bufferView buffer out of range"))?;
let base = idx(view, "byteOffset").unwrap_or(0) + idx(acc, "byteOffset").unwrap_or(0);
let stride = idx(view, "byteStride").filter(|s| *s > 0).unwrap_or(csize * n);
let mut out = Vec::with_capacity(count * n);
for i in 0..count {
for c in 0..n {
let o = base + i * stride + c * csize;
let s = buf.get(o..o + csize).ok_or_else(|| perr("accessor reads past its buffer"))?;
out.push(match csize {
1 => s[0] as u32,
2 => u16::from_le_bytes([s[0], s[1]]) as u32,
_ => u32::from_le_bytes([s[0], s[1], s[2], s[3]]),
});
}
}
Ok(out)
}
}
/// Parse a `.gltf` (JSON) or `.glb` file.
pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<Model> {
let (json, bin) = if bytes.starts_with(b"glTF") { split_glb(bytes)? } else { (bytes, None) };
let j: Value = serde_json::from_slice(json).map_err(|e| perr(format!("glTF JSON: {e}")))?;
let ver = j.get("asset").and_then(|a| a.get("version")).and_then(Value::as_str).unwrap_or("");
if !ver.starts_with('2') {
return Err(ModelError::Unsupported(format!("glTF version `{ver}` (2.0 required)")));
}
for ext in arr(&j, "extensionsRequired") {
let e = ext.as_str().unwrap_or("");
if !matches!(e, "KHR_materials_unlit" | "KHR_materials_emissive_strength") {
return Err(ModelError::Unsupported(format!("required extension {e}")));
}
}
let mut buffers = vec![];
for (i, b) in arr(&j, "buffers").iter().enumerate() {
let data = match b.get("uri").and_then(Value::as_str) {
Some(u) => load_uri(u, resolve)?,
None if i == 0 => bin.map(<[u8]>::to_vec).ok_or_else(|| perr("buffer 0 has no uri and the file has no BIN chunk"))?,
None => vec![],
};
buffers.push(data);
}
let d = Doc { j: &j, buffers };
// Images → textures (decoded once per image; samplers set the wrap modes).
let mut images: Vec<Option<Arc<Texture>>> = vec![];
for im in arr(&j, "images") {
let data = if let Some(u) = im.get("uri").and_then(Value::as_str) {
load_uri(u, resolve).ok()
} else if let Some(bv) = idx(im, "bufferView") {
arr(&j, "bufferViews").get(bv).and_then(|v| {
let b = d.buffers.get(idx(v, "buffer").unwrap_or(0))?;
let o = idx(v, "byteOffset").unwrap_or(0);
b.get(o..o + idx(v, "byteLength").unwrap_or(0)).map(<[u8]>::to_vec)
})
} else {
None
};
images.push(data.and_then(|b| Texture::decode(&b).ok()).map(Arc::new));
}
let wrap = |v: u64| match v {
33071 => Wrap::Clamp,
33648 => Wrap::Mirror,
_ => Wrap::Repeat,
};
let mut textures: Vec<Arc<Texture>> = vec![];
let mut tex_map: Vec<Option<usize>> = vec![];
for t in arr(&j, "textures") {
let Some(img) = idx(t, "source").and_then(|s| images.get(s).cloned().flatten()) else {
tex_map.push(None);
continue;
};
let samp = idx(t, "sampler").and_then(|s| arr(&j, "samplers").get(s));
let ws = samp.and_then(|s| s.get("wrapS")).and_then(Value::as_u64).unwrap_or(10497);
let wt = samp.and_then(|s| s.get("wrapT")).and_then(Value::as_u64).unwrap_or(10497);
let w = [wrap(ws), wrap(wt)];
let tex = if img.wrap == w { img } else { Arc::new(Texture { wrap: w, ..(*img).clone() }) };
tex_map.push(Some(textures.len()));
textures.push(tex);
}
let tref = |v: Option<&Value>| -> Option<TexRef> {
let v = v?;
let t = tex_map.get(idx(v, "index")?).copied().flatten()?;
Some(TexRef { texture: t, uv_set: idx(v, "texCoord").unwrap_or(0) as u32 })
};
let mut materials = vec![];
for m in arr(&j, "materials") {
let pbr = m.get("pbrMetallicRoughness").cloned().unwrap_or(Value::Null);
let bc = nums(&pbr, "baseColorFactor", [1.0; 4]);
let ext = m.get("extensions");
let strength = ext.and_then(|e| e.get("KHR_materials_emissive_strength")).map_or(1.0, |e| num(e, "emissiveStrength", 1.0));
let em = nums(m, "emissiveFactor", [0.0; 3]);
materials.push(Material {
name: m.get("name").and_then(Value::as_str).unwrap_or("").to_string(),
base_color: bc.map(|v| v as f32),
base_color_tex: tref(pbr.get("baseColorTexture")),
metallic: num(&pbr, "metallicFactor", 1.0) as f32,
roughness: num(&pbr, "roughnessFactor", 1.0) as f32,
metallic_roughness_tex: tref(pbr.get("metallicRoughnessTexture")),
normal_tex: tref(m.get("normalTexture")),
normal_scale: m.get("normalTexture").map_or(1.0, |n| num(n, "scale", 1.0)) as f32,
occlusion_tex: tref(m.get("occlusionTexture")),
occlusion_strength: m.get("occlusionTexture").map_or(1.0, |n| num(n, "strength", 1.0)) as f32,
emissive: [(em[0] * strength) as f32, (em[1] * strength) as f32, (em[2] * strength) as f32],
emissive_tex: tref(m.get("emissiveTexture")),
alpha_mode: match m.get("alphaMode").and_then(Value::as_str) {
Some("MASK") => AlphaMode::Mask,
Some("BLEND") => AlphaMode::Blend,
_ => AlphaMode::Opaque,
},
alpha_cutoff: num(m, "alphaCutoff", 0.5) as f32,
double_sided: m.get("doubleSided").and_then(Value::as_bool).unwrap_or(false),
unlit: ext.and_then(|e| e.get("KHR_materials_unlit")).is_some(),
});
}
let mut meshes = vec![];
for m in arr(&j, "meshes") {
let mut mesh = Mesh { name: m.get("name").and_then(Value::as_str).unwrap_or("").to_string(), primitives: vec![] };
for p in arr(m, "primitives") {
if let Some(prim) = primitive(&d, p, &materials)? {
mesh.primitives.push(prim);
}
}
meshes.push(mesh);
}
let mut nodes = vec![];
for n in arr(&j, "nodes") {
let matrix = n.get("matrix").and_then(Value::as_array).filter(|a| a.len() == 16).map(|a| {
// Column-major in the file.
let v: Vec<f64> = a.iter().map(|x| x.as_f64().unwrap_or(0.0)).collect();
let mut m = [[0.0; 4]; 4];
for c in 0..4 {
for r in 0..4 {
m[r][c] = v[c * 4 + r];
}
}
Mat4(m)
});
nodes.push(Node {
name: n.get("name").and_then(Value::as_str).unwrap_or("").to_string(),
translation: nums(n, "translation", [0.0; 3]),
rotation: nums(n, "rotation", [0.0, 0.0, 0.0, 1.0]),
scale: nums(n, "scale", [1.0; 3]),
matrix,
mesh: idx(n, "mesh").filter(|&m| m < meshes.len()),
skin: idx(n, "skin"),
children: arr(n, "children").iter().filter_map(Value::as_u64).map(|c| c as usize).collect(),
});
}
let scene = idx(&j, "scene").unwrap_or(0);
let roots: Vec<usize> = match arr(&j, "scenes").get(scene) {
Some(s) => arr(s, "nodes").iter().filter_map(Value::as_u64).map(|c| c as usize).collect(),
None => {
// No scene: every node that is nobody's child.
let mut child = vec![false; nodes.len()];
for n in &nodes {
for &c in &n.children {
if c < child.len() {
child[c] = true;
}
}
}
(0..nodes.len()).filter(|&i| !child[i]).collect()
}
};
let mut skins = vec![];
for s in arr(&j, "skins") {
let joints: Vec<usize> = arr(s, "joints").iter().filter_map(Value::as_u64).map(|c| c as usize).collect();
let inverse_bind = match idx(s, "inverseBindMatrices") {
Some(a) => {
let (f, _) = d.floats(a)?;
f.chunks_exact(16)
.map(|v| {
let mut m = [[0.0; 4]; 4];
for c in 0..4 {
for r in 0..4 {
m[r][c] = v[c * 4 + r] as f64;
}
}
Mat4(m)
})
.collect()
}
None => vec![Mat4::IDENTITY; joints.len()],
};
skins.push(Skin { joints, inverse_bind });
}
let mut animations = vec![];
for a in arr(&j, "animations") {
let samplers = arr(a, "samplers");
let mut anim = Animation { name: a.get("name").and_then(Value::as_str).unwrap_or("").to_string(), channels: vec![] };
for c in arr(a, "channels") {
let Some(t) = c.get("target") else { continue };
let Some(node) = idx(t, "node") else { continue };
let path = match t.get("path").and_then(Value::as_str) {
Some("translation") => ChannelPath::Translation,
Some("rotation") => ChannelPath::Rotation,
Some("scale") => ChannelPath::Scale,
_ => continue,
};
let Some(s) = idx(c, "sampler").and_then(|s| samplers.get(s)) else { continue };
let interp = match s.get("interpolation").and_then(Value::as_str) {
Some("STEP") => Interp::Step,
Some("CUBICSPLINE") => Interp::CubicSpline,
_ => Interp::Linear,
};
let (Some(input), Some(output)) = (idx(s, "input"), idx(s, "output")) else { continue };
let (times, _) = d.floats(input)?;
let (values, _) = d.floats(output)?;
anim.channels.push(Channel { node, path, interp, times, values });
}
animations.push(anim);
}
Ok(Model { meshes, nodes, roots, materials, textures, skins, animations })
}
fn primitive(d: &Doc, p: &Value, materials: &[Material]) -> Result<Option<Primitive>> {
let mode = idx(p, "mode").unwrap_or(4);
if !matches!(mode, 4..=6) {
return Ok(None);
}
let attrs = p.get("attributes").ok_or_else(|| perr("primitive without attributes"))?;
let Some(pa) = idx(attrs, "POSITION") else { return Ok(None) };
let (pos, _) = d.floats(pa)?;
let positions: Vec<[f32; 3]> = pos.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect();
let n = positions.len();
let vec3s = |a: Option<usize>| -> Result<Vec<[f32; 3]>> {
Ok(match a {
Some(a) => d.floats(a)?.0.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect(),
None => vec![],
})
};
let mut normals = vec3s(idx(attrs, "NORMAL"))?;
let uvs: Vec<[f32; 2]> = match idx(attrs, "TEXCOORD_0") {
Some(a) => d.floats(a)?.0.chunks_exact(2).map(|c| [c[0], c[1]]).collect(),
None => vec![],
};
let tangents: Vec<[f32; 4]> = match idx(attrs, "TANGENT") {
Some(a) => d.floats(a)?.0.chunks_exact(4).map(|c| [c[0], c[1], c[2], c[3]]).collect(),
None => vec![],
};
let joints: Vec<[u16; 4]> = match idx(attrs, "JOINTS_0") {
Some(a) => d.ints(a)?.chunks_exact(4).map(|c| [c[0] as u16, c[1] as u16, c[2] as u16, c[3] as u16]).collect(),
None => vec![],
};
let weights: Vec<[f32; 4]> = match idx(attrs, "WEIGHTS_0") {
Some(a) => d.floats(a)?.0.chunks_exact(4).map(|c| [c[0], c[1], c[2], c[3]]).collect(),
None => vec![],
};
let raw: Vec<u32> = match idx(p, "indices") {
Some(a) => d.ints(a)?,
None => (0..n as u32).collect(),
};
if raw.iter().any(|&i| i as usize >= n) {
return Err(perr("index out of range"));
}
let mut indices = Vec::with_capacity(raw.len());
match mode {
5 => {
for i in 2..raw.len() {
let (a, b, c) = (raw[i - 2], raw[i - 1], raw[i]);
if i % 2 == 0 { indices.extend([a, b, c]) } else { indices.extend([b, a, c]) }
}
}
6 => {
for i in 2..raw.len() {
indices.extend([raw[0], raw[i - 1], raw[i]]);
}
}
_ => indices.extend(raw.chunks_exact(3).flatten()),
}
if normals.len() != n {
normals.clear();
}
let material = idx(p, "material").filter(|&m| m < materials.len());
let skinned = joints.len() == n && weights.len() == n;
let mut prim = Primitive {
positions,
normals,
uvs: if uvs.len() == n { uvs } else { vec![] },
tangents: if tangents.len() == n { tangents } else { vec![] },
joints: if skinned { joints } else { vec![] },
weights: if skinned { weights } else { vec![] },
indices,
material,
};
if prim.normals.is_empty() {
prim.flat_normals();
prim.tangents.clear();
}
let needs_tangents = material.is_some_and(|m| materials[m].normal_tex.is_some());
if needs_tangents && prim.tangents.is_empty() {
prim.compute_tangents();
}
Ok(Some(prim))
}

691
crates/model/src/lib.rs Normal file
View File

@ -0,0 +1,691 @@
//! 3D models for the Advanced 3D renderer.
//!
//! - [`gltf`]: glTF 2.0 import (`.gltf` with external or embedded buffers, `.glb`), written from
//! the Khronos glTF 2.0 specification: meshes, PBR metallic-roughness materials (base colour,
//! 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).
//! - [`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.
//!
//! Models are in their own units with +Y up (glTF's convention); the renderer maps them into
//! After Effects' Y-down pixel space.
pub mod extrude;
pub mod gltf;
pub mod obj;
pub mod prim;
pub mod triangulate;
use std::sync::Arc;
pub use effectcraft_geom::{Mat4, Vec3, vec3};
#[derive(Debug, thiserror::Error)]
pub enum ModelError {
#[error("{0}")]
Parse(String),
#[error("missing resource `{0}`")]
Missing(String),
#[error("unsupported: {0}")]
Unsupported(String),
}
pub type Result<T> = std::result::Result<T, ModelError>;
pub(crate) fn perr(s: impl Into<String>) -> ModelError {
ModelError::Parse(s.into())
}
/// Texture coordinate wrapping (glTF sampler wrap modes).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Wrap {
#[default]
Repeat,
Clamp,
Mirror,
}
/// A decoded texture: straight RGBA, 0..1, as stored in the file (colour textures are still
/// sRGB-encoded; the renderer linearises them).
#[derive(Clone, Debug, PartialEq)]
pub struct Texture {
pub width: u32,
pub height: u32,
pub data: Vec<[f32; 4]>,
pub wrap: [Wrap; 2],
}
impl Texture {
pub fn solid(c: [f32; 4]) -> Texture {
Texture { width: 1, height: 1, data: vec![c], wrap: [Wrap::Repeat; 2] }
}
/// Decode a PNG or JPEG.
pub fn decode(bytes: &[u8]) -> Result<Texture> {
let img = image::load_from_memory(bytes).map_err(|e| perr(format!("image: {e}")))?.to_rgba32f();
let (w, h) = (img.width(), img.height());
let data = img.pixels().map(|p| p.0).collect();
Ok(Texture { width: w, height: h, data, wrap: [Wrap::Repeat; 2] })
}
}
/// How a material's alpha is used (glTF `alphaMode`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum AlphaMode {
#[default]
Opaque,
Mask,
Blend,
}
/// A texture reference of a material.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TexRef {
pub texture: usize,
/// TEXCOORD set (only set 0 is imported; others fall back to set 0).
pub uv_set: u32,
}
/// A metallic-roughness PBR material (glTF 2.0 core material model).
#[derive(Clone, Debug, PartialEq)]
pub struct Material {
pub name: String,
/// Linear RGBA factor.
pub base_color: [f32; 4],
pub base_color_tex: Option<TexRef>,
pub metallic: f32,
pub roughness: f32,
/// Roughness in G, metalness in B.
pub metallic_roughness_tex: Option<TexRef>,
pub normal_tex: Option<TexRef>,
pub normal_scale: f32,
/// Occlusion in R.
pub occlusion_tex: Option<TexRef>,
pub occlusion_strength: f32,
/// Linear emissive factor (× KHR_materials_emissive_strength).
pub emissive: [f32; 3],
pub emissive_tex: Option<TexRef>,
pub alpha_mode: AlphaMode,
pub alpha_cutoff: f32,
pub double_sided: bool,
/// KHR_materials_unlit.
pub unlit: bool,
}
impl Default for Material {
fn default() -> Self {
Material {
name: String::new(),
base_color: [1.0; 4],
base_color_tex: None,
metallic: 1.0,
roughness: 1.0,
metallic_roughness_tex: None,
normal_tex: None,
normal_scale: 1.0,
occlusion_tex: None,
occlusion_strength: 1.0,
emissive: [0.0; 3],
emissive_tex: None,
alpha_mode: AlphaMode::Opaque,
alpha_cutoff: 0.5,
double_sided: false,
unlit: false,
}
}
}
impl Material {
/// A plain dielectric material of a colour (primitives, OBJ without MTL).
pub fn plain(base: [f32; 4], metallic: f32, roughness: f32) -> Material {
Material { base_color: base, metallic, roughness, ..Material::default() }
}
}
/// Triangles with one material.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Primitive {
pub positions: Vec<[f32; 3]>,
pub normals: Vec<[f32; 3]>,
/// TEXCOORD_0 (empty when the primitive has none).
pub uvs: Vec<[f32; 2]>,
/// Tangents (xyz, w = bitangent sign); empty when not needed.
pub tangents: Vec<[f32; 4]>,
/// JOINTS_0 / WEIGHTS_0 (skinned meshes; empty otherwise).
pub joints: Vec<[u16; 4]>,
pub weights: Vec<[f32; 4]>,
/// Triangle list.
pub indices: Vec<u32>,
pub material: Option<usize>,
}
impl Primitive {
pub fn triangle_count(&self) -> usize {
self.indices.len() / 3
}
/// Smooth vertex normals from the triangles (area weighted), for meshes without normals.
pub fn compute_normals(&mut self) {
let mut n = vec![[0.0f32; 3]; self.positions.len()];
for t in self.indices.chunks_exact(3) {
let (a, b, c) = (self.positions[t[0] as usize], self.positions[t[1] as usize], self.positions[t[2] as usize]);
let f = cross(sub(b, a), sub(c, a));
for &i in t {
let v = &mut n[i as usize];
v[0] += f[0];
v[1] += f[1];
v[2] += f[2];
}
}
self.normals = n.into_iter().map(|v| normalize(v).unwrap_or([0.0, 0.0, 1.0])).collect();
}
/// Split every triangle into its own vertices with a face normal (glTF: flat normals when a
/// primitive has none).
pub fn flat_normals(&mut self) {
let mut p = Primitive { material: self.material, ..Default::default() };
for t in self.indices.chunks_exact(3) {
let (a, b, c) = (self.positions[t[0] as usize], self.positions[t[1] as usize], self.positions[t[2] as usize]);
let f = normalize(cross(sub(b, a), sub(c, a))).unwrap_or([0.0, 0.0, 1.0]);
for &i in t {
let i = i as usize;
p.indices.push(p.positions.len() as u32);
p.positions.push(self.positions[i]);
p.normals.push(f);
if !self.uvs.is_empty() {
p.uvs.push(self.uvs[i]);
}
if !self.joints.is_empty() {
p.joints.push(self.joints[i]);
p.weights.push(self.weights[i]);
}
}
}
*self = p;
}
/// Per-vertex tangents from the UV layout (accumulated per triangle, Gram-Schmidt
/// orthogonalised against the normal, handedness in w).
pub fn compute_tangents(&mut self) {
let n = self.positions.len();
if self.uvs.len() != n || self.normals.len() != n {
return;
}
let mut tan = vec![[0.0f32; 3]; n];
let mut bit = vec![[0.0f32; 3]; n];
for t in self.indices.chunks_exact(3) {
let (i0, i1, i2) = (t[0] as usize, t[1] as usize, t[2] as usize);
let (p0, p1, p2) = (self.positions[i0], self.positions[i1], self.positions[i2]);
let (w0, w1, w2) = (self.uvs[i0], self.uvs[i1], self.uvs[i2]);
let e1 = sub(p1, p0);
let e2 = sub(p2, p0);
let (du1, dv1, du2, dv2) = (w1[0] - w0[0], w1[1] - w0[1], w2[0] - w0[0], w2[1] - w0[1]);
let det = du1 * dv2 - du2 * dv1;
if det.abs() < 1e-12 {
continue;
}
let r = 1.0 / det;
let sdir = [(e1[0] * dv2 - e2[0] * dv1) * r, (e1[1] * dv2 - e2[1] * dv1) * r, (e1[2] * dv2 - e2[2] * dv1) * r];
let tdir = [(e2[0] * du1 - e1[0] * du2) * r, (e2[1] * du1 - e1[1] * du2) * r, (e2[2] * du1 - e1[2] * du2) * r];
for i in [i0, i1, i2] {
for k in 0..3 {
tan[i][k] += sdir[k];
bit[i][k] += tdir[k];
}
}
}
self.tangents = (0..n)
.map(|i| {
let nn = self.normals[i];
let t = tan[i];
let d = dot(nn, t);
let o = [t[0] - nn[0] * d, t[1] - nn[1] * d, t[2] - nn[2] * d];
let o = normalize(o).unwrap_or_else(|| any_perpendicular(nn));
let w = if dot(cross(nn, o), bit[i]) < 0.0 { -1.0 } else { 1.0 };
[o[0], o[1], o[2], w]
})
.collect();
}
/// Axis-aligned bounds of the positions.
pub fn bounds(&self) -> Option<([f32; 3], [f32; 3])> {
let mut it = self.positions.iter();
let f = *it.next()?;
let (mut lo, mut hi) = (f, f);
for p in it {
for k in 0..3 {
lo[k] = lo[k].min(p[k]);
hi[k] = hi[k].max(p[k]);
}
}
Some((lo, hi))
}
}
pub(crate) fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
pub(crate) fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
pub(crate) fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
pub(crate) fn normalize(a: [f32; 3]) -> Option<[f32; 3]> {
let l = dot(a, a).sqrt();
(l > 1e-20 && l.is_finite()).then(|| [a[0] / l, a[1] / l, a[2] / l])
}
fn any_perpendicular(n: [f32; 3]) -> [f32; 3] {
let a = if n[0].abs() < 0.9 { [1.0, 0.0, 0.0] } else { [0.0, 1.0, 0.0] };
normalize(cross(n, a)).unwrap_or([1.0, 0.0, 0.0])
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Mesh {
pub name: String,
pub primitives: Vec<Primitive>,
}
/// A node of the scene hierarchy: local transform (TRS or a matrix), an optional mesh and skin.
#[derive(Clone, Debug, PartialEq)]
pub struct Node {
pub name: String,
pub translation: [f64; 3],
/// Unit quaternion (x, y, z, w).
pub rotation: [f64; 4],
pub scale: [f64; 3],
/// A fixed local matrix (nodes given by `matrix` are not animated).
pub matrix: Option<Mat4>,
pub mesh: Option<usize>,
pub skin: Option<usize>,
pub children: Vec<usize>,
}
impl Default for Node {
fn default() -> Self {
Node {
name: String::new(),
translation: [0.0; 3],
rotation: [0.0, 0.0, 0.0, 1.0],
scale: [1.0; 3],
matrix: None,
mesh: None,
skin: None,
children: vec![],
}
}
}
impl Node {
pub fn local(&self) -> Mat4 {
self.matrix.unwrap_or_else(|| trs(self.translation, self.rotation, self.scale))
}
}
/// Translation × rotation (quaternion x, y, z, w) × scale.
pub fn trs(t: [f64; 3], q: [f64; 4], s: [f64; 3]) -> Mat4 {
let r = quat_matrix(q);
let mut m = r;
for i in 0..3 {
for j in 0..3 {
m.0[i][j] *= s[j];
}
m.0[i][3] = t[i];
}
m
}
/// Rotation matrix of a quaternion (x, y, z, w); normalised first.
pub fn quat_matrix(q: [f64; 4]) -> Mat4 {
let l = (q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3]).sqrt();
let [x, y, z, w] = if l > 1e-12 { [q[0] / l, q[1] / l, q[2] / l, q[3] / l] } else { [0.0, 0.0, 0.0, 1.0] };
Mat4([
[1.0 - 2.0 * (y * y + z * z), 2.0 * (x * y - z * w), 2.0 * (x * z + y * w), 0.0],
[2.0 * (x * y + z * w), 1.0 - 2.0 * (x * x + z * z), 2.0 * (y * z - x * w), 0.0],
[2.0 * (x * z - y * w), 2.0 * (y * z + x * w), 1.0 - 2.0 * (x * x + y * y), 0.0],
[0.0, 0.0, 0.0, 1.0],
])
}
/// Spherical linear interpolation of unit quaternions (shortest path).
pub fn slerp(a: [f64; 4], b: [f64; 4], t: f64) -> [f64; 4] {
let mut d = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3];
let mut b = b;
if d < 0.0 {
d = -d;
b = [-b[0], -b[1], -b[2], -b[3]];
}
let (ka, kb) = if d > 0.9995 {
(1.0 - t, t)
} else {
let th = d.clamp(-1.0, 1.0).acos();
let s = th.sin();
(((1.0 - t) * th).sin() / s, (t * th).sin() / s)
};
let q = [a[0] * ka + b[0] * kb, a[1] * ka + b[1] * kb, a[2] * ka + b[2] * kb, a[3] * ka + b[3] * kb];
let l = (q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3]).sqrt().max(1e-12);
[q[0] / l, q[1] / l, q[2] / l, q[3] / l]
}
/// A skin: joint nodes and their inverse bind matrices.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Skin {
pub joints: Vec<usize>,
pub inverse_bind: Vec<Mat4>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ChannelPath {
Translation,
Rotation,
Scale,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Interp {
Step,
Linear,
CubicSpline,
}
/// One animated node property.
#[derive(Clone, Debug, PartialEq)]
pub struct Channel {
pub node: usize,
pub path: ChannelPath,
pub interp: Interp,
/// Key times (seconds).
pub times: Vec<f32>,
/// Values, `width` floats per key (cubic spline: in-tangent, value, out-tangent per key).
pub values: Vec<f32>,
}
impl Channel {
fn width(&self) -> usize {
match self.path {
ChannelPath::Rotation => 4,
_ => 3,
}
}
/// The value at time `t` (seconds), clamped to the key range.
pub fn sample(&self, t: f64) -> Option<[f64; 4]> {
let n = self.times.len();
let w = self.width();
let stride = if self.interp == Interp::CubicSpline { 3 * w } else { w };
let off = if self.interp == Interp::CubicSpline { w } else { 0 };
if n == 0 || self.values.len() < n * stride {
return None;
}
let get = |k: usize, o: usize| -> [f64; 4] {
let mut v = [0.0; 4];
for i in 0..w {
v[i] = self.values[k * stride + o + i] as f64;
}
v
};
let t0 = self.times[0] as f64;
if t <= t0 || n == 1 {
return Some(get(0, off));
}
if t >= self.times[n - 1] as f64 {
return Some(get(n - 1, off));
}
let k = self.times.partition_point(|&x| (x as f64) <= t).saturating_sub(1).min(n - 2);
let (ta, tb) = (self.times[k] as f64, self.times[k + 1] as f64);
let dt = (tb - ta).max(1e-9);
let u = ((t - ta) / dt).clamp(0.0, 1.0);
let (a, b) = (get(k, off), get(k + 1, off));
let v = match self.interp {
Interp::Step => a,
Interp::Linear => {
if self.path == ChannelPath::Rotation {
slerp(a, b, u)
} else {
let mut v = [0.0; 4];
for i in 0..w {
v[i] = a[i] + (b[i] - a[i]) * u;
}
v
}
}
Interp::CubicSpline => {
// Hermite spline with tangents scaled by the key interval (glTF Appendix C).
let out_a = get(k, 2 * w);
let in_b = get(k + 1, 0);
let (u2, u3) = (u * u, u * u * u);
let h00 = 2.0 * u3 - 3.0 * u2 + 1.0;
let h10 = u3 - 2.0 * u2 + u;
let h01 = -2.0 * u3 + 3.0 * u2;
let h11 = u3 - u2;
let mut v = [0.0; 4];
for i in 0..w {
v[i] = h00 * a[i] + h10 * dt * out_a[i] + h01 * b[i] + h11 * dt * in_b[i];
}
if self.path == ChannelPath::Rotation {
let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2] + v[3] * v[3]).sqrt().max(1e-12);
for c in v.iter_mut() {
*c /= l;
}
}
v
}
};
Some(v)
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Animation {
pub name: String,
pub channels: Vec<Channel>,
}
impl Animation {
/// Length in seconds (the last key time of any channel).
pub fn duration(&self) -> f64 {
self.channels.iter().filter_map(|c| c.times.last()).fold(0.0f64, |a, &b| a.max(b as f64))
}
}
/// An imported model (one glTF scene, one OBJ file, or a primitive).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Model {
pub meshes: Vec<Mesh>,
pub nodes: Vec<Node>,
/// Root nodes of the displayed scene.
pub roots: Vec<usize>,
pub materials: Vec<Material>,
pub textures: Vec<Arc<Texture>>,
pub skins: Vec<Skin>,
pub animations: Vec<Animation>,
}
/// One mesh placed in model space by [`Model::instances`].
#[derive(Clone, Debug)]
pub struct Instance<'m> {
pub mesh: &'m Mesh,
/// Node → model space.
pub world: Mat4,
/// Skinned meshes: joint matrices (model space, `joint_world × inverse_bind`).
pub joints: Option<Vec<Mat4>>,
}
impl Model {
/// A model of one mesh at the root.
pub fn single(mesh: Mesh, materials: Vec<Material>) -> Model {
Model { meshes: vec![mesh], nodes: vec![Node { mesh: Some(0), ..Node::default() }], roots: vec![0], materials, ..Model::default() }
}
/// Local node matrices at animation `clip` time `t` (seconds); `None` = rest pose.
pub fn local_pose(&self, clip: Option<usize>, t: f64) -> Vec<Mat4> {
let mut trs_v: Vec<([f64; 3], [f64; 4], [f64; 3])> = self.nodes.iter().map(|n| (n.translation, n.rotation, n.scale)).collect();
if let Some(a) = clip.and_then(|c| self.animations.get(c)) {
for ch in &a.channels {
let (Some(v), Some(e)) = (ch.sample(t), trs_v.get_mut(ch.node)) else { continue };
match ch.path {
ChannelPath::Translation => e.0 = [v[0], v[1], v[2]],
ChannelPath::Rotation => e.1 = v,
ChannelPath::Scale => e.2 = [v[0], v[1], v[2]],
}
}
}
self.nodes.iter().zip(trs_v).map(|(n, (t, r, s))| n.matrix.unwrap_or_else(|| trs(t, r, s))).collect()
}
/// Model-space matrices of every node (hierarchy applied) for a local pose.
pub fn world_pose(&self, local: &[Mat4]) -> Vec<Mat4> {
let mut world = vec![Mat4::IDENTITY; self.nodes.len()];
let mut seen = vec![false; self.nodes.len()];
let mut stack: Vec<(usize, Mat4)> = self.roots.iter().map(|&r| (r, Mat4::IDENTITY)).collect();
while let Some((i, parent)) = stack.pop() {
if i >= self.nodes.len() || seen[i] {
continue;
}
seen[i] = true;
let w = parent * local[i];
world[i] = w;
for &c in &self.nodes[i].children {
stack.push((c, w));
}
}
world
}
/// The meshes to draw at animation `clip` time `t`.
pub fn instances(&self, clip: Option<usize>, t: f64) -> Vec<Instance<'_>> {
let local = self.local_pose(clip, t);
let world = self.world_pose(&local);
let mut seen = vec![false; self.nodes.len()];
let mut stack: Vec<usize> = self.roots.clone();
let mut out = vec![];
while let Some(i) = stack.pop() {
if i >= self.nodes.len() || seen[i] {
continue;
}
seen[i] = true;
let n = &self.nodes[i];
if let Some(m) = n.mesh.and_then(|m| self.meshes.get(m)) {
let joints = n.skin.and_then(|s| self.skins.get(s)).map(|s| {
s.joints
.iter()
.enumerate()
.map(|(k, &j)| world.get(j).copied().unwrap_or(Mat4::IDENTITY) * s.inverse_bind.get(k).copied().unwrap_or(Mat4::IDENTITY))
.collect()
});
// Skinned vertices are placed by their joints alone (glTF: the node transform of
// a skinned mesh is ignored).
let w = if joints.is_some() { Mat4::IDENTITY } else { world[i] };
out.push(Instance { mesh: m, world: w, joints });
}
stack.extend(n.children.iter().rev());
}
out
}
/// Model-space bounds of the rest pose (None for an empty model).
pub fn bounds(&self) -> Option<([f64; 3], [f64; 3])> {
let mut lo = [f64::INFINITY; 3];
let mut hi = [f64::NEG_INFINITY; 3];
for inst in self.instances(None, 0.0) {
for p in &inst.mesh.primitives {
for (k, v) in p.positions.iter().enumerate() {
let q = skin_point(&inst, p, k, *v);
for c in 0..3 {
lo[c] = lo[c].min(q[c]);
hi[c] = hi[c].max(q[c]);
}
}
}
}
lo[0].is_finite().then_some((lo, hi))
}
pub fn vertex_count(&self) -> usize {
self.meshes.iter().flat_map(|m| &m.primitives).map(|p| p.positions.len()).sum()
}
pub fn triangle_count(&self) -> usize {
self.meshes.iter().flat_map(|m| &m.primitives).map(|p| p.triangle_count()).sum()
}
}
/// The skinning (or node) matrix of vertex `k` of an instance's primitive.
pub fn vertex_matrix(inst: &Instance, p: &Primitive, k: usize) -> Mat4 {
let Some(j) = &inst.joints else { return inst.world };
let (Some(ji), Some(wi)) = (p.joints.get(k), p.weights.get(k)) else { return inst.world };
let mut m = [[0.0; 4]; 4];
let mut total = 0.0;
for c in 0..4 {
let w = wi[c] as f64;
if w == 0.0 {
continue;
}
let Some(jm) = j.get(ji[c] as usize) else { continue };
total += w;
for r in 0..4 {
for q in 0..4 {
m[r][q] += jm.0[r][q] * w;
}
}
}
if total <= 1e-9 { inst.world } else { Mat4(m) }
}
/// A vertex position of an instance in model space.
pub fn skin_point(inst: &Instance, p: &Primitive, k: usize, v: [f32; 3]) -> [f64; 3] {
let q = vertex_matrix(inst, p, k).apply(vec3(v[0] as f64, v[1] as f64, v[2] as f64));
[q.x, q.y, q.z]
}
/// Which loader handles a file name (`gltf`, `glb`, `obj`), 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"),
_ => None,
}
}
/// Load a model from its main file's bytes; `resolve(uri)` reads sibling resources (buffers,
/// textures, MTL files) by their relative URI.
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(_) => 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"))),
}
}
/// Summary for footage metadata (Project panel, Interpret Footage).
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize)]
pub struct Summary {
pub meshes: usize,
pub vertices: usize,
pub triangles: usize,
pub materials: usize,
pub textures: usize,
pub animations: Vec<(String, f64)>,
pub skinned: bool,
/// Rest-pose bounds (model units).
pub min: [f64; 3],
pub max: [f64; 3],
}
impl Model {
pub fn summary(&self) -> Summary {
let (min, max) = self.bounds().unwrap_or(([0.0; 3], [0.0; 3]));
Summary {
meshes: self.meshes.len(),
vertices: self.vertex_count(),
triangles: self.triangle_count(),
materials: self.materials.len(),
textures: self.textures.len(),
animations: self.animations.iter().map(|a| (a.name.clone(), a.duration())).collect(),
skinned: !self.skins.is_empty(),
min,
max,
}
}
}
#[cfg(test)]
mod tests;

262
crates/model/src/obj.rs Normal file
View File

@ -0,0 +1,262 @@
//! Wavefront OBJ geometry and MTL materials.
//!
//! OBJ: `v` (optional vertex colours ignored), `vt`, `vn`, `f` with `v`, `v/vt`, `v//vn`,
//! `v/vt/vn` references (negative = relative), polygons fan-triangulated, `o`/`g` groups, `s`
//! smoothing (normals computed per smoothing state when a face has none), `usemtl`, `mtllib`.
//!
//! MTL materials map onto metallic-roughness: `Kd` → base colour, `d`/`Tr` → alpha, `Ke` →
//! emissive, `Pr`/`Pm` (the PBR extension) → roughness/metallic, otherwise roughness from the
//! Phong exponent `Ns` (`sqrt(2 / (Ns + 2))`) and metallic 0; `map_Kd`, `map_Ke`,
//! `map_Bump`/`bump`/`norm` load base colour, emissive and normal textures.
use std::collections::HashMap;
use std::sync::Arc;
use crate::{AlphaMode, Material, Mesh, Model, Primitive, Result, TexRef, Texture, perr};
/// Parse MTL text into named materials (textures appended to `textures`).
pub fn parse_mtl(text: &str, resolve: &dyn Fn(&str) -> Option<Vec<u8>>, textures: &mut Vec<Arc<Texture>>) -> Vec<Material> {
let mut out: Vec<Material> = vec![];
let mut loaded: HashMap<String, Option<usize>> = HashMap::new();
let mut tex = |name: &str, textures: &mut Vec<Arc<Texture>>| -> Option<TexRef> {
// Options (`-bm 1 file.png`): the file name is the last token.
let file = name.split_whitespace().last()?.to_string();
let t = *loaded.entry(file.clone()).or_insert_with(|| {
let b = resolve(&file)?;
let t = Texture::decode(&b).ok()?;
textures.push(Arc::new(t));
Some(textures.len() - 1)
});
t.map(|texture| TexRef { texture, uv_set: 0 })
};
let mut ns_seen = false;
let mut pr_seen = false;
for line in text.lines() {
let line = line.trim();
let (key, rest) = line.split_once(char::is_whitespace).map(|(k, r)| (k, r.trim())).unwrap_or((line, ""));
let f3 = || -> [f32; 3] {
let v: Vec<f32> = rest.split_whitespace().filter_map(|x| x.parse().ok()).collect();
match v.len() {
0 => [0.0; 3],
1 | 2 => [v[0]; 3],
_ => [v[0], v[1], v[2]],
}
};
let f1 = || rest.split_whitespace().next().and_then(|x| x.parse::<f32>().ok());
if key == "newmtl" {
if let Some(m) = out.last_mut() {
finish(m, ns_seen, pr_seen);
}
ns_seen = false;
pr_seen = false;
out.push(Material { name: rest.to_string(), metallic: 0.0, roughness: 0.5, ..Material::default() });
continue;
}
let Some(m) = out.last_mut() else { continue };
match key {
"Kd" => {
let c = f3();
m.base_color = [srgb_to_linear(c[0]), srgb_to_linear(c[1]), srgb_to_linear(c[2]), m.base_color[3]];
}
"Ke" => m.emissive = f3().map(srgb_to_linear),
"d" => m.base_color[3] = f1().unwrap_or(1.0),
"Tr" => m.base_color[3] = 1.0 - f1().unwrap_or(0.0),
"Ns" => {
let ns = f1().unwrap_or(0.0).max(0.0);
if !pr_seen {
m.roughness = (2.0 / (ns + 2.0)).sqrt();
}
ns_seen = true;
}
"Pr" => {
m.roughness = f1().unwrap_or(0.5).clamp(0.0, 1.0);
pr_seen = true;
}
"Pm" => m.metallic = f1().unwrap_or(0.0).clamp(0.0, 1.0),
"map_Kd" => m.base_color_tex = tex(rest, textures),
"map_Ke" => {
m.emissive_tex = tex(rest, textures);
if m.emissive == [0.0; 3] {
m.emissive = [1.0; 3];
}
}
"map_Bump" | "map_bump" | "bump" | "norm" => m.normal_tex = tex(rest, textures),
_ => {}
}
}
if let Some(m) = out.last_mut() {
finish(m, ns_seen, pr_seen);
}
out
}
fn finish(m: &mut Material, _ns: bool, _pr: bool) {
if m.base_color[3] < 0.999 {
m.alpha_mode = AlphaMode::Blend;
}
}
/// sRGB-encoded component → linear (MTL colours are display colours).
pub fn srgb_to_linear(c: f32) -> f32 {
if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
}
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
struct Key {
v: u32,
t: u32,
n: u32,
/// Smoothing state (faces without normals get computed normals per state).
s: u32,
}
/// Parse OBJ text (and its MTL libraries through `resolve`).
pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<Model> {
let text = String::from_utf8_lossy(bytes);
let mut v: Vec<[f32; 3]> = vec![];
let mut vt: Vec<[f32; 2]> = vec![];
let mut vn: Vec<[f32; 3]> = vec![];
let mut materials: Vec<Material> = vec![];
let mut textures: Vec<Arc<Texture>> = vec![];
// One primitive per material, in order of first use.
let mut prims: Vec<(Option<usize>, Primitive, HashMap<Key, u32>, Vec<bool>)> = vec![];
let mut current: Option<usize> = None;
let mut smooth = 0u32;
let mut flat_id = 1u32 << 31;
let mut faces = 0usize;
// Join `\` line continuations.
let joined = text.replace("\\\r\n", " ").replace("\\\n", " ");
for (ln, line) in joined.lines().enumerate() {
let line = line.split('#').next().unwrap_or("").trim();
if line.is_empty() {
continue;
}
let mut it = line.split_whitespace();
let key = it.next().unwrap_or("");
let rest: Vec<&str> = it.collect();
let fl = |i: usize| rest.get(i).and_then(|x| x.parse::<f32>().ok());
match key {
"v" => v.push([fl(0).unwrap_or(0.0), fl(1).unwrap_or(0.0), fl(2).unwrap_or(0.0)]),
"vt" => vt.push([fl(0).unwrap_or(0.0), fl(1).unwrap_or(0.0)]),
"vn" => vn.push([fl(0).unwrap_or(0.0), fl(1).unwrap_or(0.0), fl(2).unwrap_or(1.0)]),
"s" => {
smooth = match rest.first().copied() {
None | Some("off") | Some("0") => 0,
Some(x) => x.parse().unwrap_or(1).max(1),
}
}
"mtllib" => {
let name = line["mtllib".len()..].trim();
for file in std::iter::once(name).chain(name.split_whitespace()) {
if let Some(b) = resolve(file) {
let mut ms = parse_mtl(&String::from_utf8_lossy(&b), resolve, &mut textures);
materials.append(&mut ms);
break;
}
}
}
"usemtl" => {
let name = line["usemtl".len()..].trim();
current = materials.iter().position(|m| m.name == name);
if current.is_none() {
// Unknown material: a named grey default.
materials.push(Material { name: name.to_string(), metallic: 0.0, roughness: 0.5, base_color: [0.8, 0.8, 0.8, 1.0], ..Material::default() });
current = Some(materials.len() - 1);
}
}
"f" => {
if rest.len() < 3 {
continue;
}
faces += 1;
let resolve_i = |s: &str, len: usize| -> Option<u32> {
if s.is_empty() {
return None;
}
let i: i64 = s.parse().ok()?;
let k = if i < 0 { len as i64 + i } else { i - 1 };
(k >= 0 && (k as usize) < len).then_some(k as u32)
};
let mut refs = vec![];
for r in &rest {
let mut parts = r.split('/');
let vi = resolve_i(parts.next().unwrap_or(""), v.len()).ok_or_else(|| perr(format!("line {}: bad vertex index `{r}`", ln + 1)))?;
let ti = parts.next().and_then(|s| resolve_i(s, vt.len()));
let ni = parts.next().and_then(|s| resolve_i(s, vn.len()));
refs.push((vi, ti, ni));
}
let has_n = refs.iter().all(|r| r.2.is_some());
// Faces without normals: shared within a smoothing group, unique otherwise.
let s = if has_n {
0
} else if smooth == 0 {
flat_id += 1;
flat_id
} else {
smooth
};
let slot = match prims.iter().position(|p| p.0 == current) {
Some(i) => i,
None => {
prims.push((current, Primitive { material: current, ..Default::default() }, HashMap::new(), vec![]));
prims.len() - 1
}
};
let (_, prim, map, need) = &mut prims[slot];
let mut ids = vec![];
for (vi, ti, ni) in refs {
let k = Key { v: vi, t: ti.map_or(u32::MAX, |x| x), n: ni.map_or(u32::MAX, |x| x), s };
let id = *map.entry(k).or_insert_with(|| {
prim.positions.push(v[vi as usize]);
prim.uvs.push(ti.map_or([0.0, 0.0], |t| vt[t as usize]));
prim.normals.push(ni.map_or([0.0; 3], |n| vn[n as usize]));
need.push(ni.is_none());
(prim.positions.len() - 1) as u32
});
ids.push(id);
}
for i in 2..ids.len() {
prim.indices.extend([ids[0], ids[i - 1], ids[i]]);
}
}
_ => {}
}
}
if faces == 0 {
return Err(perr("OBJ file without faces"));
}
let any_uv = !vt.is_empty();
let mut mesh = Mesh { name: String::new(), primitives: vec![] };
for (_, mut p, _, need) in prims {
if need.iter().any(|&b| b) {
// Fill missing normals from the triangles (area weighted), keep given ones.
let given = p.normals.clone();
p.compute_normals();
for (i, n) in p.normals.iter_mut().enumerate() {
if !need[i] {
*n = given[i];
}
}
}
if !any_uv {
p.uvs.clear();
} else {
// OBJ's v axis points up the image; textures are stored top row first.
for uv in p.uvs.iter_mut() {
uv[1] = 1.0 - uv[1];
}
}
if p.material.is_some_and(|m| materials[m].normal_tex.is_some()) {
p.compute_tangents();
}
mesh.primitives.push(p);
}
Ok(Model::single(mesh, materials).with_textures(textures))
}
impl Model {
fn with_textures(mut self, t: Vec<Arc<Texture>>) -> Model {
self.textures = t;
self
}
}

235
crates/model/src/prim.rs Normal file
View File

@ -0,0 +1,235 @@
//! Parametric primitives (Layer ▸ New ▸ Cube/Sphere/Plane/Torus/Cone/Cylinder).
//!
//! Meshes are built in model space (+Y up, counter-clockwise front faces, like glTF), centred
//! on the origin, in pixels, with normals, UVs (0..1 across each face/around each surface) and
//! tangents.
use std::f32::consts::{PI, TAU};
use serde::{Deserialize, Serialize};
use crate::{Material, Mesh, Model, Primitive};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum PrimitiveKind {
#[default]
Cube,
Sphere,
Plane,
Torus,
Cone,
Cylinder,
}
impl PrimitiveKind {
pub const ALL: [PrimitiveKind; 6] =
[PrimitiveKind::Cube, PrimitiveKind::Sphere, PrimitiveKind::Plane, PrimitiveKind::Torus, PrimitiveKind::Cone, PrimitiveKind::Cylinder];
pub fn label(self) -> &'static str {
match self {
PrimitiveKind::Cube => "Cube",
PrimitiveKind::Sphere => "Sphere",
PrimitiveKind::Plane => "Plane",
PrimitiveKind::Torus => "Torus",
PrimitiveKind::Cone => "Cone",
PrimitiveKind::Cylinder => "Cylinder",
}
}
pub fn parse(s: &str) -> Option<PrimitiveKind> {
PrimitiveKind::ALL.into_iter().find(|k| k.label().eq_ignore_ascii_case(s.trim()))
}
}
/// Primitive dimensions (pixels) and tessellation.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PrimParams {
/// Width, height, depth (cube, plane: width × height).
pub size: [f32; 3],
/// Sphere/cone/cylinder radius; torus ring radius.
pub radius: f32,
/// Torus tube radius.
pub tube: f32,
/// Cone/cylinder height.
pub height: f32,
/// Segments around.
pub segments: u32,
/// Sphere rings / torus tube sides.
pub rings: u32,
}
impl Default for PrimParams {
fn default() -> Self {
PrimParams { size: [300.0; 3], radius: 150.0, tube: 50.0, height: 300.0, segments: 48, rings: 24 }
}
}
struct B {
p: Primitive,
}
impl B {
fn new() -> B {
B { p: Primitive { material: Some(0), ..Default::default() } }
}
fn v(&mut self, pos: [f32; 3], n: [f32; 3], uv: [f32; 2]) -> u32 {
self.p.positions.push(pos);
self.p.normals.push(n);
self.p.uvs.push(uv);
(self.p.positions.len() - 1) as u32
}
fn tri(&mut self, a: u32, b: u32, c: u32) {
self.p.indices.extend([a, b, c]);
}
fn quad(&mut self, a: u32, b: u32, c: u32, d: u32) {
// a-b-c-d counter-clockwise.
self.tri(a, b, c);
self.tri(a, c, d);
}
}
/// The mesh of a primitive.
pub fn mesh(kind: PrimitiveKind, pr: &PrimParams) -> Primitive {
let seg = pr.segments.clamp(3, 512);
let rings = pr.rings.clamp(2, 512);
let mut b = B::new();
match kind {
PrimitiveKind::Cube => {
let [w, h, d] = pr.size.map(|v| v.abs() * 0.5);
// (normal, u axis, v axis) per face; v runs down the texture.
let faces: [([f32; 3], [f32; 3], [f32; 3]); 6] = [
([0.0, 0.0, 1.0], [1.0, 0.0, 0.0], [0.0, -1.0, 0.0]),
([0.0, 0.0, -1.0], [-1.0, 0.0, 0.0], [0.0, -1.0, 0.0]),
([1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [0.0, -1.0, 0.0]),
([-1.0, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0, -1.0, 0.0]),
([0.0, 1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]),
([0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, -1.0]),
];
let half = [w, h, d];
for (n, u, v) in faces {
let c = |su: f32, sv: f32| -> [f32; 3] { [0, 1, 2].map(|k| (n[k] + u[k] * su + v[k] * sv) * half[k]) };
let i0 = b.v(c(-1.0, -1.0), n, [0.0, 0.0]);
let i1 = b.v(c(1.0, -1.0), n, [1.0, 0.0]);
let i2 = b.v(c(1.0, 1.0), n, [1.0, 1.0]);
let i3 = b.v(c(-1.0, 1.0), n, [0.0, 1.0]);
// u × v points along -n for these axes (v down): wind so the face is CCW from outside.
b.quad(i0, i3, i2, i1);
}
}
PrimitiveKind::Plane => {
let (w, h) = (pr.size[0].abs() * 0.5, pr.size[1].abs() * 0.5);
let n = [0.0, 0.0, 1.0];
let i0 = b.v([-w, h, 0.0], n, [0.0, 0.0]);
let i1 = b.v([w, h, 0.0], n, [1.0, 0.0]);
let i2 = b.v([w, -h, 0.0], n, [1.0, 1.0]);
let i3 = b.v([-w, -h, 0.0], n, [0.0, 1.0]);
b.quad(i0, i3, i2, i1);
}
PrimitiveKind::Sphere => {
let r = pr.radius.abs();
for j in 0..=rings {
let th = PI * j as f32 / rings as f32;
// Exact poles and seam (welded positions).
let (st, ct) = if j == 0 {
(0.0, 1.0)
} else if j == rings {
(0.0, -1.0)
} else {
th.sin_cos()
};
for i in 0..=seg {
let ph = TAU * (i % seg) as f32 / seg as f32;
let n = [st * ph.sin(), ct, st * ph.cos()];
b.v([n[0] * r, n[1] * r, n[2] * r], n, [i as f32 / seg as f32, j as f32 / rings as f32]);
}
}
let row = seg + 1;
for j in 0..rings {
for i in 0..seg {
let a = j * row + i;
let (b0, c, d) = (a + row, a + row + 1, a + 1);
if j != 0 {
b.tri(a, b0, d);
}
if j != rings - 1 {
b.tri(d, b0, c);
}
}
}
}
PrimitiveKind::Torus => {
let (rr, tr) = (pr.radius.abs(), pr.tube.abs());
for j in 0..=rings {
let v = TAU * (j % rings) as f32 / rings as f32;
for i in 0..=seg {
let u = TAU * (i % seg) as f32 / seg as f32;
// Ring in the XZ plane.
let (cu, su, cv, sv) = (u.cos(), u.sin(), v.cos(), v.sin());
let n = [cv * su, sv, cv * cu];
let p = [(rr + tr * cv) * su, tr * sv, (rr + tr * cv) * cu];
b.v(p, n, [i as f32 / seg as f32, j as f32 / rings as f32]);
}
}
let row = seg + 1;
for j in 0..rings {
for i in 0..seg {
let a = j * row + i;
b.quad(a, a + 1, a + row + 1, a + row);
}
}
}
PrimitiveKind::Cylinder | PrimitiveKind::Cone => {
let r = pr.radius.abs();
let h = pr.height.abs() * 0.5;
let top_r = if kind == PrimitiveKind::Cone { 0.0 } else { r };
// Side normal tilt for the cone (slope).
let (ny, nk) = {
let s = (r - top_r) / (2.0 * h).max(1e-6);
let l = (1.0 + s * s).sqrt();
(s / l, 1.0 / l)
};
let base = b.p.positions.len() as u32;
for i in 0..=seg {
let a = TAU * (i % seg) as f32 / seg as f32;
let (s, c) = a.sin_cos();
let n = [s * nk, ny, c * nk];
let u = i as f32 / seg as f32;
b.v([s * top_r, h, c * top_r], n, [u, 0.0]);
b.v([s * r, -h, c * r], n, [u, 1.0]);
}
for i in 0..seg {
let t0 = base + 2 * i;
let (b0, t1, b1) = (t0 + 1, t0 + 2, t0 + 3);
if kind == PrimitiveKind::Cone {
b.tri(t0, b0, b1);
} else {
b.quad(t0, b0, b1, t1);
}
}
let caps: &[(f32, f32)] = if kind == PrimitiveKind::Cone { &[(-1.0, r)] } else { &[(1.0, r), (-1.0, r)] };
for &(side, rad) in caps {
let n = [0.0, side, 0.0];
let c = b.v([0.0, h * side, 0.0], n, [0.5, 0.5]);
let first = b.p.positions.len() as u32;
for i in 0..=seg {
let a = TAU * (i % seg) as f32 / seg as f32;
let (s, co) = a.sin_cos();
b.v([s * rad, h * side, co * rad], n, [0.5 + 0.5 * s, 0.5 - 0.5 * co * side]);
}
for i in 0..seg {
let (p0, p1) = (first + i, first + i + 1);
if side > 0.0 { b.tri(c, p0, p1) } else { b.tri(c, p1, p0) }
}
}
}
}
b.p.compute_tangents();
b.p
}
/// A primitive as a one-mesh model with one material.
pub fn model(kind: PrimitiveKind, pr: &PrimParams, material: Material) -> Model {
let mut material = material;
if kind == PrimitiveKind::Plane {
material.double_sided = true;
}
Model::single(Mesh { name: kind.label().to_string(), primitives: vec![mesh(kind, pr)] }, vec![material])
}

318
crates/model/src/tests.rs Normal file
View File

@ -0,0 +1,318 @@
use std::collections::HashMap;
use super::extrude::{BevelStyle, ExtrudeParams, Outline, extrude, group_contours};
use super::prim::{PrimParams, PrimitiveKind, mesh};
use super::triangulate::{signed_area, triangulate};
use super::*;
fn fixture(name: &str) -> Vec<u8> {
std::fs::read(format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"))).unwrap()
}
fn resolver(name: &str) -> Option<Vec<u8>> {
std::fs::read(format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"))).ok()
}
fn check_quad(m: &Model) {
assert_eq!(m.nodes.len(), 2);
assert_eq!(m.roots, vec![0]);
assert_eq!(m.nodes[0].children, vec![1]);
assert_eq!(m.meshes.len(), 1);
let p = &m.meshes[0].primitives[0];
assert_eq!(p.positions.len(), 4);
assert_eq!(p.indices, vec![0, 1, 2, 0, 2, 3]);
assert_eq!(p.uvs[0], [0.0, 1.0]);
assert_eq!(p.normals[2], [0.0, 0.0, 1.0]);
let mat = &m.materials[0];
assert_eq!(mat.name, "Painted");
assert_eq!(mat.base_color, [1.0, 0.5, 0.25, 1.0]);
assert!((mat.metallic - 0.25).abs() < 1e-6 && (mat.roughness - 0.75).abs() < 1e-6);
assert_eq!(mat.emissive, [0.1, 0.2, 0.3]);
assert!(mat.double_sided);
let t = &m.textures[mat.base_color_tex.unwrap().texture];
assert_eq!((t.width, t.height), (2, 2));
assert_eq!(t.data[0], [1.0, 0.0, 0.0, 1.0]);
assert_eq!(t.data[3], [1.0, 1.0, 1.0, 1.0]);
assert_eq!(t.wrap, [Wrap::Clamp, Wrap::Repeat]);
// Hierarchy: scale 2 under a +1 Y translation.
let (lo, hi) = m.bounds().unwrap();
assert!((lo[0] + 1.0).abs() < 1e-9 && (hi[0] - 1.0).abs() < 1e-9);
assert!((lo[1] - 0.0).abs() < 1e-9 && (hi[1] - 2.0).abs() < 1e-9);
// Animation: root translation (0,1,0) → (2,1,0) over 1 s.
assert_eq!(m.animations.len(), 1);
assert_eq!(m.animations[0].name, "Move");
assert!((m.animations[0].duration() - 1.0).abs() < 1e-9);
let inst = m.instances(Some(0), 0.5);
assert_eq!(inst.len(), 1);
let c = inst[0].world.apply(vec3(0.0, 0.0, 0.0));
assert!((c.x - 1.0).abs() < 1e-6 && (c.y - 1.0).abs() < 1e-6, "{c:?}");
}
#[test]
fn gltf_with_data_uris() {
let m = gltf::parse(&fixture("quad.gltf"), &|_| None).unwrap();
check_quad(&m);
}
#[test]
fn glb_matches_gltf() {
let m = load("quad.glb", &fixture("quad.glb"), &resolver).unwrap();
check_quad(&m);
let g = gltf::parse(&fixture("quad.gltf"), &|_| None).unwrap();
assert_eq!(m.meshes, g.meshes);
assert_eq!(m.materials, g.materials);
}
#[test]
fn gltf_errors_are_reported() {
assert!(gltf::parse(b"{\"asset\":{\"version\":\"1.0\"}}", &|_| None).is_err());
assert!(gltf::parse(b"not json", &|_| None).is_err());
let missing = br#"{"asset":{"version":"2.0"},"buffers":[{"uri":"nowhere.bin","byteLength":4}]}"#;
assert!(matches!(gltf::parse(missing, &|_| None), Err(ModelError::Missing(_))));
assert!(gltf::split_glb(b"glTF\x01\0\0\0").is_err());
}
#[test]
fn base64_and_uri_decoding() {
assert_eq!(gltf::base64_decode("aGVsbG8=").unwrap(), b"hello");
assert_eq!(gltf::base64_decode("aGVsbG8").unwrap(), b"hello");
assert_eq!(gltf::uri_decode("my%20file.bin"), "my file.bin");
}
#[test]
fn obj_with_mtl() {
let m = load("cube.obj", &fixture("cube.obj"), &resolver).unwrap();
assert_eq!(m.materials.len(), 2);
let red = &m.materials[0];
assert_eq!(red.name, "Red");
assert_eq!(red.base_color, [1.0, 0.0, 0.0, 1.0]);
assert!((red.roughness - (2.0f32 / 100.0).sqrt()).abs() < 1e-6);
assert_eq!(red.metallic, 0.0);
let gold = &m.materials[1];
assert_eq!((gold.metallic, gold.roughness), (1.0, 0.3));
let prims = &m.meshes[0].primitives;
assert_eq!(prims.len(), 2);
assert_eq!(prims[0].material, Some(0));
assert_eq!(prims.iter().map(|p| p.triangle_count()).sum::<usize>(), 12);
// Four unique (v, vt, vn) corners per face.
assert_eq!(m.vertex_count(), 24);
// Every face is wound counter-clockwise from outside (face normal agrees with vn).
for p in prims {
for t in p.indices.chunks(3) {
let (a, b, c) = (p.positions[t[0] as usize], p.positions[t[1] as usize], p.positions[t[2] as usize]);
let f = cross(sub(b, a), sub(c, a));
assert!(dot(f, p.normals[t[0] as usize]) > 0.0);
}
}
let (lo, hi) = m.bounds().unwrap();
assert_eq!((lo, hi), ([-0.5; 3], [0.5; 3]));
// V is flipped to the image's top-down rows.
assert_eq!(prims[0].uvs[0], [0.0, 1.0]);
}
#[test]
fn obj_without_normals_gets_them() {
let src = b"v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n";
let m = obj::parse(src, &|_| None).unwrap();
let p = &m.meshes[0].primitives[0];
assert_eq!(p.normals, vec![[0.0, 0.0, 1.0]; 3]);
assert!(obj::parse(b"v 0 0 0\n", &|_| None).is_err());
}
#[test]
fn channel_interpolation_modes() {
let mut c = Channel { node: 0, path: ChannelPath::Translation, interp: Interp::Step, times: vec![0.0, 1.0], values: vec![0.0, 0.0, 0.0, 10.0, 0.0, 0.0] };
assert_eq!(c.sample(0.7).unwrap()[0], 0.0);
c.interp = Interp::Linear;
assert!((c.sample(0.7).unwrap()[0] - 7.0).abs() < 1e-9);
assert_eq!(c.sample(5.0).unwrap()[0], 10.0);
// Cubic spline with zero tangents: smoothstep.
c.interp = Interp::CubicSpline;
c.values = vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 10.0, 0.0, 0.0, 0.0, 0.0, 0.0];
assert!((c.sample(0.5).unwrap()[0] - 5.0).abs() < 1e-9);
assert!((c.sample(0.25).unwrap()[0] - 10.0 * (3.0 * 0.0625 - 2.0 * 0.015625)).abs() < 1e-9);
// Rotation: slerp keeps unit length, half way around Z.
let r =
Channel { node: 0, path: ChannelPath::Rotation, interp: Interp::Linear, times: vec![0.0, 1.0], values: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0] };
let q = r.sample(0.5).unwrap();
assert!((q[2] - (0.5f64).sqrt()).abs() < 1e-9 && (q[3] - (0.5f64).sqrt()).abs() < 1e-9);
}
#[test]
fn skinning_moves_vertices_with_joints() {
let mut p = mesh(PrimitiveKind::Plane, &PrimParams { size: [2.0, 2.0, 0.0], ..Default::default() });
p.joints = vec![[0, 0, 0, 0], [1, 0, 0, 0], [1, 0, 0, 0], [0, 0, 0, 0]];
p.weights = vec![[1.0, 0.0, 0.0, 0.0]; 4];
let mut m = Model::single(Mesh { name: "s".into(), primitives: vec![p] }, vec![Material::default()]);
m.nodes[0].skin = Some(0);
m.nodes.push(Node::default());
m.nodes.push(Node { translation: [0.0, 0.0, 5.0], ..Node::default() });
m.roots = vec![0, 1, 2];
m.skins.push(Skin { joints: vec![1, 2], inverse_bind: vec![Mat4::IDENTITY; 2] });
let inst = m.instances(None, 0.0);
let pr = &inst[0].mesh.primitives[0];
assert_eq!(skin_point(&inst[0], pr, 0, pr.positions[0])[2], 0.0);
assert_eq!(skin_point(&inst[0], pr, 1, pr.positions[1])[2], 5.0);
}
/// Edges of a mesh welded by position: (undirected edge → uses, directed edge → uses).
fn edge_stats(p: &Primitive) -> (HashMap<(u32, u32), usize>, HashMap<(u32, u32), usize>) {
let mut weld: HashMap<[u32; 3], u32> = HashMap::new();
let ids: Vec<u32> = p
.positions
.iter()
.map(|q| {
let k = q.map(|v| (v + 0.0).to_bits());
let n = weld.len() as u32;
*weld.entry(k).or_insert(n)
})
.collect();
let mut und = HashMap::new();
let mut dir = HashMap::new();
for t in p.indices.chunks(3) {
let v = [ids[t[0] as usize], ids[t[1] as usize], ids[t[2] as usize]];
if v[0] == v[1] || v[1] == v[2] || v[0] == v[2] {
continue;
}
for k in 0..3 {
let (a, b) = (v[k], v[(k + 1) % 3]);
*und.entry((a.min(b), a.max(b))).or_insert(0) += 1;
*dir.entry((a, b)).or_insert(0) += 1;
}
}
(und, dir)
}
fn assert_watertight(p: &Primitive) {
let (und, dir) = edge_stats(p);
for (e, n) in &und {
assert_eq!(*n, 2, "edge {e:?} used {n} times");
}
for (e, n) in &dir {
assert_eq!(*n, 1, "directed edge {e:?} used {n} times (inconsistent winding)");
}
}
/// Signed volume (positive when faces wind outward).
fn volume(p: &Primitive) -> f64 {
p.indices
.chunks(3)
.map(|t| {
let [a, b, c] = [0, 1, 2].map(|k| p.positions[t[k] as usize].map(|v| v as f64));
(a[0] * (b[1] * c[2] - b[2] * c[1]) - a[1] * (b[0] * c[2] - b[2] * c[0]) + a[2] * (b[0] * c[1] - b[1] * c[0])) / 6.0
})
.sum()
}
/// Vertex normals agree with the faces they belong to.
fn assert_normals_outward(p: &Primitive) {
for t in p.indices.chunks(3) {
let (a, b, c) = (p.positions[t[0] as usize], p.positions[t[1] as usize], p.positions[t[2] as usize]);
let f = cross(sub(b, a), sub(c, a));
if dot(f, f) < 1e-10 {
continue;
}
for &i in t {
let n = p.normals[i as usize];
assert!((dot(n, n) - 1.0).abs() < 1e-3, "normal not unit");
assert!(dot(f, n) > 0.0, "normal {n:?} faces away from triangle {f:?}");
}
}
}
#[test]
fn primitive_vertex_counts_and_normals() {
let pr = PrimParams { segments: 16, rings: 8, ..Default::default() };
let cases = [
(PrimitiveKind::Cube, 24, 36),
(PrimitiveKind::Plane, 4, 6),
(PrimitiveKind::Sphere, 17 * 9, 6 * 16 * 7),
(PrimitiveKind::Torus, 17 * 9, 6 * 16 * 8),
(PrimitiveKind::Cylinder, 2 * 17 + 2 * 18, 6 * 16 + 2 * 3 * 16),
(PrimitiveKind::Cone, 2 * 17 + 18, 3 * 16 + 3 * 16),
];
for (k, v, i) in cases {
let m = mesh(k, &pr);
assert_eq!((m.positions.len(), m.indices.len()), (v, i), "{k:?}");
assert_eq!(m.normals.len(), v);
assert_eq!(m.uvs.len(), v);
assert_eq!(m.tangents.len(), v);
assert_normals_outward(&m);
if k != PrimitiveKind::Plane {
assert!(volume(&m) > 0.0, "{k:?} winds inward");
assert_watertight(&m);
}
}
// Volumes approach the analytic ones.
let cube = mesh(PrimitiveKind::Cube, &PrimParams { size: [2.0, 3.0, 4.0], ..Default::default() });
assert!((volume(&cube) - 24.0).abs() < 1e-4);
let s = mesh(PrimitiveKind::Sphere, &PrimParams { radius: 1.0, segments: 128, rings: 64, ..Default::default() });
assert!((volume(&s) - 4.0 / 3.0 * std::f64::consts::PI).abs() < 0.01);
}
#[test]
fn triangulation_covers_polygon_with_hole() {
let outer = vec![[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [5.0, 12.0], [0.0, 10.0]];
let hole = vec![[3.0, 3.0], [3.0, 7.0], [7.0, 7.0], [7.0, 3.0]];
let pts: Vec<[f64; 2]> = outer.iter().chain(hole.iter()).copied().collect();
let tris = triangulate(&outer, std::slice::from_ref(&hole));
let area: f64 = tris.iter().map(|t| signed_area(&[pts[t[0]], pts[t[1]], pts[t[2]]]) * 0.5).sum();
let expect = signed_area(&outer).abs() * 0.5 - 16.0;
assert!((area - expect).abs() < 1e-9, "{area} vs {expect}");
assert!(tris.iter().all(|t| signed_area(&[pts[t[0]], pts[t[1]], pts[t[2]]]) >= 0.0));
// A concave "C".
let c = vec![[0.0, 0.0], [6.0, 0.0], [6.0, 2.0], [2.0, 2.0], [2.0, 4.0], [6.0, 4.0], [6.0, 6.0], [0.0, 6.0]];
let tris = triangulate(&c, &[]);
let area: f64 = tris.iter().map(|t| signed_area(&[c[t[0]], c[t[1]], c[t[2]]]) * 0.5).sum();
assert!((area - 28.0).abs() < 1e-9);
}
fn ring_outline() -> Outline {
let circle =
|r: f64, n: usize| -> Vec<[f64; 2]> { (0..n).map(|i| (i as f64 / n as f64) * std::f64::consts::TAU).map(|a| [r * a.cos(), r * a.sin()]).collect() };
Outline { outer: circle(100.0, 48), holes: vec![circle(50.0, 32)] }
}
#[test]
fn extrusion_is_watertight_with_outward_normals() {
let square = Outline { outer: vec![[0.0, 0.0], [100.0, 0.0], [100.0, 100.0], [0.0, 100.0]], holes: vec![] };
for bevel in BevelStyle::ALL {
for o in [square.clone(), ring_outline()] {
let p = ExtrudeParams { bevel, bevel_depth: 8.0, hole_bevel: 0.5, depth: 40.0 };
let m = extrude(std::slice::from_ref(&o), &p);
assert_watertight(&m);
assert_normals_outward(&m);
assert!(volume(&m) > 0.0, "{bevel:?}: inward winding");
let (lo, hi) = m.bounds().unwrap();
assert!(lo[2].abs() < 1e-6 && (hi[2] - 40.0).abs() < 1e-4);
}
}
// Without a bevel the square block's volume is exact.
let m = extrude(&[square], &ExtrudeParams { bevel: BevelStyle::None, bevel_depth: 0.0, hole_bevel: 1.0, depth: 10.0 });
assert!((volume(&m) - 100_000.0).abs() < 1e-2);
// Front cap faces the camera (−z), back cap away.
assert!(m.normals.iter().any(|n| n[2] < -0.99) && m.normals.iter().any(|n| n[2] > 0.99));
}
#[test]
fn bevels_shrink_the_caps() {
let square = Outline { outer: vec![[0.0, 0.0], [100.0, 0.0], [100.0, 100.0], [0.0, 100.0]], holes: vec![] };
let m = extrude(std::slice::from_ref(&square), &ExtrudeParams { bevel: BevelStyle::Angular, bevel_depth: 10.0, hole_bevel: 1.0, depth: 50.0 });
// Front cap vertices (z = 0) are inset by the bevel.
let front: Vec<_> = m.positions.iter().filter(|p| p[2] == 0.0).collect();
assert!(front.iter().all(|p| p[0] >= 9.999 && p[0] <= 90.001));
// The widest ring (the walls) still reaches the outline.
assert!(m.positions.iter().any(|p| p[0] == 0.0 && p[2] == 10.0));
// Convex bevels have more profile steps than angular ones.
let c = extrude(std::slice::from_ref(&square), &ExtrudeParams { bevel: BevelStyle::Convex, bevel_depth: 10.0, hole_bevel: 1.0, depth: 50.0 });
assert!(c.positions.len() > m.positions.len());
}
#[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]];
// "O" (outer + hole) next to an island inside another hole.
let g = group_contours(vec![sq(0.0, 0.0, 10.0), sq(2.0, 2.0, 6.0), sq(20.0, 0.0, 10.0), sq(22.0, 2.0, 6.0), sq(24.0, 4.0, 2.0)]);
assert_eq!(g.len(), 3);
assert_eq!(g.iter().map(|o| o.holes.len()).sum::<usize>(), 2);
}

View File

@ -0,0 +1,187 @@
//! Polygon triangulation with holes: holes are bridged into the outer contour (each hole's
//! rightmost vertex joined to a visible outer vertex), then ears are clipped.
type P = [f64; 2];
/// Twice the signed area (positive = counter-clockwise in a Y-up frame).
pub fn signed_area(c: &[P]) -> f64 {
let n = c.len();
(0..n)
.map(|i| {
let (a, b) = (c[i], c[(i + 1) % n]);
a[0] * b[1] - b[0] * a[1]
})
.sum()
}
#[inline]
fn cross(o: P, a: P, b: P) -> f64 {
(a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
}
fn in_triangle(p: P, a: P, b: P, c: P) -> bool {
let d1 = cross(a, b, p);
let d2 = cross(b, c, p);
let d3 = cross(c, a, p);
d1 >= 0.0 && d2 >= 0.0 && d3 >= 0.0
}
/// Triangulate an outer contour with holes. Contours are closed polylines (last point not
/// repeated); orientation is normalised internally. Returns triangles as indices into the
/// contours concatenated in order (`outer`, then `holes`), counter-clockwise (Y up).
pub fn triangulate(outer: &[P], holes: &[Vec<P>]) -> Vec<[usize; 3]> {
if outer.len() < 3 {
return vec![];
}
let mut pts: Vec<P> = outer.to_vec();
for h in holes {
pts.extend_from_slice(h);
}
// Ring of global indices, outer counter-clockwise.
let mut ring: Vec<usize> = (0..outer.len()).collect();
if signed_area(outer) < 0.0 {
ring.reverse();
}
// Holes clockwise, rightmost first.
let mut hs: Vec<Vec<usize>> = vec![];
let mut off = outer.len();
for h in holes {
let mut r: Vec<usize> = (off..off + h.len()).collect();
off += h.len();
if h.len() < 3 {
continue;
}
if signed_area(h) > 0.0 {
r.reverse();
}
hs.push(r);
}
hs.sort_by(|a, b| {
let mx = |r: &Vec<usize>| r.iter().map(|&i| pts[i][0]).fold(f64::NEG_INFINITY, f64::max);
mx(b).total_cmp(&mx(a))
});
for h in hs {
bridge(&pts, &mut ring, &h);
}
ear_clip(&pts, ring)
}
/// Join hole `h` into `ring` through a bridge from the hole's rightmost vertex.
fn bridge(pts: &[P], ring: &mut Vec<usize>, h: &[usize]) {
let (hk, &m) = h.iter().enumerate().max_by(|a, b| pts[*a.1][0].total_cmp(&pts[*b.1][0])).unwrap_or((0, &h[0]));
let mp = pts[m];
// Nearest ring edge hit by the ray +x from M.
let n = ring.len();
let mut best: Option<(f64, usize)> = None;
for i in 0..n {
let (a, b) = (pts[ring[i]], pts[ring[(i + 1) % n]]);
if (a[1] > mp[1]) == (b[1] > mp[1]) && a[1] != mp[1] && b[1] != mp[1] {
continue;
}
if a[1] == b[1] {
continue;
}
let t = (mp[1] - a[1]) / (b[1] - a[1]);
if !(0.0..=1.0).contains(&t) {
continue;
}
let x = a[0] + t * (b[0] - a[0]);
if x >= mp[0] && best.is_none_or(|(bx, _)| x < bx) {
best = Some((x, i));
}
}
let Some((x, i)) = best else { return };
let (a, b) = (ring[i], ring[(i + 1) % n]);
// Candidate: the edge endpoint with the larger x.
let mut cand = if pts[a][0] > pts[b][0] { (i, a) } else { ((i + 1) % n, b) };
let ip = [x, mp[1]];
// Reflex ring vertices inside triangle (M, I, P) block the view: take the one with the
// smallest angle to the ray (closest on ties).
let pp = pts[cand.1];
let (t0, t1, t2) = if cross(mp, ip, pp) >= 0.0 { (mp, ip, pp) } else { (mp, pp, ip) };
let mut best_ang = f64::INFINITY;
for k in 0..n {
let v = ring[k];
if v == cand.1 {
continue;
}
let q = pts[v];
let prev = pts[ring[(k + n - 1) % n]];
let next = pts[ring[(k + 1) % n]];
let reflex = cross(prev, q, next) <= 0.0;
if reflex && in_triangle(q, t0, t1, t2) {
let ang = (q[1] - mp[1]).abs().atan2(q[0] - mp[0]);
let d = (q[0] - mp[0]).hypot(q[1] - mp[1]);
let cd = (pts[cand.1][0] - mp[0]).hypot(pts[cand.1][1] - mp[1]);
if ang < best_ang || (ang == best_ang && d < cd) {
best_ang = ang;
cand = (k, v);
}
}
}
let (k, _) = cand;
// ring[..=k], hole from M around back to M, ring[k] again, rest.
let mut out = Vec::with_capacity(ring.len() + h.len() + 2);
out.extend_from_slice(&ring[..=k]);
for j in 0..=h.len() {
out.push(h[(hk + j) % h.len()]);
}
out.extend_from_slice(&ring[k..]);
*ring = out;
}
fn ear_clip(pts: &[P], mut ring: Vec<usize>) -> Vec<[usize; 3]> {
let mut tris = vec![];
let mut guard = 0usize;
let mut i = 0usize;
while ring.len() > 3 {
let n = ring.len();
if guard > 2 * n {
// No ear found (degenerate/self-intersecting input): clip anyway to finish.
let (a, b, c) = (ring[(i + n - 1) % n], ring[i % n], ring[(i + 1) % n]);
tris.push([a, b, c]);
ring.remove(i % n);
guard = 0;
continue;
}
let ii = i % n;
let (a, b, c) = (ring[(ii + n - 1) % n], ring[ii], ring[(ii + 1) % n]);
let (pa, pb, pc) = (pts[a], pts[b], pts[c]);
let cr = cross(pa, pb, pc);
let scale = 1.0 + pa[0].abs() + pa[1].abs() + pc[0].abs() + pc[1].abs();
// A straight-through vertex is clipped as a zero-area triangle (keeps every contour edge
// in the triangulation, so caps meet walls without T-junctions).
let straight = cr.abs() <= 1e-12 * scale * scale && (pa[0] - pb[0]) * (pc[0] - pb[0]) + (pa[1] - pb[1]) * (pc[1] - pb[1]) <= 0.0;
let convex = cr > 0.0 && !straight;
let mut ear = convex || straight;
if convex {
for &v in &ring {
if v == a || v == b || v == c {
continue;
}
let q = pts[v];
// Bridged duplicates share positions with the ear's corners.
if q == pa || q == pb || q == pc {
continue;
}
if in_triangle(q, pa, pb, pc) {
ear = false;
break;
}
}
}
if ear {
tris.push([a, b, c]);
ring.remove(ii);
guard = 0;
i = if ii == 0 { 0 } else { ii - 1 };
} else {
i = ii + 1;
guard += 1;
}
}
if ring.len() == 3 {
tris.push([ring[0], ring[1], ring[2]]);
}
tris
}

11
crates/model/tests/fixtures/cube.mtl vendored Normal file
View File

@ -0,0 +1,11 @@
# EffectCraft test fixture: materials for cube.obj (original work).
newmtl Red
Kd 1.0 0.0 0.0
Ns 98
d 1.0
newmtl Gold
Kd 1.0 0.766 0.336
Ke 0.0 0.0 0.0
Pr 0.3
Pm 1.0

View File

@ -0,0 +1,7 @@
asset: cube.mtl
title: MTL fixture: the two materials of cube.obj (Phong exponent and PBR extension keys)
author: EffectCraft contributors
source: original work
license: MIT OR Apache-2.0
added: 2026-10-02 by EffectCraft agent (M7.4)
notes: Hand-written / script-generated test data for the model importer, made from the public Khronos glTF 2.0 specification and the Wavefront OBJ/MTL format description; not taken from any third-party file. The 2x2 texture is four solid colours.

29
crates/model/tests/fixtures/cube.obj vendored Normal file
View File

@ -0,0 +1,29 @@
# EffectCraft test fixture: a unit cube with texture coordinates and normals (original work).
mtllib cube.mtl
o Cube
v -0.5 -0.5 0.5
v 0.5 -0.5 0.5
v 0.5 0.5 0.5
v -0.5 0.5 0.5
v -0.5 -0.5 -0.5
v 0.5 -0.5 -0.5
v 0.5 0.5 -0.5
v -0.5 0.5 -0.5
vt 0 0
vt 1 0
vt 1 1
vt 0 1
vn 0 0 1
vn 0 0 -1
vn 1 0 0
vn -1 0 0
vn 0 1 0
vn 0 -1 0
usemtl Red
f 1/1/1 2/2/1 3/3/1 4/4/1
f 6/1/2 5/2/2 8/3/2 7/4/2
f 2/1/3 6/2/3 7/3/3 3/4/3
usemtl Gold
f 5/1/4 1/2/4 4/3/4 8/4/4
f 4/1/5 3/2/5 7/3/5 8/4/5
f -4/1/6 -3/2/6 -7/3/6 -8/4/6

View File

@ -0,0 +1,7 @@
asset: cube.obj
title: Wavefront OBJ fixture: a unit cube with texture coordinates, normals and two materials
author: EffectCraft contributors
source: original work
license: MIT OR Apache-2.0
added: 2026-10-02 by EffectCraft agent (M7.4)
notes: Hand-written / script-generated test data for the model importer, made from the public Khronos glTF 2.0 specification and the Wavefront OBJ/MTL format description; not taken from any third-party file. The 2x2 texture is four solid colours.

BIN
crates/model/tests/fixtures/quad.glb vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,7 @@
asset: quad.glb
title: Binary glTF (GLB) fixture: the same textured, animated quad with its PNG texture in the BIN chunk
author: EffectCraft contributors
source: original work
license: MIT OR Apache-2.0
added: 2026-10-02 by EffectCraft agent (M7.4)
notes: Hand-written / script-generated test data for the model importer, made from the public Khronos glTF 2.0 specification and the Wavefront OBJ/MTL format description; not taken from any third-party file. The 2x2 texture is four solid colours.

210
crates/model/tests/fixtures/quad.gltf vendored Normal file
View File

@ -0,0 +1,210 @@
{
"asset": {
"version": "2.0",
"generator": "EffectCraft fixture script (original work)"
},
"scene": 0,
"scenes": [
{
"nodes": [
0
]
}
],
"nodes": [
{
"name": "Root",
"translation": [
0.0,
1.0,
0.0
],
"children": [
1
]
},
{
"name": "Quad",
"mesh": 0,
"scale": [
2.0,
2.0,
2.0
]
}
],
"meshes": [
{
"name": "Quad",
"primitives": [
{
"attributes": {
"POSITION": 0,
"NORMAL": 1,
"TEXCOORD_0": 2
},
"indices": 3,
"material": 0
}
]
}
],
"materials": [
{
"name": "Painted",
"pbrMetallicRoughness": {
"baseColorFactor": [
1.0,
0.5,
0.25,
1.0
],
"metallicFactor": 0.25,
"roughnessFactor": 0.75,
"baseColorTexture": {
"index": 0
}
},
"emissiveFactor": [
0.1,
0.2,
0.3
],
"doubleSided": true
}
],
"textures": [
{
"source": 0,
"sampler": 0
}
],
"samplers": [
{
"wrapS": 33071,
"wrapT": 10497
}
],
"images": [
{
"uri": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAEklEQVR4nGP4z8DwHwyBNBgAAEnICff5q7YNAAAAAElFTkSuQmCC"
}
],
"animations": [
{
"name": "Move",
"samplers": [
{
"input": 4,
"output": 5,
"interpolation": "LINEAR"
}
],
"channels": [
{
"sampler": 0,
"target": {
"node": 0,
"path": "translation"
}
}
]
}
],
"accessors": [
{
"bufferView": 0,
"componentType": 5126,
"count": 4,
"type": "VEC3",
"min": [
-0.5,
-0.5,
0.0
],
"max": [
0.5,
0.5,
0.0
]
},
{
"bufferView": 1,
"componentType": 5126,
"count": 4,
"type": "VEC3"
},
{
"bufferView": 2,
"componentType": 5126,
"count": 4,
"type": "VEC2"
},
{
"bufferView": 3,
"componentType": 5123,
"count": 6,
"type": "SCALAR"
},
{
"bufferView": 4,
"componentType": 5126,
"count": 2,
"type": "SCALAR",
"min": [
0.0
],
"max": [
1.0
]
},
{
"bufferView": 5,
"componentType": 5126,
"count": 2,
"type": "VEC3"
}
],
"bufferViews": [
{
"buffer": 0,
"byteOffset": 0,
"byteLength": 48,
"target": 34962
},
{
"buffer": 0,
"byteOffset": 48,
"byteLength": 48,
"target": 34962
},
{
"buffer": 0,
"byteOffset": 96,
"byteLength": 32,
"target": 34962
},
{
"buffer": 0,
"byteOffset": 128,
"byteLength": 12,
"target": 34963
},
{
"buffer": 0,
"byteOffset": 140,
"byteLength": 8
},
{
"buffer": 0,
"byteOffset": 148,
"byteLength": 24
}
],
"buffers": [
{
"byteLength": 172,
"uri": "data:application/octet-stream;base64,AAAAvwAAAL8AAAAAAAAAPwAAAL8AAAAAAAAAPwAAAD8AAAAAAAAAvwAAAD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAgD8AAIA/AACAPwAAgD8AAAAAAAAAAAAAAAAAAAEAAgAAAAIAAwAAAAAAAACAPwAAAAAAAIA/AAAAAAAAAEAAAIA/AAAAAA=="
}
]
}

View File

@ -0,0 +1,7 @@
asset: quad.gltf
title: glTF 2.0 fixture: a textured quad under a parent node with a translation animation (all data embedded as data URIs)
author: EffectCraft contributors
source: original work
license: MIT OR Apache-2.0
added: 2026-10-02 by EffectCraft agent (M7.4)
notes: Hand-written / script-generated test data for the model importer, made from the public Khronos glTF 2.0 specification and the Wavefront OBJ/MTL format description; not taken from any third-party file. The 2x2 texture is four solid colours.

View File

@ -584,6 +584,13 @@ pub fn light_options(ids: &mut Ids, kind: LightKind) -> PropGroup {
.with(ids.prop("coneAngle", "Cone Angle", Value::Scalar(90.0)).with_ui(ParamUi::Angle))
.with(ids.prop("coneFeather", "Cone Feather", Value::Scalar(50.0)).with_ui(ParamUi::Percent));
}
if kind == LightKind::Environment {
// The environment image: a layer (footage, comp…) mapped as an equirectangular panorama;
// none = the comp's Environment Layer.
return g
.with(ids.prop("source", "Source", Value::Layer(None)).with_ui(ParamUi::Layer))
.with(ids.prop("rotation", "Environment Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle));
}
if kind != LightKind::Ambient {
g = g
.with(ids.prop("falloff", "Falloff", Value::Enum(0)).with_ui(popup(&["None", "Smooth", "Inverse Square Clamped"])))
@ -609,6 +616,70 @@ pub fn material_options(ids: &mut Ids) -> PropGroup {
.with(ids.prop("metal", "Metal", Value::Scalar(100.0)).with_ui(ParamUi::Percent))
}
/// Material Options of a 3D model or primitive layer (Advanced 3D): shadows and lights, plus
/// the primitive's own metallic-roughness material (models keep their file's materials).
pub fn model_material_options(ids: &mut Ids, primitive: bool) -> PropGroup {
let mut g = ids
.group("materialOptions", "Material Options")
.with(ids.prop("castsShadows", "Casts Shadows", Value::Enum(0)).with_ui(popup(&["Off", "On", "Only"])))
.with(ids.prop("acceptsShadows", "Accepts Shadows", Value::Enum(1)).with_ui(popup(&["Off", "On", "Only"])))
.with(ids.prop("acceptsLights", "Accepts Lights", Value::Bool(true)).with_ui(ParamUi::Checkbox))
.with(ids.prop("appearsInReflections", "Appears in Reflections", Value::Bool(true)).with_ui(ParamUi::Checkbox));
if primitive {
g = g
.with(ids.prop("baseColor", "Base Color", Value::Color([0.8, 0.8, 0.8, 1.0])).with_ui(ParamUi::Color))
.with(ids.prop("metallic", "Metallic", Value::Scalar(0.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 1)))
.with(ids.prop("roughness", "Roughness", Value::Scalar(50.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 1)))
.with(ids.prop("emissive", "Emissive", Value::Color([0.0, 0.0, 0.0, 1.0])).with_ui(ParamUi::Color));
}
g
}
/// Geometry Options of an imported model layer: model units → pixels, and the animation clip
/// played along layer time.
pub fn model_geometry_options(ids: &mut Ids, unit_scale: f64, clips: &[String]) -> PropGroup {
let mut opts: Vec<&str> = vec!["None"];
opts.extend(clips.iter().map(String::as_str));
ids.group("geometryOptions", "Geometry Options")
.with(ids.prop("unitScale", "Model Scale", Value::Scalar(unit_scale)).with_ui(slider(0.0, 1.0e6, 0.0, 1000.0, 2)))
.with(ids.prop("animation", "Animation", Value::Enum(if clips.is_empty() { 0 } else { 1 })).with_ui(popup(&opts)))
.with(ids.prop("loopAnimation", "Loop Animation", Value::Bool(true)).with_ui(ParamUi::Checkbox))
.with(ids.prop("animationSpeed", "Animation Speed", Value::Scalar(100.0)).with_ui(ParamUi::Percent))
}
/// Geometry Options of a primitive layer (pixels).
pub fn primitive_geometry_options(ids: &mut Ids, kind: crate::PrimitiveKind) -> PropGroup {
use crate::PrimitiveKind as K;
let px = |ids: &mut Ids, m: &str, n: &str, v: f64| ids.prop(m, n, Value::Scalar(v)).with_ui(slider(0.0, 100_000.0, 0.0, 2000.0, 1));
let count = |ids: &mut Ids, m: &str, n: &str, v: f64| ids.prop(m, n, Value::Scalar(v)).with_ui(slider(3.0, 512.0, 3.0, 128.0, 0));
let mut g = ids.group("geometryOptions", "Geometry Options");
let props = match kind {
K::Cube => vec![px(ids, "width", "Width", 300.0), px(ids, "height", "Height", 300.0), px(ids, "depth", "Depth", 300.0)],
K::Plane => vec![px(ids, "width", "Width", 400.0), px(ids, "height", "Height", 400.0)],
K::Sphere => vec![px(ids, "radius", "Radius", 150.0), count(ids, "segments", "Segments", 48.0), count(ids, "rings", "Rings", 24.0)],
K::Torus => vec![
px(ids, "radius", "Radius", 150.0),
px(ids, "tubeRadius", "Tube Radius", 50.0),
count(ids, "segments", "Segments", 48.0),
count(ids, "rings", "Sides", 24.0),
],
K::Cone | K::Cylinder => vec![px(ids, "radius", "Radius", 150.0), px(ids, "height", "Height", 300.0), count(ids, "segments", "Segments", 48.0)],
};
for p in props {
g = g.with(p);
}
g
}
/// Geometry Options of a text or shape layer in an Advanced 3D comp (extrusion and bevels).
pub fn extrusion_geometry_options(ids: &mut Ids) -> PropGroup {
ids.group("geometryOptions", "Geometry Options")
.with(ids.prop("bevelStyle", "Bevel Style", Value::Enum(0)).with_ui(popup(&["None", "Angular", "Concave", "Convex"])))
.with(ids.prop("bevelDepth", "Bevel Depth", Value::Scalar(2.0)).with_ui(slider(0.0, 1000.0, 0.0, 100.0, 1)))
.with(ids.prop("holeBevelDepth", "Hole Bevel Depth", Value::Scalar(100.0)).with_ui(slider(0.0, 100.0, 0.0, 100.0, 1)))
.with(ids.prop("extrusionDepth", "Extrusion Depth", Value::Scalar(0.0)).with_ui(slider(0.0, 10_000.0, 0.0, 1000.0, 1)))
}
pub fn audio(ids: &mut Ids) -> PropGroup {
ids.group("audio", "Audio").with(ids.prop("levels", "Audio Levels", Value::Vec2([0.0, 0.0])))
}
@ -629,6 +700,7 @@ pub fn default_label(src: &LayerSource, project: &Project) -> Label {
LayerSource::Shape => Label::Blue,
LayerSource::Null => Label::Red,
LayerSource::Camera | LayerSource::Light { .. } => Label::Pink,
LayerSource::Model { .. } | LayerSource::Primitive { .. } => Label::Aqua,
}
}
@ -640,7 +712,12 @@ pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource
let (w, h) = (size.0 as f64, size.1 as f64);
let (cw, ch) = (comp.width as f64, comp.height as f64);
let anchor = match source {
LayerSource::Text | LayerSource::Shape | LayerSource::Camera | LayerSource::Light { .. } => [0.0, 0.0],
LayerSource::Text
| LayerSource::Shape
| LayerSource::Camera
| LayerSource::Light { .. }
| LayerSource::Model { .. }
| LayerSource::Primitive { .. } => [0.0, 0.0],
LayerSource::Null => [50.0, 50.0],
_ => [w / 2.0, h / 2.0],
};
@ -654,7 +731,9 @@ pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource
}
_ => {}
}
if source.is_av() {
if source.is_model() {
// Model layers have no masks or effects (as in After Effects).
} else if source.is_av() {
root.children.push(masks(&mut ids).into());
root.children.push(effects(&mut ids).into());
} else if matches!(source, LayerSource::Null) {
@ -694,8 +773,17 @@ pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource
// Nulls are 100×100.
}
root.children.push(std::mem::replace(&mut tr, PropGroup::new(0, "", "")).into());
if source.is_av() {
root.children.push(material_options(&mut ids).into());
match source {
LayerSource::Primitive { kind } => {
root.children.push(primitive_geometry_options(&mut ids, kind).into());
root.children.push(model_material_options(&mut ids, true).into());
}
LayerSource::Model { .. } => {
root.children.push(model_geometry_options(&mut ids, 1.0, &[]).into());
root.children.push(model_material_options(&mut ids, false).into());
}
_ if source.is_av() => root.children.push(material_options(&mut ids).into()),
_ => {}
}
}
let has_audio = matches!(&source, LayerSource::Footage { item } if matches!(project.item(*item).map(|i| &i.kind), Some(crate::ItemKind::Footage(f)) if f.has_audio))
@ -711,6 +799,7 @@ pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource
} else {
crate::AutoOrient::Off
};
let switches = Switches { three_d: source.is_model(), ..Switches::default() };
Layer {
id,
name: comp.unique_layer_name(name),
@ -721,13 +810,14 @@ pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource
in_point: Tick::ZERO,
out_point: out,
stretch: 100.0,
switches: Switches::default(),
switches,
blend_mode: BlendMode::Normal,
preserve_transparency: false,
track_matte: None,
parent: None,
markers: vec![],
markers_locked: false,
environment: false,
auto_orient,
props: root,
}

View File

@ -310,6 +310,36 @@ impl LightKind {
}
}
/// A parametric 3D primitive (Layer ▸ New ▸ Cube/Sphere/Plane/Torus/Cone/Cylinder).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum PrimitiveKind {
#[default]
Cube,
Sphere,
Plane,
Torus,
Cone,
Cylinder,
}
impl PrimitiveKind {
pub const ALL: [PrimitiveKind; 6] =
[PrimitiveKind::Cube, PrimitiveKind::Sphere, PrimitiveKind::Plane, PrimitiveKind::Torus, PrimitiveKind::Cone, PrimitiveKind::Cylinder];
pub fn label(self) -> &'static str {
match self {
PrimitiveKind::Cube => "Cube",
PrimitiveKind::Sphere => "Sphere",
PrimitiveKind::Plane => "Plane",
PrimitiveKind::Torus => "Torus",
PrimitiveKind::Cone => "Cone",
PrimitiveKind::Cylinder => "Cylinder",
}
}
pub fn parse(s: &str) -> Option<PrimitiveKind> {
PrimitiveKind::ALL.into_iter().find(|k| k.label().eq_ignore_ascii_case(s.trim()))
}
}
/// What a layer shows.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type")]
@ -333,12 +363,20 @@ pub enum LayerSource {
Light {
kind: LightKind,
},
/// An imported 3D model (a footage item of kind [`FootageKind::Model`]; Advanced 3D).
Model {
item: ItemId,
},
/// A parametric 3D primitive (Advanced 3D).
Primitive {
kind: PrimitiveKind,
},
}
impl LayerSource {
pub fn item(&self) -> Option<ItemId> {
match self {
LayerSource::Footage { item } | LayerSource::Comp { item } | LayerSource::Solid { item } => Some(*item),
LayerSource::Footage { item } | LayerSource::Comp { item } | LayerSource::Solid { item } | LayerSource::Model { item } => Some(*item),
_ => None,
}
}
@ -352,12 +390,18 @@ impl LayerSource {
LayerSource::Null => "Null",
LayerSource::Camera => "Camera",
LayerSource::Light { .. } => "Light",
LayerSource::Model { .. } => "3D Model",
LayerSource::Primitive { .. } => "3D Primitive",
}
}
/// Layers with pixels (cameras, lights and nulls have none).
pub fn is_av(&self) -> bool {
!matches!(self, LayerSource::Camera | LayerSource::Light { .. } | LayerSource::Null)
}
/// A 3D model or primitive layer (meshes; drawn by the Advanced 3D renderer).
pub fn is_model(&self) -> bool {
matches!(self, LayerSource::Model { .. } | LayerSource::Primitive { .. })
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@ -489,6 +533,10 @@ pub struct Layer {
pub markers_locked: bool,
#[serde(default)]
pub auto_orient: AutoOrient,
/// Layer ▸ Environment Layer: the layer is the environment (image-based light and
/// reflections) of Advanced 3D comps instead of being drawn.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub environment: bool,
/// The property tree (Masks, Effects, Transform, Text, Contents, Camera/Light options…).
pub props: PropGroup,
}
@ -540,6 +588,8 @@ pub enum FootageKind {
Still,
Sequence,
Audio,
/// A 3D model (glTF 2.0 / OBJ), placed as a model layer.
Model,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@ -625,6 +675,7 @@ impl Item {
FootageKind::Still => "Image",
FootageKind::Sequence => "Image Sequence",
FootageKind::Audio => "Audio",
FootageKind::Model => "3D Model",
},
ItemKind::Solid(_) => "Solid",
}

View File

@ -19,6 +19,7 @@ effectcraft-raster = { workspace = true }
effectcraft-path = { workspace = true }
effectcraft-text = { workspace = true }
effectcraft-effects = { workspace = true }
effectcraft-model = { workspace = true }
kurbo = { workspace = true }
rayon = { workspace = true }
web-time = { workspace = true }

View File

@ -39,6 +39,10 @@ pub trait FootageSource: Send + Sync {
fn audio(&self, _item: ItemId, _footage: &Footage, _t: Tick, _frames: usize, _rate: u32) -> Option<Vec<f32>> {
None
}
/// The parsed 3D model of a [`effectcraft_project::FootageKind::Model`] item (Advanced 3D).
fn model(&self, _item: ItemId, _footage: &Footage) -> Option<Arc<effectcraft_model::Model>> {
None
}
/// Set the decoded-frame cache budget in bytes (Settings ▸ Memory & CPU); sources without
/// a cache ignore it.
fn set_cache_budget(&self, _bytes: usize) {}

View File

@ -716,7 +716,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res
let Some(f) = app.hooks.pick_files.as_ref() else { return Some(Err("no file dialog available (pass `paths`)".into())) };
let paths = f(&[
"mp4", "mov", "m4v", "mkv", "webm", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "wav", "aif", "aiff", "mp3", "flac",
"ogg", "opus", "svg",
"ogg", "opus", "svg", "gltf", "glb", "obj",
]);
match (paths.is_empty(), key) {
(true, _) => None,

View File

@ -22,6 +22,7 @@ fn item_icon(it: &Item) -> Icon {
ItemKind::Footage(f) => match f.kind {
effectcraft_engine::project::FootageKind::Still | effectcraft_engine::project::FootageKind::Sequence => Icon::Image,
effectcraft_engine::project::FootageKind::Audio => Icon::Audio,
effectcraft_engine::project::FootageKind::Model => Icon::Cube,
_ => Icon::Footage,
},
}

View File

@ -433,6 +433,7 @@ fn layer_icon(l: &Layer, project: &effectcraft_engine::project::Project) -> Icon
LayerSource::Null => Icon::Null,
LayerSource::Camera => Icon::Camera,
LayerSource::Light { .. } => Icon::Light,
LayerSource::Model { .. } | LayerSource::Primitive { .. } => Icon::Cube,
LayerSource::Comp { .. } => Icon::Comp,
LayerSource::Solid { .. } => {
if l.switches.adjustment {

View File

@ -22,6 +22,7 @@ cpal or muda. Everything in L0 to L4, the egui UI and the web app also build for
| L2 | `project` | The document: items, compositions, layers, the property tree, render queue model, `.ecproj` serde |
| L2 | `text` | Fonts, shaping, layout, per-glyph geometry, text animators and selectors |
| L2 | `effects` | The effect registry (241 effects) and their CPU implementations |
| L2 | `model` | 3D models for Advanced 3D: glTF 2.0 (`.gltf`/`.glb`) and OBJ/MTL import (meshes, PBR metallic-roughness materials and textures, node hierarchy, skins, animations), parametric primitives, extruded/bevelled outline meshes and polygon triangulation |
| L2 | `track` | Motion tracking: feature/search region point tracking (pyramid normalized cross-correlation, Lucas–Kanade sub-pixel refinement), confidence, homography/affine/similarity solves |
| L3 | `render` | Evaluation and compositing: sources, masks, effects, transforms, 3D, motion blur, mattes, blending, layer cache, audio mixdown |
| L3 | `media` | Footage decoding (FilmCraft's pure-Rust codecs), image sequences, frame cache |

View File

@ -31,6 +31,7 @@ const LAYERS: &[(&str, u8)] = &[
("text", 2),
("effects", 2),
("track", 2),
("model", 2),
("render", 3),
("media", 3),
("expr", 3),