1use crate::canonical::facts_identity;
2use crate::{
3 AnimationAddressability, ConversionControl, CoordinateBasis, DefaultStatus, EngineProfile,
4 FactId, FactState, FactValue, ForwardAxis, Handedness, LinearUnit, PrimarySource, ProfileFact,
5 ProfileSelection, RegistryValidationError, RootMotionAddressability, SettingApplicability,
6 SettingDescriptor, SettingDomain, SettingId, SettingScope, TargetAddressability, UpAxis,
7};
8use animsmith_core::{InputIdentity, SourceFormatV1};
9use std::collections::BTreeSet;
10use std::sync::OnceLock;
11
12const VERIFIED_ON: &str = "2026-08-20";
13const ALL_FACT_IDS: [FactId; 14] = [
14 FactId::AcceptedInputs,
15 FactId::AnimationAddressability,
16 FactId::TargetCoordinateBasis,
17 FactId::TargetLinearUnit,
18 FactId::UnitConversionControl,
19 FactId::AxisConversionControl,
20 FactId::ExactAxisConversion,
21 FactId::ResultingHierarchyScale,
22 FactId::WholeEndFrameRequired,
23 FactId::AnimationChannelHandling,
24 FactId::ExtensionHandling,
25 FactId::ConstructHandling,
26 FactId::AnimationTargetAddressability,
27 FactId::RootMotionAddressability,
28];
29
30pub fn profiles_v1() -> &'static [EngineProfile] {
32 static PROFILES: OnceLock<Vec<EngineProfile>> = OnceLock::new();
33 PROFILES.get_or_init(|| {
34 let mut profiles = vec![unity_generic(), unity_humanoid(), unreal(), godot(), bevy()];
35 profiles.sort_by(|left, right| left.selection.cmp(&right.selection));
36 profiles
37 })
38}
39
40pub fn validate_registry_v1() -> Result<(), RegistryValidationError> {
50 validate_profiles(profiles_v1())
51}
52
53fn validate_profiles(profiles: &[EngineProfile]) -> Result<(), RegistryValidationError> {
54 if profiles.len() != 5 {
55 return Err(RegistryValidationError::ProfileCount {
56 found: profiles.len(),
57 });
58 }
59 let mut selections = BTreeSet::new();
60 let mut urns = BTreeSet::new();
61 for profile in profiles {
62 if !selections.insert(profile.selection.clone()) {
63 return Err(RegistryValidationError::DuplicateSelection {
64 selection: profile.selection.clone(),
65 });
66 }
67 if !urns.insert(profile.fact_bundle_urn) {
68 return Err(RegistryValidationError::DuplicateFactBundleUrn {
69 urn: profile.fact_bundle_urn,
70 });
71 }
72 let fact_ids: BTreeSet<_> = profile.facts.iter().map(ProfileFact::id).collect();
73 if fact_ids.len() != profile.facts.len()
74 || fact_ids != ALL_FACT_IDS.into_iter().collect::<BTreeSet<_>>()
75 {
76 return Err(RegistryValidationError::InvalidFactInventory {
77 selection: profile.selection.clone(),
78 });
79 }
80 let accepted_inputs = profile.accepted_inputs();
81 if accepted_inputs.is_empty()
82 || !accepted_inputs.windows(2).all(|pair| {
83 crate::canonical::format_name(pair[0]) < crate::canonical::format_name(pair[1])
84 })
85 {
86 return Err(RegistryValidationError::InvalidAcceptedInputFact {
87 selection: profile.selection.clone(),
88 });
89 }
90 let setting_ids: BTreeSet<_> = profile.settings.iter().map(SettingDescriptor::id).collect();
91 if setting_ids.len() != profile.settings.len() {
92 return Err(RegistryValidationError::DuplicateSettingDescriptor {
93 selection: profile.selection.clone(),
94 });
95 }
96 for descriptor in &profile.settings {
97 let valid_default = matches!(
98 (descriptor.applicability(), descriptor.default_status()),
99 (
100 SettingApplicability::Applicable,
101 DefaultStatus::RequiredWithoutDefault
102 ) | (
103 SettingApplicability::NotApplicable,
104 DefaultStatus::NotApplicable
105 )
106 );
107 if !valid_default {
108 return Err(RegistryValidationError::InvalidDescriptorDefault {
109 selection: profile.selection.clone(),
110 setting: descriptor.id(),
111 });
112 }
113 }
114 let mut source_ids = BTreeSet::new();
115 for source in &profile.sources {
116 if !source_ids.insert(source.id()) {
117 return Err(RegistryValidationError::DuplicateSourceId {
118 selection: profile.selection.clone(),
119 source_id: source.id(),
120 });
121 }
122 for fact in source.supported_facts() {
123 if !fact_ids.contains(fact) {
124 return Err(RegistryValidationError::UnknownSourceFact {
125 selection: profile.selection.clone(),
126 source_id: source.id(),
127 fact: *fact,
128 });
129 }
130 if !matches!(
131 profile.fact(*fact).map(ProfileFact::state),
132 Some(FactState::Known(_))
133 ) {
134 return Err(RegistryValidationError::SourceReferencesNonKnownFact {
135 selection: profile.selection.clone(),
136 source_id: source.id(),
137 fact: *fact,
138 });
139 }
140 }
141 for setting in source.supported_settings() {
142 if !setting_ids.contains(setting) {
143 return Err(RegistryValidationError::UnknownSourceSetting {
144 selection: profile.selection.clone(),
145 source_id: source.id(),
146 setting: *setting,
147 });
148 }
149 }
150 }
151 for fact in &profile.facts {
152 if matches!(fact.state(), FactState::Known(_))
153 && !profile
154 .sources
155 .iter()
156 .any(|source| source.supported_facts().contains(&fact.id()))
157 {
158 return Err(RegistryValidationError::UnreferencedKnownFact {
159 selection: profile.selection.clone(),
160 fact: fact.id(),
161 });
162 }
163 }
164 for descriptor in &profile.settings {
165 if !profile
166 .sources
167 .iter()
168 .any(|source| source.supported_settings().contains(&descriptor.id()))
169 {
170 return Err(RegistryValidationError::UnreferencedSetting {
171 selection: profile.selection.clone(),
172 setting: descriptor.id(),
173 });
174 }
175 }
176 if facts_identity(profile) != profile.facts_identity {
177 return Err(RegistryValidationError::FactsIdentityMismatch {
178 selection: profile.selection.clone(),
179 });
180 }
181 }
182 Ok(())
183}
184
185fn unity_generic() -> EngineProfile {
186 let accepted = vec![SourceFormatV1::Fbx];
187 let facts = base_facts(
188 &accepted,
189 vec![
190 (
191 FactId::UnitConversionControl,
192 FactValue::ConversionControl(ConversionControl::ProfileSetting(
193 SettingId::ConvertUnits,
194 )),
195 ),
196 (
197 FactId::AxisConversionControl,
198 FactValue::ConversionControl(ConversionControl::ProfileSetting(
199 SettingId::BakeAxisConversion,
200 )),
201 ),
202 (
203 FactId::RootMotionAddressability,
204 FactValue::RootMotionAddressability(
205 RootMotionAddressability::ExactSourceTransformPath,
206 ),
207 ),
208 ],
209 );
210 finish_profile(
211 ProfileSelection::new("unity-generic", 1, "6000.3", "fbx-model-importer"),
212 "urn:animsmith:engine-profile:unity-generic:1",
213 facts,
214 unity_settings(true),
215 unity_sources(true),
216 )
217}
218
219fn unity_humanoid() -> EngineProfile {
220 let accepted = vec![SourceFormatV1::Fbx];
221 let facts = base_facts(
222 &accepted,
223 vec![
224 (
225 FactId::UnitConversionControl,
226 FactValue::ConversionControl(ConversionControl::ProfileSetting(
227 SettingId::ConvertUnits,
228 )),
229 ),
230 (
231 FactId::AxisConversionControl,
232 FactValue::ConversionControl(ConversionControl::ProfileSetting(
233 SettingId::BakeAxisConversion,
234 )),
235 ),
236 (
237 FactId::RootMotionAddressability,
238 FactValue::RootMotionAddressability(RootMotionAddressability::HumanoidAvatarBody),
239 ),
240 ],
241 );
242 finish_profile(
243 ProfileSelection::new("unity-humanoid", 1, "6000.3", "fbx-model-importer"),
244 "urn:animsmith:engine-profile:unity-humanoid:1",
245 facts,
246 unity_settings(false),
247 unity_sources(false),
248 )
249}
250
251fn unreal() -> EngineProfile {
252 let accepted = vec![SourceFormatV1::Fbx];
253 let facts = base_facts(
254 &accepted,
255 vec![
256 (
257 FactId::TargetCoordinateBasis,
258 FactValue::CoordinateBasis(CoordinateBasis {
259 handedness: Handedness::Left,
260 up_axis: UpAxis::Z,
261 forward_axis: ForwardAxis::PositiveX,
262 }),
263 ),
264 (
265 FactId::TargetLinearUnit,
266 FactValue::LinearUnit(LinearUnit::Centimetre),
267 ),
268 (
269 FactId::UnitConversionControl,
270 FactValue::ConversionControl(ConversionControl::ImporterOption),
271 ),
272 (
273 FactId::AxisConversionControl,
274 FactValue::ConversionControl(ConversionControl::ImporterOption),
275 ),
276 (FactId::WholeEndFrameRequired, FactValue::Boolean(true)),
277 ],
278 );
279 finish_profile(
280 ProfileSelection::new("unreal", 1, "5.8", "fbx-importer"),
281 "urn:animsmith:engine-profile:unreal:1",
282 facts,
283 vec![],
284 vec![
285 source(
286 "unreal-animation-sequences-5.8",
287 "5.8",
288 "https://dev.epicgames.com/documentation/en-us/unreal-engine/animation-sequences-in-unreal-engine?application_version=5.8",
289 &[FactId::WholeEndFrameRequired],
290 &[],
291 ),
292 source(
293 "unreal-coordinate-system-5.8",
294 "5.8",
295 "https://dev.epicgames.com/documentation/en-us/unreal-engine/coordinate-system-and-spaces-in-unreal-engine?application_version=5.8",
296 &[FactId::TargetCoordinateBasis],
297 &[],
298 ),
299 source(
300 "unreal-fbx-import-options-5.8",
301 "5.8",
302 "https://dev.epicgames.com/documentation/en-us/unreal-engine/fbx-import-options-reference-in-unreal-engine?application_version=5.8",
303 &[
304 FactId::AcceptedInputs,
305 FactId::UnitConversionControl,
306 FactId::AxisConversionControl,
307 ],
308 &[],
309 ),
310 source(
311 "unreal-units-5.8",
312 "5.8",
313 "https://dev.epicgames.com/documentation/en-us/unreal-engine/units-of-measurement-in-unreal-engine?application_version=5.8",
314 &[FactId::TargetLinearUnit],
315 &[],
316 ),
317 ],
318 )
319}
320
321fn godot() -> EngineProfile {
322 let accepted = vec![
323 SourceFormatV1::GltfJson,
324 SourceFormatV1::Glb,
325 SourceFormatV1::Fbx,
326 ];
327 let facts = base_facts(&accepted, vec![]);
328 finish_profile(
329 ProfileSelection::new("godot", 1, "4.7", "resource-importer-scene"),
330 "urn:animsmith:engine-profile:godot:1",
331 facts,
332 vec![],
333 vec![source(
334 "godot-resource-importer-scene-4.7",
335 "4.7",
336 "https://docs.godotengine.org/en/4.7/classes/class_resourceimporterscene.html",
337 &[FactId::AcceptedInputs],
338 &[],
339 )],
340 )
341}
342
343fn bevy() -> EngineProfile {
344 let accepted = vec![SourceFormatV1::GltfJson, SourceFormatV1::Glb];
345 let facts = base_facts(
346 &accepted,
347 vec![
348 (
349 FactId::AnimationAddressability,
350 FactValue::AnimationAddressability(AnimationAddressability::GltfAssetLabel),
351 ),
352 (
353 FactId::AnimationTargetAddressability,
354 FactValue::TargetAddressability(TargetAddressability::NamePathDerivedId),
355 ),
356 ],
357 );
358 finish_profile(
359 ProfileSelection::new("bevy", 1, "0.19.0", "gltf-asset-loader"),
360 "urn:animsmith:engine-profile:bevy:1",
361 facts,
362 vec![],
363 vec![
364 source(
365 "bevy-animation-target-id-0.19.0",
366 "0.19.0",
367 "https://docs.rs/bevy/0.19.0/bevy/animation/struct.AnimationTargetId.html",
368 &[FactId::AnimationTargetAddressability],
369 &[],
370 ),
371 source(
372 "bevy-gltf-asset-label-0.19.0",
373 "0.19.0",
374 "https://docs.rs/bevy/0.19.0/bevy/gltf/enum.GltfAssetLabel.html",
375 &[FactId::AcceptedInputs, FactId::AnimationAddressability],
376 &[],
377 ),
378 source(
379 "khronos-gltf-2.0",
380 "2.0",
381 "https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html",
382 &[FactId::AcceptedInputs],
383 &[],
384 ),
385 ],
386 )
387}
388
389fn base_facts(accepted: &[SourceFormatV1], known: Vec<(FactId, FactValue)>) -> Vec<ProfileFact> {
390 let mut accepted = accepted.to_vec();
391 accepted.sort_by_key(|format| crate::canonical::format_name(*format));
392 let known: std::collections::BTreeMap<_, _> = known.into_iter().collect();
393 ALL_FACT_IDS
394 .into_iter()
395 .map(|id| {
396 let state = if id == FactId::AcceptedInputs {
397 FactState::Known(FactValue::AcceptedFormats(accepted.clone()))
398 } else if let Some(value) = known.get(&id) {
399 FactState::Known(value.clone())
400 } else {
401 FactState::Unknown
402 };
403 ProfileFact::new(id, state)
404 })
405 .collect()
406}
407
408fn unity_settings(generic: bool) -> Vec<SettingDescriptor> {
409 use DefaultStatus::{NotApplicable, RequiredWithoutDefault};
410 use SettingApplicability::{Applicable, NotApplicable as DoesNotApply};
411 vec![
412 SettingDescriptor::new(
413 SettingId::ConvertUnits,
414 SettingScope::Document,
415 SettingDomain::Boolean,
416 Applicable,
417 RequiredWithoutDefault,
418 ),
419 SettingDescriptor::new(
420 SettingId::BakeAxisConversion,
421 SettingScope::Document,
422 SettingDomain::Boolean,
423 Applicable,
424 RequiredWithoutDefault,
425 ),
426 SettingDescriptor::new(
427 SettingId::RootMotionSource,
428 SettingScope::Document,
429 SettingDomain::SourceTransformPath,
430 if generic { Applicable } else { DoesNotApply },
431 if generic {
432 RequiredWithoutDefault
433 } else {
434 NotApplicable
435 },
436 ),
437 SettingDescriptor::new(
438 SettingId::RootRotation,
439 SettingScope::Clip,
440 SettingDomain::BakeOrExtract,
441 Applicable,
442 RequiredWithoutDefault,
443 ),
444 SettingDescriptor::new(
445 SettingId::RootPositionY,
446 SettingScope::Clip,
447 SettingDomain::BakeOrExtract,
448 Applicable,
449 RequiredWithoutDefault,
450 ),
451 SettingDescriptor::new(
452 SettingId::RootPositionXz,
453 SettingScope::Clip,
454 SettingDomain::BakeOrExtract,
455 Applicable,
456 RequiredWithoutDefault,
457 ),
458 ]
459}
460
461fn unity_sources(generic: bool) -> Vec<PrimarySource> {
462 let mut sources = vec![
463 source(
464 "unity-animation-clip-import-6000.3",
465 "6000.3",
466 "https://docs.unity3d.com/6000.3/Documentation/Manual/class-AnimationClip.html",
467 &[],
468 &[
469 SettingId::RootRotation,
470 SettingId::RootPositionY,
471 SettingId::RootPositionXz,
472 ],
473 ),
474 source(
475 "unity-model-import-6000.3",
476 "6000.3",
477 "https://docs.unity3d.com/6000.3/Documentation/Manual/FBXImporter-Model.html",
478 &[
479 FactId::AcceptedInputs,
480 FactId::UnitConversionControl,
481 FactId::AxisConversionControl,
482 ],
483 &[SettingId::ConvertUnits, SettingId::BakeAxisConversion],
484 ),
485 source(
486 "unity-model-importer-clip-animation-6000.3",
487 "6000.3",
488 "https://docs.unity3d.com/6000.3/Documentation/ScriptReference/ModelImporterClipAnimation.html",
489 &[],
490 &[
491 SettingId::RootRotation,
492 SettingId::RootPositionY,
493 SettingId::RootPositionXz,
494 ],
495 ),
496 source(
497 "unity-rig-import-6000.3",
498 "6000.3",
499 "https://docs.unity3d.com/6000.3/Documentation/Manual/FBXImporter-Rig.html",
500 &[FactId::RootMotionAddressability],
501 if generic {
502 &[]
503 } else {
504 &[SettingId::RootMotionSource]
505 },
506 ),
507 ];
508 if generic {
509 sources.push(source(
510 "unity-model-importer-motion-node-name-6000.3",
511 "6000.3",
512 "https://docs.unity3d.com/6000.3/Documentation/ScriptReference/ModelImporter-motionNodeName.html",
513 &[FactId::RootMotionAddressability],
514 &[SettingId::RootMotionSource],
515 ));
516 }
517 sources
518}
519
520fn source(
521 id: &'static str,
522 target_version: &'static str,
523 url: &'static str,
524 facts: &[FactId],
525 settings: &[SettingId],
526) -> PrimarySource {
527 PrimarySource::new(
528 id,
529 target_version,
530 url,
531 VERIFIED_ON,
532 facts.to_vec(),
533 settings.to_vec(),
534 )
535}
536
537fn finish_profile(
538 selection: ProfileSelection,
539 fact_bundle_urn: &'static str,
540 mut facts: Vec<ProfileFact>,
541 mut settings: Vec<SettingDescriptor>,
542 mut sources: Vec<PrimarySource>,
543) -> EngineProfile {
544 facts.sort_by_key(|fact| fact.id().as_str());
545 settings.sort_by_key(|descriptor| descriptor.id().as_str());
546 sources.sort_by_key(PrimarySource::id);
547 let mut profile = EngineProfile {
548 selection,
549 fact_bundle_urn,
550 facts,
551 settings,
552 sources,
553 facts_identity: InputIdentity::from_bytes(&[]),
554 };
555 profile.facts_identity = facts_identity(&profile);
556 profile
557}
558
559#[cfg(test)]
560mod tests {
561 use super::*;
562
563 fn without_fact(source: &PrimarySource, removed: FactId) -> PrimarySource {
564 PrimarySource::new(
565 source.id(),
566 source.target_version(),
567 source.url(),
568 source.verified_on(),
569 source
570 .supported_facts()
571 .iter()
572 .copied()
573 .filter(|id| *id != removed)
574 .collect(),
575 source.supported_settings().to_vec(),
576 )
577 }
578
579 fn without_setting(source: &PrimarySource, removed: SettingId) -> PrimarySource {
580 PrimarySource::new(
581 source.id(),
582 source.target_version(),
583 source.url(),
584 source.verified_on(),
585 source.supported_facts().to_vec(),
586 source
587 .supported_settings()
588 .iter()
589 .copied()
590 .filter(|id| *id != removed)
591 .collect(),
592 )
593 }
594
595 #[test]
596 fn validation_refuses_known_fact_without_source_coverage() {
597 let mut profiles = profiles_v1().to_vec();
598 let bevy = profiles
599 .iter_mut()
600 .find(|profile| profile.selection.family() == "bevy")
601 .unwrap();
602 bevy.sources = bevy
603 .sources
604 .iter()
605 .map(|source| without_fact(source, FactId::AnimationTargetAddressability))
606 .collect();
607 assert!(matches!(
608 validate_profiles(&profiles),
609 Err(RegistryValidationError::UnreferencedKnownFact {
610 fact: FactId::AnimationTargetAddressability,
611 ..
612 })
613 ));
614 }
615
616 #[test]
617 fn validation_refuses_not_applicable_descriptor_without_source_coverage() {
618 let mut profiles = profiles_v1().to_vec();
619 let humanoid = profiles
620 .iter_mut()
621 .find(|profile| profile.selection.family() == "unity-humanoid")
622 .unwrap();
623 humanoid.sources = humanoid
624 .sources
625 .iter()
626 .map(|source| without_setting(source, SettingId::RootMotionSource))
627 .collect();
628 assert!(matches!(
629 validate_profiles(&profiles),
630 Err(RegistryValidationError::UnreferencedSetting {
631 setting: SettingId::RootMotionSource,
632 ..
633 })
634 ));
635 }
636
637 #[test]
638 fn validation_refuses_every_noncanonical_accepted_input_fact_shape() {
639 for state in [
640 FactState::Unknown,
641 FactState::Known(FactValue::AcceptedFormats(vec![])),
642 FactState::Known(FactValue::AcceptedFormats(vec![
643 SourceFormatV1::GltfJson,
644 SourceFormatV1::Fbx,
645 ])),
646 ] {
647 let mut profiles = profiles_v1().to_vec();
648 let godot = profiles
649 .iter_mut()
650 .find(|profile| profile.selection.family() == "godot")
651 .unwrap();
652 let accepted = godot
653 .facts
654 .iter_mut()
655 .find(|fact| fact.id() == FactId::AcceptedInputs)
656 .unwrap();
657 *accepted = ProfileFact::new(FactId::AcceptedInputs, state);
658 assert!(matches!(
659 validate_profiles(&profiles),
660 Err(RegistryValidationError::InvalidAcceptedInputFact { .. })
661 ));
662 }
663 }
664}