1use crate::addressability::{
4 GltfAnimationAddressabilityBevyAdapterV1, GltfAnimationAddressabilityInventoryV1,
5};
6use crate::error::PredictionRuleError;
7use crate::registry::profiles_v1;
8use animsmith_core::{
9 Applicability, Check, CheckCtx, CheckOutput, CheckSelection, EngineAnimationAddressabilityV1,
10 EngineFactIdV1, EngineFactStateV1, EngineFactValueV1, EnginePredictionBasisV1,
11 EnginePredictionFacetV1, EnginePredictionV1, EvaluationScope, EvaluationScopeCode,
12 LoadedSource, PredictionBasisReferenceV1, PredictionProvenanceV1,
13 PredictionUnavailableReasonV1, RAW_SOURCE_V1_MAX_CLIPS, RawSourceBasisReferenceV1,
14 RawSourceBindingV1, RawSourceDomainV1, RawSourceFieldIdV1, RawSourceKeyV1, SourceFormatV1,
15 SourceSetCoverageStateV1, evaluate_checks,
16};
17
18#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
21#[error("source animation index {source_clip_index} is outside the V1 limit of {limit} animations")]
22pub struct BevyAnimationAssetLabelError {
23 pub source_clip_index: usize,
25 pub limit: usize,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
32#[non_exhaustive]
33pub enum GltfAnimationAddressabilityAdapterError {
34 #[error(transparent)]
36 PredictionRule(#[from] PredictionRuleError),
37 #[error(transparent)]
40 Evaluation(#[from] animsmith_core::EvaluationError),
41 #[error(transparent)]
44 Contract(#[from] crate::GltfAnimationAddressabilityError),
45}
46
47pub const ENGINE_ADDRESSABILITY_CHECK_ID: &str = "engine-addressability";
49
50pub const ENGINE_CHECK_IDS_V1: &[&str] = &[ENGINE_ADDRESSABILITY_CHECK_ID];
52
53const BEVY_FAMILY: &str = "bevy";
54const BEVY_PROFILE_REVISION: u32 = 1;
55const BEVY_ENGINE_VERSION: &str = "0.19.0";
56const BEVY_IMPORTER: &str = "gltf-asset-loader";
57const BEVY_ANIMATION_LABEL_SOURCE: &str = "bevy-gltf-asset-label-0.19.0";
58
59#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct BevyAnimationAssetLabelV1 {
67 source_clip_index: usize,
68 display_selector: String,
69}
70
71impl BevyAnimationAssetLabelV1 {
72 pub fn new(source_clip_index: usize) -> Result<Self, BevyAnimationAssetLabelError> {
79 if source_clip_index >= RAW_SOURCE_V1_MAX_CLIPS {
80 return Err(BevyAnimationAssetLabelError {
81 source_clip_index,
82 limit: RAW_SOURCE_V1_MAX_CLIPS,
83 });
84 }
85 Ok(Self {
86 source_clip_index,
87 display_selector: format!("Animation{source_clip_index}"),
88 })
89 }
90
91 pub const fn source_clip_index(&self) -> usize {
93 self.source_clip_index
94 }
95
96 pub fn as_str(&self) -> &str {
98 &self.display_selector
99 }
100}
101
102pub struct EngineAddressabilityCheck<'a> {
108 source: &'a LoadedSource,
109 provenance: Option<&'a PredictionProvenanceV1>,
110}
111
112impl<'a> EngineAddressabilityCheck<'a> {
113 pub fn new(
122 source: &'a LoadedSource,
123 provenance: Option<&'a PredictionProvenanceV1>,
124 ) -> Result<Self, PredictionRuleError> {
125 if let Some(provenance) = provenance {
126 let raw_source = RawSourceBindingV1::from_source(source.source_facts());
127 if &raw_source != provenance.raw_source()
128 || source.dependency_closure() != provenance.dependency_closure()
129 {
130 return Err(PredictionRuleError::SourceProvenanceMismatch);
131 }
132 if is_bevy_tuple(provenance)
133 && !profiles_v1().iter().any(|profile| {
134 let selection = profile.selection();
135 selection.family() == BEVY_FAMILY
136 && selection.profile_revision() == BEVY_PROFILE_REVISION
137 && selection.engine_version() == BEVY_ENGINE_VERSION
138 && selection.importer() == BEVY_IMPORTER
139 && profile.facts_identity() == provenance.profile().facts_identity()
140 })
141 {
142 return Err(PredictionRuleError::FrozenProfileMismatch);
143 }
144 }
145 Ok(Self { source, provenance })
146 }
147}
148
149impl Check for EngineAddressabilityCheck<'_> {
150 fn id(&self) -> &'static str {
151 ENGINE_ADDRESSABILITY_CHECK_ID
152 }
153
154 fn applicability(&self, _ctx: &CheckCtx<'_>) -> Applicability {
155 borrowed_applicability(self.source, self.provenance)
156 }
157
158 fn evaluate(&self, _ctx: &CheckCtx<'_>) -> CheckOutput {
159 let Some(provenance) = self.provenance else {
160 return CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new());
161 };
162 if !is_bevy_gltf(self.source, provenance) || facts_are_complete_and_empty(self.source) {
163 return CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new());
164 }
165 evaluate_animation_asset_labels(self.source, provenance)
166 }
167}
168
169pub fn build_bevy_animation_addressability_adapter_v1(
184 source: &LoadedSource,
185 inventory: &GltfAnimationAddressabilityInventoryV1,
186 prediction_provenance: Option<PredictionProvenanceV1>,
187 ctx: &CheckCtx<'_>,
188) -> Result<Option<GltfAnimationAddressabilityBevyAdapterV1>, GltfAnimationAddressabilityAdapterError>
189{
190 let Some(prediction_provenance) = prediction_provenance else {
191 return Ok(None);
192 };
193 let check = EngineAddressabilityCheck::new(source, Some(&prediction_provenance))?;
194 if !is_bevy_gltf(source, &prediction_provenance) {
195 return Ok(None);
196 }
197
198 let evaluation = {
199 let checks: [Box<dyn Check + '_>; 1] = [Box::new(check)];
200 let mut evaluations = evaluate_checks(ctx, &checks, CheckSelection::All)?;
201 let evaluation = evaluations
202 .pop()
203 .expect("one valid static check catalog produces one evaluation");
204 debug_assert!(evaluations.is_empty());
205 evaluation
206 };
207 Ok(Some(GltfAnimationAddressabilityBevyAdapterV1::new(
208 prediction_provenance,
209 evaluation,
210 inventory,
211 )?))
212}
213
214fn borrowed_applicability(
215 source: &LoadedSource,
216 provenance: Option<&PredictionProvenanceV1>,
217) -> Applicability {
218 let Some(provenance) = provenance else {
219 return Applicability::NotApplicable;
220 };
221 if !is_bevy_gltf(source, provenance) {
222 return Applicability::NotApplicable;
223 }
224 if facts_are_complete_and_empty(source) {
225 Applicability::NotApplicable
226 } else {
227 Applicability::Applicable
228 }
229}
230
231fn facts_are_complete_and_empty(source: &LoadedSource) -> bool {
232 let clips = source.source_facts().clips();
233 clips.coverage().state() == SourceSetCoverageStateV1::Complete && clips.rows().is_empty()
234}
235
236fn is_bevy_gltf(source: &LoadedSource, provenance: &PredictionProvenanceV1) -> bool {
237 let facts = source.source_facts();
238 facts.format() == provenance.source_format()
239 && is_bevy_tuple(provenance)
240 && matches!(
241 provenance.source_format(),
242 SourceFormatV1::GltfJson | SourceFormatV1::Glb
243 )
244 && matches!(
245 provenance
246 .profile()
247 .fact(EngineFactIdV1::AnimationAddressability)
248 .map(|fact| fact.state()),
249 Some(EngineFactStateV1::Known(
250 EngineFactValueV1::AnimationAddressability(
251 EngineAnimationAddressabilityV1::GltfAssetLabel
252 )
253 ))
254 )
255 && provenance
256 .profile()
257 .source(BEVY_ANIMATION_LABEL_SOURCE)
258 .is_some()
259}
260
261fn is_bevy_tuple(provenance: &PredictionProvenanceV1) -> bool {
262 let selection = provenance.profile().selection();
263 selection.family() == BEVY_FAMILY
264 && selection.profile_revision() == BEVY_PROFILE_REVISION
265 && selection.engine_version() == BEVY_ENGINE_VERSION
266 && selection.importer() == BEVY_IMPORTER
267}
268
269fn evaluate_animation_asset_labels(
270 source: &LoadedSource,
271 provenance: &PredictionProvenanceV1,
272) -> CheckOutput {
273 let facts = source.source_facts();
274 let inventory_scope =
275 EvaluationScope::new(EvaluationScopeCode::ANIMATION_ASSET_LABEL_INVENTORY);
276 if facts.format() != provenance.source_format()
277 || facts.clips().coverage().state() != SourceSetCoverageStateV1::Complete
278 {
279 return unavailable_inventory(provenance, inventory_scope);
280 }
281
282 let mut scopes = Vec::with_capacity(facts.clips().rows().len());
283 let mut facets = Vec::with_capacity(facts.clips().rows().len());
284 for clip in facts.clips().rows() {
285 let source_index = clip.source_clip_index();
286 let label = BevyAnimationAssetLabelV1::new(source_index)
287 .expect("loader-valid retained clip indices satisfy the V1 raw-source bound");
288 let scope = EvaluationScope::new(EvaluationScopeCode::ANIMATION_ASSET_LABEL)
289 .subject(label.as_str().to_owned());
290 let source_name = RawSourceBasisReferenceV1::from_source(
291 RawSourceDomainV1::Clip,
292 RawSourceKeyV1::Clip {
293 source_clip_index: source_index as u64,
294 },
295 RawSourceFieldIdV1::new("source_name.state").expect("static field is valid"),
296 facts,
297 )
298 .expect("loader-valid retained clip rows must resolve their own raw-source witness");
299 let basis = EnginePredictionBasisV1::new(vec![
300 PredictionBasisReferenceV1::profile_fact("animation_addressability")
301 .expect("static fact id is valid"),
302 PredictionBasisReferenceV1::primary_source(BEVY_ANIMATION_LABEL_SOURCE)
303 .expect("static primary-source id is valid"),
304 PredictionBasisReferenceV1::raw_source(source_name),
305 ])
306 .expect("three distinct static basis references are valid");
307 let facet = EnginePredictionFacetV1::available(scope.clone(), basis)
308 .expect("complete source rows have unique bounded scopes");
309 scopes.push(scope);
310 facets.push(facet);
311 }
312
313 if facets.is_empty() {
314 return CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new());
315 }
316 let prediction = EnginePredictionV1::new(provenance.identity().clone(), facets)
317 .expect("complete bounded source rows form one valid canonical prediction");
318 CheckOutput::from_coverage(Vec::new(), scopes, Vec::new()).with_engine_prediction(prediction)
319}
320
321fn unavailable_inventory(
322 provenance: &PredictionProvenanceV1,
323 scope: EvaluationScope,
324) -> CheckOutput {
325 let basis = EnginePredictionBasisV1::new(vec![
326 PredictionBasisReferenceV1::profile_fact("animation_addressability")
327 .expect("static fact id is valid"),
328 PredictionBasisReferenceV1::primary_source(BEVY_ANIMATION_LABEL_SOURCE)
329 .expect("static primary-source id is valid"),
330 ])
331 .expect("static basis is nonempty and canonical");
332 let facet = EnginePredictionFacetV1::required_unavailable(
333 scope,
334 basis,
335 vec![PredictionUnavailableReasonV1::RawSourceIncomplete],
336 )
337 .expect("static unavailable facet is valid");
338 let prediction = EnginePredictionV1::new(provenance.identity().clone(), vec![facet])
339 .expect("one static facet is valid");
340 CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new())
341 .with_engine_prediction(prediction)
342}