oxihuman-export 0.1.2

Export pipeline for OxiHuman — glTF, COLLADA, STL, and streaming formats
Documentation
//! PBRT (Physically Based Rendering) scene export stub — ASCII PBRT v3 format.
//!
//! Provides a lightweight scene description that can be serialised to the
//! ASCII PBRT v3 text format without any external dependencies.

// ──────────────────────────────────────────────────────────────────────────────
// Structs
// ──────────────────────────────────────────────────────────────────────────────

/// Configuration for PBRT export.
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct PbrtExportConfig {
    /// PBRT format version string (e.g. "3").
    pub version: String,
    /// Film width in pixels.
    pub film_width: u32,
    /// Film height in pixels.
    pub film_height: u32,
    /// Number of samples per pixel.
    pub samples_per_pixel: u32,
    /// Output filename written into the film directive.
    pub output_filename: String,
}

/// A material description for PBRT.
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct PbrtMaterial {
    /// Material name identifier.
    pub name: String,
    /// Diffuse colour (RGB 0‒1).
    pub diffuse: [f32; 3],
    /// Roughness (0 = mirror, 1 = fully rough).
    pub roughness: f32,
}

/// A single shape in a PBRT scene.
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct PbrtShape {
    /// Shape type string, e.g. `"trianglemesh"`.
    pub shape_type: String,
    /// Vertex positions.
    pub vertices: Vec<[f32; 3]>,
    /// Triangle face indices.
    pub faces: Vec<[u32; 3]>,
    /// Material name applied to this shape.
    pub material_name: String,
}

/// Camera description for a PBRT scene.
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct PbrtCamera {
    /// Eye/origin position.
    pub position: [f32; 3],
    /// Look-at target.
    pub look_at: [f32; 3],
    /// Vertical field-of-view in degrees.
    pub fov_deg: f32,
}

/// Infinite area light entry.
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct PbrtInfiniteLight {
    /// Intensity scale.
    pub intensity: f32,
}

/// A PBRT scene composed of shapes, a camera, and lights.
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct PbrtScene {
    /// Export configuration.
    pub config: PbrtExportConfig,
    /// Shapes in the scene.
    pub shapes: Vec<PbrtShape>,
    /// Optional camera.
    pub camera: Option<PbrtCamera>,
    /// Infinite area lights.
    pub infinite_lights: Vec<PbrtInfiniteLight>,
}

// ──────────────────────────────────────────────────────────────────────────────
// Functions
// ──────────────────────────────────────────────────────────────────────────────

/// Return a sensible default [`PbrtExportConfig`].
#[allow(dead_code)]
pub fn default_pbrt_config() -> PbrtExportConfig {
    PbrtExportConfig {
        version: "3".to_string(),
        film_width: 1280,
        film_height: 720,
        samples_per_pixel: 64,
        output_filename: "output.exr".to_string(),
    }
}

/// Create a new, empty [`PbrtScene`] using the given config.
#[allow(dead_code)]
pub fn new_pbrt_scene(cfg: &PbrtExportConfig) -> PbrtScene {
    PbrtScene {
        config: cfg.clone(),
        shapes: Vec::new(),
        camera: None,
        infinite_lights: Vec::new(),
    }
}

/// Append a pre-built [`PbrtShape`] to the scene.
#[allow(dead_code)]
pub fn pbrt_add_shape(scene: &mut PbrtScene, shape: PbrtShape) {
    scene.shapes.push(shape);
}

/// Convenience: build a triangle-mesh shape and add it to the scene.
#[allow(dead_code)]
pub fn pbrt_add_triangle_mesh(
    scene: &mut PbrtScene,
    verts: &[[f32; 3]],
    faces: &[[u32; 3]],
    material: &str,
) {
    let shape = PbrtShape {
        shape_type: "trianglemesh".to_string(),
        vertices: verts.to_vec(),
        faces: faces.to_vec(),
        material_name: material.to_string(),
    };
    scene.shapes.push(shape);
}

