Skip to main content

oxihuman_export/
job_queue.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Sequential job queue for managing and executing multiple export operations,
5//! with progress tracking and error collection.
6
7use 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// ── Internal job type ────────────────────────────────────────────────────────
16
17#[allow(dead_code)]
18#[derive(Debug, Clone)]
19enum JobType {
20    Glb,
21    Obj,
22    Stl,
23    Ply,
24    Json,
25}
26
27// ── Job status ───────────────────────────────────────────────────────────────
28
29/// Status of a single export job.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum JobStatus {
32    Pending,
33    Running,
34    Completed,
35    /// Contains the error message if the job failed.
36    Failed(String),
37}
38
39// ── Export job ───────────────────────────────────────────────────────────────
40
41/// A single export job descriptor.
42#[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    /// Create a new export job in `Pending` state.
53    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    /// Returns `true` if the job has finished (completed or failed).
64    pub fn is_done(&self) -> bool {
65        matches!(self.status, JobStatus::Completed | JobStatus::Failed(_))
66    }
67
68    /// Returns `true` if the job ended in failure.
69    pub fn is_failed(&self) -> bool {
70        matches!(self.status, JobStatus::Failed(_))
71    }
72}
73
74// ── Queue result ─────────────────────────────────────────────────────────────
75
76/// Result summary for a completed job queue run.
77#[derive(Debug, Clone)]
78pub struct QueueResult {
79    pub total: usize,
80    pub completed: usize,
81    pub failed: usize,
82    /// `(job_name, error_message)` pairs for each failure.
83    pub errors: Vec<(String, String)>,
84}
85
86impl QueueResult {
87    /// Fraction of jobs that succeeded, in `[0.0, 1.0]`.
88    /// Returns `1.0` when `total == 0`.
89    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    /// Returns `true` when every job completed without error.
97    pub fn all_succeeded(&self) -> bool {
98        self.failed == 0
99    }
100
101    /// Returns `true` when at least one job failed.
102    pub fn has_failures(&self) -> bool {
103        self.failed > 0
104    }
105}
106
107// ── Job queue ────────────────────────────────────────────────────────────────
108
109/// A sequential queue of export jobs.
110pub struct ExportJobQueue {
111    jobs: Vec<ExportJob>,
112    next_id: usize,
113}
114
115impl ExportJobQueue {
116    /// Create an empty job queue.
117    pub fn new() -> Self {
118        Self {
119            jobs: Vec::new(),
120            next_id: 0,
121        }
122    }
123
124    // -- builders ------------------------------------------------------------
125
126    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    /// Add a GLB export job.
136    pub fn add_glb(&mut self, name: impl Into<String>, path: PathBuf) -> usize {
137        self.add_job(name, path, JobType::Glb)
138    }
139
140    /// Add an OBJ export job.
141    pub fn add_obj(&mut self, name: impl Into<String>, path: PathBuf) -> usize {
142        self.add_job(name, path, JobType::Obj)
143    }
144
145    /// Add an STL export job.
146    pub fn add_stl(&mut self, name: impl Into<String>, path: PathBuf) -> usize {
147        self.add_job(name, path, JobType::Stl)
148    }
149
150    /// Add a PLY export job.
151    pub fn add_ply(&mut self, name: impl Into<String>, path: PathBuf) -> usize {
152        self.add_job(name, path, JobType::Ply)
153    }
154
155    /// Add a JSON mesh export job.
156    pub fn add_json(&mut self, name: impl Into<String>, path: PathBuf) -> usize {
157        self.add_job(name, path, JobType::Json)
158    }
159
160    // -- introspection -------------------------------------------------------
161
162    /// Total number of jobs (pending, running, done, or failed).
163    pub fn job_count(&self) -> usize {
164        self.jobs.len()
165    }
166
167    /// Number of jobs still waiting to run.
168    pub fn pending_count(&self) -> usize {
169        self.jobs
170            .iter()
171            .filter(|j| j.status == JobStatus::Pending)
172            .count()
173    }
174
175    /// Returns `true` when the queue has no jobs at all.
176    pub fn is_empty(&self) -> bool {
177        self.jobs.is_empty()
178    }
179
180    /// Look up a job by its ID.
181    pub fn get_job(&self, id: usize) -> Option<&ExportJob> {
182        self.jobs.iter().find(|j| j.id == id)
183    }
184
185    /// Collect references to all failed jobs.
186    pub fn failed_jobs(&self) -> Vec<&ExportJob> {
187        self.jobs.iter().filter(|j| j.is_failed()).collect()
188    }
189
190    // -- execution -----------------------------------------------------------
191
192    /// Execute all pending jobs sequentially using `mesh`.
193    ///
194    /// `progress` is called with `(completed_so_far, total_pending)` after
195    /// every job, whether it succeeded or failed.
196    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            // Mark as Running.
215            if let Some(job) = self.jobs.iter_mut().find(|j| j.id == id) {
216                job.status = JobStatus::Running;
217            }
218
219            // Snapshot what we need before the borrow ends.
220            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            // Update status.
235            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    /// Retry all failed jobs using `mesh`.
262    pub fn retry_failed(&mut self, mesh: &MeshBuffers) -> QueueResult {
263        // Reset failed jobs back to Pending.
264        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    // -- mutation ------------------------------------------------------------
273
274    /// Remove all jobs from the queue.
275    pub fn clear(&mut self) {
276        self.jobs.clear();
277    }
278
279    /// Remove every job whose status is `Completed`.
280    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
291// ── Internal dispatch ────────────────────────────────────────────────────────
292
293fn 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// ── Tests ────────────────────────────────────────────────────────────────────
312
313#[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        // Add a good job and a job with a bad (non-writable) path.
463        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        // The failed job must survive; the completed job must be gone.
472        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}