Skip to main content

primase_core/
clip.rs

1//! An animation clip (`core.clip`) and what binds one to occurrences (`core.clip_binding`).
2//!
3//! A clip is a definition: channels over time, each driving one property of one target. Targets name definitions,
4//! not occurrences: a joint of a skeleton ([`ClipTarget::Joint`]), a morph target of a geometry's morph set
5//! ([`ClipTarget::Morph`], named by the prototype that holds the set, so it holds whichever level-of-detail form is
6//! drawn), or a source's control ([`ClipTarget::Control`]: a scalar whose meaning stays in its source's namespace,
7//! kept so it is not lost), a source-scoped draw group ([`ClipTarget::DrawGroup`]), or a named material
8//! parameter ([`ClipTarget::MaterialParameter`]). Draw groups use Step visibility (-1 inherits,
9//! 0 hides, 1 shows); material vectors replace same-width defaults in `core.material_parameters`.
10//! One clip may drive a skeleton and several morph sets together. A [`ClipBinding`] says
11//! which rig and which mesh occurrences a clip drives, so occurrences sharing a clip still animate apart.
12//!
13//! Meaning (core's evaluation rule; a source's curves that evaluate otherwise, as Havok's compressed splines or one
14//! engine's weighted tangents or Euler curves, stay in its namespace until a pass lowers them):
15//! - [`Interpolation::Step`] holds a key's value until the next key; [`Interpolation::Linear`] interpolates
16//!   linearly, rotations by spherical linear interpolation (the shorter arc); [`Interpolation::Hermite`] is a cubic
17//!   Hermite spline through the keys with each key's in and out tangents (glTF's `CUBICSPLINE`: values stored as
18//!   in-tangent, value, out-tangent per key, tangents per second times the segment's length), rotations componentwise
19//!   then normalised.
20//! - Before the first key and after the last, a channel holds its end values ([`Extrapolation::Hold`]) or the clip
21//!   repeats over `[0, duration]` ([`Extrapolation::Loop`]).
22//! - A joint channel replaces the joint's local translation, rotation or scale (in its parent's space); a joint no
23//!   channel drives keeps its pose. With an [`AdditiveReference`] the clip's values are taken relative to the
24//!   reference and applied on top of a base pose, joint by joint in local space: `T = T_base + (T_clip - T_ref)`,
25//!   `R = R_base * R_ref^-1 * R_clip`, `S = S_base * S_clip / S_ref`; a channel the clip lacks adds nothing.
26//!   [`AdditiveReference::Identity`] is the reference of a clip whose values are already deltas (`T_ref = 0`, `R_ref` the
27//!   identity, `S_ref = 1`): `T = T_base + T_clip`, `R = R_base * R_clip`, `S = S_base * S_clip`.
28//!   [`AdditiveReference::IdentityParent`] is the same deltas composed on the other side, the parent's: `R = R_clip * R_base`
29//!   (`T` and `S` as for `Identity`: a sum and a product, which do not depend on the order).
30//! - Root motion is not a joint channel: [`RootMotion`] states the space it moves in and is applied (or not) by a pass.
31
32use std::collections::BTreeSet;
33
34use serde::{Deserialize, Serialize};
35use serde_json::Value;
36
37use crate::morphs::{MorphId, Morphs};
38use crate::rig::{RigInstance, RigPose};
39use crate::skeleton::{JointId, Skeleton};
40use crate::{Component, Entity, Instance, JsonError, World};
41
42/// What a channel drives.
43#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
44pub enum ClipTarget {
45    /// A joint of the skeleton held by `skeleton`.
46    Joint {
47        skeleton: Entity,
48        #[serde(deserialize_with = "joint_id")]
49        joint: JointId,
50    },
51    /// A morph target of the morph set held by `geometry` (a prototype's `core.morphs`).
52    Morph {
53        geometry: Entity,
54        #[serde(deserialize_with = "morph_id")]
55        morph: MorphId,
56    },
57    /// A source's scalar control by its name (its meaning is its source's).
58    Control { name: String },
59    /// A draw group in a source-defined scope. Core supports groups 0..64:
60    /// sources with more groups must refuse by name rather than truncate.
61    DrawGroup { scope: String, group: u32 },
62    /// A named parameter on resolved material entities. Each target must carry
63    /// the same-width named default in core.material_parameters.
64    MaterialParameter { materials: Vec<Entity>, name: String },
65}
66
67fn joint_id<'de, D: serde::Deserializer<'de>>(d: D) -> Result<JointId, D::Error> {
68    u32::deserialize(d).map(JointId)
69}
70fn morph_id<'de, D: serde::Deserializer<'de>>(d: D) -> Result<MorphId, D::Error> {
71    u32::deserialize(d).map(MorphId)
72}
73
74/// Which property of its target a channel drives, with the values it takes.
75#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
76pub enum Property {
77    /// A joint's local translation (three values).
78    Translation,
79    /// A joint's local rotation, a unit quaternion `[x, y, z, w]` (four values).
80    Rotation,
81    /// A joint's local scale (three values).
82    Scale,
83    /// A morph target's weight, or a control's value (one value).
84    Scalar,
85    /// DrawGroup only: one Step value per key, -1 inherit the occurrence's
86    /// default, 0 hidden, 1 shown. No interpolation between visibility states.
87    Visibility,
88    /// MaterialParameter only: 1..=4 finite floats per key, Step or Linear.
89    Values(u8),
90}
91
92impl Property {
93    /// Number of floats per key value (Hermite adds in/out tangent records).
94    /// Values(0) and Values(5..) are invalid, rejected by the clip verifier.
95    pub fn width(self) -> usize {
96        match self {
97            Property::Translation | Property::Scale => 3,
98            Property::Rotation => 4,
99            Property::Scalar | Property::Visibility => 1,
100            Property::Values(n) => usize::from(n),
101        }
102    }
103}
104
105/// How a channel's values are interpolated between keys (see the module documentation).
106#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
107pub enum Interpolation {
108    Step,
109    Linear,
110    Hermite,
111}
112
113/// One channel: key times in seconds, strictly increasing, and its values: per key [`Property`]'s width of values,
114/// three times that for [`Interpolation::Hermite`] (in-tangent, value, out-tangent), flattened.
115#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
116pub struct Channel {
117    pub target: ClipTarget,
118    pub property: Property,
119    pub interpolation: Interpolation,
120    pub times: Vec<f32>,
121    pub values: Vec<f32>,
122}
123
124/// What a channel does outside its keys.
125#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
126pub enum Extrapolation {
127    Hold,
128    Loop,
129}
130
131/// The space root motion is stated in.
132#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
133pub enum RootSpace {
134    /// The rig's own space (`core.rig_instance`'s placement).
135    Rig,
136}
137
138/// A clip's root motion: how the whole rig moves, in `space`, apart from its joints. Translation (three values a key)
139/// and rotation (four), each a channel without a target of its own.
140#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
141pub struct RootMotion {
142    pub space: RootSpace,
143    pub translation: Option<(Interpolation, Vec<f32>, Vec<f32>)>,
144    pub rotation: Option<(Interpolation, Vec<f32>, Vec<f32>)>,
145}
146
147/// What an additive clip is relative to.
148#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
149pub enum AdditiveReference {
150    /// Nothing: the clip's values are deltas, relative to zero, the identity and one (the module documentation states
151    /// the composition). A source that stores additive animation as deltas lowers to this; no entity stands for the
152    /// reference.
153    Identity,
154    /// As [`AdditiveReference::Identity`] (deltas from zero, the identity and one), composed on the parent's side of the
155    /// base rotation: `R = R_clip * R_base`, where `Identity` has `R = R_base * R_clip`. Translations add and scales
156    /// multiply as for `Identity`. A source whose engine composes its additive animation this way (Sekiro's blend hint 1,
157    /// as HKLib's `ADDITIVE_PARENT_SPACE` names it) lowers to this.
158    IdentityParent,
159    /// The `core.rig_pose` held by this entity.
160    Pose(Entity),
161    /// Another clip's (the `core.clip` held by `clip`) values at `time` seconds.
162    ClipFrame { clip: Entity, time: f32 },
163}
164
165/// `core.clip` (see the module documentation).
166#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
167pub struct Clip {
168    /// The source's name: provenance.
169    pub name: String,
170    /// Seconds; every key within `[0, duration]`.
171    pub duration: f32,
172    pub extrapolation: Extrapolation,
173    pub channels: Vec<Channel>,
174    pub root_motion: Option<RootMotion>,
175    pub additive: Option<AdditiveReference>,
176}
177
178/// `core.clip_binding`: a clip driving a rig (its joint channels, on the rig's skeleton) and mesh occurrences (its
179/// morph channels, on the morph sets they draw). On its own entity: several bindings may drive one occurrence (a
180/// pass layering them states how).
181#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
182pub struct ClipBinding {
183    pub clip: Entity,
184    pub rig: Option<Entity>,
185    pub meshes: Vec<Entity>,
186}
187
188const UNIT_TOLERANCE: f32 = 1e-3;
189
190/// A channel's keys and values against its interpolation and width, by `what` (for errors).
191fn verify_keys(
192    what: &str,
193    interpolation: Interpolation,
194    width: usize,
195    times: &[f32],
196    values: &[f32],
197    duration: f32,
198    unit: bool,
199) -> Result<(), String> {
200    if times.is_empty() {
201        return Err(format!("{what}: no keys"));
202    }
203    if !times
204        .iter()
205        .all(|t| t.is_finite() && (0.0..=duration).contains(t))
206        || !times.windows(2).all(|p| p[0] < p[1])
207    {
208        return Err(format!("{what}: key times not increasing within [0, {duration}]"));
209    }
210    let per_key = if interpolation == Interpolation::Hermite {
211        3 * width
212    } else {
213        width
214    };
215    if values.len() != times.len() * per_key {
216        return Err(format!(
217            "{what}: {} values for {} keys of {per_key}",
218            values.len(),
219            times.len()
220        ));
221    }
222    if !values.iter().all(|x| x.is_finite()) {
223        return Err(format!("{what}: a value not finite"));
224    }
225    if unit {
226        // the key values (the middle of each Hermite triple) are rotations
227        let offset = if interpolation == Interpolation::Hermite {
228            width
229        } else {
230            0
231        };
232        for (k, key) in values.chunks(per_key).enumerate() {
233            let q = &key[offset..offset + width];
234            let len = q.iter().map(|x| x * x).sum::<f32>().sqrt();
235            if (len - 1.0).abs() > UNIT_TOLERANCE {
236                return Err(format!("{what}: key {k}'s rotation of length {len}"));
237            }
238        }
239    }
240    Ok(())
241}
242
243impl Component for Clip {
244    const NAME: &'static str = "core.clip";
245    /// 2: draw-group visibility and named material-parameter targets/properties.
246    const VERSION: u32 = 2;
247    const GEOMETRY: crate::Geometry = crate::Geometry::None;
248    /// Name, duration, targets and key counts, not the values.
249    fn to_json(&self) -> Result<Option<Value>, JsonError> {
250        let channels: Vec<Value> = self
251            .channels
252            .iter()
253            .map(|c| {
254                serde_json::json!({
255                    "target": c.target,
256                    "property": c.property,
257                    "interpolation": c.interpolation,
258                    "keys": c.times.len(),
259                })
260            })
261            .collect();
262        Ok(Some(serde_json::json!({
263            "name": self.name,
264            "duration": crate::json::to_value(&self.duration)?,
265            "extrapolation": self.extrapolation,
266            "channels": channels,
267            "root_motion": self.root_motion.is_some(),
268            "additive": crate::json::to_value(&self.additive)?,
269        })))
270    }
271    fn references(&self, out: &mut Vec<Entity>) {
272        for c in &self.channels {
273            match &c.target {
274                ClipTarget::Joint { skeleton, .. } => out.push(*skeleton),
275                ClipTarget::Morph { geometry, .. } => out.push(*geometry),
276                ClipTarget::MaterialParameter { materials, .. } => out.extend(materials),
277                ClipTarget::DrawGroup { .. } | ClipTarget::Control { .. } => {}
278            }
279        }
280        match self.additive {
281            Some(AdditiveReference::Pose(e)) => out.push(e),
282            Some(AdditiveReference::ClipFrame { clip, .. }) => out.push(clip),
283            Some(AdditiveReference::Identity | AdditiveReference::IdentityParent) | None => {}
284        }
285    }
286    /// Duration finite and non-negative; each channel's property fits its target (joints take translation, rotation
287    /// or scale; morphs and controls a scalar; scoped draw groups Step visibility; named material parameters
288    /// Values(1..=4) matching every target's default), its target exists in the definition it names, one channel per target
289    /// and property; keys and values as [`Channel`] says, rotations unit quaternions (within 1e-3); root motion's
290    /// likewise; an additive reference a `core.rig_pose` or a `core.clip` with the frame's time within its duration.
291    fn verify(&self, e: Entity, w: &World) -> Result<(), String> {
292        if !self.duration.is_finite() || self.duration < 0.0 {
293            return Err(format!("duration {} not finite and non-negative", self.duration));
294        }
295        let mut seen = BTreeSet::new();
296        let mut material_channels = BTreeSet::new();
297        for (k, c) in self.channels.iter().enumerate() {
298            let what = format!("channel {k}");
299            match (&c.target, c.property) {
300                (
301                    ClipTarget::Joint { skeleton, joint },
302                    Property::Translation | Property::Rotation | Property::Scale,
303                ) => {
304                    let s = w
305                        .get::<Skeleton>(*skeleton)
306                        .ok_or_else(|| format!("{what}: {skeleton:?} holds no core.skeleton"))?;
307                    if s.index_of(*joint).is_none() {
308                        return Err(format!("{what}: joint {joint:?} is not in its skeleton"));
309                    }
310                }
311                (ClipTarget::Morph { geometry, morph }, Property::Scalar) => {
312                    let m = w
313                        .get::<Morphs>(*geometry)
314                        .ok_or_else(|| format!("{what}: {geometry:?} holds no core.morphs"))?;
315                    if m.target(*morph).is_none() {
316                        return Err(format!("{what}: morph {morph:?} is not in its set"));
317                    }
318                }
319                (ClipTarget::Control { .. }, Property::Scalar) => {}
320                (ClipTarget::DrawGroup { scope, group }, Property::Visibility) => {
321                    if scope.trim().is_empty() || *group >= 64 {
322                        return Err(format!(
323                            "{what}: draw-group scope must be nonempty and group below core's limit of 64"
324                        ));
325                    }
326                    if c.interpolation != Interpolation::Step {
327                        return Err(format!("{what}: visibility requires Step interpolation"));
328                    }
329                    if c.values.iter().any(|v| ![-1.0, 0.0, 1.0].contains(v)) {
330                        return Err(format!(
331                            "{what}: visibility values must be -1 (default), 0 (hidden) or 1 (shown)"
332                        ));
333                    }
334                }
335                (ClipTarget::MaterialParameter { materials, name }, Property::Values(width)) => {
336                    if !(1..=4).contains(&width) || name.trim().is_empty() || materials.is_empty() {
337                        return Err(format!(
338                            "{what}: material parameter needs a name, targets and Values(1..=4)"
339                        ));
340                    }
341                    if c.interpolation == Interpolation::Hermite {
342                        return Err(format!(
343                            "{what}: material values require Step or Linear interpolation"
344                        ));
345                    }
346                    let mut targets = BTreeSet::new();
347                    for &material in materials {
348                        if !targets.insert(material) {
349                            return Err(format!("{what}: repeated material target {material:?}"));
350                        }
351                        let defaults = w.get::<crate::MaterialParameters>(material).ok_or_else(|| {
352                            format!("{what}: {material:?} holds no core.material_parameters default")
353                        })?;
354                        let value = defaults.0.get(name).ok_or_else(|| {
355                            format!("{what}: {material:?} has no default for material parameter {name:?}")
356                        })?;
357                        if value.len() != usize::from(width) || !value.iter().all(|v| v.is_finite()) {
358                            return Err(format!(
359                                "{what}: {material:?} default for {name:?} is not {width} finite values"
360                            ));
361                        }
362                        if !material_channels.insert((material, name)) {
363                            return Err(format!(
364                                "{what}: a second channel for material {material:?} parameter {name:?}"
365                            ));
366                        }
367                    }
368                }
369                (t, p) => return Err(format!("{what}: {p:?} does not fit {t:?}")),
370            }
371            if !seen.insert((&c.target, c.property)) {
372                return Err(format!(
373                    "{what}: a second channel for {:?} {:?}",
374                    c.target, c.property
375                ));
376            }
377            verify_keys(
378                &what,
379                c.interpolation,
380                c.property.width(),
381                &c.times,
382                &c.values,
383                self.duration,
384                c.property == Property::Rotation,
385            )?;
386        }
387        if let Some(r) = &self.root_motion {
388            for (what, curve, width) in [
389                ("root translation", &r.translation, 3),
390                ("root rotation", &r.rotation, 4),
391            ] {
392                if let Some((i, times, values)) = curve {
393                    verify_keys(what, *i, width, times, values, self.duration, width == 4)?;
394                }
395            }
396        }
397        match self.additive {
398            Some(AdditiveReference::Pose(e)) if !w.has::<RigPose>(e) => {
399                return Err(format!("additive reference {e:?} holds no core.rig_pose"));
400            }
401            Some(AdditiveReference::ClipFrame { clip, time }) => {
402                let c = w
403                    .get::<Clip>(clip)
404                    .ok_or_else(|| format!("additive reference {clip:?} holds no core.clip"))?;
405                if !(time.is_finite() && (0.0..=c.duration).contains(&time)) {
406                    return Err(format!(
407                        "additive reference time {time} outside its clip's [0, {}]",
408                        c.duration
409                    ));
410                }
411                // clips additive to clips' frames: the chain ends (in a pose, or a clip that is not additive), or a
412                // clip's values would be relative to themselves
413                let mut seen = BTreeSet::from([e]);
414                let mut next = Some(clip);
415                while let Some(at) = next {
416                    if !seen.insert(at) {
417                        return Err(format!(
418                            "additive references from {e:?} come back to {at:?}: a cycle"
419                        ));
420                    }
421                    next = match w.get::<Clip>(at).and_then(|c| c.additive) {
422                        Some(AdditiveReference::ClipFrame { clip, .. }) => Some(clip),
423                        _ => None,
424                    };
425                }
426            }
427            _ => {}
428        }
429        Ok(())
430    }
431}
432
433impl Component for ClipBinding {
434    const NAME: &'static str = "core.clip_binding";
435    const GEOMETRY: crate::Geometry = crate::Geometry::None;
436    fn to_json(&self) -> Result<Option<Value>, JsonError> {
437        crate::json::to_value(self).map(Some)
438    }
439    fn references(&self, out: &mut Vec<Entity>) {
440        out.push(self.clip);
441        out.extend(self.rig);
442        out.extend(&self.meshes);
443    }
444    /// The clip a `core.clip`; its joint channels on the rig's skeleton (so it needs a rig when it has any); each of its
445    /// meshes an occurrence drawing a morph set the clip's morph channels drive (its prototype's, or its own mesh). A
446    /// morph channel none of its meshes draws drives nothing through this binding (an occurrence posed into a static
447    /// mesh leaves it).
448    fn verify(&self, _e: Entity, w: &World) -> Result<(), String> {
449        let clip = w
450            .get::<Clip>(self.clip)
451            .ok_or_else(|| format!("{:?} holds no core.clip", self.clip))?;
452        let skeleton = match self.rig {
453            Some(r) => Some(
454                w.get::<RigInstance>(r)
455                    .ok_or_else(|| format!("{r:?} is not a rig"))?
456                    .skeleton,
457            ),
458            None => None,
459        };
460        let mut driven = BTreeSet::new();
461        for c in &clip.channels {
462            match c.target {
463                ClipTarget::Joint { skeleton: s, .. } if Some(s) != skeleton => {
464                    return Err(format!("a joint channel on {s:?}, the rig poses {skeleton:?}"));
465                }
466                ClipTarget::Morph { geometry, .. } => {
467                    driven.insert(geometry);
468                }
469                ClipTarget::Joint { .. }
470                | ClipTarget::Control { .. }
471                | ClipTarget::DrawGroup { .. }
472                | ClipTarget::MaterialParameter { .. } => {}
473            }
474        }
475        for &m in &self.meshes {
476            let drawn = w.get::<Instance>(m).map_or(m, |i| i.prototype);
477            if !driven.contains(&drawn) {
478                return Err(format!(
479                    "mesh {m:?} draws {drawn:?}, whose morph set the clip does not drive"
480                ));
481            }
482        }
483        Ok(())
484    }
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490    use crate::morphs::{Deltas, Frame, Target};
491    use crate::skeleton::{Joint, Rest};
492    use crate::{Mesh, Primitive, Semantic, Stream, Topology, Transform};
493
494    const S: f32 = std::f32::consts::FRAC_1_SQRT_2;
495
496    /// A skeleton (joints 1 and 2), a prototype with a morph set (target 9), a rig and a placement of the prototype,
497    /// and a clip turning joint 2 a quarter turn about z over a second (linear, two keys), raising it with a Hermite
498    /// translation, driving morph 9 and a source control: (world, clip, binding, skeleton, prototype, rig).
499    fn world() -> (World, Entity, Entity, Entity, Entity, Entity) {
500        let mut w = World::new();
501        let skeleton = w.spawn();
502        w.insert(
503            skeleton,
504            Skeleton {
505                joints: [(1, None), (2, Some(1))]
506                    .map(|(id, parent): (u32, Option<u32>)| Joint {
507                        id: JointId(id),
508                        name: format!("j{id}"),
509                        parent: parent.map(JointId),
510                        rest: Rest::Affine(Transform::IDENTITY),
511                    })
512                    .into(),
513            },
514        );
515        let proto = w.spawn();
516        w.insert(
517            proto,
518            Mesh::new(vec![Primitive {
519                topology: Topology::Points,
520                streams: [(Semantic::Position, Stream::F32x3(vec![[0.0; 3]]))].into(),
521                indices: None,
522                material: None,
523            }]),
524        );
525        w.insert(
526            proto,
527            Morphs {
528                targets: vec![Target {
529                    id: MorphId(9),
530                    name: "blink".into(),
531                    default_weight: 0.0,
532                    frames: vec![Frame {
533                        weight: 1.0,
534                        primitives: vec![Some(Deltas {
535                            positions: Some(vec![[0.0, 0.1, 0.0]]),
536                            ..Deltas::default()
537                        })],
538                    }],
539                }],
540            },
541        );
542        let rig = w.spawn();
543        w.insert(rig, RigInstance { skeleton });
544        w.insert(rig, Transform::IDENTITY);
545        let placed = w.spawn();
546        w.insert(
547            placed,
548            Instance {
549                prototype: proto,
550                variant: 0,
551            },
552        );
553        w.insert(placed, Transform::IDENTITY);
554        let joint2 = ClipTarget::Joint {
555            skeleton,
556            joint: JointId(2),
557        };
558        let clip = w.spawn();
559        w.insert(
560            clip,
561            Clip {
562                name: "wave".into(),
563                duration: 1.0,
564                extrapolation: Extrapolation::Loop,
565                channels: vec![
566                    Channel {
567                        target: joint2.clone(),
568                        property: Property::Rotation,
569                        interpolation: Interpolation::Linear,
570                        times: vec![0.0, 1.0],
571                        values: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, S, S],
572                    },
573                    Channel {
574                        target: joint2,
575                        property: Property::Translation,
576                        interpolation: Interpolation::Hermite,
577                        times: vec![0.0, 1.0],
578                        values: vec![0.0; 3]
579                            .into_iter()
580                            .chain([0.0, 0.0, 0.0])
581                            .chain([0.0; 3])
582                            .chain([0.0; 3])
583                            .chain([0.0, 1.0, 0.0])
584                            .chain([0.0; 3])
585                            .collect(),
586                    },
587                    Channel {
588                        target: ClipTarget::Morph {
589                            geometry: proto,
590                            morph: MorphId(9),
591                        },
592                        property: Property::Scalar,
593                        interpolation: Interpolation::Step,
594                        times: vec![0.0, 0.5],
595                        values: vec![0.0, 1.0],
596                    },
597                    Channel {
598                        target: ClipTarget::Control {
599                            name: "board_flip".into(),
600                        },
601                        property: Property::Scalar,
602                        interpolation: Interpolation::Linear,
603                        times: vec![0.25],
604                        values: vec![0.5],
605                    },
606                ],
607                root_motion: Some(RootMotion {
608                    space: RootSpace::Rig,
609                    translation: Some((
610                        Interpolation::Linear,
611                        vec![0.0, 1.0],
612                        vec![0.0, 0.0, 0.0, 0.0, 0.0, -2.0],
613                    )),
614                    rotation: None,
615                }),
616                additive: None,
617            },
618        );
619        let binding = w.spawn();
620        w.insert(
621            binding,
622            ClipBinding {
623                clip,
624                rig: Some(rig),
625                meshes: vec![placed],
626            },
627        );
628        (w, clip, binding, skeleton, proto, rig)
629    }
630
631    fn errors(w: &World) -> Vec<String> {
632        w.verify_components().into_iter().map(|(_, _, m)| m).collect()
633    }
634
635    /// One clip drives a joint, a morph and a control together; bound to a rig and a placement it reaches the
636    /// skeleton, the morph set's prototype and the occurrences.
637    #[test]
638    fn a_clip_drives_joints_morphs_and_controls_together() {
639        let (w, clip, binding, skeleton, proto, rig) = world();
640        assert_eq!(errors(&w), Vec::<String>::new());
641        assert_eq!(w.closure([clip]), [clip, skeleton, proto].into());
642        assert!(w.closure([binding]).contains(&rig));
643    }
644
645    fn with_display_channels() -> (World, Entity, Entity) {
646        let (mut w, clip, ..) = world();
647        let material = w.spawn();
648        w.insert(
649            material,
650            crate::MaterialParameters(std::collections::BTreeMap::from([(
651                "tint".into(),
652                vec![0.25, 0.5],
653            )])),
654        );
655        w.get_mut::<Clip>(clip).unwrap().channels.extend([
656            Channel {
657                target: ClipTarget::DrawGroup {
658                    scope: "character".into(),
659                    group: 63,
660                },
661                property: Property::Visibility,
662                interpolation: Interpolation::Step,
663                times: vec![0.0, 0.5, 1.0],
664                values: vec![-1.0, 0.0, 1.0],
665            },
666            Channel {
667                target: ClipTarget::MaterialParameter {
668                    materials: vec![material],
669                    name: "tint".into(),
670                },
671                property: Property::Values(2),
672                interpolation: Interpolation::Linear,
673                times: vec![0.0, 1.0],
674                values: vec![0.25, 0.5, 0.75, 1.0],
675            },
676        ]);
677        (w, clip, material)
678    }
679
680    #[test]
681    fn display_channels_roundtrip_and_keep_material_references_in_the_closure() {
682        let (w, clip, material) = with_display_channels();
683        assert!(errors(&w).is_empty(), "{:?}", errors(&w));
684        let c = w.get::<Clip>(clip).unwrap();
685        let copy: Clip = serde_json::from_value(serde_json::to_value(c).unwrap()).unwrap();
686        assert_eq!(
687            &copy, c,
688            "includes old joint/morph ids and new targets/properties"
689        );
690        assert_eq!(copy.channels.last().unwrap().property.width(), 2);
691        let closure = w.closure([clip]);
692        assert!(closure.contains(&material));
693        let part = w.subset(&closure);
694        assert_eq!(part.get::<Clip>(clip), Some(c));
695        assert!(
696            c.remap_primitives(&[1, 0]).is_none(),
697            "clip channels are not mesh-primitive arrays"
698        );
699        assert!(errors(&part).is_empty());
700    }
701
702    #[test]
703    fn visibility_is_step_only_three_valued_and_scope_bounded() {
704        for interpolation in [Interpolation::Linear, Interpolation::Hermite] {
705            let (mut w, c, _) = with_display_channels();
706            w.get_mut::<Clip>(c).unwrap().channels[4].interpolation = interpolation;
707            assert!(errors(&w).iter().any(|e| e.contains("visibility requires Step")));
708        }
709        for value in [-2.0, 0.5, 2.0, f32::NAN] {
710            let (mut w, c, _) = with_display_channels();
711            w.get_mut::<Clip>(c).unwrap().channels[4].values[1] = value;
712            assert!(errors(&w).iter().any(|e| e.contains("visibility values")));
713        }
714        for (scope, group) in [(" ", 0), ("character", 64)] {
715            let (mut w, c, _) = with_display_channels();
716            w.get_mut::<Clip>(c).unwrap().channels[4].target = ClipTarget::DrawGroup {
717                scope: scope.into(),
718                group,
719            };
720            assert!(errors(&w).iter().any(|e| e.contains("scope")));
721        }
722    }
723
724    #[test]
725    fn material_channels_require_defaults_widths_unique_targets_and_no_overlap() {
726        for width in [0, 1, 3, 4, 5] {
727            let (mut w, c, _) = with_display_channels();
728            w.get_mut::<Clip>(c).unwrap().channels[5].property = Property::Values(width);
729            assert!(!errors(&w).is_empty(), "default width 2 does not match {width}");
730        }
731        let (mut w, _c, m) = with_display_channels();
732        w.remove::<crate::MaterialParameters>(m);
733        assert!(
734            errors(&w)
735                .iter()
736                .any(|e| e.contains("no core.material_parameters"))
737        );
738        let (mut w, c, m) = with_display_channels();
739        w.get_mut::<Clip>(c).unwrap().channels[5].target = ClipTarget::MaterialParameter {
740            materials: vec![m, m],
741            name: "tint".into(),
742        };
743        assert!(errors(&w).iter().any(|e| e.contains("repeated material")));
744        let (mut w, c, _) = with_display_channels();
745        let duplicate = w.get::<Clip>(c).unwrap().channels[5].clone();
746        w.get_mut::<Clip>(c).unwrap().channels.push(duplicate);
747        assert!(errors(&w).iter().any(|e| e.contains("second channel")));
748        let (mut w, c, _) = with_display_channels();
749        w.get_mut::<Clip>(c).unwrap().channels[5].interpolation = Interpolation::Hermite;
750        assert!(errors(&w).iter().any(|e| e.contains("Step or Linear")));
751    }
752
753    #[test]
754    fn malformed_clips_and_bindings_fail_verification() {
755        let fails = |edit: &dyn Fn(&mut World, Entity, Entity), what: &str| {
756            let (mut w, clip, binding, ..) = world();
757            edit(&mut w, clip, binding);
758            let e = errors(&w);
759            assert!(e.iter().any(|m| m.contains(what)), "{what}: {e:?}");
760        };
761        fails(
762            &|w, c, _| w.get_mut::<Clip>(c).unwrap().channels[0].times = vec![1.0, 0.0],
763            "not increasing",
764        );
765        fails(
766            &|w, c, _| w.get_mut::<Clip>(c).unwrap().channels[0].times = vec![0.0, 2.0],
767            "within [0, 1]",
768        );
769        fails(
770            &|w, c, _| {
771                w.get_mut::<Clip>(c).unwrap().channels[0].values.pop();
772            },
773            "7 values for 2 keys of 4",
774        );
775        fails(
776            &|w, c, _| w.get_mut::<Clip>(c).unwrap().channels[0].values[3] = 2.0,
777            "rotation of length",
778        );
779        fails(
780            &|w, c, _| {
781                w.get_mut::<Clip>(c).unwrap().channels[1].values.pop();
782            },
783            "values for 2 keys of 9",
784        );
785        fails(
786            &|w, c, _| w.get_mut::<Clip>(c).unwrap().channels[2].property = Property::Rotation,
787            "does not fit",
788        );
789        fails(
790            &|w, c, _| {
791                let clip = w.get_mut::<Clip>(c).unwrap();
792                let again = clip.channels[2].clone();
793                clip.channels.push(again);
794            },
795            "a second channel",
796        );
797        fails(
798            &|w, c, _| {
799                if let ClipTarget::Joint { joint, .. } = &mut w.get_mut::<Clip>(c).unwrap().channels[0].target
800                {
801                    *joint = JointId(7);
802                }
803            },
804            "not in its skeleton",
805        );
806        fails(
807            &|w, c, _| {
808                if let ClipTarget::Morph { morph, .. } = &mut w.get_mut::<Clip>(c).unwrap().channels[2].target
809                {
810                    *morph = MorphId(1);
811                }
812            },
813            "not in its set",
814        );
815        fails(
816            &|w, c, _| w.get_mut::<Clip>(c).unwrap().duration = f32::NAN,
817            "duration",
818        );
819        // an additive reference that is no pose, or a frame outside its clip
820        fails(
821            &|w, c, _| w.get_mut::<Clip>(c).unwrap().additive = Some(AdditiveReference::Pose(c)),
822            "no core.rig_pose",
823        );
824        fails(
825            &|w, c, _| {
826                w.get_mut::<Clip>(c).unwrap().additive =
827                    Some(AdditiveReference::ClipFrame { clip: c, time: 3.0 })
828            },
829            "outside its clip",
830        );
831        // additive to its own frame, or to a clip additive to it: a cycle
832        fails(
833            &|w, c, _| {
834                w.get_mut::<Clip>(c).unwrap().additive =
835                    Some(AdditiveReference::ClipFrame { clip: c, time: 0.0 })
836            },
837            "a cycle",
838        );
839        fails(
840            &|w, c, _| {
841                let mut other = w.get::<Clip>(c).unwrap().clone();
842                other.additive = Some(AdditiveReference::ClipFrame { clip: c, time: 0.0 });
843                let o = w.spawn();
844                w.insert(o, other);
845                w.get_mut::<Clip>(c).unwrap().additive =
846                    Some(AdditiveReference::ClipFrame { clip: o, time: 0.0 });
847            },
848            "a cycle",
849        );
850        // a binding without the rig its joint channels need, or listing a mesh whose morph set the clip does not drive
851        fails(
852            &|w, _, b| w.get_mut::<ClipBinding>(b).unwrap().rig = None,
853            "the rig poses None",
854        );
855        fails(
856            &|w, c, b| w.get_mut::<ClipBinding>(b).unwrap().meshes.push(c),
857            "whose morph set the clip does not drive",
858        );
859    }
860}