1use std::collections::{HashMap, HashSet};
27
28pub const BP3D_RAW_BASE: &str =
30 "https://raw.githubusercontent.com/Kevin-Mattheus-Moerman/BodyParts3D/main";
31pub const BP3D_STL_DIR: &str = "assets/BodyParts3D_data/stl";
33pub const BP3D_PARTS_LIST: &str = "assets/BodyParts3D_data/parts_list_e.txt";
35pub const BP3D_PART_OF: &str = "assets/BodyParts3D_data/conventional_part_of.txt";
37pub const BP3D_LICENCE: &str = "CC-BY-SA-2.1-JP";
39pub const BP3D_ATTRIBUTION: &str =
41 "BodyParts3D, © The Database Center for Life Science licensed under CC Attribution-Share Alike 2.1 Japan";
42pub const BP3D_SOURCE_URL: &str = "https://lifesciencedb.jp/bp3d/";
44pub const BP3D_CITATION: &str = "Mitsuhashi N, Fujieda K, Tamura T, Kawamoto S, Takagi T, Okubo K. \
46 BodyParts3D: 3D structure database for anatomical concepts. Nucleic Acids Res. 2009 Jan;37(Database issue):D782-5. \
47 https://doi.org/10.1093/nar/gkn613";
48pub const BP3D_DATA_DOI: &str = "https://doi.org/10.18908/lsdba.nbdc00837-000";
50
51static SYSTEM_ROOTS: &[(&str, &str)] = &[
55 ("FMA7161", "circulatory"), ("FMA7158", "respiratory"), ("FMA7152", "digestive"), ("FMA7157", "nervous"), ("FMA72954", "muscular"), ("FMA23881", "skeletal"), ("FMA23878", "skeletal"), ("FMA61406", "skeletal"), ("FMA61409", "skeletal"), ("FMA9668", "endocrine"), ("FMA74594", "immune_lymphatic"), ("FMA72979", "integumentary"), ("FMA7159", "urinary"), ("FMA7160", "reproductive"), ("FMA45664", "reproductive"), ("FMA78499", "sensory"), ];
72
73fn system_for_root(fma_id: &str) -> Option<&'static str> {
75 SYSTEM_ROOTS
76 .iter()
77 .find(|(f, _)| *f == fma_id)
78 .map(|(_, s)| *s)
79}
80
81fn unquote(s: &str) -> &str {
83 s.trim().trim_matches('"')
84}
85
86pub struct Bp3dHierarchy {
88 names: HashMap<String, String>,
89 part_to_wholes: HashMap<String, Vec<String>>,
91}
92
93impl Bp3dHierarchy {
94 pub fn from_mapping(parts_list_txt: &str, part_of_txt: &str) -> Self {
97 let mut names = HashMap::new();
98 for line in parts_list_txt.lines() {
99 let c: Vec<&str> = line.split('\t').collect();
100 if c.len() >= 2 {
101 let id = unquote(c[0]);
102 if id != "id" && !id.is_empty() {
103 names.insert(id.to_string(), c[1].trim().to_string());
104 }
105 }
106 }
107 let mut part_to_wholes: HashMap<String, Vec<String>> = HashMap::new();
108 for line in part_of_txt.lines() {
109 let c: Vec<&str> = line.split('\t').collect();
110 if c.len() < 4 {
111 continue;
112 }
113 let whole = unquote(c[0]);
114 let part = unquote(c[2]);
115 if whole == "id" || whole.is_empty() || part.is_empty() {
116 continue; }
118 part_to_wholes
119 .entry(part.to_string())
120 .or_default()
121 .push(whole.to_string());
122 }
123 Self {
124 names,
125 part_to_wholes,
126 }
127 }
128
129 pub fn name(&self, id: &str) -> Option<&str> {
131 self.names.get(id).map(String::as_str)
132 }
133
134 pub fn wholes_of(&self, id: &str) -> &[String] {
137 self.part_to_wholes
138 .get(id)
139 .map(Vec::as_slice)
140 .unwrap_or(&[])
141 }
142
143 pub fn systems_for(&self, id: &str) -> Vec<&'static str> {
147 let mut out: Vec<&'static str> = Vec::new();
148 let mut seen: HashSet<String> = HashSet::new();
149 let mut stack = vec![id.to_string()];
150 while let Some(cur) = stack.pop() {
151 if !seen.insert(cur.clone()) {
152 continue;
153 }
154 if let Some(sys) = system_for_root(&cur) {
155 if !out.contains(&sys) {
156 out.push(sys);
157 }
158 }
159 if let Some(wholes) = self.part_to_wholes.get(&cur) {
160 for w in wholes {
161 stack.push(w.clone());
162 }
163 }
164 }
165 out.sort_unstable();
166 out
167 }
168}
169
170pub fn stl_url(id: &str) -> String {
172 format!("{BP3D_RAW_BASE}/{BP3D_STL_DIR}/{id}.stl")
173}
174
175pub const BP3D_FMA_CSV: &str = "assets/BodyParts3D_data/FMA.csv";
177
178pub fn parse_fma_isa(csv: &str) -> HashMap<String, String> {
182 let mut out = HashMap::new();
183 for line in csv.lines() {
184 let (first, last) = match (line.find(','), line.rfind(',')) {
185 (Some(f), Some(l)) if l > f => (f, l),
186 _ => continue,
187 };
188 let id = unquote(&line[..first]);
189 let parent = unquote(&line[last + 1..]);
190 if id.is_empty() || parent.is_empty() || id == "FMAID" {
191 continue; }
193 if id.bytes().all(|b| b.is_ascii_digit()) && parent.bytes().all(|b| b.is_ascii_digit()) {
195 out.insert(format!("FMA{id}"), format!("FMA{parent}"));
196 }
197 }
198 out
199}
200
201#[cfg(not(target_arch = "wasm32"))]
203mod producer {
204 use super::*;
205 use std::collections::{BTreeMap, HashSet};
206 use std::path::Path;
207
208 use qualia_core_db::bundle::BundleWriter;
209 use qualia_core_db::container_10d::ProvenanceSidecar;
210 use qualia_core_db::render::anatomy_pack::AnatomyOrganMeta;
211 use qualia_core_db::render::compile_10d::compile_organ_asset;
212
213 const HTTP_USER_AGENT: &str = "QualiaDB-anatomy/1.0";
214
215 fn get_text(url: &str) -> Result<String, String> {
216 let resp = reqwest::blocking::Client::new()
217 .get(url)
218 .header(reqwest::header::USER_AGENT, HTTP_USER_AGENT)
219 .send()
220 .map_err(|e| format!("GET {url}: {e}"))?
221 .error_for_status()
222 .map_err(|e| format!("status {url}: {e}"))?;
223 resp.text().map_err(|e| format!("body {url}: {e}"))
224 }
225
226 fn get_bytes(url: &str) -> Result<Vec<u8>, String> {
227 let resp = reqwest::blocking::Client::new()
228 .get(url)
229 .header(reqwest::header::USER_AGENT, HTTP_USER_AGENT)
230 .send()
231 .map_err(|e| format!("GET {url}: {e}"))?
232 .error_for_status()
233 .map_err(|e| format!("status {url}: {e}"))?;
234 Ok(resp
235 .bytes()
236 .map_err(|e| format!("body {url}: {e}"))?
237 .to_vec())
238 }
239
240 #[derive(Debug, Clone)]
242 pub struct Bp3dAsset {
243 pub id: String,
244 pub size: usize,
245 }
246
247 pub fn list_available_stl() -> Result<Vec<Bp3dAsset>, String> {
249 let url = "https://api.github.com/repos/Kevin-Mattheus-Moerman/BodyParts3D/git/trees/main?recursive=1";
250 let json = get_text(url)?;
251 let v: serde_json::Value =
252 serde_json::from_str(&json).map_err(|e| format!("tree json: {e}"))?;
253 let prefix = format!("{BP3D_STL_DIR}/");
254 let mut out = Vec::new();
255 if let Some(arr) = v.get("tree").and_then(|t| t.as_array()) {
256 for e in arr {
257 let path = e.get("path").and_then(|p| p.as_str()).unwrap_or("");
258 if let Some(fname) = path.strip_prefix(&prefix) {
259 if let Some(id) = fname.strip_suffix(".stl") {
260 let size = e.get("size").and_then(|s| s.as_u64()).unwrap_or(0) as usize;
261 out.push(Bp3dAsset {
262 id: id.to_string(),
263 size,
264 });
265 }
266 }
267 }
268 }
269 Ok(out)
270 }
271
272 #[derive(Debug, Clone, Default)]
274 pub struct Bp3dSelection {
275 pub systems: Vec<String>,
277 pub max_structures: usize,
279 pub max_stl_bytes: usize,
282 }
283
284 #[derive(Debug, Clone)]
286 pub struct Bp3dPackReport {
287 pub out_path: String,
288 pub structures_packed: usize,
289 pub bundle_bytes: usize,
290 pub total_stl_bytes: usize,
291 pub per_system: Vec<(String, usize)>,
293 pub ontology_q42_bytes: usize,
295 pub ontology_quins: usize,
297 pub q42_sidecar_path: String,
299 pub failed: Vec<(String, String)>,
301 }
302
303 pub fn build_bodyparts3d_pack(
308 selection: &Bp3dSelection,
309 out_path: impl AsRef<Path>,
310 ) -> Result<Bp3dPackReport, String> {
311 let out_path = out_path.as_ref();
312
313 let parts = get_text(&format!("{BP3D_RAW_BASE}/{BP3D_PARTS_LIST}"))?;
315 let part_of = get_text(&format!("{BP3D_RAW_BASE}/{BP3D_PART_OF}"))?;
316 let hier = Bp3dHierarchy::from_mapping(&parts, &part_of);
317
318 let mut avail = list_available_stl()?;
320 avail.sort_by(|a, b| a.id.cmp(&b.id));
321 let want: HashSet<&str> = selection.systems.iter().map(String::as_str).collect();
322 let mut selected: Vec<(String, Vec<&'static str>)> = Vec::new();
323 for a in &avail {
324 if selection.max_stl_bytes > 0 && a.size > selection.max_stl_bytes {
325 continue;
326 }
327 let systems = hier.systems_for(&a.id);
328 if systems.is_empty() {
329 continue; }
331 if !want.is_empty() && !systems.iter().any(|s| want.contains(s)) {
332 continue;
333 }
334 selected.push((a.id.clone(), systems));
335 if selection.max_structures > 0 && selected.len() >= selection.max_structures {
336 break;
337 }
338 }
339 if selected.is_empty() {
340 return Err("no BodyParts3D structures matched the selection".to_string());
341 }
342
343 let mut writer = BundleWriter::new();
345 let mut failed: Vec<(String, String)> = Vec::new();
346 let mut total_stl_bytes = 0usize;
347 let mut per_system: BTreeMap<String, usize> = BTreeMap::new();
348 let mut packed = 0usize;
349 let mut concepts: Vec<super::super::bodyparts3d_ontology::OntologyConcept> = Vec::new();
351 for (id, systems) in &selected {
352 let bytes = match get_bytes(&stl_url(id)) {
353 Ok(b) => b,
354 Err(e) => {
355 failed.push((id.clone(), e));
356 continue;
357 }
358 };
359 total_stl_bytes += bytes.len();
360 let primary = systems[0];
361 let uri = format!("urn:bodyparts3d:{id}");
362 let provenance =
365 ProvenanceSidecar::new(uri.clone().into_bytes(), "model/stl", BP3D_LICENCE);
366 match compile_organ_asset(
367 &bytes,
368 Some("stl"),
369 &uri,
370 "stl",
371 Some(primary),
372 None,
373 Some(&provenance),
374 ) {
375 Ok(asset) => {
376 let digest = asset.compiled_digest; let meta = AnatomyOrganMeta {
378 system: primary.to_string(),
379 label: hier.name(id).unwrap_or(id).to_string(), systems: systems.iter().map(|s| s.to_string()).collect(),
381 position: [0.5, 0.5, 0.5], rgba: wellfare_core::anatomy::default_registry().color_of(primary),
383 };
384 if let Err(e) = writer.add_file(
385 format!("{id}.10d"),
386 "10d",
387 asset.container_10d,
388 Some(meta.to_cbor()),
389 ) {
390 failed.push((id.clone(), format!("bundle add: {e}")));
391 continue;
392 }
393 for s in systems {
394 *per_system.entry(s.to_string()).or_default() += 1;
395 }
396 concepts.push(super::super::bodyparts3d_ontology::OntologyConcept {
397 id: id.clone(),
398 compiled_digest: digest,
399 systems: systems.iter().map(|s| s.to_string()).collect(),
400 });
401 packed += 1;
402 }
403 Err(e) => failed.push((id.clone(), format!("compile: {e}"))),
404 }
405 }
406 if packed == 0 {
407 return Err(format!(
408 "no BodyParts3D structures compiled (all {} failed)",
409 failed.len()
410 ));
411 }
412
413 let fma_csv = get_text(&format!("{BP3D_RAW_BASE}/{BP3D_FMA_CSV}"))?;
416 let isa = parse_fma_isa(&fma_csv);
417 let (q42_bytes, ontology_quins) =
418 super::super::bodyparts3d_ontology::ontology_q42_bytes(&concepts, &hier, &isa);
419 let ontology_q42_bytes = q42_bytes.len();
420 writer
421 .add_file("body.q42", "q42", q42_bytes.clone(), None)
422 .map_err(|e| format!("bundle add body.q42: {e}"))?;
423
424 let bundle = writer.build().map_err(|e| format!("bundle build: {e}"))?;
425 if let Some(parent) = out_path.parent() {
426 std::fs::create_dir_all(parent).map_err(|e| format!("create out dir: {e}"))?;
427 }
428 std::fs::write(out_path, &bundle)
429 .map_err(|e| format!("write {}: {e}", out_path.display()))?;
430 let q42_sidecar = out_path.with_extension("q42");
432 std::fs::write(&q42_sidecar, &q42_bytes)
433 .map_err(|e| format!("write {}: {e}", q42_sidecar.display()))?;
434
435 Ok(Bp3dPackReport {
436 out_path: out_path.display().to_string(),
437 structures_packed: packed,
438 bundle_bytes: bundle.len(),
439 total_stl_bytes,
440 per_system: per_system.into_iter().collect(),
441 ontology_q42_bytes,
442 ontology_quins,
443 q42_sidecar_path: q42_sidecar.display().to_string(),
444 failed,
445 })
446 }
447}
448
449#[cfg(not(target_arch = "wasm32"))]
450pub use producer::{
451 build_bodyparts3d_pack, list_available_stl, Bp3dAsset, Bp3dPackReport, Bp3dSelection,
452};
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457
458 const PARTS: &str = "\"id\"\ten\n\
460 FMA20394\thuman body\n\
461 FMA72954\tmuscular system\n\
462 FMA7158\trespiratory system\n\
463 FMA9668\tendocrine system\n\
464 FMA13295\tdiaphragm\n\
465 FMA_BICEPS\tbiceps brachii\n\
466 FMA_THYROID\tthyroid gland\n";
467 const PART_OF: &str = "\"id\"\tname\tpart id\tpart name\n\
469 FMA20394\thuman body\tFMA72954\tmuscular system\n\
470 FMA20394\thuman body\tFMA7158\trespiratory system\n\
471 FMA20394\thuman body\tFMA9668\tendocrine system\n\
472 FMA72954\tmuscular system\tFMA13295\tdiaphragm\n\
473 FMA7158\trespiratory system\tFMA13295\tdiaphragm\n\
474 FMA72954\tmuscular system\tFMA_BICEPS\tbiceps brachii\n\
475 FMA9668\tendocrine system\tFMA_THYROID\tthyroid gland\n";
476
477 #[test]
478 fn maps_names_and_walks_structures_to_their_systems() {
479 let h = Bp3dHierarchy::from_mapping(PARTS, PART_OF);
480 assert_eq!(h.name("FMA13295"), Some("diaphragm"));
481 assert_eq!(h.name("FMA_THYROID"), Some("thyroid gland"));
482 assert_eq!(h.systems_for("FMA_BICEPS"), vec!["muscular"]);
484 assert_eq!(h.systems_for("FMA_THYROID"), vec!["endocrine"]);
485 assert_eq!(h.systems_for("FMA13295"), vec!["muscular", "respiratory"]);
487 }
488
489 #[test]
490 fn system_roots_themselves_resolve_and_unknowns_are_empty() {
491 let h = Bp3dHierarchy::from_mapping(PARTS, PART_OF);
492 assert_eq!(h.systems_for("FMA72954"), vec!["muscular"]);
494 assert!(h.systems_for("FMA_NOT_A_THING").is_empty());
496 }
497
498 #[test]
499 fn every_system_root_maps_to_a_real_body_system() {
500 let reg = wellfare_core::anatomy::default_registry();
502 for (fma, sys) in SYSTEM_ROOTS {
503 assert!(
504 reg.get(sys).is_some(),
505 "root {fma} → unknown system id {sys}"
506 );
507 }
508 }
509
510 #[test]
511 fn parse_fma_isa_reads_child_to_parent_even_with_commas_in_labels() {
512 let csv = "\"FMAID\",\"Preferred Label\",\"Parent FMAID\"\n\
513 13295,\"Diaphragm\",9909\n\
514 7163,\"Skin, layer of body\",72979\n";
515 let isa = parse_fma_isa(csv);
516 assert_eq!(isa.get("FMA13295"), Some(&"FMA9909".to_string()));
517 assert_eq!(isa.get("FMA7163"), Some(&"FMA72979".to_string()));
519 assert_eq!(isa.len(), 2, "header row skipped");
520 }
521
522 #[test]
523 fn wholes_of_returns_direct_part_of_parents() {
524 let h = Bp3dHierarchy::from_mapping(PARTS, PART_OF);
525 assert_eq!(
527 h.wholes_of("FMA13295"),
528 &["FMA72954".to_string(), "FMA7158".to_string()]
529 );
530 assert!(h.wholes_of("FMA_UNKNOWN").is_empty());
531 }
532
533 #[test]
534 fn stl_url_is_the_raw_github_path() {
535 assert_eq!(
536 stl_url("FMA13295"),
537 "https://raw.githubusercontent.com/Kevin-Mattheus-Moerman/BodyParts3D/main/assets/BodyParts3D_data/stl/FMA13295.stl"
538 );
539 }
540}