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