Skip to main content

oxihuman_export/
metadata.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7/// OxiHuman-specific metadata to embed in GLTF asset.extras.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct OxiHumanMeta {
10    /// Generator identifier.
11    pub generator: String,
12    /// Semantic version string.
13    pub version: String,
14    /// ISO 8601 timestamp (UTC) when exported.
15    pub exported_at: String,
16    /// Body measurements at export time.
17    pub measurements: Option<MeasurementsMeta>,
18    /// Active preset name, if any.
19    pub preset: Option<String>,
20    /// Key morph parameters (height/weight/muscle/age).
21    pub params: Option<ParamsMeta>,
22    /// Active expression preset name.
23    pub expression: Option<String>,
24    /// Number of morph targets applied.
25    pub target_count: Option<usize>,
26    /// Policy profile used ("Strict" or "Standard").
27    pub policy: Option<String>,
28    /// Arbitrary extra key-value pairs.
29    pub extra: std::collections::HashMap<String, Value>,
30}
31
32/// Body measurements at export time.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct MeasurementsMeta {
35    /// Height in centimetres.
36    pub height_cm: Option<f32>,
37    /// Weight in kilograms.
38    pub weight_kg: Option<f32>,
39    /// Chest circumference in centimetres.
40    pub chest_cm: Option<f32>,
41    /// Waist circumference in centimetres.
42    pub waist_cm: Option<f32>,
43    /// Hips circumference in centimetres.
44    pub hips_cm: Option<f32>,
45}
46
47/// Key morph parameters snapshot.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct ParamsMeta {
50    /// Height blend factor [0, 1].
51    pub height: f32,
52    /// Weight blend factor [0, 1].
53    pub weight: f32,
54    /// Muscle blend factor [0, 1].
55    pub muscle: f32,
56    /// Age blend factor [0, 1].
57    pub age: f32,
58}
59
60impl OxiHumanMeta {
61    /// Create a minimal metadata block with just the generator info.
62    pub fn minimal() -> Self {
63        Self {
64            generator: "oxihuman-export".into(),
65            version: env!("CARGO_PKG_VERSION").into(),
66            exported_at: current_timestamp(),
67            measurements: None,
68            preset: None,
69            params: None,
70            expression: None,
71            target_count: None,
72            policy: None,
73            extra: Default::default(),
74        }
75    }
76
77    /// Convert to a `serde_json::Value` for embedding in GLTF.
78    pub fn to_json(&self) -> Value {
79        serde_json::to_value(self).unwrap_or(Value::Null)
80    }
81
82    /// Parse from a `serde_json::Value`.
83    pub fn from_json(v: &Value) -> Option<Self> {
84        serde_json::from_value(v.clone()).ok()
85    }
86
87    /// Set a parameter block from raw f32 values.
88    pub fn with_params(mut self, height: f32, weight: f32, muscle: f32, age: f32) -> Self {
89        self.params = Some(ParamsMeta {
90            height,
91            weight,
92            muscle,
93            age,
94        });
95        self
96    }
97
98    /// Set measurements.
99    pub fn with_measurements(mut self, m: MeasurementsMeta) -> Self {
100        self.measurements = Some(m);
101        self
102    }
103}
104
105/// Returns current UTC timestamp in ISO 8601 format without pulling in chrono.
106/// Uses `std::time::SystemTime`.
107fn current_timestamp() -> String {
108    use std::time::{SystemTime, UNIX_EPOCH};
109    let secs = SystemTime::now()
110        .duration_since(UNIX_EPOCH)
111        .map(|d| d.as_secs())
112        .unwrap_or(0);
113    let (y, mo, d, h, mi, sec) = unix_secs_to_datetime(secs);
114    format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, mo, d, h, mi, sec)
115}
116
117fn unix_secs_to_datetime(secs: u64) -> (u32, u32, u32, u32, u32, u32) {
118    let sec = (secs % 60) as u32;
119    let min = ((secs / 60) % 60) as u32;
120    let hour = ((secs / 3600) % 24) as u32;
121    let days = secs / 86400;
122
123    // Walk forward year by year from the Unix epoch (1970).
124    let mut y = 1970u32;
125    let mut d = days;
126    loop {
127        let dy = if is_leap(y) { 366u64 } else { 365u64 };
128        if d < dy {
129            break;
130        }
131        d -= dy;
132        y += 1;
133    }
134
135    let months = [
136        31u32,
137        if is_leap(y) { 29 } else { 28 },
138        31,
139        30,
140        31,
141        30,
142        31,
143        31,
144        30,
145        31,
146        30,
147        31,
148    ];
149    let mut mo = 1u32;
150    for &ml in &months {
151        if d < ml as u64 {
152            break;
153        }
154        d -= ml as u64;
155        mo += 1;
156    }
157
158    (y, mo, d as u32 + 1, hour, min, sec)
159}
160
161fn is_leap(y: u32) -> bool {
162    (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400)
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn minimal_has_generator() {
171        assert_eq!(OxiHumanMeta::minimal().generator, "oxihuman-export");
172    }
173
174    #[test]
175    fn to_json_has_generator_key() {
176        let meta = OxiHumanMeta::minimal();
177        assert!(meta.to_json()["generator"].as_str().is_some());
178    }
179
180    #[test]
181    fn from_json_roundtrip() {
182        let meta = OxiHumanMeta::minimal();
183        let json = meta.to_json();
184        let back = OxiHumanMeta::from_json(&json).expect("deserialization failed");
185        assert_eq!(back.generator, "oxihuman-export");
186    }
187
188    #[test]
189    fn with_params_sets_params() {
190        let meta = OxiHumanMeta::minimal().with_params(0.5, 0.4, 0.3, 0.2);
191        assert!(meta.params.is_some());
192    }
193
194    #[test]
195    fn timestamp_is_nonempty() {
196        assert!(!OxiHumanMeta::minimal().exported_at.is_empty());
197    }
198
199    #[test]
200    fn timestamp_contains_t() {
201        assert!(OxiHumanMeta::minimal().exported_at.contains('T'));
202    }
203}