1use 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#[derive(Debug, Clone, PartialEq, Eq, Error)]
20#[non_exhaustive]
21pub enum AssemblyError {
22 #[error("track references source bone {bone}, but the source skeleton has {bone_count} bones")]
24 SourceBoneOutOfBounds {
25 bone: BoneId,
27 bone_count: usize,
29 },
30 #[error("source bone name {name:?} is ambiguous (bones {first} and {second})")]
32 AmbiguousSourceName {
33 name: String,
35 first: BoneId,
37 second: BoneId,
39 },
40 #[error("base bone name {name:?} is ambiguous (bones {first} and {second})")]
42 AmbiguousBaseName {
43 name: String,
45 first: BoneId,
47 second: BoneId,
49 },
50 #[error("source bone {source_bone} named {name:?} is missing from the base skeleton")]
52 MissingBaseBone {
53 source_bone: BoneId,
55 name: String,
57 },
58 #[error("bone name {name:?} is ambiguous (bones {first} and {second})")]
60 AmbiguousSelectedName {
61 name: String,
63 first: BoneId,
65 second: BoneId,
67 },
68 #[error("selected base bone {bone} is outside the base skeleton ({bone_count} bones)")]
70 SelectedBoneOutOfBounds {
71 bone: BoneId,
73 bone_count: usize,
75 },
76 #[error("selected node name {name:?} is missing from the assembled skeleton")]
78 MissingSelectedName {
79 name: String,
81 },
82 #[error("selected node name {name:?} is ambiguous (nodes {first} and {second})")]
84 AmbiguousRemovalName {
85 name: String,
87 first: BoneId,
89 second: BoneId,
91 },
92 #[error("selected node closures contain the entire assembled skeleton")]
94 EntireSkeletonSelected,
95 #[error("clip {clip_index} track {track_index} still targets selected node {bone}")]
97 RemovalTrackReference {
98 clip_index: usize,
100 track_index: usize,
102 bone: BoneId,
104 },
105 #[error("mesh instance {instance_index} is attached to selected node {bone}")]
107 RemovalMeshInstanceReference {
108 instance_index: usize,
110 bone: BoneId,
112 },
113 #[error(
115 "mesh instance {instance_index} skin joint {joint_index} references selected node {bone}"
116 )]
117 RemovalSkinJointReference {
118 instance_index: usize,
120 joint_index: usize,
122 bone: BoneId,
124 },
125 #[error("selected node {bone} carries an inverse bind and is skin-referenced")]
127 RemovalBoneInverseBindReference {
128 bone: BoneId,
130 },
131 #[error(
133 "source skin {source_skin_index} references selected source node {source_node_index} projected to node {bone}"
134 )]
135 RemovalSourceSkinReference {
136 source_skin_index: usize,
138 source_node_index: usize,
140 bone: BoneId,
142 },
143 #[error("node-removal plan does not match the assembled skeleton")]
145 RemovalPlanDocumentMismatch,
146 #[error("node-removal document is invalid: {violation}")]
148 InvalidRemovalDocument {
149 violation: DocumentShapeError,
151 },
152}
153
154#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct RemovedNode {
157 pub original_node_index: BoneId,
159 pub name: String,
161 pub original_parent_node_index: Option<BoneId>,
163 pub selected: bool,
165}
166
167#[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 #[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 #[must_use]
184 pub fn removed_nodes(&self) -> &[RemovedNode] {
185 &self.removed_nodes
186 }
187}
188
189pub 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
277pub 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
415pub struct RestPoseTrackOptions {
416 pub translation: bool,
418 pub rotation: bool,
420 pub scale: bool,
422}
423
424impl RestPoseTrackOptions {
425 pub const ALL: Self = Self {
427 translation: true,
428 rotation: true,
429 scale: true,
430 };
431}
432
433pub 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
510pub 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
551pub 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
565pub 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
637pub 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
693pub 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}