1use crate::canonical::facts_identity;
2use crate::{
3 AnimationAddressability, ConversionControl, CoordinateBasis, DefaultStatus, EngineProfile,
4 EngineProfileV2, FactId, FactState, FactValue, ForwardAxis, Handedness, LinearUnit,
5 PrimarySource, PrimarySourceV2, ProfileFact, ProfileSelection, RegistryValidationError,
6 RootMotionAddressability, SettingApplicability, SettingDefaultV2, SettingDescriptor,
7 SettingDescriptorV2, SettingDomain, SettingDomainV2, SettingId, SettingIdV2, SettingScope,
8 SettingValueV2, TargetAddressability, UpAxis,
9};
10use animsmith_core::{InputIdentity, SourceFormatV1};
11use std::collections::BTreeSet;
12use std::sync::OnceLock;
13
14const VERIFIED_ON: &str = "2026-08-20";
15const UNITY_GENERIC_V2_VERIFIED_ON: &str = "2026-08-26";
16const IMPORT_ADVICE_V2_VERIFIED_ON: &str = "2026-08-26";
17const BEVY_V2_VERIFIED_ON: &str = "2026-08-25";
18const BEVY_0_19_COMMIT: &str = "c6f634ca9f406d68ba5109d921247b654cb42c10";
19const ALL_FACT_IDS: [FactId; 14] = [
20 FactId::AcceptedInputs,
21 FactId::AnimationAddressability,
22 FactId::TargetCoordinateBasis,
23 FactId::TargetLinearUnit,
24 FactId::UnitConversionControl,
25 FactId::AxisConversionControl,
26 FactId::ExactAxisConversion,
27 FactId::ResultingHierarchyScale,
28 FactId::WholeEndFrameRequired,
29 FactId::AnimationChannelHandling,
30 FactId::ExtensionHandling,
31 FactId::ConstructHandling,
32 FactId::AnimationTargetAddressability,
33 FactId::RootMotionAddressability,
34];
35
36pub fn profiles_v1() -> &'static [EngineProfile] {
38 static PROFILES: OnceLock<Vec<EngineProfile>> = OnceLock::new();
39 PROFILES.get_or_init(|| {
40 let mut profiles = vec![unity_generic(), unity_humanoid(), unreal(), godot(), bevy()];
41 profiles.sort_by(|left, right| left.selection.cmp(&right.selection));
42 profiles
43 })
44}
45
46pub fn profiles_v2() -> &'static [EngineProfileV2] {
51 static PROFILES: OnceLock<Vec<EngineProfileV2>> = OnceLock::new();
52 PROFILES.get_or_init(|| {
53 let mut profiles = vec![
54 unity_generic_v2(),
55 unreal_v2(),
56 godot_v2(),
57 bevy_v2(),
58 bevy_v3(),
59 ];
60 profiles.sort_by(|left, right| left.selection().cmp(right.selection()));
61 profiles
62 })
63}
64
65pub fn validate_registry_v2() -> Result<(), RegistryValidationErrorV2> {
71 use animsmith_core::engine_contract::{EngineFactIdV2, EngineFactStateV2};
72 const FACT_IDS: [EngineFactIdV2; 10] = [
73 EngineFactIdV2::AcceptedInputs,
74 EngineFactIdV2::ApplicationWorldUnitPolicy,
75 EngineFactIdV2::ImporterScaleConversion,
76 EngineFactIdV2::ImportSettingProjection,
77 EngineFactIdV2::PhysicalDimensionsPreserved,
78 EngineFactIdV2::ResultingTransformScale,
79 EngineFactIdV2::RootMotionAddressability,
80 EngineFactIdV2::SourceImportDisposition,
81 EngineFactIdV2::SourceToTargetUnitMapping,
82 EngineFactIdV2::TargetLinearUnit,
83 ];
84 let profiles = profiles_v2();
85 if profiles.len() != 5 {
86 return Err(RegistryValidationErrorV2::ProfileCount {
87 found: profiles.len(),
88 });
89 }
90 let mut selections = BTreeSet::new();
91 let mut urns = BTreeSet::new();
92 for profile in profiles {
93 if !selections.insert(profile.selection().clone()) {
94 return Err(RegistryValidationErrorV2::DuplicateSelection {
95 selection: profile.selection().clone(),
96 });
97 }
98 if !urns.insert(profile.profile_urn()) {
99 return Err(RegistryValidationErrorV2::DuplicateProfileUrn {
100 urn: profile.profile_urn(),
101 });
102 }
103 if profile.accepted_inputs().is_empty()
104 || !profile.accepted_inputs().windows(2).all(|pair| {
105 crate::canonical::format_name(pair[0]) < crate::canonical::format_name(pair[1])
106 })
107 {
108 return Err(RegistryValidationErrorV2::InvalidAcceptedInputs {
109 selection: profile.selection().clone(),
110 });
111 }
112 let fact_ids: BTreeSet<_> = profile.facts().iter().map(|fact| fact.id()).collect();
113 if fact_ids.len() != profile.facts().len()
114 || fact_ids != FACT_IDS.into_iter().collect::<BTreeSet<_>>()
115 {
116 return Err(RegistryValidationErrorV2::InvalidFactInventory {
117 selection: profile.selection().clone(),
118 });
119 }
120
121 let setting_ids: BTreeSet<_> = profile
122 .setting_descriptors()
123 .iter()
124 .map(SettingDescriptorV2::id)
125 .collect();
126 if setting_ids.len() != profile.setting_descriptors().len() {
127 return Err(RegistryValidationErrorV2::DuplicateSettingDescriptor {
128 selection: profile.selection().clone(),
129 });
130 }
131 for descriptor in profile.setting_descriptors() {
132 if let SettingDefaultV2::Verified(value) = descriptor.default()
133 && !value.matches_domain(descriptor.domain())
134 {
135 return Err(RegistryValidationErrorV2::InvalidVerifiedDefault {
136 selection: profile.selection().clone(),
137 setting: descriptor.id(),
138 });
139 }
140 }
141
142 let mut source_ids = BTreeSet::new();
143 let mut supported_settings = BTreeSet::new();
144 let mut supported_facts = BTreeSet::new();
145 let mut accepted_inputs_supported = false;
146 for source in profile.sources() {
147 if !source_ids.insert(source.id()) {
148 return Err(RegistryValidationErrorV2::DuplicateSourceId {
149 selection: profile.selection().clone(),
150 source_id: source.id(),
151 });
152 }
153 accepted_inputs_supported |= source.supports_accepted_inputs();
154 for fact in source.supported_facts() {
155 let state = profile
156 .facts()
157 .iter()
158 .find(|row| row.id() == *fact)
159 .map(|row| row.state());
160 if !matches!(state, Some(EngineFactStateV2::Known(_))) {
161 return Err(RegistryValidationErrorV2::UnknownSourceFact {
162 selection: profile.selection().clone(),
163 source_id: source.id(),
164 fact: *fact,
165 });
166 }
167 supported_facts.insert(*fact);
168 }
169 for setting in source.supported_settings() {
170 if !setting_ids.contains(setting) {
171 return Err(RegistryValidationErrorV2::UnknownSourceSetting {
172 selection: profile.selection().clone(),
173 source_id: source.id(),
174 setting: *setting,
175 });
176 }
177 supported_settings.insert(*setting);
178 }
179 }
180 if !accepted_inputs_supported {
181 return Err(RegistryValidationErrorV2::UnreferencedAcceptedInputs {
182 selection: profile.selection().clone(),
183 });
184 }
185 if let Some(fact) = profile
186 .facts()
187 .iter()
188 .filter(|row| matches!(row.state(), EngineFactStateV2::Known(_)))
189 .map(|row| row.id())
190 .find(|fact| !supported_facts.contains(fact))
191 {
192 return Err(RegistryValidationErrorV2::UnreferencedFact {
193 selection: profile.selection().clone(),
194 fact,
195 });
196 }
197 if let Some(setting) = setting_ids.difference(&supported_settings).next() {
198 return Err(RegistryValidationErrorV2::UnreferencedSetting {
199 selection: profile.selection().clone(),
200 setting: *setting,
201 });
202 }
203 }
204 Ok(())
205}
206
207#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
209#[non_exhaustive]
210pub enum RegistryValidationErrorV2 {
211 #[error("V2 registry contains {found} profiles rather than the authorized set")]
213 ProfileCount {
214 found: usize,
216 },
217 #[error("duplicate V2 profile selection {selection:?}")]
219 DuplicateSelection {
220 selection: ProfileSelection,
222 },
223 #[error("duplicate V2 profile URN {urn}")]
225 DuplicateProfileUrn {
226 urn: &'static str,
228 },
229 #[error("invalid V2 accepted inputs for {selection:?}")]
231 InvalidAcceptedInputs {
232 selection: ProfileSelection,
234 },
235 #[error("invalid V2 fact inventory for {selection:?}")]
237 InvalidFactInventory {
238 selection: ProfileSelection,
240 },
241 #[error("duplicate V2 setting descriptor for {selection:?}")]
243 DuplicateSettingDescriptor {
244 selection: ProfileSelection,
246 },
247 #[error("invalid V2 verified default for {setting:?} in {selection:?}")]
249 InvalidVerifiedDefault {
250 selection: ProfileSelection,
252 setting: SettingIdV2,
254 },
255 #[error("duplicate V2 source id {source_id} in {selection:?}")]
257 DuplicateSourceId {
258 selection: ProfileSelection,
260 source_id: &'static str,
262 },
263 #[error("V2 source {source_id} references unknown setting {setting:?} in {selection:?}")]
265 UnknownSourceSetting {
266 selection: ProfileSelection,
268 source_id: &'static str,
270 setting: SettingIdV2,
272 },
273 #[error("V2 source {source_id} references unavailable fact {fact:?} in {selection:?}")]
275 UnknownSourceFact {
276 selection: ProfileSelection,
278 source_id: &'static str,
280 fact: animsmith_core::engine_contract::EngineFactIdV2,
282 },
283 #[error("V2 accepted inputs have no source reference in {selection:?}")]
285 UnreferencedAcceptedInputs {
286 selection: ProfileSelection,
288 },
289 #[error("V2 fact {fact:?} has no source reference in {selection:?}")]
291 UnreferencedFact {
292 selection: ProfileSelection,
294 fact: animsmith_core::engine_contract::EngineFactIdV2,
296 },
297 #[error("V2 setting {setting:?} has no source reference in {selection:?}")]
299 UnreferencedSetting {
300 selection: ProfileSelection,
302 setting: SettingIdV2,
304 },
305}
306
307pub fn validate_registry_v1() -> Result<(), RegistryValidationError> {
317 validate_profiles(profiles_v1())
318}
319
320fn validate_profiles(profiles: &[EngineProfile]) -> Result<(), RegistryValidationError> {
321 if profiles.len() != 5 {
322 return Err(RegistryValidationError::ProfileCount {
323 found: profiles.len(),
324 });
325 }
326 let mut selections = BTreeSet::new();
327 let mut urns = BTreeSet::new();
328 for profile in profiles {
329 if !selections.insert(profile.selection.clone()) {
330 return Err(RegistryValidationError::DuplicateSelection {
331 selection: profile.selection.clone(),
332 });
333 }
334 if !urns.insert(profile.fact_bundle_urn) {
335 return Err(RegistryValidationError::DuplicateFactBundleUrn {
336 urn: profile.fact_bundle_urn,
337 });
338 }
339 let fact_ids: BTreeSet<_> = profile.facts.iter().map(ProfileFact::id).collect();
340 if fact_ids.len() != profile.facts.len()
341 || fact_ids != ALL_FACT_IDS.into_iter().collect::<BTreeSet<_>>()
342 {
343 return Err(RegistryValidationError::InvalidFactInventory {
344 selection: profile.selection.clone(),
345 });
346 }
347 let accepted_inputs = profile.accepted_inputs();
348 if accepted_inputs.is_empty()
349 || !accepted_inputs.windows(2).all(|pair| {
350 crate::canonical::format_name(pair[0]) < crate::canonical::format_name(pair[1])
351 })
352 {
353 return Err(RegistryValidationError::InvalidAcceptedInputFact {
354 selection: profile.selection.clone(),
355 });
356 }
357 let setting_ids: BTreeSet<_> = profile.settings.iter().map(SettingDescriptor::id).collect();
358 if setting_ids.len() != profile.settings.len() {
359 return Err(RegistryValidationError::DuplicateSettingDescriptor {
360 selection: profile.selection.clone(),
361 });
362 }
363 for descriptor in &profile.settings {
364 let valid_default = matches!(
365 (descriptor.applicability(), descriptor.default_status()),
366 (
367 SettingApplicability::Applicable,
368 DefaultStatus::RequiredWithoutDefault
369 ) | (
370 SettingApplicability::NotApplicable,
371 DefaultStatus::NotApplicable
372 )
373 );
374 if !valid_default {
375 return Err(RegistryValidationError::InvalidDescriptorDefault {
376 selection: profile.selection.clone(),
377 setting: descriptor.id(),
378 });
379 }
380 }
381 let mut source_ids = BTreeSet::new();
382 for source in &profile.sources {
383 if !source_ids.insert(source.id()) {
384 return Err(RegistryValidationError::DuplicateSourceId {
385 selection: profile.selection.clone(),
386 source_id: source.id(),
387 });
388 }
389 for fact in source.supported_facts() {
390 if !fact_ids.contains(fact) {
391 return Err(RegistryValidationError::UnknownSourceFact {
392 selection: profile.selection.clone(),
393 source_id: source.id(),
394 fact: *fact,
395 });
396 }
397 if !matches!(
398 profile.fact(*fact).map(ProfileFact::state),
399 Some(FactState::Known(_))
400 ) {
401 return Err(RegistryValidationError::SourceReferencesNonKnownFact {
402 selection: profile.selection.clone(),
403 source_id: source.id(),
404 fact: *fact,
405 });
406 }
407 }
408 for setting in source.supported_settings() {
409 if !setting_ids.contains(setting) {
410 return Err(RegistryValidationError::UnknownSourceSetting {
411 selection: profile.selection.clone(),
412 source_id: source.id(),
413 setting: *setting,
414 });
415 }
416 }
417 }
418 for fact in &profile.facts {
419 if matches!(fact.state(), FactState::Known(_))
420 && !profile
421 .sources
422 .iter()
423 .any(|source| source.supported_facts().contains(&fact.id()))
424 {
425 return Err(RegistryValidationError::UnreferencedKnownFact {
426 selection: profile.selection.clone(),
427 fact: fact.id(),
428 });
429 }
430 }
431 for descriptor in &profile.settings {
432 if !profile
433 .sources
434 .iter()
435 .any(|source| source.supported_settings().contains(&descriptor.id()))
436 {
437 return Err(RegistryValidationError::UnreferencedSetting {
438 selection: profile.selection.clone(),
439 setting: descriptor.id(),
440 });
441 }
442 }
443 if facts_identity(profile) != profile.facts_identity {
444 return Err(RegistryValidationError::FactsIdentityMismatch {
445 selection: profile.selection.clone(),
446 });
447 }
448 }
449 Ok(())
450}
451
452fn unity_generic() -> EngineProfile {
453 let accepted = vec![SourceFormatV1::Fbx];
454 let facts = base_facts(
455 &accepted,
456 vec![
457 (
458 FactId::UnitConversionControl,
459 FactValue::ConversionControl(ConversionControl::ProfileSetting(
460 SettingId::ConvertUnits,
461 )),
462 ),
463 (
464 FactId::AxisConversionControl,
465 FactValue::ConversionControl(ConversionControl::ProfileSetting(
466 SettingId::BakeAxisConversion,
467 )),
468 ),
469 (
470 FactId::RootMotionAddressability,
471 FactValue::RootMotionAddressability(
472 RootMotionAddressability::ExactSourceTransformPath,
473 ),
474 ),
475 ],
476 );
477 finish_profile(
478 ProfileSelection::new("unity-generic", 1, "6000.3", "fbx-model-importer"),
479 "urn:animsmith:engine-profile:unity-generic:1",
480 facts,
481 unity_settings(true),
482 unity_sources(true),
483 )
484}
485
486fn unity_humanoid() -> EngineProfile {
487 let accepted = vec![SourceFormatV1::Fbx];
488 let facts = base_facts(
489 &accepted,
490 vec![
491 (
492 FactId::UnitConversionControl,
493 FactValue::ConversionControl(ConversionControl::ProfileSetting(
494 SettingId::ConvertUnits,
495 )),
496 ),
497 (
498 FactId::AxisConversionControl,
499 FactValue::ConversionControl(ConversionControl::ProfileSetting(
500 SettingId::BakeAxisConversion,
501 )),
502 ),
503 (
504 FactId::RootMotionAddressability,
505 FactValue::RootMotionAddressability(RootMotionAddressability::HumanoidAvatarBody),
506 ),
507 ],
508 );
509 finish_profile(
510 ProfileSelection::new("unity-humanoid", 1, "6000.3", "fbx-model-importer"),
511 "urn:animsmith:engine-profile:unity-humanoid:1",
512 facts,
513 unity_settings(false),
514 unity_sources(false),
515 )
516}
517
518fn unreal() -> EngineProfile {
519 let accepted = vec![SourceFormatV1::Fbx];
520 let facts = base_facts(
521 &accepted,
522 vec![
523 (
524 FactId::TargetCoordinateBasis,
525 FactValue::CoordinateBasis(CoordinateBasis {
526 handedness: Handedness::Left,
527 up_axis: UpAxis::Z,
528 forward_axis: ForwardAxis::PositiveX,
529 }),
530 ),
531 (
532 FactId::TargetLinearUnit,
533 FactValue::LinearUnit(LinearUnit::Centimetre),
534 ),
535 (
536 FactId::UnitConversionControl,
537 FactValue::ConversionControl(ConversionControl::ImporterOption),
538 ),
539 (
540 FactId::AxisConversionControl,
541 FactValue::ConversionControl(ConversionControl::ImporterOption),
542 ),
543 (FactId::WholeEndFrameRequired, FactValue::Boolean(true)),
544 ],
545 );
546 finish_profile(
547 ProfileSelection::new("unreal", 1, "5.8", "fbx-importer"),
548 "urn:animsmith:engine-profile:unreal:1",
549 facts,
550 vec![],
551 vec![
552 source(
553 "unreal-animation-sequences-5.8",
554 "5.8",
555 "https://dev.epicgames.com/documentation/en-us/unreal-engine/animation-sequences-in-unreal-engine?application_version=5.8",
556 &[FactId::WholeEndFrameRequired],
557 &[],
558 ),
559 source(
560 "unreal-coordinate-system-5.8",
561 "5.8",
562 "https://dev.epicgames.com/documentation/en-us/unreal-engine/coordinate-system-and-spaces-in-unreal-engine?application_version=5.8",
563 &[FactId::TargetCoordinateBasis],
564 &[],
565 ),
566 source(
567 "unreal-fbx-import-options-5.8",
568 "5.8",
569 "https://dev.epicgames.com/documentation/en-us/unreal-engine/fbx-import-options-reference-in-unreal-engine?application_version=5.8",
570 &[
571 FactId::AcceptedInputs,
572 FactId::UnitConversionControl,
573 FactId::AxisConversionControl,
574 ],
575 &[],
576 ),
577 source(
578 "unreal-units-5.8",
579 "5.8",
580 "https://dev.epicgames.com/documentation/en-us/unreal-engine/units-of-measurement-in-unreal-engine?application_version=5.8",
581 &[FactId::TargetLinearUnit],
582 &[],
583 ),
584 ],
585 )
586}
587
588fn godot() -> EngineProfile {
589 let accepted = vec![
590 SourceFormatV1::GltfJson,
591 SourceFormatV1::Glb,
592 SourceFormatV1::Fbx,
593 ];
594 let facts = base_facts(&accepted, vec![]);
595 finish_profile(
596 ProfileSelection::new("godot", 1, "4.7", "resource-importer-scene"),
597 "urn:animsmith:engine-profile:godot:1",
598 facts,
599 vec![],
600 vec![source(
601 "godot-resource-importer-scene-4.7",
602 "4.7",
603 "https://docs.godotengine.org/en/4.7/classes/class_resourceimporterscene.html",
604 &[FactId::AcceptedInputs],
605 &[],
606 )],
607 )
608}
609
610fn bevy() -> EngineProfile {
611 let accepted = vec![SourceFormatV1::GltfJson, SourceFormatV1::Glb];
612 let facts = base_facts(
613 &accepted,
614 vec![
615 (
616 FactId::AnimationAddressability,
617 FactValue::AnimationAddressability(AnimationAddressability::GltfAssetLabel),
618 ),
619 (
620 FactId::AnimationTargetAddressability,
621 FactValue::TargetAddressability(TargetAddressability::NamePathDerivedId),
622 ),
623 ],
624 );
625 finish_profile(
626 ProfileSelection::new("bevy", 1, "0.19.0", "gltf-asset-loader"),
627 "urn:animsmith:engine-profile:bevy:1",
628 facts,
629 vec![],
630 vec![
631 source(
632 "bevy-animation-target-id-0.19.0",
633 "0.19.0",
634 "https://docs.rs/bevy/0.19.0/bevy/animation/struct.AnimationTargetId.html",
635 &[FactId::AnimationTargetAddressability],
636 &[],
637 ),
638 source(
639 "bevy-gltf-asset-label-0.19.0",
640 "0.19.0",
641 "https://docs.rs/bevy/0.19.0/bevy/gltf/enum.GltfAssetLabel.html",
642 &[FactId::AcceptedInputs, FactId::AnimationAddressability],
643 &[],
644 ),
645 source(
646 "khronos-gltf-2.0",
647 "2.0",
648 "https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html",
649 &[FactId::AcceptedInputs],
650 &[],
651 ),
652 ],
653 )
654}
655
656fn unity_generic_v2() -> EngineProfileV2 {
662 use SettingDefaultV2::RequiredExplicit;
663 use SettingDomainV2::{
664 AnimationType, AvatarSetup, BakeOrExtract, Boolean, SourceTransformPath,
665 };
666 use SettingIdV2::{
667 AnimationType as AnimationTypeId, AvatarSetup as AvatarSetupId, ImportAnimation,
668 RootMotionSource, RootPositionXz, RootPositionY, RootRotation,
669 };
670 use animsmith_core::engine_contract::{
671 EngineFactIdV2, EngineFactStateV2, EngineFactValueV2, EngineProfileFactV2,
672 EngineRootMotionAddressabilityV1,
673 };
674
675 let mut settings = vec![
676 SettingDescriptorV2::new(
677 AnimationTypeId,
678 SettingScope::Document,
679 AnimationType,
680 RequiredExplicit,
681 ),
682 SettingDescriptorV2::new(
683 AvatarSetupId,
684 SettingScope::Document,
685 AvatarSetup,
686 RequiredExplicit,
687 ),
688 SettingDescriptorV2::new(
689 ImportAnimation,
690 SettingScope::Document,
691 Boolean,
692 RequiredExplicit,
693 ),
694 SettingDescriptorV2::new(
695 RootMotionSource,
696 SettingScope::Document,
697 SourceTransformPath,
698 RequiredExplicit,
699 ),
700 SettingDescriptorV2::new(
701 RootRotation,
702 SettingScope::Clip,
703 BakeOrExtract,
704 RequiredExplicit,
705 ),
706 SettingDescriptorV2::new(
707 RootPositionY,
708 SettingScope::Clip,
709 BakeOrExtract,
710 RequiredExplicit,
711 ),
712 SettingDescriptorV2::new(
713 RootPositionXz,
714 SettingScope::Clip,
715 BakeOrExtract,
716 RequiredExplicit,
717 ),
718 ];
719 settings.sort_by_key(|descriptor| descriptor.id().as_str());
720
721 let facts = vec![
722 EngineProfileFactV2::new(
723 EngineFactIdV2::AcceptedInputs,
724 EngineFactStateV2::Known(EngineFactValueV2::AcceptedFormats(vec![
725 SourceFormatV1::Fbx,
726 ])),
727 ),
728 EngineProfileFactV2::new(
729 EngineFactIdV2::ApplicationWorldUnitPolicy,
730 EngineFactStateV2::Unknown,
731 ),
732 EngineProfileFactV2::new(
733 EngineFactIdV2::ImportSettingProjection,
734 EngineFactStateV2::Unknown,
735 ),
736 EngineProfileFactV2::new(
737 EngineFactIdV2::ImporterScaleConversion,
738 EngineFactStateV2::Unknown,
739 ),
740 EngineProfileFactV2::new(
741 EngineFactIdV2::PhysicalDimensionsPreserved,
742 EngineFactStateV2::Unknown,
743 ),
744 EngineProfileFactV2::new(
745 EngineFactIdV2::ResultingTransformScale,
746 EngineFactStateV2::Unknown,
747 ),
748 EngineProfileFactV2::new(
749 EngineFactIdV2::RootMotionAddressability,
750 EngineFactStateV2::Known(EngineFactValueV2::RootMotionAddressability(
751 EngineRootMotionAddressabilityV1::ExactSourceTransformPath,
752 )),
753 ),
754 EngineProfileFactV2::new(
755 EngineFactIdV2::SourceImportDisposition,
756 EngineFactStateV2::Unknown,
757 ),
758 EngineProfileFactV2::new(
759 EngineFactIdV2::SourceToTargetUnitMapping,
760 EngineFactStateV2::Unknown,
761 ),
762 EngineProfileFactV2::new(EngineFactIdV2::TargetLinearUnit, EngineFactStateV2::Unknown),
763 ];
764
765 let mut sources = vec![
766 PrimarySourceV2 {
767 id: "unity-fbx-model-importer-6000.3",
768 target_version: "6000.3",
769 url: "https://docs.unity3d.com/6000.3/Documentation/Manual/FBXImporter-Model.html",
770 verified_on: UNITY_GENERIC_V2_VERIFIED_ON,
771 supported_facts: vec![EngineFactIdV2::AcceptedInputs],
772 supported_settings: vec![AnimationTypeId, AvatarSetupId, ImportAnimation],
773 },
774 PrimarySourceV2 {
775 id: "unity-fbx-animation-clip-6000.3",
776 target_version: "6000.3",
777 url: "https://docs.unity3d.com/6000.3/Documentation/Manual/class-AnimationClip.html",
778 verified_on: UNITY_GENERIC_V2_VERIFIED_ON,
779 supported_facts: vec![],
780 supported_settings: vec![RootRotation, RootPositionY, RootPositionXz],
781 },
782 PrimarySourceV2 {
783 id: "unity-fbx-motion-node-6000.3",
784 target_version: "6000.3",
785 url: "https://docs.unity3d.com/6000.3/Documentation/ScriptReference/ModelImporter-motionNodeName.html",
786 verified_on: UNITY_GENERIC_V2_VERIFIED_ON,
787 supported_facts: vec![EngineFactIdV2::RootMotionAddressability],
788 supported_settings: vec![RootMotionSource],
789 },
790 ];
791 for source in &mut sources {
792 source.supported_facts.sort_by_key(|fact| fact.as_str());
793 source
794 .supported_settings
795 .sort_by_key(|setting| setting.as_str());
796 }
797 sources.sort_by_key(|source| source.id);
798
799 EngineProfileV2 {
800 selection: ProfileSelection::new("unity-generic", 2, "6000.3", "fbx-model-importer"),
801 profile_urn: "urn:animsmith:engine-profile:unity-generic:2",
802 accepted_inputs: vec![SourceFormatV1::Fbx],
803 facts,
804 settings,
805 sources,
806 }
807}
808
809fn unreal_v2() -> EngineProfileV2 {
811 use SettingDefaultV2::RequiredExplicit;
812 use SettingDomainV2::SampleRate;
813 use SettingIdV2::SampleRate as SampleRateId;
814 use animsmith_core::engine_contract::EngineFactIdV2;
815
816 let settings = vec![SettingDescriptorV2::new(
817 SampleRateId,
818 SettingScope::Document,
819 SampleRate,
820 RequiredExplicit,
821 )];
822 let sources = vec![PrimarySourceV2 {
823 id: "unreal-fbx-import-options-5.8",
824 target_version: "5.8",
825 url: "https://dev.epicgames.com/documentation/en-us/unreal-engine/fbx-import-options-reference-in-unreal-engine?application_version=5.8",
826 verified_on: IMPORT_ADVICE_V2_VERIFIED_ON,
827 supported_facts: vec![
828 EngineFactIdV2::AcceptedInputs,
829 EngineFactIdV2::ImportSettingProjection,
830 ],
831 supported_settings: vec![SampleRateId],
832 }];
833
834 EngineProfileV2 {
835 selection: ProfileSelection::new("unreal", 2, "5.8", "fbx-importer"),
836 profile_urn: "urn:animsmith:engine-profile:unreal:2",
837 accepted_inputs: vec![SourceFormatV1::Fbx],
838 facts: document_advice_facts_v2(vec![SourceFormatV1::Fbx], "unreal_fbx_import_data"),
839 settings,
840 sources,
841 }
842}
843
844fn godot_v2() -> EngineProfileV2 {
846 use SettingDefaultV2::Verified;
847 use SettingDomainV2::{Boolean, PositiveInteger};
848 use SettingIdV2::{AnimationFps, AnimationTrimming};
849 use animsmith_core::engine_contract::EngineFactIdV2;
850
851 let mut settings = vec![
852 SettingDescriptorV2::new(
853 AnimationFps,
854 SettingScope::Document,
855 PositiveInteger,
856 Verified(SettingValueV2::PositiveInteger(30)),
857 ),
858 SettingDescriptorV2::new(
859 AnimationTrimming,
860 SettingScope::Document,
861 Boolean,
862 Verified(SettingValueV2::Boolean(false)),
863 ),
864 ];
865 settings.sort_by_key(|descriptor| descriptor.id().as_str());
866
867 let sources = vec![PrimarySourceV2 {
868 id: "godot-resource-importer-scene-4.7",
869 target_version: "4.7",
870 url: "https://docs.godotengine.org/en/4.7/classes/class_resourceimporterscene.html",
871 verified_on: IMPORT_ADVICE_V2_VERIFIED_ON,
872 supported_facts: vec![
873 EngineFactIdV2::AcceptedInputs,
874 EngineFactIdV2::ImportSettingProjection,
875 ],
876 supported_settings: vec![AnimationFps, AnimationTrimming],
877 }];
878
879 let accepted_inputs = vec![SourceFormatV1::Glb, SourceFormatV1::GltfJson];
880 EngineProfileV2 {
881 selection: ProfileSelection::new("godot", 2, "4.7", "resource-importer-scene"),
882 profile_urn: "urn:animsmith:engine-profile:godot:2",
883 facts: document_advice_facts_v2(accepted_inputs.clone(), "godot_params"),
884 accepted_inputs,
885 settings,
886 sources,
887 }
888}
889
890fn document_advice_facts_v2(
891 accepted_inputs: Vec<SourceFormatV1>,
892 import_setting_projection: &'static str,
893) -> Vec<animsmith_core::engine_contract::EngineProfileFactV2> {
894 use animsmith_core::engine_contract::{
895 EngineFactIdV2, EngineFactStateV2, EngineFactValueV2, EngineProfileFactV2,
896 };
897
898 vec![
899 EngineProfileFactV2::new(
900 EngineFactIdV2::AcceptedInputs,
901 EngineFactStateV2::Known(EngineFactValueV2::AcceptedFormats(accepted_inputs)),
902 ),
903 EngineProfileFactV2::new(
904 EngineFactIdV2::ApplicationWorldUnitPolicy,
905 EngineFactStateV2::Unknown,
906 ),
907 EngineProfileFactV2::new(
908 EngineFactIdV2::ImportSettingProjection,
909 EngineFactStateV2::Known(EngineFactValueV2::Token(import_setting_projection.into())),
910 ),
911 EngineProfileFactV2::new(
912 EngineFactIdV2::ImporterScaleConversion,
913 EngineFactStateV2::Unknown,
914 ),
915 EngineProfileFactV2::new(
916 EngineFactIdV2::PhysicalDimensionsPreserved,
917 EngineFactStateV2::Unknown,
918 ),
919 EngineProfileFactV2::new(
920 EngineFactIdV2::ResultingTransformScale,
921 EngineFactStateV2::Unknown,
922 ),
923 EngineProfileFactV2::new(
924 EngineFactIdV2::RootMotionAddressability,
925 EngineFactStateV2::Unknown,
926 ),
927 EngineProfileFactV2::new(
928 EngineFactIdV2::SourceImportDisposition,
929 EngineFactStateV2::Unknown,
930 ),
931 EngineProfileFactV2::new(
932 EngineFactIdV2::SourceToTargetUnitMapping,
933 EngineFactStateV2::Unknown,
934 ),
935 EngineProfileFactV2::new(EngineFactIdV2::TargetLinearUnit, EngineFactStateV2::Unknown),
936 ]
937}
938
939fn bevy_v2() -> EngineProfileV2 {
940 use SettingDefaultV2::{RequiredExplicit, Verified};
941 use SettingDomainV2::{Boolean, HandlerEnvironment, LoadMeshesState};
942 use SettingIdV2::{
943 BevyAnimationFeature, ExtensionHandlerEnvironment, LoadAnimations, LoadMeshes,
944 RotateMeshes, RotateSceneEntity,
945 };
946 use animsmith_core::engine_contract::{
947 EngineFactIdV2, EngineFactStateV2, EngineFactValueV2, EngineLinearUnitV2,
948 EngineProfileFactV2, ReducedRatioV1,
949 };
950
951 let mut settings = vec![
952 SettingDescriptorV2::new(
953 RotateSceneEntity,
954 SettingScope::Document,
955 Boolean,
956 Verified(SettingValueV2::Boolean(false)),
957 ),
958 SettingDescriptorV2::new(
959 RotateMeshes,
960 SettingScope::Document,
961 Boolean,
962 Verified(SettingValueV2::Boolean(false)),
963 ),
964 SettingDescriptorV2::new(
965 LoadMeshes,
966 SettingScope::Document,
967 LoadMeshesState,
968 Verified(SettingValueV2::LoadMeshesState(
969 crate::BevyLoadMeshesStateV2::Nonempty,
970 )),
971 ),
972 SettingDescriptorV2::new(
973 ExtensionHandlerEnvironment,
974 SettingScope::Document,
975 HandlerEnvironment,
976 RequiredExplicit,
977 ),
978 SettingDescriptorV2::new(
982 BevyAnimationFeature,
983 SettingScope::Document,
984 Boolean,
985 RequiredExplicit,
986 ),
987 SettingDescriptorV2::new(
988 LoadAnimations,
989 SettingScope::Document,
990 Boolean,
991 Verified(SettingValueV2::Boolean(true)),
992 ),
993 ];
994 settings.sort_by_key(|descriptor| descriptor.id().as_str());
995
996 let facts = vec![
997 EngineProfileFactV2::new(
998 EngineFactIdV2::AcceptedInputs,
999 EngineFactStateV2::Known(EngineFactValueV2::AcceptedFormats(vec![
1000 SourceFormatV1::Glb,
1001 SourceFormatV1::GltfJson,
1002 ])),
1003 ),
1004 EngineProfileFactV2::new(
1005 EngineFactIdV2::ApplicationWorldUnitPolicy,
1006 EngineFactStateV2::Known(EngineFactValueV2::Boolean(false)),
1007 ),
1008 EngineProfileFactV2::new(
1009 EngineFactIdV2::ImporterScaleConversion,
1010 EngineFactStateV2::Known(EngineFactValueV2::Token("none".into())),
1011 ),
1012 EngineProfileFactV2::new(
1013 EngineFactIdV2::ImportSettingProjection,
1014 EngineFactStateV2::Unknown,
1015 ),
1016 EngineProfileFactV2::new(
1017 EngineFactIdV2::PhysicalDimensionsPreserved,
1018 EngineFactStateV2::Known(EngineFactValueV2::Boolean(true)),
1019 ),
1020 EngineProfileFactV2::new(
1021 EngineFactIdV2::ResultingTransformScale,
1022 EngineFactStateV2::Known(EngineFactValueV2::Token(
1023 "loader_entities_unit_orthonormal_trs_nodes_passthrough_matrix_nodes_decomposed"
1024 .into(),
1025 )),
1026 ),
1027 EngineProfileFactV2::new(
1028 EngineFactIdV2::RootMotionAddressability,
1029 EngineFactStateV2::Unknown,
1030 ),
1031 EngineProfileFactV2::new(
1032 EngineFactIdV2::SourceImportDisposition,
1033 EngineFactStateV2::Unknown,
1034 ),
1035 EngineProfileFactV2::new(
1036 EngineFactIdV2::SourceToTargetUnitMapping,
1037 EngineFactStateV2::Known(EngineFactValueV2::UnitRatio(
1038 ReducedRatioV1::new(1, 1).expect("one-to-one is a reduced positive ratio"),
1039 )),
1040 ),
1041 EngineProfileFactV2::new(
1042 EngineFactIdV2::TargetLinearUnit,
1043 EngineFactStateV2::Known(EngineFactValueV2::LinearUnit(
1044 EngineLinearUnitV2::EngineWorldLengthUnit,
1045 )),
1046 ),
1047 ];
1048
1049 let pinned = |path: &'static str| -> &'static str {
1050 match path {
1051 "loader" => {
1052 "https://github.com/bevyengine/bevy/blob/c6f634ca9f406d68ba5109d921247b654cb42c10/crates/bevy_gltf/src/loader/mod.rs"
1053 }
1054 "coordinates" => {
1055 "https://github.com/bevyengine/bevy/blob/c6f634ca9f406d68ba5109d921247b654cb42c10/crates/bevy_gltf/src/convert_coordinates.rs"
1056 }
1057 "handlers" => {
1058 "https://github.com/bevyengine/bevy/blob/c6f634ca9f406d68ba5109d921247b654cb42c10/crates/bevy_gltf/src/loader/extensions/mod.rs"
1059 }
1060 "pbr" => {
1061 "https://github.com/bevyengine/bevy/blob/c6f634ca9f406d68ba5109d921247b654cb42c10/crates/bevy_pbr/src/gltf.rs"
1062 }
1063 "render_asset" => {
1064 "https://github.com/bevyengine/bevy/blob/c6f634ca9f406d68ba5109d921247b654cb42c10/crates/bevy_asset/src/render_asset.rs"
1065 }
1066 "cargo" => {
1067 "https://github.com/bevyengine/bevy/blob/c6f634ca9f406d68ba5109d921247b654cb42c10/Cargo.toml"
1068 }
1069 _ => unreachable!("closed Bevy source path"),
1070 }
1071 };
1072 let mut sources = vec![
1073 PrimarySourceV2 {
1074 id: "bevy-gltf-loader-0.19.0-c6f634ca",
1075 target_version: BEVY_0_19_COMMIT,
1076 url: pinned("loader"),
1077 verified_on: BEVY_V2_VERIFIED_ON,
1078 supported_facts: vec![
1079 EngineFactIdV2::AcceptedInputs,
1080 EngineFactIdV2::ApplicationWorldUnitPolicy,
1081 EngineFactIdV2::ImporterScaleConversion,
1082 EngineFactIdV2::PhysicalDimensionsPreserved,
1083 EngineFactIdV2::ResultingTransformScale,
1084 EngineFactIdV2::SourceToTargetUnitMapping,
1085 EngineFactIdV2::TargetLinearUnit,
1086 ],
1087 supported_settings: vec![LoadAnimations, LoadMeshes, RotateMeshes, RotateSceneEntity],
1088 },
1089 PrimarySourceV2 {
1090 id: "bevy-gltf-coordinate-conversion-0.19.0-c6f634ca",
1091 target_version: BEVY_0_19_COMMIT,
1092 url: pinned("coordinates"),
1093 verified_on: BEVY_V2_VERIFIED_ON,
1094 supported_facts: vec![
1095 EngineFactIdV2::ImporterScaleConversion,
1096 EngineFactIdV2::PhysicalDimensionsPreserved,
1097 EngineFactIdV2::ResultingTransformScale,
1098 EngineFactIdV2::SourceToTargetUnitMapping,
1099 ],
1100 supported_settings: vec![RotateMeshes, RotateSceneEntity],
1101 },
1102 PrimarySourceV2 {
1103 id: "bevy-gltf-extension-registry-0.19.0-c6f634ca",
1104 target_version: BEVY_0_19_COMMIT,
1105 url: pinned("handlers"),
1106 verified_on: BEVY_V2_VERIFIED_ON,
1107 supported_facts: vec![],
1108 supported_settings: vec![ExtensionHandlerEnvironment],
1109 },
1110 PrimarySourceV2 {
1111 id: "bevy-pbr-gltf-handler-0.19.0-c6f634ca",
1112 target_version: BEVY_0_19_COMMIT,
1113 url: pinned("pbr"),
1114 verified_on: BEVY_V2_VERIFIED_ON,
1115 supported_facts: vec![],
1116 supported_settings: vec![ExtensionHandlerEnvironment],
1117 },
1118 PrimarySourceV2 {
1119 id: "bevy-render-asset-usages-0.19.0-c6f634ca",
1120 target_version: BEVY_0_19_COMMIT,
1121 url: pinned("render_asset"),
1122 verified_on: BEVY_V2_VERIFIED_ON,
1123 supported_facts: vec![],
1124 supported_settings: vec![LoadMeshes],
1125 },
1126 PrimarySourceV2 {
1127 id: "bevy-feature-manifest-0.19.0-c6f634ca",
1128 target_version: BEVY_0_19_COMMIT,
1129 url: pinned("cargo"),
1130 verified_on: BEVY_V2_VERIFIED_ON,
1131 supported_facts: vec![],
1132 supported_settings: vec![BevyAnimationFeature],
1133 },
1134 PrimarySourceV2 {
1135 id: "khronos-gltf-2.0-coordinate-units",
1136 target_version: "2.0",
1137 url: "https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html#coordinate-system-and-units",
1138 verified_on: BEVY_V2_VERIFIED_ON,
1139 supported_facts: vec![
1140 EngineFactIdV2::AcceptedInputs,
1141 EngineFactIdV2::PhysicalDimensionsPreserved,
1142 EngineFactIdV2::SourceToTargetUnitMapping,
1143 ],
1144 supported_settings: vec![],
1145 },
1146 ];
1147 for source in &mut sources {
1148 source.supported_facts.sort_by_key(|fact| fact.as_str());
1149 source
1150 .supported_settings
1151 .sort_by_key(|setting| setting.as_str());
1152 }
1153 sources.sort_by_key(|source| source.id());
1154
1155 EngineProfileV2 {
1156 selection: ProfileSelection::new("bevy", 2, "0.19.0", "gltf-asset-loader"),
1157 profile_urn: "urn:animsmith:engine-profile:bevy:2",
1158 accepted_inputs: vec![SourceFormatV1::Glb, SourceFormatV1::GltfJson],
1159 facts,
1160 settings,
1161 sources,
1162 }
1163}
1164
1165fn bevy_v3() -> EngineProfileV2 {
1169 use animsmith_core::engine_contract::{EngineFactIdV2, EngineFactStateV2, EngineFactValueV2};
1170
1171 let mut profile = bevy_v2();
1172 profile.selection = ProfileSelection::new("bevy", 3, "0.19.0", "gltf-asset-loader");
1173 profile.profile_urn = "urn:animsmith:engine-profile:bevy:3";
1174 let disposition = profile
1175 .facts
1176 .iter_mut()
1177 .find(|fact| fact.id() == EngineFactIdV2::SourceImportDisposition)
1178 .expect("every V2 profile has source import disposition");
1179 *disposition = animsmith_core::engine_contract::EngineProfileFactV2::new(
1180 EngineFactIdV2::SourceImportDisposition,
1181 EngineFactStateV2::Known(EngineFactValueV2::Token("materialized_import_gates".into())),
1182 );
1183 let loader = profile
1184 .sources
1185 .iter_mut()
1186 .find(|source| source.id == "bevy-gltf-loader-0.19.0-c6f634ca")
1187 .expect("Bevy V2 loader source exists");
1188 loader
1189 .supported_facts
1190 .push(EngineFactIdV2::SourceImportDisposition);
1191 loader.supported_facts.sort_by_key(|fact| fact.as_str());
1192 profile
1193}
1194
1195fn base_facts(accepted: &[SourceFormatV1], known: Vec<(FactId, FactValue)>) -> Vec<ProfileFact> {
1196 let mut accepted = accepted.to_vec();
1197 accepted.sort_by_key(|format| crate::canonical::format_name(*format));
1198 let known: std::collections::BTreeMap<_, _> = known.into_iter().collect();
1199 ALL_FACT_IDS
1200 .into_iter()
1201 .map(|id| {
1202 let state = if id == FactId::AcceptedInputs {
1203 FactState::Known(FactValue::AcceptedFormats(accepted.clone()))
1204 } else if let Some(value) = known.get(&id) {
1205 FactState::Known(value.clone())
1206 } else {
1207 FactState::Unknown
1208 };
1209 ProfileFact::new(id, state)
1210 })
1211 .collect()
1212}
1213
1214fn unity_settings(generic: bool) -> Vec<SettingDescriptor> {
1215 use DefaultStatus::{NotApplicable, RequiredWithoutDefault};
1216 use SettingApplicability::{Applicable, NotApplicable as DoesNotApply};
1217 vec![
1218 SettingDescriptor::new(
1219 SettingId::ConvertUnits,
1220 SettingScope::Document,
1221 SettingDomain::Boolean,
1222 Applicable,
1223 RequiredWithoutDefault,
1224 ),
1225 SettingDescriptor::new(
1226 SettingId::BakeAxisConversion,
1227 SettingScope::Document,
1228 SettingDomain::Boolean,
1229 Applicable,
1230 RequiredWithoutDefault,
1231 ),
1232 SettingDescriptor::new(
1233 SettingId::RootMotionSource,
1234 SettingScope::Document,
1235 SettingDomain::SourceTransformPath,
1236 if generic { Applicable } else { DoesNotApply },
1237 if generic {
1238 RequiredWithoutDefault
1239 } else {
1240 NotApplicable
1241 },
1242 ),
1243 SettingDescriptor::new(
1244 SettingId::RootRotation,
1245 SettingScope::Clip,
1246 SettingDomain::BakeOrExtract,
1247 Applicable,
1248 RequiredWithoutDefault,
1249 ),
1250 SettingDescriptor::new(
1251 SettingId::RootPositionY,
1252 SettingScope::Clip,
1253 SettingDomain::BakeOrExtract,
1254 Applicable,
1255 RequiredWithoutDefault,
1256 ),
1257 SettingDescriptor::new(
1258 SettingId::RootPositionXz,
1259 SettingScope::Clip,
1260 SettingDomain::BakeOrExtract,
1261 Applicable,
1262 RequiredWithoutDefault,
1263 ),
1264 ]
1265}
1266
1267fn unity_sources(generic: bool) -> Vec<PrimarySource> {
1268 let mut sources = vec![
1269 source(
1270 "unity-animation-clip-import-6000.3",
1271 "6000.3",
1272 "https://docs.unity3d.com/6000.3/Documentation/Manual/class-AnimationClip.html",
1273 &[],
1274 &[
1275 SettingId::RootRotation,
1276 SettingId::RootPositionY,
1277 SettingId::RootPositionXz,
1278 ],
1279 ),
1280 source(
1281 "unity-model-import-6000.3",
1282 "6000.3",
1283 "https://docs.unity3d.com/6000.3/Documentation/Manual/FBXImporter-Model.html",
1284 &[
1285 FactId::AcceptedInputs,
1286 FactId::UnitConversionControl,
1287 FactId::AxisConversionControl,
1288 ],
1289 &[SettingId::ConvertUnits, SettingId::BakeAxisConversion],
1290 ),
1291 source(
1292 "unity-model-importer-clip-animation-6000.3",
1293 "6000.3",
1294 "https://docs.unity3d.com/6000.3/Documentation/ScriptReference/ModelImporterClipAnimation.html",
1295 &[],
1296 &[
1297 SettingId::RootRotation,
1298 SettingId::RootPositionY,
1299 SettingId::RootPositionXz,
1300 ],
1301 ),
1302 source(
1303 "unity-rig-import-6000.3",
1304 "6000.3",
1305 "https://docs.unity3d.com/6000.3/Documentation/Manual/FBXImporter-Rig.html",
1306 &[FactId::RootMotionAddressability],
1307 if generic {
1308 &[]
1309 } else {
1310 &[SettingId::RootMotionSource]
1311 },
1312 ),
1313 ];
1314 if generic {
1315 sources.push(source(
1316 "unity-model-importer-motion-node-name-6000.3",
1317 "6000.3",
1318 "https://docs.unity3d.com/6000.3/Documentation/ScriptReference/ModelImporter-motionNodeName.html",
1319 &[FactId::RootMotionAddressability],
1320 &[SettingId::RootMotionSource],
1321 ));
1322 }
1323 sources
1324}
1325
1326fn source(
1327 id: &'static str,
1328 target_version: &'static str,
1329 url: &'static str,
1330 facts: &[FactId],
1331 settings: &[SettingId],
1332) -> PrimarySource {
1333 PrimarySource::new(
1334 id,
1335 target_version,
1336 url,
1337 VERIFIED_ON,
1338 facts.to_vec(),
1339 settings.to_vec(),
1340 )
1341}
1342
1343fn finish_profile(
1344 selection: ProfileSelection,
1345 fact_bundle_urn: &'static str,
1346 mut facts: Vec<ProfileFact>,
1347 mut settings: Vec<SettingDescriptor>,
1348 mut sources: Vec<PrimarySource>,
1349) -> EngineProfile {
1350 facts.sort_by_key(|fact| fact.id().as_str());
1351 settings.sort_by_key(|descriptor| descriptor.id().as_str());
1352 sources.sort_by_key(PrimarySource::id);
1353 let mut profile = EngineProfile {
1354 selection,
1355 fact_bundle_urn,
1356 facts,
1357 settings,
1358 sources,
1359 facts_identity: InputIdentity::from_bytes(&[]),
1360 };
1361 profile.facts_identity = facts_identity(&profile);
1362 profile
1363}
1364
1365#[cfg(test)]
1366mod tests {
1367 use super::*;
1368
1369 fn without_fact(source: &PrimarySource, removed: FactId) -> PrimarySource {
1370 PrimarySource::new(
1371 source.id(),
1372 source.target_version(),
1373 source.url(),
1374 source.verified_on(),
1375 source
1376 .supported_facts()
1377 .iter()
1378 .copied()
1379 .filter(|id| *id != removed)
1380 .collect(),
1381 source.supported_settings().to_vec(),
1382 )
1383 }
1384
1385 fn without_setting(source: &PrimarySource, removed: SettingId) -> PrimarySource {
1386 PrimarySource::new(
1387 source.id(),
1388 source.target_version(),
1389 source.url(),
1390 source.verified_on(),
1391 source.supported_facts().to_vec(),
1392 source
1393 .supported_settings()
1394 .iter()
1395 .copied()
1396 .filter(|id| *id != removed)
1397 .collect(),
1398 )
1399 }
1400
1401 #[test]
1402 fn validation_refuses_known_fact_without_source_coverage() {
1403 let mut profiles = profiles_v1().to_vec();
1404 let bevy = profiles
1405 .iter_mut()
1406 .find(|profile| profile.selection.family() == "bevy")
1407 .unwrap();
1408 bevy.sources = bevy
1409 .sources
1410 .iter()
1411 .map(|source| without_fact(source, FactId::AnimationTargetAddressability))
1412 .collect();
1413 assert!(matches!(
1414 validate_profiles(&profiles),
1415 Err(RegistryValidationError::UnreferencedKnownFact {
1416 fact: FactId::AnimationTargetAddressability,
1417 ..
1418 })
1419 ));
1420 }
1421
1422 #[test]
1423 fn validation_refuses_not_applicable_descriptor_without_source_coverage() {
1424 let mut profiles = profiles_v1().to_vec();
1425 let humanoid = profiles
1426 .iter_mut()
1427 .find(|profile| profile.selection.family() == "unity-humanoid")
1428 .unwrap();
1429 humanoid.sources = humanoid
1430 .sources
1431 .iter()
1432 .map(|source| without_setting(source, SettingId::RootMotionSource))
1433 .collect();
1434 assert!(matches!(
1435 validate_profiles(&profiles),
1436 Err(RegistryValidationError::UnreferencedSetting {
1437 setting: SettingId::RootMotionSource,
1438 ..
1439 })
1440 ));
1441 }
1442
1443 #[test]
1444 fn validation_refuses_every_noncanonical_accepted_input_fact_shape() {
1445 for state in [
1446 FactState::Unknown,
1447 FactState::Known(FactValue::AcceptedFormats(vec![])),
1448 FactState::Known(FactValue::AcceptedFormats(vec![
1449 SourceFormatV1::GltfJson,
1450 SourceFormatV1::Fbx,
1451 ])),
1452 ] {
1453 let mut profiles = profiles_v1().to_vec();
1454 let godot = profiles
1455 .iter_mut()
1456 .find(|profile| profile.selection.family() == "godot")
1457 .unwrap();
1458 let accepted = godot
1459 .facts
1460 .iter_mut()
1461 .find(|fact| fact.id() == FactId::AcceptedInputs)
1462 .unwrap();
1463 *accepted = ProfileFact::new(FactId::AcceptedInputs, state);
1464 assert!(matches!(
1465 validate_profiles(&profiles),
1466 Err(RegistryValidationError::InvalidAcceptedInputFact { .. })
1467 ));
1468 }
1469 }
1470}