1use crate::error::PredictionRuleError;
4use crate::{SettingIdV2, profiles_v2};
5use animsmith_core::engine_contract::{EngineSettingIdV2, EngineSettingValueV2};
6use animsmith_core::prediction::{EnginePredictionBasisV4, PredictionBasisReferenceV4};
7use animsmith_core::{
8 Applicability, Check, CheckCtx, CheckOutput, EngineMachineResultV1, EnginePredictionFacetV4,
9 EnginePredictionV4, EnginePredictionV5, EvaluationScope, EvaluationScopeCode, LoadedSource,
10 PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE, PredictionBasisReferenceV1,
11 PredictionBasisReferenceV2, PredictionFacetDemandV2, PredictionProvenanceV5,
12 PredictionRuleAllocationV2, PredictionScalarV1, PredictionUnavailableReasonV2,
13 RawAnimationChannelInventoryV1, ResolvedSettingLocationV1, SourceFormatV1,
14 SourceImportDispositionResultV1, SourceImportDispositionV1, SourceImportSubjectKindV1,
15};
16
17pub const ENGINE_TRACK_SUPPORT_CHECK_ID: &str = "engine-track-support";
19
20const BEVY_FAMILY: &str = "bevy";
21const BEVY_PROFILE_REVISION: u32 = 3;
22const BEVY_ENGINE_VERSION: &str = "0.19.0";
23const BEVY_IMPORTER: &str = "gltf-asset-loader";
24const BEVY_LOADER_SOURCE: &str = "bevy-gltf-loader-0.19.0-c6f634ca";
25const BEVY_MANIFEST_SOURCE: &str = "bevy-feature-manifest-0.19.0-c6f634ca";
26
27const ANIMATION_SCOPE: &str = "engine-track-support:animation";
28const CHANNEL_SCOPE: &str = "engine-track-support:animation-channel";
29const INVENTORY_SCOPE: &str = "engine-track-support:inventory";
30const BUDGET_SCOPE: &str = "engine-track-support:facet-budget";
31
32pub struct EngineTrackSupportCheck<'a> {
34 source: &'a LoadedSource,
35 provenance: Option<&'a PredictionProvenanceV5>,
36}
37
38impl<'a> EngineTrackSupportCheck<'a> {
39 pub fn new(
41 source: &'a LoadedSource,
42 provenance: Option<&'a PredictionProvenanceV5>,
43 ) -> Result<Self, PredictionRuleError> {
44 if let Some(provenance) = provenance {
45 provenance
46 .validate()
47 .map_err(|_| PredictionRuleError::SourceProvenanceMismatch)?;
48 let raw_source = animsmith_core::RawSourceBindingV2::from_source(
49 source.source_facts(),
50 source.exact_source_timing(),
51 )
52 .map_err(|_| PredictionRuleError::SourceProvenanceMismatch)?;
53 if provenance.base().raw_source() != &raw_source
54 || provenance.raw_animation_channels()
55 != &RawAnimationChannelInventoryV1::from_source(source.source_facts())
56 || provenance.base().dependency_closure() != source.dependency_closure()
57 {
58 return Err(PredictionRuleError::SourceProvenanceMismatch);
59 }
60 if is_bevy_tuple(provenance) && !has_frozen_bevy_profile(provenance) {
61 return Err(PredictionRuleError::FrozenProfileMismatch);
62 }
63 }
64 Ok(Self { source, provenance })
65 }
66}
67
68impl Check for EngineTrackSupportCheck<'_> {
69 fn id(&self) -> &'static str {
70 ENGINE_TRACK_SUPPORT_CHECK_ID
71 }
72
73 fn applicability(&self, _ctx: &CheckCtx<'_>) -> Applicability {
74 self.provenance
75 .filter(|provenance| {
76 is_exact_bevy_gltf(self.source, provenance)
77 && !provenance.raw_animation_channels().is_complete_empty()
78 })
79 .map_or(Applicability::NotApplicable, |_| Applicability::Applicable)
80 }
81
82 fn evaluate(&self, _ctx: &CheckCtx<'_>) -> CheckOutput {
83 let Some(provenance) = self.provenance else {
84 return empty_output();
85 };
86 if !is_exact_bevy_gltf(self.source, provenance)
87 || provenance.raw_animation_channels().is_complete_empty()
88 {
89 return empty_output();
90 }
91 let demand = demand(provenance.raw_animation_channels());
92 let (capacity, summary_required) = match demand {
93 PredictionFacetDemandV2::Exact(count) => (count, false),
94 PredictionFacetDemandV2::NPlusOne => (
95 PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE.saturating_sub(1),
96 true,
97 ),
98 };
99 evaluate_allocated(provenance, capacity, summary_required)
100 }
101
102 fn prediction_facet_demand_v2(&self, _ctx: &CheckCtx<'_>) -> PredictionFacetDemandV2 {
103 self.provenance
104 .filter(|provenance| {
105 is_exact_bevy_gltf(self.source, provenance)
106 && !provenance.raw_animation_channels().is_complete_empty()
107 })
108 .map_or(PredictionFacetDemandV2::Exact(0), |provenance| {
109 demand(provenance.raw_animation_channels())
110 })
111 }
112
113 fn evaluate_with_prediction_allocation_v2(
114 &self,
115 _ctx: &CheckCtx<'_>,
116 allocation: PredictionRuleAllocationV2<'_>,
117 ) -> CheckOutput {
118 let Some(provenance) = self.provenance else {
119 return empty_output();
120 };
121 if !is_exact_bevy_gltf(self.source, provenance)
122 || provenance.raw_animation_channels().is_complete_empty()
123 {
124 return empty_output();
125 }
126 evaluate_allocated(
127 provenance,
128 allocation.candidate_capacity(),
129 allocation.summary_required(),
130 )
131 }
132}
133
134fn demand(inventory: &RawAnimationChannelInventoryV1) -> PredictionFacetDemandV2 {
135 if inventory.is_complete_empty() {
136 return PredictionFacetDemandV2::Exact(0);
137 }
138 if !inventory.source_coverage_complete() {
139 return PredictionFacetDemandV2::Exact(1);
140 }
141 if inventory.candidate_overflow() {
142 return PredictionFacetDemandV2::NPlusOne;
143 }
144 usize::try_from(inventory.candidate_count())
145 .ok()
146 .filter(|count| *count <= PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE)
147 .map_or(
148 PredictionFacetDemandV2::NPlusOne,
149 PredictionFacetDemandV2::Exact,
150 )
151}
152
153fn evaluate_allocated(
154 provenance: &PredictionProvenanceV5,
155 candidate_capacity: usize,
156 summary_required: bool,
157) -> CheckOutput {
158 let inventory = provenance.raw_animation_channels();
159 let mut facets =
160 Vec::with_capacity(candidate_capacity.saturating_add(usize::from(summary_required)));
161 if !inventory.is_complete_empty() && candidate_capacity != 0 {
162 if !inventory.source_coverage_complete() {
163 facets.push(unavailable(
164 EvaluationScope::new(EvaluationScopeCode::custom(INVENTORY_SCOPE)),
165 inventory_basis(inventory),
166 PredictionUnavailableReasonV2::RawSourceIncomplete,
167 ));
168 } else {
169 for row in inventory.rows() {
170 if facets.len() == candidate_capacity {
171 break;
172 }
173 if let Some(channel) = row.source_channel_index() {
174 facets.push(subject_facet(
175 channel_scope(row.source_animation_index(), channel),
176 row_basis(inventory, row.source_animation_index(), Some(channel)),
177 SourceImportSubjectKindV1::AnimationChannel,
178 gate(provenance),
179 ));
180 } else {
181 facets.push(subject_facet(
182 animation_scope(row.source_animation_index()),
183 row_basis(inventory, row.source_animation_index(), None),
184 SourceImportSubjectKindV1::Animation,
185 gate(provenance),
186 ));
187 }
188 }
189 }
190 }
191 if summary_required {
192 facets.push(unavailable(
193 EvaluationScope::new(EvaluationScopeCode::custom(BUDGET_SCOPE)),
194 static_basis(),
195 PredictionUnavailableReasonV2::FacetBudgetExceeded,
196 ));
197 }
198 let evaluated_scopes = facets
199 .iter()
200 .filter(|facet| facet.result().is_some())
201 .map(|facet| facet.scope().clone())
202 .collect();
203 let inner = EnginePredictionV4::new(provenance.base().identity().clone(), facets)
204 .expect("bounded track-support facets satisfy V4");
205 let prediction = EnginePredictionV5::new(provenance, inner)
206 .expect("track-support V5 prediction binds its V5 provenance");
207 CheckOutput::from_coverage(Vec::new(), evaluated_scopes, Vec::new())
208 .with_engine_prediction_v5(prediction)
209}
210
211fn subject_facet(
212 scope: EvaluationScope,
213 basis: EnginePredictionBasisV4,
214 subject_kind: SourceImportSubjectKindV1,
215 controlling_gate: Option<EngineSettingIdV2>,
216) -> EnginePredictionFacetV4 {
217 match controlling_gate {
218 Some(gate) => EnginePredictionFacetV4::available(
219 scope,
220 basis,
221 EngineMachineResultV1::SourceImportDisposition(SourceImportDispositionResultV1 {
222 subject_kind,
223 disposition: SourceImportDispositionV1::Dropped,
224 controlling_gate: Some(gate),
225 }),
226 )
227 .expect("negative Bevy gate result is valid"),
228 None => unavailable(
229 scope,
230 basis,
231 PredictionUnavailableReasonV2::RuntimeAnimationSurvivalUnavailable,
232 ),
233 }
234}
235
236fn unavailable(
237 scope: EvaluationScope,
238 basis: EnginePredictionBasisV4,
239 reason: PredictionUnavailableReasonV2,
240) -> EnginePredictionFacetV4 {
241 EnginePredictionFacetV4::required_unavailable(scope, basis, vec![reason])
242 .expect("track-support unavailable result is valid")
243}
244
245fn gate(provenance: &PredictionProvenanceV5) -> Option<EngineSettingIdV2> {
246 let settings = provenance.base().settings();
247 if matches!(
248 settings
249 .document_setting(EngineSettingIdV2::BevyAnimationFeature)
250 .map(|row| row.value()),
251 Some(EngineSettingValueV2::Boolean(false))
252 ) {
253 Some(EngineSettingIdV2::BevyAnimationFeature)
254 } else if matches!(
255 settings
256 .document_setting(EngineSettingIdV2::LoadAnimations)
257 .map(|row| row.value()),
258 Some(EngineSettingValueV2::Boolean(false))
259 ) {
260 Some(EngineSettingIdV2::LoadAnimations)
261 } else {
262 None
263 }
264}
265
266fn static_basis() -> EnginePredictionBasisV4 {
267 let mut references = vec![
268 profile_fact_reference(),
269 primary_source_reference(BEVY_LOADER_SOURCE),
270 primary_source_reference(BEVY_MANIFEST_SOURCE),
271 ];
272 references.push(setting_reference(SettingIdV2::BevyAnimationFeature));
273 references.push(setting_reference(SettingIdV2::LoadAnimations));
274 EnginePredictionBasisV4::new(references).expect("static track-support basis is valid")
275}
276
277fn inventory_basis(inventory: &RawAnimationChannelInventoryV1) -> EnginePredictionBasisV4 {
278 let mut references = static_basis().references().to_vec();
279 references.push(project_reference(
280 "raw_animation_channel_inventory.animation_coverage",
281 PredictionScalarV1::text(inventory_coverage_name(inventory))
282 .expect("static coverage token"),
283 ));
284 references.push(project_reference(
285 "raw_animation_channel_inventory.source_coverage_complete",
286 PredictionScalarV1::Boolean {
287 value: inventory.source_coverage_complete(),
288 },
289 ));
290 if let Some(row) = inventory.rows().iter().find(|row| {
291 row.channel_coverage().is_some_and(|coverage| {
292 coverage.state() != animsmith_core::RawSourceSetCoverageStateV1::Complete
293 })
294 }) {
295 let coverage = row.channel_coverage().expect("matched animation row");
296 references.push(project_reference(
297 "raw_animation_channel_inventory.incomplete_channel_animation_row",
298 PredictionScalarV1::UnsignedInteger {
299 value: row.source_animation_index(),
300 },
301 ));
302 references.push(project_reference(
303 "raw_animation_channel_inventory.incomplete_channel_coverage",
304 PredictionScalarV1::text(coverage_state_name(coverage.state()))
305 .expect("static coverage token"),
306 ));
307 if let Some(reason) = coverage.reason() {
308 let reason = serde_json::to_value(reason).expect("coverage reason serializes");
309 references.push(project_reference(
310 "raw_animation_channel_inventory.incomplete_channel_reason",
311 PredictionScalarV1::text(reason.as_str().expect("coverage reason is a token"))
312 .expect("static reason token"),
313 ));
314 }
315 }
316 EnginePredictionBasisV4::new(references).expect("inventory basis is valid")
317}
318
319fn row_basis(
320 inventory: &RawAnimationChannelInventoryV1,
321 animation: u64,
322 channel: Option<u64>,
323) -> EnginePredictionBasisV4 {
324 let mut references = inventory_basis(inventory).references().to_vec();
325 references.push(project_reference(
326 "raw_animation_channel_inventory.animation_row",
327 PredictionScalarV1::UnsignedInteger { value: animation },
328 ));
329 if let Some(channel) = channel {
330 references.push(project_reference(
331 "raw_animation_channel_inventory.channel_row",
332 PredictionScalarV1::UnsignedInteger { value: channel },
333 ));
334 }
335 EnginePredictionBasisV4::new(references).expect("row basis is valid")
336}
337
338fn project_reference(field: &'static str, value: PredictionScalarV1) -> PredictionBasisReferenceV4 {
339 PredictionBasisReferenceV4::v2(PredictionBasisReferenceV2::v1(
340 PredictionBasisReferenceV1::project_field(field, value).expect("static inventory field id"),
341 ))
342}
343
344fn profile_fact_reference() -> PredictionBasisReferenceV4 {
345 PredictionBasisReferenceV4::v2(PredictionBasisReferenceV2::v1(
346 PredictionBasisReferenceV1::profile_fact("source_import_disposition")
347 .expect("static fact id"),
348 ))
349}
350
351fn primary_source_reference(source: &'static str) -> PredictionBasisReferenceV4 {
352 PredictionBasisReferenceV4::v2(PredictionBasisReferenceV2::v1(
353 PredictionBasisReferenceV1::primary_source(source).expect("static source id"),
354 ))
355}
356
357fn setting_reference(setting: SettingIdV2) -> PredictionBasisReferenceV4 {
358 PredictionBasisReferenceV4::v2(PredictionBasisReferenceV2::v1(
359 PredictionBasisReferenceV1::resolved_setting(
360 ResolvedSettingLocationV1::Document,
361 setting.as_str(),
362 )
363 .expect("static setting id"),
364 ))
365}
366
367fn animation_scope(animation: u64) -> EvaluationScope {
368 EvaluationScope::new(EvaluationScopeCode::custom(ANIMATION_SCOPE))
369 .subject(format!("source_animation:{animation}"))
370}
371
372fn channel_scope(animation: u64, channel: u64) -> EvaluationScope {
373 EvaluationScope::new(EvaluationScopeCode::custom(CHANNEL_SCOPE)).subject(format!(
374 "source_animation:{animation}:source_channel:{channel}"
375 ))
376}
377
378fn inventory_coverage_name(inventory: &RawAnimationChannelInventoryV1) -> &'static str {
379 coverage_state_name(inventory.animation_coverage().state())
380}
381
382fn coverage_state_name(state: animsmith_core::RawSourceSetCoverageStateV1) -> &'static str {
383 match state {
384 animsmith_core::RawSourceSetCoverageStateV1::Complete => "complete",
385 animsmith_core::RawSourceSetCoverageStateV1::Partial => "partial",
386 animsmith_core::RawSourceSetCoverageStateV1::Unavailable => "unavailable",
387 }
388}
389
390fn is_bevy_tuple(provenance: &PredictionProvenanceV5) -> bool {
391 let selection = provenance.base().profile().selection();
392 selection.family() == BEVY_FAMILY
393 && selection.profile_revision() == BEVY_PROFILE_REVISION
394 && selection.engine_version() == BEVY_ENGINE_VERSION
395 && selection.importer() == BEVY_IMPORTER
396}
397
398fn has_frozen_bevy_profile(provenance: &PredictionProvenanceV5) -> bool {
399 profiles_v2().iter().any(|profile| {
400 let selection = profile.selection();
401 selection.family() == BEVY_FAMILY
402 && selection.profile_revision() == BEVY_PROFILE_REVISION
403 && selection.engine_version() == BEVY_ENGINE_VERSION
404 && selection.importer() == BEVY_IMPORTER
405 && crate::project_engine_profile_v2(profile)
406 .is_ok_and(|projected| &projected == provenance.base().profile())
407 })
408}
409
410fn is_exact_bevy_gltf(source: &LoadedSource, provenance: &PredictionProvenanceV5) -> bool {
411 matches!(
412 source.source_facts().format(),
413 SourceFormatV1::GltfJson | SourceFormatV1::Glb
414 ) && source.source_facts().format() == provenance.base().source_format()
415 && is_bevy_tuple(provenance)
416 && has_frozen_bevy_profile(provenance)
417}
418
419fn empty_output() -> CheckOutput {
420 CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new())
421}