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("scene {scene_index} root {root_index} references missing node {bone}")]
145 RemovalSceneRootOutOfBounds {
146 scene_index: usize,
148 root_index: usize,
150 bone: BoneId,
152 },
153 #[error("node-removal plan does not match the assembled skeleton")]
155 RemovalPlanDocumentMismatch,
156 #[error("node-removal document is invalid: {violation}")]
158 InvalidRemovalDocument {
159 violation: DocumentShapeError,
161 },
162}
163
164#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct RemovedNode {
167 pub original_node_index: BoneId,
169 pub name: String,
171 pub original_parent_node_index: Option<BoneId>,
173 pub selected: bool,
175}
176
177#[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 #[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 #[must_use]
194 pub fn removed_nodes(&self) -> &[RemovedNode] {
195 &self.removed_nodes
196 }
197}
198
199pub 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
288pub 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
443pub struct RestPoseTrackOptions {
444 pub translation: bool,
446 pub rotation: bool,
448 pub scale: bool,
450}
451
452impl RestPoseTrackOptions {
453 pub const ALL: Self = Self {
455 translation: true,
456 rotation: true,
457 scale: true,
458 };
459}
460
461pub 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
538pub 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
579pub 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
593pub 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
665pub 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
721pub 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}