Skip to main content

brep_app/plm/
player_data.rs

1//! The player's data, uploaded at a PLM save: manufacturing instructions'
2//! (their steps), or an assembly's motion study (the active study's baked
3//! frames — `brep_render::engine_state::simulation_player`), both the same
4//! `PlayerData` the PLM serves from the trusted template.
5//!
6//! The PLM serves the player per instructions revision from data the CAD app
7//! makes (`brep_plm::player`): the page is the instructions crate's trusted
8//! template, and only the `PlayerData` JSON comes from the app. That data is
9//! keyed by the document it was made from, and an instructions revision does
10//! not release without data made from the document it holds. So every
11//! accepted save of an instructions revision is followed by
12//! `PUT /api/parts/<p>/revisions/<r>/player-data?content_hash=<SHA-256 of the
13//! saved bytes>`, the same way [`crate::plm::thumbnail`] follows it with the
14//! picture:
15//!
16//! - [`stage`](crate::plm::player_data::stage), at the save site: the data is made from the engine on the UI
17//!   thread (the step pictures and part thumbnails need its scene). A save
18//!   made while a rebuild is still running waits for that run to land
19//!   ([`tick`](crate::plm::player_data::tick)), so the data shows the model that was saved.
20//! - `upload_on_save`, a `SavedHook` the PLM backend runs after each
21//!   accepted write: when data was staged for exactly those bytes, it is
22//!   uploaded in a task of its own. It never fails the save. A refusal is
23//!   counted in [`stats`](crate::plm::player_data::stats) and said once as a notice ([`take_notice`](crate::plm::player_data::take_notice)); a `409`
24//!   (the revision holds another document by now) reads "player data is from
25//!   a different document". The next save makes it again.
26
27use std::cell::RefCell;
28use std::collections::BTreeMap;
29use std::future::Future;
30use std::pin::Pin;
31use std::rc::Rc;
32use std::task::{Context, Poll, Waker};
33
34use brep_render::engine_state::EngineState;
35
36use super::backend::SavedHook;
37use super::client::{PlmClient, PlmError};
38use super::content_hash;
39
40/// What a `409` reads as: the server holds another document than the one the
41/// data was made from.
42pub const STALE: &str = "player data is from a different document";
43
44/// What happened to the player data this session made, for scripts and tests.
45#[derive(Debug, Clone, Default, PartialEq, serde::Serialize)]
46pub struct Stats {
47    /// Staged at a save of an instructions revision.
48    pub staged: u32,
49    /// Uploaded and accepted.
50    pub uploaded: u32,
51    /// Not made (the instructions could not be played) or superseded.
52    pub skipped: u32,
53    /// Refused or unreachable.
54    pub failed: u32,
55    /// The last refusal or reason, as the user should read it.
56    pub last_error: String,
57}
58
59thread_local! {
60    static STAGED: RefCell<BTreeMap<String, Staged>> = RefCell::new(BTreeMap::new());
61    static WAITING: RefCell<Vec<Waiting>> = const { RefCell::new(Vec::new()) };
62    static STATS: RefCell<Stats> = RefCell::new(Stats::default());
63    static NOTICE: RefCell<Option<String>> = const { RefCell::new(None) };
64}
65
66/// This session's counts.
67pub fn stats() -> Stats {
68    STATS.with(|s| s.borrow().clone())
69}
70
71fn count(f: impl FnOnce(&mut Stats)) {
72    STATS.with(|s| f(&mut s.borrow_mut()));
73}
74
75/// The refusal to say since the last call, once.
76pub fn take_notice() -> Option<String> {
77    NOTICE.with(|n| n.borrow_mut().take())
78}
79
80struct Staged {
81    hash: String,
82    data: Data,
83}
84
85/// A save made while its document was still rebuilding: made once the run
86/// that was pending lands.
87struct Waiting {
88    doc: u64,
89    after: u64,
90    data: Data,
91}
92
93/// Player data on its way: made at the save, or once its run lands.
94#[derive(Clone, Default)]
95struct Data(Rc<RefCell<Slot>>);
96
97#[derive(Default)]
98struct Slot {
99    /// `Some(None)`: nothing to upload.
100    json: Option<Option<String>>,
101    waker: Option<Waker>,
102}
103
104impl Data {
105    fn fill(&self, json: Option<String>) {
106        let mut slot = self.0.borrow_mut();
107        slot.json = Some(json);
108        if let Some(waker) = slot.waker.take() {
109            waker.wake();
110        }
111    }
112}
113
114impl Future for Data {
115    type Output = Option<String>;
116
117    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
118        let mut slot = self.0.borrow_mut();
119        match slot.json.take() {
120            Some(json) => Poll::Ready(json),
121            None => {
122                slot.waker = Some(cx.waker().clone());
123                Poll::Pending
124            }
125        }
126    }
127}
128
129/// The player data of `engine`'s document, as JSON: its instructions, or its
130/// active motion study as baked (a study not played to the end has none);
131/// `None` (counted) when it cannot be played.
132fn make(engine: &EngineState) -> Option<String> {
133    let made = if engine.is_instructions() { engine.instructions_player_data() } else { engine.simulation_player_data_baked() };
134    match made {
135        Ok(data) => Some(data.to_json()),
136        Err(why) => {
137            count(|s| {
138                s.skipped += 1;
139                s.last_error = format!("no player data: {why}");
140            });
141            None
142        }
143    }
144}
145
146/// Stage the player data for the save of `document` under the PLM store key
147/// `key` (`part/<p>/rev/<r>`) when `engine` holds instructions or a played
148/// motion study. `doc` is the document's tab id, for a save made mid-rebuild
149/// ([`tick`]).
150pub fn stage(key: &str, document: &str, engine: &EngineState, doc: u64) {
151    // Instructions always publish; an assembly publishes its active study
152    // once it has been played or scrubbed (the bake is what is published).
153    if !engine.is_instructions() && engine.simulation_playback_json().is_null() {
154        return;
155    }
156    let data = Data::default();
157    if engine.run_pending() {
158        WAITING.with(|w| w.borrow_mut().push(Waiting { doc, after: engine.applied_generation(), data: data.clone() }));
159    } else {
160        data.fill(make(engine));
161    }
162    count(|s| s.staged += 1);
163    STAGED.with(|s| s.borrow_mut().insert(key.to_string(), Staged { hash: content_hash(document.as_bytes()), data }));
164}
165
166/// Make the data of the saves made mid-rebuild whose run has landed; called
167/// once per frame. `engine_of` finds a tab's engine by id (`None`: the tab
168/// closed, and its data is dropped).
169pub fn tick<'a>(engine_of: impl Fn(u64) -> Option<&'a EngineState>) {
170    if WAITING.with(|w| w.borrow().is_empty()) {
171        return;
172    }
173    let mut ready: Vec<(Data, Option<String>)> = Vec::new();
174    WAITING.with(|w| {
175        w.borrow_mut().retain(|waiting| match engine_of(waiting.doc) {
176            None => {
177                ready.push((waiting.data.clone(), None));
178                false
179            }
180            Some(engine) if engine.run_pending() || engine.applied_generation() == waiting.after => true,
181            Some(engine) => {
182                ready.push((waiting.data.clone(), make(engine)));
183                false
184            }
185        })
186    });
187    for (data, json) in ready {
188        data.fill(json);
189    }
190}
191
192/// The data staged for exactly these bytes under `key`, taken. Data staged
193/// for other bytes (a later save) is left for that save's own hook.
194fn take(key: &str, document: &str) -> Option<Data> {
195    let hash = content_hash(document.as_bytes());
196    STAGED.with(|s| {
197        let mut staged = s.borrow_mut();
198        match staged.get(key) {
199            Some(entry) if entry.hash == hash => staged.remove(key).map(|e| e.data),
200            _ => None,
201        }
202    })
203}
204
205/// The hook: after an accepted document write, upload the data staged for it,
206/// in a task of its own. Always `Ok`: player data never fails a save.
207pub(crate) fn upload_on_save() -> SavedHook {
208    Rc::new(|client, key, value| {
209        let key = crate::plm::uses::revision_key_in(&key).unwrap_or(key);
210        if let Some(data) = take(&key, &value) {
211            let hash = content_hash(value.as_bytes());
212            crate::store::spawn_waking(async move { upload(&client, &key, &hash, data).await });
213        }
214        Box::pin(async { Ok(()) })
215    })
216}
217
218async fn upload(client: &PlmClient, key: &str, hash: &str, data: Data) {
219    let Some(json) = data.await else {
220        count(|s| s.skipped += 1);
221        return;
222    };
223    match put(client, key, hash, json).await {
224        Ok(()) => count(|s| s.uploaded += 1),
225        Err(error) => {
226            let why = if error.status() == Some(409) { STALE.to_string() } else { format!("player data not uploaded: {error}") };
227            log::warn!("{key}: {why}");
228            count(|s| {
229                s.failed += 1;
230                s.last_error = why.clone();
231            });
232            NOTICE.with(|n| *n.borrow_mut() = Some(why));
233        }
234    }
235}
236
237/// Upload `json` as the player data of the instructions revision `key`, made
238/// from the document whose hash is `hash`.
239pub async fn put(client: &PlmClient, key: &str, hash: &str, json: String) -> Result<(), PlmError> {
240    let (part, revision) = super::family::key_ids(key).ok_or_else(|| PlmError::Malformed(format!("'{key}' is not a revision key")))?;
241    let path = format!("{}/player-data?content_hash={hash}", super::identity::revision_path(&part, &revision));
242    client.call_typed("PUT", &path, json.into_bytes(), "application/json").await.map(drop)
243}
244