diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 9caf1c27..f89c0e87 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 635fb0f5..fc3f4a02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index a1941cf4..a5294834 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 132bdcac..a60242bb 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -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 } diff --git a/crates/engine/src/commands/comp.rs b/crates/engine/src/commands/comp.rs index 5ebd491e..a33fd574 100644 --- a/crates/engine/src/commands/comp.rs +++ b/crates/engine/src/commands/comp.rs @@ -125,6 +125,7 @@ fn settings(s: &mut Session, p: &Value) -> Result { } 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)?; diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index a85a391f..11e8a387 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -110,7 +110,7 @@ fn import(s: &mut Session, p: &Value) -> Result { 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); diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 3dea6880..ac728200 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -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 { +pub(crate) fn insert_layer(proj: &mut Project, st: &mut crate::EditorState, cid: ItemId, mut layer: Layer) -> Result { + // 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 { 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")), diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 26db85c9..519bce63 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -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()); diff --git a/crates/engine/src/commands/model3d.rs b/crates/engine/src/commands/model3d.rs new file mode 100644 index 00000000..1ad9cc32 --- /dev/null +++ b/crates/engine/src/commands/model3d.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + let mut n = 0; + let tri = |v: &Value| -> Option { + 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 { + 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 { + 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::>()})) +} + +// ---------------------------------------------------------------- materials + +fn material_set(s: &mut Session, p: &Value) -> Result { + 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 { + 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 { + 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::>()})) +} + +/// 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 { + 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 { + 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 { + 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 + ), + ] +} diff --git a/crates/engine/src/commands/stubs.rs b/crates/engine/src/commands/stubs.rs index f7868d9c..01880a53 100644 --- a/crates/engine/src/commands/stubs.rs +++ b/crates/engine/src/commands/stubs.rs @@ -29,11 +29,9 @@ pub fn specs() -> Vec { 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 { 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. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f64ce817..eb234ea8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/tests_model3d.rs b/crates/engine/src/tests_model3d.rs new file mode 100644 index 00000000..51f9173d --- /dev/null +++ b/crates/engine/src/tests_model3d.rs @@ -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> { + None + } + fn model(&self, _: ItemId, f: &Footage) -> Option> { + 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 { + 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); +} diff --git a/crates/lottie/src/export.rs b/crates/lottie/src/export.rs index fa5447d8..eeb6e273 100644 --- a/crates/lottie/src/export.rs +++ b/crates/lottie/src/export.rs @@ -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)) { diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index 89992569..010a6414 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -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 } diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 29546f7e..fcc872d1 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -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 { diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index b4cc51ef..a7898fc6 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -122,6 +122,8 @@ struct Inner { sources: Mutex, Option>>, /// In-memory files registered with `add_bytes` (web builds, tests). files: Mutex>>, + /// Parsed 3D models by path (`None`: failed to load; not retried until `forget`). + models: Mutex>>>, cache: Mutex, 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 = 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> { + 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> { + 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> { 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> { + 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); } diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index 4e1c5611..e97544db 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -28,11 +28,50 @@ pub fn probe(path: impl AsRef) -> Result { pub fn probe_single(path: impl AsRef) -> Result { 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>) -> Result { + 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 { + 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); } diff --git a/crates/model/Cargo.toml b/crates/model/Cargo.toml new file mode 100644 index 00000000..20a1fde0 --- /dev/null +++ b/crates/model/Cargo.toml @@ -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 } diff --git a/crates/model/src/extrude.rs b/crates/model/src/extrude.rs new file mode 100644 index 00000000..344c67cb --- /dev/null +++ b/crates/model/src/extrude.rs @@ -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

, + pub holes: Vec>, +} + +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 { + let cs: Vec> = 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 = 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> = (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 = vec![]; + let mut slot = vec![usize::MAX; n]; + let mut order: Vec = (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

, + /// Outward unit normal of edge i (pts[i] → pts[i+1]). + edge_n: Vec

, + /// Miter offset direction per vertex (outward, length ≥ 1). + miter: Vec

, + /// Smooth-shaded vertex (shallow corner). + smooth: Vec, + bevel: f64, +} + +fn contour(mut pts: Vec

, 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

= (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![]; + let mut ring_par: Vec> = vec![]; + for c in &cs { + let rs = rings(p.bevel, c.bevel, depth); + let pos: Vec> = + 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, 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

= ring_pos[0][0].iter().map(|q| [q[0], q[1]]).collect(); + let holes2: Vec> = 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 +} diff --git a/crates/model/src/gltf.rs b/crates/model/src/gltf.rs new file mode 100644 index 00000000..375884bc --- /dev/null +++ b/crates/model/src/gltf.rs @@ -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 { 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> { + 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>) -> Result> { + 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>, +} + +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 { + 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(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, 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> { + 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>) -> Result { + 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>> = 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> = vec![]; + let mut tex_map: Vec> = 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 { + 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 = 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 = 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 = 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> { + 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| -> Result> { + 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 = 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)) +} diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs new file mode 100644 index 00000000..290e5500 --- /dev/null +++ b/crates/model/src/lib.rs @@ -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 = std::result::Result; + +pub(crate) fn perr(s: impl Into) -> 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 { + 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, + pub metallic: f32, + pub roughness: f32, + /// Roughness in G, metalness in B. + pub metallic_roughness_tex: Option, + pub normal_tex: Option, + pub normal_scale: f32, + /// Occlusion in R. + pub occlusion_tex: Option, + pub occlusion_strength: f32, + /// Linear emissive factor (× KHR_materials_emissive_strength). + pub emissive: [f32; 3], + pub emissive_tex: Option, + 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, + pub material: Option, +} + +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, +} + +/// 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, + pub mesh: Option, + pub skin: Option, + pub children: Vec, +} + +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, + pub inverse_bind: Vec, +} + +#[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, + /// Values, `width` floats per key (cubic spline: in-tangent, value, out-tangent per key). + pub values: Vec, +} + +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, +} + +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, + pub nodes: Vec, + /// Root nodes of the displayed scene. + pub roots: Vec, + pub materials: Vec, + pub textures: Vec>, + pub skins: Vec, + pub animations: Vec, +} + +/// 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>, +} + +impl Model { + /// A model of one mesh at the root. + pub fn single(mesh: Mesh, materials: Vec) -> 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, t: f64) -> Vec { + 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 { + 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, t: f64) -> Vec> { + 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 = 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>) -> Result { + 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; diff --git a/crates/model/src/obj.rs b/crates/model/src/obj.rs new file mode 100644 index 00000000..e6727e73 --- /dev/null +++ b/crates/model/src/obj.rs @@ -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>, textures: &mut Vec>) -> Vec { + let mut out: Vec = vec![]; + let mut loaded: HashMap> = HashMap::new(); + let mut tex = |name: &str, textures: &mut Vec>| -> Option { + // 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 = 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::().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>) -> Result { + 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 = vec![]; + let mut textures: Vec> = vec![]; + // One primitive per material, in order of first use. + let mut prims: Vec<(Option, Primitive, HashMap, Vec)> = vec![]; + let mut current: Option = 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::().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 { + 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>) -> Model { + self.textures = t; + self + } +} diff --git a/crates/model/src/prim.rs b/crates/model/src/prim.rs new file mode 100644 index 00000000..f04873df --- /dev/null +++ b/crates/model/src/prim.rs @@ -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::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]) +} diff --git a/crates/model/src/tests.rs b/crates/model/src/tests.rs new file mode 100644 index 00000000..31040b7c --- /dev/null +++ b/crates/model/src/tests.rs @@ -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 { + std::fs::read(format!("{}/tests/fixtures/{name}", env!("CARGO_MANIFEST_DIR"))).unwrap() +} + +fn resolver(name: &str) -> Option> { + 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::(), 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 = 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::(), 2); +} diff --git a/crates/model/src/triangulate.rs b/crates/model/src/triangulate.rs new file mode 100644 index 00000000..7505f5f9 --- /dev/null +++ b/crates/model/src/triangulate.rs @@ -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

]) -> Vec<[usize; 3]> { + if outer.len() < 3 { + return vec![]; + } + let mut pts: Vec

= outer.to_vec(); + for h in holes { + pts.extend_from_slice(h); + } + // Ring of global indices, outer counter-clockwise. + let mut ring: Vec = (0..outer.len()).collect(); + if signed_area(outer) < 0.0 { + ring.reverse(); + } + // Holes clockwise, rightmost first. + let mut hs: Vec> = vec![]; + let mut off = outer.len(); + for h in holes { + let mut r: Vec = (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| 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, 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) -> 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 +} diff --git a/crates/model/tests/fixtures/cube.mtl b/crates/model/tests/fixtures/cube.mtl new file mode 100644 index 00000000..9ea4c99c --- /dev/null +++ b/crates/model/tests/fixtures/cube.mtl @@ -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 diff --git a/crates/model/tests/fixtures/cube.mtl.attribution b/crates/model/tests/fixtures/cube.mtl.attribution new file mode 100644 index 00000000..7af62d2d --- /dev/null +++ b/crates/model/tests/fixtures/cube.mtl.attribution @@ -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. diff --git a/crates/model/tests/fixtures/cube.obj b/crates/model/tests/fixtures/cube.obj new file mode 100644 index 00000000..e50ef30c --- /dev/null +++ b/crates/model/tests/fixtures/cube.obj @@ -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 diff --git a/crates/model/tests/fixtures/cube.obj.attribution b/crates/model/tests/fixtures/cube.obj.attribution new file mode 100644 index 00000000..faf95fc0 --- /dev/null +++ b/crates/model/tests/fixtures/cube.obj.attribution @@ -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. diff --git a/crates/model/tests/fixtures/quad.glb b/crates/model/tests/fixtures/quad.glb new file mode 100644 index 00000000..ab978546 Binary files /dev/null and b/crates/model/tests/fixtures/quad.glb differ diff --git a/crates/model/tests/fixtures/quad.glb.attribution b/crates/model/tests/fixtures/quad.glb.attribution new file mode 100644 index 00000000..c1b0e631 --- /dev/null +++ b/crates/model/tests/fixtures/quad.glb.attribution @@ -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. diff --git a/crates/model/tests/fixtures/quad.gltf b/crates/model/tests/fixtures/quad.gltf new file mode 100644 index 00000000..82afef9b --- /dev/null +++ b/crates/model/tests/fixtures/quad.gltf @@ -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==" + } + ] +} diff --git a/crates/model/tests/fixtures/quad.gltf.attribution b/crates/model/tests/fixtures/quad.gltf.attribution new file mode 100644 index 00000000..1ccf03b7 --- /dev/null +++ b/crates/model/tests/fixtures/quad.gltf.attribution @@ -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. diff --git a/crates/project/src/build.rs b/crates/project/src/build.rs index 12e384d6..a201879d 100644 --- a/crates/project/src/build.rs +++ b/crates/project/src/build.rs @@ -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, } diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 1ed35c09..4a00c413 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -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::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 { 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", } diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 4c8a9787..80e30455 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -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 } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 14a81575..4ec70cfa 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -39,6 +39,10 @@ pub trait FootageSource: Send + Sync { fn audio(&self, _item: ItemId, _footage: &Footage, _t: Tick, _frames: usize, _rate: u32) -> Option> { None } + /// The parsed 3D model of a [`effectcraft_project::FootageKind::Model`] item (Advanced 3D). + fn model(&self, _item: ItemId, _footage: &Footage) -> Option> { + 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) {} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index da5eaec2..22b2bd40 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -716,7 +716,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option None, diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 1824fec5..d13f3768 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -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, }, } diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 5b6c085f..c9a42698 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -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 { diff --git a/docs/architecture.md b/docs/architecture.md index c3d478e3..7e2921e0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 | diff --git a/xtask/src/main.rs b/xtask/src/main.rs index ae38adf9..7e265297 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -31,6 +31,7 @@ const LAYERS: &[(&str, u8)] = &[ ("text", 2), ("effects", 2), ("track", 2), + ("model", 2), ("render", 3), ("media", 3), ("expr", 3),