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