1#![cfg(not(target_arch = "wasm32"))]
16
17use std::collections::HashMap;
18use std::path::Path;
19
20use serde::Serialize;
21use wellfare_core::anatomy::{normalize_organ_key, system_memberships_for_organ, AnatomyModel};
22
23use qualia_core_db::bundle::BundleWriter;
24use qualia_core_db::q42_volume::UnifiedVolumeBuilder;
25use qualia_core_db::render::anatomy_pack::AnatomyOrganMeta;
26use qualia_core_db::{NQuin, QUINS_PER_BLOCK};
27
28use super::anatomy_body::{body_container, compile_body, organ_container, CompiledOrgan};
29use super::ccf_resolver::{discover_ref_organs, fetch_glb, organs_for_model, HRA_SPARQL_ENDPOINT};
30
31pub const CURATED_ORGAN_TOKENS: &[&str] = &[
35 "brain",
37 "spinal-cord",
38 "heart",
40 "lung",
42 "trachea",
43 "larynx",
44 "main-bronchus",
45 "liver",
47 "pancreas",
48 "small-intestine",
49 "large-intestine",
50 "mouth",
51 "kidney",
53 "urinary-bladder",
54 "ureter",
55 "spleen",
57 "thymus",
58 "lymph-node",
59 "skin",
62 "eye",
64 "pelvis",
66 "prostate",
68 "uterus",
69 "ovary",
70 "vagina",
71 "fallopian-tube",
72];
73
74#[derive(Debug, Clone, Serialize)]
76pub struct DiscoveredOrgan {
77 pub filename: String,
78 pub token: String,
79}
80
81pub fn discover_model_organs(model: AnatomyModel) -> Result<Vec<DiscoveredOrgan>, String> {
85 let all =
86 discover_ref_organs(HRA_SPARQL_ENDPOINT).map_err(|e| format!("SPARQL discovery: {e}"))?;
87 Ok(organs_for_model(&all, model)
88 .into_iter()
89 .map(|o| DiscoveredOrgan {
90 token: normalize_organ_key(&o.filename),
91 filename: o.filename,
92 })
93 .collect())
94}
95
96#[derive(Debug, Clone, Serialize)]
99pub struct PackReport {
100 pub model: String,
101 pub out_path: String,
102 pub organs_packed: usize,
103 pub total_10d_bytes: usize,
104 pub bundle_bytes: usize,
105 pub curated_not_found: Vec<String>,
107 pub failed: Vec<(String, String)>,
109 pub packed_keys: Vec<String>,
111 pub q42_graph_bytes: usize,
114 pub q42_quins: usize,
116 pub q42_sidecar_path: String,
118}
119
120pub fn build_anatomy_pack(
125 model: AnatomyModel,
126 out_path: impl AsRef<Path>,
127 curated: Option<&[&str]>,
128) -> Result<PackReport, String> {
129 let out_path = out_path.as_ref();
130
131 let all =
133 discover_ref_organs(HRA_SPARQL_ENDPOINT).map_err(|e| format!("SPARQL discovery: {e}"))?;
134 let model_organs = organs_for_model(&all, model);
135
136 let (selected, curated_not_found): (Vec<_>, Vec<String>) = match curated {
140 Some(list) => {
141 let sel: Vec<_> = model_organs
142 .iter()
143 .filter(|o| {
144 let key = normalize_organ_key(&o.filename);
145 list.iter().any(|t| key.as_str() == *t)
146 })
147 .cloned()
148 .collect();
149 let found: std::collections::BTreeSet<String> = sel
150 .iter()
151 .map(|o| normalize_organ_key(&o.filename))
152 .collect();
153 let missing: Vec<String> = list
154 .iter()
155 .filter(|t| !found.contains(**t))
156 .map(|t| (*t).to_string())
157 .collect();
158 (sel, missing)
159 }
160 None => (model_organs.clone(), Vec::new()),
161 };
162
163 if selected.is_empty() {
164 return Err(format!(
165 "no reference organs discovered for {}",
166 model.as_str()
167 ));
168 }
169
170 let mut fetched: Vec<(String, Vec<u8>)> = Vec::new();
172 let mut failed: Vec<(String, String)> = Vec::new();
173 for organ in &selected {
174 match fetch_glb(&organ.glb_url) {
175 Ok(bytes) => fetched.push((organ.filename.clone(), bytes)),
176 Err(e) => failed.push((organ.filename.clone(), format!("fetch: {e}"))),
177 }
178 }
179
180 let compiled = compile_body(model, &fetched);
182 for (k, e) in &compiled.failed {
183 failed.push((k.clone(), format!("compile: {e}")));
184 }
185
186 let mut writer = BundleWriter::new();
188 let mut total_10d_bytes = 0usize;
189 let mut packed_keys: Vec<String> = Vec::new();
190 for organ in &compiled.organs {
191 let systems: Vec<String> = system_memberships_for_organ(&organ.organ_key)
194 .into_iter()
195 .map(|(s, _)| s.to_string())
196 .collect();
197 let meta = AnatomyOrganMeta {
198 system: organ.system_id.clone(),
199 label: normalize_organ_key(&organ.organ_key), systems,
201 position: position_for(&organ.organ_key),
202 rgba: palette_for(&organ.system_id),
203 };
204 let bytes = organ.asset.container_10d.clone();
205 total_10d_bytes += bytes.len();
206 writer
207 .add_file(organ.organ_key.clone(), "10d", bytes, Some(meta.to_cbor()))
208 .map_err(|e| format!("bundle add {}: {e}", organ.organ_key))?;
209 packed_keys.push(organ.organ_key.clone());
210 }
211
212 let source_urls: HashMap<String, String> = selected
219 .iter()
220 .map(|o| (o.filename.clone(), o.glb_url.clone()))
221 .collect();
222 let (q42_bytes, q42_quins) = build_pack_q42(model, &compiled.organs, &source_urls);
223 let q42_graph_bytes = q42_bytes.len();
224 writer
226 .add_file("body.q42", "q42", q42_bytes.clone(), None)
227 .map_err(|e| format!("bundle add body.q42: {e}"))?;
228
229 let bundle = writer.build().map_err(|e| format!("bundle build: {e}"))?;
230 if let Some(parent) = out_path.parent() {
231 std::fs::create_dir_all(parent).map_err(|e| format!("create out dir: {e}"))?;
232 }
233 std::fs::write(out_path, &bundle).map_err(|e| format!("write {}: {e}", out_path.display()))?;
234
235 let q42_sidecar = out_path.with_extension("q42");
239 std::fs::write(&q42_sidecar, &q42_bytes)
240 .map_err(|e| format!("write {}: {e}", q42_sidecar.display()))?;
241
242 Ok(PackReport {
243 model: model.as_str().to_string(),
244 out_path: out_path.display().to_string(),
245 organs_packed: compiled.organs.len(),
246 total_10d_bytes,
247 bundle_bytes: bundle.len(),
248 curated_not_found,
249 failed,
250 packed_keys,
251 q42_graph_bytes,
252 q42_quins,
253 q42_sidecar_path: q42_sidecar.display().to_string(),
254 })
255}
256
257fn build_pack_q42(
264 model: AnatomyModel,
265 organs: &[CompiledOrgan],
266 source_urls: &HashMap<String, String>,
267) -> (Vec<u8>, usize) {
268 let containers: Vec<_> = organs
269 .iter()
270 .map(|o| organ_container(o, model, source_urls.get(&o.organ_key).map(String::as_str)))
271 .collect();
272 let body = body_container(model, &containers);
273 let quins = body.quins.len();
274 (q42_bytes_from_graph(&body.quins, &body.lexicon), quins)
275}
276
277fn q42_bytes_from_graph(quins: &[NQuin], lexicon: &HashMap<u64, String>) -> Vec<u8> {
281 let mut sorted = quins.to_vec();
282 sorted.sort_by_key(|q| q.object);
283 let mut builder = UnifiedVolumeBuilder::with_lex_map(lexicon)
284 .expect("body Q42 lexicon entries fit the current Q42LEX format");
285 for (seq, chunk) in sorted.chunks(QUINS_PER_BLOCK).enumerate() {
286 builder
287 .push_block(seq as u64, chunk)
288 .expect("body Q42 graph is object-sorted");
289 }
290 builder.finish_to_bytes()
291}
292
293fn palette_for(system: &str) -> [f32; 4] {
298 wellfare_core::anatomy::default_registry().color_of(system)
299}
300
301fn position_for(filename: &str) -> [f32; 3] {
306 let token = normalize_organ_key(filename);
307 let [x, y, z] = match token.as_str() {
308 "brain" => [0.50, 0.93, 0.50],
309 "spinal-cord" => [0.50, 0.70, 0.44],
310 "trachea" => [0.50, 0.74, 0.55],
311 "thymus" => [0.50, 0.66, 0.55],
312 "heart" => [0.50, 0.60, 0.55],
313 "lung" => [0.42, 0.62, 0.50],
314 "liver" => [0.57, 0.53, 0.52],
315 "stomach" => [0.44, 0.52, 0.52],
316 "spleen" => [0.60, 0.52, 0.44],
317 "pancreas" => [0.50, 0.50, 0.46],
318 "gallbladder" => [0.56, 0.51, 0.55],
319 "kidney" => [0.50, 0.47, 0.40],
320 "small-intestine" => [0.50, 0.42, 0.55],
321 "large-intestine" => [0.50, 0.42, 0.60],
322 "urinary-bladder" => [0.50, 0.33, 0.55],
323 "larynx" => [0.50, 0.77, 0.55],
324 "main-bronchus" => [0.50, 0.66, 0.50],
325 "mouth" => [0.50, 0.85, 0.56],
326 "ureter" => [0.50, 0.40, 0.42],
327 "lymph-node" => [0.44, 0.68, 0.50],
328 "eye" => [0.50, 0.90, 0.57],
329 "pelvis" => [0.50, 0.35, 0.50],
330 "skin" => [0.50, 0.50, 0.50],
331 "prostate" => [0.50, 0.31, 0.50],
332 "uterus" => [0.50, 0.34, 0.50],
333 "ovary" => [0.50, 0.37, 0.45],
334 "vagina" => [0.50, 0.29, 0.50],
335 "fallopian-tube" => [0.50, 0.38, 0.45],
336 _ => [0.50, 0.50, 0.50],
337 };
338 let lower = filename.to_ascii_lowercase();
340 let x = if lower.contains("-l.") || lower.contains("-left") {
341 x - 0.09
342 } else if lower.contains("-r.") || lower.contains("-right") {
343 x + 0.09
344 } else {
345 x
346 };
347 [x, y, z]
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353
354 #[test]
358 fn pack_q42_carries_provenance_and_system_semantics() {
359 use qualia_core_db::q42_volume::{Q42Volume, Q42_MAGIC};
360
361 const TRI_OBJ: &[u8] = b"v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n";
364 let organs = vec![
365 ("3d-vh-m-lung.obj".to_string(), TRI_OBJ.to_vec()),
366 (
367 "3d-vh-m-blood-vasculature.obj".to_string(),
368 TRI_OBJ.to_vec(),
369 ),
370 ];
371 let body = compile_body(AnatomyModel::Male, &organs);
372 assert_eq!(body.organs.len(), 2, "both organs mapped to a system");
373
374 let mut urls = HashMap::new();
375 urls.insert(
376 "3d-vh-m-lung.obj".to_string(),
377 "https://cdn.humanatlas.io/hra/lung.glb".to_string(),
378 );
379 let (q42, quin_count) = build_pack_q42(AnatomyModel::Male, &body.organs, &urls);
380 assert!(quin_count > 0, "the graph has facts");
381 assert!(q42.starts_with(&Q42_MAGIC), "produced a Q42 volume");
382
383 let tmp = tempfile::NamedTempFile::new().unwrap();
385 std::fs::write(tmp.path(), &q42).unwrap();
386 let vol = Q42Volume::open(tmp.path()).unwrap();
387 let quins = vol.read_all_quins().unwrap();
388 assert_eq!(
389 quins.len(),
390 quin_count,
391 "every fact recoverable from the .q42"
392 );
393
394 let lex = vol.lex_view().unwrap();
396 let vals: Vec<String> = quins
397 .iter()
398 .filter_map(|q| lex.lookup_hash(q.object).map(str::to_string))
399 .collect();
400 assert!(
402 vals.iter().any(|v| v == "CC-BY-4.0"),
403 "licence fact present: {vals:?}"
404 );
405 assert!(
406 vals.iter().any(|v| v.contains("humanatlas.io")),
407 "source citation present: {vals:?}"
408 );
409 assert!(
411 vals.iter().any(|v| v == "respiratory"),
412 "lung system present"
413 );
414 assert!(
415 vals.iter().any(|v| v == "circulatory"),
416 "vasculature system present"
417 );
418 }
419
420 #[test]
421 fn laterality_nudges_paired_organs_apart() {
422 let l = position_for("3d-vh-f-kidney-l.glb");
423 let r = position_for("3d-vh-f-kidney-r.glb");
424 assert!(
425 l[0] < r[0],
426 "left kidney is left of right kidney: {l:?} {r:?}"
427 );
428 assert_eq!(position_for("3d-vh-m-heart.glb")[0], 0.50);
430 }
431
432 #[test]
433 fn palette_covers_systems_with_neutral_fallback() {
434 assert_eq!(palette_for("circulatory")[0], 0.80);
436 assert_eq!(palette_for("immune_lymphatic")[1], 0.82);
437 for sys in [
439 "nervous",
440 "circulatory",
441 "respiratory",
442 "digestive",
443 "urinary",
444 "immune_lymphatic",
445 ] {
446 assert_ne!(
447 palette_for(sys),
448 [0.62, 0.66, 0.72, 1.0],
449 "{sys} should have a colour"
450 );
451 }
452 assert_eq!(palette_for("unknown-system"), [0.62, 0.66, 0.72, 1.0]);
454 }
455}