Skip to main content

oxihuman_morph/
anthropometric_constraints.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Anthropometric constraint enforcement for realistic body proportions.
5
6use std::collections::HashMap;
7
8// ---------------------------------------------------------------------------
9// Structs
10// ---------------------------------------------------------------------------
11
12/// A single named anthropometric ratio constraint.
13#[allow(dead_code)]
14#[derive(Debug, Clone)]
15pub struct AnthroConstraint {
16    pub name: String,
17    pub description: String,
18    pub min_ratio: f32,
19    pub max_ratio: f32,
20}
21
22/// A set of anthropometric constraints.
23#[allow(dead_code)]
24#[derive(Debug, Clone)]
25pub struct AnthroConstraintSet {
26    pub constraints: Vec<AnthroConstraint>,
27}
28
29/// A violation of a single constraint.
30#[allow(dead_code)]
31#[derive(Debug, Clone)]
32pub struct AnthroViolation {
33    pub constraint_name: String,
34    pub actual_ratio: f32,
35    pub min_ratio: f32,
36    pub max_ratio: f32,
37    /// 0..1 – how far outside the bounds (0 = just at boundary, 1 = one full range-width outside).
38    pub severity: f32,
39}
40
41/// Full result of checking a parameter set against a constraint set.
42#[allow(dead_code)]
43#[derive(Debug, Clone)]
44pub struct AnthroCheckResult {
45    pub violations: Vec<AnthroViolation>,
46    pub is_realistic: bool,
47    pub realism_score: f32,
48}
49
50// ---------------------------------------------------------------------------
51// Standard constraint set
52// ---------------------------------------------------------------------------
53
54/// Return a set of ≥8 realistic anthropometric constraints.
55#[allow(dead_code)]
56pub fn standard_anthropometric_constraints() -> AnthroConstraintSet {
57    let constraints = vec![
58        AnthroConstraint {
59            name: "head_height_to_body".into(),
60            description: "Head height as fraction of total body height (1/6 to 1/8)".into(),
61            min_ratio: 0.11,
62            max_ratio: 0.17,
63        },
64        AnthroConstraint {
65            name: "shoulder_to_hip_width".into(),
66            description: "Shoulder width relative to hip width".into(),
67            min_ratio: 0.8,
68            max_ratio: 1.5,
69        },
70        AnthroConstraint {
71            name: "arm_span_to_height".into(),
72            description: "Arm span approximately equal to height".into(),
73            min_ratio: 0.95,
74            max_ratio: 1.05,
75        },
76        AnthroConstraint {
77            name: "leg_to_torso".into(),
78            description: "Leg length relative to torso length".into(),
79            min_ratio: 0.9,
80            max_ratio: 1.2,
81        },
82        AnthroConstraint {
83            name: "bmi_realistic".into(),
84            description: "Body mass index realistic range".into(),
85            min_ratio: 15.0,
86            max_ratio: 45.0,
87        },
88        AnthroConstraint {
89            name: "foot_to_height".into(),
90            description: "Foot length as fraction of total height".into(),
91            min_ratio: 0.13,
92            max_ratio: 0.17,
93        },
94        AnthroConstraint {
95            name: "hand_to_forearm".into(),
96            description: "Hand length relative to forearm length".into(),
97            min_ratio: 0.6,
98            max_ratio: 0.8,
99        },
100        AnthroConstraint {
101            name: "neck_to_head".into(),
102            description: "Neck circumference relative to head circumference".into(),
103            min_ratio: 0.3,
104            max_ratio: 0.5,
105        },
106    ];
107    AnthroConstraintSet { constraints }
108}
109
110// ---------------------------------------------------------------------------
111// Core functions
112// ---------------------------------------------------------------------------
113
114/// Compute BMI from height (metres) and weight (kg).
115#[allow(dead_code)]
116pub fn bmi_from_params(height_m: f32, weight_kg: f32) -> f32 {
117    if height_m <= 0.0 {
118        return 0.0;
119    }
120    weight_kg / (height_m * height_m)
121}
122
123/// Compute severity: 0 if within [min, max], otherwise normalised overshoot.
124#[allow(dead_code)]
125pub fn violation_severity(actual: f32, min: f32, max: f32) -> f32 {
126    let range = (max - min).max(f32::EPSILON);
127    if actual < min {
128        ((min - actual) / range).clamp(0.0, 1.0)
129    } else if actual > max {
130        ((actual - max) / range).clamp(0.0, 1.0)
131    } else {
132        0.0
133    }
134}
135
136/// 1.0 − mean(severity), clamped to 0..1.
137#[allow(dead_code)]
138pub fn realism_score(violations: &[AnthroViolation]) -> f32 {
139    if violations.is_empty() {
140        return 1.0;
141    }
142    let mean_sev = violations.iter().map(|v| v.severity).sum::<f32>() / violations.len() as f32;
143    (1.0 - mean_sev).clamp(0.0, 1.0)
144}
145
146/// Compute all body ratios that can be derived from the named params.
147///
148/// Expected param names (all in consistent SI / normalised units):
149/// `height`, `weight`, `shoulder_width`, `hip_width`, `head_height`,
150/// `arm_span`, `leg_length`, `torso_length`, `foot_length`,
151/// `hand_length`, `forearm_length`, `neck_circ`, `head_circ`.
152#[allow(dead_code)]
153pub fn params_to_body_ratios(params: &HashMap<String, f32>) -> HashMap<String, f32> {
154    let get = |k: &str| -> Option<f32> { params.get(k).copied().filter(|&v| v > 0.0) };
155
156    let mut ratios = HashMap::new();
157
158    if let (Some(head_h), Some(height)) = (get("head_height"), get("height")) {
159        ratios.insert("head_height_to_body".into(), head_h / height);
160    }
161    if let (Some(sw), Some(hw)) = (get("shoulder_width"), get("hip_width")) {
162        ratios.insert("shoulder_to_hip_width".into(), sw / hw);
163    }
164    if let (Some(span), Some(height)) = (get("arm_span"), get("height")) {
165        ratios.insert("arm_span_to_height".into(), span / height);
166    }
167    if let (Some(leg), Some(torso)) = (get("leg_length"), get("torso_length")) {
168        ratios.insert("leg_to_torso".into(), leg / torso);
169    }
170    if let (Some(h), Some(w)) = (get("height"), get("weight")) {
171        ratios.insert("bmi_realistic".into(), bmi_from_params(h, w));
172    }
173    if let (Some(foot), Some(height)) = (get("foot_length"), get("height")) {
174        ratios.insert("foot_to_height".into(), foot / height);
175    }
176    if let (Some(hand), Some(fore)) = (get("hand_length"), get("forearm_length")) {
177        ratios.insert("hand_to_forearm".into(), hand / fore);
178    }
179    if let (Some(neck), Some(head_c)) = (get("neck_circ"), get("head_circ")) {
180        ratios.insert("neck_to_head".into(), neck / head_c);
181    }
182
183    ratios
184}
185
186/// Check body ratios derived from `params` against every constraint.
187#[allow(dead_code)]
188pub fn check_params_against_constraints(
189    params: &HashMap<String, f32>,
190    constraints: &AnthroConstraintSet,
191) -> AnthroCheckResult {
192    let ratios = params_to_body_ratios(params);
193    let mut violations = Vec::new();
194
195    for c in &constraints.constraints {
196        let actual = match ratios.get(&c.name) {
197            Some(&v) => v,
198            None => continue,
199        };
200        let sev = violation_severity(actual, c.min_ratio, c.max_ratio);
201        if sev > 0.0 {
202            violations.push(AnthroViolation {
203                constraint_name: c.name.clone(),
204                actual_ratio: actual,
205                min_ratio: c.min_ratio,
206                max_ratio: c.max_ratio,
207                severity: sev,
208            });
209        }
210    }
211
212    let score = realism_score(&violations);
213    AnthroCheckResult {
214        is_realistic: violations.is_empty(),
215        violations,
216        realism_score: score,
217    }
218}
219
220/// Clamp params to satisfy constraints; returns count of params clamped.
221///
222/// For ratio-based constraints the function adjusts the numerator param to
223/// bring the ratio within bounds (if both numerator and denominator exist).
224#[allow(dead_code)]
225pub fn enforce_constraints(
226    params: &mut HashMap<String, f32>,
227    constraints: &AnthroConstraintSet,
228) -> usize {
229    let mut clamped = 0usize;
230
231    for c in &constraints.constraints {
232        match c.name.as_str() {
233            "bmi_realistic" => {
234                let height = params.get("height").copied().unwrap_or(0.0);
235                if height <= 0.0 {
236                    continue;
237                }
238                if let Some(weight) = params.get_mut("weight") {
239                    let bmi = *weight / (height * height);
240                    if bmi < c.min_ratio {
241                        *weight = c.min_ratio * height * height;
242                        clamped += 1;
243                    } else if bmi > c.max_ratio {
244                        *weight = c.max_ratio * height * height;
245                        clamped += 1;
246                    }
247                }
248            }
249            "head_height_to_body" => {
250                clamp_ratio_numerator(params, "head_height", "height", c, &mut clamped);
251            }
252            "shoulder_to_hip_width" => {
253                clamp_ratio_numerator(params, "shoulder_width", "hip_width", c, &mut clamped);
254            }
255            "arm_span_to_height" => {
256                clamp_ratio_numerator(params, "arm_span", "height", c, &mut clamped);
257            }
258            "leg_to_torso" => {
259                clamp_ratio_numerator(params, "leg_length", "torso_length", c, &mut clamped);
260            }
261            "foot_to_height" => {
262                clamp_ratio_numerator(params, "foot_length", "height", c, &mut clamped);
263            }
264            "hand_to_forearm" => {
265                clamp_ratio_numerator(params, "hand_length", "forearm_length", c, &mut clamped);
266            }
267            "neck_to_head" => {
268                clamp_ratio_numerator(params, "neck_circ", "head_circ", c, &mut clamped);
269            }
270            _ => {}
271        }
272    }
273
274    clamped
275}
276
277// Helper: clamp `numerator` so that numerator/denominator ∈ [min, max].
278fn clamp_ratio_numerator(
279    params: &mut HashMap<String, f32>,
280    num_key: &str,
281    den_key: &str,
282    c: &AnthroConstraint,
283    clamped: &mut usize,
284) {
285    let denom = params.get(den_key).copied().unwrap_or(0.0);
286    if denom <= 0.0 {
287        return;
288    }
289    if let Some(num) = params.get_mut(num_key) {
290        let ratio = *num / denom;
291        if ratio < c.min_ratio {
292            *num = c.min_ratio * denom;
293            *clamped += 1;
294        } else if ratio > c.max_ratio {
295            *num = c.max_ratio * denom;
296            *clamped += 1;
297        }
298    }
299}
300
301// ---------------------------------------------------------------------------
302// Tests
303// ---------------------------------------------------------------------------
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    fn typical_human() -> HashMap<String, f32> {
310        let mut m = HashMap::new();
311        m.insert("height".into(), 1.75);
312        m.insert("weight".into(), 70.0);
313        m.insert("head_height".into(), 0.23); // ~0.131 of 1.75
314        m.insert("shoulder_width".into(), 0.46);
315        m.insert("hip_width".into(), 0.38);
316        m.insert("arm_span".into(), 1.76);
317        m.insert("leg_length".into(), 0.95);
318        m.insert("torso_length".into(), 0.85);
319        m.insert("foot_length".into(), 0.26); // ~0.149 of 1.75
320        m.insert("hand_length".into(), 0.14);
321        m.insert("forearm_length".into(), 0.20);
322        m.insert("neck_circ".into(), 0.13);
323        m.insert("head_circ".into(), 0.38);
324        m
325    }
326
327    #[test]
328    fn test_bmi_from_params_normal() {
329        let bmi = bmi_from_params(1.75, 70.0);
330        assert!((bmi - 22.857).abs() < 0.01, "bmi={bmi}");
331    }
332
333    #[test]
334    fn test_bmi_from_params_zero_height() {
335        assert_eq!(bmi_from_params(0.0, 70.0), 0.0);
336    }
337
338    #[test]
339    fn test_realism_score_no_violations() {
340        let score = realism_score(&[]);
341        assert_eq!(score, 1.0);
342    }
343
344    #[test]
345    fn test_realism_score_all_severe() {
346        let vs = vec![
347            AnthroViolation {
348                constraint_name: "a".into(),
349                actual_ratio: 0.0,
350                min_ratio: 0.0,
351                max_ratio: 0.0,
352                severity: 1.0,
353            },
354            AnthroViolation {
355                constraint_name: "b".into(),
356                actual_ratio: 0.0,
357                min_ratio: 0.0,
358                max_ratio: 0.0,
359                severity: 1.0,
360            },
361        ];
362        assert_eq!(realism_score(&vs), 0.0);
363    }
364
365    #[test]
366    fn test_violation_severity_in_bounds() {
367        assert_eq!(violation_severity(0.5, 0.3, 0.7), 0.0);
368    }
369
370    #[test]
371    fn test_violation_severity_below_min() {
372        let sev = violation_severity(0.1, 0.3, 0.7);
373        assert!(sev > 0.0);
374        assert!(sev <= 1.0);
375    }
376
377    #[test]
378    fn test_violation_severity_above_max() {
379        let sev = violation_severity(0.9, 0.3, 0.7);
380        assert!(sev > 0.0);
381        assert!(sev <= 1.0);
382    }
383
384    #[test]
385    fn test_violation_severity_at_min_boundary() {
386        assert_eq!(violation_severity(0.3, 0.3, 0.7), 0.0);
387    }
388
389    #[test]
390    fn test_violation_severity_at_max_boundary() {
391        assert_eq!(violation_severity(0.7, 0.3, 0.7), 0.0);
392    }
393
394    #[test]
395    fn test_standard_constraints_at_least_8() {
396        let cs = standard_anthropometric_constraints();
397        assert!(cs.constraints.len() >= 8, "len={}", cs.constraints.len());
398    }
399
400    #[test]
401    fn test_check_valid_human_no_violations() {
402        let params = typical_human();
403        let cs = standard_anthropometric_constraints();
404        let result = check_params_against_constraints(&params, &cs);
405        assert!(
406            result.is_realistic,
407            "Expected no violations, got: {:?}",
408            result.violations
409        );
410        assert!(result.realism_score > 0.9);
411    }
412
413    #[test]
414    fn test_check_extreme_params_have_violations() {
415        let mut params = HashMap::new();
416        params.insert("height".into(), 1.0);
417        params.insert("weight".into(), 200.0); // BMI = 200, way out of range
418        params.insert("head_height".into(), 0.5); // head_height_to_body = 0.5, >0.17
419        params.insert("hip_width".into(), 0.3);
420        params.insert("shoulder_width".into(), 0.06); // shoulder/hip = 0.2, <0.8
421        let cs = standard_anthropometric_constraints();
422        let result = check_params_against_constraints(&params, &cs);
423        assert!(!result.is_realistic);
424        assert!(!result.violations.is_empty());
425    }
426
427    #[test]
428    fn test_enforce_constraints_clamps_bmi() {
429        let mut params = HashMap::new();
430        params.insert("height".into(), 1.75);
431        params.insert("weight".into(), 300.0); // BMI ~98
432        let cs = standard_anthropometric_constraints();
433        let count = enforce_constraints(&mut params, &cs);
434        assert!(count >= 1);
435        let bmi = bmi_from_params(1.75, *params.get("weight").expect("should succeed"));
436        assert!(bmi <= 45.0 + 0.001);
437    }
438
439    #[test]
440    fn test_params_to_body_ratios_returns_map() {
441        let params = typical_human();
442        let ratios = params_to_body_ratios(&params);
443        assert!(!ratios.is_empty());
444        assert!(ratios.contains_key("bmi_realistic"));
445        assert!(ratios.contains_key("head_height_to_body"));
446    }
447
448    #[test]
449    fn test_params_to_body_ratios_empty_params() {
450        let params = HashMap::new();
451        let ratios = params_to_body_ratios(&params);
452        assert!(ratios.is_empty());
453    }
454
455    #[test]
456    fn test_realism_score_partial() {
457        let vs = vec![AnthroViolation {
458            constraint_name: "x".into(),
459            actual_ratio: 0.0,
460            min_ratio: 0.0,
461            max_ratio: 0.0,
462            severity: 0.5,
463        }];
464        let s = realism_score(&vs);
465        assert!((s - 0.5).abs() < 1e-5);
466    }
467}