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
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 pub const TBP_FAR_PLANE_METERS: f64 = 10.0;
949
950 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 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 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 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 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 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 pub fn center_pixel_depth(&self) -> Option<f64> {
1008 self.center_pixel_depth_with_far_plane(Self::TBP_FAR_PLANE_METERS)
1009 }
1010
1011 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 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 pub fn health(&self) -> RenderHealth {
1024 self.health_with_far_plane(Self::TBP_FAR_PLANE_METERS)
1025 }
1026
1027 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 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 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 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 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 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 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 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 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 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 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#[derive(Debug, Clone)]
1215pub enum RenderError {
1216 MeshNotFound(String),
1218 TextureNotFound(String),
1220 FileNotFound { path: String, reason: String },
1222 FileWriteFailed { path: String, reason: String },
1224 DirectoryCreationFailed { path: String, reason: String },
1226 RenderFailed(String),
1228 InvalidConfig(String),
1230 InvalidInput(String),
1232 SerializationError(String),
1234 DataParsingError(String),
1236 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
1268pub fn render_to_buffer(
1293 object_dir: &Path,
1294 camera_transform: &Transform,
1295 object_rotation: &ObjectRotation,
1296 config: &RenderConfig,
1297) -> Result<RenderOutput, RenderError> {
1298 render::render_headless(object_dir, camera_transform, object_rotation, config)
1300}
1301
1302pub 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
1319pub 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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1352pub struct CenterHitValidationReport {
1353 pub object_id: String,
1355 pub object_dir: String,
1357 pub target_policy: TargetingPolicy,
1359 pub rotations: Vec<CenterHitRotationReport>,
1361}
1362
1363impl CenterHitValidationReport {
1364 pub fn is_valid(&self) -> bool {
1366 self.rotations
1367 .iter()
1368 .all(|rotation| rotation.center_hits > 0)
1369 }
1370
1371 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#[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#[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#[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
1423pub 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
1499pub 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 cache.cache_scene(mesh_path.clone());
1585 cache.cache_texture(texture_path.clone());
1586
1587 render::render_headless(object_dir, camera_transform, object_rotation, config)
1589}
1590
1591pub 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
1625pub use batch::{
1627 BatchRenderConfig, BatchRenderError, BatchRenderOutput, BatchRenderRequest, BatchRenderer,
1628 BatchState, RenderStatus,
1629};
1630
1631pub use render::RenderSession;
1634
1635pub use render::PersistentRenderer;
1641
1642pub fn create_batch_renderer(config: &BatchRenderConfig) -> Result<BatchRenderer, RenderError> {
1660 Ok(BatchRenderer::new(config.clone()))
1661}
1662
1663pub 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
1698pub 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
1739pub 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 for request in requests {
1786 queue_render_request(&mut renderer, request)?;
1787 }
1788
1789 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
1810pub 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 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 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); }
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); }
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); }
2178
2179 #[test]
2180 fn test_ycb_representative_objects() {
2181 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 #[test]
2227 fn test_render_config_tbp_default() {
2228 let config = RenderConfig::tbp_default();
2229 assert_eq!(config.width, 64);
2231 assert_eq!(config.height, 64);
2232 assert!(config.zoom > 0.0);
2234 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 assert!(fov > 0.0);
2261 assert!(fov < PI);
2262
2263 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 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 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 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 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 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 let point = intrinsics.unproject([32.0, 32.0], 1.0);
2354 assert!((point[0]).abs() < 0.001); assert!((point[1]).abs() < 0.001); assert!((point[2] - 1.0).abs() < 0.001); }
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 assert_eq!(output.get_rgba(0, 0), Some([255, 0, 0, 255]));
2377 assert_eq!(output.get_rgba(1, 0), Some([0, 255, 0, 255]));
2379 assert_eq!(output.get_rgba(0, 1), Some([0, 0, 255, 255]));
2381 assert_eq!(output.get_rgba(1, 1), Some([255, 255, 255, 255]));
2383 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); 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]); }
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 #[test]
2653 fn test_object_rotation_extreme_angles() {
2654 let rot = ObjectRotation::new(450.0, -720.0, 1080.0);
2656 let quat = rot.to_quat();
2657 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 assert_eq!(transform.translation, Vec3::ZERO);
2667 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 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 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); }
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 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 let original = Vec3::new(0.5, -0.3, 2.0);
2734 let projected = intrinsics.project(original).unwrap();
2735
2736 let unprojected = intrinsics.unproject(projected, original.z as f64);
2738
2739 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); }
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 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 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 assert!(doubled.fov_radians() < base.fov_radians());
2818
2819 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 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 assert!(fx_b > fx_a);
2843
2844 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 assert!(bright.key_light_intensity > default.key_light_intensity);
2857
2858 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 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 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 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 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}