Skip to main content

oxihuman_morph/
body_proportions.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4#![allow(dead_code)]
5
6use crate::params::ParamState;
7use std::collections::HashMap;
8
9// ---------------------------------------------------------------------------
10// Structures
11// ---------------------------------------------------------------------------
12
13/// Canonical proportion schema (ratios expressed as multiples of head height).
14pub struct ProportionSchema {
15    pub name: String,
16    /// Total height in heads.
17    pub heads_tall: f32,
18    /// Shoulder width / head width.
19    pub shoulder_ratio: f32,
20    /// Hip width / head width.
21    pub hip_ratio: f32,
22    /// Leg length / total height.
23    pub leg_ratio: f32,
24    /// Arm length / total height.
25    pub arm_ratio: f32,
26    pub description: String,
27}
28
29/// Analysis of a character against a reference schema.
30pub struct ProportionAnalysis {
31    pub schema_name: String,
32    /// key → how far from ideal (signed).
33    pub deviations: HashMap<String, f32>,
34    pub rms_deviation: f32,
35    pub closest_schema: String,
36}
37
38/// A set of named proportion schemas.
39pub struct ProportionLibrary {
40    schemas: Vec<ProportionSchema>,
41}
42
43// ---------------------------------------------------------------------------
44// ProportionLibrary impl
45// ---------------------------------------------------------------------------
46
47impl Default for ProportionLibrary {
48    fn default() -> Self {
49        Self::new()
50    }
51}
52
53impl ProportionLibrary {
54    /// Create an empty library.
55    pub fn new() -> Self {
56        Self {
57            schemas: Vec::new(),
58        }
59    }
60
61    /// Add a schema to the library.
62    pub fn add(&mut self, schema: ProportionSchema) {
63        self.schemas.push(schema);
64    }
65
66    /// Find a schema by name (case-sensitive).
67    pub fn find(&self, name: &str) -> Option<&ProportionSchema> {
68        self.schemas.iter().find(|s| s.name == name)
69    }
70
71    /// Return all schemas in the library.
72    pub fn schemas(&self) -> &[ProportionSchema] {
73        &self.schemas
74    }
75
76    /// Find the closest schema by L2 distance of ratio deviations from params.
77    pub fn closest(&self, params: &ParamState) -> Option<&ProportionSchema> {
78        let ratios = params_to_ratios(params);
79        self.schemas.iter().min_by(|a, b| {
80            let da = schema_l2_distance(a, &ratios);
81            let db = schema_l2_distance(b, &ratios);
82            da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
83        })
84    }
85
86    /// Analyze params against the named schema.
87    pub fn analyze(&self, params: &ParamState, schema_name: &str) -> Option<ProportionAnalysis> {
88        let schema = self.find(schema_name)?;
89        let ratios = params_to_ratios(params);
90        let deviations = schema_deviations(schema, &ratios);
91
92        let rms_deviation = {
93            let sum_sq: f32 = deviations.values().map(|v| v * v).sum();
94            (sum_sq / deviations.len() as f32).sqrt()
95        };
96
97        let closest_schema = self
98            .closest(params)
99            .map(|s| s.name.clone())
100            .unwrap_or_default();
101
102        Some(ProportionAnalysis {
103            schema_name: schema_name.to_string(),
104            deviations,
105            rms_deviation,
106            closest_schema,
107        })
108    }
109}
110
111// ---------------------------------------------------------------------------
112// Built-in schemas factory
113// ---------------------------------------------------------------------------
114
115/// Returns a library pre-loaded with standard artistic proportion schemas.
116pub fn standard_schemas() -> ProportionLibrary {
117    let mut lib = ProportionLibrary::new();
118
119    lib.add(ProportionSchema {
120        name: "vitruvian".to_string(),
121        heads_tall: 8.0,
122        shoulder_ratio: 1.5,
123        hip_ratio: 1.3,
124        leg_ratio: 0.53,
125        arm_ratio: 0.45,
126        description: "Classical Vitruvian Man proportions (Leonardo da Vinci)".to_string(),
127    });
128
129    lib.add(ProportionSchema {
130        name: "fashion".to_string(),
131        heads_tall: 9.0,
132        shoulder_ratio: 1.4,
133        hip_ratio: 1.2,
134        leg_ratio: 0.56,
135        arm_ratio: 0.44,
136        description: "Fashion illustration proportions — elongated legs".to_string(),
137    });
138
139    lib.add(ProportionSchema {
140        name: "heroic".to_string(),
141        heads_tall: 8.5,
142        shoulder_ratio: 1.8,
143        hip_ratio: 1.2,
144        leg_ratio: 0.54,
145        arm_ratio: 0.46,
146        description: "Heroic/comic-book proportions — broad shoulders".to_string(),
147    });
148
149    lib.add(ProportionSchema {
150        name: "child_6yr".to_string(),
151        heads_tall: 6.0,
152        shoulder_ratio: 1.2,
153        hip_ratio: 1.1,
154        leg_ratio: 0.47,
155        arm_ratio: 0.40,
156        description: "Approximate proportions of a 6-year-old child".to_string(),
157    });
158
159    lib.add(ProportionSchema {
160        name: "realistic".to_string(),
161        heads_tall: 7.5,
162        shoulder_ratio: 1.4,
163        hip_ratio: 1.3,
164        leg_ratio: 0.52,
165        arm_ratio: 0.44,
166        description: "Realistic adult human proportions".to_string(),
167    });
168
169    lib
170}
171
172// ---------------------------------------------------------------------------
173// Standalone functions
174// ---------------------------------------------------------------------------
175
176/// Derive approximate artistic ratios from ParamState.
177///
178/// Mapping conventions (all values are [0, 1]):
179/// - `height` (0=short ~1.5 m, 1=tall ~2.0 m) maps to heads_tall ≈ 6.0–9.0
180/// - `weight` (0=thin, 1=heavy) affects shoulder/hip ratios
181/// - `muscle` (0=none, 1=maximum) increases shoulder_ratio
182/// - `age`   (0=child, 1=elder) affects leg_ratio
183pub fn params_to_ratios(params: &ParamState) -> HashMap<String, f32> {
184    let mut map = HashMap::new();
185
186    // heads_tall: 6.0 (short/child) → 9.0 (tall/fashion)
187    let heads_tall = 6.0 + params.height * 3.0;
188    map.insert("heads_tall".to_string(), heads_tall);
189
190    // shoulder_ratio: base 1.2–1.8, muscle adds up to 0.4
191    let shoulder_ratio = 1.2 + params.weight * 0.2 + params.muscle * 0.4;
192    map.insert("shoulder_ratio".to_string(), shoulder_ratio);
193
194    // hip_ratio: 1.1–1.5, increases slightly with weight
195    let hip_ratio = 1.1 + params.weight * 0.4;
196    map.insert("hip_ratio".to_string(), hip_ratio);
197
198    // leg_ratio: child (low age) → shorter legs; adult → longer
199    let leg_ratio = 0.47 + params.age.clamp(0.0, 1.0) * 0.09;
200    map.insert("leg_ratio".to_string(), leg_ratio);
201
202    // arm_ratio: fairly stable, slight muscle influence
203    let arm_ratio = 0.40 + params.muscle * 0.06;
204    map.insert("arm_ratio".to_string(), arm_ratio);
205
206    map
207}
208
209/// Adjust params to better match the given schema proportions.
210///
211/// Solves the inverse of `params_to_ratios` for the four primary params.
212pub fn normalize_to_schema(params: &mut ParamState, schema: &ProportionSchema) {
213    // Invert heads_tall → height
214    // heads_tall = 6.0 + height * 3.0  →  height = (heads_tall - 6.0) / 3.0
215    params.height = ((schema.heads_tall - 6.0) / 3.0).clamp(0.0, 1.0);
216
217    // Invert leg_ratio → age
218    // leg_ratio = 0.47 + age * 0.09  →  age = (leg_ratio - 0.47) / 0.09
219    params.age = ((schema.leg_ratio - 0.47) / 0.09).clamp(0.0, 1.0);
220
221    // Invert shoulder_ratio → muscle (holding weight at current value)
222    // shoulder_ratio = 1.2 + weight * 0.2 + muscle * 0.4
223    // muscle = (shoulder_ratio - 1.2 - weight * 0.2) / 0.4
224    let muscle_raw = (schema.shoulder_ratio - 1.2 - params.weight * 0.2) / 0.4;
225    params.muscle = muscle_raw.clamp(0.0, 1.0);
226
227    // Invert hip_ratio → weight
228    // hip_ratio = 1.1 + weight * 0.4  →  weight = (hip_ratio - 1.1) / 0.4
229    params.weight = ((schema.hip_ratio - 1.1) / 0.4).clamp(0.0, 1.0);
230
231    // Re-solve muscle with the newly determined weight
232    let muscle_corrected = (schema.shoulder_ratio - 1.2 - params.weight * 0.2) / 0.4;
233    params.muscle = muscle_corrected.clamp(0.0, 1.0);
234}
235
236/// Compute a proportion score: 0.0 = perfect match, higher = more deviation.
237///
238/// Returns the RMS of all ratio deviations (normalised to the schema value).
239pub fn proportion_score(params: &ParamState, schema: &ProportionSchema) -> f32 {
240    let ratios = params_to_ratios(params);
241    let devs = schema_deviations(schema, &ratios);
242    if devs.is_empty() {
243        return 0.0;
244    }
245    let sum_sq: f32 = devs.values().map(|v| v * v).sum();
246    (sum_sq / devs.len() as f32).sqrt()
247}
248
249/// Return a ParamState that approximates golden-ratio body proportions.
250///
251/// The golden ratio φ ≈ 1.618 governs classic aesthetic ideals.
252/// We target the "vitruvian" schema (8 heads tall) with φ-derived tweaks.
253pub fn golden_ratio_params() -> ParamState {
254    const PHI: f32 = 1.618_034;
255    // heads_tall ≈ 8.0  → height = (8.0 - 6.0) / 3.0 ≈ 0.667
256    let height = (8.0_f32 - 6.0) / 3.0;
257    // Navel divides body at φ: leg fraction ≈ 1/φ ≈ 0.618 → leg_ratio ≈ 0.53 maps to vitruvian
258    // age = (0.53 - 0.47) / 0.09 ≈ 0.667
259    let age = (0.53_f32 - 0.47) / 0.09;
260    // Shoulder/hip ratio at golden proportion: shoulder ~ 1.5, hip ~ 1.3 (vitruvian)
261    // muscle = (1.5 - 1.2 - 0.5 * 0.2) / 0.4 = (0.3 - 0.1) / 0.4 = 0.5
262    let weight = (1.3_f32 - 1.1) / 0.4; // 0.5
263    let muscle = (1.5_f32 - 1.2 - weight * 0.2) / 0.4;
264
265    let mut p = ParamState::new(
266        height.clamp(0.0, 1.0),
267        weight.clamp(0.0, 1.0),
268        muscle.clamp(0.0, 1.0),
269        age.clamp(0.0, 1.0),
270    );
271    p.extra.insert("phi".to_string(), PHI);
272    p
273}
274
275// ---------------------------------------------------------------------------
276// Internal helpers
277// ---------------------------------------------------------------------------
278
279/// Extract the five canonical ratios from a ProportionSchema into a map.
280fn schema_to_ratio_map(schema: &ProportionSchema) -> HashMap<String, f32> {
281    let mut m = HashMap::new();
282    m.insert("heads_tall".to_string(), schema.heads_tall);
283    m.insert("shoulder_ratio".to_string(), schema.shoulder_ratio);
284    m.insert("hip_ratio".to_string(), schema.hip_ratio);
285    m.insert("leg_ratio".to_string(), schema.leg_ratio);
286    m.insert("arm_ratio".to_string(), schema.arm_ratio);
287    m
288}
289
290/// Compute signed deviations (params_ratio - schema_ratio) for each key.
291fn schema_deviations(
292    schema: &ProportionSchema,
293    ratios: &HashMap<String, f32>,
294) -> HashMap<String, f32> {
295    let ideal = schema_to_ratio_map(schema);
296    let mut devs = HashMap::new();
297    for (key, ideal_val) in &ideal {
298        if let Some(&actual_val) = ratios.get(key.as_str()) {
299            devs.insert(key.clone(), actual_val - ideal_val);
300        }
301    }
302    devs
303}
304
305/// L2 distance between a schema's ratios and a ratio map.
306fn schema_l2_distance(schema: &ProportionSchema, ratios: &HashMap<String, f32>) -> f32 {
307    let ideal = schema_to_ratio_map(schema);
308    let mut sum_sq = 0.0_f32;
309    for (key, ideal_val) in &ideal {
310        let actual = ratios.get(key.as_str()).copied().unwrap_or(*ideal_val);
311        let d = actual - ideal_val;
312        sum_sq += d * d;
313    }
314    sum_sq.sqrt()
315}
316
317// ---------------------------------------------------------------------------
318// Unit tests
319// ---------------------------------------------------------------------------
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    fn default_params() -> ParamState {
326        ParamState::new(0.5, 0.5, 0.5, 0.5)
327    }
328
329    // -----------------------------------------------------------------------
330    // 1. ProportionLibrary::new / add / find
331    // -----------------------------------------------------------------------
332
333    #[test]
334    fn library_add_and_find() {
335        let mut lib = ProportionLibrary::new();
336        lib.add(ProportionSchema {
337            name: "test".to_string(),
338            heads_tall: 7.0,
339            shoulder_ratio: 1.3,
340            hip_ratio: 1.2,
341            leg_ratio: 0.50,
342            arm_ratio: 0.43,
343            description: "test schema".to_string(),
344        });
345        let found = lib.find("test");
346        assert!(found.is_some());
347        assert!((found.expect("should succeed").heads_tall - 7.0).abs() < 1e-6);
348    }
349
350    #[test]
351    fn library_find_missing_returns_none() {
352        let lib = ProportionLibrary::new();
353        assert!(lib.find("nonexistent").is_none());
354    }
355
356    #[test]
357    fn library_find_is_case_sensitive() {
358        let mut lib = ProportionLibrary::new();
359        lib.add(ProportionSchema {
360            name: "Vitruvian".to_string(),
361            heads_tall: 8.0,
362            shoulder_ratio: 1.5,
363            hip_ratio: 1.3,
364            leg_ratio: 0.53,
365            arm_ratio: 0.45,
366            description: String::new(),
367        });
368        assert!(lib.find("vitruvian").is_none());
369        assert!(lib.find("Vitruvian").is_some());
370    }
371
372    // -----------------------------------------------------------------------
373    // 2. standard_schemas
374    // -----------------------------------------------------------------------
375
376    #[test]
377    fn standard_schemas_has_five_entries() {
378        let lib = standard_schemas();
379        let names = ["vitruvian", "fashion", "heroic", "child_6yr", "realistic"];
380        for name in &names {
381            assert!(lib.find(name).is_some(), "missing schema: {}", name);
382        }
383    }
384
385    #[test]
386    fn vitruvian_schema_values() {
387        let lib = standard_schemas();
388        let s = lib.find("vitruvian").expect("should succeed");
389        assert!((s.heads_tall - 8.0).abs() < 1e-6);
390        assert!((s.shoulder_ratio - 1.5).abs() < 1e-6);
391        assert!((s.hip_ratio - 1.3).abs() < 1e-6);
392        assert!((s.leg_ratio - 0.53).abs() < 1e-6);
393        assert!((s.arm_ratio - 0.45).abs() < 1e-6);
394    }
395
396    #[test]
397    fn fashion_schema_is_tallest() {
398        let lib = standard_schemas();
399        let fashion = lib.find("fashion").expect("should succeed");
400        let vitruvian = lib.find("vitruvian").expect("should succeed");
401        assert!(fashion.heads_tall > vitruvian.heads_tall);
402    }
403
404    #[test]
405    fn heroic_schema_has_widest_shoulders() {
406        let lib = standard_schemas();
407        let heroic = lib.find("heroic").expect("should succeed");
408        let vitruvian = lib.find("vitruvian").expect("should succeed");
409        assert!(heroic.shoulder_ratio > vitruvian.shoulder_ratio);
410    }
411
412    // -----------------------------------------------------------------------
413    // 3. params_to_ratios
414    // -----------------------------------------------------------------------
415
416    #[test]
417    fn params_to_ratios_zero_params() {
418        let p = ParamState::new(0.0, 0.0, 0.0, 0.0);
419        let r = params_to_ratios(&p);
420        assert!((r["heads_tall"] - 6.0).abs() < 1e-5);
421        assert!((r["shoulder_ratio"] - 1.2).abs() < 1e-5);
422        assert!((r["hip_ratio"] - 1.1).abs() < 1e-5);
423        assert!((r["leg_ratio"] - 0.47).abs() < 1e-5);
424        assert!((r["arm_ratio"] - 0.40).abs() < 1e-5);
425    }
426
427    #[test]
428    fn params_to_ratios_one_params() {
429        let p = ParamState::new(1.0, 1.0, 1.0, 1.0);
430        let r = params_to_ratios(&p);
431        assert!((r["heads_tall"] - 9.0).abs() < 1e-5);
432        assert!((r["shoulder_ratio"] - 1.8).abs() < 1e-5);
433        assert!((r["hip_ratio"] - 1.5).abs() < 1e-5);
434        assert!((r["leg_ratio"] - 0.56).abs() < 1e-5);
435        assert!((r["arm_ratio"] - 0.46).abs() < 1e-5);
436    }
437
438    #[test]
439    fn params_to_ratios_contains_all_keys() {
440        let r = params_to_ratios(&default_params());
441        for key in &[
442            "heads_tall",
443            "shoulder_ratio",
444            "hip_ratio",
445            "leg_ratio",
446            "arm_ratio",
447        ] {
448            assert!(r.contains_key(*key), "missing key: {}", key);
449        }
450    }
451
452    // -----------------------------------------------------------------------
453    // 4. proportion_score
454    // -----------------------------------------------------------------------
455
456    #[test]
457    fn proportion_score_exact_match_is_zero() {
458        // Build params that exactly map to vitruvian
459        let lib = standard_schemas();
460        let schema = lib.find("vitruvian").expect("should succeed");
461        let mut p = ParamState::default();
462        normalize_to_schema(&mut p, schema);
463        let score = proportion_score(&p, schema);
464        // After normalization the score should be very close to zero
465        assert!(score < 0.05, "expected near-zero score, got {}", score);
466    }
467
468    #[test]
469    fn proportion_score_different_params_is_nonzero() {
470        let lib = standard_schemas();
471        let schema = lib.find("heroic").expect("should succeed");
472        let p = ParamState::new(0.0, 0.0, 0.0, 0.0); // child-like params
473        let score = proportion_score(&p, schema);
474        assert!(score > 0.0, "expected non-zero score");
475    }
476
477    // -----------------------------------------------------------------------
478    // 5. ProportionLibrary::closest
479    // -----------------------------------------------------------------------
480
481    #[test]
482    fn closest_child_params_returns_child_schema() {
483        let lib = standard_schemas();
484        // height=0.0, weight=0.0, muscle=0.0, age=0.0 → child-like
485        let p = ParamState::new(0.0, 0.0, 0.0, 0.0);
486        let closest = lib.closest(&p).expect("should succeed");
487        assert_eq!(closest.name, "child_6yr");
488    }
489
490    #[test]
491    fn closest_tall_muscular_params_returns_heroic_or_fashion() {
492        let lib = standard_schemas();
493        // height=1.0, muscle=1.0 → heroic proportions
494        let p = ParamState::new(1.0, 0.0, 1.0, 1.0);
495        let closest = lib.closest(&p).expect("should succeed");
496        assert!(
497            closest.name == "heroic" || closest.name == "fashion",
498            "unexpected schema: {}",
499            closest.name
500        );
501    }
502
503    #[test]
504    fn closest_empty_library_returns_none() {
505        let lib = ProportionLibrary::new();
506        let p = default_params();
507        assert!(lib.closest(&p).is_none());
508    }
509
510    // -----------------------------------------------------------------------
511    // 6. ProportionLibrary::analyze
512    // -----------------------------------------------------------------------
513
514    #[test]
515    fn analyze_returns_correct_schema_name() {
516        let lib = standard_schemas();
517        let p = default_params();
518        let analysis = lib.analyze(&p, "vitruvian").expect("should succeed");
519        assert_eq!(analysis.schema_name, "vitruvian");
520    }
521
522    #[test]
523    fn analyze_deviations_has_all_keys() {
524        let lib = standard_schemas();
525        let p = default_params();
526        let analysis = lib.analyze(&p, "realistic").expect("should succeed");
527        for key in &[
528            "heads_tall",
529            "shoulder_ratio",
530            "hip_ratio",
531            "leg_ratio",
532            "arm_ratio",
533        ] {
534            assert!(analysis.deviations.contains_key(*key));
535        }
536    }
537
538    #[test]
539    fn analyze_rms_deviation_nonnegative() {
540        let lib = standard_schemas();
541        let p = default_params();
542        let analysis = lib.analyze(&p, "fashion").expect("should succeed");
543        assert!(analysis.rms_deviation >= 0.0);
544    }
545
546    #[test]
547    fn analyze_missing_schema_returns_none() {
548        let lib = standard_schemas();
549        let p = default_params();
550        assert!(lib.analyze(&p, "does_not_exist").is_none());
551    }
552
553    // -----------------------------------------------------------------------
554    // 7. normalize_to_schema
555    // -----------------------------------------------------------------------
556
557    #[test]
558    fn normalize_to_schema_then_score_is_low() {
559        let lib = standard_schemas();
560        for name in &["vitruvian", "fashion", "heroic", "child_6yr", "realistic"] {
561            let schema = lib.find(name).expect("should succeed");
562            let mut p = ParamState::default();
563            normalize_to_schema(&mut p, schema);
564            let score = proportion_score(&p, schema);
565            assert!(
566                score < 0.1,
567                "schema '{}': score {} is too high after normalization",
568                name,
569                score
570            );
571        }
572    }
573
574    #[test]
575    fn normalize_clamps_params_to_unit_interval() {
576        // Use a schema with extreme values that might push params out of [0,1]
577        let schema = ProportionSchema {
578            name: "extreme".to_string(),
579            heads_tall: 12.0,    // would map to height > 1
580            shoulder_ratio: 3.0, // would map to muscle > 1
581            hip_ratio: 0.5,      // would map to weight < 0
582            leg_ratio: 0.10,     // would map to age < 0
583            arm_ratio: 0.50,
584            description: String::new(),
585        };
586        let mut p = ParamState::default();
587        normalize_to_schema(&mut p, &schema);
588        assert!(p.height >= 0.0 && p.height <= 1.0);
589        assert!(p.weight >= 0.0 && p.weight <= 1.0);
590        assert!(p.muscle >= 0.0 && p.muscle <= 1.0);
591        assert!(p.age >= 0.0 && p.age <= 1.0);
592    }
593
594    // -----------------------------------------------------------------------
595    // 8. golden_ratio_params
596    // -----------------------------------------------------------------------
597
598    #[test]
599    fn golden_ratio_params_in_unit_range() {
600        let p = golden_ratio_params();
601        assert!(p.height >= 0.0 && p.height <= 1.0);
602        assert!(p.weight >= 0.0 && p.weight <= 1.0);
603        assert!(p.muscle >= 0.0 && p.muscle <= 1.0);
604        assert!(p.age >= 0.0 && p.age <= 1.0);
605    }
606
607    #[test]
608    fn golden_ratio_params_close_to_vitruvian() {
609        let lib = standard_schemas();
610        let vitruvian = lib.find("vitruvian").expect("should succeed");
611        let p = golden_ratio_params();
612        let score = proportion_score(&p, vitruvian);
613        // Should be reasonably close to vitruvian proportions
614        assert!(
615            score < 0.5,
616            "golden ratio params score {} vs vitruvian",
617            score
618        );
619    }
620
621    #[test]
622    fn golden_ratio_params_contains_phi_extra() {
623        let p = golden_ratio_params();
624        let phi = p.extra.get("phi").copied().unwrap_or(0.0);
625        assert!((phi - 1.618_034).abs() < 1e-4);
626    }
627
628    // -----------------------------------------------------------------------
629    // 9. Default trait
630    // -----------------------------------------------------------------------
631
632    #[test]
633    fn proportion_library_default_is_empty() {
634        let lib = ProportionLibrary::default();
635        assert!(lib.find("anything").is_none());
636    }
637}