1use bevy::prelude::*;
54use serde::{Deserialize, Serialize};
55use std::f32::consts::PI;
56use std::path::{Path, PathBuf};
57
58mod render;
61
62pub mod batch;
64
65pub mod backend;
67
68pub mod cache;
70
71pub mod fixtures;
73
74pub const RENDERER_POLICY_VERSION: &str = "tbp-targeting-v1";
76
77pub use ycbust::{
79 self, DownloadOptions, Subset as YcbSubset, GOOGLE_16K_MESH_RELATIVE, REPRESENTATIVE_OBJECTS,
80 TBP_SIMILAR_OBJECTS, TBP_STANDARD_OBJECTS,
81};
82
83pub 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 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 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 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 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 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 pub fn models_exist<P: AsRef<Path>>(output_dir: P) -> bool {
152 objects_exist(output_dir, REPRESENTATIVE_OBJECTS)
153 }
154
155 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 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
169pub fn initialize() {
203 use std::sync::atomic::{AtomicBool, Ordering};
205 static INITIALIZED: AtomicBool = AtomicBool::new(false);
206
207 if !INITIALIZED.swap(true, Ordering::SeqCst) {
208 let config = backend::BackendConfig::new();
210 config.apply_env();
211 }
212}
213
214#[derive(Clone, Debug, PartialEq)]
217pub struct ObjectRotation {
218 pub pitch: f64,
220 pub yaw: f64,
222 pub roll: f64,
224}
225
226impl ObjectRotation {
227 pub fn new(pitch: f64, yaw: f64, roll: f64) -> Self {
229 Self { pitch, yaw, roll }
230 }
231
232 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 pub fn identity() -> Self {
243 Self::new(0.0, 0.0, 0.0)
244 }
245
246 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 pub fn tbp_known_orientations() -> Vec<Self> {
259 vec![
260 Self::from_array([0.0, 0.0, 0.0]), Self::from_array([0.0, 90.0, 0.0]), Self::from_array([0.0, 180.0, 0.0]), Self::from_array([0.0, 270.0, 0.0]), Self::from_array([90.0, 0.0, 0.0]), Self::from_array([-90.0, 0.0, 0.0]), 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 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 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#[derive(Clone, Debug)]
304pub struct ViewpointConfig {
305 pub radius: f32,
307 pub yaw_count: usize,
309 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 pitch_angles_deg: vec![-30.0, 0.0, 30.0],
321 }
322 }
323}
324
325impl ViewpointConfig {
326 pub fn viewpoint_count(&self) -> usize {
328 self.yaw_count * self.pitch_angles_deg.len()
329 }
330}
331
332#[derive(Clone, Copy, Debug, PartialEq)]
334pub struct MeshBounds {
335 pub min: Vec3,
337 pub max: Vec3,
339 pub center: Vec3,
341 pub vertex_count: usize,
343}
344
345impl MeshBounds {
346 pub fn extents(&self) -> Vec3 {
348 self.max - self.min
349 }
350}
351
352#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
354#[serde(tag = "policy", content = "target", rename_all = "snake_case")]
355pub enum TargetingPolicy {
356 Origin,
358 MeshCenter,
360 ExplicitTarget([f32; 3]),
362}
363
364impl TargetingPolicy {
365 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#[derive(Clone, Debug, PartialEq)]
377pub struct TargetedViewpoints {
378 pub policy: TargetingPolicy,
380 pub target_point: Vec3,
382 pub mesh_bounds: Option<MeshBounds>,
384 pub viewpoints: Vec<Transform>,
386}
387
388#[derive(Clone, Debug, Resource)]
390pub struct SensorConfig {
391 pub viewpoints: ViewpointConfig,
393 pub object_rotations: Vec<ObjectRotation>,
395 pub output_dir: String,
397 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 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 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 pub fn total_captures(&self) -> usize {
435 self.viewpoints.viewpoint_count() * self.object_rotations.len()
436 }
437}
438
439pub fn generate_viewpoints(config: &ViewpointConfig) -> Vec<Transform> {
446 generate_viewpoints_around_target(config, Vec3::ZERO)
447}
448
449pub 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 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
480pub fn rotated_mesh_center(mesh_center: Vec3, object_rotation: &ObjectRotation) -> Vec3 {
487 object_rotation.to_quat() * mesh_center
488}
489
490pub 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
503pub 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
557pub fn load_ycb_mesh_bounds(object_dir: &Path) -> Result<MeshBounds, RenderError> {
559 load_mesh_bounds(&object_dir.join(GOOGLE_16K_MESH_RELATIVE))
560}
561
562pub 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
576pub 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#[derive(Component)]
614pub struct CaptureTarget;
615
616#[derive(Component)]
618pub struct CaptureCamera;
619
620#[derive(Clone, Debug, PartialEq)]
628pub struct RenderConfig {
629 pub width: u32,
631 pub height: u32,
633 pub zoom: f32,
636 pub near_plane: f32,
638 pub far_plane: f32,
640 pub lighting: LightingConfig,
642}
643
644#[derive(Clone, Debug, PartialEq)]
648pub struct LightingConfig {
649 pub ambient_brightness: f32,
651 pub key_light_intensity: f32,
653 pub key_light_position: [f32; 3],
655 pub fill_light_intensity: f32,
657 pub fill_light_position: [f32; 3],
659 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 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 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 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 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 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 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 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 pub fn intrinsics(&self) -> CameraIntrinsics {
783 self.intrinsics_for_size(self.width, self.height)
784 }
785
786 pub fn intrinsics_for_size(&self, width: u32, height: u32) -> CameraIntrinsics {
791 let base_hfov_rad = 90.0_f64.to_radians();
792 let fx_norm = (base_hfov_rad / 2.0).tan() / self.zoom as f64;
794 let fx = (width as f64 / 2.0) / fx_norm;
796 let fy = fx; 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#[derive(Clone, Debug, PartialEq)]
811pub struct CameraIntrinsics {
812 pub focal_length: [f64; 2],
814 pub principal_point: [f64; 2],
816 pub image_size: [u32; 2],
818}
819
820impl CameraIntrinsics {
821 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 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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
841pub struct RenderHealth {
842 pub center_pixel: Option<[u32; 2]>,
844 pub center_depth: Option<f64>,
846 pub center_foreground: bool,
848 pub foreground_pixel_count: usize,
850 pub foreground_coverage: f64,
852 pub center_5x5_foreground_count: usize,
854 pub nearest_foreground_pixel: Option<[u32; 2]>,
856 pub nearest_foreground_depth: Option<f64>,
858 pub nearest_foreground_distance_px: Option<f64>,
860}
861
862#[derive(Clone, Debug)]
864pub struct RenderOutput {
865 pub rgba: Vec<u8>,
867 pub depth: Vec<f64>,
871 pub width: u32,
873 pub height: u32,
875 pub intrinsics: CameraIntrinsics,
877 pub camera_transform: Transform,
879 pub object_rotation: ObjectRotation,
881 pub target_point: Vec3,
883 pub targeting_policy: TargetingPolicy,
885}
886
887impl RenderOutput {
888 pub const TBP_FAR_PLANE_METERS: f64 = 10.0;
890
891 pub fn with_targeting(mut self, target_point: Vec3, targeting_policy: TargetingPolicy) -> Self {
893 self.target_point = target_point;
894 self.targeting_policy = targeting_policy;
895 self
896 }
897
898 pub fn get_rgba(&self, x: u32, y: u32) -> Option<[u8; 4]> {
900 if x >= self.width || y >= self.height {
901 return None;
902 }
903 let idx = ((y * self.width + x) * 4) as usize;
904 Some([
905 self.rgba[idx],
906 self.rgba[idx + 1],
907 self.rgba[idx + 2],
908 self.rgba[idx + 3],
909 ])
910 }
911
912 pub fn get_depth(&self, x: u32, y: u32) -> Option<f64> {
914 if x >= self.width || y >= self.height {
915 return None;
916 }
917 let idx = (y * self.width + x) as usize;
918 Some(self.depth[idx])
919 }
920
921 pub fn get_rgb(&self, x: u32, y: u32) -> Option<[u8; 3]> {
923 self.get_rgba(x, y).map(|rgba| [rgba[0], rgba[1], rgba[2]])
924 }
925
926 pub fn center_pixel(&self) -> Option<[u32; 2]> {
928 if self.width == 0 || self.height == 0 {
929 return None;
930 }
931
932 let x = self.intrinsics.principal_point[0]
933 .round()
934 .clamp(0.0, (self.width - 1) as f64) as u32;
935 let y = self.intrinsics.principal_point[1]
936 .round()
937 .clamp(0.0, (self.height - 1) as f64) as u32;
938 Some([x, y])
939 }
940
941 pub fn center_pixel_raw_depth(&self) -> Option<f64> {
943 let [x, y] = self.center_pixel()?;
944 self.get_depth(x, y)
945 }
946
947 pub fn center_pixel_depth(&self) -> Option<f64> {
949 self.center_pixel_depth_with_far_plane(Self::TBP_FAR_PLANE_METERS)
950 }
951
952 pub fn center_pixel_depth_with_far_plane(&self, far_plane: f64) -> Option<f64> {
954 self.center_pixel_raw_depth()
955 .filter(|depth| Self::is_foreground_depth(*depth, far_plane))
956 }
957
958 pub fn is_foreground_depth(depth: f64, far_plane: f64) -> bool {
960 depth.is_finite() && depth > 0.0 && far_plane.is_finite() && depth < far_plane * 0.999
961 }
962
963 pub fn health(&self) -> RenderHealth {
965 self.health_with_far_plane(Self::TBP_FAR_PLANE_METERS)
966 }
967
968 pub fn health_with_far_plane(&self, far_plane: f64) -> RenderHealth {
970 let center_pixel = self.center_pixel();
971 let center_depth = self.center_pixel_raw_depth();
972 let center_foreground = center_depth
973 .map(|depth| Self::is_foreground_depth(depth, far_plane))
974 .unwrap_or(false);
975
976 let total_pixels = (self.width as usize).saturating_mul(self.height as usize);
977 let mut foreground_pixel_count = 0usize;
978 let mut center_5x5_foreground_count = 0usize;
979 let mut nearest_foreground_pixel = None;
980 let mut nearest_foreground_depth = None;
981 let mut nearest_foreground_distance_px = None;
982
983 for y in 0..self.height {
984 for x in 0..self.width {
985 let Some(depth) = self.get_depth(x, y) else {
986 continue;
987 };
988 if !Self::is_foreground_depth(depth, far_plane) {
989 continue;
990 }
991
992 foreground_pixel_count += 1;
993
994 if let Some([cx, cy]) = center_pixel {
995 let dx = x as i64 - cx as i64;
996 let dy = y as i64 - cy as i64;
997
998 if dx.abs() <= 2 && dy.abs() <= 2 {
999 center_5x5_foreground_count += 1;
1000 }
1001
1002 let distance = ((dx * dx + dy * dy) as f64).sqrt();
1003 if nearest_foreground_distance_px
1004 .map(|current| distance < current)
1005 .unwrap_or(true)
1006 {
1007 nearest_foreground_pixel = Some([x, y]);
1008 nearest_foreground_depth = Some(depth);
1009 nearest_foreground_distance_px = Some(distance);
1010 }
1011 }
1012 }
1013 }
1014
1015 RenderHealth {
1016 center_pixel,
1017 center_depth,
1018 center_foreground,
1019 foreground_pixel_count,
1020 foreground_coverage: if total_pixels > 0 {
1021 foreground_pixel_count as f64 / total_pixels as f64
1022 } else {
1023 0.0
1024 },
1025 center_5x5_foreground_count,
1026 nearest_foreground_pixel,
1027 nearest_foreground_depth,
1028 nearest_foreground_distance_px,
1029 }
1030 }
1031
1032 pub fn camera_to_world_point(&self, camera_point: [f64; 3]) -> [f64; 3] {
1034 let point = Vec3::new(
1035 camera_point[0] as f32,
1036 camera_point[1] as f32,
1037 camera_point[2] as f32,
1038 );
1039 let rotated = self.camera_transform.rotation * point;
1040 let translated = self.camera_transform.translation + rotated;
1041 [
1042 translated.x as f64,
1043 translated.y as f64,
1044 translated.z as f64,
1045 ]
1046 }
1047
1048 pub fn world_to_camera_point(&self, world_point: [f64; 3]) -> [f64; 3] {
1050 let point = Vec3::new(
1051 world_point[0] as f32,
1052 world_point[1] as f32,
1053 world_point[2] as f32,
1054 );
1055 let relative = point - self.camera_transform.translation;
1056 let camera_point = self.camera_transform.rotation.inverse() * relative;
1057 [
1058 camera_point.x as f64,
1059 camera_point.y as f64,
1060 camera_point.z as f64,
1061 ]
1062 }
1063
1064 pub fn center_surface_point_world(&self) -> Option<[f64; 3]> {
1066 self.center_surface_point_world_with_far_plane(Self::TBP_FAR_PLANE_METERS)
1067 }
1068
1069 pub fn center_surface_point_world_with_far_plane(&self, far_plane: f64) -> Option<[f64; 3]> {
1071 let [x, y] = self.center_pixel()?;
1072 self.pixel_surface_point_world_with_far_plane([x, y], far_plane)
1073 }
1074
1075 pub fn pixel_surface_point_world(&self, pixel: [u32; 2]) -> Option<[f64; 3]> {
1077 self.pixel_surface_point_world_with_far_plane(pixel, Self::TBP_FAR_PLANE_METERS)
1078 }
1079
1080 pub fn pixel_surface_point_world_with_far_plane(
1086 &self,
1087 pixel: [u32; 2],
1088 far_plane: f64,
1089 ) -> Option<[f64; 3]> {
1090 let [x, y] = pixel;
1091 let depth = self.get_depth(x, y)?;
1092 if !Self::is_foreground_depth(depth, far_plane) {
1093 return None;
1094 }
1095
1096 let fx = self.intrinsics.focal_length[0];
1097 let fy = self.intrinsics.focal_length[1];
1098 if !fx.is_finite()
1099 || !fy.is_finite()
1100 || fx.abs() <= f64::EPSILON
1101 || fy.abs() <= f64::EPSILON
1102 {
1103 return None;
1104 }
1105
1106 let camera_x = (x as f64 - self.intrinsics.principal_point[0]) / fx * depth;
1107 let camera_y = -((y as f64 - self.intrinsics.principal_point[1]) / fy * depth);
1108 Some(self.camera_to_world_point([camera_x, camera_y, -depth]))
1109 }
1110
1111 pub fn to_rgb_image(&self) -> Vec<Vec<[u8; 3]>> {
1113 let mut image = Vec::with_capacity(self.height as usize);
1114 for y in 0..self.height {
1115 let mut row = Vec::with_capacity(self.width as usize);
1116 for x in 0..self.width {
1117 row.push(self.get_rgb(x, y).unwrap_or([0, 0, 0]));
1118 }
1119 image.push(row);
1120 }
1121 image
1122 }
1123
1124 pub fn to_depth_image(&self) -> Vec<Vec<f64>> {
1126 let mut image = Vec::with_capacity(self.height as usize);
1127 for y in 0..self.height {
1128 let mut row = Vec::with_capacity(self.width as usize);
1129 for x in 0..self.width {
1130 row.push(self.get_depth(x, y).unwrap_or(0.0));
1131 }
1132 image.push(row);
1133 }
1134 image
1135 }
1136}
1137
1138#[derive(Debug, Clone)]
1140pub enum RenderError {
1141 MeshNotFound(String),
1143 TextureNotFound(String),
1145 FileNotFound { path: String, reason: String },
1147 FileWriteFailed { path: String, reason: String },
1149 DirectoryCreationFailed { path: String, reason: String },
1151 RenderFailed(String),
1153 InvalidConfig(String),
1155 InvalidInput(String),
1157 SerializationError(String),
1159 DataParsingError(String),
1161 RenderTimeout { duration_secs: u64 },
1163}
1164
1165impl std::fmt::Display for RenderError {
1166 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1167 match self {
1168 RenderError::MeshNotFound(path) => write!(f, "Mesh not found: {}", path),
1169 RenderError::TextureNotFound(path) => write!(f, "Texture not found: {}", path),
1170 RenderError::FileNotFound { path, reason } => {
1171 write!(f, "File not found at {}: {}", path, reason)
1172 }
1173 RenderError::FileWriteFailed { path, reason } => {
1174 write!(f, "Failed to write file {}: {}", path, reason)
1175 }
1176 RenderError::DirectoryCreationFailed { path, reason } => {
1177 write!(f, "Failed to create directory {}: {}", path, reason)
1178 }
1179 RenderError::RenderFailed(msg) => write!(f, "Render failed: {}", msg),
1180 RenderError::InvalidConfig(msg) => write!(f, "Invalid config: {}", msg),
1181 RenderError::InvalidInput(msg) => write!(f, "Invalid input: {}", msg),
1182 RenderError::SerializationError(msg) => write!(f, "Serialization error: {}", msg),
1183 RenderError::DataParsingError(msg) => write!(f, "Data parsing error: {}", msg),
1184 RenderError::RenderTimeout { duration_secs } => {
1185 write!(f, "Render timeout after {} seconds", duration_secs)
1186 }
1187 }
1188 }
1189}
1190
1191impl std::error::Error for RenderError {}
1192
1193pub fn render_to_buffer(
1218 object_dir: &Path,
1219 camera_transform: &Transform,
1220 object_rotation: &ObjectRotation,
1221 config: &RenderConfig,
1222) -> Result<RenderOutput, RenderError> {
1223 render::render_headless(object_dir, camera_transform, object_rotation, config)
1225}
1226
1227pub fn render_to_buffer_with_target(
1233 object_dir: &Path,
1234 camera_transform: &Transform,
1235 object_rotation: &ObjectRotation,
1236 config: &RenderConfig,
1237 target_point: Vec3,
1238 targeting_policy: TargetingPolicy,
1239) -> Result<RenderOutput, RenderError> {
1240 render_to_buffer(object_dir, camera_transform, object_rotation, config)
1241 .map(|output| output.with_targeting(target_point, targeting_policy))
1242}
1243
1244pub fn render_all_viewpoints(
1257 object_dir: &Path,
1258 viewpoint_config: &ViewpointConfig,
1259 rotations: &[ObjectRotation],
1260 render_config: &RenderConfig,
1261) -> Result<Vec<RenderOutput>, RenderError> {
1262 let viewpoints = generate_viewpoints(viewpoint_config);
1263 let mut outputs = Vec::with_capacity(viewpoints.len() * rotations.len());
1264
1265 for rotation in rotations {
1266 for viewpoint in &viewpoints {
1267 let output = render_to_buffer(object_dir, viewpoint, rotation, render_config)?;
1268 outputs.push(output);
1269 }
1270 }
1271
1272 Ok(outputs)
1273}
1274
1275#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1277pub struct CenterHitValidationReport {
1278 pub object_id: String,
1280 pub object_dir: String,
1282 pub target_policy: TargetingPolicy,
1284 pub rotations: Vec<CenterHitRotationReport>,
1286}
1287
1288impl CenterHitValidationReport {
1289 pub fn is_valid(&self) -> bool {
1291 self.rotations
1292 .iter()
1293 .all(|rotation| rotation.center_hits > 0)
1294 }
1295
1296 pub fn zero_hit_rotations(&self) -> Vec<usize> {
1298 self.rotations
1299 .iter()
1300 .filter(|rotation| rotation.center_hits == 0)
1301 .map(|rotation| rotation.rotation_index)
1302 .collect()
1303 }
1304}
1305
1306#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1308pub struct CenterHitRotationReport {
1309 pub rotation_index: usize,
1310 pub rotation_euler: [f64; 3],
1311 pub target_point: [f32; 3],
1312 pub mesh_bounds: Option<MeshBoundsMetadata>,
1313 pub total_viewpoints: usize,
1314 pub center_hits: usize,
1315 pub center_misses: usize,
1316 pub misses: Vec<CenterHitMiss>,
1317}
1318
1319#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
1321pub struct MeshBoundsMetadata {
1322 pub min: [f32; 3],
1323 pub max: [f32; 3],
1324 pub center: [f32; 3],
1325 pub vertex_count: usize,
1326}
1327
1328impl From<MeshBounds> for MeshBoundsMetadata {
1329 fn from(bounds: MeshBounds) -> Self {
1330 Self {
1331 min: bounds.min.to_array(),
1332 max: bounds.max.to_array(),
1333 center: bounds.center.to_array(),
1334 vertex_count: bounds.vertex_count,
1335 }
1336 }
1337}
1338
1339#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1341pub struct CenterHitMiss {
1342 pub viewpoint_index: usize,
1343 pub camera_position: [f32; 3],
1344 pub camera_rotation_xyzw: [f32; 4],
1345 pub health: RenderHealth,
1346}
1347
1348pub fn validate_center_hits(
1351 object_id: impl Into<String>,
1352 object_dir: &Path,
1353 viewpoint_config: &ViewpointConfig,
1354 rotations: &[ObjectRotation],
1355 render_config: &RenderConfig,
1356 target_policy: &TargetingPolicy,
1357) -> Result<CenterHitValidationReport, RenderError> {
1358 let object_id = object_id.into();
1359 let mut rotation_reports = Vec::with_capacity(rotations.len());
1360
1361 for (rotation_index, rotation) in rotations.iter().enumerate() {
1362 let targeted =
1363 generate_targeted_viewpoints(object_dir, viewpoint_config, rotation, target_policy)?;
1364 let requests: Vec<batch::BatchRenderRequest> = targeted
1365 .viewpoints
1366 .iter()
1367 .map(|viewpoint| batch::BatchRenderRequest {
1368 object_dir: PathBuf::from(object_dir),
1369 viewpoint: *viewpoint,
1370 object_rotation: rotation.clone(),
1371 render_config: render_config.clone(),
1372 target_point: targeted.target_point,
1373 targeting_policy: target_policy.clone(),
1374 })
1375 .collect();
1376
1377 let outputs = render_batch(requests, &batch::BatchRenderConfig::default())
1378 .map_err(|error| RenderError::RenderFailed(error.to_string()))?;
1379
1380 let mut center_hits = 0usize;
1381 let mut misses = Vec::new();
1382 for (viewpoint_index, output) in outputs.iter().enumerate() {
1383 if output.status != batch::RenderStatus::Success {
1384 return Err(RenderError::RenderFailed(format!(
1385 "Render failed for {} rotation {} viewpoint {}: {:?}",
1386 object_id, rotation_index, viewpoint_index, output.error_message
1387 )));
1388 }
1389
1390 if output.health.center_foreground {
1391 center_hits += 1;
1392 } else {
1393 let t = output.request.viewpoint.translation;
1394 let q = output.request.viewpoint.rotation;
1395 misses.push(CenterHitMiss {
1396 viewpoint_index,
1397 camera_position: [t.x, t.y, t.z],
1398 camera_rotation_xyzw: [q.x, q.y, q.z, q.w],
1399 health: output.health.clone(),
1400 });
1401 }
1402 }
1403
1404 rotation_reports.push(CenterHitRotationReport {
1405 rotation_index,
1406 rotation_euler: [rotation.pitch, rotation.yaw, rotation.roll],
1407 target_point: targeted.target_point.to_array(),
1408 mesh_bounds: targeted.mesh_bounds.map(MeshBoundsMetadata::from),
1409 total_viewpoints: outputs.len(),
1410 center_hits,
1411 center_misses: outputs.len().saturating_sub(center_hits),
1412 misses,
1413 });
1414 }
1415
1416 Ok(CenterHitValidationReport {
1417 object_id,
1418 object_dir: object_dir.display().to_string(),
1419 target_policy: target_policy.clone(),
1420 rotations: rotation_reports,
1421 })
1422}
1423
1424pub fn render_to_buffer_cached(
1499 object_dir: &Path,
1500 camera_transform: &Transform,
1501 object_rotation: &ObjectRotation,
1502 config: &RenderConfig,
1503 cache: &mut cache::ModelCache,
1504) -> Result<RenderOutput, RenderError> {
1505 let mesh_path = object_dir.join("google_16k/textured.obj");
1506 let texture_path = object_dir.join("google_16k/texture_map.png");
1507
1508 cache.cache_scene(mesh_path.clone());
1510 cache.cache_texture(texture_path.clone());
1511
1512 render::render_headless(object_dir, camera_transform, object_rotation, config)
1514}
1515
1516pub fn render_to_files(
1533 object_dir: &Path,
1534 camera_transform: &Transform,
1535 object_rotation: &ObjectRotation,
1536 config: &RenderConfig,
1537 rgba_path: &Path,
1538 depth_path: &Path,
1539) -> Result<(), RenderError> {
1540 render::render_to_files(
1541 object_dir,
1542 camera_transform,
1543 object_rotation,
1544 config,
1545 rgba_path,
1546 depth_path,
1547 )
1548}
1549
1550pub use batch::{
1552 BatchRenderConfig, BatchRenderError, BatchRenderOutput, BatchRenderRequest, BatchRenderer,
1553 BatchState, RenderStatus,
1554};
1555
1556pub use render::RenderSession;
1559
1560pub use render::PersistentRenderer;
1566
1567pub fn create_batch_renderer(config: &BatchRenderConfig) -> Result<BatchRenderer, RenderError> {
1585 Ok(BatchRenderer::new(config.clone()))
1586}
1587
1588pub fn queue_render_request(
1615 renderer: &mut BatchRenderer,
1616 request: BatchRenderRequest,
1617) -> Result<(), RenderError> {
1618 renderer
1619 .queue_request(request)
1620 .map_err(|e| RenderError::RenderFailed(e.to_string()))
1621}
1622
1623pub fn render_next_in_batch(
1645 renderer: &mut BatchRenderer,
1646 _timeout_ms: u32,
1647) -> Result<Option<BatchRenderOutput>, RenderError> {
1648 if let Some(request) = renderer.pending_requests.pop_front() {
1649 let output = render_to_buffer(
1650 &request.object_dir,
1651 &request.viewpoint,
1652 &request.object_rotation,
1653 &request.render_config,
1654 )?;
1655 let batch_output = BatchRenderOutput::from_render_output(request, output);
1656 renderer.completed_results.push(batch_output.clone());
1657 renderer.renders_processed += 1;
1658 Ok(Some(batch_output))
1659 } else {
1660 Ok(None)
1661 }
1662}
1663
1664pub fn render_batch(
1683 requests: Vec<BatchRenderRequest>,
1684 config: &BatchRenderConfig,
1685) -> Result<Vec<BatchRenderOutput>, RenderError> {
1686 if requests.is_empty() {
1687 return Ok(Vec::new());
1688 }
1689
1690 if requests.len() > 1 && requests_share_batch_context(&requests) {
1691 let first_request = requests[0].clone();
1692 let viewpoints: Vec<Transform> = requests.iter().map(|request| request.viewpoint).collect();
1693 let outputs = render::render_headless_sequence(
1694 &first_request.object_dir,
1695 &viewpoints,
1696 &first_request.object_rotation,
1697 &first_request.render_config,
1698 )?;
1699
1700 return Ok(requests
1701 .into_iter()
1702 .zip(outputs)
1703 .map(|(request, output)| BatchRenderOutput::from_render_output(request, output))
1704 .collect());
1705 }
1706
1707 let mut renderer = create_batch_renderer(config)?;
1708
1709 for request in requests {
1711 queue_render_request(&mut renderer, request)?;
1712 }
1713
1714 let mut results = Vec::new();
1716 while let Some(output) = render_next_in_batch(&mut renderer, config.frame_timeout_ms)? {
1717 results.push(output);
1718 }
1719
1720 Ok(results)
1721}
1722
1723fn requests_share_batch_context(requests: &[BatchRenderRequest]) -> bool {
1724 let Some(first) = requests.first() else {
1725 return true;
1726 };
1727
1728 requests.iter().all(|request| {
1729 request.object_dir == first.object_dir
1730 && request.object_rotation == first.object_rotation
1731 && request.render_config == first.render_config
1732 })
1733}
1734
1735pub use bevy::prelude::{Quat, Transform, Vec3};
1737
1738#[cfg(test)]
1739mod tests {
1740 use super::*;
1741
1742 fn assert_vec3_close(actual: Vec3, expected: Vec3) {
1743 assert!(
1744 (actual - expected).length() < 1e-5,
1745 "expected {:?}, got {:?}",
1746 expected,
1747 actual
1748 );
1749 }
1750
1751 fn assert_point_close(actual: [f64; 3], expected: [f64; 3]) {
1752 for axis in 0..3 {
1753 assert!(
1754 (actual[axis] - expected[axis]).abs() < 1e-5,
1755 "axis {} expected {:?}, got {:?}",
1756 axis,
1757 expected,
1758 actual
1759 );
1760 }
1761 }
1762
1763 fn render_output_for_depth(
1764 width: u32,
1765 height: u32,
1766 depth: Vec<f64>,
1767 intrinsics: CameraIntrinsics,
1768 camera_transform: Transform,
1769 ) -> RenderOutput {
1770 RenderOutput {
1771 rgba: vec![0u8; (width * height * 4) as usize],
1772 depth,
1773 width,
1774 height,
1775 intrinsics,
1776 camera_transform,
1777 object_rotation: ObjectRotation::identity(),
1778 target_point: Vec3::ZERO,
1779 targeting_policy: TargetingPolicy::Origin,
1780 }
1781 }
1782
1783 #[test]
1784 fn test_object_rotation_identity() {
1785 let rot = ObjectRotation::identity();
1786 assert_eq!(rot.pitch, 0.0);
1787 assert_eq!(rot.yaw, 0.0);
1788 assert_eq!(rot.roll, 0.0);
1789 }
1790
1791 #[test]
1792 fn test_object_rotation_from_array() {
1793 let rot = ObjectRotation::from_array([10.0, 20.0, 30.0]);
1794 assert_eq!(rot.pitch, 10.0);
1795 assert_eq!(rot.yaw, 20.0);
1796 assert_eq!(rot.roll, 30.0);
1797 }
1798
1799 #[test]
1800 fn test_requests_share_batch_context_for_homogeneous_batch() {
1801 let config = RenderConfig::tbp_default();
1802 let request = BatchRenderRequest {
1803 object_dir: "/tmp/ycb/003_cracker_box".into(),
1804 viewpoint: Transform::IDENTITY,
1805 object_rotation: ObjectRotation::identity(),
1806 render_config: config.clone(),
1807 target_point: Vec3::ZERO,
1808 targeting_policy: TargetingPolicy::Origin,
1809 };
1810
1811 assert!(requests_share_batch_context(&[
1812 request.clone(),
1813 BatchRenderRequest {
1814 viewpoint: Transform::from_xyz(1.0, 0.0, 0.0),
1815 ..request
1816 },
1817 ]));
1818 }
1819
1820 #[test]
1821 fn test_requests_share_batch_context_rejects_mixed_objects() {
1822 let config = RenderConfig::tbp_default();
1823 let request = BatchRenderRequest {
1824 object_dir: "/tmp/ycb/003_cracker_box".into(),
1825 viewpoint: Transform::IDENTITY,
1826 object_rotation: ObjectRotation::identity(),
1827 render_config: config.clone(),
1828 target_point: Vec3::ZERO,
1829 targeting_policy: TargetingPolicy::Origin,
1830 };
1831
1832 assert!(!requests_share_batch_context(&[
1833 request.clone(),
1834 BatchRenderRequest {
1835 object_dir: "/tmp/ycb/005_tomato_soup_can".into(),
1836 ..request
1837 },
1838 ]));
1839 }
1840
1841 #[test]
1842 fn test_tbp_benchmark_rotations() {
1843 let rotations = ObjectRotation::tbp_benchmark_rotations();
1844 assert_eq!(rotations.len(), 3);
1845 assert_eq!(rotations[0], ObjectRotation::from_array([0.0, 0.0, 0.0]));
1846 assert_eq!(rotations[1], ObjectRotation::from_array([0.0, 90.0, 0.0]));
1847 assert_eq!(rotations[2], ObjectRotation::from_array([0.0, 180.0, 0.0]));
1848 }
1849
1850 #[test]
1851 fn test_tbp_known_orientations_count() {
1852 let orientations = ObjectRotation::tbp_known_orientations();
1853 assert_eq!(orientations.len(), 14);
1854 }
1855
1856 #[test]
1857 fn test_rotation_to_quat() {
1858 let rot = ObjectRotation::identity();
1859 let quat = rot.to_quat();
1860 assert!((quat.w - 1.0).abs() < 0.001);
1862 assert!(quat.x.abs() < 0.001);
1863 assert!(quat.y.abs() < 0.001);
1864 assert!(quat.z.abs() < 0.001);
1865 }
1866
1867 #[test]
1868 fn test_rotation_90_yaw() {
1869 let rot = ObjectRotation::new(0.0, 90.0, 0.0);
1870 let quat = rot.to_quat();
1871 assert!((quat.w - 0.707).abs() < 0.01);
1873 assert!((quat.y - 0.707).abs() < 0.01);
1874 }
1875
1876 #[test]
1877 fn test_viewpoint_config_default() {
1878 let config = ViewpointConfig::default();
1879 assert_eq!(config.radius, 0.5);
1880 assert_eq!(config.yaw_count, 8);
1881 assert_eq!(config.pitch_angles_deg.len(), 3);
1882 }
1883
1884 #[test]
1885 fn test_viewpoint_count() {
1886 let config = ViewpointConfig::default();
1887 assert_eq!(config.viewpoint_count(), 24); }
1889
1890 #[test]
1891 fn test_generate_viewpoints_count() {
1892 let config = ViewpointConfig::default();
1893 let viewpoints = generate_viewpoints(&config);
1894 assert_eq!(viewpoints.len(), 24);
1895 }
1896
1897 #[test]
1898 fn test_viewpoints_spherical_radius() {
1899 let config = ViewpointConfig::default();
1900 let viewpoints = generate_viewpoints(&config);
1901
1902 for (i, transform) in viewpoints.iter().enumerate() {
1903 let actual_radius = transform.translation.length();
1904 assert!(
1905 (actual_radius - config.radius).abs() < 0.001,
1906 "Viewpoint {} has incorrect radius: {} (expected {})",
1907 i,
1908 actual_radius,
1909 config.radius
1910 );
1911 }
1912 }
1913
1914 #[test]
1915 fn test_viewpoints_looking_at_origin() {
1916 let config = ViewpointConfig::default();
1917 let viewpoints = generate_viewpoints(&config);
1918
1919 for (i, transform) in viewpoints.iter().enumerate() {
1920 let forward = transform.forward();
1921 let to_origin = (Vec3::ZERO - transform.translation).normalize();
1922 let dot = forward.dot(to_origin);
1923 assert!(
1924 dot > 0.99,
1925 "Viewpoint {} not looking at origin, dot product: {}",
1926 i,
1927 dot
1928 );
1929 }
1930 }
1931
1932 #[test]
1933 fn test_generate_viewpoints_around_target_preserves_orbit() {
1934 let config = ViewpointConfig {
1935 radius: 2.0,
1936 yaw_count: 4,
1937 pitch_angles_deg: vec![0.0],
1938 };
1939 let target = Vec3::new(1.0, -0.5, 0.25);
1940 let viewpoints = generate_viewpoints_around_target(&config, target);
1941
1942 assert_eq!(viewpoints.len(), 4);
1943 for (i, transform) in viewpoints.iter().enumerate() {
1944 let offset = transform.translation - target;
1945 assert!(
1946 (offset.length() - config.radius).abs() < 1e-5,
1947 "viewpoint {} has radius {}, expected {}",
1948 i,
1949 offset.length(),
1950 config.radius
1951 );
1952
1953 let forward = transform.forward();
1954 let to_target = (target - transform.translation).normalize();
1955 assert!(
1956 forward.dot(to_target) > 0.99,
1957 "viewpoint {} is not looking at target",
1958 i
1959 );
1960 }
1961 }
1962
1963 #[test]
1964 fn test_generate_viewpoints_keeps_origin_targeting() {
1965 let config = ViewpointConfig {
1966 radius: 1.0,
1967 yaw_count: 1,
1968 pitch_angles_deg: vec![0.0],
1969 };
1970
1971 let origin_view = generate_viewpoints(&config)[0];
1972 let explicit_origin_view = generate_viewpoints_around_target(&config, Vec3::ZERO)[0];
1973
1974 assert_vec3_close(origin_view.translation, explicit_origin_view.translation);
1975 let forward = origin_view.forward();
1976 let to_origin = (Vec3::ZERO - origin_view.translation).normalize();
1977 assert!(forward.dot(to_origin) > 0.99);
1978 }
1979
1980 #[test]
1981 fn test_object_centered_viewpoints_apply_yaw_rotation_to_target() {
1982 let config = ViewpointConfig {
1983 radius: 1.0,
1984 yaw_count: 1,
1985 pitch_angles_deg: vec![0.0],
1986 };
1987 let mesh_center = Vec3::new(0.25, 0.0, 0.0);
1988 let rotation = ObjectRotation::new(0.0, 90.0, 0.0);
1989
1990 let target = rotated_mesh_center(mesh_center, &rotation);
1991 assert!(target.distance(mesh_center) > 0.1);
1992
1993 let origin_view = generate_viewpoints(&config)[0];
1994 let centered_view = generate_object_centered_viewpoints(&config, mesh_center, &rotation)[0];
1995
1996 assert_vec3_close(centered_view.translation, origin_view.translation + target);
1997 let forward = centered_view.forward();
1998 let to_target = (target - centered_view.translation).normalize();
1999 assert!(forward.dot(to_target) > 0.99);
2000 }
2001
2002 #[test]
2003 fn test_load_ycb_mesh_bounds_from_standard_obj_path() {
2004 let dir = tempfile::tempdir().unwrap();
2005 let mesh_dir = dir.path().join("google_16k");
2006 std::fs::create_dir_all(&mesh_dir).unwrap();
2007 std::fs::write(
2008 mesh_dir.join("textured.obj"),
2009 "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",
2010 )
2011 .unwrap();
2012
2013 let bounds = load_ycb_mesh_bounds(dir.path()).unwrap();
2014
2015 assert_eq!(bounds.vertex_count, 3);
2016 assert_vec3_close(bounds.min, Vec3::new(-1.0, -2.0, -3.0));
2017 assert_vec3_close(bounds.max, Vec3::new(3.0, 4.0, 5.0));
2018 assert_vec3_close(bounds.center, Vec3::new(1.0, 1.0, 1.0));
2019 assert_vec3_close(bounds.extents(), Vec3::new(4.0, 6.0, 8.0));
2020 }
2021
2022 #[test]
2023 fn test_targeting_policy_serializes_stable_label() {
2024 assert_eq!(TargetingPolicy::Origin.label(), "origin");
2025 assert_eq!(TargetingPolicy::MeshCenter.label(), "mesh-center");
2026
2027 let json = serde_json::to_string(&TargetingPolicy::MeshCenter).unwrap();
2028 assert!(json.contains("mesh_center"));
2029 let loaded: TargetingPolicy = serde_json::from_str(&json).unwrap();
2030 assert_eq!(loaded, TargetingPolicy::MeshCenter);
2031 }
2032
2033 #[test]
2034 fn test_render_output_with_targeting_overrides_origin_default() {
2035 let target_point = Vec3::new(0.1, 0.2, -0.3);
2036 let output = render_output_for_depth(
2037 1,
2038 1,
2039 vec![1.0],
2040 RenderConfig::tbp_default().intrinsics(),
2041 Transform::IDENTITY,
2042 )
2043 .with_targeting(target_point, TargetingPolicy::MeshCenter);
2044
2045 assert_eq!(output.target_point, target_point);
2046 assert_eq!(output.targeting_policy, TargetingPolicy::MeshCenter);
2047 }
2048
2049 #[test]
2050 fn test_center_hit_validation_report_detects_zero_hit_rotation() {
2051 let report = CenterHitValidationReport {
2052 object_id: "test_object".to_string(),
2053 object_dir: "/tmp/ycb/test_object".to_string(),
2054 target_policy: TargetingPolicy::MeshCenter,
2055 rotations: vec![
2056 CenterHitRotationReport {
2057 rotation_index: 0,
2058 rotation_euler: [0.0, 0.0, 0.0],
2059 target_point: [0.0, 0.0, 0.0],
2060 mesh_bounds: None,
2061 total_viewpoints: 24,
2062 center_hits: 1,
2063 center_misses: 23,
2064 misses: Vec::new(),
2065 },
2066 CenterHitRotationReport {
2067 rotation_index: 1,
2068 rotation_euler: [0.0, 90.0, 0.0],
2069 target_point: [0.1, 0.0, 0.0],
2070 mesh_bounds: None,
2071 total_viewpoints: 24,
2072 center_hits: 0,
2073 center_misses: 24,
2074 misses: Vec::new(),
2075 },
2076 ],
2077 };
2078
2079 assert!(!report.is_valid());
2080 assert_eq!(report.zero_hit_rotations(), vec![1]);
2081 }
2082
2083 #[test]
2084 fn test_sensor_config_default() {
2085 let config = SensorConfig::default();
2086 assert_eq!(config.object_rotations.len(), 1);
2087 assert_eq!(config.total_captures(), 24);
2088 }
2089
2090 #[test]
2091 fn test_sensor_config_tbp_benchmark() {
2092 let config = SensorConfig::tbp_benchmark();
2093 assert_eq!(config.object_rotations.len(), 3);
2094 assert_eq!(config.total_captures(), 72); }
2096
2097 #[test]
2098 fn test_sensor_config_tbp_full() {
2099 let config = SensorConfig::tbp_full_training();
2100 assert_eq!(config.object_rotations.len(), 14);
2101 assert_eq!(config.total_captures(), 336); }
2103
2104 #[test]
2105 fn test_ycb_representative_objects() {
2106 assert_eq!(crate::ycb::REPRESENTATIVE_OBJECTS.len(), 3);
2108 assert!(crate::ycb::REPRESENTATIVE_OBJECTS.contains(&"003_cracker_box"));
2109 }
2110
2111 #[test]
2112 fn test_ycb_tbp_standard_objects() {
2113 assert_eq!(crate::ycb::TBP_STANDARD_OBJECTS.len(), 10);
2114 assert!(crate::ycb::TBP_STANDARD_OBJECTS.contains(&"025_mug"));
2115 }
2116
2117 #[test]
2118 fn test_ycb_tbp_similar_objects() {
2119 assert_eq!(crate::ycb::TBP_SIMILAR_OBJECTS.len(), 10);
2120 assert!(crate::ycb::TBP_SIMILAR_OBJECTS.contains(&"003_cracker_box"));
2121 }
2122
2123 #[test]
2124 fn test_ycb_object_mesh_path() {
2125 let path = crate::ycb::object_mesh_path("/tmp/ycb", "003_cracker_box");
2126 assert_eq!(
2127 path,
2128 std::path::Path::new("/tmp/ycb")
2129 .join("003_cracker_box")
2130 .join("google_16k")
2131 .join("textured.obj")
2132 );
2133 }
2134
2135 #[test]
2136 fn test_ycb_object_texture_path() {
2137 let path = crate::ycb::object_texture_path("/tmp/ycb", "003_cracker_box");
2138 assert_eq!(
2139 path,
2140 std::path::Path::new("/tmp/ycb")
2141 .join("003_cracker_box")
2142 .join("google_16k")
2143 .join("texture_map.png")
2144 );
2145 }
2146
2147 #[test]
2152 fn test_render_config_tbp_default() {
2153 let config = RenderConfig::tbp_default();
2154 assert_eq!(config.width, 64);
2156 assert_eq!(config.height, 64);
2157 assert!(config.zoom > 0.0);
2159 assert!(config.near_plane > 0.0);
2161 assert!(config.far_plane > config.near_plane);
2162 }
2163
2164 #[test]
2165 fn test_render_config_preview() {
2166 let config = RenderConfig::preview();
2167 assert_eq!(config.width, 256);
2168 assert_eq!(config.height, 256);
2169 }
2170
2171 #[test]
2172 fn test_render_config_default_is_tbp() {
2173 let default = RenderConfig::default();
2174 let tbp = RenderConfig::tbp_default();
2175 assert_eq!(default.width, tbp.width);
2176 assert_eq!(default.height, tbp.height);
2177 }
2178
2179 #[test]
2180 fn test_render_config_fov() {
2181 let config = RenderConfig::tbp_default();
2182 let fov = config.fov_radians();
2183 assert!(fov > 0.0);
2186 assert!(fov < PI);
2187
2188 let zoomed = RenderConfig {
2190 zoom: config.zoom * 2.0,
2191 ..config
2192 };
2193 assert!(zoomed.fov_radians() < fov);
2194 }
2195
2196 #[test]
2197 fn test_render_config_intrinsics() {
2198 let config = RenderConfig::tbp_default();
2199 let intrinsics = config.intrinsics();
2200
2201 assert_eq!(intrinsics.image_size, [config.width, config.height]);
2203 assert_eq!(
2204 intrinsics.principal_point,
2205 [config.width as f64 / 2.0, config.height as f64 / 2.0]
2206 );
2207 assert_eq!(intrinsics.focal_length[0], intrinsics.focal_length[1]);
2209 assert!(intrinsics.focal_length[0] > 0.0);
2210 }
2211
2212 #[test]
2213 fn test_render_config_intrinsics_for_size_uses_tbp_zoom_formula() {
2214 let config = RenderConfig {
2215 width: 64,
2216 height: 64,
2217 zoom: 4.0,
2218 ..RenderConfig::tbp_default()
2219 };
2220
2221 let intrinsics = config.intrinsics_for_size(64, 64);
2222
2223 assert!((intrinsics.focal_length[0] - 128.0).abs() < 1e-9);
2226 assert!((intrinsics.focal_length[1] - 128.0).abs() < 1e-9);
2227 assert_ne!(intrinsics.focal_length[0], 64.0 * config.zoom as f64);
2228 assert_eq!(intrinsics.principal_point, [32.0, 32.0]);
2229 assert_eq!(intrinsics.image_size, [64, 64]);
2230 }
2231
2232 #[test]
2233 fn test_render_config_intrinsics_for_size_tracks_actual_readback_size() {
2234 let config = RenderConfig {
2235 width: 64,
2236 height: 64,
2237 zoom: 4.0,
2238 ..RenderConfig::tbp_default()
2239 };
2240
2241 let intrinsics = config.intrinsics_for_size(128, 96);
2242
2243 assert!((intrinsics.focal_length[0] - 256.0).abs() < 1e-9);
2244 assert!((intrinsics.focal_length[1] - 256.0).abs() < 1e-9);
2245 assert_eq!(intrinsics.principal_point, [64.0, 48.0]);
2246 assert_eq!(intrinsics.image_size, [128, 96]);
2247 }
2248
2249 #[test]
2250 fn test_camera_intrinsics_project() {
2251 let intrinsics = CameraIntrinsics {
2252 focal_length: [100.0, 100.0],
2253 principal_point: [32.0, 32.0],
2254 image_size: [64, 64],
2255 };
2256
2257 let center = intrinsics.project(Vec3::new(0.0, 0.0, 1.0));
2259 assert!(center.is_some());
2260 let [x, y] = center.unwrap();
2261 assert!((x - 32.0).abs() < 0.001);
2262 assert!((y - 32.0).abs() < 0.001);
2263
2264 let behind = intrinsics.project(Vec3::new(0.0, 0.0, -1.0));
2266 assert!(behind.is_none());
2267 }
2268
2269 #[test]
2270 fn test_camera_intrinsics_unproject() {
2271 let intrinsics = CameraIntrinsics {
2272 focal_length: [100.0, 100.0],
2273 principal_point: [32.0, 32.0],
2274 image_size: [64, 64],
2275 };
2276
2277 let point = intrinsics.unproject([32.0, 32.0], 1.0);
2279 assert!((point[0]).abs() < 0.001); assert!((point[1]).abs() < 0.001); assert!((point[2] - 1.0).abs() < 0.001); }
2283
2284 #[test]
2285 fn test_render_output_get_rgba() {
2286 let output = RenderOutput {
2287 rgba: vec![
2288 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255,
2289 ],
2290 depth: vec![1.0, 2.0, 3.0, 4.0],
2291 width: 2,
2292 height: 2,
2293 intrinsics: RenderConfig::tbp_default().intrinsics(),
2294 camera_transform: Transform::IDENTITY,
2295 object_rotation: ObjectRotation::identity(),
2296 target_point: Vec3::ZERO,
2297 targeting_policy: TargetingPolicy::Origin,
2298 };
2299
2300 assert_eq!(output.get_rgba(0, 0), Some([255, 0, 0, 255]));
2302 assert_eq!(output.get_rgba(1, 0), Some([0, 255, 0, 255]));
2304 assert_eq!(output.get_rgba(0, 1), Some([0, 0, 255, 255]));
2306 assert_eq!(output.get_rgba(1, 1), Some([255, 255, 255, 255]));
2308 assert_eq!(output.get_rgba(2, 0), None);
2310 }
2311
2312 #[test]
2313 fn test_render_output_get_depth() {
2314 let output = RenderOutput {
2315 rgba: vec![0u8; 16],
2316 depth: vec![1.0, 2.0, 3.0, 4.0],
2317 width: 2,
2318 height: 2,
2319 intrinsics: RenderConfig::tbp_default().intrinsics(),
2320 camera_transform: Transform::IDENTITY,
2321 object_rotation: ObjectRotation::identity(),
2322 target_point: Vec3::ZERO,
2323 targeting_policy: TargetingPolicy::Origin,
2324 };
2325
2326 assert_eq!(output.get_depth(0, 0), Some(1.0));
2327 assert_eq!(output.get_depth(1, 0), Some(2.0));
2328 assert_eq!(output.get_depth(0, 1), Some(3.0));
2329 assert_eq!(output.get_depth(1, 1), Some(4.0));
2330 assert_eq!(output.get_depth(2, 0), None);
2331 }
2332
2333 #[test]
2334 fn test_render_output_to_rgb_image() {
2335 let output = RenderOutput {
2336 rgba: vec![
2337 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255,
2338 ],
2339 depth: vec![1.0, 2.0, 3.0, 4.0],
2340 width: 2,
2341 height: 2,
2342 intrinsics: RenderConfig::tbp_default().intrinsics(),
2343 camera_transform: Transform::IDENTITY,
2344 object_rotation: ObjectRotation::identity(),
2345 target_point: Vec3::ZERO,
2346 targeting_policy: TargetingPolicy::Origin,
2347 };
2348
2349 let image = output.to_rgb_image();
2350 assert_eq!(image.len(), 2); assert_eq!(image[0].len(), 2); assert_eq!(image[0][0], [255, 0, 0]); assert_eq!(image[0][1], [0, 255, 0]); assert_eq!(image[1][0], [0, 0, 255]); assert_eq!(image[1][1], [255, 255, 255]); }
2357
2358 #[test]
2359 fn test_render_output_to_depth_image() {
2360 let output = RenderOutput {
2361 rgba: vec![0u8; 16],
2362 depth: vec![1.0, 2.0, 3.0, 4.0],
2363 width: 2,
2364 height: 2,
2365 intrinsics: RenderConfig::tbp_default().intrinsics(),
2366 camera_transform: Transform::IDENTITY,
2367 object_rotation: ObjectRotation::identity(),
2368 target_point: Vec3::ZERO,
2369 targeting_policy: TargetingPolicy::Origin,
2370 };
2371
2372 let depth_image = output.to_depth_image();
2373 assert_eq!(depth_image.len(), 2);
2374 assert_eq!(depth_image[0], vec![1.0, 2.0]);
2375 assert_eq!(depth_image[1], vec![3.0, 4.0]);
2376 }
2377
2378 #[test]
2379 fn test_render_health_center_hit() {
2380 let mut depth = vec![10.0; 7 * 7];
2381 depth[3 * 7 + 3] = 0.25;
2382 depth[6 * 7 + 6] = 0.5;
2383 let output = render_output_for_depth(
2384 7,
2385 7,
2386 depth,
2387 CameraIntrinsics {
2388 focal_length: [10.0, 10.0],
2389 principal_point: [3.0, 3.0],
2390 image_size: [7, 7],
2391 },
2392 Transform::IDENTITY,
2393 );
2394
2395 let health = output.health();
2396
2397 assert_eq!(health.center_pixel, Some([3, 3]));
2398 assert_eq!(health.center_depth, Some(0.25));
2399 assert!(health.center_foreground);
2400 assert_eq!(health.foreground_pixel_count, 2);
2401 assert!((health.foreground_coverage - 2.0 / 49.0).abs() < 1e-12);
2402 assert_eq!(health.center_5x5_foreground_count, 1);
2403 assert_eq!(health.nearest_foreground_pixel, Some([3, 3]));
2404 assert_eq!(health.nearest_foreground_depth, Some(0.25));
2405 assert_eq!(health.nearest_foreground_distance_px, Some(0.0));
2406 }
2407
2408 #[test]
2409 fn test_render_health_far_center_uses_nearest_foreground() {
2410 let mut depth = vec![10.0; 7 * 7];
2411 depth[3 * 7 + 1] = 0.5;
2412 let output = render_output_for_depth(
2413 7,
2414 7,
2415 depth,
2416 CameraIntrinsics {
2417 focal_length: [10.0, 10.0],
2418 principal_point: [3.0, 3.0],
2419 image_size: [7, 7],
2420 },
2421 Transform::IDENTITY,
2422 );
2423
2424 let health = output.health();
2425
2426 assert_eq!(health.center_pixel, Some([3, 3]));
2427 assert_eq!(health.center_depth, Some(10.0));
2428 assert!(!health.center_foreground);
2429 assert_eq!(health.foreground_pixel_count, 1);
2430 assert_eq!(health.center_5x5_foreground_count, 1);
2431 assert_eq!(health.nearest_foreground_pixel, Some([1, 3]));
2432 assert_eq!(health.nearest_foreground_depth, Some(0.5));
2433 assert_eq!(health.nearest_foreground_distance_px, Some(2.0));
2434 }
2435
2436 #[test]
2437 fn test_center_surface_point_world_uses_bevy_camera_forward() {
2438 let mut depth = vec![10.0; 3 * 3];
2439 depth[3 + 1] = 0.25;
2440 let output = render_output_for_depth(
2441 3,
2442 3,
2443 depth,
2444 CameraIntrinsics {
2445 focal_length: [1.0, 1.0],
2446 principal_point: [1.0, 1.0],
2447 image_size: [3, 3],
2448 },
2449 Transform::IDENTITY,
2450 );
2451
2452 assert_eq!(output.center_pixel_depth(), Some(0.25));
2453 assert_point_close(
2454 output.center_surface_point_world().expect("surface point"),
2455 [0.0, 0.0, -0.25],
2456 );
2457 }
2458
2459 #[test]
2460 fn test_pixel_surface_point_world_maps_image_y_down_to_camera_y_up() {
2461 let mut depth = vec![10.0; 3 * 3];
2462 depth[2] = 2.0;
2463 let output = render_output_for_depth(
2464 3,
2465 3,
2466 depth,
2467 CameraIntrinsics {
2468 focal_length: [1.0, 1.0],
2469 principal_point: [1.0, 1.0],
2470 image_size: [3, 3],
2471 },
2472 Transform::IDENTITY,
2473 );
2474
2475 assert_point_close(
2476 output
2477 .pixel_surface_point_world([2, 0])
2478 .expect("surface point"),
2479 [2.0, 2.0, -2.0],
2480 );
2481 }
2482
2483 #[test]
2484 fn test_camera_world_point_helpers_roundtrip() {
2485 let output = render_output_for_depth(
2486 1,
2487 1,
2488 vec![0.25],
2489 CameraIntrinsics {
2490 focal_length: [1.0, 1.0],
2491 principal_point: [0.0, 0.0],
2492 image_size: [1, 1],
2493 },
2494 Transform::from_xyz(0.0, 0.0, 1.0).looking_at(Vec3::ZERO, Vec3::Y),
2495 );
2496
2497 assert_point_close(
2498 output.center_surface_point_world().expect("surface point"),
2499 [0.0, 0.0, 0.75],
2500 );
2501
2502 let world_point = [0.1, -0.2, 0.7];
2503 let camera_point = output.world_to_camera_point(world_point);
2504 assert_point_close(output.camera_to_world_point(camera_point), world_point);
2505 }
2506
2507 #[test]
2508 fn test_render_error_display() {
2509 let err = RenderError::MeshNotFound("/path/to/mesh.obj".to_string());
2510 assert!(err.to_string().contains("Mesh not found"));
2511 assert!(err.to_string().contains("/path/to/mesh.obj"));
2512 }
2513
2514 #[test]
2519 fn test_object_rotation_extreme_angles() {
2520 let rot = ObjectRotation::new(450.0, -720.0, 1080.0);
2522 let quat = rot.to_quat();
2523 assert!((quat.length() - 1.0).abs() < 0.001);
2525 }
2526
2527 #[test]
2528 fn test_object_rotation_to_transform() {
2529 let rot = ObjectRotation::new(45.0, 90.0, 0.0);
2530 let transform = rot.to_transform();
2531 assert_eq!(transform.translation, Vec3::ZERO);
2533 assert!(transform.rotation != Quat::IDENTITY);
2535 }
2536
2537 #[test]
2538 fn test_viewpoint_config_single_viewpoint() {
2539 let config = ViewpointConfig {
2540 radius: 1.0,
2541 yaw_count: 1,
2542 pitch_angles_deg: vec![0.0],
2543 };
2544 assert_eq!(config.viewpoint_count(), 1);
2545 let viewpoints = generate_viewpoints(&config);
2546 assert_eq!(viewpoints.len(), 1);
2547 let pos = viewpoints[0].translation;
2549 assert!((pos.x).abs() < 0.001);
2550 assert!((pos.y).abs() < 0.001);
2551 assert!((pos.z - 1.0).abs() < 0.001);
2552 }
2553
2554 #[test]
2555 fn test_viewpoint_radius_scaling() {
2556 let config1 = ViewpointConfig {
2557 radius: 0.5,
2558 yaw_count: 4,
2559 pitch_angles_deg: vec![0.0],
2560 };
2561 let config2 = ViewpointConfig {
2562 radius: 2.0,
2563 yaw_count: 4,
2564 pitch_angles_deg: vec![0.0],
2565 };
2566
2567 let v1 = generate_viewpoints(&config1);
2568 let v2 = generate_viewpoints(&config2);
2569
2570 for (vp1, vp2) in v1.iter().zip(v2.iter()) {
2572 let ratio = vp2.translation.length() / vp1.translation.length();
2573 assert!((ratio - 4.0).abs() < 0.01); }
2575 }
2576
2577 #[test]
2578 fn test_camera_intrinsics_project_at_z_zero() {
2579 let intrinsics = CameraIntrinsics {
2580 focal_length: [100.0, 100.0],
2581 principal_point: [32.0, 32.0],
2582 image_size: [64, 64],
2583 };
2584
2585 let result = intrinsics.project(Vec3::new(1.0, 1.0, 0.0));
2587 assert!(result.is_none());
2588 }
2589
2590 #[test]
2591 fn test_camera_intrinsics_roundtrip() {
2592 let intrinsics = CameraIntrinsics {
2593 focal_length: [100.0, 100.0],
2594 principal_point: [32.0, 32.0],
2595 image_size: [64, 64],
2596 };
2597
2598 let original = Vec3::new(0.5, -0.3, 2.0);
2600 let projected = intrinsics.project(original).unwrap();
2601
2602 let unprojected = intrinsics.unproject(projected, original.z as f64);
2604
2605 assert!((unprojected[0] - original.x as f64).abs() < 0.001); assert!((unprojected[1] - original.y as f64).abs() < 0.001); assert!((unprojected[2] - original.z as f64).abs() < 0.001); }
2610
2611 #[test]
2612 fn test_render_output_empty() {
2613 let output = RenderOutput {
2614 rgba: vec![],
2615 depth: vec![],
2616 width: 0,
2617 height: 0,
2618 intrinsics: RenderConfig::tbp_default().intrinsics(),
2619 camera_transform: Transform::IDENTITY,
2620 object_rotation: ObjectRotation::identity(),
2621 target_point: Vec3::ZERO,
2622 targeting_policy: TargetingPolicy::Origin,
2623 };
2624
2625 assert_eq!(output.get_rgba(0, 0), None);
2627 assert_eq!(output.get_depth(0, 0), None);
2628 assert!(output.to_rgb_image().is_empty());
2629 assert!(output.to_depth_image().is_empty());
2630 }
2631
2632 #[test]
2633 fn test_render_output_1x1() {
2634 let output = RenderOutput {
2635 rgba: vec![128, 64, 32, 255],
2636 depth: vec![0.5],
2637 width: 1,
2638 height: 1,
2639 intrinsics: RenderConfig::tbp_default().intrinsics(),
2640 camera_transform: Transform::IDENTITY,
2641 object_rotation: ObjectRotation::identity(),
2642 target_point: Vec3::ZERO,
2643 targeting_policy: TargetingPolicy::Origin,
2644 };
2645
2646 assert_eq!(output.get_rgba(0, 0), Some([128, 64, 32, 255]));
2647 assert_eq!(output.get_depth(0, 0), Some(0.5));
2648 assert_eq!(output.get_rgb(0, 0), Some([128, 64, 32]));
2649
2650 let rgb_img = output.to_rgb_image();
2651 assert_eq!(rgb_img.len(), 1);
2652 assert_eq!(rgb_img[0].len(), 1);
2653 assert_eq!(rgb_img[0][0], [128, 64, 32]);
2654 }
2655
2656 #[test]
2657 fn test_render_config_high_res() {
2658 let config = RenderConfig::high_res();
2659 assert_eq!(config.width, 512);
2660 assert_eq!(config.height, 512);
2661
2662 let intrinsics = config.intrinsics();
2663 assert_eq!(intrinsics.image_size, [512, 512]);
2664 assert_eq!(intrinsics.principal_point, [256.0, 256.0]);
2665 }
2666
2667 #[test]
2668 fn test_render_config_zoom_affects_fov() {
2669 let base = RenderConfig {
2674 zoom: 2.0,
2675 ..RenderConfig::tbp_default()
2676 };
2677 let doubled = RenderConfig {
2678 zoom: 4.0,
2679 ..RenderConfig::tbp_default()
2680 };
2681
2682 assert!(doubled.fov_radians() < base.fov_radians());
2684
2685 let base_half_tan = (base.fov_radians() / 2.0).tan();
2687 let doubled_half_tan = (doubled.fov_radians() / 2.0).tan();
2688 assert!((base_half_tan / doubled_half_tan - 2.0).abs() < 1e-4);
2689 }
2690
2691 #[test]
2692 fn test_render_config_zoom_affects_intrinsics() {
2693 let a = RenderConfig {
2696 zoom: 2.0,
2697 ..RenderConfig::tbp_default()
2698 };
2699 let b = RenderConfig {
2700 zoom: 4.0,
2701 ..RenderConfig::tbp_default()
2702 };
2703
2704 let fx_a = a.intrinsics().focal_length[0];
2705 let fx_b = b.intrinsics().focal_length[0];
2706
2707 assert!(fx_b > fx_a);
2709
2710 assert!((fx_a / a.zoom as f64 - fx_b / b.zoom as f64).abs() < 1e-9);
2712 }
2713
2714 #[test]
2715 fn test_lighting_config_variants() {
2716 let default = LightingConfig::default();
2717 let bright = LightingConfig::bright();
2718 let soft = LightingConfig::soft();
2719 let unlit = LightingConfig::unlit();
2720
2721 assert!(bright.key_light_intensity > default.key_light_intensity);
2723
2724 assert_eq!(unlit.key_light_intensity, 0.0);
2726 assert_eq!(unlit.fill_light_intensity, 0.0);
2727 assert_eq!(unlit.ambient_brightness, 1.0);
2728
2729 assert!(soft.key_light_intensity < default.key_light_intensity);
2731 }
2732
2733 #[test]
2734 fn test_all_render_error_variants() {
2735 let errors = vec![
2736 RenderError::MeshNotFound("mesh.obj".to_string()),
2737 RenderError::TextureNotFound("texture.png".to_string()),
2738 RenderError::RenderFailed("GPU error".to_string()),
2739 RenderError::InvalidConfig("bad config".to_string()),
2740 ];
2741
2742 for err in errors {
2743 let msg = err.to_string();
2745 assert!(!msg.is_empty());
2746 }
2747 }
2748
2749 #[test]
2750 fn test_tbp_known_orientations_unique() {
2751 let orientations = ObjectRotation::tbp_known_orientations();
2752
2753 let quats: Vec<Quat> = orientations.iter().map(|r| r.to_quat()).collect();
2755
2756 for (i, q1) in quats.iter().enumerate() {
2757 for (j, q2) in quats.iter().enumerate() {
2758 if i != j {
2759 let dot = q1.dot(*q2).abs();
2761 assert!(
2762 dot < 0.999,
2763 "Orientations {} and {} produce same quaternion",
2764 i,
2765 j
2766 );
2767 }
2768 }
2769 }
2770 }
2771}