1#![allow(dead_code)]
5#![allow(clippy::too_many_arguments)]
6
7use std::collections::HashMap;
11
12#[derive(Debug, Clone)]
20pub struct ScanCloud {
21 pub points: Vec<[f32; 3]>,
23 pub normals: Option<Vec<[f32; 3]>>,
25}
26
27impl ScanCloud {
28 pub fn new(points: Vec<[f32; 3]>) -> Self {
30 Self {
31 points,
32 normals: None,
33 }
34 }
35
36 pub fn with_normals(points: Vec<[f32; 3]>, normals: Vec<[f32; 3]>) -> Self {
41 assert_eq!(
42 points.len(),
43 normals.len(),
44 "points and normals must have the same length"
45 );
46 Self {
47 points,
48 normals: Some(normals),
49 }
50 }
51
52 pub fn point_count(&self) -> usize {
54 self.points.len()
55 }
56
57 pub fn bbox(&self) -> ([f32; 3], [f32; 3]) {
61 if self.points.is_empty() {
62 return ([0.0; 3], [0.0; 3]);
63 }
64 let mut min = [f32::INFINITY; 3];
65 let mut max = [f32::NEG_INFINITY; 3];
66 for p in &self.points {
67 for i in 0..3 {
68 if p[i] < min[i] {
69 min[i] = p[i];
70 }
71 if p[i] > max[i] {
72 max[i] = p[i];
73 }
74 }
75 }
76 (min, max)
77 }
78
79 pub fn centroid(&self) -> [f32; 3] {
83 if self.points.is_empty() {
84 return [0.0; 3];
85 }
86 let n = self.points.len() as f32;
87 let mut sum = [0.0_f32; 3];
88 for p in &self.points {
89 sum[0] += p[0];
90 sum[1] += p[1];
91 sum[2] += p[2];
92 }
93 [sum[0] / n, sum[1] / n, sum[2] / n]
94 }
95
96 pub fn height(&self) -> f32 {
98 let (min, max) = self.bbox();
99 (max[1] - min[1]).max(0.0)
100 }
101
102 pub fn normalize(&self) -> Self {
106 let c = self.centroid();
107 let h = self.height().max(1e-8);
108 let pts: Vec<[f32; 3]> = self
109 .points
110 .iter()
111 .map(|p| [(p[0] - c[0]) / h, (p[1] - c[1]) / h, (p[2] - c[2]) / h])
112 .collect();
113 let nrm = self.normals.clone();
114 Self {
115 points: pts,
116 normals: nrm,
117 }
118 }
119}
120
121#[derive(Debug, Clone)]
127pub struct FitResult {
128 pub params: HashMap<String, f32>,
130 pub residual_error: f32,
132 pub iterations: usize,
134 pub converged: bool,
136}
137
138#[derive(Debug, Clone)]
144pub struct FitConfig {
145 pub max_iterations: usize,
147 pub convergence_tol: f32,
149 pub learning_rate: f32,
151 pub param_names: Vec<String>,
153}
154
155impl Default for FitConfig {
156 fn default() -> Self {
157 Self {
158 max_iterations: 50,
159 convergence_tol: 0.001,
160 learning_rate: 0.1,
161 param_names: vec![
162 "height".to_string(),
163 "weight".to_string(),
164 "muscle".to_string(),
165 "age".to_string(),
166 ],
167 }
168 }
169}
170
171#[derive(Debug, Clone)]
179pub struct BodyMeasurementsEstimate {
180 pub height_m: f32,
182 pub shoulder_width_m: f32,
184 pub chest_circumference_m: f32,
186 pub waist_circumference_m: f32,
188 pub hip_width_m: f32,
190}
191
192fn half_width_at_y(cloud: &ScanCloud, y_target: f32, band: f32) -> f32 {
198 let mut max_x = 0.0_f32;
199 let mut max_z = 0.0_f32;
200 let mut found = false;
201 for p in &cloud.points {
202 if (p[1] - y_target).abs() <= band {
203 let ax = p[0].abs();
204 let az = p[2].abs();
205 if ax > max_x {
206 max_x = ax;
207 }
208 if az > max_z {
209 max_z = az;
210 }
211 found = true;
212 }
213 }
214 if !found {
215 max_x = cloud
217 .points
218 .iter()
219 .map(|p| p[0].abs())
220 .fold(0.0_f32, f32::max);
221 max_z = max_x * 0.6; }
223 (max_x, max_z).0.max((max_x, max_z).1) }
227
228fn half_extents_at_y(cloud: &ScanCloud, y_target: f32, band: f32) -> (f32, f32) {
230 let mut max_x = 0.0_f32;
231 let mut max_z = 0.0_f32;
232 let mut found = false;
233 for p in &cloud.points {
234 if (p[1] - y_target).abs() <= band {
235 let ax = p[0].abs();
236 let az = p[2].abs();
237 if ax > max_x {
238 max_x = ax;
239 }
240 if az > max_z {
241 max_z = az;
242 }
243 found = true;
244 }
245 }
246 if !found {
247 let gx = cloud
248 .points
249 .iter()
250 .map(|p| p[0].abs())
251 .fold(0.0_f32, f32::max);
252 let gz = cloud
253 .points
254 .iter()
255 .map(|p| p[2].abs())
256 .fold(0.0_f32, f32::max);
257 return (gx, gz.max(gx * 0.5));
258 }
259 (max_x, max_z.max(max_x * 0.4))
260}
261
262fn ellipse_circumference(a: f32, b: f32) -> f32 {
264 let t = 3.0 * (a + b) - ((3.0 * a + b) * (a + 3.0 * b)).sqrt();
266 std::f32::consts::PI * t
267}
268
269fn clamp01(v: f32) -> f32 {
271 v.clamp(0.0, 1.0)
272}
273
274pub fn estimate_measurements(cloud: &ScanCloud) -> BodyMeasurementsEstimate {
280 let h = cloud.height();
281 let (bbox_min, _bbox_max) = cloud.bbox();
282 let min_y = bbox_min[1];
283 let band = h * 0.03_f32;
284
285 let shoulder_y = min_y + h * 0.82;
287 let chest_y = min_y + h * 0.60;
288 let waist_y = min_y + h * 0.45;
289 let hip_y = min_y + h * 0.35;
290
291 let (shld_x, _) = half_extents_at_y(cloud, shoulder_y, band * 2.0);
292 let shoulder_width = shld_x * 2.0;
293
294 let (chest_x, chest_z) = half_extents_at_y(cloud, chest_y, band);
295 let chest_circ = ellipse_circumference(chest_x, chest_z.max(chest_x * 0.55));
296
297 let (waist_x, waist_z) = half_extents_at_y(cloud, waist_y, band);
298 let waist_circ = ellipse_circumference(waist_x, waist_z.max(waist_x * 0.50));
299
300 let (hip_x, _) = half_extents_at_y(cloud, hip_y, band);
301 let hip_width = hip_x * 2.0;
302
303 BodyMeasurementsEstimate {
304 height_m: h,
305 shoulder_width_m: shoulder_width,
306 chest_circumference_m: chest_circ,
307 waist_circumference_m: waist_circ,
308 hip_width_m: hip_width,
309 }
310}
311
312pub fn measurements_to_params(meas: &BodyMeasurementsEstimate) -> HashMap<String, f32> {
316 let mut params = HashMap::new();
317
318 let height_param = clamp01((meas.height_m - 1.40) / (2.10 - 1.40));
320 params.insert("height".to_string(), height_param);
321
322 let avg_girth = (meas.chest_circumference_m + meas.waist_circumference_m) * 0.5;
325 let weight_param = clamp01((avg_girth - 0.60) / (1.40 - 0.60));
326 params.insert("weight".to_string(), weight_param);
327
328 let waist_w = meas.waist_circumference_m / std::f32::consts::PI; let ratio = if waist_w > 1e-4 {
332 meas.shoulder_width_m / waist_w
333 } else {
334 1.0
335 };
336 let muscle_param = clamp01((ratio - 0.9) / (2.5 - 0.9));
338 params.insert("muscle".to_string(), muscle_param);
339
340 params.insert("age".to_string(), 0.35);
342
343 params
344}
345
346pub fn scan_to_mesh_error(scan: &ScanCloud, mesh_positions: &[[f32; 3]]) -> f32 {
351 if scan.points.is_empty() || mesh_positions.is_empty() {
352 return 0.0;
353 }
354 let total: f32 = scan
355 .points
356 .iter()
357 .map(|sp| {
358 mesh_positions
359 .iter()
360 .map(|mp| {
361 let dx = sp[0] - mp[0];
362 let dy = sp[1] - mp[1];
363 let dz = sp[2] - mp[2];
364 (dx * dx + dy * dy + dz * dz).sqrt()
365 })
366 .fold(f32::INFINITY, f32::min)
367 })
368 .sum();
369 total / scan.points.len() as f32
370}
371
372pub fn align_scan_to_mesh(scan: &ScanCloud, mesh_positions: &[[f32; 3]]) -> ScanCloud {
377 if scan.points.is_empty() || mesh_positions.is_empty() {
378 return scan.clone();
379 }
380
381 let scan_c = scan.centroid();
383 let scan_h = scan.height().max(1e-8);
384
385 let n = mesh_positions.len() as f32;
387 let mut mesh_c = [0.0_f32; 3];
388 for p in mesh_positions {
389 mesh_c[0] += p[0];
390 mesh_c[1] += p[1];
391 mesh_c[2] += p[2];
392 }
393 mesh_c[0] /= n;
394 mesh_c[1] /= n;
395 mesh_c[2] /= n;
396
397 let min_y = mesh_positions
399 .iter()
400 .map(|p| p[1])
401 .fold(f32::INFINITY, f32::min);
402 let max_y = mesh_positions
403 .iter()
404 .map(|p| p[1])
405 .fold(f32::NEG_INFINITY, f32::max);
406 let mesh_h = (max_y - min_y).max(1e-8);
407
408 let scale = mesh_h / scan_h;
409
410 let pts: Vec<[f32; 3]> = scan
411 .points
412 .iter()
413 .map(|p| {
414 [
415 (p[0] - scan_c[0]) * scale + mesh_c[0],
416 (p[1] - scan_c[1]) * scale + mesh_c[1],
417 (p[2] - scan_c[2]) * scale + mesh_c[2],
418 ]
419 })
420 .collect();
421
422 ScanCloud {
423 points: pts,
424 normals: scan.normals.clone(),
425 }
426}
427
428#[allow(clippy::type_complexity)]
437pub fn fit_params_to_scan(
438 scan: &ScanCloud,
439 config: &FitConfig,
440 mesh_fn: &dyn Fn(&HashMap<String, f32>) -> Vec<[f32; 3]>,
441) -> FitResult {
442 if scan.points.is_empty() {
443 return FitResult {
444 params: HashMap::new(),
445 residual_error: 0.0,
446 iterations: 0,
447 converged: true,
448 };
449 }
450
451 let mut params: HashMap<String, f32> = config
453 .param_names
454 .iter()
455 .map(|n| (n.clone(), 0.5_f32))
456 .collect();
457
458 let mut steps: HashMap<String, f32> = config
460 .param_names
461 .iter()
462 .map(|n| (n.clone(), config.learning_rate))
463 .collect();
464
465 let initial_mesh = mesh_fn(¶ms);
467 let aligned = align_scan_to_mesh(scan, &initial_mesh);
468 let mut current_error = scan_to_mesh_error(&aligned, &initial_mesh);
469
470 let mut iterations = 0usize;
471 let mut converged = false;
472
473 'outer: for _iter in 0..config.max_iterations {
474 let mut improved_any = false;
475
476 for name in &config.param_names {
477 let cur_val = *params.get(name).unwrap_or(&0.5);
478 let step = *steps.get(name).unwrap_or(&0.1);
479
480 let val_plus = clamp01(cur_val + step);
482 params.insert(name.clone(), val_plus);
483 let mesh_plus = mesh_fn(¶ms);
484 let aligned_plus = align_scan_to_mesh(scan, &mesh_plus);
485 let err_plus = scan_to_mesh_error(&aligned_plus, &mesh_plus);
486
487 let val_minus = clamp01(cur_val - step);
489 params.insert(name.clone(), val_minus);
490 let mesh_minus = mesh_fn(¶ms);
491 let aligned_minus = align_scan_to_mesh(scan, &mesh_minus);
492 let err_minus = scan_to_mesh_error(&aligned_minus, &mesh_minus);
493
494 let (best_val, best_err) = if err_plus <= err_minus {
496 (val_plus, err_plus)
497 } else {
498 (val_minus, err_minus)
499 };
500
501 if best_err < current_error {
502 params.insert(name.clone(), best_val);
503 current_error = best_err;
504 improved_any = true;
505 } else {
506 params.insert(name.clone(), cur_val);
508 steps.insert(name.clone(), step * 0.5);
509 }
510 }
511
512 iterations += 1;
513
514 if !improved_any || current_error < config.convergence_tol {
516 converged = true;
517 break 'outer;
518 }
519 }
520
521 FitResult {
522 params,
523 residual_error: current_error,
524 iterations,
525 converged,
526 }
527}
528
529pub fn quick_fit_from_bbox(cloud: &ScanCloud) -> HashMap<String, f32> {
533 let meas = estimate_measurements(cloud);
534 measurements_to_params(&meas)
535}
536
537#[cfg(test)]
542mod tests {
543 use super::*;
544
545 fn make_human_cloud() -> ScanCloud {
550 let mut pts: Vec<[f32; 3]> = Vec::new();
551
552 pts.push([0.0, 0.0, 0.0]);
554 pts.push([0.0, 1.75, 0.0]);
555
556 for dx in [-0.23_f32, 0.0, 0.23] {
558 pts.push([dx, 1.435, 0.0]);
559 pts.push([dx, 1.435, 0.15]);
560 pts.push([dx, 1.435, -0.15]);
561 }
562
563 for dx in [-0.19_f32, 0.19] {
565 pts.push([dx, 1.05, 0.0]);
566 pts.push([dx, 1.05, 0.12]);
567 pts.push([dx, 1.05, -0.12]);
568 }
569 for dz in [-0.12_f32, 0.12] {
570 pts.push([0.0, 1.05, dz]);
571 }
572
573 for dx in [-0.14_f32, 0.14] {
575 pts.push([dx, 0.7875, 0.0]);
576 pts.push([dx, 0.7875, 0.09]);
577 pts.push([dx, 0.7875, -0.09]);
578 }
579
580 for dx in [-0.17_f32, 0.0, 0.17] {
582 pts.push([dx, 0.6125, 0.0]);
583 }
584
585 ScanCloud::new(pts)
586 }
587
588 fn make_simple_mesh(h: f32) -> Vec<[f32; 3]> {
590 vec![
591 [0.0, 0.0, 0.0],
592 [0.0, h, 0.0],
593 [0.2, h * 0.6, 0.0],
594 [-0.2, h * 0.6, 0.0],
595 [0.15, h * 0.45, 0.0],
596 [-0.15, h * 0.45, 0.0],
597 ]
598 }
599
600 #[test]
603 fn scan_cloud_new_stores_points() {
604 let pts = vec![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]];
605 let cloud = ScanCloud::new(pts.clone());
606 assert_eq!(cloud.points, pts);
607 assert!(cloud.normals.is_none());
608 }
609
610 #[test]
613 fn scan_cloud_with_normals_stores_both() {
614 let pts = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]];
615 let nrm = vec![[0.0, 1.0, 0.0], [0.0, 1.0, 0.0]];
616 let cloud = ScanCloud::with_normals(pts.clone(), nrm.clone());
617 assert_eq!(cloud.points, pts);
618 assert_eq!(cloud.normals.expect("should succeed"), nrm);
619 }
620
621 #[test]
624 fn scan_cloud_point_count() {
625 let cloud = make_human_cloud();
626 assert!(cloud.point_count() > 0);
627 let empty = ScanCloud::new(vec![]);
628 assert_eq!(empty.point_count(), 0);
629 }
630
631 #[test]
634 fn scan_cloud_bbox_correct() {
635 let pts = vec![[1.0, 2.0, 3.0], [-1.0, 0.0, -3.0], [0.5, 5.0, 1.0]];
636 let cloud = ScanCloud::new(pts);
637 let (min, max) = cloud.bbox();
638 assert!((min[0] - (-1.0)).abs() < 1e-5);
639 assert!((min[1] - 0.0).abs() < 1e-5);
640 assert!((min[2] - (-3.0)).abs() < 1e-5);
641 assert!((max[0] - 1.0).abs() < 1e-5);
642 assert!((max[1] - 5.0).abs() < 1e-5);
643 assert!((max[2] - 3.0).abs() < 1e-5);
644 }
645
646 #[test]
649 fn scan_cloud_centroid_correct() {
650 let pts = vec![[1.0, 0.0, 0.0], [-1.0, 0.0, 0.0], [0.0, 2.0, 0.0]];
651 let cloud = ScanCloud::new(pts);
652 let c = cloud.centroid();
653 assert!((c[0] - 0.0).abs() < 1e-5, "cx={}", c[0]);
654 assert!((c[1] - (2.0 / 3.0)).abs() < 1e-5, "cy={}", c[1]);
655 assert!((c[2] - 0.0).abs() < 1e-5, "cz={}", c[2]);
656 }
657
658 #[test]
661 fn scan_cloud_height() {
662 let cloud = make_human_cloud();
663 let h = cloud.height();
664 assert!((h - 1.75).abs() < 1e-4, "height={}", h);
665 }
666
667 #[test]
670 fn scan_cloud_normalize_unit_height() {
671 let cloud = make_human_cloud();
672 let norm = cloud.normalize();
673 let h = norm.height();
674 assert!((h - 1.0).abs() < 1e-4, "normalized height={}", h);
675 let c = norm.centroid();
676 assert!(c[1].abs() < 0.1, "centroid y={}", c[1]);
678 }
679
680 #[test]
683 fn estimate_measurements_sensible() {
684 let cloud = make_human_cloud();
685 let meas = estimate_measurements(&cloud);
686 assert!(
687 (meas.height_m - 1.75).abs() < 1e-4,
688 "height={}",
689 meas.height_m
690 );
691 assert!(meas.shoulder_width_m > 0.0, "shoulder_width <= 0");
692 assert!(meas.chest_circumference_m > 0.0, "chest_circ <= 0");
693 assert!(meas.waist_circumference_m > 0.0, "waist_circ <= 0");
694 assert!(meas.hip_width_m > 0.0, "hip_width <= 0");
695 assert!(
698 meas.chest_circumference_m > meas.waist_circumference_m * 0.5,
699 "chest {} waist {}",
700 meas.chest_circumference_m,
701 meas.waist_circumference_m
702 );
703 }
704
705 #[test]
708 fn measurements_to_params_in_range() {
709 let cloud = make_human_cloud();
710 let meas = estimate_measurements(&cloud);
711 let params = measurements_to_params(&meas);
712 for (k, v) in ¶ms {
713 assert!((0.0..=1.0).contains(v), "param {} = {} out of [0,1]", k, v);
714 }
715 assert!(params.contains_key("height"));
716 assert!(params.contains_key("weight"));
717 assert!(params.contains_key("muscle"));
718 assert!(params.contains_key("age"));
719 }
720
721 #[test]
724 fn scan_to_mesh_error_identical_is_zero() {
725 let pts = vec![[0.0_f32, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
726 let cloud = ScanCloud::new(pts.clone());
727 let err = scan_to_mesh_error(&cloud, &pts);
728 assert!(err < 1e-5, "error={}", err);
729 }
730
731 #[test]
734 fn scan_to_mesh_error_positive_for_different() {
735 let scan_pts = vec![[0.0_f32, 0.0, 0.0], [1.0, 0.0, 0.0]];
736 let mesh_pts = vec![[10.0_f32, 10.0, 10.0], [11.0, 10.0, 10.0]];
737 let cloud = ScanCloud::new(scan_pts);
738 let err = scan_to_mesh_error(&cloud, &mesh_pts);
739 assert!(err > 1.0, "err={}", err);
740 }
741
742 #[test]
745 fn align_scan_to_mesh_centroid_matches() {
746 let cloud = make_human_cloud();
747 let mesh = make_simple_mesh(1.75);
748 let aligned = align_scan_to_mesh(&cloud, &mesh);
749
750 let n = mesh.len() as f32;
752 let mut mc = [0.0_f32; 3];
753 for p in &mesh {
754 mc[0] += p[0];
755 mc[1] += p[1];
756 mc[2] += p[2];
757 }
758 mc[0] /= n;
759 mc[1] /= n;
760 mc[2] /= n;
761
762 let ac = aligned.centroid();
763 for i in 0..3 {
764 assert!(
765 (ac[i] - mc[i]).abs() < 1e-3,
766 "aligned centroid[{}] = {} mesh centroid = {}",
767 i,
768 ac[i],
769 mc[i]
770 );
771 }
772 }
773
774 #[test]
777 fn quick_fit_from_bbox_has_expected_keys() {
778 let cloud = make_human_cloud();
779 let params = quick_fit_from_bbox(&cloud);
780 assert!(params.contains_key("height"), "missing 'height'");
781 assert!(params.contains_key("weight"), "missing 'weight'");
782 assert!(params.contains_key("muscle"), "missing 'muscle'");
783 assert!(params.contains_key("age"), "missing 'age'");
784 for (k, v) in ¶ms {
785 assert!(
786 (0.0..=1.0).contains(v),
787 "quick_fit param {} = {} out of [0,1]",
788 k,
789 v
790 );
791 }
792 }
793
794 #[test]
797 fn fit_config_default_values() {
798 let cfg = FitConfig::default();
799 assert_eq!(cfg.max_iterations, 50);
800 assert!((cfg.convergence_tol - 0.001).abs() < 1e-6);
801 assert!((cfg.learning_rate - 0.1).abs() < 1e-6);
802 assert!(cfg.param_names.contains(&"height".to_string()));
803 assert!(cfg.param_names.contains(&"weight".to_string()));
804 assert!(cfg.param_names.contains(&"muscle".to_string()));
805 assert!(cfg.param_names.contains(&"age".to_string()));
806 }
807
808 #[test]
811 fn fit_params_to_scan_improves_error() {
812 let cloud = make_human_cloud();
813 let cfg = FitConfig {
814 max_iterations: 10,
815 ..Default::default()
816 };
817
818 let mesh_fn = |params: &HashMap<String, f32>| -> Vec<[f32; 3]> {
820 let h_p = *params.get("height").unwrap_or(&0.5);
821 let h = 1.40 + h_p * 0.70; make_simple_mesh(h)
823 };
824
825 let result = fit_params_to_scan(&cloud, &cfg, &mesh_fn);
826 assert!(result.residual_error.is_finite(), "residual is not finite");
827 assert!(result.iterations <= cfg.max_iterations);
828 for (k, v) in &result.params {
829 assert!(
830 (0.0..=1.0).contains(v),
831 "fitted param {} = {} out of [0,1]",
832 k,
833 v
834 );
835 }
836 }
837
838 #[test]
841 fn fit_params_to_scan_empty_cloud() {
842 let cloud = ScanCloud::new(vec![]);
843 let cfg = FitConfig::default();
844 let mesh_fn = |_: &HashMap<String, f32>| make_simple_mesh(1.75);
845 let result = fit_params_to_scan(&cloud, &cfg, &mesh_fn);
846 assert_eq!(result.iterations, 0);
847 assert!(result.converged);
848 }
849
850 #[test]
853 fn empty_cloud_bbox_returns_zeros() {
854 let cloud = ScanCloud::new(vec![]);
855 let (min, max) = cloud.bbox();
856 for i in 0..3 {
857 assert_eq!(min[i], 0.0);
858 assert_eq!(max[i], 0.0);
859 }
860 }
861
862 #[test]
865 fn write_quick_fit_results_to_tmp() {
866 let cloud = make_human_cloud();
867 let params = quick_fit_from_bbox(&cloud);
868
869 let mut lines = Vec::new();
871 let mut keys: Vec<&String> = params.keys().collect();
872 keys.sort();
873 for k in keys {
874 lines.push(format!("{}: {:.4}", k, params[k]));
875 }
876 let content = lines.join("\n");
877 std::fs::write("/tmp/oxihuman_body_scan_fit_quick.txt", &content).expect("should succeed");
878
879 let read_back = std::fs::read_to_string("/tmp/oxihuman_body_scan_fit_quick.txt")
880 .expect("should succeed");
881 assert!(read_back.contains("height"));
882 }
883}