Skip to main content

oxihuman_morph/
session.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Morph session serialization.
5//!
6//! A `MorphSession` captures the full state needed to reproduce a morphed mesh:
7//! the parameter values, which targets were loaded, and where they came from.
8//!
9//! Privacy note: sessions store only normalized parameters (no raw geometry).
10
11use anyhow::{Context, Result};
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15
16use crate::params::ParamState;
17
18/// A saved morph session — everything needed to recreate a body configuration.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct MorphSession {
21    /// Session format version.
22    pub version: String,
23    /// Body morphing parameters.
24    pub params: SessionParams,
25    /// Optional: directory that was used to load targets.
26    pub targets_dir: Option<PathBuf>,
27    /// Names of targets that were loaded (for documentation / reload).
28    pub loaded_target_names: Vec<String>,
29    /// Optional human-readable label for this session.
30    pub label: Option<String>,
31}
32
33/// Serializable parameter state (mirrors ParamState but serde-friendly).
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct SessionParams {
36    pub height: f32,
37    pub weight: f32,
38    pub muscle: f32,
39    pub age: f32,
40    #[serde(default)]
41    pub extra: HashMap<String, f32>,
42}
43
44impl From<&ParamState> for SessionParams {
45    fn from(p: &ParamState) -> Self {
46        SessionParams {
47            height: p.height,
48            weight: p.weight,
49            muscle: p.muscle,
50            age: p.age,
51            extra: p.extra.clone(),
52        }
53    }
54}
55
56impl From<SessionParams> for ParamState {
57    fn from(s: SessionParams) -> Self {
58        ParamState {
59            height: s.height,
60            weight: s.weight,
61            muscle: s.muscle,
62            age: s.age,
63            extra: s.extra,
64        }
65    }
66}
67
68impl MorphSession {
69    /// Create a new session from the current engine state.
70    pub fn new(params: &ParamState) -> Self {
71        MorphSession {
72            version: "0.1.0".to_string(),
73            params: SessionParams::from(params),
74            targets_dir: None,
75            loaded_target_names: Vec::new(),
76            label: None,
77        }
78    }
79
80    /// Set the label for this session.
81    pub fn with_label(mut self, label: &str) -> Self {
82        self.label = Some(label.to_string());
83        self
84    }
85
86    /// Set the targets directory.
87    pub fn with_targets_dir(mut self, dir: impl Into<PathBuf>) -> Self {
88        self.targets_dir = Some(dir.into());
89        self
90    }
91
92    /// Add a target name to the session's loaded-targets list.
93    pub fn add_target_name(&mut self, name: &str) {
94        if !self.loaded_target_names.contains(&name.to_string()) {
95            self.loaded_target_names.push(name.to_string());
96        }
97    }
98
99    /// Reconstruct a `ParamState` from the session.
100    pub fn to_param_state(&self) -> ParamState {
101        ParamState {
102            height: self.params.height,
103            weight: self.params.weight,
104            muscle: self.params.muscle,
105            age: self.params.age,
106            extra: self.params.extra.clone(),
107        }
108    }
109
110    /// Serialize to JSON string.
111    pub fn to_json(&self) -> Result<String> {
112        Ok(serde_json::to_string_pretty(self)?)
113    }
114
115    /// Deserialize from JSON string.
116    pub fn from_json(s: &str) -> Result<Self> {
117        Ok(serde_json::from_str(s)?)
118    }
119
120    /// Save session to a JSON file.
121    pub fn save(&self, path: &Path) -> Result<()> {
122        let json = self.to_json()?;
123        std::fs::write(path, json)
124            .with_context(|| format!("saving session to {}", path.display()))?;
125        Ok(())
126    }
127
128    /// Load session from a JSON file.
129    pub fn load(path: &Path) -> Result<Self> {
130        let json = std::fs::read_to_string(path)
131            .with_context(|| format!("reading session from {}", path.display()))?;
132        Self::from_json(&json)
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use crate::params::ParamState;
140
141    fn sample_session() -> MorphSession {
142        let mut session = MorphSession::new(&ParamState::new(0.7, 0.3, 0.8, 0.2));
143        session.add_target_name("height");
144        session.add_target_name("muscle");
145        session.loaded_target_names.push("weight".to_string());
146        session
147    }
148
149    #[test]
150    fn session_round_trip_json() {
151        let session = sample_session();
152        let json = session.to_json().expect("should succeed");
153        let restored = MorphSession::from_json(&json).expect("should succeed");
154        assert!((restored.params.height - 0.7).abs() < 1e-5);
155        assert!((restored.params.muscle - 0.8).abs() < 1e-5);
156        assert_eq!(restored.loaded_target_names.len(), 3);
157    }
158
159    #[test]
160    fn session_to_param_state() {
161        let session = sample_session();
162        let p = session.to_param_state();
163        assert!((p.height - 0.7).abs() < 1e-5);
164        assert!((p.age - 0.2).abs() < 1e-5);
165    }
166
167    #[test]
168    fn session_save_load_file() {
169        let session = sample_session()
170            .with_label("test session")
171            .with_targets_dir("/tmp/targets");
172        let path = std::path::PathBuf::from("/tmp/test_oxihuman_session.json");
173        session.save(&path).expect("should succeed");
174        let loaded = MorphSession::load(&path).expect("should succeed");
175        assert_eq!(loaded.label, Some("test session".to_string()));
176        assert!((loaded.params.weight - 0.3).abs() < 1e-5);
177        std::fs::remove_file(&path).ok();
178    }
179
180    #[test]
181    fn add_target_name_deduplicates() {
182        let mut session = MorphSession::new(&ParamState::default());
183        session.add_target_name("height");
184        session.add_target_name("height"); // duplicate
185        assert_eq!(session.loaded_target_names.len(), 1);
186    }
187
188    #[test]
189    fn session_with_extra_params() {
190        let mut p = ParamState::default();
191        p.extra.insert("expression".to_string(), 0.4);
192        let session = MorphSession::new(&p);
193        let json = session.to_json().expect("should succeed");
194        let restored = MorphSession::from_json(&json).expect("should succeed");
195        assert_eq!(restored.params.extra.get("expression").copied(), Some(0.4));
196    }
197
198    #[test]
199    fn from_param_state_conversion() {
200        let p = ParamState::new(0.1, 0.2, 0.3, 0.4);
201        let sp = SessionParams::from(&p);
202        let p2: ParamState = sp.into();
203        assert!((p2.height - 0.1).abs() < 1e-5);
204        assert!((p2.age - 0.4).abs() < 1e-5);
205    }
206}