1use std::path::PathBuf;
8
9use oxihuman_mesh::MeshBuffers;
10
11use crate::{
12 export_glb, export_json_mesh_to_file, export_obj, export_ply, export_stl_binary, PlyFormat,
13};
14
15#[allow(dead_code)]
18#[derive(Debug, Clone)]
19enum JobType {
20 Glb,
21 Obj,
22 Stl,
23 Ply,
24 Json,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum JobStatus {
32 Pending,
33 Running,
34 Completed,
35 Failed(String),
37}
38
39#[derive(Debug, Clone)]
43pub struct ExportJob {
44 pub id: usize,
45 pub name: String,
46 pub output_path: PathBuf,
47 pub status: JobStatus,
48 job_type: JobType,
49}
50
51impl ExportJob {
52 pub fn new(id: usize, name: impl Into<String>, output_path: PathBuf) -> Self {
54 Self {
55 id,
56 name: name.into(),
57 output_path,
58 status: JobStatus::Pending,
59 job_type: JobType::Glb,
60 }
61 }
62
63 pub fn is_done(&self) -> bool {
65 matches!(self.status, JobStatus::Completed | JobStatus::Failed(_))
66 }
67
68 pub fn is_failed(&self) -> bool {
70 matches!(self.status, JobStatus::Failed(_))
71 }
72}
73
74#[derive(Debug, Clone)]
78pub struct QueueResult {
79 pub total: usize,
80 pub completed: usize,
81 pub failed: usize,
82 pub errors: Vec<(String, String)>,
84}
85
86impl QueueResult {
87 pub fn success_rate(&self) -> f32 {
90 if self.total == 0 {
91 return 1.0;
92 }
93 self.completed as f32 / self.total as f32
94 }
95
96 pub fn all_succeeded(&self) -> bool {
98 self.failed == 0
99 }
100
101 pub fn has_failures(&self) -> bool {
103 self.failed > 0
104 }
105}
106
107pub struct ExportJobQueue {
111 jobs: Vec<ExportJob>,
112 next_id: usize,
113}
114
115impl ExportJobQueue {
116 pub fn new() -> Self {
118 Self {
119 jobs: Vec::new(),
120 next_id: 0,
121 }
122 }
123
124 fn add_job(&mut self, name: impl Into<String>, path: PathBuf, job_type: JobType) -> usize {
127 let id = self.next_id;
128 self.next_id += 1;
129 let mut job = ExportJob::new(id, name, path);
130 job.job_type = job_type;
131 self.jobs.push(job);
132 id
133 }
134
135 pub fn add_glb(&mut self, name: impl Into<String>, path: PathBuf) -> usize {
137 self.add_job(name, path, JobType::Glb)
138 }
139
140 pub fn add_obj(&mut self, name: impl Into<String>, path: PathBuf) -> usize {
142 self.add_job(name, path, JobType::Obj)
143 }
144
145 pub fn add_stl(&mut self, name: impl Into<String>, path: PathBuf) -> usize {
147 self.add_job(name, path, JobType::Stl)
148 }
149
150 pub fn add_ply(&mut self, name: impl Into<String>, path: PathBuf) -> usize {
152 self.add_job(name, path, JobType::Ply)
153 }
154
155 pub fn add_json(&mut self, name: impl Into<String>, path: PathBuf) -> usize {
157 self.add_job(name, path, JobType::Json)
158 }
159
160 pub fn job_count(&self) -> usize {
164 self.jobs.len()
165 }
166
167 pub fn pending_count(&self) -> usize {
169 self.jobs
170 .iter()
171 .filter(|j| j.status == JobStatus::Pending)
172 .count()
173 }
174
175 pub fn is_empty(&self) -> bool {
177 self.jobs.is_empty()
178 }
179
180 pub fn get_job(&self, id: usize) -> Option<&ExportJob> {
182 self.jobs.iter().find(|j| j.id == id)
183 }
184
185 pub fn failed_jobs(&self) -> Vec<&ExportJob> {
187 self.jobs.iter().filter(|j| j.is_failed()).collect()
188 }
189
190 pub fn run(
197 &mut self,
198 mesh: &MeshBuffers,
199 mut progress: impl FnMut(usize, usize),
200 ) -> QueueResult {
201 let pending_ids: Vec<usize> = self
202 .jobs
203 .iter()
204 .filter(|j| j.status == JobStatus::Pending)
205 .map(|j| j.id)
206 .collect();
207
208 let total = pending_ids.len();
209 let mut completed = 0usize;
210 let mut failed = 0usize;
211 let mut errors: Vec<(String, String)> = Vec::new();
212
213 for (step, id) in pending_ids.into_iter().enumerate() {
214 if let Some(job) = self.jobs.iter_mut().find(|j| j.id == id) {
216 job.status = JobStatus::Running;
217 }
218
219 let (job_type, output_path, job_name) = {
221 let job = match self.jobs.iter().find(|j| j.id == id) {
222 Some(j) => j,
223 None => continue,
224 };
225 (
226 job.job_type.clone(),
227 job.output_path.clone(),
228 job.name.clone(),
229 )
230 };
231
232 let result = dispatch_export(mesh, &job_type, &output_path);
233
234 if let Some(job) = self.jobs.iter_mut().find(|j| j.id == id) {
236 match result {
237 Ok(()) => {
238 job.status = JobStatus::Completed;
239 completed += 1;
240 }
241 Err(e) => {
242 let msg = e.to_string();
243 errors.push((job_name, msg.clone()));
244 job.status = JobStatus::Failed(msg);
245 failed += 1;
246 }
247 }
248 }
249
250 progress(step + 1, total);
251 }
252
253 QueueResult {
254 total,
255 completed,
256 failed,
257 errors,
258 }
259 }
260
261 pub fn retry_failed(&mut self, mesh: &MeshBuffers) -> QueueResult {
263 for job in &mut self.jobs {
265 if job.is_failed() {
266 job.status = JobStatus::Pending;
267 }
268 }
269 self.run(mesh, |_, _| {})
270 }
271
272 pub fn clear(&mut self) {
276 self.jobs.clear();
277 }
278
279 pub fn remove_completed(&mut self) {
281 self.jobs.retain(|j| j.status != JobStatus::Completed);
282 }
283}
284
285impl Default for ExportJobQueue {
286 fn default() -> Self {
287 Self::new()
288 }
289}
290
291fn dispatch_export(
294 mesh: &MeshBuffers,
295 job_type: &JobType,
296 output_path: &std::path::Path,
297) -> anyhow::Result<()> {
298 match job_type {
299 JobType::Glb => {
300 let mut m = mesh.clone();
301 m.has_suit = true;
302 export_glb(&m, output_path)
303 }
304 JobType::Obj => export_obj(mesh, output_path),
305 JobType::Stl => export_stl_binary(mesh, output_path),
306 JobType::Ply => export_ply(mesh, output_path, PlyFormat::BinaryLittleEndian),
307 JobType::Json => export_json_mesh_to_file(mesh, output_path),
308 }
309}
310
311#[cfg(test)]
314mod tests {
315 use super::*;
316
317 fn make_mesh() -> MeshBuffers {
318 MeshBuffers {
319 positions: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
320 normals: vec![[0.0, 0.0, 1.0]; 3],
321 tangents: vec![[1.0, 0.0, 0.0, 1.0]; 3],
322 uvs: vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]],
323 indices: vec![0, 1, 2],
324 colors: None,
325 has_suit: false,
326 }
327 }
328
329 #[test]
330 fn queue_new_is_empty() {
331 let q = ExportJobQueue::new();
332 assert!(q.is_empty());
333 assert_eq!(q.job_count(), 0);
334 assert_eq!(q.pending_count(), 0);
335 }
336
337 #[test]
338 fn add_glb_job_increases_count() {
339 let mut q = ExportJobQueue::new();
340 q.add_glb("test", PathBuf::from("/tmp/test_job_queue_x.glb"));
341 assert_eq!(q.job_count(), 1);
342 assert_eq!(q.pending_count(), 1);
343 assert!(!q.is_empty());
344 }
345
346 #[test]
347 fn add_multiple_jobs() {
348 let mut q = ExportJobQueue::new();
349 q.add_glb("a", PathBuf::from("/tmp/test_job_queue_a.glb"));
350 q.add_obj("b", PathBuf::from("/tmp/test_job_queue_b.obj"));
351 q.add_stl("c", PathBuf::from("/tmp/test_job_queue_c.stl"));
352 q.add_ply("d", PathBuf::from("/tmp/test_job_queue_d.ply"));
353 q.add_json("e", PathBuf::from("/tmp/test_job_queue_e.json"));
354 assert_eq!(q.job_count(), 5);
355 assert_eq!(q.pending_count(), 5);
356 }
357
358 #[test]
359 fn job_status_starts_pending() {
360 let mut q = ExportJobQueue::new();
361 let id = q.add_glb("pending", PathBuf::from("/tmp/test_job_queue_p.glb"));
362 let job = q.get_job(id).expect("should succeed");
363 assert_eq!(job.status, JobStatus::Pending);
364 assert!(!job.is_done());
365 assert!(!job.is_failed());
366 }
367
368 #[test]
369 fn run_empty_queue_succeeds() {
370 let mut q = ExportJobQueue::new();
371 let mesh = make_mesh();
372 let result = q.run(&mesh, |_, _| {});
373 assert_eq!(result.total, 0);
374 assert_eq!(result.completed, 0);
375 assert_eq!(result.failed, 0);
376 assert!(result.all_succeeded());
377 assert!(!result.has_failures());
378 assert!((result.success_rate() - 1.0).abs() < f32::EPSILON);
379 }
380
381 #[test]
382 fn run_single_glb_job() {
383 let mut q = ExportJobQueue::new();
384 let path = PathBuf::from("/tmp/test_job_queue_single.glb");
385 q.add_glb("single-glb", path.clone());
386 let mesh = make_mesh();
387 let result = q.run(&mesh, |_, _| {});
388 assert_eq!(result.total, 1);
389 assert_eq!(result.completed, 1);
390 assert_eq!(result.failed, 0);
391 assert!(result.all_succeeded());
392 assert!(path.exists(), "GLB file should have been created");
393 }
394
395 #[test]
396 fn run_single_obj_job() {
397 let mut q = ExportJobQueue::new();
398 let path = PathBuf::from("/tmp/test_job_queue_single.obj");
399 q.add_obj("single-obj", path.clone());
400 let mesh = make_mesh();
401 let result = q.run(&mesh, |_, _| {});
402 assert_eq!(result.total, 1);
403 assert_eq!(result.completed, 1);
404 assert!(result.all_succeeded());
405 assert!(path.exists(), "OBJ file should have been created");
406 }
407
408 #[test]
409 fn run_multiple_jobs_all_complete() {
410 let mut q = ExportJobQueue::new();
411 q.add_glb("glb", PathBuf::from("/tmp/test_job_queue_multi.glb"));
412 q.add_obj("obj", PathBuf::from("/tmp/test_job_queue_multi.obj"));
413 q.add_stl("stl", PathBuf::from("/tmp/test_job_queue_multi.stl"));
414 q.add_ply("ply", PathBuf::from("/tmp/test_job_queue_multi.ply"));
415 q.add_json("json", PathBuf::from("/tmp/test_job_queue_multi.json"));
416
417 let mesh = make_mesh();
418 let result = q.run(&mesh, |_, _| {});
419 assert_eq!(result.total, 5);
420 assert_eq!(result.completed, 5);
421 assert_eq!(result.failed, 0);
422 assert!(result.all_succeeded());
423 }
424
425 #[test]
426 fn queue_result_success_rate() {
427 let result = QueueResult {
428 total: 4,
429 completed: 3,
430 failed: 1,
431 errors: vec![("job".into(), "oops".into())],
432 };
433 let rate = result.success_rate();
434 assert!((rate - 0.75).abs() < 1e-5, "expected 0.75, got {rate}");
435 assert!(result.has_failures());
436 assert!(!result.all_succeeded());
437 }
438
439 #[test]
440 fn failed_jobs_empty_after_success() {
441 let mut q = ExportJobQueue::new();
442 q.add_obj("obj", PathBuf::from("/tmp/test_job_queue_fj.obj"));
443 let mesh = make_mesh();
444 q.run(&mesh, |_, _| {});
445 assert!(q.failed_jobs().is_empty());
446 }
447
448 #[test]
449 fn clear_removes_all_jobs() {
450 let mut q = ExportJobQueue::new();
451 q.add_glb("a", PathBuf::from("/tmp/test_job_queue_ca.glb"));
452 q.add_obj("b", PathBuf::from("/tmp/test_job_queue_cb.obj"));
453 assert_eq!(q.job_count(), 2);
454 q.clear();
455 assert!(q.is_empty());
456 assert_eq!(q.job_count(), 0);
457 }
458
459 #[test]
460 fn remove_completed_keeps_failed() {
461 let mut q = ExportJobQueue::new();
462 q.add_obj("good", PathBuf::from("/tmp/test_job_queue_rc_good.obj"));
464 q.add_glb("bad", PathBuf::from("/no_such_dir/impossible.glb"));
465
466 let mesh = make_mesh();
467 q.run(&mesh, |_, _| {});
468
469 q.remove_completed();
470
471 assert_eq!(q.job_count(), 1);
473 assert_eq!(q.failed_jobs().len(), 1);
474 }
475
476 #[test]
477 fn progress_callback_called() {
478 let mut q = ExportJobQueue::new();
479 q.add_obj("p1", PathBuf::from("/tmp/test_job_queue_prog1.obj"));
480 q.add_obj("p2", PathBuf::from("/tmp/test_job_queue_prog2.obj"));
481 q.add_obj("p3", PathBuf::from("/tmp/test_job_queue_prog3.obj"));
482
483 let mesh = make_mesh();
484 let mut calls: Vec<(usize, usize)> = Vec::new();
485 q.run(&mesh, |done, total| calls.push((done, total)));
486
487 assert_eq!(calls.len(), 3);
488 assert_eq!(calls[0], (1, 3));
489 assert_eq!(calls[1], (2, 3));
490 assert_eq!(calls[2], (3, 3));
491 }
492}