Skip to main content

qualia_client_core/wellfair/
anatomy_render.rs

1//! S5.7 — the **render surface** for the 3D Anatomy Qapp (interim visual).
2//!
3//! Builds a [`RenderScene`] representing the whole body as coloured regions per body system, painted by
4//! the accumulated burden (σ → RGBA via [`AnatomyViewReport::system_percepts`]). This is the **interim
5//! visual** noted in the progress log: a headless whole-body percept snapshot that lets Timothy *see* the
6//! body coloured by burden without needing the ~200–290 MB live CCF/HRA GLB download (the cache + real
7//! mesh path remains open). The orbit camera (azimuth/elevation) is driven by the caller so the Studio UI
8//! can spin the body.
9//!
10//! The 17 body systems are placed at anatomically meaningful positions on a normalized body silhouette.
11//! Each system renders as a coloured node whose radius grows with its burden (a bigger region = more
12//! accumulated adverse load) and whose colour is the σ-derived RGBA from the percept. Distributed-overlay
13//! systems (ECS / ENS / glymphatic) are rendered as translucent overlays over their host regions. The
14//! scene is consumed by the headless `webizen_render::render_scene_png` pipeline — no browser WebGPU
15//! required.
16
17use webizen_render::scene_contract::{
18    EpistemicState, RenderScene, SceneCamera, SceneNode, ScenePoint,
19};
20
21use super::anatomy_view::{AnatomyViewReport, SystemPercept};
22
23/// The approximate anatomical position of each body system on a normalized body silhouette
24/// (x: 0..1 left→right, y: 0..1 top→bottom, z: 0..1 front→back). These are illustrative — the real
25/// 3D body (when the GLB cache lands) replaces this silhouette with organ meshes.
26fn system_position(system_id: &str) -> ScenePoint {
27    let p = match system_id {
28        // Head / neck.
29        "nervous" => (0.50, 0.12, 0.50),
30        "sensory" => (0.50, 0.10, 0.55),
31        "endocrine" => (0.50, 0.20, 0.50),
32        // Chest.
33        "respiratory" => (0.50, 0.28, 0.50),
34        "circulatory" => (0.50, 0.30, 0.45),
35        "immune" => (0.45, 0.32, 0.50),
36        // Abdomen.
37        "digestive" => (0.50, 0.45, 0.50),
38        "urinary" => (0.50, 0.50, 0.55),
39        "reticuloendothelial" => (0.55, 0.42, 0.50),
40        "hematopoietic" => (0.50, 0.50, 0.40),
41        // Pelvis.
42        "reproductive" => (0.50, 0.58, 0.55),
43        // Whole-body / distributed.
44        "musculoskeletal" => (0.50, 0.50, 0.50),
45        "integumentary" => (0.50, 0.50, 0.60),
46        "thermoregulatory" => (0.50, 0.50, 0.62),
47        // Distributed overlays — placed at their primary host region.
48        "ens" => (0.50, 0.45, 0.52),        // enteric → digestive
49        "glymphatic" => (0.50, 0.12, 0.52), // brain cleanup → nervous
50        "ecs" => (0.50, 0.50, 0.50),        // endocannabinoid → whole-body
51        _ => (0.50, 0.50, 0.50),
52    };
53    ScenePoint {
54        x: p.0,
55        y: p.1,
56        z: p.2,
57    }
58}
59
60/// Whether a system is a distributed overlay (rendered translucently over its hosts).
61fn is_overlay(system_id: &str) -> bool {
62    matches!(system_id, "ens" | "glymphatic" | "ecs")
63}
64
65/// The base radius for a system region (pixels). Distributed overlays are larger (they cover a region,
66/// not a point); discrete systems are smaller and grow with burden.
67fn base_radius(system_id: &str) -> f64 {
68    if is_overlay(system_id) {
69        28.0
70    } else {
71        10.0
72    }
73}
74
75/// Convert a percept's normalized linear RGBA [0..1]⁴ to a CSS colour string for the renderer.
76fn rgba_to_css(rgba: [f32; 4]) -> String {
77    let r = (rgba[0] * 255.0).round().clamp(0.0, 255.0) as u8;
78    let g = (rgba[1] * 255.0).round().clamp(0.0, 255.0) as u8;
79    let b = (rgba[2] * 255.0).round().clamp(0.0, 255.0) as u8;
80    format!("#{r:02x}{g:02x}{b:02x}")
81}
82
83/// Build a whole-body [`RenderScene`] from an [`AnatomyViewReport`], coloured by accumulated burden and
84/// viewed from `(azimuth, elevation)` in degrees. `azimuth` 0..360 rotates around the body; `elevation`
85/// -90..90 looks up→down. The camera orbits at a fixed radius around the body centre.
86pub fn body_scene(report: &AnatomyViewReport, azimuth_deg: f64, elevation_deg: f64) -> RenderScene {
87    body_scene_with_fit(
88        report,
89        azimuth_deg,
90        elevation_deg,
91        &wellfare_core::anatomy::BodyFit::identity(),
92    )
93}
94
95/// Like [`body_scene`], but stretches the silhouette by the person's declared stature / torso / legs.
96pub fn body_scene_with_fit(
97    report: &AnatomyViewReport,
98    azimuth_deg: f64,
99    elevation_deg: f64,
100    fit: &wellfare_core::anatomy::BodyFit,
101) -> RenderScene {
102    let percepts = report.system_percepts();
103    let mut scene = RenderScene {
104        background: "#0a0f14".to_string(),
105        camera: orbit_camera(azimuth_deg, elevation_deg),
106        epistemic_filter: EpistemicState::Collapsed,
107        ..Default::default()
108    };
109
110    // If there are no percepts, render the settled baseline for every system so the body is still
111    // visible (not a blank screen).
112    let all_systems = [
113        "nervous",
114        "sensory",
115        "endocrine",
116        "respiratory",
117        "circulatory",
118        "immune",
119        "digestive",
120        "urinary",
121        "reticuloendothelial",
122        "hematopoietic",
123        "reproductive",
124        "musculoskeletal",
125        "integumentary",
126        "thermoregulatory",
127        "ens",
128        "glymphatic",
129        "ecs",
130    ];
131
132    for &sys in all_systems.iter() {
133        let percept = percepts
134            .iter()
135            .find(|p| p.system_id == sys)
136            .cloned()
137            .unwrap_or_else(|| SystemPercept {
138                system_id: sys.to_string(),
139                level: wellfare_core::anatomy::WellbeingLevel::Settled,
140                sigma: wellfare_core::anatomy::burden_to_sigma(0),
141                rgba: [0.29, 0.62, 0.36, 1.0], // settled green
142                frequency_hz: 0.0,
143            });
144        let pos = fit_silhouette_point(system_position(sys), fit);
145        let overlay = is_overlay(sys);
146        // Radius grows with burden: settled = base, under_strain = base × 2.2.
147        let burden_scale = match percept.level {
148            wellfare_core::anatomy::WellbeingLevel::UnderStrain => 2.2,
149            wellfare_core::anatomy::WellbeingLevel::WorthWatching => 1.5,
150            wellfare_core::anatomy::WellbeingLevel::Settled => 1.0,
151        };
152        let radius = base_radius(sys) * burden_scale;
153        let alpha = if overlay { 0.35 } else { 0.92 };
154        scene.add_node(SceneNode {
155            id: sys.to_string(),
156            position: pos,
157            color: rgba_to_css(percept.rgba),
158            radius,
159            alpha,
160            is_inferencing: percept.level != wellfare_core::anatomy::WellbeingLevel::Settled,
161            pulse_rate: if percept.level == wellfare_core::anatomy::WellbeingLevel::UnderStrain {
162                1.2
163            } else {
164                0.0
165            },
166            tensor: Default::default(),
167            epistemic_state: EpistemicState::Collapsed,
168            version: 0.0,
169            entity_id: 0,
170            affordance_bits: 0,
171        });
172    }
173
174    scene
175}
176
177fn fit_silhouette_point(
178    p: ScenePoint,
179    fit: &wellfare_core::anatomy::BodyFit,
180) -> ScenePoint {
181    // Silhouette y is top→bottom (0 head, 1 feet) — invert for the CCF-style fit bands.
182    let y_up = 1.0 - p.y as f32;
183    let y_seg = if y_up < fit.pelvis_y_norm {
184        fit.leg_scale_y
185    } else {
186        fit.torso_scale_y
187    };
188    let y_from_feet = (1.0 - p.y) * (y_seg as f64) * (fit.stature_scale as f64);
189    let cx = 0.50;
190    ScenePoint {
191        x: cx + (p.x - cx) * (fit.arm_span_scale_x as f64) * (fit.shoulder_scale_x as f64),
192        y: (1.0 - y_from_feet).clamp(0.02, 0.98),
193        z: p.z,
194    }
195}
196
197/// The orbit camera for `(azimuth, elevation)` in degrees, looking at the body centre `[0.5, 0.5, 0]`
198/// from a fixed radius.
199fn orbit_camera(azimuth_deg: f64, elevation_deg: f64) -> SceneCamera {
200    let az = azimuth_deg.to_radians();
201    let el = elevation_deg.clamp(-89.0, 89.0).to_radians();
202    let radius = 1.8;
203    let x = 0.5 + radius * el.cos() * az.sin();
204    let y = 0.5 + radius * el.sin();
205    let z = radius * el.cos() * az.cos();
206    SceneCamera {
207        position: [x, y, z],
208        target: [0.5, 0.5, 0.0],
209        fov: 50.0,
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use wellfare_core::anatomy::{PhysiologicalState, RecordRef};
217
218    #[test]
219    fn body_scene_has_a_node_per_system_and_orbits() {
220        let refs = vec![RecordRef::new("r:htn", "condition", "Hypertension")];
221        let report = super::super::anatomy_view::build_report(
222            refs,
223            wellfare_core::anatomy::Lens::Person,
224            2,
225            PhysiologicalState::Baseline,
226        );
227        let scene = body_scene(&report, 0.0, 0.0);
228        // All 17 systems are represented (even unburdened ones → settled baseline).
229        assert_eq!(scene.nodes.len(), 17, "every body system is rendered");
230        // The circulatory system (hypertension) is under strain → bigger + pulsing.
231        let circ = scene.nodes.iter().find(|n| n.id == "circulatory").unwrap();
232        assert!(
233            circ.radius > 10.0,
234            "burdened circulatory is enlarged: {}",
235            circ.radius
236        );
237        assert!(circ.is_inferencing, "burdened system is inferencing");
238        assert!(circ.pulse_rate > 0.0, "under-strain pulses");
239        // A settled system (e.g. respiratory) is calm.
240        let resp = scene.nodes.iter().find(|n| n.id == "respiratory").unwrap();
241        assert!(!resp.is_inferencing, "settled system is not inferencing");
242        assert_eq!(resp.pulse_rate, 0.0, "settled does not pulse");
243        // Distributed overlays are translucent.
244        let ens = scene.nodes.iter().find(|n| n.id == "ens").unwrap();
245        assert!(ens.alpha < 0.5, "overlay is translucent: {}", ens.alpha);
246    }
247
248    #[test]
249    fn orbit_camera_rotates_with_azimuth() {
250        let front = orbit_camera(0.0, 0.0);
251        let side = orbit_camera(90.0, 0.0);
252        // At azimuth 0 the camera is in front (z > 0); at azimuth 90 it's to the side (x shifts).
253        assert!(
254            front.position[2] > side.position[2],
255            "front view has more z"
256        );
257        assert!(
258            (side.position[0] - 0.5).abs() > 0.01,
259            "side view shifts x off-centre"
260        );
261        // Elevation tilts the camera up/down.
262        let up = orbit_camera(0.0, 45.0);
263        assert!(
264            up.position[1] > front.position[1],
265            "elevation raises the camera"
266        );
267    }
268
269    #[test]
270    fn rgba_to_css_round_trips_primary_channels() {
271        assert_eq!(rgba_to_css([1.0, 0.0, 0.0, 1.0]), "#ff0000");
272        assert_eq!(rgba_to_css([0.0, 1.0, 0.0, 1.0]), "#00ff00");
273        assert_eq!(rgba_to_css([0.0, 0.0, 1.0, 1.0]), "#0000ff");
274        // Clamping.
275        assert_eq!(rgba_to_css([2.0, -1.0, 0.5, 1.0]), "#ff0080");
276    }
277}