Skip to main content

oxihuman_export/
batch_pipeline.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Parallel batch character export — generate and export multiple character
5//! variants from a parameter grid.
6
7#![allow(dead_code)]
8
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11
12// ── Public types ──────────────────────────────────────────────────────────────
13
14/// Specification for a single character export job.
15pub struct BatchCharacterSpec {
16    pub id: String,
17    pub params: HashMap<String, f32>,
18    pub output_format: BatchOutputFormat,
19    pub output_path: PathBuf,
20}
21
22/// Supported output formats for batch export.
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub enum BatchOutputFormat {
25    Glb,
26    Obj,
27    Stl,
28    Json,
29    Csv,
30}
31
32impl BatchOutputFormat {
33    fn extension(self) -> &'static str {
34        match self {
35            BatchOutputFormat::Glb => "glb",
36            BatchOutputFormat::Obj => "obj",
37            BatchOutputFormat::Stl => "stl",
38            BatchOutputFormat::Json => "json",
39            BatchOutputFormat::Csv => "csv",
40        }
41    }
42
43    fn name(self) -> &'static str {
44        match self {
45            BatchOutputFormat::Glb => "glb",
46            BatchOutputFormat::Obj => "obj",
47            BatchOutputFormat::Stl => "stl",
48            BatchOutputFormat::Json => "json",
49            BatchOutputFormat::Csv => "csv",
50        }
51    }
52}
53
54/// Configuration for a batch run.
55pub struct BatchConfig {
56    /// Optional path to a base .obj mesh file. If `None`, a stub tetrahedron
57    /// is generated in-memory.
58    pub base_obj_path: Option<PathBuf>,
59    /// Max parallel jobs (config only; sequential execution for now).
60    pub max_parallel: usize,
61    /// Skip export if the output file already exists.
62    pub skip_existing: bool,
63    /// Print per-job messages to stdout.
64    pub verbose: bool,
65}
66
67impl Default for BatchConfig {
68    fn default() -> Self {
69        BatchConfig {
70            base_obj_path: None,
71            max_parallel: 4,
72            skip_existing: false,
73            verbose: false,
74        }
75    }
76}
77
78/// Aggregated results for a batch run.
79pub struct BatchResult {
80    pub total: usize,
81    pub succeeded: usize,
82    pub failed: usize,
83    pub skipped: usize,
84    /// `(id, error_message)` for each failed job.
85    pub errors: Vec<(String, String)>,
86}
87
88// ── Tetrahedron stub ──────────────────────────────────────────────────────────
89
90/// Generate a minimal 4-vertex tetrahedron in OBJ text format.
91fn stub_tetrahedron_obj() -> String {
92    concat!(
93        "# OxiHuman stub tetrahedron\n",
94        "v  1.0  1.0  1.0\n",
95        "v -1.0 -1.0  1.0\n",
96        "v -1.0  1.0 -1.0\n",
97        "v  1.0 -1.0 -1.0\n",
98        "f 1 2 3\n",
99        "f 1 2 4\n",
100        "f 1 3 4\n",
101        "f 2 3 4\n",
102    )
103    .to_string()
104}
105
106// ── Core batch runner ─────────────────────────────────────────────────────────
107
108/// Run a batch export for each spec in `specs`.
109pub fn run_batch(specs: &[BatchCharacterSpec], cfg: &BatchConfig) -> BatchResult {
110    let total = specs.len();
111    let mut succeeded = 0usize;
112    let mut failed = 0usize;
113    let mut skipped = 0usize;
114    let mut errors: Vec<(String, String)> = Vec::new();
115
116    for spec in specs {
117        // Skip if output already exists and skip_existing is set.
118        if cfg.skip_existing && spec.output_path.exists() {
119            if cfg.verbose {
120                println!("[batch] skip (exists): {}", spec.output_path.display());
121            }
122            skipped += 1;
123            continue;
124        }
125
126        if cfg.verbose {
127            println!(
128                "[batch] exporting: {} → {}",
129                spec.id,
130                spec.output_path.display()
131            );
132        }
133
134        match export_one(spec, cfg) {
135            Ok(()) => {
136                succeeded += 1;
137            }
138            Err(e) => {
139                if cfg.verbose {
140                    eprintln!("[batch] FAILED {}: {}", spec.id, e);
141                }
142                errors.push((spec.id.clone(), e));
143                failed += 1;
144            }
145        }
146    }
147
148    BatchResult {
149        total,
150        succeeded,
151        failed,
152        skipped,
153        errors,
154    }
155}
156
157/// Export a single character spec.
158fn export_one(spec: &BatchCharacterSpec, cfg: &BatchConfig) -> Result<(), String> {
159    // Ensure the parent directory exists.
160    if let Some(parent) = spec.output_path.parent() {
161        std::fs::create_dir_all(parent).map_err(|e| format!("create_dir_all: {}", e))?;
162    }
163
164    // Load or generate mesh content.
165    let mesh_content = if let Some(ref base_path) = cfg.base_obj_path {
166        std::fs::read_to_string(base_path).map_err(|e| format!("reading base OBJ: {}", e))?
167    } else {
168        stub_tetrahedron_obj()
169    };
170
171    // Produce output based on format.
172    let content = match spec.output_format {
173        BatchOutputFormat::Obj => mesh_content,
174        BatchOutputFormat::Stl => obj_to_stl_stub(&mesh_content, &spec.id),
175        BatchOutputFormat::Glb => obj_to_glb_stub(&mesh_content),
176        BatchOutputFormat::Json => params_to_json(&spec.params),
177        BatchOutputFormat::Csv => params_to_csv(&spec.params),
178    };
179
180    std::fs::write(&spec.output_path, content).map_err(|e| format!("writing output: {}", e))?;
181
182    Ok(())
183}
184
185// ── Format converters (stubs that produce valid minimal output) ───────────────
186
187fn obj_to_stl_stub(obj: &str, name: &str) -> String {
188    let mut stl = format!("solid {}\n", name);
189    // Parse triangles from OBJ and emit dummy normals
190    let verts: Vec<[f32; 3]> = obj
191        .lines()
192        .filter(|l| l.starts_with("v "))
193        .filter_map(|l| {
194            let mut it = l[2..].split_whitespace();
195            let x: f32 = it.next()?.parse().ok()?;
196            let y: f32 = it.next()?.parse().ok()?;
197            let z: f32 = it.next()?.parse().ok()?;
198            Some([x, y, z])
199        })
200        .collect();
201    for line in obj.lines().filter(|l| l.starts_with("f ")) {
202        let indices: Vec<usize> = line[2..]
203            .split_whitespace()
204            .filter_map(|t| t.split('/').next()?.parse::<usize>().ok())
205            .collect();
206        if indices.len() >= 3 {
207            let (a, b, c) = (indices[0] - 1, indices[1] - 1, indices[2] - 1);
208            if a < verts.len() && b < verts.len() && c < verts.len() {
209                stl.push_str("  facet normal 0 0 0\n    outer loop\n");
210                for &idx in &[a, b, c] {
211                    let v = verts[idx];
212                    stl.push_str(&format!("      vertex {} {} {}\n", v[0], v[1], v[2]));
213                }
214                stl.push_str("    endloop\n  endfacet\n");
215            }
216        }
217    }
218    stl.push_str(&format!("endsolid {}\n", name));
219    stl
220}
221
222fn obj_to_glb_stub(obj: &str) -> String {
223    // Return a minimal JSON representation (true GLB would be binary).
224    // For batch testing purposes this is a text placeholder.
225    format!(
226        "{{\"type\":\"glb-stub\",\"source_lines\":{}}}",
227        obj.lines().count()
228    )
229}
230
231fn params_to_json(params: &HashMap<String, f32>) -> String {
232    let mut pairs: Vec<String> = params
233        .iter()
234        .map(|(k, v)| format!("  \"{}\": {:.6}", k, v))
235        .collect();
236    pairs.sort();
237    format!("{{\n{}\n}}", pairs.join(",\n"))
238}
239
240fn params_to_csv(params: &HashMap<String, f32>) -> String {
241    let mut keys: Vec<&String> = params.keys().collect();
242    keys.sort();
243    let header = keys
244        .iter()
245        .map(|k| k.as_str())
246        .collect::<Vec<_>>()
247        .join(",");
248    let values = keys
249        .iter()
250        .map(|k| format!("{:.6}", params[*k]))
251        .collect::<Vec<_>>()
252        .join(",");
253    format!("{}\n{}\n", header, values)
254}
255
256// ── Parameter grid ────────────────────────────────────────────────────────────
257
258/// Generate a Cartesian product of parameter ranges.
259///
260/// `ranges`: `name → (min, max, steps)`.
261/// Returns a vector of parameter maps — one per grid point.
262pub fn generate_param_grid(
263    ranges: &HashMap<String, (f32, f32, usize)>,
264) -> Vec<HashMap<String, f32>> {
265    // Sort keys for deterministic ordering.
266    let mut keys: Vec<String> = ranges.keys().cloned().collect();
267    keys.sort();
268
269    // Compute values per key.
270    let values_per_key: Vec<(String, Vec<f32>)> = keys
271        .iter()
272        .map(|k| {
273            let (lo, hi, steps) = ranges[k];
274            let vals = if steps <= 1 {
275                vec![lo]
276            } else {
277                (0..steps)
278                    .map(|i| lo + (hi - lo) * (i as f32) / ((steps - 1) as f32))
279                    .collect()
280            };
281            (k.clone(), vals)
282        })
283        .collect();
284
285    // Cartesian product via iterative expansion.
286    let mut result: Vec<HashMap<String, f32>> = vec![HashMap::new()];
287
288    for (key, vals) in &values_per_key {
289        let mut next = Vec::with_capacity(result.len() * vals.len());
290        for existing in &result {
291            for &v in vals {
292                let mut m = existing.clone();
293                m.insert(key.clone(), v);
294                next.push(m);
295            }
296        }
297        result = next;
298    }
299
300    result
301}
302
303// ── Spec builders ─────────────────────────────────────────────────────────────
304
305/// Build `BatchCharacterSpec` list from a param grid.
306pub fn specs_from_param_grid(
307    grid: &[HashMap<String, f32>],
308    format: BatchOutputFormat,
309    out_dir: &Path,
310) -> Vec<BatchCharacterSpec> {
311    grid.iter()
312        .enumerate()
313        .map(|(i, params)| {
314            let id = format!("char_{:04}", i);
315            let output_path = out_dir.join(format!("{}.{}", id, format.extension()));
316            BatchCharacterSpec {
317                id,
318                params: params.clone(),
319                output_format: format,
320                output_path,
321            }
322        })
323        .collect()
324}
325
326// ── Summaries ─────────────────────────────────────────────────────────────────
327
328/// Return a human-readable summary line for a `BatchResult`.
329pub fn batch_result_summary(result: &BatchResult) -> String {
330    format!(
331        "Batch: total={} succeeded={} failed={} skipped={}",
332        result.total, result.succeeded, result.failed, result.skipped,
333    )
334}
335
336/// Return a human-readable count/format breakdown for a slice of specs.
337pub fn estimate_batch_size(specs: &[BatchCharacterSpec]) -> String {
338    let mut counts: HashMap<&str, usize> = HashMap::new();
339    for spec in specs {
340        *counts.entry(spec.output_format.name()).or_insert(0) += 1;
341    }
342    let mut parts: Vec<String> = counts
343        .iter()
344        .map(|(fmt, n)| format!("{}×{}", n, fmt))
345        .collect();
346    parts.sort();
347    format!("{} specs ({})", specs.len(), parts.join(", "))
348}
349
350// ── Tests ─────────────────────────────────────────────────────────────────────
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    fn tmpdir(suffix: &str) -> PathBuf {
357        use std::time::{SystemTime, UNIX_EPOCH};
358        let nanos = SystemTime::now()
359            .duration_since(UNIX_EPOCH)
360            .expect("should succeed")
361            .subsec_nanos();
362        let p = PathBuf::from(format!("/tmp/oxihuman_batch_{}_{}", suffix, nanos));
363        std::fs::create_dir_all(&p).expect("should succeed");
364        p
365    }
366
367    // 1. Two params × two steps → 4 items
368    #[test]
369    fn param_grid_two_params_two_steps_is_four() {
370        let mut ranges = HashMap::new();
371        ranges.insert("height".to_string(), (0.0f32, 1.0, 2));
372        ranges.insert("weight".to_string(), (0.0f32, 1.0, 2));
373        let grid = generate_param_grid(&ranges);
374        assert_eq!(grid.len(), 4);
375    }
376
377    // 2. One param × three steps → 3 items
378    #[test]
379    fn param_grid_one_param_three_steps_is_three() {
380        let mut ranges = HashMap::new();
381        ranges.insert("age".to_string(), (20.0f32, 60.0, 3));
382        let grid = generate_param_grid(&ranges);
383        assert_eq!(grid.len(), 3);
384    }
385
386    // 3. Empty ranges → 1 item (the empty map)
387    #[test]
388    fn param_grid_empty_ranges_gives_one() {
389        let ranges: HashMap<String, (f32, f32, usize)> = HashMap::new();
390        let grid = generate_param_grid(&ranges);
391        assert_eq!(grid.len(), 1);
392        assert!(grid[0].is_empty());
393    }
394
395    // 4. specs_from_param_grid count matches
396    #[test]
397    fn specs_from_param_grid_count_matches() {
398        let mut ranges = HashMap::new();
399        ranges.insert("height".to_string(), (0.0f32, 1.0, 3));
400        ranges.insert("weight".to_string(), (0.0f32, 1.0, 2));
401        let grid = generate_param_grid(&ranges);
402        let out_dir = Path::new("/tmp");
403        let specs = specs_from_param_grid(&grid, BatchOutputFormat::Json, out_dir);
404        assert_eq!(specs.len(), grid.len()); // 3 × 2 = 6
405    }
406
407    // 5. specs_from_param_grid sets correct extension
408    #[test]
409    fn specs_from_param_grid_correct_extension() {
410        let mut ranges = HashMap::new();
411        ranges.insert("x".to_string(), (0.0f32, 1.0, 2));
412        let grid = generate_param_grid(&ranges);
413        let out_dir = Path::new("/tmp");
414        let specs = specs_from_param_grid(&grid, BatchOutputFormat::Stl, out_dir);
415        for spec in &specs {
416            assert!(
417                spec.output_path
418                    .extension()
419                    .map(|e| e == "stl")
420                    .unwrap_or(false),
421                "expected .stl extension"
422            );
423        }
424    }
425
426    // 6. batch_result_summary contains the numbers
427    #[test]
428    fn batch_result_summary_contains_numbers() {
429        let result = BatchResult {
430            total: 10,
431            succeeded: 7,
432            failed: 2,
433            skipped: 1,
434            errors: vec![("id1".into(), "err".into()), ("id2".into(), "err2".into())],
435        };
436        let s = batch_result_summary(&result);
437        assert!(s.contains("10"), "should contain total");
438        assert!(s.contains('7'), "should contain succeeded");
439        assert!(s.contains('2'), "should contain failed");
440        assert!(s.contains('1'), "should contain skipped");
441    }
442
443    // 7. run_batch with 3 JSON specs succeeds
444    #[test]
445    fn run_batch_three_json_specs_succeed() {
446        let out_dir = tmpdir("batch_json");
447        let mut ranges = HashMap::new();
448        ranges.insert("height".to_string(), (0.5f32, 1.0, 3));
449        let grid = generate_param_grid(&ranges);
450        let specs = specs_from_param_grid(&grid, BatchOutputFormat::Json, &out_dir);
451        let cfg = BatchConfig::default();
452        let result = run_batch(&specs, &cfg);
453        assert_eq!(result.total, 3);
454        assert_eq!(result.succeeded, 3);
455        assert_eq!(result.failed, 0);
456        assert_eq!(result.skipped, 0);
457    }
458
459    // 8. run_batch with OBJ format creates files
460    #[test]
461    fn run_batch_obj_creates_files() {
462        let out_dir = tmpdir("batch_obj");
463        let specs = vec![BatchCharacterSpec {
464            id: "test_char".to_string(),
465            params: HashMap::new(),
466            output_format: BatchOutputFormat::Obj,
467            output_path: out_dir.join("test_char.obj"),
468        }];
469        let cfg = BatchConfig::default();
470        let result = run_batch(&specs, &cfg);
471        assert_eq!(result.succeeded, 1);
472        assert!(out_dir.join("test_char.obj").exists());
473    }
474
475    // 9. skip_existing logic skips existing files
476    #[test]
477    fn run_batch_skip_existing_skips() {
478        let out_dir = tmpdir("batch_skip");
479        let path = out_dir.join("char_0000.json");
480        // Pre-create the file
481        std::fs::write(&path, "{}").expect("should succeed");
482
483        let specs = vec![BatchCharacterSpec {
484            id: "char_0000".to_string(),
485            params: HashMap::new(),
486            output_format: BatchOutputFormat::Json,
487            output_path: path,
488        }];
489        let cfg = BatchConfig {
490            skip_existing: true,
491            ..Default::default()
492        };
493        let result = run_batch(&specs, &cfg);
494        assert_eq!(result.skipped, 1);
495        assert_eq!(result.succeeded, 0);
496    }
497
498    // 10. failed spec captured in errors
499    #[test]
500    fn run_batch_failed_spec_captured() {
501        // Output path in a non-existent dir that cannot be created — use a
502        // file path where the "parent dir" is an existing regular file.
503        let out_dir = tmpdir("batch_fail");
504        let blocker = out_dir.join("blocker");
505        std::fs::write(&blocker, b"I am a file, not a dir").expect("should succeed");
506        // Try to write into blocker/char.json — parent is a file, not a dir
507        let bad_path = blocker.join("char.json");
508        let specs = vec![BatchCharacterSpec {
509            id: "bad-char".to_string(),
510            params: HashMap::new(),
511            output_format: BatchOutputFormat::Json,
512            output_path: bad_path,
513        }];
514        let cfg = BatchConfig::default();
515        let result = run_batch(&specs, &cfg);
516        assert_eq!(result.failed, 1);
517        assert_eq!(result.errors.len(), 1);
518        assert_eq!(result.errors[0].0, "bad-char");
519    }
520
521    // 11. estimate_batch_size output contains count and format
522    #[test]
523    fn estimate_batch_size_output_contains_info() {
524        let mut ranges = HashMap::new();
525        ranges.insert("h".to_string(), (0.0f32, 1.0, 2));
526        let grid = generate_param_grid(&ranges);
527        let specs = specs_from_param_grid(&grid, BatchOutputFormat::Csv, Path::new("/tmp"));
528        let s = estimate_batch_size(&specs);
529        assert!(s.contains('2'), "should mention count 2");
530        assert!(s.contains("csv"), "should mention format csv");
531    }
532
533    // 12. run_batch CSV format produces comma-separated content
534    #[test]
535    fn run_batch_csv_produces_valid_csv() {
536        let out_dir = tmpdir("batch_csv");
537        let mut params = HashMap::new();
538        params.insert("height".to_string(), 0.75f32);
539        params.insert("weight".to_string(), 0.5f32);
540        let specs = vec![BatchCharacterSpec {
541            id: "csv_char".to_string(),
542            params,
543            output_format: BatchOutputFormat::Csv,
544            output_path: out_dir.join("csv_char.csv"),
545        }];
546        let cfg = BatchConfig::default();
547        let result = run_batch(&specs, &cfg);
548        assert_eq!(result.succeeded, 1);
549        let content = std::fs::read_to_string(out_dir.join("csv_char.csv")).expect("should succeed");
550        assert!(content.contains(','), "CSV should contain commas");
551    }
552
553    // 13. Param grid with 1 step returns boundary values
554    #[test]
555    fn param_grid_one_step_returns_min() {
556        let mut ranges = HashMap::new();
557        ranges.insert("muscle".to_string(), (0.3f32, 0.9, 1));
558        let grid = generate_param_grid(&ranges);
559        assert_eq!(grid.len(), 1);
560        let val = grid[0]["muscle"];
561        assert!((val - 0.3).abs() < 1e-5);
562    }
563}