Skip to main content

animsmith_core/
assembly.rs

1//! Lossless, name-based clip operations for character-assembly pipelines.
2//!
3//! These helpers deliberately do not retarget motion.  They only move a clip
4//! between skeletons with the same exact bone names, remove selected channels,
5//! fill omitted channels from an authoritative rest pose, and make quaternion
6//! signs consistent.
7
8use std::collections::{BTreeMap, BTreeSet, HashMap};
9
10use glam::{Quat, Vec3};
11use thiserror::Error;
12
13use crate::model::{
14    BoneId, Clip, Document, DocumentShapeError, Interpolation, Property, Skeleton,
15    SourceSkeletonAssets, SourceSkeletonCoverage, Track, TrackValues, validate_document_shape,
16};
17
18/// Failure while resolving an exact bone name for an assembly operation.
19#[derive(Debug, Clone, PartialEq, Eq, Error)]
20#[non_exhaustive]
21pub enum AssemblyError {
22    /// A track references a source bone outside its skeleton.
23    #[error("track references source bone {bone}, but the source skeleton has {bone_count} bones")]
24    SourceBoneOutOfBounds {
25        /// Referenced source bone index.
26        bone: BoneId,
27        /// Number of source bones.
28        bone_count: usize,
29    },
30    /// More than one source bone has a referenced exact name.
31    #[error("source bone name {name:?} is ambiguous (bones {first} and {second})")]
32    AmbiguousSourceName {
33        /// Ambiguous name.
34        name: String,
35        /// First source bone with this name.
36        first: BoneId,
37        /// Another source bone with this name.
38        second: BoneId,
39    },
40    /// More than one destination bone has a referenced exact name.
41    #[error("base bone name {name:?} is ambiguous (bones {first} and {second})")]
42    AmbiguousBaseName {
43        /// Ambiguous name.
44        name: String,
45        /// First base bone with this name.
46        first: BoneId,
47        /// Another base bone with this name.
48        second: BoneId,
49    },
50    /// A referenced source-bone name is absent from the base skeleton.
51    #[error("source bone {source_bone} named {name:?} is missing from the base skeleton")]
52    MissingBaseBone {
53        /// Referenced source bone index.
54        source_bone: BoneId,
55        /// Missing exact name.
56        name: String,
57    },
58    /// A supplied named-bone selection is ambiguous in the skeleton.
59    #[error("bone name {name:?} is ambiguous (bones {first} and {second})")]
60    AmbiguousSelectedName {
61        /// Ambiguous name.
62        name: String,
63        /// First matching bone.
64        first: BoneId,
65        /// Another matching bone.
66        second: BoneId,
67    },
68    /// A caller-selected completion target is outside the base skeleton.
69    #[error("selected base bone {bone} is outside the base skeleton ({bone_count} bones)")]
70    SelectedBoneOutOfBounds {
71        /// Invalid selected bone.
72        bone: BoneId,
73        /// Number of base bones.
74        bone_count: usize,
75    },
76    /// A requested node name is absent from the assembled skeleton.
77    #[error("selected node name {name:?} is missing from the assembled skeleton")]
78    MissingSelectedName {
79        /// Missing exact node name.
80        name: String,
81    },
82    /// A requested node name occurs more than once in the assembled skeleton.
83    #[error("selected node name {name:?} is ambiguous (nodes {first} and {second})")]
84    AmbiguousRemovalName {
85        /// Ambiguous exact node name.
86        name: String,
87        /// First matching node.
88        first: BoneId,
89        /// Another matching node.
90        second: BoneId,
91    },
92    /// Removing the selected closures would leave no skeleton nodes.
93    #[error("selected node closures contain the entire assembled skeleton")]
94    EntireSkeletonSelected,
95    /// A final animation track still targets a selected closure.
96    #[error("clip {clip_index} track {track_index} still targets selected node {bone}")]
97    RemovalTrackReference {
98        /// Final clip index.
99        clip_index: usize,
100        /// Final track index.
101        track_index: usize,
102        /// Referenced node.
103        bone: BoneId,
104    },
105    /// A mesh instance is attached to a selected closure.
106    #[error("mesh instance {instance_index} is attached to selected node {bone}")]
107    RemovalMeshInstanceReference {
108        /// Mesh-instance index.
109        instance_index: usize,
110        /// Referenced node.
111        bone: BoneId,
112    },
113    /// A skin joint belongs to a selected closure.
114    #[error(
115        "mesh instance {instance_index} skin joint {joint_index} references selected node {bone}"
116    )]
117    RemovalSkinJointReference {
118        /// Mesh-instance index.
119        instance_index: usize,
120        /// Joint slot.
121        joint_index: usize,
122        /// Referenced node.
123        bone: BoneId,
124    },
125    /// A selected node carries bone-level inverse-bind evidence.
126    #[error("selected node {bone} carries an inverse bind and is skin-referenced")]
127    RemovalBoneInverseBindReference {
128        /// Referenced node.
129        bone: BoneId,
130    },
131    /// Complete source-skin evidence references a selected closure.
132    #[error(
133        "source skin {source_skin_index} references selected source node {source_node_index} projected to node {bone}"
134    )]
135    RemovalSourceSkinReference {
136        /// Stable source-skin index.
137        source_skin_index: usize,
138        /// Stable source-node index.
139        source_node_index: usize,
140        /// Referenced normalized node.
141        bone: BoneId,
142    },
143    /// A declared scene root is outside the skeleton.
144    #[error("scene {scene_index} root {root_index} references missing node {bone}")]
145    RemovalSceneRootOutOfBounds {
146        /// Scene index.
147        scene_index: usize,
148        /// Root slot within the scene.
149        root_index: usize,
150        /// Missing node index.
151        bone: BoneId,
152    },
153    /// The document no longer has the skeleton used to build a removal plan.
154    #[error("node-removal plan does not match the assembled skeleton")]
155    RemovalPlanDocumentMismatch,
156    /// The strict input or projected document shape is invalid.
157    #[error("node-removal document is invalid: {violation}")]
158    InvalidRemovalDocument {
159        /// Strict structural violation.
160        violation: DocumentShapeError,
161    },
162}
163
164/// One node in a planned subtree removal.
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct RemovedNode {
167    /// Node index before structural projection.
168    pub original_node_index: BoneId,
169    /// Exact node name before structural projection.
170    pub name: String,
171    /// Parent index before structural projection.
172    pub original_parent_node_index: Option<BoneId>,
173    /// Whether the recipe selected this node directly rather than through an ancestor.
174    pub selected: bool,
175}
176
177/// A validated, deterministic node-subtree projection plan.
178#[derive(Debug, Clone)]
179pub struct NodeSubtreeRemovalPlan {
180    skeleton_identity: Vec<(String, Option<BoneId>)>,
181    old_to_new: Vec<Option<BoneId>>,
182    removed_nodes: Vec<RemovedNode>,
183}
184
185impl NodeSubtreeRemovalPlan {
186    /// Whether `bone` belongs to a selected subtree.
187    #[must_use]
188    pub fn removes(&self, bone: BoneId) -> bool {
189        self.old_to_new.get(bone).is_some_and(Option::is_none)
190    }
191
192    /// Removed nodes in original parent-before-child order.
193    #[must_use]
194    pub fn removed_nodes(&self) -> &[RemovedNode] {
195        &self.removed_nodes
196    }
197}
198
199/// Resolve exact node selectors and their complete descendant closure.
200///
201/// The plan is non-mutating. Call [`apply_node_subtree_removal`] after all
202/// animation transforms have finished; it revalidates every live reference
203/// before committing the projection.
204///
205/// # Errors
206///
207/// Returns [`AssemblyError`] for an invalid document, missing or ambiguous
208/// selector, or a selection that contains the entire skeleton.
209pub fn plan_node_subtree_removal(
210    document: &Document,
211    names: &[String],
212) -> Result<NodeSubtreeRemovalPlan, AssemblyError> {
213    validate_document_shape(document)
214        .map_err(|violation| AssemblyError::InvalidRemovalDocument { violation })?;
215    validate_scene_roots(document)?;
216
217    let mut selected = vec![false; document.skeleton.bones.len()];
218    let mut matches = names
219        .iter()
220        .map(|name| (name.as_str(), Vec::new()))
221        .collect::<HashMap<_, _>>();
222    for (bone, candidate) in document.skeleton.bones.iter().enumerate() {
223        if let Some(matches) = matches.get_mut(candidate.name.as_str()) {
224            matches.push(bone);
225        }
226    }
227    for name in names {
228        let matches = &matches[name.as_str()];
229        let Some(&first) = matches.first() else {
230            return Err(AssemblyError::MissingSelectedName { name: name.clone() });
231        };
232        if let Some(&second) = matches.get(1) {
233            return Err(AssemblyError::AmbiguousRemovalName {
234                name: name.clone(),
235                first,
236                second,
237            });
238        }
239        selected[first] = true;
240    }
241
242    let mut removed = vec![false; document.skeleton.bones.len()];
243    for (bone, entry) in document.skeleton.bones.iter().enumerate() {
244        removed[bone] = selected[bone] || entry.parent.is_some_and(|parent| removed[parent]);
245    }
246    if !removed.is_empty() && removed.iter().all(|removed| *removed) {
247        return Err(AssemblyError::EntireSkeletonSelected);
248    }
249
250    let mut next = 0;
251    let old_to_new = removed
252        .iter()
253        .map(|removed| {
254            if *removed {
255                None
256            } else {
257                let mapped = next;
258                next += 1;
259                Some(mapped)
260            }
261        })
262        .collect();
263    let removed_nodes = document
264        .skeleton
265        .bones
266        .iter()
267        .enumerate()
268        .filter(|(bone, _)| removed[*bone])
269        .map(|(bone, entry)| RemovedNode {
270            original_node_index: bone,
271            name: entry.name.clone(),
272            original_parent_node_index: entry.parent,
273            selected: selected[bone],
274        })
275        .collect();
276    Ok(NodeSubtreeRemovalPlan {
277        skeleton_identity: document
278            .skeleton
279            .bones
280            .iter()
281            .map(|bone| (bone.name.clone(), bone.parent))
282            .collect(),
283        old_to_new,
284        removed_nodes,
285    })
286}
287
288/// Apply a planned subtree projection transactionally.
289///
290/// All final track, mesh-instance, and skin references are checked before the
291/// document is changed. Source-native skeleton projection is cleared:
292/// after authored node deletion it cannot remain a complete account of the
293/// new normalized hierarchy.
294///
295/// # Errors
296///
297/// Returns [`AssemblyError`] if the plan is stale, a forbidden reference
298/// survives, or the projected document is structurally invalid. `document`
299/// is unchanged on every error.
300pub fn apply_node_subtree_removal(
301    document: &mut Document,
302    plan: &NodeSubtreeRemovalPlan,
303) -> Result<(), AssemblyError> {
304    let identity = document
305        .skeleton
306        .bones
307        .iter()
308        .map(|bone| (bone.name.clone(), bone.parent))
309        .collect::<Vec<_>>();
310    if identity != plan.skeleton_identity {
311        return Err(AssemblyError::RemovalPlanDocumentMismatch);
312    }
313    validate_document_shape(document)
314        .map_err(|violation| AssemblyError::InvalidRemovalDocument { violation })?;
315    validate_scene_roots(document)?;
316    if plan.removed_nodes.is_empty() {
317        return Ok(());
318    }
319
320    for (clip_index, clip) in document.clips.iter().enumerate() {
321        for (track_index, track) in clip.tracks.iter().enumerate() {
322            if plan.removes(track.bone) {
323                return Err(AssemblyError::RemovalTrackReference {
324                    clip_index,
325                    track_index,
326                    bone: track.bone,
327                });
328            }
329        }
330    }
331    for (instance_index, instance) in document.assets.instances.iter().enumerate() {
332        if plan.removes(instance.node) {
333            return Err(AssemblyError::RemovalMeshInstanceReference {
334                instance_index,
335                bone: instance.node,
336            });
337        }
338        for (joint_index, &bone) in instance.skin_joints.iter().enumerate() {
339            if plan.removes(bone) {
340                return Err(AssemblyError::RemovalSkinJointReference {
341                    instance_index,
342                    joint_index,
343                    bone,
344                });
345            }
346        }
347    }
348    for (bone, entry) in document.skeleton.bones.iter().enumerate() {
349        if plan.removes(bone) && entry.inverse_bind.is_some() {
350            return Err(AssemblyError::RemovalBoneInverseBindReference { bone });
351        }
352    }
353    if document.assets.source_skeleton.coverage == SourceSkeletonCoverage::Complete {
354        let projected = document
355            .assets
356            .source_skeleton
357            .nodes
358            .iter()
359            .filter_map(|node| node.bone.map(|bone| (node.source_node_index, bone)))
360            .collect::<HashMap<_, _>>();
361        for skin in &document.assets.source_skeleton.skins {
362            let source_nodes = skin
363                .joint_source_node_indices
364                .iter()
365                .copied()
366                .chain(skin.skeleton_root_source_node_index)
367                .chain(
368                    skin.attachments
369                        .iter()
370                        .map(|attachment| attachment.source_node_index),
371                );
372            for source_node_index in source_nodes {
373                if let Some(&bone) = projected.get(&source_node_index)
374                    && plan.removes(bone)
375                {
376                    return Err(AssemblyError::RemovalSourceSkinReference {
377                        source_skin_index: skin.source_skin_index,
378                        source_node_index,
379                        bone,
380                    });
381                }
382            }
383        }
384    }
385
386    document.skeleton.bones = std::mem::take(&mut document.skeleton.bones)
387        .into_iter()
388        .enumerate()
389        .filter_map(|(bone, mut entry)| {
390            plan.old_to_new[bone]?;
391            entry.parent = entry.parent.map(|parent| {
392                plan.old_to_new[parent]
393                    .expect("a retained node cannot have a removed ancestor outside its closure")
394            });
395            Some(entry)
396        })
397        .collect();
398    for clip in &mut document.clips {
399        for track in &mut clip.tracks {
400            track.bone = plan.old_to_new[track.bone]
401                .expect("live track references were rejected before projection");
402        }
403    }
404    for instance in &mut document.assets.instances {
405        instance.node = plan.old_to_new[instance.node]
406            .expect("live instance references were rejected before projection");
407        for bone in &mut instance.skin_joints {
408            *bone = plan.old_to_new[*bone]
409                .expect("live joint references were rejected before projection");
410        }
411    }
412    for scene in &mut document.assets.scenes {
413        scene.roots = scene
414            .roots
415            .iter()
416            .filter_map(|&root| plan.old_to_new[root])
417            .collect();
418    }
419    document.assets.source_skeleton = SourceSkeletonAssets::default();
420    debug_assert!(validate_document_shape(document).is_ok());
421    Ok(())
422}
423
424fn validate_scene_roots(document: &Document) -> Result<(), AssemblyError> {
425    let bone_count = document.skeleton.bones.len();
426    for (scene_index, scene) in document.assets.scenes.iter().enumerate() {
427        for (root_index, &bone) in scene.roots.iter().enumerate() {
428            if bone >= bone_count {
429                return Err(AssemblyError::RemovalSceneRootOutOfBounds {
430                    scene_index,
431                    root_index,
432                    bone,
433                });
434            }
435        }
436    }
437    Ok(())
438}
439
440/// Selects which absent local-transform channels [`complete_rest_pose_tracks`]
441/// creates.
442#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
443pub struct RestPoseTrackOptions {
444    /// Complete absent translation channels.
445    pub translation: bool,
446    /// Complete absent rotation channels.
447    pub rotation: bool,
448    /// Complete absent scale channels.
449    pub scale: bool,
450}
451
452impl RestPoseTrackOptions {
453    /// Select all three local-transform channels.
454    pub const ALL: Self = Self {
455        translation: true,
456        rotation: true,
457        scale: true,
458    };
459}
460
461/// Remap a source clip onto `base` using exact, unique bone names.
462///
463/// The returned clip has the same keys, values, interpolation, name, and
464/// duration as `source`; only each track's bone index changes.  Every source
465/// bone referenced by a track and its base counterpart must have a unique,
466/// exact name.  Names outside the referenced set are intentionally irrelevant.
467///
468/// # Errors
469///
470/// Returns [`AssemblyError`] if a track references an invalid source bone, or
471/// if a referenced source name is ambiguous or missing in `base`.
472pub fn remap_clip_to_base(
473    source: &Clip,
474    source_skeleton: &Skeleton,
475    base: &Skeleton,
476) -> Result<Clip, AssemblyError> {
477    let referenced: BTreeSet<BoneId> = source.tracks.iter().map(|track| track.bone).collect();
478    let mut referenced_names = BTreeSet::new();
479    for &bone in &referenced {
480        let Some(source_bone) = source_skeleton.bones.get(bone) else {
481            return Err(AssemblyError::SourceBoneOutOfBounds {
482                bone,
483                bone_count: source_skeleton.bones.len(),
484            });
485        };
486        referenced_names.insert(source_bone.name.as_str());
487    }
488    let mut source_names = BTreeMap::new();
489    for (bone, source_bone) in source_skeleton.bones.iter().enumerate() {
490        if !referenced_names.contains(source_bone.name.as_str()) {
491            continue;
492        }
493        if let Some(first) = source_names.insert(source_bone.name.as_str(), bone) {
494            return Err(AssemblyError::AmbiguousSourceName {
495                name: source_bone.name.clone(),
496                first,
497                second: bone,
498            });
499        }
500    }
501
502    let mut base_names = BTreeMap::new();
503    for (bone, base_bone) in base.bones.iter().enumerate() {
504        if !referenced_names.contains(base_bone.name.as_str()) {
505            continue;
506        }
507        if let Some(first) = base_names.insert(base_bone.name.as_str(), bone) {
508            return Err(AssemblyError::AmbiguousBaseName {
509                name: base_bone.name.clone(),
510                first,
511                second: bone,
512            });
513        }
514    }
515
516    let remapped: BTreeMap<BoneId, BoneId> = referenced
517        .iter()
518        .map(|&source_bone| {
519            let name = source_skeleton.bones[source_bone].name.as_str();
520            base_names
521                .get(name)
522                .copied()
523                .map(|base_bone| (source_bone, base_bone))
524                .ok_or_else(|| AssemblyError::MissingBaseBone {
525                    source_bone,
526                    name: name.to_owned(),
527                })
528        })
529        .collect::<Result<_, _>>()?;
530
531    let mut output = source.clone();
532    for track in &mut output.tracks {
533        track.bone = remapped[&track.bone];
534    }
535    Ok(output)
536}
537
538/// Remove every track targeting any exact, uniquely named `bones` entry.
539///
540/// Unknown names select no tracks.  A duplicated selected name is rejected so
541/// a caller cannot silently strip motion from an unintended bone.
542///
543/// # Errors
544///
545/// Returns [`AssemblyError::AmbiguousSelectedName`] if a requested name occurs
546/// more than once in `skeleton`.
547pub fn strip_named_bone_tracks(
548    clip: &mut Clip,
549    skeleton: &Skeleton,
550    bones: impl IntoIterator<Item = impl AsRef<str>>,
551) -> Result<usize, AssemblyError> {
552    let requested: BTreeSet<String> = bones
553        .into_iter()
554        .map(|name| name.as_ref().to_owned())
555        .collect();
556    let mut selected = BTreeSet::new();
557    for name in requested {
558        let matches: Vec<_> = skeleton
559            .bones
560            .iter()
561            .enumerate()
562            .filter_map(|(id, bone)| (bone.name == name).then_some(id))
563            .collect();
564        if let [bone] = matches.as_slice() {
565            selected.insert(*bone);
566        } else if let [first, second, ..] = matches.as_slice() {
567            return Err(AssemblyError::AmbiguousSelectedName {
568                name,
569                first: *first,
570                second: *second,
571            });
572        }
573    }
574    let before = clip.tracks.len();
575    clip.tracks.retain(|track| !selected.contains(&track.bone));
576    Ok(before - clip.tracks.len())
577}
578
579/// Add one rest-pose key at time zero for selected channels that a clip omits.
580///
581/// Existing tracks, including empty ones, are never altered.  The created
582/// tracks use linear interpolation; one key is a constant hold under the core
583/// sampler.  Tracks are appended in skeleton order, then translation,
584/// rotation, scale order for deterministic output.
585pub fn complete_rest_pose_tracks(
586    clip: &mut Clip,
587    base: &Skeleton,
588    options: RestPoseTrackOptions,
589) -> Result<usize, AssemblyError> {
590    complete_rest_pose_tracks_for_bones(clip, base, 0..base.bones.len(), options)
591}
592
593/// Add absent rest-pose channels for an explicit base-bone selection.
594///
595/// Bone ids are sorted and deduplicated before tracks are appended, so caller
596/// ordering cannot change the output. Existing tracks are never altered.
597///
598/// # Errors
599///
600/// Returns [`AssemblyError::SelectedBoneOutOfBounds`] when a selected id is not
601/// present in `base`.
602pub fn complete_rest_pose_tracks_for_bones(
603    clip: &mut Clip,
604    base: &Skeleton,
605    bones: impl IntoIterator<Item = BoneId>,
606    options: RestPoseTrackOptions,
607) -> Result<usize, AssemblyError> {
608    let properties = [
609        (Property::Translation, options.translation),
610        (Property::Rotation, options.rotation),
611        (Property::Scale, options.scale),
612    ];
613    let mut added = 0;
614    let bones = bones.into_iter().collect::<BTreeSet<_>>();
615    for bone in bones {
616        let Some(base_bone) = base.bones.get(bone) else {
617            return Err(AssemblyError::SelectedBoneOutOfBounds {
618                bone,
619                bone_count: base.bones.len(),
620            });
621        };
622        for (property, enabled) in properties {
623            if !enabled
624                || clip
625                    .tracks
626                    .iter()
627                    .any(|track| track.bone == bone && track.property == property)
628            {
629                continue;
630            }
631            clip.tracks.push(rest_track(
632                bone,
633                property,
634                base_bone.rest.translation,
635                base_bone.rest.rotation,
636                base_bone.rest.scale,
637            ));
638            added += 1;
639        }
640    }
641    Ok(added)
642}
643
644fn rest_track(
645    bone: BoneId,
646    property: Property,
647    translation: Vec3,
648    rotation: Quat,
649    scale: Vec3,
650) -> Track {
651    let values = match property {
652        Property::Translation => TrackValues::Vec3s(vec![translation]),
653        Property::Rotation => TrackValues::Quats(vec![rotation]),
654        Property::Scale => TrackValues::Vec3s(vec![scale]),
655    };
656    Track {
657        bone,
658        property,
659        interpolation: Interpolation::Linear,
660        times: vec![0.0],
661        values,
662    }
663}
664
665/// Make rotation-track key quaternions hemisphere-consistent in key order.
666///
667/// Each finite key is compared to the preceding finite key.  If their dot
668/// product is negative, the key is negated.  Cubic-spline keys negate their
669/// whole tangent/value/tangent triplet, preserving the represented curve.
670/// A zero dot product is deliberately left unchanged, making ties stable and
671/// avoiding platform-dependent sign choices.  Returns the number of flipped
672/// keys.
673pub fn normalize_quaternion_hemispheres(clip: &mut Clip) -> usize {
674    let mut flipped = 0;
675    for track in &mut clip.tracks {
676        if track.property != Property::Rotation {
677            continue;
678        }
679        let key_count = track.key_count();
680        let interpolation = track.interpolation;
681        let TrackValues::Quats(values) = &mut track.values else {
682            continue;
683        };
684        let mut previous = None;
685        for key in 0..key_count {
686            let value_index = match interpolation {
687                Interpolation::CubicSpline => key * 3 + 1,
688                _ => key,
689            };
690            let Some(value) = values.get(value_index).copied() else {
691                break;
692            };
693            if !value.is_finite() {
694                continue;
695            }
696            if previous.is_some_and(|previous: Quat| previous.dot(value) < 0.0) {
697                let start = match interpolation {
698                    Interpolation::CubicSpline => key * 3,
699                    _ => key,
700                };
701                let count = match interpolation {
702                    Interpolation::CubicSpline => 3,
703                    _ => 1,
704                };
705                if start + count > values.len() {
706                    break;
707                }
708                for quaternion in &mut values[start..start + count] {
709                    *quaternion = -*quaternion;
710                }
711                previous = Some(-value);
712                flipped += 1;
713            } else {
714                previous = Some(value);
715            }
716        }
717    }
718    flipped
719}
720
721/// Remove one final key from every nonempty track and shorten the clip to the
722/// latest remaining key.
723///
724/// This is useful for converting a duplicate-endpoint loop to an open loop.
725/// Empty tracks are removed.  If every track becomes empty, the duration is
726/// zero.  Cubic-spline tangent/value/tangent triplets are removed together.
727/// Returns the number of keys removed.
728pub fn remove_final_keys(clip: &mut Clip) -> usize {
729    let mut removed = 0;
730    for track in &mut clip.tracks {
731        if track.times.pop().is_none() {
732            continue;
733        }
734        removed += 1;
735        let count = match track.interpolation {
736            Interpolation::CubicSpline => 3,
737            _ => 1,
738        };
739        match &mut track.values {
740            TrackValues::Vec3s(values) => values.truncate(values.len().saturating_sub(count)),
741            TrackValues::Quats(values) => values.truncate(values.len().saturating_sub(count)),
742        }
743    }
744    clip.tracks.retain(|track| !track.times.is_empty());
745    clip.duration_s = clip
746        .tracks
747        .iter()
748        .map(|track| track.end_time() as f64)
749        .fold(0.0, f64::max);
750    removed
751}
752
753#[cfg(test)]
754mod node_subtree_removal_tests {
755    use super::*;
756    use crate::model::{
757        Bone, MaterialAsset, MeshAsset, MeshInstance, SceneAsset, SceneAssets, SourceNodeAsset,
758        SourceNodeLocalRest, SourceSkinAsset, TextureAsset, Transform,
759    };
760
761    fn bone(name: &str, parent: Option<BoneId>) -> Bone {
762        Bone {
763            name: name.into(),
764            parent,
765            rest: Transform::IDENTITY,
766            inverse_bind: None,
767        }
768    }
769
770    fn source_node(
771        source_node_index: usize,
772        bone: BoneId,
773        parent: Option<usize>,
774    ) -> SourceNodeAsset {
775        let mut node = SourceNodeAsset::new(
776            source_node_index,
777            SourceNodeLocalRest::Trs {
778                translation: Vec3::ZERO,
779                rotation: Quat::IDENTITY,
780                scale: Vec3::ONE,
781            },
782        );
783        node.parent_source_node_index = parent;
784        node.bone = Some(bone);
785        node
786    }
787
788    fn fixture() -> Document {
789        let bones = vec![
790            bone("kept-root-a", None),
791            bone("prop-root", None),
792            bone("prop-child", Some(1)),
793            bone("kept-root-b", None),
794            bone("kept-child", Some(3)),
795        ];
796        let source_skeleton = SourceSkeletonAssets {
797            coverage: SourceSkeletonCoverage::Complete,
798            nodes: vec![
799                source_node(100, 0, None),
800                source_node(101, 1, None),
801                source_node(102, 2, Some(101)),
802                source_node(103, 3, None),
803                source_node(104, 4, Some(103)),
804            ],
805            skins: vec![SourceSkinAsset {
806                source_skin_index: 7,
807                skeleton_root_source_node_index: Some(103),
808                joint_source_node_indices: vec![100, 104],
809                ..SourceSkinAsset::default()
810            }],
811        };
812        Document {
813            skeleton: Skeleton { bones },
814            clips: vec![Clip {
815                name: "walk".into(),
816                duration_s: 1.0,
817                tracks: vec![Track {
818                    bone: 4,
819                    property: Property::Translation,
820                    interpolation: Interpolation::Linear,
821                    times: vec![0.0],
822                    values: TrackValues::Vec3s(vec![Vec3::ZERO]),
823                }],
824            }],
825            assets: SceneAssets {
826                meshes: vec![MeshAsset {
827                    name: "kept-mesh".into(),
828                    ..MeshAsset::default()
829                }],
830                materials: vec![MaterialAsset {
831                    name: "preexisting-orphan-material".into(),
832                    base_color: [1.0; 4],
833                    metallic: 0.0,
834                    roughness: 1.0,
835                    base_color_texture: Some(TextureAsset {
836                        bytes: vec![1, 2, 3, 4],
837                        mime: "image/png".into(),
838                    }),
839                    normal_texture: None,
840                    metallic_roughness_texture: None,
841                    occlusion_texture: None,
842                }],
843                instances: vec![MeshInstance {
844                    source_node_index: 103,
845                    node: 3,
846                    mesh: 0,
847                    skin_joints: vec![0, 4],
848                    ..MeshInstance::default()
849                }],
850                scenes: vec![SceneAsset {
851                    source_scene_index: 4,
852                    name: Some("scene".into()),
853                    roots: vec![0, 1, 3],
854                }],
855                source_skeleton,
856                ..SceneAssets::default()
857            },
858            ..Document::default()
859        }
860    }
861
862    fn plan_prop(document: &Document) -> NodeSubtreeRemovalPlan {
863        plan_node_subtree_removal(document, &["prop-root".into()]).expect("plan prop subtree")
864    }
865
866    fn assert_unchanged(document: &Document, before: &str) {
867        assert_eq!(format!("{document:?}"), before);
868    }
869
870    #[test]
871    fn plan_resolves_exact_unique_names_and_marks_complete_closure() {
872        let document = fixture();
873        let plan = plan_node_subtree_removal(&document, &["prop-root".into(), "prop-child".into()])
874            .unwrap();
875
876        assert!(!plan.removes(0));
877        assert!(plan.removes(1));
878        assert!(plan.removes(2));
879        assert!(!plan.removes(3));
880        assert!(!plan.removes(99));
881        assert_eq!(
882            plan.removed_nodes(),
883            [
884                RemovedNode {
885                    original_node_index: 1,
886                    name: "prop-root".into(),
887                    original_parent_node_index: None,
888                    selected: true,
889                },
890                RemovedNode {
891                    original_node_index: 2,
892                    name: "prop-child".into(),
893                    original_parent_node_index: Some(1),
894                    selected: true,
895                },
896            ]
897        );
898        let reversed =
899            plan_node_subtree_removal(&document, &["prop-child".into(), "prop-root".into()])
900                .unwrap();
901        assert_eq!(reversed.removed_nodes(), plan.removed_nodes());
902    }
903
904    #[test]
905    fn plan_rejects_missing_ambiguous_and_entire_skeleton_selections() {
906        let document = fixture();
907        assert_eq!(
908            plan_node_subtree_removal(&document, &["Prop-Root".into()]).unwrap_err(),
909            AssemblyError::MissingSelectedName {
910                name: "Prop-Root".into()
911            }
912        );
913
914        let mut ambiguous = document.clone();
915        ambiguous.skeleton.bones[3].name = "prop-root".into();
916        assert_eq!(
917            plan_node_subtree_removal(&ambiguous, &["prop-root".into()]).unwrap_err(),
918            AssemblyError::AmbiguousRemovalName {
919                name: "prop-root".into(),
920                first: 1,
921                second: 3,
922            }
923        );
924
925        assert_eq!(
926            plan_node_subtree_removal(
927                &document,
928                &[
929                    "kept-root-a".into(),
930                    "prop-root".into(),
931                    "kept-root-b".into()
932                ]
933            )
934            .unwrap_err(),
935            AssemblyError::EntireSkeletonSelected
936        );
937    }
938
939    #[test]
940    fn apply_stably_remaps_every_normalized_bone_reference_and_clears_source_projection() {
941        let mut document = fixture();
942        let retained_inverse_bind = glam::Mat4::from_translation(Vec3::new(7.0, 8.0, 9.0));
943        let retained_material = format!("{:?}", document.assets.materials[0]);
944        document.skeleton.bones[4].inverse_bind = Some(retained_inverse_bind);
945        let plan = plan_prop(&document);
946
947        apply_node_subtree_removal(&mut document, &plan).unwrap();
948
949        assert_eq!(
950            document
951                .skeleton
952                .bones
953                .iter()
954                .map(|bone| (bone.name.as_str(), bone.parent))
955                .collect::<Vec<_>>(),
956            [
957                ("kept-root-a", None),
958                ("kept-root-b", None),
959                ("kept-child", Some(1)),
960            ]
961        );
962        assert_eq!(document.clips[0].tracks[0].bone, 2);
963        assert_eq!(
964            document.skeleton.bones[2].inverse_bind,
965            Some(retained_inverse_bind)
966        );
967        assert_eq!(document.assets.instances[0].node, 1);
968        assert_eq!(document.assets.instances[0].skin_joints, [0, 2]);
969        assert_eq!(document.assets.scenes[0].roots, [0, 1]);
970        assert_eq!(document.assets.meshes[0].name, "kept-mesh");
971        assert_eq!(document.assets.materials.len(), 1);
972        assert_eq!(
973            format!("{:?}", document.assets.materials[0]),
974            retained_material
975        );
976        assert!(
977            document.assets.source_skeleton.coverage == SourceSkeletonCoverage::Unavailable
978                && document.assets.source_skeleton.nodes.is_empty()
979                && document.assets.source_skeleton.skins.is_empty()
980        );
981        validate_document_shape(&document).unwrap();
982    }
983
984    #[test]
985    fn apply_refuses_surviving_track_target_transactionally() {
986        let mut document = fixture();
987        let plan = plan_prop(&document);
988        document.clips[0].tracks[0].bone = 2;
989        let before = format!("{document:?}");
990
991        assert_eq!(
992            apply_node_subtree_removal(&mut document, &plan).unwrap_err(),
993            AssemblyError::RemovalTrackReference {
994                clip_index: 0,
995                track_index: 0,
996                bone: 2,
997            }
998        );
999        assert_unchanged(&document, &before);
1000    }
1001
1002    #[test]
1003    fn apply_refuses_mesh_attachment_and_skin_joint_transactionally() {
1004        let mut attached = fixture();
1005        let plan = plan_prop(&attached);
1006        attached.assets.instances[0].node = 2;
1007        let before = format!("{attached:?}");
1008        assert_eq!(
1009            apply_node_subtree_removal(&mut attached, &plan).unwrap_err(),
1010            AssemblyError::RemovalMeshInstanceReference {
1011                instance_index: 0,
1012                bone: 2,
1013            }
1014        );
1015        assert_unchanged(&attached, &before);
1016
1017        let mut skinned = fixture();
1018        let plan = plan_prop(&skinned);
1019        skinned.assets.instances[0].skin_joints[1] = 2;
1020        let before = format!("{skinned:?}");
1021        assert_eq!(
1022            apply_node_subtree_removal(&mut skinned, &plan).unwrap_err(),
1023            AssemblyError::RemovalSkinJointReference {
1024                instance_index: 0,
1025                joint_index: 1,
1026                bone: 2,
1027            }
1028        );
1029        assert_unchanged(&skinned, &before);
1030    }
1031
1032    #[test]
1033    fn apply_refuses_every_complete_source_skin_reference() {
1034        for reference in ["joint", "root", "attachment"] {
1035            let mut document = fixture();
1036            let plan = plan_prop(&document);
1037            let skin = &mut document.assets.source_skeleton.skins[0];
1038            let source_node_index = match reference {
1039                "joint" => {
1040                    skin.joint_source_node_indices.push(102);
1041                    102
1042                }
1043                "root" => {
1044                    skin.skeleton_root_source_node_index = Some(101);
1045                    101
1046                }
1047                "attachment" => {
1048                    skin.attachments.push(crate::model::SourceSkinAttachment {
1049                        source_node_index: 102,
1050                        source_mesh_index: None,
1051                    });
1052                    102
1053                }
1054                _ => unreachable!(),
1055            };
1056            let before = format!("{document:?}");
1057
1058            assert_eq!(
1059                apply_node_subtree_removal(&mut document, &plan).unwrap_err(),
1060                AssemblyError::RemovalSourceSkinReference {
1061                    source_skin_index: 7,
1062                    source_node_index,
1063                    bone: if source_node_index == 101 { 1 } else { 2 },
1064                }
1065            );
1066            assert_unchanged(&document, &before);
1067        }
1068    }
1069
1070    #[test]
1071    fn apply_refuses_bone_inverse_bind_without_an_instance_or_source_projection() {
1072        let mut document = fixture();
1073        document.assets.instances.clear();
1074        document.assets.source_skeleton = SourceSkeletonAssets::default();
1075        document.skeleton.bones[2].inverse_bind = Some(glam::Mat4::IDENTITY);
1076        let plan = plan_prop(&document);
1077        let before = format!("{document:?}");
1078
1079        assert_eq!(
1080            apply_node_subtree_removal(&mut document, &plan).unwrap_err(),
1081            AssemblyError::RemovalBoneInverseBindReference { bone: 2 }
1082        );
1083        assert_unchanged(&document, &before);
1084    }
1085
1086    #[test]
1087    fn apply_checks_every_clip_and_property_for_surviving_track_targets() {
1088        for (property, values) in [
1089            (Property::Scale, TrackValues::Vec3s(vec![Vec3::ONE])),
1090            (
1091                Property::Rotation,
1092                TrackValues::Quats(vec![glam::Quat::IDENTITY]),
1093            ),
1094        ] {
1095            let mut document = fixture();
1096            document.clips.push(Clip {
1097                name: "second".into(),
1098                duration_s: 0.0,
1099                tracks: vec![Track {
1100                    bone: 2,
1101                    property,
1102                    interpolation: Interpolation::Linear,
1103                    times: vec![0.0],
1104                    values,
1105                }],
1106            });
1107            let plan = plan_prop(&document);
1108            let before = format!("{document:?}");
1109            assert_eq!(
1110                apply_node_subtree_removal(&mut document, &plan).unwrap_err(),
1111                AssemblyError::RemovalTrackReference {
1112                    clip_index: 1,
1113                    track_index: 0,
1114                    bone: 2,
1115                },
1116                "property {property:?}"
1117            );
1118            assert_unchanged(&document, &before);
1119        }
1120    }
1121
1122    #[test]
1123    fn apply_revalidates_plan_identity_and_structural_references() {
1124        let mut renamed = fixture();
1125        let plan = plan_prop(&renamed);
1126        renamed.skeleton.bones[4].name = "changed-after-planning".into();
1127        let before = format!("{renamed:?}");
1128        assert_eq!(
1129            apply_node_subtree_removal(&mut renamed, &plan).unwrap_err(),
1130            AssemblyError::RemovalPlanDocumentMismatch
1131        );
1132        assert_unchanged(&renamed, &before);
1133
1134        let mut invalid_scene = fixture();
1135        let plan = plan_prop(&invalid_scene);
1136        invalid_scene.assets.scenes[0].roots[2] = 99;
1137        let before = format!("{invalid_scene:?}");
1138        assert_eq!(
1139            apply_node_subtree_removal(&mut invalid_scene, &plan).unwrap_err(),
1140            AssemblyError::RemovalSceneRootOutOfBounds {
1141                scene_index: 0,
1142                root_index: 2,
1143                bone: 99,
1144            }
1145        );
1146        assert_unchanged(&invalid_scene, &before);
1147    }
1148
1149    #[test]
1150    fn empty_plan_is_a_true_no_op_and_invalid_inputs_fail_closed() {
1151        let mut document = fixture();
1152        let before = format!("{document:?}");
1153        let plan = plan_node_subtree_removal(&document, &[]).unwrap();
1154        assert!(plan.removed_nodes().is_empty());
1155        apply_node_subtree_removal(&mut document, &plan).unwrap();
1156        assert_unchanged(&document, &before);
1157
1158        let mut malformed = fixture();
1159        malformed.skeleton.bones[4].parent = Some(4);
1160        assert!(matches!(
1161            plan_node_subtree_removal(&malformed, &["prop-root".into()]),
1162            Err(AssemblyError::InvalidRemovalDocument { .. })
1163        ));
1164    }
1165}