1#![allow(dead_code)]
8
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11
12pub 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#[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
54pub struct BatchConfig {
56 pub base_obj_path: Option<PathBuf>,
59 pub max_parallel: usize,
61 pub skip_existing: bool,
63 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
78pub struct BatchResult {
80 pub total: usize,
81 pub succeeded: usize,
82 pub failed: usize,
83 pub skipped: usize,
84 pub errors: Vec<(String, String)>,
86}
87
88fn 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
106pub 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 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
157fn export_one(spec: &BatchCharacterSpec, cfg: &BatchConfig) -> Result<(), String> {
159 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 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 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
185fn obj_to_stl_stub(obj: &str, name: &str) -> String {
188 let mut stl = format!("solid {}\n", name);
189 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 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
256pub fn generate_param_grid(
263 ranges: &HashMap<String, (f32, f32, usize)>,
264) -> Vec<HashMap<String, f32>> {
265 let mut keys: Vec<String> = ranges.keys().cloned().collect();
267 keys.sort();
268
269 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 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
303pub 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
326pub 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
336pub 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#[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 #[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 #[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 #[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 #[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()); }
406
407 #[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 #[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 #[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 #[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 #[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 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 #[test]
500 fn run_batch_failed_spec_captured() {
501 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 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 #[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 #[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 #[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}