Skip to main content

oxihuman_morph/
body_scan_fit_core.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4#![allow(dead_code)]
5#![allow(clippy::too_many_arguments)]
6
7//! Core scan-fitting types: [`ScanCloud`], [`FitResult`], [`FitConfig`],
8//! [`BodyMeasurementsEstimate`], and the gradient-free fitting functions.
9
10use std::collections::HashMap;
11
12// ---------------------------------------------------------------------------
13// ScanCloud
14// ---------------------------------------------------------------------------
15
16/// A point cloud produced by a body scanner.
17///
18/// Points are in metres, Y-up convention.
19#[derive(Debug, Clone)]
20pub struct ScanCloud {
21    /// 3-D positions `[x, y, z]` in metres.
22    pub points: Vec<[f32; 3]>,
23    /// Optional per-point outward normals.
24    pub normals: Option<Vec<[f32; 3]>>,
25}
26
27impl ScanCloud {
28    /// Create a cloud from positions only (normals set to `None`).
29    pub fn new(points: Vec<[f32; 3]>) -> Self {
30        Self {
31            points,
32            normals: None,
33        }
34    }
35
36    /// Create a cloud with explicit normals.
37    ///
38    /// # Panics
39    /// Panics if `normals.len() != points.len()`.
40    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    /// Number of points in the cloud.
53    pub fn point_count(&self) -> usize {
54        self.points.len()
55    }
56
57    /// Compute the axis-aligned bounding box as `(min, max)`.
58    ///
59    /// Returns `([0.0; 3], [0.0; 3])` for an empty cloud.
60    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    /// Compute the centroid (mean position) of the cloud.
80    ///
81    /// Returns `[0.0; 3]` for an empty cloud.
82    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    /// Body height = Y extent of the bounding box (in metres).
97    pub fn height(&self) -> f32 {
98        let (min, max) = self.bbox();
99        (max[1] - min[1]).max(0.0)
100    }
101
102    /// Return a new cloud centred at the origin and scaled so that height = 1.
103    ///
104    /// If height is zero the cloud is only centred, not scaled.
105    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// ---------------------------------------------------------------------------
122// FitResult
123// ---------------------------------------------------------------------------
124
125/// Outcome of a parameter-fitting run.
126#[derive(Debug, Clone)]
127pub struct FitResult {
128    /// Parameter name → value in `[0, 1]`.
129    pub params: HashMap<String, f32>,
130    /// Mean closest-point distance (metres) between scan and fitted mesh.
131    pub residual_error: f32,
132    /// Number of coordinate-descent iterations executed.
133    pub iterations: usize,
134    /// Whether the run converged within `convergence_tol`.
135    pub converged: bool,
136}
137
138// ---------------------------------------------------------------------------
139// FitConfig
140// ---------------------------------------------------------------------------
141
142/// Configuration for the gradient-free fitting loop.
143#[derive(Debug, Clone)]
144pub struct FitConfig {
145    /// Maximum number of outer iterations (one full sweep per iteration).
146    pub max_iterations: usize,
147    /// Convergence tolerance on mean error improvement.
148    pub convergence_tol: f32,
149    /// Initial step size for each parameter.
150    pub learning_rate: f32,
151    /// Names of the parameters to fit.
152    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// ---------------------------------------------------------------------------
172// BodyMeasurementsEstimate
173// ---------------------------------------------------------------------------
174
175/// Axis-aligned body measurement estimates derived from a [`ScanCloud`].
176///
177/// All values are in **metres**.
178#[derive(Debug, Clone)]
179pub struct BodyMeasurementsEstimate {
180    /// Standing height (Y extent of bounding box).
181    pub height_m: f32,
182    /// Shoulder width (X extent at shoulder level ≈ 85 % of height).
183    pub shoulder_width_m: f32,
184    /// Estimated chest circumference (treating cross-section as ellipse).
185    pub chest_circumference_m: f32,
186    /// Estimated waist circumference.
187    pub waist_circumference_m: f32,
188    /// Hip width (X extent at hip level ≈ 35 % of height).
189    pub hip_width_m: f32,
190}
191
192// ---------------------------------------------------------------------------
193// Internal helpers
194// ---------------------------------------------------------------------------
195
196/// Half-width of the cloud in X at a given Y target ± band.
197fn 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        // Fallback to global X extent
216        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; // assume depth ~ 60 % of width
222    }
223    (max_x, max_z).0.max((max_x, max_z).1) // return the wider axis
224                                           // Actually return a tuple for circumference computation
225                                           // We'll inline this differently below.
226}
227
228/// Returns `(half_x, half_z)` of the cloud at a y-slice.
229fn 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
262/// Ramanujan ellipse circumference approximation for semi-axes a, b.
263fn ellipse_circumference(a: f32, b: f32) -> f32 {
264    // Ramanujan first approximation: π × [3(a+b) - sqrt((3a+b)(a+3b))]
265    let t = 3.0 * (a + b) - ((3.0 * a + b) * (a + 3.0 * b)).sqrt();
266    std::f32::consts::PI * t
267}
268
269/// Clamp a value to `[0, 1]`.
270fn clamp01(v: f32) -> f32 {
271    v.clamp(0.0, 1.0)
272}
273
274// ---------------------------------------------------------------------------
275// Public free functions
276// ---------------------------------------------------------------------------
277
278/// Estimate body measurements from a scan cloud using bounding-box slicing.
279pub 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    // Anatomical Y-fractions (from floor)
286    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
312/// Map body measurements to approximate parameter values in `[0, 1]`.
313///
314/// Uses simple linear heuristics calibrated to average human proportions.
315pub fn measurements_to_params(meas: &BodyMeasurementsEstimate) -> HashMap<String, f32> {
316    let mut params = HashMap::new();
317
318    // Height: range 1.40 m → 0.0, 2.10 m → 1.0
319    let height_param = clamp01((meas.height_m - 1.40) / (2.10 - 1.40));
320    params.insert("height".to_string(), height_param);
321
322    // Weight: inferred from chest + waist circumference
323    // Rough: circ range 0.60–1.40 m maps to [0, 1]
324    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    // Muscle: shoulder-to-waist ratio
329    // Athletic: shoulder wide, waist narrow → ratio > 1.6
330    let waist_w = meas.waist_circumference_m / std::f32::consts::PI; // diameter
331    let ratio = if waist_w > 1e-4 {
332        meas.shoulder_width_m / waist_w
333    } else {
334        1.0
335    };
336    // ratio 0.9 → 0.0 muscle, 2.5 → 1.0 muscle
337    let muscle_param = clamp01((ratio - 0.9) / (2.5 - 0.9));
338    params.insert("muscle".to_string(), muscle_param);
339
340    // Age: no direct measurement; default mid-range
341    params.insert("age".to_string(), 0.35);
342
343    params
344}
345
346/// Compute mean closest-point distance from scan to mesh positions.
347///
348/// For each scan point the nearest mesh vertex is found by brute force.
349/// Returns 0.0 if either collection is empty.
350pub 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
372/// Align a scan cloud to a mesh via centroid translation + uniform scale.
373///
374/// The returned cloud has the same centroid as the mesh and the same height
375/// (Y extent).  If the scan height is zero the original cloud is returned.
376pub 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    // Scan stats
382    let scan_c = scan.centroid();
383    let scan_h = scan.height().max(1e-8);
384
385    // Mesh centroid
386    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    // Mesh height
398    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/// Fit body parameters to a scan cloud using coordinate descent.
429///
430/// For each parameter, tries `current ± step`; keeps the move that reduces
431/// the scan-to-mesh error.  Step is halved when no improvement is found.
432/// Runs for at most `config.max_iterations` outer rounds.
433///
434/// `mesh_fn` must map a `&HashMap<String, f32>` (parameter values) to a
435/// `Vec<[f32; 3]>` of mesh vertex positions.
436#[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    // Initialise parameters at 0.5
452    let mut params: HashMap<String, f32> = config
453        .param_names
454        .iter()
455        .map(|n| (n.clone(), 0.5_f32))
456        .collect();
457
458    // Step sizes per parameter
459    let mut steps: HashMap<String, f32> = config
460        .param_names
461        .iter()
462        .map(|n| (n.clone(), config.learning_rate))
463        .collect();
464
465    // Compute initial error
466    let initial_mesh = mesh_fn(&params);
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            // Try + step
481            let val_plus = clamp01(cur_val + step);
482            params.insert(name.clone(), val_plus);
483            let mesh_plus = mesh_fn(&params);
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            // Try - step
488            let val_minus = clamp01(cur_val - step);
489            params.insert(name.clone(), val_minus);
490            let mesh_minus = mesh_fn(&params);
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            // Pick best
495            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                // Restore original and halve step
507                params.insert(name.clone(), cur_val);
508                steps.insert(name.clone(), step * 0.5);
509            }
510        }
511
512        iterations += 1;
513
514        // Convergence: improvement in this round was tiny
515        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
529/// Quick parameter estimate from bounding-box measurements only (no mesh).
530///
531/// Uses [`estimate_measurements`] → [`measurements_to_params`].
532pub fn quick_fit_from_bbox(cloud: &ScanCloud) -> HashMap<String, f32> {
533    let meas = estimate_measurements(cloud);
534    measurements_to_params(&meas)
535}
536
537// ---------------------------------------------------------------------------
538// Tests
539// ---------------------------------------------------------------------------
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544
545    // ── Helper: build a synthetic humanoid point cloud ──────────────────────
546
547    /// Build a minimal humanoid point cloud (Y-up, metres).
548    /// Height ≈ 1.75 m, broad shoulders, moderate waist.
549    fn make_human_cloud() -> ScanCloud {
550        let mut pts: Vec<[f32; 3]> = Vec::new();
551
552        // Floor and crown
553        pts.push([0.0, 0.0, 0.0]);
554        pts.push([0.0, 1.75, 0.0]);
555
556        // Shoulder slice (y ≈ 0.82 × 1.75 = 1.435 m), width ~0.46 m
557        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        // Chest slice (y ≈ 0.60 × 1.75 = 1.05 m), half-x=0.19 half-z=0.12
564        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        // Waist slice (y ≈ 0.45 × 1.75 = 0.7875 m), half-x=0.14 half-z=0.09
574        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        // Hip slice (y ≈ 0.35 × 1.75 = 0.6125 m), half-x=0.17
581        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    /// Build a simple sparse mesh (column spanning 0.0–1.75 m in Y).
589    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 1: ScanCloud::new stores points correctly ───────────────────────
601
602    #[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 2: ScanCloud::with_normals stores both ──────────────────────────
611
612    #[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 3: point_count returns correct count ────────────────────────────
622
623    #[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 4: bbox returns correct min/max ─────────────────────────────────
632
633    #[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 5: centroid is correct ──────────────────────────────────────────
647
648    #[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 6: height returns Y extent ─────────────────────────────────────
659
660    #[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 7: normalize produces unit height at origin ────────────────────
668
669    #[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        // Centroid Y should be near 0
677        assert!(c[1].abs() < 0.1, "centroid y={}", c[1]);
678    }
679
680    // ── Test 8: estimate_measurements returns sensible values ────────────────
681
682    #[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        // chest > waist is typical for athletic builds
696        // (relaxed requirement: both positive)
697        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 9: measurements_to_params output in [0,1] ──────────────────────
706
707    #[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 &params {
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 10: scan_to_mesh_error zero for identical sets ─────────────────
722
723    #[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 11: scan_to_mesh_error is positive for different sets ───────────
732
733    #[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 12: align_scan_to_mesh brings centroids close ──────────────────
743
744    #[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        // Compute mesh centroid
751        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 13: quick_fit_from_bbox returns height & weight keys ───────────
775
776    #[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 &params {
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 14: FitConfig default has correct values ────────────────────────
795
796    #[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 15: fit_params_to_scan improves on initial error ───────────────
809
810    #[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        // mesh_fn: a simple parameterised mesh where height param scales Y
819        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; // maps [0,1] to [1.40, 2.10]
822            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 16: fit_params_to_scan on empty cloud returns immediately ───────
839
840    #[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 17: empty cloud bbox returns zeros ──────────────────────────────
851
852    #[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 18: write quick_fit results to /tmp/ ────────────────────────────
863
864    #[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        // Serialize to JSON-like string and write to /tmp/
870        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}