Skip to main content

raps_cli/commands/
reality.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2025 Dmytro Yemelianov
3
4//! Reality Capture commands
5//!
6//! Commands for photogrammetry processing.
7
8use anyhow::Result;
9use clap::Subcommand;
10use colored::Colorize;
11use dialoguer::{Input, Select};
12use indicatif::{ProgressBar, ProgressStyle};
13#[allow(unused_imports)]
14use raps_kernel::{progress, prompts};
15use serde::Serialize;
16use std::path::PathBuf;
17use std::time::Duration;
18
19use crate::output::OutputFormat;
20use raps_kernel::interactive;
21// use raps_kernel::output::OutputFormat;
22use raps_reality::{OutputFormat as RealityOutputFormat, RealityCaptureClient, SceneType};
23
24#[derive(Debug, Subcommand)]
25pub enum RealityCommands {
26    /// List photoscenes
27    List,
28
29    /// Create a new photoscene
30    Create {
31        /// Photoscene name
32        #[arg(short, long)]
33        name: Option<String>,
34
35        /// Scene type (aerial or object)
36        #[arg(short, long)]
37        scene_type: Option<String>,
38
39        /// Output format (rcm, rcs, obj, fbx, ortho)
40        #[arg(short, long)]
41        format: Option<String>,
42    },
43
44    /// Upload photos to a photoscene
45    Upload {
46        /// Photoscene ID
47        photoscene_id: String,
48
49        /// Photo files to upload
50        #[arg(required = true)]
51        photos: Vec<PathBuf>,
52    },
53
54    /// Start processing a photoscene
55    Process {
56        /// Photoscene ID
57        photoscene_id: String,
58    },
59
60    /// Check photoscene progress
61    Status {
62        /// Photoscene ID
63        photoscene_id: String,
64
65        /// Wait for completion
66        #[arg(short, long)]
67        wait: bool,
68    },
69
70    /// Get result (download link)
71    Result {
72        /// Photoscene ID
73        photoscene_id: String,
74
75        /// Output format
76        #[arg(short, long, default_value = "obj")]
77        format: String,
78    },
79
80    /// List available output formats
81    Formats,
82
83    /// Delete a photoscene
84    Delete {
85        /// Photoscene ID
86        photoscene_id: String,
87    },
88}
89
90impl RealityCommands {
91    pub async fn execute(
92        self,
93        client: &RealityCaptureClient,
94        output_format: OutputFormat,
95    ) -> Result<()> {
96        match self {
97            RealityCommands::List => list_photoscenes(client, output_format).await,
98            RealityCommands::Create {
99                name,
100                scene_type,
101                format,
102            } => create_photoscene(client, name, scene_type, format, output_format).await,
103            RealityCommands::Upload {
104                photoscene_id,
105                photos,
106            } => upload_photos(client, &photoscene_id, photos, output_format).await,
107            RealityCommands::Process { photoscene_id } => {
108                start_processing(client, &photoscene_id, output_format).await
109            }
110            RealityCommands::Status {
111                photoscene_id,
112                wait,
113            } => check_status(client, &photoscene_id, wait, output_format).await,
114            RealityCommands::Result {
115                photoscene_id,
116                format,
117            } => get_result(client, &photoscene_id, &format, output_format).await,
118            RealityCommands::Formats => list_formats(client, output_format),
119            RealityCommands::Delete { photoscene_id } => {
120                delete_photoscene(client, &photoscene_id, output_format).await
121            }
122        }
123    }
124}
125
126async fn list_photoscenes(
127    client: &RealityCaptureClient,
128    output_format: OutputFormat,
129) -> Result<()> {
130    if output_format.supports_colors() {
131        println!("{}", "Fetching photoscenes...".dimmed());
132    }
133
134    let photoscenes = client.list_photoscenes().await?;
135
136    #[derive(Serialize)]
137    struct PhotosceneOutput {
138        id: String,
139        name: String,
140        scene_type: String,
141        status: String,
142        progress: String,
143    }
144
145    let photoscene_outputs: Vec<PhotosceneOutput> = photoscenes
146        .iter()
147        .map(|p| PhotosceneOutput {
148            id: p.photoscene_id.clone(),
149            name: p.name.clone().unwrap_or_default(),
150            scene_type: p.scene_type.clone().unwrap_or_default(),
151            status: p.status.clone().unwrap_or_default(),
152            progress: p.progress.clone().unwrap_or_default(),
153        })
154        .collect();
155
156    if photoscene_outputs.is_empty() {
157        match output_format {
158            OutputFormat::Table => println!("{}", "No photoscenes found.".yellow()),
159            _ => {
160                output_format.write(&Vec::<PhotosceneOutput>::new())?;
161            }
162        }
163        return Ok(());
164    }
165
166    match output_format {
167        OutputFormat::Table => {
168            println!("\n{}", "Photoscenes:".bold());
169            println!(
170                "{:<30} {:<20} {:<10} {:<12} {}",
171                "ID".bold(),
172                "Name".bold(),
173                "Type".bold(),
174                "Status".bold(),
175                "Progress".bold()
176            );
177            println!("{}", "-".repeat(90));
178
179            for scene in &photoscene_outputs {
180                let status_colored = match scene.status.as_str() {
181                    "Done" | "Created" => scene.status.green().to_string(),
182                    "Error" => scene.status.red().to_string(),
183                    "Processing" => scene.status.yellow().to_string(),
184                    _ => scene.status.clone(),
185                };
186                println!(
187                    "{:<30} {:<20} {:<10} {:<12} {}",
188                    scene.id, scene.name, scene.scene_type, status_colored, scene.progress
189                );
190            }
191
192            println!("{}", "-".repeat(90));
193        }
194        _ => {
195            output_format.write(&photoscene_outputs)?;
196        }
197    }
198    Ok(())
199}
200
201async fn create_photoscene(
202    client: &RealityCaptureClient,
203    name: Option<String>,
204    scene_type: Option<String>,
205    format: Option<String>,
206    output_format: OutputFormat,
207) -> Result<()> {
208    // Get name
209    let scene_name = match name {
210        Some(n) => n,
211        None => {
212            // In non-interactive mode, require the name
213            if interactive::is_non_interactive() {
214                anyhow::bail!(
215                    "Photoscene name is required in non-interactive mode. Use --name flag."
216                );
217            }
218            Input::new()
219                .with_prompt("Enter photoscene name")
220                .interact_text()?
221        }
222    };
223
224    // Get scene type
225    let selected_scene_type = match scene_type {
226        Some(t) => match t.to_lowercase().as_str() {
227            "aerial" => SceneType::Aerial,
228            "object" => SceneType::Object,
229            _ => anyhow::bail!("Invalid scene type. Use 'aerial' or 'object'"),
230        },
231        None => {
232            // In non-interactive mode, default to object
233            if interactive::is_non_interactive() {
234                SceneType::Object
235            } else {
236                let types = vec!["aerial (drone/outdoor)", "object (turntable/indoor)"];
237                let selection = Select::new()
238                    .with_prompt("Select scene type")
239                    .items(&types)
240                    .interact()?;
241                if selection == 0 {
242                    SceneType::Aerial
243                } else {
244                    SceneType::Object
245                }
246            }
247        }
248    };
249
250    // Get output format
251    let selected_format = match format {
252        Some(f) => parse_format(&f)?,
253        None => {
254            // In non-interactive mode, default to OBJ
255            if interactive::is_non_interactive() {
256                RealityOutputFormat::Obj
257            } else {
258                let formats = RealityOutputFormat::all();
259                let format_labels: Vec<String> = formats
260                    .iter()
261                    .map(|f| format!("{} - {}", f, f.description()))
262                    .collect();
263
264                let selection = Select::new()
265                    .with_prompt("Select output format")
266                    .items(&format_labels)
267                    .default(2) // OBJ is usually a good default
268                    .interact()?;
269
270                formats[selection]
271            }
272        }
273    };
274
275    if output_format.supports_colors() {
276        println!("{}", "Creating photoscene...".dimmed());
277    }
278
279    let photoscene = client
280        .create_photoscene(&scene_name, selected_scene_type, selected_format)
281        .await?;
282
283    #[derive(Serialize)]
284    struct CreatePhotosceneOutput {
285        success: bool,
286        photoscene_id: String,
287        name: String,
288    }
289
290    let output = CreatePhotosceneOutput {
291        success: true,
292        photoscene_id: photoscene.photoscene_id.clone(),
293        name: scene_name.clone(),
294    };
295
296    match output_format {
297        OutputFormat::Table => {
298            println!("{} Photoscene created!", "✓".green().bold());
299            println!("  {} {}", "ID:".bold(), output.photoscene_id.cyan());
300            println!("  {} {}", "Name:".bold(), output.name);
301
302            println!("\n{}", "Next steps:".yellow());
303            println!(
304                "  1. Upload photos: raps reality upload {} <photo1.jpg> <photo2.jpg> ...",
305                output.photoscene_id
306            );
307            println!(
308                "  2. Start processing: raps reality process {}",
309                output.photoscene_id
310            );
311            println!(
312                "  3. Check status: raps reality status {} --wait",
313                output.photoscene_id
314            );
315        }
316        _ => {
317            output_format.write(&output)?;
318        }
319    }
320
321    Ok(())
322}
323
324async fn upload_photos(
325    client: &RealityCaptureClient,
326    photoscene_id: &str,
327    photos: Vec<PathBuf>,
328    _output_format: OutputFormat,
329) -> Result<()> {
330    // Validate files exist
331    for photo in &photos {
332        if !photo.exists() {
333            anyhow::bail!("File not found: {}", photo.display());
334        }
335    }
336
337    let pb = ProgressBar::new(photos.len() as u64);
338    pb.set_style(
339        ProgressStyle::default_bar()
340            .template("{msg} [{bar:40.cyan/blue}] {pos}/{len}")
341            .expect("valid progress template")
342            .progress_chars("█▓░"),
343    );
344    pb.set_message("Uploading photos");
345
346    // Upload in batches of 5
347    let photo_refs: Vec<&std::path::Path> = photos.iter().map(|p| p.as_path()).collect();
348
349    for chunk in photo_refs.chunks(5) {
350        client.upload_photos(photoscene_id, chunk).await?;
351        pb.inc(chunk.len() as u64);
352    }
353
354    pb.finish_with_message("Upload complete");
355
356    println!("{} Uploaded {} photos!", "✓".green().bold(), photos.len());
357    Ok(())
358}
359
360async fn start_processing(
361    client: &RealityCaptureClient,
362    photoscene_id: &str,
363    _output_format: OutputFormat,
364) -> Result<()> {
365    println!("{}", "Starting processing...".dimmed());
366
367    client.start_processing(photoscene_id).await?;
368
369    println!("{} Processing started!", "✓".green().bold());
370    println!(
371        "  {}",
372        "Use 'raps reality status <id> --wait' to monitor progress".dimmed()
373    );
374    Ok(())
375}
376
377async fn check_status(
378    client: &RealityCaptureClient,
379    photoscene_id: &str,
380    wait: bool,
381    _output_format: OutputFormat,
382) -> Result<()> {
383    if wait {
384        let spinner = ProgressBar::new_spinner();
385        spinner.set_style(
386            ProgressStyle::default_spinner()
387                .template("{spinner:.cyan} {msg}")
388                .expect("valid progress template"),
389        );
390        spinner.enable_steady_tick(Duration::from_millis(100));
391
392        // 4-hour timeout — photogrammetry processing can legitimately take hours
393        let timeout = Duration::from_secs(4 * 60 * 60);
394        let start = std::time::Instant::now();
395
396        loop {
397            if start.elapsed() > timeout {
398                spinner.finish_with_message(format!(
399                    "{} Timed out after {} hours. Use 'raps reality status {}' to check later.",
400                    "⏱".yellow().bold(),
401                    timeout.as_secs() / 3600,
402                    photoscene_id
403                ));
404                break;
405            }
406
407            let progress = client.get_progress(photoscene_id).await?;
408            let msg = progress.progress_msg.as_deref().unwrap_or("");
409            spinner.set_message(format!("Progress: {}% - {}", progress.progress, msg));
410
411            if progress.progress == "100" || progress.status.as_deref() == Some("Done") {
412                spinner.finish_with_message(format!("{} Processing complete!", "✓".green().bold()));
413                break;
414            }
415
416            if progress.status.as_deref() == Some("Error") {
417                spinner.finish_with_message(format!(
418                    "{} Processing failed: {}",
419                    "✗".red().bold(),
420                    msg
421                ));
422                break;
423            }
424
425            tokio::time::sleep(Duration::from_secs(10)).await;
426        }
427    } else {
428        let progress = client.get_progress(photoscene_id).await?;
429
430        println!("{}", "Photoscene Status:".bold());
431        println!("  {} {}%", "Progress:".bold(), progress.progress.cyan());
432
433        if let Some(ref status) = progress.status {
434            println!("  {} {}", "Status:".bold(), status);
435        }
436
437        if let Some(ref msg) = progress.progress_msg {
438            println!("  {} {}", "Message:".bold(), msg.dimmed());
439        }
440    }
441
442    Ok(())
443}
444
445async fn get_result(
446    client: &RealityCaptureClient,
447    photoscene_id: &str,
448    format: &str,
449    _output_format: OutputFormat,
450) -> Result<()> {
451    let output_format = parse_format(format)?;
452
453    println!("{}", "Fetching result...".dimmed());
454
455    let result = client.get_result(photoscene_id, output_format).await?;
456
457    println!("{}", "Photoscene Result:".bold());
458    println!("  {} {}", "ID:".bold(), result.photoscene_id);
459    println!("  {} {}%", "Progress:".bold(), result.progress);
460
461    if let Some(ref link) = result.scene_link {
462        println!("\n{}", "Download Link:".green().bold());
463        println!("  {}", link);
464    } else {
465        println!(
466            "{}",
467            "No download link available yet. Processing may still be in progress.".yellow()
468        );
469    }
470
471    if let Some(bytes) = result.filesize_bytes() {
472        let display = if bytes >= 1_073_741_824 {
473            format!("{:.2} GB", bytes as f64 / 1_073_741_824.0)
474        } else if bytes >= 1_048_576 {
475            format!("{:.2} MB", bytes as f64 / 1_048_576.0)
476        } else if bytes >= 1024 {
477            format!("{:.2} KB", bytes as f64 / 1024.0)
478        } else {
479            format!("{bytes} B")
480        };
481        println!("  {} {}", "File Size:".bold(), display);
482    } else if let Some(ref size) = result.file_size {
483        println!("  {} {}", "File Size:".bold(), size);
484    }
485
486    Ok(())
487}
488
489fn list_formats(client: &RealityCaptureClient, _output_format: OutputFormat) -> Result<()> {
490    let formats = client.available_formats();
491
492    println!("\n{}", "Available Output Formats:".bold());
493    println!("{}", "─".repeat(60));
494
495    for format in formats {
496        println!(
497            "  {} {} - {}",
498            "•".cyan(),
499            format,
500            format.description().dimmed()
501        );
502    }
503
504    println!("{}", "─".repeat(60));
505    Ok(())
506}
507
508async fn delete_photoscene(
509    client: &RealityCaptureClient,
510    photoscene_id: &str,
511    _output_format: OutputFormat,
512) -> Result<()> {
513    println!("{}", "Deleting photoscene...".dimmed());
514
515    client.delete_photoscene(photoscene_id).await?;
516
517    println!(
518        "{} Photoscene '{}' deleted!",
519        "✓".green().bold(),
520        photoscene_id
521    );
522    Ok(())
523}
524
525fn parse_format(s: &str) -> Result<RealityOutputFormat> {
526    match s.to_lowercase().as_str() {
527        "rcm" => Ok(RealityOutputFormat::Rcm),
528        "rcs" => Ok(RealityOutputFormat::Rcs),
529        "obj" => Ok(RealityOutputFormat::Obj),
530        "fbx" => Ok(RealityOutputFormat::Fbx),
531        "ortho" => Ok(RealityOutputFormat::Ortho),
532        _ => anyhow::bail!("Invalid format. Use: rcm, rcs, obj, fbx, ortho"),
533    }
534}