Skip to main content

animsmith_engine/
canonical.rs

1use crate::registry::profiles_v2;
2use crate::types::UnrealSampleRateV2;
3use crate::{
4    AnimationAddressability, BakeOrExtract, ConversionControl, DefaultStatus, EngineProfile,
5    EngineProfileV2, FactState, FactValue, ForwardAxis, Handedness, ImportHandling, LinearUnit,
6    ResolvedClipCoverageV2, ResolvedProfile, ResolvedProfileSettingsV2, ResolvedProfileV2,
7    ResolvedSettingOriginV2, RootMotionAddressability, SettingApplicability, SettingDefaultV2,
8    SettingDomainV2, SettingId, SettingIdV2, SettingScope, SettingValue, SettingValueV2,
9    TargetAddressability, UpAxis,
10};
11use animsmith_core::engine_contract::{
12    EngineAnimationAddressabilityV1, EngineBakeOrExtractV1, EngineClipSettingsV1,
13    EngineContractError, EngineConversionControlV1, EngineCoordinateBasisV1, EngineDefaultStatusV1,
14    EngineFactIdV1, EngineFactStateV1, EngineFactValueV1, EngineForwardAxisV1, EngineHandednessV1,
15    EngineImportHandlingV1, EngineLinearUnitV1, EnginePrimarySourceV1, EngineProfileFactV1,
16    EngineProfileSelectionV1, EngineRootMotionAddressabilityV1, EngineSettingApplicabilityV1,
17    EngineSettingDescriptorV1, EngineSettingDomainV1, EngineSettingIdV1, EngineSettingRowV1,
18    EngineSettingScopeV1, EngineSettingValueV1, EngineTargetAddressabilityV1, EngineUpAxisV1,
19    ResolvedEngineProfileV1, ResolvedEngineSettingsCoverageV2, ResolvedEngineSettingsV1,
20    ResolvedEngineSettingsV2, ResolvedEngineSettingsWorkV2,
21};
22use animsmith_core::engine_contract::{
23    EngineClipSettingsV3 as CoreClipSettingsV3, EnginePrimarySourceV2 as CorePrimarySourceV2,
24    EngineSampleRateV2 as CoreSampleRateV2, EngineSettingDescriptorV2 as CoreSettingDescriptorV2,
25    EngineSettingDomainV2 as CoreSettingDomainV2, EngineSettingIdV2 as CoreSettingIdV2,
26    EngineSettingRowV3, EngineSettingValueOriginV3, EngineSettingValueV2 as CoreSettingValueV2,
27    ResolvedEngineProfileV2 as CoreEngineProfileV2,
28    ResolvedEngineSettingsCoverageV2 as CoreSettingsCoverageV2,
29    ResolvedEngineSettingsV3 as CoreSettingsV3, ResolvedEngineSettingsWorkV2 as CoreSettingsWorkV2,
30};
31use animsmith_core::{InputIdentity, SourceFormatV1};
32use std::collections::BTreeMap;
33
34pub(crate) fn facts_identity(profile: &EngineProfile) -> InputIdentity {
35    project_profile(profile)
36        .expect("the frozen engine registry must project into its core-owned V1 contract")
37        .facts_identity()
38        .clone()
39}
40
41/// Whether a resolved core-owned V2 profile exactly reproduces its selected
42/// immutable registry projection.
43///
44/// Engine prediction rules retain their own tuple and source applicability
45/// predicates. This helper owns only the registry lookup and full-profile
46/// equality proof shared by those rules.
47pub(crate) fn matches_frozen_registry_projection_v2(profile: &CoreEngineProfileV2) -> bool {
48    profiles_v2().iter().any(|candidate| {
49        project_engine_profile_v2(candidate).is_ok_and(|projected| {
50            projected.selection() == profile.selection() && &projected == profile
51        })
52    })
53}
54
55pub(crate) fn settings_identity<'a>(
56    profile: &EngineProfile,
57    document: &BTreeMap<SettingId, SettingValue>,
58    clips: impl IntoIterator<Item = (&'a str, &'a BTreeMap<SettingId, SettingValue>)>,
59) -> Result<InputIdentity, EngineContractError> {
60    let profile = project_profile(profile)?;
61    Ok(project_settings(&profile, document, clips)?
62        .settings_identity()
63        .clone())
64}
65
66pub(crate) fn project_resolved_profile(
67    resolved: &ResolvedProfile,
68) -> Result<(ResolvedEngineProfileV1, ResolvedEngineSettingsV1), EngineContractError> {
69    let profile = project_profile(resolved.profile())?;
70    let settings = project_settings(
71        &profile,
72        resolved.document_settings(),
73        resolved
74            .clip_settings()
75            .iter()
76            .map(|clip| (clip.clip_name(), clip.settings())),
77    )?;
78    Ok((profile, settings))
79}
80
81/// Project one bounded V2 resolution into core-owned V2 settings.
82pub(crate) fn project_resolved_profile_v2(
83    resolved: &ResolvedProfileV2,
84) -> Result<(ResolvedEngineProfileV1, ResolvedEngineSettingsV2), EngineContractError> {
85    let profile = project_profile(resolved.profile())?;
86    let coverage = match resolved.clip_coverage() {
87        ResolvedClipCoverageV2::Complete => ResolvedEngineSettingsCoverageV2::complete(),
88        ResolvedClipCoverageV2::Partial { .. } => {
89            ResolvedEngineSettingsCoverageV2::actual_clip_rows_exceeded()
90        }
91    };
92    let work = resolved.work();
93    let settings = ResolvedEngineSettingsV2::new(
94        &profile,
95        resolved
96            .document_settings()
97            .iter()
98            .map(|(id, value)| EngineSettingRowV1::new(setting_id(*id), setting_value(value)))
99            .collect(),
100        resolved
101            .clip_settings()
102            .iter()
103            .map(|clip| {
104                EngineClipSettingsV1::new(
105                    clip.clip_name(),
106                    clip.settings()
107                        .iter()
108                        .map(|(id, value)| {
109                            EngineSettingRowV1::new(setting_id(*id), setting_value(value))
110                        })
111                        .collect(),
112                )
113            })
114            .collect::<Result<Vec<_>, _>>()?,
115        coverage,
116        ResolvedEngineSettingsWorkV2::new(
117            work.actual_clip_rows_inspected(),
118            work.materialized_clip_rows(),
119            work.retained_clip_rows(),
120        ),
121    )?;
122    Ok((profile, settings))
123}
124
125/// Project one engine-owned revision-2 profile into the core-owned V2 wire
126/// contract without changing the historical V1 projection.
127pub fn project_engine_profile_v2(
128    profile: &EngineProfileV2,
129) -> Result<CoreEngineProfileV2, EngineContractError> {
130    let selection = profile.selection();
131    CoreEngineProfileV2::new(
132        EngineProfileSelectionV1::new(
133            selection.family(),
134            selection.profile_revision(),
135            selection.engine_version(),
136            selection.importer(),
137        )?,
138        profile.profile_urn(),
139        profile.facts().to_vec(),
140        profile
141            .setting_descriptors()
142            .iter()
143            .map(|descriptor| {
144                CoreSettingDescriptorV2::new(
145                    setting_id_v2(descriptor.id()),
146                    match descriptor.scope() {
147                        SettingScope::Document => EngineSettingScopeV1::Document,
148                        SettingScope::Clip => EngineSettingScopeV1::Clip,
149                    },
150                    match descriptor.domain() {
151                        SettingDomainV2::Boolean => CoreSettingDomainV2::Boolean,
152                        SettingDomainV2::BakeOrExtract => CoreSettingDomainV2::BakeOrExtract,
153                        SettingDomainV2::SourceTransformPath => {
154                            CoreSettingDomainV2::SourceTransformPath
155                        }
156                        SettingDomainV2::AnimationType | SettingDomainV2::AvatarSetup => {
157                            CoreSettingDomainV2::Token
158                        }
159                        SettingDomainV2::LoadMeshesState | SettingDomainV2::HandlerEnvironment => {
160                            CoreSettingDomainV2::Token
161                        }
162                        SettingDomainV2::PositiveInteger => CoreSettingDomainV2::PositiveInteger,
163                        SettingDomainV2::SampleRate => CoreSettingDomainV2::SampleRate,
164                    },
165                    profile.accepted_inputs().to_vec(),
166                    match descriptor.default() {
167                        SettingDefaultV2::RequiredExplicit => None,
168                        SettingDefaultV2::Verified(value) => Some(setting_value_v2(value)),
169                    },
170                )
171            })
172            .collect::<Result<Vec<_>, _>>()?,
173        profile
174            .sources()
175            .iter()
176            .map(|source| {
177                CorePrimarySourceV2::new(
178                    source.id(),
179                    source.target_version(),
180                    source.url(),
181                    source.verified_on(),
182                    source.supported_facts().to_vec(),
183                    source
184                        .supported_settings()
185                        .iter()
186                        .copied()
187                        .map(setting_id_v2)
188                        .collect(),
189                )
190            })
191            .collect::<Result<Vec<_>, _>>()?,
192    )
193}
194
195/// Project input-validated revision-2 settings into the core-owned V3
196/// origin-bearing contract.
197pub fn project_resolved_engine_settings_v3(
198    resolved: &ResolvedProfileSettingsV2,
199) -> Result<(CoreEngineProfileV2, CoreSettingsV3), EngineContractError> {
200    let profile = project_engine_profile_v2(resolved.profile())?;
201    let document_settings = resolved
202        .document_settings()
203        .iter()
204        .map(|(id, resolved)| {
205            EngineSettingRowV3::new(
206                setting_id_v2(*id),
207                setting_value_v2(resolved.value()),
208                match resolved.origin() {
209                    ResolvedSettingOriginV2::ExplicitConfig => {
210                        EngineSettingValueOriginV3::ExplicitConfig
211                    }
212                    ResolvedSettingOriginV2::ProfileDefault => {
213                        EngineSettingValueOriginV3::ProfileDefault
214                    }
215                },
216            )
217        })
218        .collect::<Vec<_>>();
219    let clips = resolved
220        .clip_settings()
221        .iter()
222        .map(|clip| {
223            CoreClipSettingsV3::new(
224                clip.clip_ordinal(),
225                clip.clip_name(),
226                clip.settings()
227                    .iter()
228                    .map(|(id, resolved)| {
229                        EngineSettingRowV3::new(
230                            setting_id_v2(*id),
231                            setting_value_v2(resolved.value()),
232                            match resolved.origin() {
233                                ResolvedSettingOriginV2::ExplicitConfig => {
234                                    EngineSettingValueOriginV3::ExplicitConfig
235                                }
236                                ResolvedSettingOriginV2::ProfileDefault => {
237                                    EngineSettingValueOriginV3::ProfileDefault
238                                }
239                            },
240                        )
241                    })
242                    .collect(),
243            )
244        })
245        .collect::<Result<Vec<_>, _>>()?;
246    let coverage = match resolved.clip_coverage() {
247        ResolvedClipCoverageV2::Complete => CoreSettingsCoverageV2::complete(),
248        ResolvedClipCoverageV2::Partial { .. } => {
249            CoreSettingsCoverageV2::actual_clip_rows_exceeded()
250        }
251    };
252    let work = resolved.work();
253    let settings = CoreSettingsV3::new(
254        &profile,
255        resolved.source_format(),
256        document_settings,
257        clips,
258        coverage,
259        CoreSettingsWorkV2::new(
260            work.actual_clip_rows_inspected(),
261            work.materialized_clip_rows(),
262            work.retained_clip_rows(),
263        ),
264    )?;
265    Ok((profile, settings))
266}
267
268fn project_profile(
269    profile: &EngineProfile,
270) -> Result<ResolvedEngineProfileV1, EngineContractError> {
271    let selection = profile.selection();
272    ResolvedEngineProfileV1::new(
273        EngineProfileSelectionV1::new(
274            selection.family(),
275            selection.profile_revision(),
276            selection.engine_version(),
277            selection.importer(),
278        )?,
279        profile.fact_bundle_urn(),
280        profile
281            .facts()
282            .iter()
283            .map(|fact| EngineProfileFactV1::new(fact_id(fact.id()), fact_state(fact.state())))
284            .collect(),
285        profile
286            .setting_descriptors()
287            .iter()
288            .map(|descriptor| {
289                EngineSettingDescriptorV1::new(
290                    setting_id(descriptor.id()),
291                    match descriptor.scope() {
292                        SettingScope::Document => EngineSettingScopeV1::Document,
293                        SettingScope::Clip => EngineSettingScopeV1::Clip,
294                    },
295                    match descriptor.domain() {
296                        crate::SettingDomain::Boolean => EngineSettingDomainV1::Boolean,
297                        crate::SettingDomain::BakeOrExtract => EngineSettingDomainV1::BakeOrExtract,
298                        crate::SettingDomain::SourceTransformPath => {
299                            EngineSettingDomainV1::SourceTransformPath
300                        }
301                    },
302                    match descriptor.applicability() {
303                        SettingApplicability::Applicable => {
304                            EngineSettingApplicabilityV1::Applicable
305                        }
306                        SettingApplicability::NotApplicable => {
307                            EngineSettingApplicabilityV1::NotApplicable
308                        }
309                    },
310                    match descriptor.default_status() {
311                        DefaultStatus::RequiredWithoutDefault => {
312                            EngineDefaultStatusV1::RequiredWithoutDefault
313                        }
314                        DefaultStatus::NotApplicable => EngineDefaultStatusV1::NotApplicable,
315                    },
316                )
317            })
318            .collect(),
319        profile
320            .sources()
321            .iter()
322            .map(|source| {
323                EnginePrimarySourceV1::new(
324                    source.id(),
325                    source.target_version(),
326                    source.url(),
327                    source.verified_on(),
328                    source
329                        .supported_facts()
330                        .iter()
331                        .copied()
332                        .map(fact_id)
333                        .collect(),
334                    source
335                        .supported_settings()
336                        .iter()
337                        .copied()
338                        .map(setting_id)
339                        .collect(),
340                )
341            })
342            .collect::<Result<Vec<_>, _>>()?,
343    )
344}
345
346fn project_settings<'a>(
347    profile: &ResolvedEngineProfileV1,
348    document: &BTreeMap<SettingId, SettingValue>,
349    clips: impl IntoIterator<Item = (&'a str, &'a BTreeMap<SettingId, SettingValue>)>,
350) -> Result<ResolvedEngineSettingsV1, EngineContractError> {
351    ResolvedEngineSettingsV1::new(
352        profile,
353        document
354            .iter()
355            .map(|(id, value)| EngineSettingRowV1::new(setting_id(*id), setting_value(value)))
356            .collect(),
357        clips
358            .into_iter()
359            .map(|(clip_name, values)| {
360                EngineClipSettingsV1::new(
361                    clip_name,
362                    values
363                        .iter()
364                        .map(|(id, value)| {
365                            EngineSettingRowV1::new(setting_id(*id), setting_value(value))
366                        })
367                        .collect(),
368                )
369            })
370            .collect::<Result<Vec<_>, _>>()?,
371    )
372}
373
374/// Validate one materialized clip row before a bounded resolver advances.
375pub(crate) fn validate_clip_settings(
376    clip_name: &str,
377    values: &BTreeMap<SettingId, SettingValue>,
378) -> Result<(), EngineContractError> {
379    EngineClipSettingsV1::new(
380        clip_name,
381        values
382            .iter()
383            .map(|(id, value)| EngineSettingRowV1::new(setting_id(*id), setting_value(value)))
384            .collect(),
385    )?;
386    Ok(())
387}
388
389fn fact_state(state: &FactState) -> EngineFactStateV1 {
390    match state {
391        FactState::Known(value) => EngineFactStateV1::Known(fact_value(value)),
392        FactState::Unknown => EngineFactStateV1::Unknown,
393        FactState::NotApplicable => EngineFactStateV1::NotApplicable,
394    }
395}
396
397fn fact_value(value: &FactValue) -> EngineFactValueV1 {
398    match value {
399        FactValue::AcceptedFormats(formats) => EngineFactValueV1::AcceptedFormats(formats.clone()),
400        FactValue::AnimationAddressability(AnimationAddressability::GltfAssetLabel) => {
401            EngineFactValueV1::AnimationAddressability(
402                EngineAnimationAddressabilityV1::GltfAssetLabel,
403            )
404        }
405        FactValue::CoordinateBasis(value) => {
406            EngineFactValueV1::CoordinateBasis(EngineCoordinateBasisV1 {
407                handedness: match value.handedness {
408                    Handedness::Left => EngineHandednessV1::Left,
409                    Handedness::Right => EngineHandednessV1::Right,
410                },
411                up_axis: match value.up_axis {
412                    UpAxis::X => EngineUpAxisV1::X,
413                    UpAxis::Y => EngineUpAxisV1::Y,
414                    UpAxis::Z => EngineUpAxisV1::Z,
415                },
416                forward_axis: match value.forward_axis {
417                    ForwardAxis::PositiveX => EngineForwardAxisV1::PositiveX,
418                    ForwardAxis::NegativeX => EngineForwardAxisV1::NegativeX,
419                    ForwardAxis::PositiveY => EngineForwardAxisV1::PositiveY,
420                    ForwardAxis::NegativeY => EngineForwardAxisV1::NegativeY,
421                    ForwardAxis::PositiveZ => EngineForwardAxisV1::PositiveZ,
422                    ForwardAxis::NegativeZ => EngineForwardAxisV1::NegativeZ,
423                },
424            })
425        }
426        FactValue::LinearUnit(value) => EngineFactValueV1::LinearUnit(match value {
427            LinearUnit::Metre => EngineLinearUnitV1::Metre,
428            LinearUnit::Centimetre => EngineLinearUnitV1::Centimetre,
429        }),
430        FactValue::ConversionControl(value) => EngineFactValueV1::ConversionControl(match value {
431            ConversionControl::ProfileSetting(id) => {
432                EngineConversionControlV1::ProfileSetting(setting_id(*id))
433            }
434            ConversionControl::ImporterOption => EngineConversionControlV1::ImporterOption,
435        }),
436        FactValue::Boolean(value) => EngineFactValueV1::Boolean(*value),
437        FactValue::ImportHandling(value) => EngineFactValueV1::ImportHandling(match value {
438            ImportHandling::Preserved => EngineImportHandlingV1::Preserved,
439            ImportHandling::Converted => EngineImportHandlingV1::Converted,
440            ImportHandling::Discarded => EngineImportHandlingV1::Discarded,
441            ImportHandling::Unsupported => EngineImportHandlingV1::Unsupported,
442        }),
443        FactValue::TargetAddressability(TargetAddressability::NamePathDerivedId) => {
444            EngineFactValueV1::TargetAddressability(EngineTargetAddressabilityV1::NamePathDerivedId)
445        }
446        FactValue::RootMotionAddressability(value) => {
447            EngineFactValueV1::RootMotionAddressability(match value {
448                RootMotionAddressability::ExactSourceTransformPath => {
449                    EngineRootMotionAddressabilityV1::ExactSourceTransformPath
450                }
451                RootMotionAddressability::HumanoidAvatarBody => {
452                    EngineRootMotionAddressabilityV1::HumanoidAvatarBody
453                }
454            })
455        }
456    }
457}
458
459fn setting_value(value: &SettingValue) -> EngineSettingValueV1 {
460    match value {
461        SettingValue::Boolean(value) => EngineSettingValueV1::Boolean(*value),
462        SettingValue::BakeOrExtract(value) => EngineSettingValueV1::BakeOrExtract(match value {
463            BakeOrExtract::Bake => EngineBakeOrExtractV1::Bake,
464            BakeOrExtract::Extract => EngineBakeOrExtractV1::Extract,
465        }),
466        SettingValue::SourceTransformPath(value) => {
467            EngineSettingValueV1::SourceTransformPath(value.clone())
468        }
469    }
470}
471
472const fn fact_id(id: crate::FactId) -> EngineFactIdV1 {
473    match id {
474        crate::FactId::AcceptedInputs => EngineFactIdV1::AcceptedInputs,
475        crate::FactId::AnimationAddressability => EngineFactIdV1::AnimationAddressability,
476        crate::FactId::TargetCoordinateBasis => EngineFactIdV1::TargetCoordinateBasis,
477        crate::FactId::TargetLinearUnit => EngineFactIdV1::TargetLinearUnit,
478        crate::FactId::UnitConversionControl => EngineFactIdV1::UnitConversionControl,
479        crate::FactId::AxisConversionControl => EngineFactIdV1::AxisConversionControl,
480        crate::FactId::ExactAxisConversion => EngineFactIdV1::ExactAxisConversion,
481        crate::FactId::ResultingHierarchyScale => EngineFactIdV1::ResultingHierarchyScale,
482        crate::FactId::WholeEndFrameRequired => EngineFactIdV1::WholeEndFrameRequired,
483        crate::FactId::AnimationChannelHandling => EngineFactIdV1::AnimationChannelHandling,
484        crate::FactId::ExtensionHandling => EngineFactIdV1::ExtensionHandling,
485        crate::FactId::ConstructHandling => EngineFactIdV1::ConstructHandling,
486        crate::FactId::AnimationTargetAddressability => {
487            EngineFactIdV1::AnimationTargetAddressability
488        }
489        crate::FactId::RootMotionAddressability => EngineFactIdV1::RootMotionAddressability,
490    }
491}
492
493const fn setting_id(id: SettingId) -> EngineSettingIdV1 {
494    match id {
495        SettingId::ConvertUnits => EngineSettingIdV1::ConvertUnits,
496        SettingId::BakeAxisConversion => EngineSettingIdV1::BakeAxisConversion,
497        SettingId::RootMotionSource => EngineSettingIdV1::RootMotionSource,
498        SettingId::RootRotation => EngineSettingIdV1::RootRotation,
499        SettingId::RootPositionY => EngineSettingIdV1::RootPositionY,
500        SettingId::RootPositionXz => EngineSettingIdV1::RootPositionXz,
501    }
502}
503
504const fn setting_id_v2(id: SettingIdV2) -> CoreSettingIdV2 {
505    match id {
506        SettingIdV2::AnimationType => CoreSettingIdV2::AnimationType,
507        SettingIdV2::AvatarSetup => CoreSettingIdV2::AvatarSetup,
508        SettingIdV2::ImportAnimation => CoreSettingIdV2::ImportAnimation,
509        SettingIdV2::RootMotionSource => CoreSettingIdV2::RootMotionSource,
510        SettingIdV2::RootRotation => CoreSettingIdV2::RootRotation,
511        SettingIdV2::RootPositionY => CoreSettingIdV2::RootPositionY,
512        SettingIdV2::RootPositionXz => CoreSettingIdV2::RootPositionXz,
513        SettingIdV2::RotateSceneEntity => CoreSettingIdV2::RotateSceneEntity,
514        SettingIdV2::RotateMeshes => CoreSettingIdV2::RotateMeshes,
515        SettingIdV2::LoadMeshes => CoreSettingIdV2::LoadMeshes,
516        SettingIdV2::ExtensionHandlerEnvironment => CoreSettingIdV2::ExtensionHandlerEnvironment,
517        SettingIdV2::BevyAnimationFeature => CoreSettingIdV2::BevyAnimationFeature,
518        SettingIdV2::LoadAnimations => CoreSettingIdV2::LoadAnimations,
519        SettingIdV2::AnimationFps => CoreSettingIdV2::AnimationFps,
520        SettingIdV2::AnimationTrimming => CoreSettingIdV2::AnimationTrimming,
521        SettingIdV2::SampleRate => CoreSettingIdV2::SampleRate,
522    }
523}
524
525fn setting_value_v2(value: &SettingValueV2) -> CoreSettingValueV2 {
526    match value {
527        SettingValueV2::Boolean(value) => CoreSettingValueV2::Boolean(*value),
528        SettingValueV2::BakeOrExtract(value) => CoreSettingValueV2::BakeOrExtract(match value {
529            crate::BakeOrExtract::Bake => {
530                animsmith_core::engine_contract::EngineBakeOrExtractV1::Bake
531            }
532            crate::BakeOrExtract::Extract => {
533                animsmith_core::engine_contract::EngineBakeOrExtractV1::Extract
534            }
535        }),
536        SettingValueV2::SourceTransformPath(value) => {
537            CoreSettingValueV2::SourceTransformPath(value.clone())
538        }
539        SettingValueV2::AnimationType(value) => CoreSettingValueV2::Token(value.as_str().into()),
540        SettingValueV2::AvatarSetup(value) => CoreSettingValueV2::Token(value.as_str().into()),
541        SettingValueV2::LoadMeshesState(value) => CoreSettingValueV2::Token(
542            match value {
543                crate::BevyLoadMeshesStateV2::Empty => "empty",
544                crate::BevyLoadMeshesStateV2::Nonempty => "nonempty",
545            }
546            .into(),
547        ),
548        SettingValueV2::HandlerEnvironment(value) => {
549            CoreSettingValueV2::Token(value.as_str().into())
550        }
551        SettingValueV2::PositiveInteger(value) => CoreSettingValueV2::PositiveInteger(*value),
552        SettingValueV2::SampleRate(value) => CoreSettingValueV2::SampleRate(match value {
553            UnrealSampleRateV2::Default30 => CoreSampleRateV2::Default30,
554            UnrealSampleRateV2::SourceDetermined => CoreSampleRateV2::SourceDetermined,
555            UnrealSampleRateV2::CustomHz(value) => CoreSampleRateV2::CustomHz(*value),
556        }),
557    }
558}
559
560pub(crate) const fn format_name(format: SourceFormatV1) -> &'static str {
561    match format {
562        SourceFormatV1::GltfJson => "gltf_json",
563        SourceFormatV1::Glb => "glb",
564        SourceFormatV1::Fbx => "fbx",
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571    use crate::{
572        FactId, ProfileFact, SettingDescriptor, SettingDomainV2, SettingValueV2,
573        types::UnrealSampleRateV2,
574    };
575
576    #[test]
577    fn advice_setting_values_map_to_the_closed_core_vocabulary_and_bounds() {
578        assert_eq!(
579            setting_value_v2(&SettingValueV2::PositiveInteger(120)),
580            CoreSettingValueV2::PositiveInteger(120)
581        );
582        assert!(
583            !SettingValueV2::PositiveInteger(0).matches_domain(SettingDomainV2::PositiveInteger)
584        );
585        assert!(
586            !SettingValueV2::PositiveInteger(121).matches_domain(SettingDomainV2::PositiveInteger)
587        );
588        for (public, core) in [
589            (UnrealSampleRateV2::Default30, CoreSampleRateV2::Default30),
590            (
591                UnrealSampleRateV2::SourceDetermined,
592                CoreSampleRateV2::SourceDetermined,
593            ),
594            (
595                UnrealSampleRateV2::CustomHz(48_000),
596                CoreSampleRateV2::CustomHz(48_000),
597            ),
598        ] {
599            assert!(SettingValueV2::SampleRate(public).matches_domain(SettingDomainV2::SampleRate));
600            assert_eq!(
601                setting_value_v2(&SettingValueV2::SampleRate(public)),
602                CoreSettingValueV2::SampleRate(core)
603            );
604        }
605        for invalid in [0, 48_001] {
606            assert!(
607                !SettingValueV2::SampleRate(UnrealSampleRateV2::CustomHz(invalid))
608                    .matches_domain(SettingDomainV2::SampleRate)
609            );
610        }
611    }
612
613    #[test]
614    fn facts_encoding_sorts_every_repeated_stable_id() {
615        for original in crate::profiles_v1() {
616            let expected = facts_identity(original);
617            let mut reordered = original.clone();
618            let accepted = reordered
619                .facts
620                .iter_mut()
621                .find(|fact| fact.id() == FactId::AcceptedInputs)
622                .unwrap();
623            if let FactState::Known(FactValue::AcceptedFormats(formats)) = accepted.state() {
624                let mut formats = formats.clone();
625                formats.reverse();
626                *accepted = ProfileFact::new(
627                    FactId::AcceptedInputs,
628                    FactState::Known(FactValue::AcceptedFormats(formats)),
629                );
630            }
631            reordered.facts.reverse();
632            reordered.settings.reverse();
633            reordered.sources.reverse();
634            for source in &mut reordered.sources {
635                source.reverse_supported_ids_for_test();
636            }
637            assert_eq!(
638                facts_identity(&reordered),
639                expected,
640                "{:?}",
641                original.selection()
642            );
643        }
644    }
645
646    #[test]
647    fn facts_encoding_is_sensitive_to_each_record_domain() {
648        let original = crate::profiles_v1()
649            .iter()
650            .find(|profile| profile.selection.family() == "unity-generic")
651            .unwrap()
652            .clone();
653        let expected = facts_identity(&original);
654        let changed = |profile: &EngineProfile| {
655            if let Ok(projected) = project_profile(profile) {
656                assert_ne!(projected.facts_identity(), &expected);
657            }
658        };
659
660        let mut profile = original.clone();
661        profile.selection = crate::ProfileSelection::new(
662            "unity-generic-changed",
663            1,
664            "6000.3",
665            "fbx-model-importer",
666        );
667        changed(&profile);
668
669        let mut profile = original.clone();
670        profile.selection =
671            crate::ProfileSelection::new("unity-generic", 2, "6000.3", "fbx-model-importer");
672        changed(&profile);
673
674        let mut profile = original.clone();
675        profile.selection =
676            crate::ProfileSelection::new("unity-generic", 1, "6000.4", "fbx-model-importer");
677        changed(&profile);
678
679        let mut profile = original.clone();
680        profile.selection =
681            crate::ProfileSelection::new("unity-generic", 1, "6000.3", "other-importer");
682        changed(&profile);
683
684        let mut profile = original.clone();
685        profile.fact_bundle_urn = "urn:animsmith:engine-profile:unity-generic:changed";
686        changed(&profile);
687
688        let mut profile = original.clone();
689        let fact = profile
690            .facts
691            .iter_mut()
692            .find(|fact| fact.id() == FactId::ExactAxisConversion)
693            .unwrap();
694        *fact = ProfileFact::new(FactId::ExactAxisConversion, FactState::NotApplicable);
695        changed(&profile);
696
697        let mut profile = original.clone();
698        let fact = profile
699            .facts
700            .iter_mut()
701            .find(|fact| fact.id() == FactId::AcceptedInputs)
702            .unwrap();
703        *fact = ProfileFact::new(
704            FactId::AcceptedInputs,
705            FactState::Known(FactValue::AcceptedFormats(vec![
706                SourceFormatV1::Fbx,
707                SourceFormatV1::Glb,
708            ])),
709        );
710        changed(&profile);
711
712        let mut profile = original.clone();
713        let descriptor = profile.settings[0].clone();
714        profile.settings[0] = SettingDescriptor::new(
715            descriptor.id(),
716            descriptor.scope(),
717            crate::SettingDomain::BakeOrExtract,
718            descriptor.applicability(),
719            descriptor.default_status(),
720        );
721        changed(&profile);
722
723        let mut profile = original.clone();
724        let descriptor = profile.settings[0].clone();
725        profile.settings[0] = SettingDescriptor::new(
726            descriptor.id(),
727            crate::SettingScope::Clip,
728            descriptor.domain(),
729            descriptor.applicability(),
730            descriptor.default_status(),
731        );
732        changed(&profile);
733
734        let mut profile = original.clone();
735        let descriptor = profile.settings[0].clone();
736        profile.settings[0] = SettingDescriptor::new(
737            descriptor.id(),
738            descriptor.scope(),
739            descriptor.domain(),
740            crate::SettingApplicability::NotApplicable,
741            descriptor.default_status(),
742        );
743        changed(&profile);
744
745        let mut profile = original.clone();
746        let descriptor = profile.settings[0].clone();
747        profile.settings[0] = SettingDescriptor::new(
748            descriptor.id(),
749            descriptor.scope(),
750            descriptor.domain(),
751            descriptor.applicability(),
752            crate::DefaultStatus::NotApplicable,
753        );
754        changed(&profile);
755
756        let mut profile = original.clone();
757        let source = profile.sources[0].clone();
758        profile.sources[0] = crate::PrimarySource::new(
759            "changed-source-id",
760            source.target_version(),
761            source.url(),
762            source.verified_on(),
763            source.supported_facts().to_vec(),
764            source.supported_settings().to_vec(),
765        );
766        changed(&profile);
767
768        let replace_source = |profile: &mut EngineProfile,
769                              target_version,
770                              url,
771                              verified_on,
772                              facts: Vec<_>,
773                              settings: Vec<_>| {
774            let source = profile.sources[0].clone();
775            profile.sources[0] = crate::PrimarySource::new(
776                source.id(),
777                target_version,
778                url,
779                verified_on,
780                facts,
781                settings,
782            );
783        };
784
785        let mut profile = original.clone();
786        let source = profile.sources[0].clone();
787        replace_source(
788            &mut profile,
789            "changed-version",
790            source.url(),
791            source.verified_on(),
792            source.supported_facts().to_vec(),
793            source.supported_settings().to_vec(),
794        );
795        changed(&profile);
796
797        let mut profile = original.clone();
798        let source = profile.sources[0].clone();
799        replace_source(
800            &mut profile,
801            source.target_version(),
802            "https://example.invalid/changed",
803            source.verified_on(),
804            source.supported_facts().to_vec(),
805            source.supported_settings().to_vec(),
806        );
807        changed(&profile);
808
809        let mut profile = original.clone();
810        let source = profile.sources[0].clone();
811        replace_source(
812            &mut profile,
813            source.target_version(),
814            source.url(),
815            "2099-01-01",
816            source.supported_facts().to_vec(),
817            source.supported_settings().to_vec(),
818        );
819        changed(&profile);
820
821        let mut profile = original.clone();
822        let source = profile.sources[0].clone();
823        let mut facts = source.supported_facts().to_vec();
824        facts.push(FactId::AcceptedInputs);
825        replace_source(
826            &mut profile,
827            source.target_version(),
828            source.url(),
829            source.verified_on(),
830            facts,
831            source.supported_settings().to_vec(),
832        );
833        changed(&profile);
834
835        let mut profile = original.clone();
836        let source = profile.sources[0].clone();
837        let mut settings = source.supported_settings().to_vec();
838        settings.pop();
839        replace_source(
840            &mut profile,
841            source.target_version(),
842            source.url(),
843            source.verified_on(),
844            source.supported_facts().to_vec(),
845            settings,
846        );
847        changed(&profile);
848
849        let unreal = crate::profiles_v1()
850            .iter()
851            .find(|profile| profile.selection.family() == "unreal")
852            .unwrap();
853        let expected = facts_identity(unreal);
854        let mut profile = unreal.clone();
855        let fact = profile
856            .facts
857            .iter_mut()
858            .find(|fact| fact.id() == FactId::TargetCoordinateBasis)
859            .unwrap();
860        *fact = ProfileFact::new(
861            FactId::TargetCoordinateBasis,
862            FactState::Known(FactValue::CoordinateBasis(crate::CoordinateBasis {
863                handedness: crate::Handedness::Left,
864                up_axis: crate::UpAxis::Z,
865                forward_axis: crate::ForwardAxis::NegativeX,
866            })),
867        );
868        assert_ne!(facts_identity(&profile), expected);
869    }
870
871    #[test]
872    fn frozen_registry_projection_requires_full_profile_equality() {
873        let registry_profile = crate::profiles_v2()
874            .iter()
875            .find(|profile| {
876                let selection = profile.selection();
877                selection.family() == "bevy"
878                    && selection.profile_revision() == 2
879                    && selection.engine_version() == "0.19.0"
880                    && selection.importer() == "gltf-asset-loader"
881            })
882            .expect("frozen Bevy revision-2 profile");
883        let projected = project_engine_profile_v2(registry_profile).unwrap();
884        assert!(matches_frozen_registry_projection_v2(&projected));
885
886        let mut altered = registry_profile.clone();
887        altered.profile_urn = "urn:animsmith:engine-profile:bevy:revision-2-altered";
888        let altered_projection = project_engine_profile_v2(&altered).unwrap();
889        assert_ne!(altered_projection, projected);
890        assert!(
891            !matches_frozen_registry_projection_v2(&altered_projection),
892            "a same-tuple profile mutation must not satisfy the registry proof"
893        );
894    }
895}