1#![allow(dead_code)]
5
6use crate::params::ParamState;
7use std::collections::HashMap;
8
9pub struct ProportionSchema {
15 pub name: String,
16 pub heads_tall: f32,
18 pub shoulder_ratio: f32,
20 pub hip_ratio: f32,
22 pub leg_ratio: f32,
24 pub arm_ratio: f32,
26 pub description: String,
27}
28
29pub struct ProportionAnalysis {
31 pub schema_name: String,
32 pub deviations: HashMap<String, f32>,
34 pub rms_deviation: f32,
35 pub closest_schema: String,
36}
37
38pub struct ProportionLibrary {
40 schemas: Vec<ProportionSchema>,
41}
42
43impl Default for ProportionLibrary {
48 fn default() -> Self {
49 Self::new()
50 }
51}
52
53impl ProportionLibrary {
54 pub fn new() -> Self {
56 Self {
57 schemas: Vec::new(),
58 }
59 }
60
61 pub fn add(&mut self, schema: ProportionSchema) {
63 self.schemas.push(schema);
64 }
65
66 pub fn find(&self, name: &str) -> Option<&ProportionSchema> {
68 self.schemas.iter().find(|s| s.name == name)
69 }
70
71 pub fn schemas(&self) -> &[ProportionSchema] {
73 &self.schemas
74 }
75
76 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 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
111pub 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
172pub fn params_to_ratios(params: &ParamState) -> HashMap<String, f32> {
184 let mut map = HashMap::new();
185
186 let heads_tall = 6.0 + params.height * 3.0;
188 map.insert("heads_tall".to_string(), heads_tall);
189
190 let shoulder_ratio = 1.2 + params.weight * 0.2 + params.muscle * 0.4;
192 map.insert("shoulder_ratio".to_string(), shoulder_ratio);
193
194 let hip_ratio = 1.1 + params.weight * 0.4;
196 map.insert("hip_ratio".to_string(), hip_ratio);
197
198 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 let arm_ratio = 0.40 + params.muscle * 0.06;
204 map.insert("arm_ratio".to_string(), arm_ratio);
205
206 map
207}
208
209pub fn normalize_to_schema(params: &mut ParamState, schema: &ProportionSchema) {
213 params.height = ((schema.heads_tall - 6.0) / 3.0).clamp(0.0, 1.0);
216
217 params.age = ((schema.leg_ratio - 0.47) / 0.09).clamp(0.0, 1.0);
220
221 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 params.weight = ((schema.hip_ratio - 1.1) / 0.4).clamp(0.0, 1.0);
230
231 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
236pub 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
249pub fn golden_ratio_params() -> ParamState {
254 const PHI: f32 = 1.618_034;
255 let height = (8.0_f32 - 6.0) / 3.0;
257 let age = (0.53_f32 - 0.47) / 0.09;
260 let weight = (1.3_f32 - 1.1) / 0.4; 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
275fn 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
290fn 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
305fn 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#[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 #[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 #[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 #[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 #[test]
457 fn proportion_score_exact_match_is_zero() {
458 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 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); let score = proportion_score(&p, schema);
474 assert!(score > 0.0, "expected non-zero score");
475 }
476
477 #[test]
482 fn closest_child_params_returns_child_schema() {
483 let lib = standard_schemas();
484 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 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 #[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 #[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 let schema = ProportionSchema {
578 name: "extreme".to_string(),
579 heads_tall: 12.0, shoulder_ratio: 3.0, hip_ratio: 0.5, leg_ratio: 0.10, 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 #[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 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 #[test]
633 fn proportion_library_default_is_empty() {
634 let lib = ProportionLibrary::default();
635 assert!(lib.find("anything").is_none());
636 }
637}