Skip to main content

qualia_client_core/wellfair/
anatomy_assets.rs

1//! S5.8 — the **asset cache + real-mesh acquisition path** (user-triggered).
2//!
3//! The interim visual (`anatomy_render.rs`) renders a coloured silhouette because the real 3D body needs
4//! ~200–290 MB of CCF/HRA GLB downloads with no cache. This module is the user-triggered acquisition +
5//! cache: when the person clicks "Download body assets" in the Studio UI, the host discovers the
6//! reference-organ manifest from the HRA SPARQL endpoint, fetches each GLB from its CDN URL, compiles it
7//! to a sealed `.10d` (via `anatomy_body::compile_body`), and writes both the raw GLB and the compiled
8//! `.10d` to a gitignored cache under `{storage_root}/assets/ccf/{model}/`. Subsequent runs load the
9//! cached `.10d` directly — no re-download. The cache is **the person's own**, generated on demand.
10//!
11//! The discover/fetch are blocking network I/O (reqwest blocking, off the async runtime via
12//! `spawn_blocking` in the desktop command). The compile is pure CPU. Progress is reported per organ so
13//! the UI can show a real progress bar. Everything is honest about what did and did not cache — failed
14//! fetches/compiles are reported, never silently dropped.
15
16use std::path::{Path, PathBuf};
17
18use serde::{Deserialize, Serialize};
19use wellfare_core::anatomy::AnatomyModel;
20
21use super::anatomy_body::{compile_body, BodyCompileResult};
22
23/// The cache root for a model: `{storage_root}/assets/ccf/{model}/`.
24pub fn cache_dir(storage_root: impl AsRef<Path>, model: AnatomyModel) -> PathBuf {
25    storage_root
26        .as_ref()
27        .join("assets")
28        .join("ccf")
29        .join(model.as_str())
30}
31
32/// Where a raw GLB is cached: `{cache_dir}/glb/{organ_key}`.
33pub fn glb_path(storage_root: impl AsRef<Path>, model: AnatomyModel, organ_key: &str) -> PathBuf {
34    cache_dir(storage_root, model).join("glb").join(organ_key)
35}
36
37/// Where a compiled `.10d` is cached: `{cache_dir}/10d/{organ_key}.10d`.
38pub fn ten_d_path(storage_root: impl AsRef<Path>, model: AnatomyModel, organ_key: &str) -> PathBuf {
39    cache_dir(storage_root, model)
40        .join("10d")
41        .join(format!("{organ_key}.10d"))
42}
43
44/// Where the cache manifest lives: `{cache_dir}/manifest.json`.
45pub fn manifest_path(storage_root: impl AsRef<Path>, model: AnatomyModel) -> PathBuf {
46    cache_dir(storage_root, model).join("manifest.json")
47}
48
49/// One entry in the cache manifest — a cached organ with its source URL + sizes.
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct CachedOrganEntry {
52    pub organ_key: String,
53    pub system_id: String,
54    pub glb_url: String,
55    pub glb_bytes: usize,
56    pub ten_d_bytes: usize,
57}
58
59/// The cache manifest — records what was acquired, when, and from where.
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61pub struct CacheManifest {
62    pub model: String,
63    pub acquired_at_unix: u64,
64    pub organs: Vec<CachedOrganEntry>,
65}
66
67impl CacheManifest {
68    /// The organ keys in the cache (in manifest order).
69    pub fn organ_keys(&self) -> Vec<String> {
70        self.organs.iter().map(|o| o.organ_key.clone()).collect()
71    }
72
73    /// Total `.10d` bytes in the cache.
74    pub fn total_ten_d_bytes(&self) -> usize {
75        self.organs.iter().map(|o| o.ten_d_bytes).sum()
76    }
77}
78
79/// The status of a model's cache — what the UI shows to decide "download" vs "view".
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct BodyAssetsStatus {
82    pub model: String,
83    pub cached: bool,
84    pub organ_count: usize,
85    pub total_ten_d_bytes: usize,
86    pub acquired_at_unix: u64,
87}
88
89/// The status of a model's cache (cached = manifest present + every referenced `.10d` on disk).
90pub fn status(storage_root: impl AsRef<Path>, model: AnatomyModel) -> BodyAssetsStatus {
91    match load_manifest(&storage_root, model) {
92        Some(manifest) => {
93            let cached = manifest
94                .organs
95                .iter()
96                .all(|o| ten_d_path(&storage_root, model, &o.organ_key).is_file());
97            BodyAssetsStatus {
98                model: model.as_str().to_string(),
99                cached,
100                organ_count: manifest.organs.len(),
101                total_ten_d_bytes: manifest.total_ten_d_bytes(),
102                acquired_at_unix: manifest.acquired_at_unix,
103            }
104        }
105        None => BodyAssetsStatus {
106            model: model.as_str().to_string(),
107            cached: false,
108            organ_count: 0,
109            total_ten_d_bytes: 0,
110            acquired_at_unix: 0,
111        },
112    }
113}
114
115/// Load the cache manifest, or `None` if not cached.
116pub fn load_manifest(storage_root: impl AsRef<Path>, model: AnatomyModel) -> Option<CacheManifest> {
117    let path = manifest_path(storage_root, model);
118    let bytes = std::fs::read(path).ok()?;
119    serde_json::from_slice(&bytes).ok()
120}
121
122/// Whether the cache is present and complete (manifest exists + every referenced `.10d` is on disk).
123pub fn is_cached(storage_root: impl AsRef<Path>, model: AnatomyModel) -> bool {
124    let Some(manifest) = load_manifest(&storage_root, model) else {
125        return false;
126    };
127    manifest
128        .organs
129        .iter()
130        .all(|o| ten_d_path(&storage_root, model, &o.organ_key).is_file())
131}
132
133/// The cached organ keys for a model (empty if not cached).
134pub fn cached_organ_keys(storage_root: impl AsRef<Path>, model: AnatomyModel) -> Vec<String> {
135    load_manifest(&storage_root, model)
136        .map(|m| m.organ_keys())
137        .unwrap_or_default()
138}
139
140/// Load a cached `.10d` for one organ. Returns `Err` if not cached or the file is unreadable.
141pub fn load_cached_10d(
142    storage_root: impl AsRef<Path>,
143    model: AnatomyModel,
144    organ_key: &str,
145) -> Result<Vec<u8>, String> {
146    let path = ten_d_path(&storage_root, model, organ_key);
147    std::fs::read(path).map_err(|e| format!("cached .10d for {organ_key}: {e}"))
148}
149
150/// Per-organ progress reported during acquisition — drives the UI progress bar.
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct AcquireProgress {
153    /// `"discover"`, `"fetch"`, `"compile"`, or `"done"`.
154    pub stage: String,
155    /// The organ key for this progress (empty for discover/done).
156    pub organ_key: String,
157    /// How many organs are done in the current stage.
158    pub done: usize,
159    /// Total organs to process.
160    pub total: usize,
161    /// Bytes transferred so far (fetch stage).
162    pub bytes: usize,
163    /// A human-readable status line.
164    pub message: String,
165}
166
167/// The final report from an acquisition run — honest about what did and did not cache.
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169pub struct AcquireReport {
170    pub model: String,
171    pub organs_cached: usize,
172    pub organs_failed: usize,
173    pub organs_unmapped: usize,
174    pub total_glb_bytes: usize,
175    pub total_ten_d_bytes: usize,
176    /// Organ keys that failed to fetch or compile, with the error.
177    pub failed: Vec<(String, String)>,
178    /// Organ keys that fetched but had no body-system mapping (reported, not guessed).
179    pub unmapped: Vec<String>,
180}
181
182/// Acquire + cache the body assets for a model — **user-triggered**, blocking network I/O. Discovers the
183/// reference-organ manifest from the HRA SPARQL endpoint, fetches each GLB, compiles it to `.10d`, and
184/// writes both + a manifest to the cache. Progress is reported via `progress` per organ. Failed
185/// fetches/compiles are reported in the result, never silently dropped. The cache directory is created
186/// on demand; an existing cache is **refreshed** (re-fetched + re-compiled) so the person can update.
187#[cfg(not(target_arch = "wasm32"))]
188pub fn acquire_body_assets(
189    storage_root: impl AsRef<Path>,
190    model: AnatomyModel,
191    progress: impl FnMut(AcquireProgress),
192) -> Result<AcquireReport, String> {
193    acquire_body_assets_controlled(storage_root, model, progress, || false)
194}
195
196/// Cancellable form used by the desktop job centre. Cancellation is checked between remote fetches
197/// and before/after the bounded compile phase, so a request never leaves a half-written manifest.
198#[cfg(not(target_arch = "wasm32"))]
199pub fn acquire_body_assets_controlled(
200    storage_root: impl AsRef<Path>,
201    model: AnatomyModel,
202    mut progress: impl FnMut(AcquireProgress),
203    mut is_cancelled: impl FnMut() -> bool,
204) -> Result<AcquireReport, String> {
205    use super::ccf_resolver::{
206        discover_ref_organs, fetch_glb, organs_for_model, HRA_SPARQL_ENDPOINT,
207    };
208
209    if is_cancelled() {
210        return Err("cancelled".to_string());
211    }
212
213    // Discover the manifest.
214    progress(AcquireProgress {
215        stage: "discover".into(),
216        organ_key: String::new(),
217        done: 0,
218        total: 0,
219        bytes: 0,
220        message: format!(
221            "Discovering {} reference organs from the HRA…",
222            model.as_str()
223        ),
224    });
225    let all =
226        discover_ref_organs(HRA_SPARQL_ENDPOINT).map_err(|e| format!("SPARQL discovery: {e}"))?;
227    if is_cancelled() {
228        return Err("cancelled".to_string());
229    }
230    let set = organs_for_model(&all, model);
231    if set.is_empty() {
232        return Err(format!("no {} reference organs discovered", model.as_str()));
233    }
234    let total = set.len();
235
236    // Prepare the cache dirs.
237    let glb_dir = cache_dir(&storage_root, model).join("glb");
238    let ten_d_dir = cache_dir(&storage_root, model).join("10d");
239    std::fs::create_dir_all(&glb_dir).map_err(|e| format!("cache glb dir: {e}"))?;
240    std::fs::create_dir_all(&ten_d_dir).map_err(|e| format!("cache 10d dir: {e}"))?;
241
242    // Fetch + cache each GLB.
243    let mut fetched: Vec<(String, Vec<u8>)> = Vec::new();
244    let mut failed: Vec<(String, String)> = Vec::new();
245    let mut total_glb_bytes = 0usize;
246    for (i, organ) in set.iter().enumerate() {
247        if is_cancelled() {
248            return Err("cancelled".to_string());
249        }
250        progress(AcquireProgress {
251            stage: "fetch".into(),
252            organ_key: organ.filename.clone(),
253            done: i,
254            total,
255            bytes: total_glb_bytes,
256            message: format!("Fetching {} ({}/{})…", organ.filename, i + 1, total),
257        });
258        match fetch_glb(&organ.glb_url) {
259            Ok(bytes) => {
260                total_glb_bytes += bytes.len();
261                // Cache the raw GLB (best-effort — a failed write doesn't abort the run).
262                let _ = std::fs::write(glb_path(&storage_root, model, &organ.filename), &bytes);
263                fetched.push((organ.filename.clone(), bytes));
264            }
265            Err(e) => failed.push((organ.filename.clone(), format!("fetch: {e}"))),
266        }
267    }
268
269    // Compile the fetched GLBs to .10d.
270    if is_cancelled() {
271        return Err("cancelled".to_string());
272    }
273    let BodyCompileResult {
274        model: _,
275        organs: compiled,
276        unmapped,
277        failed: compile_failed,
278    } = compile_body(model, &fetched);
279    if is_cancelled() {
280        return Err("cancelled".to_string());
281    }
282    // Merge compile failures into the failed list.
283    for (k, e) in compile_failed {
284        failed.push((k, format!("compile: {e}")));
285    }
286
287    // Cache each compiled .10d + build the manifest entries.
288    let mut entries: Vec<CachedOrganEntry> = Vec::new();
289    let mut total_ten_d_bytes = 0usize;
290    for (i, organ) in compiled.iter().enumerate() {
291        if is_cancelled() {
292            return Err("cancelled".to_string());
293        }
294        progress(AcquireProgress {
295            stage: "compile".into(),
296            organ_key: organ.organ_key.clone(),
297            done: i,
298            total: compiled.len(),
299            bytes: total_ten_d_bytes,
300            message: format!(
301                "Compiling {} ({}/{})…",
302                organ.organ_key,
303                i + 1,
304                compiled.len()
305            ),
306        });
307        let path = ten_d_path(&storage_root, model, &organ.organ_key);
308        if std::fs::write(&path, &organ.asset.container_10d).is_ok() {
309            total_ten_d_bytes += organ.asset.container_10d.len();
310            // Find the source URL for the manifest.
311            let glb_url = set
312                .iter()
313                .find(|o| o.filename == organ.organ_key)
314                .map(|o| o.glb_url.clone())
315                .unwrap_or_default();
316            let glb_bytes = std::fs::metadata(glb_path(&storage_root, model, &organ.organ_key))
317                .map(|m| m.len() as usize)
318                .unwrap_or(0);
319            entries.push(CachedOrganEntry {
320                organ_key: organ.organ_key.clone(),
321                system_id: organ.system_id.clone(),
322                glb_url,
323                glb_bytes,
324                ten_d_bytes: organ.asset.container_10d.len(),
325            });
326        } else {
327            failed.push((organ.organ_key.clone(), "cache write failed".into()));
328        }
329    }
330
331    // Write the manifest.
332    let manifest = CacheManifest {
333        model: model.as_str().to_string(),
334        acquired_at_unix: std::time::SystemTime::now()
335            .duration_since(std::time::UNIX_EPOCH)
336            .map(|d| d.as_secs())
337            .unwrap_or(0),
338        organs: entries,
339    };
340    let manifest_json =
341        serde_json::to_vec_pretty(&manifest).map_err(|e| format!("manifest serde: {e}"))?;
342    std::fs::write(manifest_path(&storage_root, model), manifest_json)
343        .map_err(|e| format!("manifest write: {e}"))?;
344
345    let report = AcquireReport {
346        model: model.as_str().to_string(),
347        organs_cached: manifest.organs.len(),
348        organs_failed: failed.len(),
349        organs_unmapped: unmapped.len(),
350        total_glb_bytes,
351        total_ten_d_bytes,
352        failed,
353        unmapped,
354    };
355
356    progress(AcquireProgress {
357        stage: "done".into(),
358        organ_key: String::new(),
359        done: report.organs_cached,
360        total,
361        bytes: report.total_ten_d_bytes,
362        message: format!(
363            "{} body cached: {} organs · {} MB GLB → {} MB .10d · {} failed · {} unmapped",
364            model.as_str(),
365            report.organs_cached,
366            report.total_glb_bytes / 1_000_000,
367            report.total_ten_d_bytes / 1_000_000,
368            report.organs_failed,
369            report.organs_unmapped,
370        ),
371    });
372
373    Ok(report)
374}
375
376/// Delete the cache for a model (idempotent — no-op if not cached).
377pub fn clear_cache(storage_root: impl AsRef<Path>, model: AnatomyModel) -> Result<(), String> {
378    let dir = cache_dir(storage_root, model);
379    match std::fs::remove_dir_all(&dir) {
380        Ok(()) => Ok(()),
381        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
382        Err(e) => Err(format!("clear cache: {e}")),
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    #[test]
391    fn cache_paths_are_namespaced_by_model() {
392        let root = tempfile::tempdir().unwrap();
393        let male_glb = glb_path(root.path(), AnatomyModel::Male, "3d-vh-m-liver.glb");
394        let female_glb = glb_path(root.path(), AnatomyModel::Female, "3d-vh-m-liver.glb");
395        assert_ne!(male_glb, female_glb, "male and female caches are separate");
396        // Platform-independent path checks (Windows uses backslashes).
397        let male_str = male_glb.to_string_lossy().replace('\\', "/");
398        let female_str = female_glb.to_string_lossy().replace('\\', "/");
399        assert!(
400            male_str.contains("assets/ccf/male/glb/3d-vh-m-liver.glb"),
401            "{male_str}"
402        );
403        assert!(
404            female_str.contains("assets/ccf/female/glb/3d-vh-m-liver.glb"),
405            "{female_str}"
406        );
407        let ten_d = ten_d_path(root.path(), AnatomyModel::Male, "3d-vh-m-liver.glb");
408        let ten_d_str = ten_d.to_string_lossy().replace('\\', "/");
409        assert!(
410            ten_d_str.ends_with("10d/3d-vh-m-liver.glb.10d"),
411            "{ten_d_str}"
412        );
413    }
414
415    #[test]
416    fn is_cached_is_false_when_no_manifest_and_true_after_write() {
417        let root = tempfile::tempdir().unwrap();
418        assert!(!is_cached(root.path(), AnatomyModel::Male));
419
420        // Write a manifest + a .10d file → cached.
421        let dir = cache_dir(root.path(), AnatomyModel::Male).join("10d");
422        std::fs::create_dir_all(&dir).unwrap();
423        std::fs::write(
424            ten_d_path(root.path(), AnatomyModel::Male, "liver.glb"),
425            b"fake10d",
426        )
427        .unwrap();
428        let manifest = CacheManifest {
429            model: "male".into(),
430            acquired_at_unix: 0,
431            organs: vec![CachedOrganEntry {
432                organ_key: "liver.glb".into(),
433                system_id: "digestive".into(),
434                glb_url: "https://example/liver.glb".into(),
435                glb_bytes: 100,
436                ten_d_bytes: 8,
437            }],
438        };
439        std::fs::write(
440            manifest_path(root.path(), AnatomyModel::Male),
441            serde_json::to_vec_pretty(&manifest).unwrap(),
442        )
443        .unwrap();
444
445        assert!(is_cached(root.path(), AnatomyModel::Male));
446        assert_eq!(
447            cached_organ_keys(root.path(), AnatomyModel::Male),
448            vec!["liver.glb".to_string()]
449        );
450        assert_eq!(
451            load_cached_10d(root.path(), AnatomyModel::Male, "liver.glb").unwrap(),
452            b"fake10d".to_vec()
453        );
454    }
455
456    #[test]
457    fn is_cached_is_false_when_manifest_references_missing_10d() {
458        let root = tempfile::tempdir().unwrap();
459        // Manifest references an organ whose .10d is not on disk → not complete.
460        let manifest = CacheManifest {
461            model: "male".into(),
462            acquired_at_unix: 0,
463            organs: vec![CachedOrganEntry {
464                organ_key: "missing.glb".into(),
465                system_id: "nervous".into(),
466                glb_url: "x".into(),
467                glb_bytes: 0,
468                ten_d_bytes: 0,
469            }],
470        };
471        std::fs::create_dir_all(cache_dir(root.path(), AnatomyModel::Male)).unwrap();
472        std::fs::write(
473            manifest_path(root.path(), AnatomyModel::Male),
474            serde_json::to_vec_pretty(&manifest).unwrap(),
475        )
476        .unwrap();
477        assert!(
478            !is_cached(root.path(), AnatomyModel::Male),
479            "missing .10d → not cached"
480        );
481    }
482
483    #[test]
484    fn clear_cache_is_idempotent_and_removes_the_dir() {
485        let root = tempfile::tempdir().unwrap();
486        // No cache → no-op.
487        clear_cache(root.path(), AnatomyModel::Male).unwrap();
488        // Create a cache → clear removes it.
489        std::fs::create_dir_all(cache_dir(root.path(), AnatomyModel::Male)).unwrap();
490        std::fs::write(manifest_path(root.path(), AnatomyModel::Male), b"{}").unwrap();
491        assert!(manifest_path(root.path(), AnatomyModel::Male).exists());
492        clear_cache(root.path(), AnatomyModel::Male).unwrap();
493        assert!(!manifest_path(root.path(), AnatomyModel::Male).exists());
494        // Idempotent.
495        clear_cache(root.path(), AnatomyModel::Male).unwrap();
496    }
497
498    #[test]
499    fn manifest_round_trips_through_serde() {
500        let m = CacheManifest {
501            model: "female".into(),
502            acquired_at_unix: 1_750_000_000,
503            organs: vec![
504                CachedOrganEntry {
505                    organ_key: "liver.glb".into(),
506                    system_id: "digestive".into(),
507                    glb_url: "https://cdn/liver.glb".into(),
508                    glb_bytes: 1_000_000,
509                    ten_d_bytes: 500_000,
510                },
511                CachedOrganEntry {
512                    organ_key: "lung.glb".into(),
513                    system_id: "respiratory".into(),
514                    glb_url: "https://cdn/lung.glb".into(),
515                    glb_bytes: 2_000_000,
516                    ten_d_bytes: 800_000,
517                },
518            ],
519        };
520        let json = serde_json::to_string(&m).unwrap();
521        let back: CacheManifest = serde_json::from_str(&json).unwrap();
522        assert_eq!(back, m);
523        assert_eq!(
524            back.organ_keys(),
525            vec!["liver.glb".to_string(), "lung.glb".to_string()]
526        );
527        assert_eq!(back.total_ten_d_bytes(), 1_300_000);
528    }
529}