Skip to main content

qualia_client_core/wellfair/
bodyparts3d_resolver.rs

1//! Ingest the **BodyParts3D** FMA-keyed anatomy meshes — the open library that *completes* the body the
2//! CCF/HRA reference organs cannot: 437 individual muscles, 251 bones, 99 nerves, endocrine + sense-organ
3//! glands. Where CCF is viscera-only, BodyParts3D fills the muscular / skeletal / nervous / endocrine /
4//! sensory systems.
5//!
6//! **Licence (precise):** the mesh files originate from the **BodyParts3D / Anatomography** project at
7//! **lifesciencedb.jp** (© The Database Center for Life Science, DBCLS) and are licensed
8//! **CC-BY-SA 2.1 Japan** (Attribution-**ShareAlike**). We retrieve them via a redistribution repo,
9//! `github.com/Kevin-Mattheus-Moerman/BodyParts3D` — whose *own* `LICENSE` (MIT) covers only Moerman's
10//! Julia code + OBJ→STL packaging, **not** the anatomy data: a permissive wrapper does not relicense the
11//! upstream copyleft meshes. So the meshes remain CC-BY-SA and ship as a **separate, clearly-licensed
12//! pack** (share-alike stays contained; the CCF CC-BY-4.0 pack stays permissive). Attribution + citation
13//! are recorded per the database's terms ([`BP3D_ATTRIBUTION`], [`BP3D_CITATION`], [`BP3D_DATA_DOI`]).
14//! Data version 3.0 / 20110915 (a single male reference model).
15//!
16//! **The join (Timothy's "organs are parts of systems" made real):** every mesh is keyed by an FMA id;
17//! `conventional_part_of.txt` gives the part-of hierarchy with the *systems themselves* as FMA nodes
18//! (`FMA72954` = muscular system, `FMA9668` = endocrine system, …). Walking a structure **up** the
19//! part-of graph until it reaches a system root yields its system membership(s) — and a structure that
20//! reaches several roots is genuinely multi-system (the diaphragm resolves to *both* muscular and
21//! respiratory, straight from the ontology).
22//!
23//! This module is **pure** (parse + graph walk), unit-tested against fixtures; the live fetch + pack
24//! producer are a `#[cfg(not(wasm32))]` transport layer at the bottom.
25
26use std::collections::{HashMap, HashSet};
27
28/// Raw base of the BodyParts3D fork we ingest (STL + the mapping files live under it).
29pub const BP3D_RAW_BASE: &str =
30    "https://raw.githubusercontent.com/Kevin-Mattheus-Moerman/BodyParts3D/main";
31/// Repo-relative path of the STL directory (files are `FMA<id>.stl` / `BP<id>.stl`).
32pub const BP3D_STL_DIR: &str = "assets/BodyParts3D_data/stl";
33/// Repo-relative path of the id→English-name list (`"id"\ten` header, then tab-separated rows).
34pub const BP3D_PARTS_LIST: &str = "assets/BodyParts3D_data/parts_list_e.txt";
35/// Repo-relative path of the part-of hierarchy (`id, name, part id, part name` — `part` is part-of `id`).
36pub const BP3D_PART_OF: &str = "assets/BodyParts3D_data/conventional_part_of.txt";
37/// The BodyParts3D licence id, recorded in each mesh's provenance sidecar and the pack attribution.
38pub const BP3D_LICENCE: &str = "CC-BY-SA-2.1-JP";
39/// The **exact** attribution string the database's terms require (do not paraphrase away).
40pub const BP3D_ATTRIBUTION: &str =
41    "BodyParts3D, © The Database Center for Life Science licensed under CC Attribution-Share Alike 2.1 Japan";
42/// The primary source of the mesh data (the originals; the GitHub repo is a redistribution mirror).
43pub const BP3D_SOURCE_URL: &str = "https://lifesciencedb.jp/bp3d/";
44/// The citation the database asks users of the content to include.
45pub 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";
48/// The data-archive DOI for the BodyParts3D content.
49pub const BP3D_DATA_DOI: &str = "https://doi.org/10.18908/lsdba.nbdc00837-000";
50
51/// FMA ids of the anatomical **systems**, mapped to our body-system ids. A structure's part-of chain is
52/// walked until it reaches one of these; the reached root(s) are the structure's system membership(s).
53/// (Enumerated live from BodyParts3D's `parts_list_e.txt` — the 16 `*-system` nodes.)
54static SYSTEM_ROOTS: &[(&str, &str)] = &[
55    ("FMA7161", "circulatory"),       // cardiovascular system
56    ("FMA7158", "respiratory"),       // respiratory system
57    ("FMA7152", "digestive"),         // alimentary system
58    ("FMA7157", "nervous"),           // nervous system
59    ("FMA72954", "muscular"),         // muscular system
60    ("FMA23881", "skeletal"),         // skeletal system
61    ("FMA23878", "skeletal"),         // articular system (joints) → skeletal
62    ("FMA61406", "skeletal"),         // skeletal system of free upper limb → skeletal
63    ("FMA61409", "skeletal"),         // skeletal system of free lower limb → skeletal
64    ("FMA9668", "endocrine"),         // endocrine system
65    ("FMA74594", "immune_lymphatic"), // lymphoid system
66    ("FMA72979", "integumentary"),    // integumentary system
67    ("FMA7159", "urinary"),           // urinary system
68    ("FMA7160", "reproductive"),      // genital system
69    ("FMA45664", "reproductive"),     // male genital system
70    ("FMA78499", "sensory"),          // sense organ system
71];
72
73/// The body-system id for a system-root FMA id, if it is a known system root.
74fn 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
81/// Strip surrounding double-quotes and whitespace from a TSV cell (the header cells are quoted).
82fn unquote(s: &str) -> &str {
83    s.trim().trim_matches('"')
84}
85
86/// The BodyParts3D part-of hierarchy: id→name and part→wholes, for resolving a structure to its system(s).
87pub struct Bp3dHierarchy {
88    names: HashMap<String, String>,
89    /// `part_id → [whole_id, …]` — the edges walked upward to reach a system root.
90    part_to_wholes: HashMap<String, Vec<String>>,
91}
92
93impl Bp3dHierarchy {
94    /// Build from the two mapping files' text: `parts_list_e.txt` (id→name) and
95    /// `conventional_part_of.txt` (each row: whole `id` — its `part id`). Header rows are skipped.
96    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; // header / malformed
117            }
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    /// The English anatomical name for a structure id.
130    pub fn name(&self, id: &str) -> Option<&str> {
131        self.names.get(id).map(String::as_str)
132    }
133
134    /// The **direct** part-of parents (wholes) of a structure — the immediate `partOf` edges for the
135    /// ontology (as opposed to [`systems_for`](Self::systems_for), which walks all the way to a system).
136    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    /// The body system(s) a structure belongs to — walk part→whole up to the system roots. A structure
144    /// can reach several roots (the diaphragm is muscular **and** respiratory), so all are returned,
145    /// sorted for determinism. Empty if it reaches no system (abstract / immaterial nodes).
146    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
170/// The raw STL URL for a BodyParts3D structure id (`FMA13295` → `…/stl/FMA13295.stl`).
171pub fn stl_url(id: &str) -> String {
172    format!("{BP3D_RAW_BASE}/{BP3D_STL_DIR}/{id}.stl")
173}
174
175/// Repo-relative path of the FMA is-a table (`FMAID,"Preferred Label",Parent FMAID`).
176pub const BP3D_FMA_CSV: &str = "assets/BodyParts3D_data/FMA.csv";
177
178/// Parse the FMA **is-a** CSV (`FMAID,"Preferred Label",Parent FMAID`) into a child→parent map, keyed in
179/// the `FMA<id>` form (matching the STL filenames). The label may contain commas (it is quoted), so the
180/// id and parent are taken as the first and last comma-separated fields — robust to embedded commas.
181pub 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; // header / blank
192        }
193        // CSV ids are bare numbers; meshes are `FMA<num>` — normalise to the FMA-prefixed form.
194        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// ── Live fetch + pack producer (native only; blocking network I/O) ───────────────────────────────
202#[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    /// One available STL structure: its id and byte size (from the git-trees listing).
241    #[derive(Debug, Clone)]
242    pub struct Bp3dAsset {
243        pub id: String,
244        pub size: usize,
245    }
246
247    /// List every STL structure available in the repo (id + byte size) via the GitHub git-trees API.
248    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    /// What to include from BodyParts3D — the **bandwidth control** (the full set is ~1.3 GB / 937 files).
273    #[derive(Debug, Clone, Default)]
274    pub struct Bp3dSelection {
275        /// Only structures whose membership intersects these system ids (empty = every system).
276        pub systems: Vec<String>,
277        /// Cap the structure count (0 = no cap).
278        pub max_structures: usize,
279        /// Skip a structure whose STL exceeds this many bytes (0 = no cap) — e.g. drop the 79 MB
280        /// whole-body composite blobs.
281        pub max_stl_bytes: usize,
282    }
283
284    /// Honest report of a BodyParts3D pack build.
285    #[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        /// (system_id, structure count) — the completeness this pack adds, per system.
292        pub per_system: Vec<(String, usize)>,
293        /// Size in bytes of the ontology `.q42` (concepts + is-a + part-of + system + geometry links).
294        pub ontology_q42_bytes: usize,
295        /// Number of quins (facts) in the ontology graph.
296        pub ontology_quins: usize,
297        /// Path the linkable `.q42` sidecar was written to (byte-identical to the bundle's `body.q42`).
298        pub q42_sidecar_path: String,
299        /// (structure id, error) for anything that failed to fetch or compile — never silently dropped.
300        pub failed: Vec<(String, String)>,
301    }
302
303    /// Build a **separate, CC-BY-SA** `.hmc` pack of BodyParts3D structures that complete the body
304    /// (the muscles/bones/glands/nerves CCF lacks). Each mesh is resolved to its system(s) via the
305    /// part-of walk, compiled to a sealed `.10d` attested with the BodyParts3D licence, and packed with
306    /// its full multi-system [`AnatomyOrganMeta`]. Blocking network I/O; honest about what failed.
307    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        // 1. The part-of hierarchy (id→name, part→whole) for resolving structures to systems.
314        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        // 2. What's available, deterministically ordered, filtered by the selection.
319        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; // abstract / immaterial — nothing to place
330            }
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        // 3. Fetch → compile (attested CC-BY-SA) → pack. Failures are reported, not dropped.
344        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        // Concepts (id + compiled `.10d` digest + systems) → the ontology `.q42` after the loop.
350        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            // Attest each mesh with the BodyParts3D licence so the `.10d` passes the renderer's
363            // fail-closed governance gate and travels with its CC-BY-SA provenance.
364            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; // capture before container_10d is moved
377                    let meta = AnatomyOrganMeta {
378                        system: primary.to_string(),
379                        label: hier.name(id).unwrap_or(id).to_string(), // human FMA name for the parts list
380                        systems: systems.iter().map(|s| s.to_string()).collect(),
381                        position: [0.5, 0.5, 0.5], // BodyParts3D meshes carry true coordinates; the renderer uses those
382                        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        // The addressable ONTOLOGY: fetch the FMA is-a table and emit the `.q42` graph (OBO IRIs + house
414        // aliases, is-a + part-of + system + geometry links) that cites the `.10d` meshes just packed.
415        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        // A directly-linkable, byte-identical ontology `.q42` sidecar beside the bundle.
431        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    // A minimal fixture mirroring the real files' format (tab-separated, quoted header).
459    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    // Rows: whole `id` — its `part id`. The diaphragm is a part of BOTH muscular and respiratory systems.
468    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        // A single-system structure resolves to one system…
483        assert_eq!(h.systems_for("FMA_BICEPS"), vec!["muscular"]);
484        assert_eq!(h.systems_for("FMA_THYROID"), vec!["endocrine"]);
485        // …and a genuine dual-role structure resolves to BOTH (straight from the ontology).
486        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        // A system root resolves to itself.
493        assert_eq!(h.systems_for("FMA72954"), vec!["muscular"]);
494        // An id with no path to any system → no membership (reported empty, never guessed).
495        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        // Guard: each FMA system root maps to an id the registry actually knows.
501        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        // A label containing a comma is still parsed (id = first field, parent = last field).
518        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        // The diaphragm is a direct part of both the muscular and respiratory systems (fixture row order).
526        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}