1use std::path::{Path, PathBuf};
17
18use serde::{Deserialize, Serialize};
19use wellfare_core::anatomy::AnatomyModel;
20
21use super::anatomy_body::{compile_body, BodyCompileResult};
22
23pub 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
32pub 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
37pub 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
44pub fn manifest_path(storage_root: impl AsRef<Path>, model: AnatomyModel) -> PathBuf {
46 cache_dir(storage_root, model).join("manifest.json")
47}
48
49#[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#[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 pub fn organ_keys(&self) -> Vec<String> {
70 self.organs.iter().map(|o| o.organ_key.clone()).collect()
71 }
72
73 pub fn total_ten_d_bytes(&self) -> usize {
75 self.organs.iter().map(|o| o.ten_d_bytes).sum()
76 }
77}
78
79#[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
89pub 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
115pub 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
122pub 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
133pub 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
140pub 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#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct AcquireProgress {
153 pub stage: String,
155 pub organ_key: String,
157 pub done: usize,
159 pub total: usize,
161 pub bytes: usize,
163 pub message: String,
165}
166
167#[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 pub failed: Vec<(String, String)>,
178 pub unmapped: Vec<String>,
180}
181
182#[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#[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 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 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 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 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 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 for (k, e) in compile_failed {
284 failed.push((k, format!("compile: {e}")));
285 }
286
287 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 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 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
376pub 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 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 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 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 clear_cache(root.path(), AnatomyModel::Male).unwrap();
488 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 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}