/// Serialise the scene to an ASCII PBRT v3 string.
#[allow(dead_code)]
pub fn pbrt_to_string(scene: &PbrtScene) -> String {
    let mut out = String::new();

    // Header comment
    out.push_str(&format!(
        "# PBRT v{} scene generated by oxihuman-export\n\n",
        scene.config.version
    ));

    // Film directive
    out.push_str(&format!(
        "Film \"image\" \"integer xresolution\" [{xr}] \"integer yresolution\" [{yr}] \
         \"integer pixelsamples\" [{spp}] \"string filename\" [\"{fname}\"]\n\n",
        xr = scene.config.film_width,
        yr = scene.config.film_height,
        spp = scene.config.samples_per_pixel,
        fname = scene.config.output_filename,
    ));

    // Camera
    if let Some(cam) = &scene.camera {
        let p = cam.position;
        let l = cam.look_at;
        out.push_str(&format!(
            "LookAt {} {} {}  {} {} {}  0 1 0\n",
            p[0], p[1], p[2], l[0], l[1], l[2]
        ));
        out.push_str(&format!(
            "Camera \"perspective\" \"float fov\" [{}]\n\n",
            cam.fov_deg
        ));
    }

    out.push_str("WorldBegin\n\n");

    // Infinite lights
    for light in &scene.infinite_lights {
        out.push_str(&format!(
            "LightSource \"infinite\" \"rgb L\" [{v} {v} {v}]\n",
            v = light.intensity
        ));
    }
    if !scene.infinite_lights.is_empty() {
        out.push('\n');
    }

    // Shapes
    for shape in &scene.shapes {
        out.push_str(&format!(
            "NamedMaterial \"{}\"\n",
            shape.material_name
        ));
        out.push_str(&format!("Shape \"{}\"", shape.shape_type));

        if !shape.vertices.is_empty() {
            let pts: Vec<String> = shape
                .vertices
                .iter()
                .map(|v| format!("{} {} {}", v[0], v[1], v[2]))
                .collect();
            out.push_str(&format!(" \"point3 P\" [{}]", pts.join(" ")));
        }

        if !shape.faces.is_empty() {
            let idx: Vec<String> = shape
                .faces
                .iter()
                .flat_map(|f| [f[0].to_string(), f[1].to_string(), f[2].to_string()])
                .collect();
            out.push_str(&format!(" \"integer indices\" [{}]", idx.join(" ")));
        }

        out.push('\n');
    }

    out.push_str("\nWorldEnd\n");
    out
}

/// Write the PBRT scene to a file at `path`.
///
/// Returns `Err` with a message if writing fails.
#[allow(dead_code)]
pub fn pbrt_write_to_file(scene: &PbrtScene, path: &str) -> Result<(), String> {
    let content = pbrt_to_string(scene);
    std::fs::write(path, content).map_err(|e| e.to_string())
}

/// Return the number of shapes in the scene.
#[allow(dead_code)]
pub fn pbrt_shape_count(scene: &PbrtScene) -> usize {
    scene.shapes.len()
}

/// Set the scene camera.
#[allow(dead_code)]
pub fn pbrt_set_camera(
    scene: &mut PbrtScene,
    pos: [f32; 3],
    look_at: [f32; 3],
    fov_deg: f32,
) {
    scene.camera = Some(PbrtCamera {
        position: pos,
        look_at,
        fov_deg,
    });
}

/// Append an infinite area light with the given intensity.
#[allow(dead_code)]
pub fn pbrt_add_infinite_light(scene: &mut PbrtScene, intensity: f32) {
    scene.infinite_lights.push(PbrtInfiniteLight { intensity });
}

/// Remove all shapes and lights from the scene, keeping the config and camera.
#[allow(dead_code)]
pub fn pbrt_scene_clear(scene: &mut PbrtScene) {
    scene.shapes.clear();
    scene.infinite_lights.clear();
}

