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