Skip to main content

bevy_sensor/
lib.rs

1//! bevy-sensor: Multi-view rendering for YCB object dataset
2//!
3//! This library provides Bevy-based rendering of 3D objects from multiple viewpoints,
4//! designed to match TBP (Thousand Brains Project) habitat sensor conventions for
5//! use in neocortx sensorimotor learning experiments.
6//!
7//! # Headless Rendering (NEW)
8//!
9//! Render directly to memory buffers for use in sensorimotor learning:
10//!
11//! ```ignore
12//! use bevy_sensor::{render_to_buffer, RenderConfig, ViewpointConfig, ObjectRotation};
13//! use std::path::Path;
14//!
15//! let config = RenderConfig::tbp_default(); // 64x64, RGBD
16//! let viewpoint = bevy_sensor::generate_viewpoints(&ViewpointConfig::default())[0];
17//! let rotation = ObjectRotation::identity();
18//!
19//! let output = render_to_buffer(
20//!     Path::new("/tmp/ycb/003_cracker_box"),
21//!     &viewpoint,
22//!     &rotation,
23//!     &config,
24//! )?;
25//!
26//! // output.rgba: Vec<u8> - RGBA pixels (64*64*4 bytes)
27//! // output.depth: Vec<f32> - Depth values (64*64 floats)
28//! ```
29//!
30//! # File-based Capture (Legacy)
31//!
32//! ```ignore
33//! use bevy_sensor::{SensorConfig, ViewpointConfig, ObjectRotation};
34//!
35//! let config = SensorConfig {
36//!     viewpoints: ViewpointConfig::default(),
37//!     object_rotations: ObjectRotation::tbp_benchmark_rotations(),
38//!     ..Default::default()
39//! };
40//! ```
41//!
42//! # YCB Dataset
43//!
44//! Download YCB models programmatically:
45//!
46//! ```ignore
47//! use bevy_sensor::ycb::{download_models, Subset};
48//!
49//! // Download representative subset (3 objects)
50//! download_models("/tmp/ycb", Subset::Representative).await?;
51//! ```
52
53use bevy::prelude::*;
54use serde::{Deserialize, Serialize};
55use std::f32::consts::PI;
56use std::path::{Path, PathBuf};
57
58// Headless rendering implementation
59// Full GPU rendering requires a display - see render module for details
60mod render;
61
62// Batch rendering API for efficient multi-viewpoint rendering
63pub mod batch;
64
65// Benchmark helpers for renderer throughput artifacts
66pub mod benchmark;
67
68// WebGPU and cross-platform backend support
69pub mod backend;
70
71// Model caching system for efficient multi-viewpoint rendering
72pub mod cache;
73
74// Test fixtures for pre-rendered images (CI/CD support)
75pub mod fixtures;
76
77/// Stable renderer/targeting-policy version for cache manifests.
78pub const RENDERER_POLICY_VERSION: &str = "tbp-targeting-v1";
79
80// Re-export ycbust types for convenience
81pub use ycbust::{
82    self, DownloadOptions, Subset as YcbSubset, GOOGLE_16K_MESH_RELATIVE, REPRESENTATIVE_OBJECTS,
83    TBP_SIMILAR_OBJECTS, TBP_STANDARD_OBJECTS,
84};
85
86/// YCB dataset utilities
87pub mod ycb {
88    pub use ycbust::{
89        download_ycb, DownloadOptions, Subset, REPRESENTATIVE_OBJECTS, TBP_SIMILAR_OBJECTS,
90        TBP_STANDARD_OBJECTS,
91    };
92
93    use std::path::Path;
94
95    /// Download YCB models to the specified directory.
96    ///
97    /// # Arguments
98    /// * `output_dir` - Directory to download models to
99    /// * `subset` - Which subset of objects to download
100    ///
101    /// # Example
102    /// ```ignore
103    /// use bevy_sensor::ycb::{download_models, Subset};
104    ///
105    /// download_models("/tmp/ycb", Subset::Representative).await?;
106    /// ```
107    pub async fn download_models<P: AsRef<Path>>(
108        output_dir: P,
109        subset: Subset,
110    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
111        download_ycb(subset, output_dir.as_ref(), DownloadOptions::default()).await?;
112        Ok(())
113    }
114
115    /// Download YCB models with custom options.
116    pub async fn download_models_with_options<P: AsRef<Path>>(
117        output_dir: P,
118        subset: Subset,
119        options: DownloadOptions,
120    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
121        download_ycb(subset, output_dir.as_ref(), options).await?;
122        Ok(())
123    }
124
125    /// Download specific YCB objects by object ID using the standard `google_16k` meshes.
126    ///
127    /// Thin wrapper over [`ycbust::download_objects`] (added upstream in v0.3.3):
128    /// preserves this crate's ergonomic `P: AsRef<Path>` surface while delegating
129    /// skip / resume / integrity / parallelism to the upstream implementation.
130    pub async fn download_objects<P: AsRef<Path>>(
131        output_dir: P,
132        object_ids: &[&str],
133    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
134        ycbust::download_objects(object_ids, output_dir.as_ref(), DownloadOptions::default())
135            .await?;
136        Ok(())
137    }
138
139    /// Return object IDs whose standard `google_16k` mesh or texture is missing.
140    pub fn missing_objects<P: AsRef<Path>>(output_dir: P, object_ids: &[&str]) -> Vec<String> {
141        ycbust::validate_objects(output_dir.as_ref(), object_ids)
142            .into_iter()
143            .filter(|validation| !validation.is_complete())
144            .map(|validation| validation.name)
145            .collect()
146    }
147
148    /// Check if all requested YCB objects exist at the given path.
149    pub fn objects_exist<P: AsRef<Path>>(output_dir: P, object_ids: &[&str]) -> bool {
150        missing_objects(output_dir, object_ids).is_empty()
151    }
152
153    /// Check if the representative YCB models exist at the given path.
154    pub fn models_exist<P: AsRef<Path>>(output_dir: P) -> bool {
155        objects_exist(output_dir, REPRESENTATIVE_OBJECTS)
156    }
157
158    /// Get the path to a specific YCB object's OBJ file
159    pub fn object_mesh_path<P: AsRef<Path>>(output_dir: P, object_id: &str) -> std::path::PathBuf {
160        ycbust::object_mesh_path(output_dir.as_ref(), object_id)
161    }
162
163    /// Get the path to a specific YCB object's texture file
164    pub fn object_texture_path<P: AsRef<Path>>(
165        output_dir: P,
166        object_id: &str,
167    ) -> std::path::PathBuf {
168        ycbust::object_texture_path(output_dir.as_ref(), object_id)
169    }
170}
171
172/// Initialize bevy-sensor rendering backend configuration.
173///
174/// **IMPORTANT**: Call this function ONCE at the start of your application,
175/// before any rendering operations, especially when using bevy-sensor as a library.
176///
177/// This ensures proper backend selection (WebGPU for WSL2, Vulkan for Linux, etc.)
178/// and is critical for GPU rendering on WSL2 environments.
179///
180/// # Why This Matters
181///
182/// The WGPU rendering backend caches its backend selection early during initialization.
183/// When bevy-sensor is used as a library, environment variables must be set BEFORE
184/// any GPU rendering code runs. This function does that automatically.
185///
186/// # Example
187///
188/// ```ignore
189/// use bevy_sensor;
190///
191/// fn main() {
192///     // Initialize FIRST, before any rendering
193///     bevy_sensor::initialize();
194///
195///     // Now use the rendering API
196///     let output = bevy_sensor::render_to_buffer(
197///         object_dir, &viewpoint, &rotation, &config
198///     )?;
199/// }
200/// ```
201///
202/// # Calling Multiple Times
203///
204/// Safe to call multiple times - subsequent calls are no-ops after the first call.
205pub fn initialize() {
206    // Use a OnceCell equivalent to ensure this only runs once
207    use std::sync::atomic::{AtomicBool, Ordering};
208    static INITIALIZED: AtomicBool = AtomicBool::new(false);
209
210    if !INITIALIZED.swap(true, Ordering::SeqCst) {
211        // First call - initialize backend
212        let config = backend::BackendConfig::new();
213        config.apply_env();
214    }
215}
216
217/// Object rotation in Euler angles (degrees), matching TBP benchmark format.
218/// Format: [pitch, yaw, roll] or [x, y, z] rotation.
219#[derive(Clone, Debug, PartialEq)]
220pub struct ObjectRotation {
221    /// Rotation around X-axis (pitch) in degrees
222    pub pitch: f64,
223    /// Rotation around Y-axis (yaw) in degrees
224    pub yaw: f64,
225    /// Rotation around Z-axis (roll) in degrees
226    pub roll: f64,
227}
228
229impl ObjectRotation {
230    /// Create a new rotation from Euler angles in degrees
231    pub fn new(pitch: f64, yaw: f64, roll: f64) -> Self {
232        Self { pitch, yaw, roll }
233    }
234
235    /// Create from TBP-style array [pitch, yaw, roll] in degrees
236    pub fn from_array(arr: [f64; 3]) -> Self {
237        Self {
238            pitch: arr[0],
239            yaw: arr[1],
240            roll: arr[2],
241        }
242    }
243
244    /// Identity rotation (no rotation)
245    pub fn identity() -> Self {
246        Self::new(0.0, 0.0, 0.0)
247    }
248
249    /// TBP benchmark rotations: [0,0,0], [0,90,0], [0,180,0]
250    /// Used in shorter YCB experiments to reduce computational load.
251    pub fn tbp_benchmark_rotations() -> Vec<Self> {
252        vec![
253            Self::from_array([0.0, 0.0, 0.0]),
254            Self::from_array([0.0, 90.0, 0.0]),
255            Self::from_array([0.0, 180.0, 0.0]),
256        ]
257    }
258
259    /// TBP 14 known orientations (cube faces and corners)
260    /// These are the orientations objects are learned in during training.
261    pub fn tbp_known_orientations() -> Vec<Self> {
262        vec![
263            // 6 cube faces (90° rotations around each axis)
264            Self::from_array([0.0, 0.0, 0.0]),   // Front
265            Self::from_array([0.0, 90.0, 0.0]),  // Right
266            Self::from_array([0.0, 180.0, 0.0]), // Back
267            Self::from_array([0.0, 270.0, 0.0]), // Left
268            Self::from_array([90.0, 0.0, 0.0]),  // Top
269            Self::from_array([-90.0, 0.0, 0.0]), // Bottom
270            // 8 cube corners (45° rotations)
271            Self::from_array([45.0, 45.0, 0.0]),
272            Self::from_array([45.0, 135.0, 0.0]),
273            Self::from_array([45.0, 225.0, 0.0]),
274            Self::from_array([45.0, 315.0, 0.0]),
275            Self::from_array([-45.0, 45.0, 0.0]),
276            Self::from_array([-45.0, 135.0, 0.0]),
277            Self::from_array([-45.0, 225.0, 0.0]),
278            Self::from_array([-45.0, 315.0, 0.0]),
279        ]
280    }
281
282    /// Convert to Bevy Quat (converts f64 to f32 for Bevy compatibility)
283    pub fn to_quat(&self) -> Quat {
284        Quat::from_euler(
285            EulerRot::XYZ,
286            (self.pitch as f32).to_radians(),
287            (self.yaw as f32).to_radians(),
288            (self.roll as f32).to_radians(),
289        )
290    }
291
292    /// Convert to Bevy Transform (rotation only, no translation)
293    pub fn to_transform(&self) -> Transform {
294        Transform::from_rotation(self.to_quat())
295    }
296
297    /// Convert to a full Bevy Transform with caller-provided translation and scale.
298    pub fn to_transform_with_translation_scale(&self, translation: Vec3, scale: Vec3) -> Transform {
299        Transform {
300            translation,
301            rotation: self.to_quat(),
302            scale,
303        }
304    }
305}
306
307impl Default for ObjectRotation {
308    fn default() -> Self {
309        Self::identity()
310    }
311}
312
313/// Configuration for viewpoint generation matching TBP habitat sensor behavior.
314/// Uses spherical coordinates to capture objects from multiple elevations.
315#[derive(Clone, Debug)]
316pub struct ViewpointConfig {
317    /// Distance from camera to object center (meters)
318    pub radius: f32,
319    /// Number of horizontal positions (yaw angles) around the object
320    pub yaw_count: usize,
321    /// Elevation angles in degrees (pitch). Positive = above, negative = below.
322    pub pitch_angles_deg: Vec<f32>,
323}
324
325impl Default for ViewpointConfig {
326    fn default() -> Self {
327        Self {
328            radius: 0.5,
329            yaw_count: 8,
330            // Three elevations: below (-30°), level (0°), above (+30°)
331            // This matches TBP's look_up/look_down capability
332            pitch_angles_deg: vec![-30.0, 0.0, 30.0],
333        }
334    }
335}
336
337impl ViewpointConfig {
338    /// Total number of viewpoints this config will generate
339    pub fn viewpoint_count(&self) -> usize {
340        self.yaw_count * self.pitch_angles_deg.len()
341    }
342}
343
344/// Axis-aligned mesh bounds in object-local coordinates.
345#[derive(Clone, Copy, Debug, PartialEq)]
346pub struct MeshBounds {
347    /// Minimum object-local vertex coordinate.
348    pub min: Vec3,
349    /// Maximum object-local vertex coordinate.
350    pub max: Vec3,
351    /// Center of the axis-aligned bounding box.
352    pub center: Vec3,
353    /// Number of vertices inspected while computing the bounds.
354    pub vertex_count: usize,
355}
356
357impl MeshBounds {
358    /// Size of the axis-aligned bounding box on each axis.
359    pub fn extents(&self) -> Vec3 {
360        self.max - self.min
361    }
362}
363
364/// Render-target selection policy for TBP/YCB camera orbits.
365#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
366#[serde(tag = "policy", content = "target", rename_all = "snake_case")]
367pub enum TargetingPolicy {
368    /// Preserve historical behavior: camera viewpoints look at world origin.
369    Origin,
370    /// Load the YCB mesh AABB center and rotate it by the object rotation.
371    MeshCenter,
372    /// Use a caller-provided world target point.
373    ExplicitTarget([f32; 3]),
374}
375
376impl TargetingPolicy {
377    /// Stable label for manifests and logs.
378    pub fn label(&self) -> &'static str {
379        match self {
380            TargetingPolicy::Origin => "origin",
381            TargetingPolicy::MeshCenter => "mesh-center",
382            TargetingPolicy::ExplicitTarget(_) => "explicit-target",
383        }
384    }
385}
386
387/// Generated viewpoints plus the target metadata used to create them.
388#[derive(Clone, Debug, PartialEq)]
389pub struct TargetedViewpoints {
390    /// Targeting policy used for this viewpoint set.
391    pub policy: TargetingPolicy,
392    /// Point every viewpoint looks at in world coordinates.
393    pub target_point: Vec3,
394    /// Mesh bounds when the policy required loading object-local bounds.
395    pub mesh_bounds: Option<MeshBounds>,
396    /// Camera viewpoints for the selected policy.
397    pub viewpoints: Vec<Transform>,
398}
399
400/// Full sensor configuration for capture sessions
401#[derive(Clone, Debug, Resource)]
402pub struct SensorConfig {
403    /// Viewpoint configuration (camera positions)
404    pub viewpoints: ViewpointConfig,
405    /// Object rotations to capture (each rotation generates a full viewpoint set)
406    pub object_rotations: Vec<ObjectRotation>,
407    /// Output directory for captures
408    pub output_dir: String,
409    /// Filename pattern (use {view} for view index, {rot} for rotation index)
410    pub filename_pattern: String,
411}
412
413impl Default for SensorConfig {
414    fn default() -> Self {
415        Self {
416            viewpoints: ViewpointConfig::default(),
417            object_rotations: vec![ObjectRotation::identity()],
418            output_dir: ".".to_string(),
419            filename_pattern: "capture_{rot}_{view}.png".to_string(),
420        }
421    }
422}
423
424impl SensorConfig {
425    /// Create config for TBP benchmark comparison (3 rotations × 24 viewpoints = 72 captures)
426    pub fn tbp_benchmark() -> Self {
427        Self {
428            viewpoints: ViewpointConfig::default(),
429            object_rotations: ObjectRotation::tbp_benchmark_rotations(),
430            output_dir: ".".to_string(),
431            filename_pattern: "capture_{rot}_{view}.png".to_string(),
432        }
433    }
434
435    /// Create config for full TBP training (14 rotations × 24 viewpoints = 336 captures)
436    pub fn tbp_full_training() -> Self {
437        Self {
438            viewpoints: ViewpointConfig::default(),
439            object_rotations: ObjectRotation::tbp_known_orientations(),
440            output_dir: ".".to_string(),
441            filename_pattern: "capture_{rot}_{view}.png".to_string(),
442        }
443    }
444
445    /// Total number of captures this config will generate
446    pub fn total_captures(&self) -> usize {
447        self.viewpoints.viewpoint_count() * self.object_rotations.len()
448    }
449}
450
451/// Generate camera viewpoints using spherical coordinates.
452///
453/// Spherical coordinate system (matching TBP habitat sensor conventions):
454/// - Yaw: horizontal rotation around Y-axis (0° to 360°)
455/// - Pitch: elevation angle from horizontal plane (-90° to +90°)
456/// - Radius: distance from origin (object center)
457pub fn generate_viewpoints(config: &ViewpointConfig) -> Vec<Transform> {
458    generate_viewpoints_around_target(config, Vec3::ZERO)
459}
460
461/// Generate camera viewpoints around an explicit target point.
462///
463/// The generated camera offsets match [`generate_viewpoints`], but each camera
464/// is translated by `target` and rotated to look at that target. This is the
465/// caller-provided target form used by NeoCortx parity probes that should not
466/// assume the object surface of interest is at the world origin.
467pub fn generate_viewpoints_around_target(config: &ViewpointConfig, target: Vec3) -> Vec<Transform> {
468    let mut views = Vec::with_capacity(config.viewpoint_count());
469
470    for pitch_deg in &config.pitch_angles_deg {
471        let pitch = pitch_deg.to_radians();
472
473        for i in 0..config.yaw_count {
474            let yaw = (i as f32) * 2.0 * PI / (config.yaw_count as f32);
475
476            // Spherical to Cartesian conversion (Y-up coordinate system)
477            // x = r * cos(pitch) * sin(yaw)
478            // y = r * sin(pitch)
479            // z = r * cos(pitch) * cos(yaw)
480            let x = config.radius * pitch.cos() * yaw.sin();
481            let y = config.radius * pitch.sin();
482            let z = config.radius * pitch.cos() * yaw.cos();
483
484            let translation = target + Vec3::new(x, y, z);
485            let transform = Transform::from_translation(translation).looking_at(target, Vec3::Y);
486            views.push(transform);
487        }
488    }
489    views
490}
491
492/// Rotate an object-local mesh center into the rendered world frame.
493///
494/// This uses the same object-rotation convention as rendering itself
495/// (`ObjectRotation::to_quat`). It intentionally applies yaw-only rotations as
496/// well as pitch/roll rotations, so downstream parity code does not need a
497/// temporary special case for centered YCB renders.
498pub fn rotated_mesh_center(mesh_center: Vec3, object_rotation: &ObjectRotation) -> Vec3 {
499    object_rotation.to_quat() * mesh_center
500}
501
502/// Generate TBP viewpoint transforms around a rotated object mesh center.
503///
504/// Use this when the YCB mesh's AABB center is a better render target than the
505/// source origin. The camera orbit remains exactly the same shape as
506/// [`generate_viewpoints`], but centered on `object_rotation * mesh_center`.
507pub fn generate_object_centered_viewpoints(
508    config: &ViewpointConfig,
509    mesh_center: Vec3,
510    object_rotation: &ObjectRotation,
511) -> Vec<Transform> {
512    generate_viewpoints_around_target(config, rotated_mesh_center(mesh_center, object_rotation))
513}
514
515/// Load axis-aligned bounds from an OBJ mesh.
516///
517/// This is a small public wrapper around the same YCB `google_16k/textured.obj`
518/// layout used by the renderer. It lets downstream callers avoid carrying their
519/// own OBJ parsing just to target an object's visual center.
520pub fn load_mesh_bounds(mesh_path: &Path) -> Result<MeshBounds, RenderError> {
521    if !mesh_path.exists() {
522        return Err(RenderError::MeshNotFound(mesh_path.display().to_string()));
523    }
524
525    let (models, _) = tobj::load_obj(
526        mesh_path,
527        &tobj::LoadOptions {
528            triangulate: false,
529            single_index: true,
530            ..Default::default()
531        },
532    )
533    .map_err(|err| {
534        RenderError::DataParsingError(format!(
535            "Failed to parse OBJ mesh {}: {}",
536            mesh_path.display(),
537            err
538        ))
539    })?;
540
541    let mut min = Vec3::splat(f32::INFINITY);
542    let mut max = Vec3::splat(f32::NEG_INFINITY);
543    let mut vertex_count = 0usize;
544
545    for model in models {
546        for vertex in model.mesh.positions.chunks_exact(3) {
547            let point = Vec3::new(vertex[0], vertex[1], vertex[2]);
548            min = min.min(point);
549            max = max.max(point);
550            vertex_count += 1;
551        }
552    }
553
554    if vertex_count == 0 {
555        return Err(RenderError::DataParsingError(format!(
556            "OBJ mesh {} contains no vertices",
557            mesh_path.display()
558        )));
559    }
560
561    Ok(MeshBounds {
562        min,
563        max,
564        center: (min + max) * 0.5,
565        vertex_count,
566    })
567}
568
569/// Load bounds for a YCB object directory using the standard google_16k mesh.
570pub fn load_ycb_mesh_bounds(object_dir: &Path) -> Result<MeshBounds, RenderError> {
571    load_mesh_bounds(&object_dir.join(GOOGLE_16K_MESH_RELATIVE))
572}
573
574/// Generate object-centered TBP viewpoints for a YCB object directory.
575pub fn generate_ycb_object_centered_viewpoints(
576    object_dir: &Path,
577    config: &ViewpointConfig,
578    object_rotation: &ObjectRotation,
579) -> Result<Vec<Transform>, RenderError> {
580    let bounds = load_ycb_mesh_bounds(object_dir)?;
581    Ok(generate_object_centered_viewpoints(
582        config,
583        bounds.center,
584        object_rotation,
585    ))
586}
587
588/// Generate viewpoints for a requested targeting policy.
589pub fn generate_targeted_viewpoints(
590    object_dir: &Path,
591    config: &ViewpointConfig,
592    object_rotation: &ObjectRotation,
593    policy: &TargetingPolicy,
594) -> Result<TargetedViewpoints, RenderError> {
595    match policy {
596        TargetingPolicy::Origin => Ok(TargetedViewpoints {
597            policy: policy.clone(),
598            target_point: Vec3::ZERO,
599            mesh_bounds: None,
600            viewpoints: generate_viewpoints(config),
601        }),
602        TargetingPolicy::MeshCenter => {
603            let bounds = load_ycb_mesh_bounds(object_dir)?;
604            let target_point = rotated_mesh_center(bounds.center, object_rotation);
605            Ok(TargetedViewpoints {
606                policy: policy.clone(),
607                target_point,
608                mesh_bounds: Some(bounds),
609                viewpoints: generate_viewpoints_around_target(config, target_point),
610            })
611        }
612        TargetingPolicy::ExplicitTarget(target) => {
613            let target_point = Vec3::from_array(*target);
614            Ok(TargetedViewpoints {
615                policy: policy.clone(),
616                target_point,
617                mesh_bounds: None,
618                viewpoints: generate_viewpoints_around_target(config, target_point),
619            })
620        }
621    }
622}
623
624/// Marker component for the target object being captured
625#[derive(Component)]
626pub struct CaptureTarget;
627
628/// Marker component for the capture camera
629#[derive(Component)]
630pub struct CaptureCamera;
631
632// ============================================================================
633// Headless Rendering API (NEW)
634// ============================================================================
635
636/// Configuration for headless rendering.
637///
638/// Matches TBP habitat sensor defaults: 64x64 resolution with RGBD output.
639#[derive(Clone, Debug, PartialEq)]
640pub struct RenderConfig {
641    /// Image width in pixels (default: 64)
642    pub width: u32,
643    /// Image height in pixels (default: 64)
644    pub height: u32,
645    /// Zoom factor affecting field of view (`tbp_default`: 4.0)
646    /// Use >1 to zoom in (narrower FOV), <1 to zoom out (wider FOV)
647    pub zoom: f32,
648    /// Near clipping plane in meters (default: 0.01)
649    pub near_plane: f32,
650    /// Far clipping plane in meters (default: 10.0)
651    pub far_plane: f32,
652    /// Lighting configuration
653    pub lighting: LightingConfig,
654}
655
656/// Lighting configuration for rendering.
657///
658/// Controls ambient light and point lights in the scene.
659#[derive(Clone, Debug, PartialEq)]
660pub struct LightingConfig {
661    /// Ambient light brightness (0.0 - 1.0, default: 0.3)
662    pub ambient_brightness: f32,
663    /// Key light intensity in lumens (default: 1500.0)
664    pub key_light_intensity: f32,
665    /// Key light position [x, y, z] (default: [4.0, 8.0, 4.0])
666    pub key_light_position: [f32; 3],
667    /// Fill light intensity in lumens (default: 500.0)
668    pub fill_light_intensity: f32,
669    /// Fill light position [x, y, z] (default: [-4.0, 2.0, -4.0])
670    pub fill_light_position: [f32; 3],
671    /// Enable shadows (default: false for performance)
672    pub shadows_enabled: bool,
673}
674
675impl Default for LightingConfig {
676    fn default() -> Self {
677        Self {
678            ambient_brightness: 0.3,
679            key_light_intensity: 1500.0,
680            key_light_position: [4.0, 8.0, 4.0],
681            fill_light_intensity: 500.0,
682            fill_light_position: [-4.0, 2.0, -4.0],
683            shadows_enabled: false,
684        }
685    }
686}
687
688impl LightingConfig {
689    /// Bright lighting for clear visibility
690    pub fn bright() -> Self {
691        Self {
692            ambient_brightness: 0.5,
693            key_light_intensity: 2000.0,
694            key_light_position: [4.0, 8.0, 4.0],
695            fill_light_intensity: 800.0,
696            fill_light_position: [-4.0, 2.0, -4.0],
697            shadows_enabled: false,
698        }
699    }
700
701    /// Soft lighting with minimal shadows
702    pub fn soft() -> Self {
703        Self {
704            ambient_brightness: 0.4,
705            key_light_intensity: 1000.0,
706            key_light_position: [3.0, 6.0, 3.0],
707            fill_light_intensity: 600.0,
708            fill_light_position: [-3.0, 3.0, -3.0],
709            shadows_enabled: false,
710        }
711    }
712
713    /// Unlit mode - ambient only, no point lights
714    pub fn unlit() -> Self {
715        Self {
716            ambient_brightness: 1.0,
717            key_light_intensity: 0.0,
718            key_light_position: [0.0, 0.0, 0.0],
719            fill_light_intensity: 0.0,
720            fill_light_position: [0.0, 0.0, 0.0],
721            shadows_enabled: false,
722        }
723    }
724}
725
726impl Default for RenderConfig {
727    fn default() -> Self {
728        Self::tbp_default()
729    }
730}
731
732impl RenderConfig {
733    /// TBP-compatible 64x64 RGBD patch sensor configuration.
734    ///
735    /// Uses TBP's 90° base-HFOV zoom formula with a 64x64 patch render. TBP's
736    /// Habitat patch sensor uses zoom=10 with a separate viewfinder; the current
737    /// single-sensor YCB benchmark keeps zoom=4 for centering stability.
738    ///
739    /// TBP ref: `missing_depthto3d_sensor2_semantic0.yaml` (zoom=10 upstream)
740    pub fn tbp_default() -> Self {
741        Self {
742            width: 64,
743            height: 64,
744            zoom: 4.0,
745            near_plane: 0.01,
746            far_plane: 10.0,
747            lighting: LightingConfig::default(),
748        }
749    }
750
751    /// Higher resolution configuration for debugging and visualization.
752    pub fn preview() -> Self {
753        Self {
754            width: 256,
755            height: 256,
756            zoom: 1.0,
757            near_plane: 0.01,
758            far_plane: 10.0,
759            lighting: LightingConfig::default(),
760        }
761    }
762
763    /// High resolution configuration for detailed captures.
764    pub fn high_res() -> Self {
765        Self {
766            width: 512,
767            height: 512,
768            zoom: 1.0,
769            near_plane: 0.01,
770            far_plane: 10.0,
771            lighting: LightingConfig::default(),
772        }
773    }
774
775    /// Calculate vertical field of view in radians based on zoom.
776    ///
777    /// TBP zooms by dividing the focal length, not the angle:
778    ///   `fx_norm = tan(hfov/2) / zoom`
779    /// This is equivalent to `fov = 2 * atan(tan(hfov/2) / zoom)`.
780    /// With hfov=90° and zoom=10, effective FOV ≈ 11.4° (not 9°).
781    pub fn fov_radians(&self) -> f32 {
782        let base_hfov_rad = 90.0_f32.to_radians();
783        let half_tan = (base_hfov_rad / 2.0).tan() / self.zoom;
784        2.0 * half_tan.atan()
785    }
786
787    /// Compute camera intrinsics for use with neocortx.
788    ///
789    /// Returns focal length and principal point based on resolution and FOV.
790    /// Matches TBP Python: `fx = tan(hfov/2) / zoom` in normalized [-1,1] space,
791    /// converted to pixel space: `fx_pixel = (width/2) / fx_normalized`.
792    ///
793    /// TBP ref: `transforms.py:440` `fx = np.tan(hfov[i] / 2.0) / zoom`
794    pub fn intrinsics(&self) -> CameraIntrinsics {
795        self.intrinsics_for_size(self.width, self.height)
796    }
797
798    /// Compute camera intrinsics for a concrete render target size.
799    ///
800    /// This keeps readback metadata aligned with the actual image dimensions
801    /// while preserving TBP's focal-length-space zoom formula.
802    pub fn intrinsics_for_size(&self, width: u32, height: u32) -> CameraIntrinsics {
803        let base_hfov_rad = 90.0_f64.to_radians();
804        // TBP normalized focal length: fx_norm = tan(hfov/2) / zoom
805        let fx_norm = (base_hfov_rad / 2.0).tan() / self.zoom as f64;
806        // Convert to pixel focal length: fx_pixel = (width/2) / fx_norm
807        let fx = (width as f64 / 2.0) / fx_norm;
808        let fy = fx; // Square pixels (TBP adjusts fy for aspect ratio, but we use 64x64)
809
810        CameraIntrinsics {
811            focal_length: [fx, fy],
812            principal_point: [width as f64 / 2.0, height as f64 / 2.0],
813            image_size: [width, height],
814        }
815    }
816}
817
818/// Camera intrinsic parameters for 3D reconstruction.
819///
820/// Compatible with neocortx's VisionIntrinsics format.
821/// Uses f64 for TBP numerical precision compatibility.
822#[derive(Clone, Debug, PartialEq)]
823pub struct CameraIntrinsics {
824    /// Focal length in pixels (fx, fy)
825    pub focal_length: [f64; 2],
826    /// Principal point (cx, cy) - typically image center
827    pub principal_point: [f64; 2],
828    /// Image dimensions (width, height)
829    pub image_size: [u32; 2],
830}
831
832impl CameraIntrinsics {
833    /// Project a 3D point to 2D pixel coordinates.
834    pub fn project(&self, point: Vec3) -> Option<[f64; 2]> {
835        if point.z <= 0.0 {
836            return None;
837        }
838        let x = (point.x as f64 / point.z as f64) * self.focal_length[0] + self.principal_point[0];
839        let y = (point.y as f64 / point.z as f64) * self.focal_length[1] + self.principal_point[1];
840        Some([x, y])
841    }
842
843    /// Unproject a 2D pixel to a 3D point at given depth.
844    pub fn unproject(&self, pixel: [f64; 2], depth: f64) -> [f64; 3] {
845        let x = (pixel[0] - self.principal_point[0]) / self.focal_length[0] * depth;
846        let y = (pixel[1] - self.principal_point[1]) / self.focal_length[1] * depth;
847        [x, y, depth]
848    }
849}
850
851/// Cheap diagnostics derived from a rendered depth buffer.
852#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
853pub struct RenderHealth {
854    /// Center pixel selected from camera intrinsics, clamped to image bounds.
855    pub center_pixel: Option<[u32; 2]>,
856    /// Raw depth at the center pixel, including far-plane/background values.
857    pub center_depth: Option<f64>,
858    /// Whether the center pixel has a finite positive depth before the far plane.
859    pub center_foreground: bool,
860    /// Number of foreground pixels in the full depth buffer.
861    pub foreground_pixel_count: usize,
862    /// Foreground fraction in `[0, 1]` over the declared image size.
863    pub foreground_coverage: f64,
864    /// Number of foreground pixels in the 5x5 window centered on `center_pixel`.
865    pub center_5x5_foreground_count: usize,
866    /// Foreground pixel nearest to `center_pixel`, if any foreground exists.
867    pub nearest_foreground_pixel: Option<[u32; 2]>,
868    /// Depth at `nearest_foreground_pixel`.
869    pub nearest_foreground_depth: Option<f64>,
870    /// Euclidean pixel distance from `center_pixel` to `nearest_foreground_pixel`.
871    pub nearest_foreground_distance_px: Option<f64>,
872}
873
874/// Output from headless rendering containing RGBA and depth data.
875#[derive(Clone, Debug)]
876pub struct RenderOutput {
877    /// RGBA pixel data in row-major order (width * height * 4 bytes)
878    pub rgba: Vec<u8>,
879    /// Depth values in meters, row-major order (width * height f64s)
880    /// Values are linear depth from camera, not normalized.
881    /// Uses f64 for TBP numerical precision compatibility.
882    pub depth: Vec<f64>,
883    /// Image width in pixels
884    pub width: u32,
885    /// Image height in pixels
886    pub height: u32,
887    /// Camera intrinsics used for this render
888    pub intrinsics: CameraIntrinsics,
889    /// Camera transform (world position and orientation)
890    pub camera_transform: Transform,
891    /// Object rotation applied during render
892    pub object_rotation: ObjectRotation,
893    /// Object world translation applied during render
894    pub object_translation: Vec3,
895    /// Object scale applied during render
896    pub object_scale: Vec3,
897    /// Point the camera was intended to target for this render.
898    pub target_point: Vec3,
899    /// Policy used to derive `target_point`.
900    pub targeting_policy: TargetingPolicy,
901}
902
903pub(crate) fn semantic_3d_from_depth(
904    depth: &[f64],
905    width: u32,
906    height: u32,
907    intrinsics: &CameraIntrinsics,
908    camera_transform: Transform,
909    object_semantic_id: u32,
910    far_plane: f64,
911) -> Vec<[f64; 4]> {
912    let total_pixels = (width as usize).saturating_mul(height as usize);
913    let mut rows = Vec::with_capacity(total_pixels);
914    for y in 0..height {
915        for x in 0..width {
916            let idx = (y * width + x) as usize;
917            let Some(&pixel_depth) = depth.get(idx) else {
918                rows.push([0.0, 0.0, 0.0, 0.0]);
919                continue;
920            };
921            let Some(world) = pixel_surface_point_world_from_parts(
922                pixel_depth,
923                [x, y],
924                intrinsics,
925                camera_transform,
926                far_plane,
927            ) else {
928                rows.push([0.0, 0.0, 0.0, 0.0]);
929                continue;
930            };
931            rows.push([world[0], world[1], world[2], object_semantic_id as f64]);
932        }
933    }
934    rows
935}
936
937fn pixel_surface_point_world_from_parts(
938    depth: f64,
939    pixel: [u32; 2],
940    intrinsics: &CameraIntrinsics,
941    camera_transform: Transform,
942    far_plane: f64,
943) -> Option<[f64; 3]> {
944    if !RenderOutput::is_foreground_depth(depth, far_plane) {
945        return None;
946    }
947
948    let fx = intrinsics.focal_length[0];
949    let fy = intrinsics.focal_length[1];
950    if !fx.is_finite() || !fy.is_finite() || fx.abs() <= f64::EPSILON || fy.abs() <= f64::EPSILON {
951        return None;
952    }
953
954    let [x, y] = pixel;
955    let camera_x = (x as f64 - intrinsics.principal_point[0]) / fx * depth;
956    let camera_y = -((y as f64 - intrinsics.principal_point[1]) / fy * depth);
957    let point = Vec3::new(camera_x as f32, camera_y as f32, -depth as f32);
958    let world = camera_transform.translation + camera_transform.rotation * point;
959    Some([world.x as f64, world.y as f64, world.z as f64])
960}
961
962impl RenderOutput {
963    /// Default far plane used by TBP render helpers.
964    pub const TBP_FAR_PLANE_METERS: f64 = 10.0;
965
966    /// Attach the render target metadata used to generate this camera transform.
967    pub fn with_targeting(mut self, target_point: Vec3, targeting_policy: TargetingPolicy) -> Self {
968        self.target_point = target_point;
969        self.targeting_policy = targeting_policy;
970        self
971    }
972
973    /// Attach the object translation/scale metadata used for the render.
974    pub fn with_object_transform(mut self, object_translation: Vec3, object_scale: Vec3) -> Self {
975        self.object_translation = object_translation;
976        self.object_scale = object_scale;
977        self
978    }
979
980    /// Get RGBA pixel at (x, y). Returns None if out of bounds.
981    pub fn get_rgba(&self, x: u32, y: u32) -> Option<[u8; 4]> {
982        if x >= self.width || y >= self.height {
983            return None;
984        }
985        let idx = ((y * self.width + x) * 4) as usize;
986        Some([
987            self.rgba[idx],
988            self.rgba[idx + 1],
989            self.rgba[idx + 2],
990            self.rgba[idx + 3],
991        ])
992    }
993
994    /// Get depth value at (x, y) in meters. Returns None if out of bounds.
995    pub fn get_depth(&self, x: u32, y: u32) -> Option<f64> {
996        if x >= self.width || y >= self.height {
997            return None;
998        }
999        let idx = (y * self.width + x) as usize;
1000        Some(self.depth[idx])
1001    }
1002
1003    /// Get RGB pixel (without alpha) at (x, y).
1004    pub fn get_rgb(&self, x: u32, y: u32) -> Option<[u8; 3]> {
1005        self.get_rgba(x, y).map(|rgba| [rgba[0], rgba[1], rgba[2]])
1006    }
1007
1008    /// Pixel nearest the camera principal point, clamped to image bounds.
1009    pub fn center_pixel(&self) -> Option<[u32; 2]> {
1010        if self.width == 0 || self.height == 0 {
1011            return None;
1012        }
1013
1014        let x = self.intrinsics.principal_point[0]
1015            .round()
1016            .clamp(0.0, (self.width - 1) as f64) as u32;
1017        let y = self.intrinsics.principal_point[1]
1018            .round()
1019            .clamp(0.0, (self.height - 1) as f64) as u32;
1020        Some([x, y])
1021    }
1022
1023    /// Raw center-pixel depth, including far-plane/background values.
1024    pub fn center_pixel_raw_depth(&self) -> Option<f64> {
1025        let [x, y] = self.center_pixel()?;
1026        self.get_depth(x, y)
1027    }
1028
1029    /// Center-pixel object depth using the TBP default far plane.
1030    pub fn center_pixel_depth(&self) -> Option<f64> {
1031        self.center_pixel_depth_with_far_plane(Self::TBP_FAR_PLANE_METERS)
1032    }
1033
1034    /// Center-pixel object depth using a caller-provided far plane.
1035    pub fn center_pixel_depth_with_far_plane(&self, far_plane: f64) -> Option<f64> {
1036        self.center_pixel_raw_depth()
1037            .filter(|depth| Self::is_foreground_depth(*depth, far_plane))
1038    }
1039
1040    /// Whether a depth value should be treated as foreground/object surface.
1041    pub fn is_foreground_depth(depth: f64, far_plane: f64) -> bool {
1042        depth.is_finite() && depth > 0.0 && far_plane.is_finite() && depth < far_plane * 0.999
1043    }
1044
1045    /// Compute render-health diagnostics using the TBP default far plane.
1046    pub fn health(&self) -> RenderHealth {
1047        self.health_with_far_plane(Self::TBP_FAR_PLANE_METERS)
1048    }
1049
1050    /// Compute render-health diagnostics using a caller-provided far plane.
1051    pub fn health_with_far_plane(&self, far_plane: f64) -> RenderHealth {
1052        let center_pixel = self.center_pixel();
1053        let center_depth = self.center_pixel_raw_depth();
1054        let center_foreground = center_depth
1055            .map(|depth| Self::is_foreground_depth(depth, far_plane))
1056            .unwrap_or(false);
1057
1058        let total_pixels = (self.width as usize).saturating_mul(self.height as usize);
1059        let mut foreground_pixel_count = 0usize;
1060        let mut center_5x5_foreground_count = 0usize;
1061        let mut nearest_foreground_pixel = None;
1062        let mut nearest_foreground_depth = None;
1063        let mut nearest_foreground_distance_px = None;
1064
1065        for y in 0..self.height {
1066            for x in 0..self.width {
1067                let Some(depth) = self.get_depth(x, y) else {
1068                    continue;
1069                };
1070                if !Self::is_foreground_depth(depth, far_plane) {
1071                    continue;
1072                }
1073
1074                foreground_pixel_count += 1;
1075
1076                if let Some([cx, cy]) = center_pixel {
1077                    let dx = x as i64 - cx as i64;
1078                    let dy = y as i64 - cy as i64;
1079
1080                    if dx.abs() <= 2 && dy.abs() <= 2 {
1081                        center_5x5_foreground_count += 1;
1082                    }
1083
1084                    let distance = ((dx * dx + dy * dy) as f64).sqrt();
1085                    if nearest_foreground_distance_px
1086                        .map(|current| distance < current)
1087                        .unwrap_or(true)
1088                    {
1089                        nearest_foreground_pixel = Some([x, y]);
1090                        nearest_foreground_depth = Some(depth);
1091                        nearest_foreground_distance_px = Some(distance);
1092                    }
1093                }
1094            }
1095        }
1096
1097        RenderHealth {
1098            center_pixel,
1099            center_depth,
1100            center_foreground,
1101            foreground_pixel_count,
1102            foreground_coverage: if total_pixels > 0 {
1103                foreground_pixel_count as f64 / total_pixels as f64
1104            } else {
1105                0.0
1106            },
1107            center_5x5_foreground_count,
1108            nearest_foreground_pixel,
1109            nearest_foreground_depth,
1110            nearest_foreground_distance_px,
1111        }
1112    }
1113
1114    /// Transform a point from Bevy camera-local coordinates into world space.
1115    pub fn camera_to_world_point(&self, camera_point: [f64; 3]) -> [f64; 3] {
1116        let point = Vec3::new(
1117            camera_point[0] as f32,
1118            camera_point[1] as f32,
1119            camera_point[2] as f32,
1120        );
1121        let rotated = self.camera_transform.rotation * point;
1122        let translated = self.camera_transform.translation + rotated;
1123        [
1124            translated.x as f64,
1125            translated.y as f64,
1126            translated.z as f64,
1127        ]
1128    }
1129
1130    /// Transform a point from world space into Bevy camera-local coordinates.
1131    pub fn world_to_camera_point(&self, world_point: [f64; 3]) -> [f64; 3] {
1132        let point = Vec3::new(
1133            world_point[0] as f32,
1134            world_point[1] as f32,
1135            world_point[2] as f32,
1136        );
1137        let relative = point - self.camera_transform.translation;
1138        let camera_point = self.camera_transform.rotation.inverse() * relative;
1139        [
1140            camera_point.x as f64,
1141            camera_point.y as f64,
1142            camera_point.z as f64,
1143        ]
1144    }
1145
1146    /// Surface point at the center pixel using the TBP default far plane.
1147    pub fn center_surface_point_world(&self) -> Option<[f64; 3]> {
1148        self.center_surface_point_world_with_far_plane(Self::TBP_FAR_PLANE_METERS)
1149    }
1150
1151    /// Surface point at the center pixel using a caller-provided far plane.
1152    pub fn center_surface_point_world_with_far_plane(&self, far_plane: f64) -> Option<[f64; 3]> {
1153        let [x, y] = self.center_pixel()?;
1154        self.pixel_surface_point_world_with_far_plane([x, y], far_plane)
1155    }
1156
1157    /// Surface point at `pixel` using the TBP default far plane.
1158    pub fn pixel_surface_point_world(&self, pixel: [u32; 2]) -> Option<[f64; 3]> {
1159        self.pixel_surface_point_world_with_far_plane(pixel, Self::TBP_FAR_PLANE_METERS)
1160    }
1161
1162    /// Surface point at `pixel` using a caller-provided far plane.
1163    ///
1164    /// Pixel coordinates follow image convention (`x` right, `y` down). The
1165    /// returned point is in world space. Internally this maps to Bevy's camera
1166    /// frame (`+X` right, `+Y` up, `-Z` forward).
1167    pub fn pixel_surface_point_world_with_far_plane(
1168        &self,
1169        pixel: [u32; 2],
1170        far_plane: f64,
1171    ) -> Option<[f64; 3]> {
1172        let [x, y] = pixel;
1173        let depth = self.get_depth(x, y)?;
1174        pixel_surface_point_world_from_parts(
1175            depth,
1176            pixel,
1177            &self.intrinsics,
1178            self.camera_transform,
1179            far_plane,
1180        )
1181    }
1182
1183    /// Build TBP-style `semantic_3d` rows using the TBP default far plane.
1184    ///
1185    /// The returned vector is row-major with one `[x, y, z, semantic_id]` row
1186    /// per pixel. Foreground pixels are unprojected into world space and use
1187    /// `object_semantic_id`; background/far pixels are `[0, 0, 0, 0]`.
1188    pub fn semantic_3d(&self, object_semantic_id: u32) -> Vec<[f64; 4]> {
1189        self.semantic_3d_with_far_plane(object_semantic_id, Self::TBP_FAR_PLANE_METERS)
1190    }
1191
1192    /// Build TBP-style `semantic_3d` rows using a caller-provided far plane.
1193    pub fn semantic_3d_with_far_plane(
1194        &self,
1195        object_semantic_id: u32,
1196        far_plane: f64,
1197    ) -> Vec<[f64; 4]> {
1198        semantic_3d_from_depth(
1199            &self.depth,
1200            self.width,
1201            self.height,
1202            &self.intrinsics,
1203            self.camera_transform,
1204            object_semantic_id,
1205            far_plane,
1206        )
1207    }
1208
1209    /// Convert to neocortx-compatible image format: Vec<Vec<[u8; 3]>>
1210    pub fn to_rgb_image(&self) -> Vec<Vec<[u8; 3]>> {
1211        let mut image = Vec::with_capacity(self.height as usize);
1212        for y in 0..self.height {
1213            let mut row = Vec::with_capacity(self.width as usize);
1214            for x in 0..self.width {
1215                row.push(self.get_rgb(x, y).unwrap_or([0, 0, 0]));
1216            }
1217            image.push(row);
1218        }
1219        image
1220    }
1221
1222    /// Convert depth to neocortx-compatible format: Vec<Vec<f64>>
1223    pub fn to_depth_image(&self) -> Vec<Vec<f64>> {
1224        let mut image = Vec::with_capacity(self.height as usize);
1225        for y in 0..self.height {
1226            let mut row = Vec::with_capacity(self.width as usize);
1227            for x in 0..self.width {
1228                row.push(self.get_depth(x, y).unwrap_or(0.0));
1229            }
1230            image.push(row);
1231        }
1232        image
1233    }
1234}
1235
1236/// Errors that can occur during rendering and file operations.
1237#[derive(Debug, Clone)]
1238pub enum RenderError {
1239    /// Object mesh file not found
1240    MeshNotFound(String),
1241    /// Object texture file not found
1242    TextureNotFound(String),
1243    /// Generic file not found error
1244    FileNotFound { path: String, reason: String },
1245    /// File write failed
1246    FileWriteFailed { path: String, reason: String },
1247    /// Directory creation failed
1248    DirectoryCreationFailed { path: String, reason: String },
1249    /// Bevy rendering failed
1250    RenderFailed(String),
1251    /// Invalid configuration
1252    InvalidConfig(String),
1253    /// Invalid input parameters
1254    InvalidInput(String),
1255    /// JSON serialization/deserialization error
1256    SerializationError(String),
1257    /// Binary data parsing error
1258    DataParsingError(String),
1259    /// Render timeout
1260    RenderTimeout { duration_secs: u64 },
1261}
1262
1263impl std::fmt::Display for RenderError {
1264    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1265        match self {
1266            RenderError::MeshNotFound(path) => write!(f, "Mesh not found: {}", path),
1267            RenderError::TextureNotFound(path) => write!(f, "Texture not found: {}", path),
1268            RenderError::FileNotFound { path, reason } => {
1269                write!(f, "File not found at {}: {}", path, reason)
1270            }
1271            RenderError::FileWriteFailed { path, reason } => {
1272                write!(f, "Failed to write file {}: {}", path, reason)
1273            }
1274            RenderError::DirectoryCreationFailed { path, reason } => {
1275                write!(f, "Failed to create directory {}: {}", path, reason)
1276            }
1277            RenderError::RenderFailed(msg) => write!(f, "Render failed: {}", msg),
1278            RenderError::InvalidConfig(msg) => write!(f, "Invalid config: {}", msg),
1279            RenderError::InvalidInput(msg) => write!(f, "Invalid input: {}", msg),
1280            RenderError::SerializationError(msg) => write!(f, "Serialization error: {}", msg),
1281            RenderError::DataParsingError(msg) => write!(f, "Data parsing error: {}", msg),
1282            RenderError::RenderTimeout { duration_secs } => {
1283                write!(f, "Render timeout after {} seconds", duration_secs)
1284            }
1285        }
1286    }
1287}
1288
1289impl std::error::Error for RenderError {}
1290
1291/// Render a YCB object to an in-memory buffer.
1292///
1293/// This is the primary API for headless rendering. It spawns a minimal Bevy app,
1294/// renders a single frame, extracts the RGBA and depth data, and shuts down.
1295///
1296/// # Arguments
1297/// * `object_dir` - Path to YCB object directory (e.g., "/tmp/ycb/003_cracker_box")
1298/// * `camera_transform` - Camera position and orientation (use `generate_viewpoints`)
1299/// * `object_rotation` - Rotation to apply to the object
1300/// * `config` - Render configuration (resolution, depth range, etc.)
1301///
1302/// # Example
1303/// ```ignore
1304/// use bevy_sensor::{render_to_buffer, RenderConfig, ViewpointConfig, ObjectRotation};
1305/// use std::path::Path;
1306///
1307/// let viewpoints = bevy_sensor::generate_viewpoints(&ViewpointConfig::default());
1308/// let output = render_to_buffer(
1309///     Path::new("/tmp/ycb/003_cracker_box"),
1310///     &viewpoints[0],
1311///     &ObjectRotation::identity(),
1312///     &RenderConfig::tbp_default(),
1313/// )?;
1314/// ```
1315pub fn render_to_buffer(
1316    object_dir: &Path,
1317    camera_transform: &Transform,
1318    object_rotation: &ObjectRotation,
1319    config: &RenderConfig,
1320) -> Result<RenderOutput, RenderError> {
1321    // Use the actual Bevy headless renderer
1322    render::render_headless(
1323        object_dir,
1324        camera_transform,
1325        object_rotation,
1326        Vec3::ZERO,
1327        Vec3::ONE,
1328        config,
1329    )
1330}
1331
1332/// Render a YCB object with an explicit object translation and scale.
1333pub fn render_to_buffer_with_object_transform(
1334    object_dir: &Path,
1335    camera_transform: &Transform,
1336    object_rotation: &ObjectRotation,
1337    object_translation: Vec3,
1338    object_scale: Vec3,
1339    config: &RenderConfig,
1340) -> Result<RenderOutput, RenderError> {
1341    render::render_headless(
1342        object_dir,
1343        camera_transform,
1344        object_rotation,
1345        object_translation,
1346        object_scale,
1347        config,
1348    )
1349}
1350
1351/// Render a YCB object and attach the target metadata used for the camera pose.
1352///
1353/// This is useful when callers generate camera transforms with
1354/// [`generate_targeted_viewpoints`] and need the live render output to carry the
1355/// exact per-render pivot point for downstream pose compensation.
1356pub fn render_to_buffer_with_target(
1357    object_dir: &Path,
1358    camera_transform: &Transform,
1359    object_rotation: &ObjectRotation,
1360    config: &RenderConfig,
1361    target_point: Vec3,
1362    targeting_policy: TargetingPolicy,
1363) -> Result<RenderOutput, RenderError> {
1364    render::render_headless_with_target(
1365        object_dir,
1366        camera_transform,
1367        object_rotation,
1368        Vec3::ZERO,
1369        Vec3::ONE,
1370        config,
1371        target_point,
1372        targeting_policy,
1373    )
1374}
1375
1376/// Render a YCB object with explicit object transform and target metadata.
1377#[allow(clippy::too_many_arguments)]
1378pub fn render_to_buffer_with_target_and_object_transform(
1379    object_dir: &Path,
1380    camera_transform: &Transform,
1381    object_rotation: &ObjectRotation,
1382    object_translation: Vec3,
1383    object_scale: Vec3,
1384    config: &RenderConfig,
1385    target_point: Vec3,
1386    targeting_policy: TargetingPolicy,
1387) -> Result<RenderOutput, RenderError> {
1388    render::render_headless_with_target(
1389        object_dir,
1390        camera_transform,
1391        object_rotation,
1392        object_translation,
1393        object_scale,
1394        config,
1395        target_point,
1396        targeting_policy,
1397    )
1398}
1399
1400/// Render all viewpoints and rotations for a YCB object.
1401///
1402/// Convenience function that renders all combinations of viewpoints and rotations.
1403///
1404/// # Arguments
1405/// * `object_dir` - Path to YCB object directory
1406/// * `viewpoint_config` - Viewpoint configuration (camera positions)
1407/// * `rotations` - Object rotations to render
1408/// * `render_config` - Render configuration
1409///
1410/// # Returns
1411/// Vector of RenderOutput, one per viewpoint × rotation combination.
1412pub fn render_all_viewpoints(
1413    object_dir: &Path,
1414    viewpoint_config: &ViewpointConfig,
1415    rotations: &[ObjectRotation],
1416    render_config: &RenderConfig,
1417) -> Result<Vec<RenderOutput>, RenderError> {
1418    let viewpoints = generate_viewpoints(viewpoint_config);
1419    let mut outputs = Vec::with_capacity(viewpoints.len() * rotations.len());
1420
1421    for rotation in rotations {
1422        for viewpoint in &viewpoints {
1423            let output = render_to_buffer(object_dir, viewpoint, rotation, render_config)?;
1424            outputs.push(output);
1425        }
1426    }
1427
1428    Ok(outputs)
1429}
1430
1431/// Structured center-hit validation report for one object.
1432#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1433pub struct CenterHitValidationReport {
1434    /// Object identifier used in logs/manifests.
1435    pub object_id: String,
1436    /// Object directory rendered.
1437    pub object_dir: String,
1438    /// Targeting policy used for all rotations.
1439    pub target_policy: TargetingPolicy,
1440    /// Per-rotation center-hit results.
1441    pub rotations: Vec<CenterHitRotationReport>,
1442}
1443
1444impl CenterHitValidationReport {
1445    /// True when every rotation has at least one center-foreground hit.
1446    pub fn is_valid(&self) -> bool {
1447        self.rotations
1448            .iter()
1449            .all(|rotation| rotation.center_hits > 0)
1450    }
1451
1452    /// Rotation indices with zero center-foreground hits.
1453    pub fn zero_hit_rotations(&self) -> Vec<usize> {
1454        self.rotations
1455            .iter()
1456            .filter(|rotation| rotation.center_hits == 0)
1457            .map(|rotation| rotation.rotation_index)
1458            .collect()
1459    }
1460}
1461
1462/// Center-hit validation result for a single object rotation.
1463#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1464pub struct CenterHitRotationReport {
1465    pub rotation_index: usize,
1466    pub rotation_euler: [f64; 3],
1467    pub target_point: [f32; 3],
1468    pub mesh_bounds: Option<MeshBoundsMetadata>,
1469    pub total_viewpoints: usize,
1470    pub center_hits: usize,
1471    pub center_misses: usize,
1472    pub misses: Vec<CenterHitMiss>,
1473}
1474
1475/// Serializable mesh-bounds metadata for reports and manifests.
1476#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
1477pub struct MeshBoundsMetadata {
1478    pub min: [f32; 3],
1479    pub max: [f32; 3],
1480    pub center: [f32; 3],
1481    pub vertex_count: usize,
1482}
1483
1484impl From<MeshBounds> for MeshBoundsMetadata {
1485    fn from(bounds: MeshBounds) -> Self {
1486        Self {
1487            min: bounds.min.to_array(),
1488            max: bounds.max.to_array(),
1489            center: bounds.center.to_array(),
1490            vertex_count: bounds.vertex_count,
1491        }
1492    }
1493}
1494
1495/// Center-hit miss with enough metadata to reproduce the bad viewpoint.
1496#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1497pub struct CenterHitMiss {
1498    pub viewpoint_index: usize,
1499    pub camera_position: [f32; 3],
1500    pub camera_rotation_xyzw: [f32; 4],
1501    pub health: RenderHealth,
1502}
1503
1504/// Validate that each rotation has at least one viewpoint whose center pixel
1505/// lands on foreground before the render far plane.
1506pub fn validate_center_hits(
1507    object_id: impl Into<String>,
1508    object_dir: &Path,
1509    viewpoint_config: &ViewpointConfig,
1510    rotations: &[ObjectRotation],
1511    render_config: &RenderConfig,
1512    target_policy: &TargetingPolicy,
1513) -> Result<CenterHitValidationReport, RenderError> {
1514    let object_id = object_id.into();
1515    let mut rotation_reports = Vec::with_capacity(rotations.len());
1516
1517    for (rotation_index, rotation) in rotations.iter().enumerate() {
1518        let targeted =
1519            generate_targeted_viewpoints(object_dir, viewpoint_config, rotation, target_policy)?;
1520        let requests: Vec<batch::BatchRenderRequest> = targeted
1521            .viewpoints
1522            .iter()
1523            .map(|viewpoint| batch::BatchRenderRequest {
1524                object_dir: PathBuf::from(object_dir),
1525                viewpoint: *viewpoint,
1526                object_rotation: rotation.clone(),
1527                object_translation: Vec3::ZERO,
1528                object_scale: Vec3::ONE,
1529                render_config: render_config.clone(),
1530                target_point: targeted.target_point,
1531                targeting_policy: target_policy.clone(),
1532            })
1533            .collect();
1534
1535        let outputs = render_batch(requests, &batch::BatchRenderConfig::default())
1536            .map_err(|error| RenderError::RenderFailed(error.to_string()))?;
1537
1538        let mut center_hits = 0usize;
1539        let mut misses = Vec::new();
1540        for (viewpoint_index, output) in outputs.iter().enumerate() {
1541            if output.status != batch::RenderStatus::Success {
1542                return Err(RenderError::RenderFailed(format!(
1543                    "Render failed for {} rotation {} viewpoint {}: {:?}",
1544                    object_id, rotation_index, viewpoint_index, output.error_message
1545                )));
1546            }
1547
1548            if output.health.center_foreground {
1549                center_hits += 1;
1550            } else {
1551                let t = output.request.viewpoint.translation;
1552                let q = output.request.viewpoint.rotation;
1553                misses.push(CenterHitMiss {
1554                    viewpoint_index,
1555                    camera_position: [t.x, t.y, t.z],
1556                    camera_rotation_xyzw: [q.x, q.y, q.z, q.w],
1557                    health: output.health.clone(),
1558                });
1559            }
1560        }
1561
1562        rotation_reports.push(CenterHitRotationReport {
1563            rotation_index,
1564            rotation_euler: [rotation.pitch, rotation.yaw, rotation.roll],
1565            target_point: targeted.target_point.to_array(),
1566            mesh_bounds: targeted.mesh_bounds.map(MeshBoundsMetadata::from),
1567            total_viewpoints: outputs.len(),
1568            center_hits,
1569            center_misses: outputs.len().saturating_sub(center_hits),
1570            misses,
1571        });
1572    }
1573
1574    Ok(CenterHitValidationReport {
1575        object_id,
1576        object_dir: object_dir.display().to_string(),
1577        target_policy: target_policy.clone(),
1578        rotations: rotation_reports,
1579    })
1580}
1581
1582/// Render with model caching support for efficient multi-viewpoint rendering.
1583///
1584/// This function tracks which models have been loaded and provides performance
1585/// insights. It still spins up a fresh headless `App` per call. For workloads
1586/// that render many frames against the same object/config, prefer
1587/// `RenderSession` (homogeneous batches per episode) or `PersistentRenderer`
1588/// (one frame per call, scene held loaded across calls — built for surface-
1589/// policy feedback loops).
1590///
1591/// # Arguments
1592/// * `object_dir` - Path to YCB object directory
1593/// * `camera_transform` - Camera position and orientation
1594/// * `object_rotation` - Rotation to apply to the object
1595/// * `config` - Render configuration
1596/// * `cache` - Model cache to track loaded assets
1597///
1598/// # Returns
1599/// RenderOutput with rendered RGBA and depth data
1600///
1601/// # Example
1602/// ```ignore
1603/// use bevy_sensor::{render_to_buffer_cached, cache::ModelCache, RenderConfig, ObjectRotation};
1604/// use std::path::PathBuf;
1605///
1606/// let mut cache = ModelCache::new();
1607/// let object_dir = PathBuf::from("/tmp/ycb/003_cracker_box");
1608/// let config = RenderConfig::tbp_default();
1609/// let viewpoints = bevy_sensor::generate_viewpoints(&ViewpointConfig::default());
1610///
1611/// // First render: loads from disk and caches
1612/// let output1 = render_to_buffer_cached(
1613///     &object_dir,
1614///     &viewpoints[0],
1615///     &ObjectRotation::identity(),
1616///     &config,
1617///     &mut cache,
1618/// )?;
1619///
1620/// // Subsequent renders: tracks in cache
1621/// for viewpoint in &viewpoints[1..] {
1622///     let output = render_to_buffer_cached(
1623///         &object_dir,
1624///         viewpoint,
1625///         &ObjectRotation::identity(),
1626///         &config,
1627///         &mut cache,
1628///     )?;
1629/// }
1630/// ```
1631///
1632/// # Note
1633/// This function uses the same rendering engine as `render_to_buffer()`. The current
1634/// batch API preserves ordering and output structure but does not yet reuse a live
1635/// Bevy renderer across calls.
1636///
1637/// ```ignore
1638/// use bevy_sensor::{
1639///     render_batch, batch::BatchRenderRequest, BatchRenderConfig, RenderConfig,
1640///     ObjectRotation, TargetingPolicy, Vec3,
1641/// };
1642///
1643/// let requests: Vec<_> = viewpoints.iter().map(|vp| {
1644///     BatchRenderRequest {
1645///         object_dir: object_dir.clone(),
1646///         viewpoint: *vp,
1647///         object_rotation: ObjectRotation::identity(),
1648///         render_config: RenderConfig::tbp_default(),
1649///         target_point: Vec3::ZERO,
1650///         targeting_policy: TargetingPolicy::Origin,
1651///     }
1652/// }).collect();
1653///
1654/// let outputs = render_batch(requests, &BatchRenderConfig::default())?;
1655/// ```
1656pub fn render_to_buffer_cached(
1657    object_dir: &Path,
1658    camera_transform: &Transform,
1659    object_rotation: &ObjectRotation,
1660    config: &RenderConfig,
1661    cache: &mut cache::ModelCache,
1662) -> Result<RenderOutput, RenderError> {
1663    render_to_buffer_cached_with_object_transform(
1664        object_dir,
1665        camera_transform,
1666        object_rotation,
1667        Vec3::ZERO,
1668        Vec3::ONE,
1669        config,
1670        cache,
1671    )
1672}
1673
1674/// Render using the model cache with explicit object translation and scale.
1675pub fn render_to_buffer_cached_with_object_transform(
1676    object_dir: &Path,
1677    camera_transform: &Transform,
1678    object_rotation: &ObjectRotation,
1679    object_translation: Vec3,
1680    object_scale: Vec3,
1681    config: &RenderConfig,
1682    cache: &mut cache::ModelCache,
1683) -> Result<RenderOutput, RenderError> {
1684    let mesh_path = object_dir.join("google_16k/textured.obj");
1685    let texture_path = object_dir.join("google_16k/texture_map.png");
1686
1687    // Track in cache
1688    cache.cache_scene(mesh_path.clone());
1689    cache.cache_texture(texture_path.clone());
1690
1691    // Render using standard pipeline
1692    render::render_headless(
1693        object_dir,
1694        camera_transform,
1695        object_rotation,
1696        object_translation,
1697        object_scale,
1698        config,
1699    )
1700}
1701
1702/// Render directly to files (for subprocess mode).
1703///
1704/// This function is designed for subprocess rendering where the process will exit
1705/// after rendering. It saves RGBA and depth data directly to the specified files
1706/// before the process terminates.
1707///
1708/// # Arguments
1709/// * `object_dir` - Path to YCB object directory
1710/// * `camera_transform` - Camera position and orientation
1711/// * `object_rotation` - Rotation to apply to the object
1712/// * `config` - Render configuration
1713/// * `rgba_path` - Output path for RGBA PNG
1714/// * `depth_path` - Output path for depth data (raw f32 bytes)
1715///
1716/// # Note
1717/// This function may call `std::process::exit(0)` and not return.
1718pub fn render_to_files(
1719    object_dir: &Path,
1720    camera_transform: &Transform,
1721    object_rotation: &ObjectRotation,
1722    config: &RenderConfig,
1723    rgba_path: &Path,
1724    depth_path: &Path,
1725) -> Result<(), RenderError> {
1726    render_to_files_with_object_transform(
1727        object_dir,
1728        camera_transform,
1729        object_rotation,
1730        Vec3::ZERO,
1731        Vec3::ONE,
1732        config,
1733        rgba_path,
1734        depth_path,
1735    )
1736}
1737
1738/// Render directly to files with explicit object translation and scale.
1739#[allow(clippy::too_many_arguments)]
1740pub fn render_to_files_with_object_transform(
1741    object_dir: &Path,
1742    camera_transform: &Transform,
1743    object_rotation: &ObjectRotation,
1744    object_translation: Vec3,
1745    object_scale: Vec3,
1746    config: &RenderConfig,
1747    rgba_path: &Path,
1748    depth_path: &Path,
1749) -> Result<(), RenderError> {
1750    render::render_to_files(
1751        object_dir,
1752        camera_transform,
1753        object_rotation,
1754        object_translation,
1755        object_scale,
1756        config,
1757        rgba_path,
1758        depth_path,
1759    )
1760}
1761
1762// Re-export batch types for convenient API access
1763pub use batch::{
1764    BatchRenderConfig, BatchRenderError, BatchRenderOutput, BatchRenderRequest, BatchRenderer,
1765    BatchState, RenderStatus,
1766};
1767
1768/// Persistent batch render session. See the module docs in `render::RenderSession`
1769/// for lifetime, thread-affinity, and config-invariance guarantees.
1770pub use render::RenderSession;
1771
1772/// Per-step persistent renderer for feedback loops. See the module docs in
1773/// `render::PersistentRenderer` for lifetime, thread-affinity, and
1774/// object/config-invariance guarantees. Built for the surface-policy use case
1775/// in neocortx where a fixed object is rendered from a moving camera many
1776/// times per episode (issue #65).
1777pub use render::PersistentRenderer;
1778
1779/// Create a new batch renderer helper for multi-viewpoint workflows.
1780///
1781/// The current implementation stores queued requests and executes them sequentially via
1782/// `render_to_buffer()`. It does not yet keep a persistent Bevy app alive across renders.
1783///
1784/// # Arguments
1785/// * `config` - Batch rendering configuration
1786///
1787/// # Returns
1788/// A BatchRenderer instance ready to queue render requests
1789///
1790/// # Example
1791/// ```ignore
1792/// use bevy_sensor::{create_batch_renderer, queue_render_request, render_next_in_batch, BatchRenderConfig};
1793///
1794/// let mut renderer = create_batch_renderer(&BatchRenderConfig::default())?;
1795/// ```
1796pub fn create_batch_renderer(config: &BatchRenderConfig) -> Result<BatchRenderer, RenderError> {
1797    Ok(BatchRenderer::new(config.clone()))
1798}
1799
1800/// Queue a render request for batch processing.
1801///
1802/// Adds a render request to the batch queue. Requests are processed in order
1803/// when you call render_next_in_batch().
1804///
1805/// # Arguments
1806/// * `renderer` - The batch renderer instance
1807/// * `request` - The render request
1808///
1809/// # Returns
1810/// Ok if queued successfully, Err if queue is full
1811///
1812/// # Example
1813/// ```ignore
1814/// use bevy_sensor::{batch::BatchRenderRequest, RenderConfig, ObjectRotation, TargetingPolicy, Vec3};
1815/// use std::path::PathBuf;
1816///
1817/// queue_render_request(&mut renderer, BatchRenderRequest {
1818///     object_dir: PathBuf::from("/tmp/ycb/003_cracker_box"),
1819///     viewpoint: camera_transform,
1820///     object_rotation: ObjectRotation::identity(),
1821///     render_config: RenderConfig::tbp_default(),
1822///     target_point: Vec3::ZERO,
1823///     targeting_policy: TargetingPolicy::Origin,
1824/// })?;
1825/// ```
1826pub fn queue_render_request(
1827    renderer: &mut BatchRenderer,
1828    request: BatchRenderRequest,
1829) -> Result<(), RenderError> {
1830    renderer
1831        .queue_request(request)
1832        .map_err(|e| RenderError::RenderFailed(e.to_string()))
1833}
1834
1835/// Process and execute the next render in the batch queue.
1836///
1837/// Executes a single queued request via `render_to_buffer()`. Returns None when the queue
1838/// is empty. Use this in a loop to process all queued renders in a stable order.
1839///
1840/// # Arguments
1841/// * `renderer` - The batch renderer instance
1842/// * `timeout_ms` - Timeout in milliseconds for this render
1843///
1844/// # Returns
1845/// Some(output) if a render completed, None if queue is empty
1846///
1847/// # Example
1848/// ```ignore
1849/// loop {
1850///     match render_next_in_batch(&mut renderer, 500)? {
1851///         Some(output) => println!("Render complete: {:?}", output.status),
1852///         None => break, // All renders done
1853///     }
1854/// }
1855/// ```
1856pub fn render_next_in_batch(
1857    renderer: &mut BatchRenderer,
1858    _timeout_ms: u32,
1859) -> Result<Option<BatchRenderOutput>, RenderError> {
1860    if let Some(request) = renderer.pending_requests.pop_front() {
1861        let output = render_to_buffer_with_object_transform(
1862            &request.object_dir,
1863            &request.viewpoint,
1864            &request.object_rotation,
1865            request.object_translation,
1866            request.object_scale,
1867            &request.render_config,
1868        )?;
1869        let batch_output = BatchRenderOutput::from_render_output(request, output);
1870        renderer.completed_results.push(batch_output.clone());
1871        renderer.renders_processed += 1;
1872        Ok(Some(batch_output))
1873    } else {
1874        Ok(None)
1875    }
1876}
1877
1878/// Render multiple requests in batch (convenience function).
1879///
1880/// Queues all requests and executes them in batch, returning all results.
1881/// Simpler than manage queue + loop for one-off batches.
1882///
1883/// # Arguments
1884/// * `requests` - Vector of render requests
1885/// * `config` - Batch rendering configuration
1886///
1887/// # Returns
1888/// Vector of BatchRenderOutput results in same order as input
1889///
1890/// # Example
1891/// ```ignore
1892/// use bevy_sensor::{render_batch, batch::BatchRenderRequest, BatchRenderConfig};
1893///
1894/// let results = render_batch(requests, &BatchRenderConfig::default())?;
1895/// ```
1896pub fn render_batch(
1897    requests: Vec<BatchRenderRequest>,
1898    config: &BatchRenderConfig,
1899) -> Result<Vec<BatchRenderOutput>, RenderError> {
1900    if requests.is_empty() {
1901        return Ok(Vec::new());
1902    }
1903
1904    if requests.len() > 1 && requests_share_batch_context(&requests) {
1905        let first_request = requests[0].clone();
1906        let viewpoints: Vec<Transform> = requests.iter().map(|request| request.viewpoint).collect();
1907        let outputs = render::render_headless_sequence(
1908            &first_request.object_dir,
1909            &viewpoints,
1910            &first_request.object_rotation,
1911            first_request.object_translation,
1912            first_request.object_scale,
1913            &first_request.render_config,
1914            first_request.target_point,
1915            first_request.targeting_policy.clone(),
1916        )?;
1917
1918        return Ok(requests
1919            .into_iter()
1920            .zip(outputs)
1921            .map(|(request, output)| BatchRenderOutput::from_render_output(request, output))
1922            .collect());
1923    }
1924
1925    let mut renderer = create_batch_renderer(config)?;
1926
1927    // Queue all requests
1928    for request in requests {
1929        queue_render_request(&mut renderer, request)?;
1930    }
1931
1932    // Execute all and collect results
1933    let mut results = Vec::new();
1934    while let Some(output) = render_next_in_batch(&mut renderer, config.frame_timeout_ms)? {
1935        results.push(output);
1936    }
1937
1938    Ok(results)
1939}
1940
1941fn requests_share_batch_context(requests: &[BatchRenderRequest]) -> bool {
1942    let Some(first) = requests.first() else {
1943        return true;
1944    };
1945
1946    requests.iter().all(|request| {
1947        request.object_dir == first.object_dir
1948            && request.object_rotation == first.object_rotation
1949            && request.object_translation == first.object_translation
1950            && request.object_scale == first.object_scale
1951            && request.render_config == first.render_config
1952            && request.target_point == first.target_point
1953            && request.targeting_policy == first.targeting_policy
1954    })
1955}
1956
1957// Re-export bevy types that consumers will need
1958pub use bevy::prelude::{Quat, Transform, Vec3};
1959
1960#[cfg(test)]
1961mod tests {
1962    use super::*;
1963
1964    fn assert_vec3_close(actual: Vec3, expected: Vec3) {
1965        assert!(
1966            (actual - expected).length() < 1e-5,
1967            "expected {:?}, got {:?}",
1968            expected,
1969            actual
1970        );
1971    }
1972
1973    fn assert_point_close(actual: [f64; 3], expected: [f64; 3]) {
1974        for axis in 0..3 {
1975            assert!(
1976                (actual[axis] - expected[axis]).abs() < 1e-5,
1977                "axis {} expected {:?}, got {:?}",
1978                axis,
1979                expected,
1980                actual
1981            );
1982        }
1983    }
1984
1985    fn render_output_for_depth(
1986        width: u32,
1987        height: u32,
1988        depth: Vec<f64>,
1989        intrinsics: CameraIntrinsics,
1990        camera_transform: Transform,
1991    ) -> RenderOutput {
1992        RenderOutput {
1993            rgba: vec![0u8; (width * height * 4) as usize],
1994            depth,
1995            width,
1996            height,
1997            intrinsics,
1998            camera_transform,
1999            object_rotation: ObjectRotation::identity(),
2000            object_translation: Vec3::ZERO,
2001            object_scale: Vec3::ONE,
2002            target_point: Vec3::ZERO,
2003            targeting_policy: TargetingPolicy::Origin,
2004        }
2005    }
2006
2007    #[test]
2008    fn test_object_rotation_identity() {
2009        let rot = ObjectRotation::identity();
2010        assert_eq!(rot.pitch, 0.0);
2011        assert_eq!(rot.yaw, 0.0);
2012        assert_eq!(rot.roll, 0.0);
2013    }
2014
2015    #[test]
2016    fn test_object_rotation_from_array() {
2017        let rot = ObjectRotation::from_array([10.0, 20.0, 30.0]);
2018        assert_eq!(rot.pitch, 10.0);
2019        assert_eq!(rot.yaw, 20.0);
2020        assert_eq!(rot.roll, 30.0);
2021    }
2022
2023    #[test]
2024    fn test_requests_share_batch_context_for_homogeneous_batch() {
2025        let config = RenderConfig::tbp_default();
2026        let request = BatchRenderRequest {
2027            object_dir: "/tmp/ycb/003_cracker_box".into(),
2028            viewpoint: Transform::IDENTITY,
2029            object_rotation: ObjectRotation::identity(),
2030            object_translation: Vec3::ZERO,
2031            object_scale: Vec3::ONE,
2032            render_config: config.clone(),
2033            target_point: Vec3::ZERO,
2034            targeting_policy: TargetingPolicy::Origin,
2035        };
2036
2037        assert!(requests_share_batch_context(&[
2038            request.clone(),
2039            BatchRenderRequest {
2040                viewpoint: Transform::from_xyz(1.0, 0.0, 0.0),
2041                ..request
2042            },
2043        ]));
2044    }
2045
2046    #[test]
2047    fn test_requests_share_batch_context_rejects_mixed_objects() {
2048        let config = RenderConfig::tbp_default();
2049        let request = BatchRenderRequest {
2050            object_dir: "/tmp/ycb/003_cracker_box".into(),
2051            viewpoint: Transform::IDENTITY,
2052            object_rotation: ObjectRotation::identity(),
2053            object_translation: Vec3::ZERO,
2054            object_scale: Vec3::ONE,
2055            render_config: config.clone(),
2056            target_point: Vec3::ZERO,
2057            targeting_policy: TargetingPolicy::Origin,
2058        };
2059
2060        assert!(!requests_share_batch_context(&[
2061            request.clone(),
2062            BatchRenderRequest {
2063                object_dir: "/tmp/ycb/005_tomato_soup_can".into(),
2064                ..request
2065            },
2066        ]));
2067    }
2068
2069    #[test]
2070    fn test_requests_share_batch_context_rejects_mixed_object_translation() {
2071        let config = RenderConfig::tbp_default();
2072        let request = BatchRenderRequest {
2073            object_dir: "/tmp/ycb/003_cracker_box".into(),
2074            viewpoint: Transform::IDENTITY,
2075            object_rotation: ObjectRotation::identity(),
2076            object_translation: Vec3::ZERO,
2077            object_scale: Vec3::ONE,
2078            render_config: config.clone(),
2079            target_point: Vec3::ZERO,
2080            targeting_policy: TargetingPolicy::Origin,
2081        };
2082
2083        assert!(!requests_share_batch_context(&[
2084            request.clone(),
2085            BatchRenderRequest {
2086                object_translation: Vec3::new(0.1, 0.0, 0.0),
2087                ..request
2088            },
2089        ]));
2090    }
2091
2092    #[test]
2093    fn test_requests_share_batch_context_rejects_mixed_object_scale() {
2094        let config = RenderConfig::tbp_default();
2095        let request = BatchRenderRequest {
2096            object_dir: "/tmp/ycb/003_cracker_box".into(),
2097            viewpoint: Transform::IDENTITY,
2098            object_rotation: ObjectRotation::identity(),
2099            object_translation: Vec3::ZERO,
2100            object_scale: Vec3::ONE,
2101            render_config: config.clone(),
2102            target_point: Vec3::ZERO,
2103            targeting_policy: TargetingPolicy::Origin,
2104        };
2105
2106        assert!(!requests_share_batch_context(&[
2107            request.clone(),
2108            BatchRenderRequest {
2109                object_scale: Vec3::splat(1.25),
2110                ..request
2111            },
2112        ]));
2113    }
2114
2115    #[test]
2116    fn test_tbp_benchmark_rotations() {
2117        let rotations = ObjectRotation::tbp_benchmark_rotations();
2118        assert_eq!(rotations.len(), 3);
2119        assert_eq!(rotations[0], ObjectRotation::from_array([0.0, 0.0, 0.0]));
2120        assert_eq!(rotations[1], ObjectRotation::from_array([0.0, 90.0, 0.0]));
2121        assert_eq!(rotations[2], ObjectRotation::from_array([0.0, 180.0, 0.0]));
2122    }
2123
2124    #[test]
2125    fn test_tbp_known_orientations_count() {
2126        let orientations = ObjectRotation::tbp_known_orientations();
2127        assert_eq!(orientations.len(), 14);
2128    }
2129
2130    #[test]
2131    fn test_rotation_to_quat() {
2132        let rot = ObjectRotation::identity();
2133        let quat = rot.to_quat();
2134        // Identity quaternion should be approximately (1, 0, 0, 0)
2135        assert!((quat.w - 1.0).abs() < 0.001);
2136        assert!(quat.x.abs() < 0.001);
2137        assert!(quat.y.abs() < 0.001);
2138        assert!(quat.z.abs() < 0.001);
2139    }
2140
2141    #[test]
2142    fn test_rotation_90_yaw() {
2143        let rot = ObjectRotation::new(0.0, 90.0, 0.0);
2144        let quat = rot.to_quat();
2145        // 90° Y rotation: w ≈ 0.707, y ≈ 0.707
2146        assert!((quat.w - 0.707).abs() < 0.01);
2147        assert!((quat.y - 0.707).abs() < 0.01);
2148    }
2149
2150    #[test]
2151    fn test_viewpoint_config_default() {
2152        let config = ViewpointConfig::default();
2153        assert_eq!(config.radius, 0.5);
2154        assert_eq!(config.yaw_count, 8);
2155        assert_eq!(config.pitch_angles_deg.len(), 3);
2156    }
2157
2158    #[test]
2159    fn test_viewpoint_count() {
2160        let config = ViewpointConfig::default();
2161        assert_eq!(config.viewpoint_count(), 24); // 8 × 3
2162    }
2163
2164    #[test]
2165    fn test_generate_viewpoints_count() {
2166        let config = ViewpointConfig::default();
2167        let viewpoints = generate_viewpoints(&config);
2168        assert_eq!(viewpoints.len(), 24);
2169    }
2170
2171    #[test]
2172    fn test_viewpoints_spherical_radius() {
2173        let config = ViewpointConfig::default();
2174        let viewpoints = generate_viewpoints(&config);
2175
2176        for (i, transform) in viewpoints.iter().enumerate() {
2177            let actual_radius = transform.translation.length();
2178            assert!(
2179                (actual_radius - config.radius).abs() < 0.001,
2180                "Viewpoint {} has incorrect radius: {} (expected {})",
2181                i,
2182                actual_radius,
2183                config.radius
2184            );
2185        }
2186    }
2187
2188    #[test]
2189    fn test_viewpoints_looking_at_origin() {
2190        let config = ViewpointConfig::default();
2191        let viewpoints = generate_viewpoints(&config);
2192
2193        for (i, transform) in viewpoints.iter().enumerate() {
2194            let forward = transform.forward();
2195            let to_origin = (Vec3::ZERO - transform.translation).normalize();
2196            let dot = forward.dot(to_origin);
2197            assert!(
2198                dot > 0.99,
2199                "Viewpoint {} not looking at origin, dot product: {}",
2200                i,
2201                dot
2202            );
2203        }
2204    }
2205
2206    #[test]
2207    fn test_generate_viewpoints_around_target_preserves_orbit() {
2208        let config = ViewpointConfig {
2209            radius: 2.0,
2210            yaw_count: 4,
2211            pitch_angles_deg: vec![0.0],
2212        };
2213        let target = Vec3::new(1.0, -0.5, 0.25);
2214        let viewpoints = generate_viewpoints_around_target(&config, target);
2215
2216        assert_eq!(viewpoints.len(), 4);
2217        for (i, transform) in viewpoints.iter().enumerate() {
2218            let offset = transform.translation - target;
2219            assert!(
2220                (offset.length() - config.radius).abs() < 1e-5,
2221                "viewpoint {} has radius {}, expected {}",
2222                i,
2223                offset.length(),
2224                config.radius
2225            );
2226
2227            let forward = transform.forward();
2228            let to_target = (target - transform.translation).normalize();
2229            assert!(
2230                forward.dot(to_target) > 0.99,
2231                "viewpoint {} is not looking at target",
2232                i
2233            );
2234        }
2235    }
2236
2237    #[test]
2238    fn test_generate_viewpoints_keeps_origin_targeting() {
2239        let config = ViewpointConfig {
2240            radius: 1.0,
2241            yaw_count: 1,
2242            pitch_angles_deg: vec![0.0],
2243        };
2244
2245        let origin_view = generate_viewpoints(&config)[0];
2246        let explicit_origin_view = generate_viewpoints_around_target(&config, Vec3::ZERO)[0];
2247
2248        assert_vec3_close(origin_view.translation, explicit_origin_view.translation);
2249        let forward = origin_view.forward();
2250        let to_origin = (Vec3::ZERO - origin_view.translation).normalize();
2251        assert!(forward.dot(to_origin) > 0.99);
2252    }
2253
2254    #[test]
2255    fn test_object_centered_viewpoints_apply_yaw_rotation_to_target() {
2256        let config = ViewpointConfig {
2257            radius: 1.0,
2258            yaw_count: 1,
2259            pitch_angles_deg: vec![0.0],
2260        };
2261        let mesh_center = Vec3::new(0.25, 0.0, 0.0);
2262        let rotation = ObjectRotation::new(0.0, 90.0, 0.0);
2263
2264        let target = rotated_mesh_center(mesh_center, &rotation);
2265        assert!(target.distance(mesh_center) > 0.1);
2266
2267        let origin_view = generate_viewpoints(&config)[0];
2268        let centered_view = generate_object_centered_viewpoints(&config, mesh_center, &rotation)[0];
2269
2270        assert_vec3_close(centered_view.translation, origin_view.translation + target);
2271        let forward = centered_view.forward();
2272        let to_target = (target - centered_view.translation).normalize();
2273        assert!(forward.dot(to_target) > 0.99);
2274    }
2275
2276    #[test]
2277    fn test_load_ycb_mesh_bounds_from_standard_obj_path() {
2278        let dir = tempfile::tempdir().unwrap();
2279        let mesh_dir = dir.path().join("google_16k");
2280        std::fs::create_dir_all(&mesh_dir).unwrap();
2281        std::fs::write(
2282            mesh_dir.join("textured.obj"),
2283            "v -1.0 -2.0 -3.0\nv 3.0 4.0 5.0\nv 1.0 0.0 2.0\nf 1 2 3\n",
2284        )
2285        .unwrap();
2286
2287        let bounds = load_ycb_mesh_bounds(dir.path()).unwrap();
2288
2289        assert_eq!(bounds.vertex_count, 3);
2290        assert_vec3_close(bounds.min, Vec3::new(-1.0, -2.0, -3.0));
2291        assert_vec3_close(bounds.max, Vec3::new(3.0, 4.0, 5.0));
2292        assert_vec3_close(bounds.center, Vec3::new(1.0, 1.0, 1.0));
2293        assert_vec3_close(bounds.extents(), Vec3::new(4.0, 6.0, 8.0));
2294    }
2295
2296    #[test]
2297    fn test_targeting_policy_serializes_stable_label() {
2298        assert_eq!(TargetingPolicy::Origin.label(), "origin");
2299        assert_eq!(TargetingPolicy::MeshCenter.label(), "mesh-center");
2300
2301        let json = serde_json::to_string(&TargetingPolicy::MeshCenter).unwrap();
2302        assert!(json.contains("mesh_center"));
2303        let loaded: TargetingPolicy = serde_json::from_str(&json).unwrap();
2304        assert_eq!(loaded, TargetingPolicy::MeshCenter);
2305    }
2306
2307    #[test]
2308    fn test_render_output_with_targeting_overrides_origin_default() {
2309        let target_point = Vec3::new(0.1, 0.2, -0.3);
2310        let output = render_output_for_depth(
2311            1,
2312            1,
2313            vec![1.0],
2314            RenderConfig::tbp_default().intrinsics(),
2315            Transform::IDENTITY,
2316        )
2317        .with_targeting(target_point, TargetingPolicy::MeshCenter);
2318
2319        assert_eq!(output.target_point, target_point);
2320        assert_eq!(output.targeting_policy, TargetingPolicy::MeshCenter);
2321    }
2322
2323    #[test]
2324    fn test_center_hit_validation_report_detects_zero_hit_rotation() {
2325        let report = CenterHitValidationReport {
2326            object_id: "test_object".to_string(),
2327            object_dir: "/tmp/ycb/test_object".to_string(),
2328            target_policy: TargetingPolicy::MeshCenter,
2329            rotations: vec![
2330                CenterHitRotationReport {
2331                    rotation_index: 0,
2332                    rotation_euler: [0.0, 0.0, 0.0],
2333                    target_point: [0.0, 0.0, 0.0],
2334                    mesh_bounds: None,
2335                    total_viewpoints: 24,
2336                    center_hits: 1,
2337                    center_misses: 23,
2338                    misses: Vec::new(),
2339                },
2340                CenterHitRotationReport {
2341                    rotation_index: 1,
2342                    rotation_euler: [0.0, 90.0, 0.0],
2343                    target_point: [0.1, 0.0, 0.0],
2344                    mesh_bounds: None,
2345                    total_viewpoints: 24,
2346                    center_hits: 0,
2347                    center_misses: 24,
2348                    misses: Vec::new(),
2349                },
2350            ],
2351        };
2352
2353        assert!(!report.is_valid());
2354        assert_eq!(report.zero_hit_rotations(), vec![1]);
2355    }
2356
2357    #[test]
2358    fn test_sensor_config_default() {
2359        let config = SensorConfig::default();
2360        assert_eq!(config.object_rotations.len(), 1);
2361        assert_eq!(config.total_captures(), 24);
2362    }
2363
2364    #[test]
2365    fn test_sensor_config_tbp_benchmark() {
2366        let config = SensorConfig::tbp_benchmark();
2367        assert_eq!(config.object_rotations.len(), 3);
2368        assert_eq!(config.total_captures(), 72); // 3 rotations × 24 viewpoints
2369    }
2370
2371    #[test]
2372    fn test_sensor_config_tbp_full() {
2373        let config = SensorConfig::tbp_full_training();
2374        assert_eq!(config.object_rotations.len(), 14);
2375        assert_eq!(config.total_captures(), 336); // 14 rotations × 24 viewpoints
2376    }
2377
2378    #[test]
2379    fn test_ycb_representative_objects() {
2380        // Verify representative objects are defined
2381        assert_eq!(crate::ycb::REPRESENTATIVE_OBJECTS.len(), 3);
2382        assert!(crate::ycb::REPRESENTATIVE_OBJECTS.contains(&"003_cracker_box"));
2383    }
2384
2385    #[test]
2386    fn test_ycb_tbp_standard_objects() {
2387        assert_eq!(crate::ycb::TBP_STANDARD_OBJECTS.len(), 10);
2388        assert!(crate::ycb::TBP_STANDARD_OBJECTS.contains(&"025_mug"));
2389    }
2390
2391    #[test]
2392    fn test_ycb_tbp_similar_objects() {
2393        assert_eq!(crate::ycb::TBP_SIMILAR_OBJECTS.len(), 10);
2394        assert!(crate::ycb::TBP_SIMILAR_OBJECTS.contains(&"003_cracker_box"));
2395    }
2396
2397    #[test]
2398    fn test_ycb_object_mesh_path() {
2399        let path = crate::ycb::object_mesh_path("/tmp/ycb", "003_cracker_box");
2400        assert_eq!(
2401            path,
2402            std::path::Path::new("/tmp/ycb")
2403                .join("003_cracker_box")
2404                .join("google_16k")
2405                .join("textured.obj")
2406        );
2407    }
2408
2409    #[test]
2410    fn test_ycb_object_texture_path() {
2411        let path = crate::ycb::object_texture_path("/tmp/ycb", "003_cracker_box");
2412        assert_eq!(
2413            path,
2414            std::path::Path::new("/tmp/ycb")
2415                .join("003_cracker_box")
2416                .join("google_16k")
2417                .join("texture_map.png")
2418        );
2419    }
2420
2421    // =========================================================================
2422    // Headless Rendering API Tests
2423    // =========================================================================
2424
2425    #[test]
2426    fn test_render_config_tbp_default() {
2427        let config = RenderConfig::tbp_default();
2428        // TBP spec: 64x64 patch sensor resolution
2429        assert_eq!(config.width, 64);
2430        assert_eq!(config.height, 64);
2431        // Zoom is a divisor in the FOV formula — must be positive
2432        assert!(config.zoom > 0.0);
2433        // Clipping planes must form a valid, positive range
2434        assert!(config.near_plane > 0.0);
2435        assert!(config.far_plane > config.near_plane);
2436    }
2437
2438    #[test]
2439    fn test_render_config_preview() {
2440        let config = RenderConfig::preview();
2441        assert_eq!(config.width, 256);
2442        assert_eq!(config.height, 256);
2443    }
2444
2445    #[test]
2446    fn test_render_config_default_is_tbp() {
2447        let default = RenderConfig::default();
2448        let tbp = RenderConfig::tbp_default();
2449        assert_eq!(default.width, tbp.width);
2450        assert_eq!(default.height, tbp.height);
2451    }
2452
2453    #[test]
2454    fn test_render_config_fov() {
2455        let config = RenderConfig::tbp_default();
2456        let fov = config.fov_radians();
2457        // FOV must be a valid positive angle strictly less than π for any
2458        // positive zoom — no cameras with ≥180° FOV.
2459        assert!(fov > 0.0);
2460        assert!(fov < PI);
2461
2462        // Zoom in should reduce FOV (tighter view).
2463        let zoomed = RenderConfig {
2464            zoom: config.zoom * 2.0,
2465            ..config
2466        };
2467        assert!(zoomed.fov_radians() < fov);
2468    }
2469
2470    #[test]
2471    fn test_render_config_intrinsics() {
2472        let config = RenderConfig::tbp_default();
2473        let intrinsics = config.intrinsics();
2474
2475        // Image size matches config; principal point at image center.
2476        assert_eq!(intrinsics.image_size, [config.width, config.height]);
2477        assert_eq!(
2478            intrinsics.principal_point,
2479            [config.width as f64 / 2.0, config.height as f64 / 2.0]
2480        );
2481        // Square pixels: fx == fy.
2482        assert_eq!(intrinsics.focal_length[0], intrinsics.focal_length[1]);
2483        assert!(intrinsics.focal_length[0] > 0.0);
2484    }
2485
2486    #[test]
2487    fn test_render_config_intrinsics_for_size_uses_tbp_zoom_formula() {
2488        let config = RenderConfig {
2489            width: 64,
2490            height: 64,
2491            zoom: 4.0,
2492            ..RenderConfig::tbp_default()
2493        };
2494
2495        let intrinsics = config.intrinsics_for_size(64, 64);
2496
2497        // TBP formula for 90° base HFOV:
2498        // fx = (width / 2) / (tan(45°) / zoom) = (width / 2) * zoom.
2499        assert!((intrinsics.focal_length[0] - 128.0).abs() < 1e-9);
2500        assert!((intrinsics.focal_length[1] - 128.0).abs() < 1e-9);
2501        assert_ne!(intrinsics.focal_length[0], 64.0 * config.zoom as f64);
2502        assert_eq!(intrinsics.principal_point, [32.0, 32.0]);
2503        assert_eq!(intrinsics.image_size, [64, 64]);
2504    }
2505
2506    #[test]
2507    fn test_render_config_intrinsics_for_size_tracks_actual_readback_size() {
2508        let config = RenderConfig {
2509            width: 64,
2510            height: 64,
2511            zoom: 4.0,
2512            ..RenderConfig::tbp_default()
2513        };
2514
2515        let intrinsics = config.intrinsics_for_size(128, 96);
2516
2517        assert!((intrinsics.focal_length[0] - 256.0).abs() < 1e-9);
2518        assert!((intrinsics.focal_length[1] - 256.0).abs() < 1e-9);
2519        assert_eq!(intrinsics.principal_point, [64.0, 48.0]);
2520        assert_eq!(intrinsics.image_size, [128, 96]);
2521    }
2522
2523    #[test]
2524    fn test_camera_intrinsics_project() {
2525        let intrinsics = CameraIntrinsics {
2526            focal_length: [100.0, 100.0],
2527            principal_point: [32.0, 32.0],
2528            image_size: [64, 64],
2529        };
2530
2531        // Point at origin of camera frame projects to principal point
2532        let center = intrinsics.project(Vec3::new(0.0, 0.0, 1.0));
2533        assert!(center.is_some());
2534        let [x, y] = center.unwrap();
2535        assert!((x - 32.0).abs() < 0.001);
2536        assert!((y - 32.0).abs() < 0.001);
2537
2538        // Point behind camera returns None
2539        let behind = intrinsics.project(Vec3::new(0.0, 0.0, -1.0));
2540        assert!(behind.is_none());
2541    }
2542
2543    #[test]
2544    fn test_camera_intrinsics_unproject() {
2545        let intrinsics = CameraIntrinsics {
2546            focal_length: [100.0, 100.0],
2547            principal_point: [32.0, 32.0],
2548            image_size: [64, 64],
2549        };
2550
2551        // Unproject principal point at depth 1.0
2552        let point = intrinsics.unproject([32.0, 32.0], 1.0);
2553        assert!((point[0]).abs() < 0.001); // x
2554        assert!((point[1]).abs() < 0.001); // y
2555        assert!((point[2] - 1.0).abs() < 0.001); // z
2556    }
2557
2558    #[test]
2559    fn test_render_output_get_rgba() {
2560        let output = RenderOutput {
2561            rgba: vec![
2562                255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255,
2563            ],
2564            depth: vec![1.0, 2.0, 3.0, 4.0],
2565            width: 2,
2566            height: 2,
2567            intrinsics: RenderConfig::tbp_default().intrinsics(),
2568            camera_transform: Transform::IDENTITY,
2569            object_rotation: ObjectRotation::identity(),
2570            object_translation: Vec3::ZERO,
2571            object_scale: Vec3::ONE,
2572            target_point: Vec3::ZERO,
2573            targeting_policy: TargetingPolicy::Origin,
2574        };
2575
2576        // Top-left: red
2577        assert_eq!(output.get_rgba(0, 0), Some([255, 0, 0, 255]));
2578        // Top-right: green
2579        assert_eq!(output.get_rgba(1, 0), Some([0, 255, 0, 255]));
2580        // Bottom-left: blue
2581        assert_eq!(output.get_rgba(0, 1), Some([0, 0, 255, 255]));
2582        // Bottom-right: white
2583        assert_eq!(output.get_rgba(1, 1), Some([255, 255, 255, 255]));
2584        // Out of bounds
2585        assert_eq!(output.get_rgba(2, 0), None);
2586    }
2587
2588    #[test]
2589    fn test_render_output_get_depth() {
2590        let output = RenderOutput {
2591            rgba: vec![0u8; 16],
2592            depth: vec![1.0, 2.0, 3.0, 4.0],
2593            width: 2,
2594            height: 2,
2595            intrinsics: RenderConfig::tbp_default().intrinsics(),
2596            camera_transform: Transform::IDENTITY,
2597            object_rotation: ObjectRotation::identity(),
2598            object_translation: Vec3::ZERO,
2599            object_scale: Vec3::ONE,
2600            target_point: Vec3::ZERO,
2601            targeting_policy: TargetingPolicy::Origin,
2602        };
2603
2604        assert_eq!(output.get_depth(0, 0), Some(1.0));
2605        assert_eq!(output.get_depth(1, 0), Some(2.0));
2606        assert_eq!(output.get_depth(0, 1), Some(3.0));
2607        assert_eq!(output.get_depth(1, 1), Some(4.0));
2608        assert_eq!(output.get_depth(2, 0), None);
2609    }
2610
2611    #[test]
2612    fn test_render_output_to_rgb_image() {
2613        let output = RenderOutput {
2614            rgba: vec![
2615                255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255,
2616            ],
2617            depth: vec![1.0, 2.0, 3.0, 4.0],
2618            width: 2,
2619            height: 2,
2620            intrinsics: RenderConfig::tbp_default().intrinsics(),
2621            camera_transform: Transform::IDENTITY,
2622            object_rotation: ObjectRotation::identity(),
2623            object_translation: Vec3::ZERO,
2624            object_scale: Vec3::ONE,
2625            target_point: Vec3::ZERO,
2626            targeting_policy: TargetingPolicy::Origin,
2627        };
2628
2629        let image = output.to_rgb_image();
2630        assert_eq!(image.len(), 2); // 2 rows
2631        assert_eq!(image[0].len(), 2); // 2 columns
2632        assert_eq!(image[0][0], [255, 0, 0]); // Red
2633        assert_eq!(image[0][1], [0, 255, 0]); // Green
2634        assert_eq!(image[1][0], [0, 0, 255]); // Blue
2635        assert_eq!(image[1][1], [255, 255, 255]); // White
2636    }
2637
2638    #[test]
2639    fn test_render_output_to_depth_image() {
2640        let output = RenderOutput {
2641            rgba: vec![0u8; 16],
2642            depth: vec![1.0, 2.0, 3.0, 4.0],
2643            width: 2,
2644            height: 2,
2645            intrinsics: RenderConfig::tbp_default().intrinsics(),
2646            camera_transform: Transform::IDENTITY,
2647            object_rotation: ObjectRotation::identity(),
2648            object_translation: Vec3::ZERO,
2649            object_scale: Vec3::ONE,
2650            target_point: Vec3::ZERO,
2651            targeting_policy: TargetingPolicy::Origin,
2652        };
2653
2654        let depth_image = output.to_depth_image();
2655        assert_eq!(depth_image.len(), 2);
2656        assert_eq!(depth_image[0], vec![1.0, 2.0]);
2657        assert_eq!(depth_image[1], vec![3.0, 4.0]);
2658    }
2659
2660    #[test]
2661    fn test_render_output_semantic_3d_marks_foreground_and_background() {
2662        let output = render_output_for_depth(
2663            2,
2664            2,
2665            vec![0.25, 10.0, 0.5, f64::INFINITY],
2666            CameraIntrinsics {
2667                focal_length: [1.0, 1.0],
2668                principal_point: [0.0, 0.0],
2669                image_size: [2, 2],
2670            },
2671            Transform::IDENTITY,
2672        );
2673
2674        let semantic = output.semantic_3d(42);
2675
2676        assert_eq!(semantic.len(), 4);
2677        assert_eq!(semantic[0][3], 42.0);
2678        assert_eq!(semantic[1], [0.0, 0.0, 0.0, 0.0]);
2679        assert_eq!(semantic[2][3], 42.0);
2680        assert_eq!(semantic[3], [0.0, 0.0, 0.0, 0.0]);
2681        assert_point_close(
2682            [semantic[0][0], semantic[0][1], semantic[0][2]],
2683            [0.0, 0.0, -0.25],
2684        );
2685        assert_point_close(
2686            [semantic[2][0], semantic[2][1], semantic[2][2]],
2687            [0.0, -0.5, -0.5],
2688        );
2689    }
2690
2691    #[test]
2692    fn test_render_output_semantic_3d_matches_pixel_surface_points() {
2693        let output = render_output_for_depth(
2694            3,
2695            3,
2696            vec![10.0, 10.0, 2.0, 10.0, 0.25, 10.0, 10.0, 10.0, 10.0],
2697            CameraIntrinsics {
2698                focal_length: [1.0, 1.0],
2699                principal_point: [1.0, 1.0],
2700                image_size: [3, 3],
2701            },
2702            Transform::IDENTITY,
2703        );
2704
2705        let semantic = output.semantic_3d(3);
2706        let top_right = output
2707            .pixel_surface_point_world([2, 0])
2708            .expect("foreground point");
2709        let center = output
2710            .pixel_surface_point_world([1, 1])
2711            .expect("foreground point");
2712
2713        assert_point_close([semantic[2][0], semantic[2][1], semantic[2][2]], top_right);
2714        assert_eq!(semantic[2][3], 3.0);
2715        assert_point_close([semantic[4][0], semantic[4][1], semantic[4][2]], center);
2716        assert_eq!(semantic[4][3], 3.0);
2717    }
2718
2719    #[test]
2720    fn test_render_health_center_hit() {
2721        let mut depth = vec![10.0; 7 * 7];
2722        depth[3 * 7 + 3] = 0.25;
2723        depth[6 * 7 + 6] = 0.5;
2724        let output = render_output_for_depth(
2725            7,
2726            7,
2727            depth,
2728            CameraIntrinsics {
2729                focal_length: [10.0, 10.0],
2730                principal_point: [3.0, 3.0],
2731                image_size: [7, 7],
2732            },
2733            Transform::IDENTITY,
2734        );
2735
2736        let health = output.health();
2737
2738        assert_eq!(health.center_pixel, Some([3, 3]));
2739        assert_eq!(health.center_depth, Some(0.25));
2740        assert!(health.center_foreground);
2741        assert_eq!(health.foreground_pixel_count, 2);
2742        assert!((health.foreground_coverage - 2.0 / 49.0).abs() < 1e-12);
2743        assert_eq!(health.center_5x5_foreground_count, 1);
2744        assert_eq!(health.nearest_foreground_pixel, Some([3, 3]));
2745        assert_eq!(health.nearest_foreground_depth, Some(0.25));
2746        assert_eq!(health.nearest_foreground_distance_px, Some(0.0));
2747    }
2748
2749    #[test]
2750    fn test_render_health_far_center_uses_nearest_foreground() {
2751        let mut depth = vec![10.0; 7 * 7];
2752        depth[3 * 7 + 1] = 0.5;
2753        let output = render_output_for_depth(
2754            7,
2755            7,
2756            depth,
2757            CameraIntrinsics {
2758                focal_length: [10.0, 10.0],
2759                principal_point: [3.0, 3.0],
2760                image_size: [7, 7],
2761            },
2762            Transform::IDENTITY,
2763        );
2764
2765        let health = output.health();
2766
2767        assert_eq!(health.center_pixel, Some([3, 3]));
2768        assert_eq!(health.center_depth, Some(10.0));
2769        assert!(!health.center_foreground);
2770        assert_eq!(health.foreground_pixel_count, 1);
2771        assert_eq!(health.center_5x5_foreground_count, 1);
2772        assert_eq!(health.nearest_foreground_pixel, Some([1, 3]));
2773        assert_eq!(health.nearest_foreground_depth, Some(0.5));
2774        assert_eq!(health.nearest_foreground_distance_px, Some(2.0));
2775    }
2776
2777    #[test]
2778    fn test_center_surface_point_world_uses_bevy_camera_forward() {
2779        let mut depth = vec![10.0; 3 * 3];
2780        depth[3 + 1] = 0.25;
2781        let output = render_output_for_depth(
2782            3,
2783            3,
2784            depth,
2785            CameraIntrinsics {
2786                focal_length: [1.0, 1.0],
2787                principal_point: [1.0, 1.0],
2788                image_size: [3, 3],
2789            },
2790            Transform::IDENTITY,
2791        );
2792
2793        assert_eq!(output.center_pixel_depth(), Some(0.25));
2794        assert_point_close(
2795            output.center_surface_point_world().expect("surface point"),
2796            [0.0, 0.0, -0.25],
2797        );
2798    }
2799
2800    #[test]
2801    fn test_pixel_surface_point_world_maps_image_y_down_to_camera_y_up() {
2802        let mut depth = vec![10.0; 3 * 3];
2803        depth[2] = 2.0;
2804        let output = render_output_for_depth(
2805            3,
2806            3,
2807            depth,
2808            CameraIntrinsics {
2809                focal_length: [1.0, 1.0],
2810                principal_point: [1.0, 1.0],
2811                image_size: [3, 3],
2812            },
2813            Transform::IDENTITY,
2814        );
2815
2816        assert_point_close(
2817            output
2818                .pixel_surface_point_world([2, 0])
2819                .expect("surface point"),
2820            [2.0, 2.0, -2.0],
2821        );
2822    }
2823
2824    #[test]
2825    fn test_camera_world_point_helpers_roundtrip() {
2826        let output = render_output_for_depth(
2827            1,
2828            1,
2829            vec![0.25],
2830            CameraIntrinsics {
2831                focal_length: [1.0, 1.0],
2832                principal_point: [0.0, 0.0],
2833                image_size: [1, 1],
2834            },
2835            Transform::from_xyz(0.0, 0.0, 1.0).looking_at(Vec3::ZERO, Vec3::Y),
2836        );
2837
2838        assert_point_close(
2839            output.center_surface_point_world().expect("surface point"),
2840            [0.0, 0.0, 0.75],
2841        );
2842
2843        let world_point = [0.1, -0.2, 0.7];
2844        let camera_point = output.world_to_camera_point(world_point);
2845        assert_point_close(output.camera_to_world_point(camera_point), world_point);
2846    }
2847
2848    #[test]
2849    fn test_render_error_display() {
2850        let err = RenderError::MeshNotFound("/path/to/mesh.obj".to_string());
2851        assert!(err.to_string().contains("Mesh not found"));
2852        assert!(err.to_string().contains("/path/to/mesh.obj"));
2853    }
2854
2855    // =========================================================================
2856    // Edge Case Tests
2857    // =========================================================================
2858
2859    #[test]
2860    fn test_object_rotation_extreme_angles() {
2861        // Test angles beyond 360 degrees
2862        let rot = ObjectRotation::new(450.0, -720.0, 1080.0);
2863        let quat = rot.to_quat();
2864        // Quaternion should still be valid (normalized)
2865        assert!((quat.length() - 1.0).abs() < 0.001);
2866    }
2867
2868    #[test]
2869    fn test_object_rotation_to_transform() {
2870        let rot = ObjectRotation::new(45.0, 90.0, 0.0);
2871        let transform = rot.to_transform();
2872        // Transform should have no translation
2873        assert_eq!(transform.translation, Vec3::ZERO);
2874        // Should have rotation
2875        assert!(transform.rotation != Quat::IDENTITY);
2876    }
2877
2878    #[test]
2879    fn test_object_rotation_to_transform_with_translation_scale() {
2880        let rot = ObjectRotation::new(0.0, 90.0, 0.0);
2881        let translation = Vec3::new(0.25, -0.5, 1.25);
2882        let scale = Vec3::new(1.0, 1.5, 0.75);
2883        let transform = rot.to_transform_with_translation_scale(translation, scale);
2884
2885        assert_eq!(transform.translation, translation);
2886        assert_eq!(transform.scale, scale);
2887        assert_eq!(transform.rotation, rot.to_quat());
2888    }
2889
2890    #[test]
2891    fn test_viewpoint_config_single_viewpoint() {
2892        let config = ViewpointConfig {
2893            radius: 1.0,
2894            yaw_count: 1,
2895            pitch_angles_deg: vec![0.0],
2896        };
2897        assert_eq!(config.viewpoint_count(), 1);
2898        let viewpoints = generate_viewpoints(&config);
2899        assert_eq!(viewpoints.len(), 1);
2900        // Single viewpoint at yaw=0, pitch=0 should be at (0, 0, radius)
2901        let pos = viewpoints[0].translation;
2902        assert!((pos.x).abs() < 0.001);
2903        assert!((pos.y).abs() < 0.001);
2904        assert!((pos.z - 1.0).abs() < 0.001);
2905    }
2906
2907    #[test]
2908    fn test_viewpoint_radius_scaling() {
2909        let config1 = ViewpointConfig {
2910            radius: 0.5,
2911            yaw_count: 4,
2912            pitch_angles_deg: vec![0.0],
2913        };
2914        let config2 = ViewpointConfig {
2915            radius: 2.0,
2916            yaw_count: 4,
2917            pitch_angles_deg: vec![0.0],
2918        };
2919
2920        let v1 = generate_viewpoints(&config1);
2921        let v2 = generate_viewpoints(&config2);
2922
2923        // Viewpoints should scale proportionally
2924        for (vp1, vp2) in v1.iter().zip(v2.iter()) {
2925            let ratio = vp2.translation.length() / vp1.translation.length();
2926            assert!((ratio - 4.0).abs() < 0.01); // 2.0 / 0.5 = 4.0
2927        }
2928    }
2929
2930    #[test]
2931    fn test_camera_intrinsics_project_at_z_zero() {
2932        let intrinsics = CameraIntrinsics {
2933            focal_length: [100.0, 100.0],
2934            principal_point: [32.0, 32.0],
2935            image_size: [64, 64],
2936        };
2937
2938        // Point at z=0 should return None (division by zero protection)
2939        let result = intrinsics.project(Vec3::new(1.0, 1.0, 0.0));
2940        assert!(result.is_none());
2941    }
2942
2943    #[test]
2944    fn test_camera_intrinsics_roundtrip() {
2945        let intrinsics = CameraIntrinsics {
2946            focal_length: [100.0, 100.0],
2947            principal_point: [32.0, 32.0],
2948            image_size: [64, 64],
2949        };
2950
2951        // Project a 3D point
2952        let original = Vec3::new(0.5, -0.3, 2.0);
2953        let projected = intrinsics.project(original).unwrap();
2954
2955        // Unproject back with the same depth (convert f32 to f64)
2956        let unprojected = intrinsics.unproject(projected, original.z as f64);
2957
2958        // Should get back approximately the same point
2959        assert!((unprojected[0] - original.x as f64).abs() < 0.001); // x
2960        assert!((unprojected[1] - original.y as f64).abs() < 0.001); // y
2961        assert!((unprojected[2] - original.z as f64).abs() < 0.001); // z
2962    }
2963
2964    #[test]
2965    fn test_render_output_empty() {
2966        let output = RenderOutput {
2967            rgba: vec![],
2968            depth: vec![],
2969            width: 0,
2970            height: 0,
2971            intrinsics: RenderConfig::tbp_default().intrinsics(),
2972            camera_transform: Transform::IDENTITY,
2973            object_rotation: ObjectRotation::identity(),
2974            object_translation: Vec3::ZERO,
2975            object_scale: Vec3::ONE,
2976            target_point: Vec3::ZERO,
2977            targeting_policy: TargetingPolicy::Origin,
2978        };
2979
2980        // Should handle empty gracefully
2981        assert_eq!(output.get_rgba(0, 0), None);
2982        assert_eq!(output.get_depth(0, 0), None);
2983        assert!(output.to_rgb_image().is_empty());
2984        assert!(output.to_depth_image().is_empty());
2985    }
2986
2987    #[test]
2988    fn test_render_output_1x1() {
2989        let output = RenderOutput {
2990            rgba: vec![128, 64, 32, 255],
2991            depth: vec![0.5],
2992            width: 1,
2993            height: 1,
2994            intrinsics: RenderConfig::tbp_default().intrinsics(),
2995            camera_transform: Transform::IDENTITY,
2996            object_rotation: ObjectRotation::identity(),
2997            object_translation: Vec3::ZERO,
2998            object_scale: Vec3::ONE,
2999            target_point: Vec3::ZERO,
3000            targeting_policy: TargetingPolicy::Origin,
3001        };
3002
3003        assert_eq!(output.get_rgba(0, 0), Some([128, 64, 32, 255]));
3004        assert_eq!(output.get_depth(0, 0), Some(0.5));
3005        assert_eq!(output.get_rgb(0, 0), Some([128, 64, 32]));
3006
3007        let rgb_img = output.to_rgb_image();
3008        assert_eq!(rgb_img.len(), 1);
3009        assert_eq!(rgb_img[0].len(), 1);
3010        assert_eq!(rgb_img[0][0], [128, 64, 32]);
3011    }
3012
3013    #[test]
3014    fn test_render_config_high_res() {
3015        let config = RenderConfig::high_res();
3016        assert_eq!(config.width, 512);
3017        assert_eq!(config.height, 512);
3018
3019        let intrinsics = config.intrinsics();
3020        assert_eq!(intrinsics.image_size, [512, 512]);
3021        assert_eq!(intrinsics.principal_point, [256.0, 256.0]);
3022    }
3023
3024    #[test]
3025    fn test_render_config_zoom_affects_fov() {
3026        // The formula fov = 2·atan(tan(base_hfov/2)/zoom) has an exact
3027        // invariant: tan(fov/2) * zoom is constant. So doubling zoom
3028        // halves tan(fov/2). (This is NOT the same as halving fov itself,
3029        // which only holds as a small-angle approximation.)
3030        let base = RenderConfig {
3031            zoom: 2.0,
3032            ..RenderConfig::tbp_default()
3033        };
3034        let doubled = RenderConfig {
3035            zoom: 4.0,
3036            ..RenderConfig::tbp_default()
3037        };
3038
3039        // Higher zoom → tighter FOV (monotonicity).
3040        assert!(doubled.fov_radians() < base.fov_radians());
3041
3042        // Exact invariant: tan(fov/2) scales as 1/zoom.
3043        let base_half_tan = (base.fov_radians() / 2.0).tan();
3044        let doubled_half_tan = (doubled.fov_radians() / 2.0).tan();
3045        assert!((base_half_tan / doubled_half_tan - 2.0).abs() < 1e-4);
3046    }
3047
3048    #[test]
3049    fn test_render_config_zoom_affects_intrinsics() {
3050        // The formula fx = (width/2)·zoom/tan(base_hfov/2) is linear in
3051        // zoom for fixed width/base_hfov, so fx/zoom is constant.
3052        let a = RenderConfig {
3053            zoom: 2.0,
3054            ..RenderConfig::tbp_default()
3055        };
3056        let b = RenderConfig {
3057            zoom: 4.0,
3058            ..RenderConfig::tbp_default()
3059        };
3060
3061        let fx_a = a.intrinsics().focal_length[0];
3062        let fx_b = b.intrinsics().focal_length[0];
3063
3064        // Monotonic: higher zoom → larger focal length.
3065        assert!(fx_b > fx_a);
3066
3067        // Exact linearity: fx/zoom is constant across configs.
3068        assert!((fx_a / a.zoom as f64 - fx_b / b.zoom as f64).abs() < 1e-9);
3069    }
3070
3071    #[test]
3072    fn test_lighting_config_variants() {
3073        let default = LightingConfig::default();
3074        let bright = LightingConfig::bright();
3075        let soft = LightingConfig::soft();
3076        let unlit = LightingConfig::unlit();
3077
3078        // Bright should have higher intensity than default
3079        assert!(bright.key_light_intensity > default.key_light_intensity);
3080
3081        // Unlit should have no point lights
3082        assert_eq!(unlit.key_light_intensity, 0.0);
3083        assert_eq!(unlit.fill_light_intensity, 0.0);
3084        assert_eq!(unlit.ambient_brightness, 1.0);
3085
3086        // Soft should have lower intensity
3087        assert!(soft.key_light_intensity < default.key_light_intensity);
3088    }
3089
3090    #[test]
3091    fn test_all_render_error_variants() {
3092        let errors = vec![
3093            RenderError::MeshNotFound("mesh.obj".to_string()),
3094            RenderError::TextureNotFound("texture.png".to_string()),
3095            RenderError::RenderFailed("GPU error".to_string()),
3096            RenderError::InvalidConfig("bad config".to_string()),
3097        ];
3098
3099        for err in errors {
3100            // All variants should have Display impl
3101            let msg = err.to_string();
3102            assert!(!msg.is_empty());
3103        }
3104    }
3105
3106    #[test]
3107    fn test_tbp_known_orientations_unique() {
3108        let orientations = ObjectRotation::tbp_known_orientations();
3109
3110        // All 14 orientations should produce unique quaternions
3111        let quats: Vec<Quat> = orientations.iter().map(|r| r.to_quat()).collect();
3112
3113        for (i, q1) in quats.iter().enumerate() {
3114            for (j, q2) in quats.iter().enumerate() {
3115                if i != j {
3116                    // Quaternions should be different (accounting for q == -q equivalence)
3117                    let dot = q1.dot(*q2).abs();
3118                    assert!(
3119                        dot < 0.999,
3120                        "Orientations {} and {} produce same quaternion",
3121                        i,
3122                        j
3123                    );
3124                }
3125            }
3126        }
3127    }
3128}