// ──────────────────────────────────────────────────────────────────────────────
// Tests
// ──────────────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_default_config() {
        let cfg = default_pbrt_config();
        assert_eq!(cfg.film_width, 1280);
        assert_eq!(cfg.film_height, 720);
        assert_eq!(cfg.samples_per_pixel, 64);
    }

    #[test]
    fn test_new_scene_empty() {
        let cfg = default_pbrt_config();
        let scene = new_pbrt_scene(&cfg);
        assert_eq!(pbrt_shape_count(&scene), 0);
        assert!(scene.camera.is_none());
        assert!(scene.infinite_lights.is_empty());
    }

    #[test]
    fn test_add_shape() {
        let cfg = default_pbrt_config();
        let mut scene = new_pbrt_scene(&cfg);
        let shape = PbrtShape {
            shape_type: "sphere".to_string(),
            vertices: Vec::new(),
            faces: Vec::new(),
            material_name: "matte".to_string(),
        };
        pbrt_add_shape(&mut scene, shape);
        assert_eq!(pbrt_shape_count(&scene), 1);
    }

    #[test]
    fn test_add_triangle_mesh() {
        let cfg = default_pbrt_config();
        let mut scene = new_pbrt_scene(&cfg);
        let verts = [[0.0f32, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
        let faces = [[0u32, 1, 2]];
        pbrt_add_triangle_mesh(&mut scene, &verts, &faces, "matte");
        assert_eq!(pbrt_shape_count(&scene), 1);
        assert_eq!(scene.shapes[0].vertices.len(), 3);
        assert_eq!(scene.shapes[0].faces.len(), 1);
    }

    #[test]
    fn test_pbrt_to_string_contains_world() {
        let cfg = default_pbrt_config();
        let scene = new_pbrt_scene(&cfg);
        let s = pbrt_to_string(&scene);
        assert!(s.contains("WorldBegin"));
        assert!(s.contains("WorldEnd"));
    }

    #[test]
    fn test_set_camera() {
        let cfg = default_pbrt_config();
        let mut scene = new_pbrt_scene(&cfg);
        pbrt_set_camera(&mut scene, [0.0, 1.0, -5.0], [0.0, 0.0, 0.0], 60.0);
        assert!(scene.camera.is_some());
        let cam = scene.camera.as_ref().expect("should succeed");
        assert!((cam.fov_deg - 60.0).abs() < 1e-5);
    }

    #[test]
    fn test_add_infinite_light() {
        let cfg = default_pbrt_config();
        let mut scene = new_pbrt_scene(&cfg);
        pbrt_add_infinite_light(&mut scene, 1.5);
        assert_eq!(scene.infinite_lights.len(), 1);
        assert!((scene.infinite_lights[0].intensity - 1.5).abs() < 1e-5);
    }

    #[test]
    fn test_scene_clear() {
        let cfg = default_pbrt_config();
        let mut scene = new_pbrt_scene(&cfg);
        let verts = [[0.0f32, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
        let faces = [[0u32, 1, 2]];
        pbrt_add_triangle_mesh(&mut scene, &verts, &faces, "matte");
        pbrt_add_infinite_light(&mut scene, 1.0);
        pbrt_scene_clear(&mut scene);
        assert_eq!(pbrt_shape_count(&scene), 0);
        assert!(scene.infinite_lights.is_empty());
    }

    #[test]
    fn test_to_string_has_film() {
        let cfg = default_pbrt_config();
        let scene = new_pbrt_scene(&cfg);
        let s = pbrt_to_string(&scene);
        assert!(s.contains("Film"));
        assert!(s.contains("1280"));
        assert!(s.contains("720"));
    }

    #[test]
    fn test_write_to_file() {
        let cfg = default_pbrt_config();
        let scene = new_pbrt_scene(&cfg);
        let path = "/tmp/pbrt_export_test_oxihuman.pbrt";
        let result = pbrt_write_to_file(&scene, path);
        assert!(result.is_ok());
        // Clean up
        let _ = std::fs::remove_file(path);
    }
}