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 benchmark;
67
68pub mod backend;
70
71pub mod cache;
73
74pub mod fixtures;
76
77pub const RENDERER_POLICY_VERSION: &str = "tbp-targeting-v1";
79
80pub use ycbust::{
82 self, DownloadOptions, Subset as YcbSubset, GOOGLE_16K_MESH_RELATIVE, REPRESENTATIVE_OBJECTS,
83 TBP_SIMILAR_OBJECTS, TBP_STANDARD_OBJECTS,
84};
85
86pub mod ycb {
88 pub use ycbust::{
89 download_ycb, DownloadOptions, Subset, REPRESENTATIVE_OBJECTS, TBP_SIMILAR_OBJECTS,
90 TBP_STANDARD_OBJECTS,
91 };
92
93 use std::path::Path;
94
95 pub async fn download_models<P: AsRef<Path>>(
108 output_dir: P,
109 subset: Subset,
110 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
111 download_ycb(subset, output_dir.as_ref(), DownloadOptions::default()).await?;
112 Ok(())
113 }
114
115 pub async fn download_models_with_options<P: AsRef<Path>>(
117 output_dir: P,
118 subset: Subset,
119 options: DownloadOptions,
120 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
121 download_ycb(subset, output_dir.as_ref(), options).await?;
122 Ok(())
123 }
124
125 pub async fn download_objects<P: AsRef<Path>>(
131 output_dir: P,
132 object_ids: &[&str],
133 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
134 ycbust::download_objects(object_ids, output_dir.as_ref(), DownloadOptions::default())
135 .await?;
136 Ok(())
137 }
138
139 pub fn missing_objects<P: AsRef<Path>>(output_dir: P, object_ids: &[&str]) -> Vec<String> {
141 ycbust::validate_objects(output_dir.as_ref(), object_ids)
142 .into_iter()
143 .filter(|validation| !validation.is_complete())
144 .map(|validation| validation.name)
145 .collect()
146 }
147
148 pub fn objects_exist<P: AsRef<Path>>(output_dir: P, object_ids: &[&str]) -> bool {
150 missing_objects(output_dir, object_ids).is_empty()
151 }
152
153 pub fn models_exist<P: AsRef<Path>>(output_dir: P) -> bool {
155 objects_exist(output_dir, REPRESENTATIVE_OBJECTS)
156 }
157
158 pub fn object_mesh_path<P: AsRef<Path>>(output_dir: P, object_id: &str) -> std::path::PathBuf {
160 ycbust::object_mesh_path(output_dir.as_ref(), object_id)
161 }
162
163 pub fn object_texture_path<P: AsRef<Path>>(
165 output_dir: P,
166 object_id: &str,
167 ) -> std::path::PathBuf {
168 ycbust::object_texture_path(output_dir.as_ref(), object_id)
169 }
170}
171
172pub fn initialize() {
206 use std::sync::atomic::{AtomicBool, Ordering};
208 static INITIALIZED: AtomicBool = AtomicBool::new(false);
209
210 if !INITIALIZED.swap(true, Ordering::SeqCst) {
211 let config = backend::BackendConfig::new();
213 config.apply_env();
214 }
215}
216
217#[derive(Clone, Debug, PartialEq)]
220pub struct ObjectRotation {
221 pub pitch: f64,
223 pub yaw: f64,
225 pub roll: f64,
227}
228
229impl ObjectRotation {
230 pub fn new(pitch: f64, yaw: f64, roll: f64) -> Self {
232 Self { pitch, yaw, roll }
233 }
234
235 pub fn from_array(arr: [f64; 3]) -> Self {
237 Self {
238 pitch: arr[0],
239 yaw: arr[1],
240 roll: arr[2],
241 }
242 }
243
244 pub fn identity() -> Self {
246 Self::new(0.0, 0.0, 0.0)
247 }
248
249 pub fn tbp_benchmark_rotations() -> Vec<Self> {
252 vec![
253 Self::from_array([0.0, 0.0, 0.0]),
254 Self::from_array([0.0, 90.0, 0.0]),
255 Self::from_array([0.0, 180.0, 0.0]),
256 ]
257 }
258
259 pub fn tbp_known_orientations() -> Vec<Self> {
262 vec![
263 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]),
272 Self::from_array([45.0, 135.0, 0.0]),
273 Self::from_array([45.0, 225.0, 0.0]),
274 Self::from_array([45.0, 315.0, 0.0]),
275 Self::from_array([-45.0, 45.0, 0.0]),
276 Self::from_array([-45.0, 135.0, 0.0]),
277 Self::from_array([-45.0, 225.0, 0.0]),
278 Self::from_array([-45.0, 315.0, 0.0]),
279 ]
280 }
281
282 pub fn to_quat(&self) -> Quat {
284 Quat::from_euler(
285 EulerRot::XYZ,
286 (self.pitch as f32).to_radians(),
287 (self.yaw as f32).to_radians(),
288 (self.roll as f32).to_radians(),
289 )
290 }
291
292 pub fn to_transform(&self) -> Transform {
294 Transform::from_rotation(self.to_quat())
295 }
296
297 pub fn to_transform_with_translation_scale(&self, translation: Vec3, scale: Vec3) -> Transform {
299 Transform {
300 translation,
301 rotation: self.to_quat(),
302 scale,
303 }
304 }
305}
306
307impl Default for ObjectRotation {
308 fn default() -> Self {
309 Self::identity()
310 }
311}
312
313#[derive(Clone, Debug)]
316pub struct ViewpointConfig {
317 pub radius: f32,
319 pub yaw_count: usize,
321 pub pitch_angles_deg: Vec<f32>,
323}
324
325impl Default for ViewpointConfig {
326 fn default() -> Self {
327 Self {
328 radius: 0.5,
329 yaw_count: 8,
330 pitch_angles_deg: vec![-30.0, 0.0, 30.0],
333 }
334 }
335}
336
337impl ViewpointConfig {
338 pub fn viewpoint_count(&self) -> usize {
340 self.yaw_count * self.pitch_angles_deg.len()
341 }
342}
343
344#[derive(Clone, Copy, Debug, PartialEq)]
346pub struct MeshBounds {
347 pub min: Vec3,
349 pub max: Vec3,
351 pub center: Vec3,
353 pub vertex_count: usize,
355}
356
357impl MeshBounds {
358 pub fn extents(&self) -> Vec3 {
360 self.max - self.min
361 }
362}
363
364#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
366#[serde(tag = "policy", content = "target", rename_all = "snake_case")]
367pub enum TargetingPolicy {
368 Origin,
370 MeshCenter,
372 ExplicitTarget([f32; 3]),
374}
375
376impl TargetingPolicy {
377 pub fn label(&self) -> &'static str {
379 match self {
380 TargetingPolicy::Origin => "origin",
381 TargetingPolicy::MeshCenter => "mesh-center",
382 TargetingPolicy::ExplicitTarget(_) => "explicit-target",
383 }
384 }
385}
386
387#[derive(Clone, Debug, PartialEq)]
389pub struct TargetedViewpoints {
390 pub policy: TargetingPolicy,
392 pub target_point: Vec3,
394 pub mesh_bounds: Option<MeshBounds>,
396 pub viewpoints: Vec<Transform>,
398}
399
400#[derive(Clone, Debug, Resource)]
402pub struct SensorConfig {
403 pub viewpoints: ViewpointConfig,
405 pub object_rotations: Vec<ObjectRotation>,
407 pub output_dir: String,
409 pub filename_pattern: String,
411}
412
413impl Default for SensorConfig {
414 fn default() -> Self {
415 Self {
416 viewpoints: ViewpointConfig::default(),
417 object_rotations: vec![ObjectRotation::identity()],
418 output_dir: ".".to_string(),
419 filename_pattern: "capture_{rot}_{view}.png".to_string(),
420 }
421 }
422}
423
424impl SensorConfig {
425 pub fn tbp_benchmark() -> Self {
427 Self {
428 viewpoints: ViewpointConfig::default(),
429 object_rotations: ObjectRotation::tbp_benchmark_rotations(),
430 output_dir: ".".to_string(),
431 filename_pattern: "capture_{rot}_{view}.png".to_string(),
432 }
433 }
434
435 pub fn tbp_full_training() -> Self {
437 Self {
438 viewpoints: ViewpointConfig::default(),
439 object_rotations: ObjectRotation::tbp_known_orientations(),
440 output_dir: ".".to_string(),
441 filename_pattern: "capture_{rot}_{view}.png".to_string(),
442 }
443 }
444
445 pub fn total_captures(&self) -> usize {
447 self.viewpoints.viewpoint_count() * self.object_rotations.len()
448 }
449}
450
451pub fn generate_viewpoints(config: &ViewpointConfig) -> Vec<Transform> {
458 generate_viewpoints_around_target(config, Vec3::ZERO)
459}
460
461pub fn generate_viewpoints_around_target(config: &ViewpointConfig, target: Vec3) -> Vec<Transform> {
468 let mut views = Vec::with_capacity(config.viewpoint_count());
469
470 for pitch_deg in &config.pitch_angles_deg {
471 let pitch = pitch_deg.to_radians();
472
473 for i in 0..config.yaw_count {
474 let yaw = (i as f32) * 2.0 * PI / (config.yaw_count as f32);
475
476 let x = config.radius * pitch.cos() * yaw.sin();
481 let y = config.radius * pitch.sin();
482 let z = config.radius * pitch.cos() * yaw.cos();
483
484 let translation = target + Vec3::new(x, y, z);
485 let transform = Transform::from_translation(translation).looking_at(target, Vec3::Y);
486 views.push(transform);
487 }
488 }
489 views
490}
491
492pub fn rotated_mesh_center(mesh_center: Vec3, object_rotation: &ObjectRotation) -> Vec3 {
499 object_rotation.to_quat() * mesh_center
500}
501
502pub fn generate_object_centered_viewpoints(
508 config: &ViewpointConfig,
509 mesh_center: Vec3,
510 object_rotation: &ObjectRotation,
511) -> Vec<Transform> {
512 generate_viewpoints_around_target(config, rotated_mesh_center(mesh_center, object_rotation))
513}
514
515pub fn load_mesh_bounds(mesh_path: &Path) -> Result<MeshBounds, RenderError> {
521 if !mesh_path.exists() {
522 return Err(RenderError::MeshNotFound(mesh_path.display().to_string()));
523 }
524
525 let (models, _) = tobj::load_obj(
526 mesh_path,
527 &tobj::LoadOptions {
528 triangulate: false,
529 single_index: true,
530 ..Default::default()
531 },
532 )
533 .map_err(|err| {
534 RenderError::DataParsingError(format!(
535 "Failed to parse OBJ mesh {}: {}",
536 mesh_path.display(),
537 err
538 ))
539 })?;
540
541 let mut min = Vec3::splat(f32::INFINITY);
542 let mut max = Vec3::splat(f32::NEG_INFINITY);
543 let mut vertex_count = 0usize;
544
545 for model in models {
546 for vertex in model.mesh.positions.chunks_exact(3) {
547 let point = Vec3::new(vertex[0], vertex[1], vertex[2]);
548 min = min.min(point);
549 max = max.max(point);
550 vertex_count += 1;
551 }
552 }
553
554 if vertex_count == 0 {
555 return Err(RenderError::DataParsingError(format!(
556 "OBJ mesh {} contains no vertices",
557 mesh_path.display()
558 )));
559 }
560
561 Ok(MeshBounds {
562 min,
563 max,
564 center: (min + max) * 0.5,
565 vertex_count,
566 })
567}
568
569pub fn load_ycb_mesh_bounds(object_dir: &Path) -> Result<MeshBounds, RenderError> {
571 load_mesh_bounds(&object_dir.join(GOOGLE_16K_MESH_RELATIVE))
572}
573
574pub fn generate_ycb_object_centered_viewpoints(
576 object_dir: &Path,
577 config: &ViewpointConfig,
578 object_rotation: &ObjectRotation,
579) -> Result<Vec<Transform>, RenderError> {
580 let bounds = load_ycb_mesh_bounds(object_dir)?;
581 Ok(generate_object_centered_viewpoints(
582 config,
583 bounds.center,
584 object_rotation,
585 ))
586}
587
588pub fn generate_targeted_viewpoints(
590 object_dir: &Path,
591 config: &ViewpointConfig,
592 object_rotation: &ObjectRotation,
593 policy: &TargetingPolicy,
594) -> Result<TargetedViewpoints, RenderError> {
595 match policy {
596 TargetingPolicy::Origin => Ok(TargetedViewpoints {
597 policy: policy.clone(),
598 target_point: Vec3::ZERO,
599 mesh_bounds: None,
600 viewpoints: generate_viewpoints(config),
601 }),
602 TargetingPolicy::MeshCenter => {
603 let bounds = load_ycb_mesh_bounds(object_dir)?;
604 let target_point = rotated_mesh_center(bounds.center, object_rotation);
605 Ok(TargetedViewpoints {
606 policy: policy.clone(),
607 target_point,
608 mesh_bounds: Some(bounds),
609 viewpoints: generate_viewpoints_around_target(config, target_point),
610 })
611 }
612 TargetingPolicy::ExplicitTarget(target) => {
613 let target_point = Vec3::from_array(*target);
614 Ok(TargetedViewpoints {
615 policy: policy.clone(),
616 target_point,
617 mesh_bounds: None,
618 viewpoints: generate_viewpoints_around_target(config, target_point),
619 })
620 }
621 }
622}
623
624#[derive(Component)]
626pub struct CaptureTarget;
627
628#[derive(Component)]
630pub struct CaptureCamera;
631
632#[derive(Clone, Debug, PartialEq)]
640pub struct RenderConfig {
641 pub width: u32,
643 pub height: u32,
645 pub zoom: f32,
648 pub near_plane: f32,
650 pub far_plane: f32,
652 pub lighting: LightingConfig,
654}
655
656#[derive(Clone, Debug, PartialEq)]
660pub struct LightingConfig {
661 pub ambient_brightness: f32,
663 pub key_light_intensity: f32,
665 pub key_light_position: [f32; 3],
667 pub fill_light_intensity: f32,
669 pub fill_light_position: [f32; 3],
671 pub shadows_enabled: bool,
673}
674
675impl Default for LightingConfig {
676 fn default() -> Self {
677 Self {
678 ambient_brightness: 0.3,
679 key_light_intensity: 1500.0,
680 key_light_position: [4.0, 8.0, 4.0],
681 fill_light_intensity: 500.0,
682 fill_light_position: [-4.0, 2.0, -4.0],
683 shadows_enabled: false,
684 }
685 }
686}
687
688impl LightingConfig {
689 pub fn bright() -> Self {
691 Self {
692 ambient_brightness: 0.5,
693 key_light_intensity: 2000.0,
694 key_light_position: [4.0, 8.0, 4.0],
695 fill_light_intensity: 800.0,
696 fill_light_position: [-4.0, 2.0, -4.0],
697 shadows_enabled: false,
698 }
699 }
700
701 pub fn soft() -> Self {
703 Self {
704 ambient_brightness: 0.4,
705 key_light_intensity: 1000.0,
706 key_light_position: [3.0, 6.0, 3.0],
707 fill_light_intensity: 600.0,
708 fill_light_position: [-3.0, 3.0, -3.0],
709 shadows_enabled: false,
710 }
711 }
712
713 pub fn unlit() -> Self {
715 Self {
716 ambient_brightness: 1.0,
717 key_light_intensity: 0.0,
718 key_light_position: [0.0, 0.0, 0.0],
719 fill_light_intensity: 0.0,
720 fill_light_position: [0.0, 0.0, 0.0],
721 shadows_enabled: false,
722 }
723 }
724}
725
726impl Default for RenderConfig {
727 fn default() -> Self {
728 Self::tbp_default()
729 }
730}
731
732impl RenderConfig {
733 pub fn tbp_default() -> Self {
741 Self {
742 width: 64,
743 height: 64,
744 zoom: 4.0,
745 near_plane: 0.01,
746 far_plane: 10.0,
747 lighting: LightingConfig::default(),
748 }
749 }
750
751 pub fn preview() -> Self {
753 Self {
754 width: 256,
755 height: 256,
756 zoom: 1.0,
757 near_plane: 0.01,
758 far_plane: 10.0,
759 lighting: LightingConfig::default(),
760 }
761 }
762
763 pub fn high_res() -> Self {
765 Self {
766 width: 512,
767 height: 512,
768 zoom: 1.0,
769 near_plane: 0.01,
770 far_plane: 10.0,
771 lighting: LightingConfig::default(),
772 }
773 }
774
775 pub fn fov_radians(&self) -> f32 {
782 let base_hfov_rad = 90.0_f32.to_radians();
783 let half_tan = (base_hfov_rad / 2.0).tan() / self.zoom;
784 2.0 * half_tan.atan()
785 }
786
787 pub fn intrinsics(&self) -> CameraIntrinsics {
795 self.intrinsics_for_size(self.width, self.height)
796 }
797
798 pub fn intrinsics_for_size(&self, width: u32, height: u32) -> CameraIntrinsics {
803 let base_hfov_rad = 90.0_f64.to_radians();
804 let fx_norm = (base_hfov_rad / 2.0).tan() / self.zoom as f64;
806 let fx = (width as f64 / 2.0) / fx_norm;
808 let fy = fx; CameraIntrinsics {
811 focal_length: [fx, fy],
812 principal_point: [width as f64 / 2.0, height as f64 / 2.0],
813 image_size: [width, height],
814 }
815 }
816}
817
818#[derive(Clone, Debug, PartialEq)]
823pub struct CameraIntrinsics {
824 pub focal_length: [f64; 2],
826 pub principal_point: [f64; 2],
828 pub image_size: [u32; 2],
830}
831
832impl CameraIntrinsics {
833 pub fn project(&self, point: Vec3) -> Option<[f64; 2]> {
835 if point.z <= 0.0 {
836 return None;
837 }
838 let x = (point.x as f64 / point.z as f64) * self.focal_length[0] + self.principal_point[0];
839 let y = (point.y as f64 / point.z as f64) * self.focal_length[1] + self.principal_point[1];
840 Some([x, y])
841 }
842
843 pub fn unproject(&self, pixel: [f64; 2], depth: f64) -> [f64; 3] {
845 let x = (pixel[0] - self.principal_point[0]) / self.focal_length[0] * depth;
846 let y = (pixel[1] - self.principal_point[1]) / self.focal_length[1] * depth;
847 [x, y, depth]
848 }
849}
850
851#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
853pub struct RenderHealth {
854 pub center_pixel: Option<[u32; 2]>,
856 pub center_depth: Option<f64>,
858 pub center_foreground: bool,
860 pub foreground_pixel_count: usize,
862 pub foreground_coverage: f64,
864 pub center_5x5_foreground_count: usize,
866 pub nearest_foreground_pixel: Option<[u32; 2]>,
868 pub nearest_foreground_depth: Option<f64>,
870 pub nearest_foreground_distance_px: Option<f64>,
872}
873
874#[derive(Clone, Debug)]
876pub struct RenderOutput {
877 pub rgba: Vec<u8>,
879 pub depth: Vec<f64>,
883 pub width: u32,
885 pub height: u32,
887 pub intrinsics: CameraIntrinsics,
889 pub camera_transform: Transform,
891 pub object_rotation: ObjectRotation,
893 pub object_translation: Vec3,
895 pub object_scale: Vec3,
897 pub target_point: Vec3,
899 pub targeting_policy: TargetingPolicy,
901}
902
903pub(crate) fn semantic_3d_from_depth(
904 depth: &[f64],
905 width: u32,
906 height: u32,
907 intrinsics: &CameraIntrinsics,
908 camera_transform: Transform,
909 object_semantic_id: u32,
910 far_plane: f64,
911) -> Vec<[f64; 4]> {
912 let total_pixels = (width as usize).saturating_mul(height as usize);
913 let mut rows = Vec::with_capacity(total_pixels);
914 for y in 0..height {
915 for x in 0..width {
916 let idx = (y * width + x) as usize;
917 let Some(&pixel_depth) = depth.get(idx) else {
918 rows.push([0.0, 0.0, 0.0, 0.0]);
919 continue;
920 };
921 let Some(world) = pixel_surface_point_world_from_parts(
922 pixel_depth,
923 [x, y],
924 intrinsics,
925 camera_transform,
926 far_plane,
927 ) else {
928 rows.push([0.0, 0.0, 0.0, 0.0]);
929 continue;
930 };
931 rows.push([world[0], world[1], world[2], object_semantic_id as f64]);
932 }
933 }
934 rows
935}
936
937fn pixel_surface_point_world_from_parts(
938 depth: f64,
939 pixel: [u32; 2],
940 intrinsics: &CameraIntrinsics,
941 camera_transform: Transform,
942 far_plane: f64,
943) -> Option<[f64; 3]> {
944 if !RenderOutput::is_foreground_depth(depth, far_plane) {
945 return None;
946 }
947
948 let fx = intrinsics.focal_length[0];
949 let fy = intrinsics.focal_length[1];
950 if !fx.is_finite() || !fy.is_finite() || fx.abs() <= f64::EPSILON || fy.abs() <= f64::EPSILON {
951 return None;
952 }
953
954 let [x, y] = pixel;
955 let camera_x = (x as f64 - intrinsics.principal_point[0]) / fx * depth;
956 let camera_y = -((y as f64 - intrinsics.principal_point[1]) / fy * depth);
957 let point = Vec3::new(camera_x as f32, camera_y as f32, -depth as f32);
958 let world = camera_transform.translation + camera_transform.rotation * point;
959 Some([world.x as f64, world.y as f64, world.z as f64])
960}
961
962impl RenderOutput {
963 pub const TBP_FAR_PLANE_METERS: f64 = 10.0;
965
966 pub fn with_targeting(mut self, target_point: Vec3, targeting_policy: TargetingPolicy) -> Self {
968 self.target_point = target_point;
969 self.targeting_policy = targeting_policy;
970 self
971 }
972
973 pub fn with_object_transform(mut self, object_translation: Vec3, object_scale: Vec3) -> Self {
975 self.object_translation = object_translation;
976 self.object_scale = object_scale;
977 self
978 }
979
980 pub fn get_rgba(&self, x: u32, y: u32) -> Option<[u8; 4]> {
982 if x >= self.width || y >= self.height {
983 return None;
984 }
985 let idx = ((y * self.width + x) * 4) as usize;
986 Some([
987 self.rgba[idx],
988 self.rgba[idx + 1],
989 self.rgba[idx + 2],
990 self.rgba[idx + 3],
991 ])
992 }
993
994 pub fn get_depth(&self, x: u32, y: u32) -> Option<f64> {
996 if x >= self.width || y >= self.height {
997 return None;
998 }
999 let idx = (y * self.width + x) as usize;
1000 Some(self.depth[idx])
1001 }
1002
1003 pub fn get_rgb(&self, x: u32, y: u32) -> Option<[u8; 3]> {
1005 self.get_rgba(x, y).map(|rgba| [rgba[0], rgba[1], rgba[2]])
1006 }
1007
1008 pub fn center_pixel(&self) -> Option<[u32; 2]> {
1010 if self.width == 0 || self.height == 0 {
1011 return None;
1012 }
1013
1014 let x = self.intrinsics.principal_point[0]
1015 .round()
1016 .clamp(0.0, (self.width - 1) as f64) as u32;
1017 let y = self.intrinsics.principal_point[1]
1018 .round()
1019 .clamp(0.0, (self.height - 1) as f64) as u32;
1020 Some([x, y])
1021 }
1022
1023 pub fn center_pixel_raw_depth(&self) -> Option<f64> {
1025 let [x, y] = self.center_pixel()?;
1026 self.get_depth(x, y)
1027 }
1028
1029 pub fn center_pixel_depth(&self) -> Option<f64> {
1031 self.center_pixel_depth_with_far_plane(Self::TBP_FAR_PLANE_METERS)
1032 }
1033
1034 pub fn center_pixel_depth_with_far_plane(&self, far_plane: f64) -> Option<f64> {
1036 self.center_pixel_raw_depth()
1037 .filter(|depth| Self::is_foreground_depth(*depth, far_plane))
1038 }
1039
1040 pub fn is_foreground_depth(depth: f64, far_plane: f64) -> bool {
1042 depth.is_finite() && depth > 0.0 && far_plane.is_finite() && depth < far_plane * 0.999
1043 }
1044
1045 pub fn health(&self) -> RenderHealth {
1047 self.health_with_far_plane(Self::TBP_FAR_PLANE_METERS)
1048 }
1049
1050 pub fn health_with_far_plane(&self, far_plane: f64) -> RenderHealth {
1052 let center_pixel = self.center_pixel();
1053 let center_depth = self.center_pixel_raw_depth();
1054 let center_foreground = center_depth
1055 .map(|depth| Self::is_foreground_depth(depth, far_plane))
1056 .unwrap_or(false);
1057
1058 let total_pixels = (self.width as usize).saturating_mul(self.height as usize);
1059 let mut foreground_pixel_count = 0usize;
1060 let mut center_5x5_foreground_count = 0usize;
1061 let mut nearest_foreground_pixel = None;
1062 let mut nearest_foreground_depth = None;
1063 let mut nearest_foreground_distance_px = None;
1064
1065 for y in 0..self.height {
1066 for x in 0..self.width {
1067 let Some(depth) = self.get_depth(x, y) else {
1068 continue;
1069 };
1070 if !Self::is_foreground_depth(depth, far_plane) {
1071 continue;
1072 }
1073
1074 foreground_pixel_count += 1;
1075
1076 if let Some([cx, cy]) = center_pixel {
1077 let dx = x as i64 - cx as i64;
1078 let dy = y as i64 - cy as i64;
1079
1080 if dx.abs() <= 2 && dy.abs() <= 2 {
1081 center_5x5_foreground_count += 1;
1082 }
1083
1084 let distance = ((dx * dx + dy * dy) as f64).sqrt();
1085 if nearest_foreground_distance_px
1086 .map(|current| distance < current)
1087 .unwrap_or(true)
1088 {
1089 nearest_foreground_pixel = Some([x, y]);
1090 nearest_foreground_depth = Some(depth);
1091 nearest_foreground_distance_px = Some(distance);
1092 }
1093 }
1094 }
1095 }
1096
1097 RenderHealth {
1098 center_pixel,
1099 center_depth,
1100 center_foreground,
1101 foreground_pixel_count,
1102 foreground_coverage: if total_pixels > 0 {
1103 foreground_pixel_count as f64 / total_pixels as f64
1104 } else {
1105 0.0
1106 },
1107 center_5x5_foreground_count,
1108 nearest_foreground_pixel,
1109 nearest_foreground_depth,
1110 nearest_foreground_distance_px,
1111 }
1112 }
1113
1114 pub fn camera_to_world_point(&self, camera_point: [f64; 3]) -> [f64; 3] {
1116 let point = Vec3::new(
1117 camera_point[0] as f32,
1118 camera_point[1] as f32,
1119 camera_point[2] as f32,
1120 );
1121 let rotated = self.camera_transform.rotation * point;
1122 let translated = self.camera_transform.translation + rotated;
1123 [
1124 translated.x as f64,
1125 translated.y as f64,
1126 translated.z as f64,
1127 ]
1128 }
1129
1130 pub fn world_to_camera_point(&self, world_point: [f64; 3]) -> [f64; 3] {
1132 let point = Vec3::new(
1133 world_point[0] as f32,
1134 world_point[1] as f32,
1135 world_point[2] as f32,
1136 );
1137 let relative = point - self.camera_transform.translation;
1138 let camera_point = self.camera_transform.rotation.inverse() * relative;
1139 [
1140 camera_point.x as f64,
1141 camera_point.y as f64,
1142 camera_point.z as f64,
1143 ]
1144 }
1145
1146 pub fn center_surface_point_world(&self) -> Option<[f64; 3]> {
1148 self.center_surface_point_world_with_far_plane(Self::TBP_FAR_PLANE_METERS)
1149 }
1150
1151 pub fn center_surface_point_world_with_far_plane(&self, far_plane: f64) -> Option<[f64; 3]> {
1153 let [x, y] = self.center_pixel()?;
1154 self.pixel_surface_point_world_with_far_plane([x, y], far_plane)
1155 }
1156
1157 pub fn pixel_surface_point_world(&self, pixel: [u32; 2]) -> Option<[f64; 3]> {
1159 self.pixel_surface_point_world_with_far_plane(pixel, Self::TBP_FAR_PLANE_METERS)
1160 }
1161
1162 pub fn pixel_surface_point_world_with_far_plane(
1168 &self,
1169 pixel: [u32; 2],
1170 far_plane: f64,
1171 ) -> Option<[f64; 3]> {
1172 let [x, y] = pixel;
1173 let depth = self.get_depth(x, y)?;
1174 pixel_surface_point_world_from_parts(
1175 depth,
1176 pixel,
1177 &self.intrinsics,
1178 self.camera_transform,
1179 far_plane,
1180 )
1181 }
1182
1183 pub fn semantic_3d(&self, object_semantic_id: u32) -> Vec<[f64; 4]> {
1189 self.semantic_3d_with_far_plane(object_semantic_id, Self::TBP_FAR_PLANE_METERS)
1190 }
1191
1192 pub fn semantic_3d_with_far_plane(
1194 &self,
1195 object_semantic_id: u32,
1196 far_plane: f64,
1197 ) -> Vec<[f64; 4]> {
1198 semantic_3d_from_depth(
1199 &self.depth,
1200 self.width,
1201 self.height,
1202 &self.intrinsics,
1203 self.camera_transform,
1204 object_semantic_id,
1205 far_plane,
1206 )
1207 }
1208
1209 pub fn to_rgb_image(&self) -> Vec<Vec<[u8; 3]>> {
1211 let mut image = Vec::with_capacity(self.height as usize);
1212 for y in 0..self.height {
1213 let mut row = Vec::with_capacity(self.width as usize);
1214 for x in 0..self.width {
1215 row.push(self.get_rgb(x, y).unwrap_or([0, 0, 0]));
1216 }
1217 image.push(row);
1218 }
1219 image
1220 }
1221
1222 pub fn to_depth_image(&self) -> Vec<Vec<f64>> {
1224 let mut image = Vec::with_capacity(self.height as usize);
1225 for y in 0..self.height {
1226 let mut row = Vec::with_capacity(self.width as usize);
1227 for x in 0..self.width {
1228 row.push(self.get_depth(x, y).unwrap_or(0.0));
1229 }
1230 image.push(row);
1231 }
1232 image
1233 }
1234}
1235
1236#[derive(Debug, Clone)]
1238pub enum RenderError {
1239 MeshNotFound(String),
1241 TextureNotFound(String),
1243 FileNotFound { path: String, reason: String },
1245 FileWriteFailed { path: String, reason: String },
1247 DirectoryCreationFailed { path: String, reason: String },
1249 RenderFailed(String),
1251 InvalidConfig(String),
1253 InvalidInput(String),
1255 SerializationError(String),
1257 DataParsingError(String),
1259 RenderTimeout { duration_secs: u64 },
1261}
1262
1263impl std::fmt::Display for RenderError {
1264 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1265 match self {
1266 RenderError::MeshNotFound(path) => write!(f, "Mesh not found: {}", path),
1267 RenderError::TextureNotFound(path) => write!(f, "Texture not found: {}", path),
1268 RenderError::FileNotFound { path, reason } => {
1269 write!(f, "File not found at {}: {}", path, reason)
1270 }
1271 RenderError::FileWriteFailed { path, reason } => {
1272 write!(f, "Failed to write file {}: {}", path, reason)
1273 }
1274 RenderError::DirectoryCreationFailed { path, reason } => {
1275 write!(f, "Failed to create directory {}: {}", path, reason)
1276 }
1277 RenderError::RenderFailed(msg) => write!(f, "Render failed: {}", msg),
1278 RenderError::InvalidConfig(msg) => write!(f, "Invalid config: {}", msg),
1279 RenderError::InvalidInput(msg) => write!(f, "Invalid input: {}", msg),
1280 RenderError::SerializationError(msg) => write!(f, "Serialization error: {}", msg),
1281 RenderError::DataParsingError(msg) => write!(f, "Data parsing error: {}", msg),
1282 RenderError::RenderTimeout { duration_secs } => {
1283 write!(f, "Render timeout after {} seconds", duration_secs)
1284 }
1285 }
1286 }
1287}
1288
1289impl std::error::Error for RenderError {}
1290
1291pub fn render_to_buffer(
1316 object_dir: &Path,
1317 camera_transform: &Transform,
1318 object_rotation: &ObjectRotation,
1319 config: &RenderConfig,
1320) -> Result<RenderOutput, RenderError> {
1321 render::render_headless(
1323 object_dir,
1324 camera_transform,
1325 object_rotation,
1326 Vec3::ZERO,
1327 Vec3::ONE,
1328 config,
1329 )
1330}
1331
1332pub fn render_to_buffer_with_object_transform(
1334 object_dir: &Path,
1335 camera_transform: &Transform,
1336 object_rotation: &ObjectRotation,
1337 object_translation: Vec3,
1338 object_scale: Vec3,
1339 config: &RenderConfig,
1340) -> Result<RenderOutput, RenderError> {
1341 render::render_headless(
1342 object_dir,
1343 camera_transform,
1344 object_rotation,
1345 object_translation,
1346 object_scale,
1347 config,
1348 )
1349}
1350
1351pub fn render_to_buffer_with_target(
1357 object_dir: &Path,
1358 camera_transform: &Transform,
1359 object_rotation: &ObjectRotation,
1360 config: &RenderConfig,
1361 target_point: Vec3,
1362 targeting_policy: TargetingPolicy,
1363) -> Result<RenderOutput, RenderError> {
1364 render::render_headless_with_target(
1365 object_dir,
1366 camera_transform,
1367 object_rotation,
1368 Vec3::ZERO,
1369 Vec3::ONE,
1370 config,
1371 target_point,
1372 targeting_policy,
1373 )
1374}
1375
1376#[allow(clippy::too_many_arguments)]
1378pub fn render_to_buffer_with_target_and_object_transform(
1379 object_dir: &Path,
1380 camera_transform: &Transform,
1381 object_rotation: &ObjectRotation,
1382 object_translation: Vec3,
1383 object_scale: Vec3,
1384 config: &RenderConfig,
1385 target_point: Vec3,
1386 targeting_policy: TargetingPolicy,
1387) -> Result<RenderOutput, RenderError> {
1388 render::render_headless_with_target(
1389 object_dir,
1390 camera_transform,
1391 object_rotation,
1392 object_translation,
1393 object_scale,
1394 config,
1395 target_point,
1396 targeting_policy,
1397 )
1398}
1399
1400pub fn render_all_viewpoints(
1413 object_dir: &Path,
1414 viewpoint_config: &ViewpointConfig,
1415 rotations: &[ObjectRotation],
1416 render_config: &RenderConfig,
1417) -> Result<Vec<RenderOutput>, RenderError> {
1418 let viewpoints = generate_viewpoints(viewpoint_config);
1419 let mut outputs = Vec::with_capacity(viewpoints.len() * rotations.len());
1420
1421 for rotation in rotations {
1422 for viewpoint in &viewpoints {
1423 let output = render_to_buffer(object_dir, viewpoint, rotation, render_config)?;
1424 outputs.push(output);
1425 }
1426 }
1427
1428 Ok(outputs)
1429}
1430
1431#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1433pub struct CenterHitValidationReport {
1434 pub object_id: String,
1436 pub object_dir: String,
1438 pub target_policy: TargetingPolicy,
1440 pub rotations: Vec<CenterHitRotationReport>,
1442}
1443
1444impl CenterHitValidationReport {
1445 pub fn is_valid(&self) -> bool {
1447 self.rotations
1448 .iter()
1449 .all(|rotation| rotation.center_hits > 0)
1450 }
1451
1452 pub fn zero_hit_rotations(&self) -> Vec<usize> {
1454 self.rotations
1455 .iter()
1456 .filter(|rotation| rotation.center_hits == 0)
1457 .map(|rotation| rotation.rotation_index)
1458 .collect()
1459 }
1460}
1461
1462#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1464pub struct CenterHitRotationReport {
1465 pub rotation_index: usize,
1466 pub rotation_euler: [f64; 3],
1467 pub target_point: [f32; 3],
1468 pub mesh_bounds: Option<MeshBoundsMetadata>,
1469 pub total_viewpoints: usize,
1470 pub center_hits: usize,
1471 pub center_misses: usize,
1472 pub misses: Vec<CenterHitMiss>,
1473}
1474
1475#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
1477pub struct MeshBoundsMetadata {
1478 pub min: [f32; 3],
1479 pub max: [f32; 3],
1480 pub center: [f32; 3],
1481 pub vertex_count: usize,
1482}
1483
1484impl From<MeshBounds> for MeshBoundsMetadata {
1485 fn from(bounds: MeshBounds) -> Self {
1486 Self {
1487 min: bounds.min.to_array(),
1488 max: bounds.max.to_array(),
1489 center: bounds.center.to_array(),
1490 vertex_count: bounds.vertex_count,
1491 }
1492 }
1493}
1494
1495#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1497pub struct CenterHitMiss {
1498 pub viewpoint_index: usize,
1499 pub camera_position: [f32; 3],
1500 pub camera_rotation_xyzw: [f32; 4],
1501 pub health: RenderHealth,
1502}
1503
1504pub fn validate_center_hits(
1507 object_id: impl Into<String>,
1508 object_dir: &Path,
1509 viewpoint_config: &ViewpointConfig,
1510 rotations: &[ObjectRotation],
1511 render_config: &RenderConfig,
1512 target_policy: &TargetingPolicy,
1513) -> Result<CenterHitValidationReport, RenderError> {
1514 let object_id = object_id.into();
1515 let mut rotation_reports = Vec::with_capacity(rotations.len());
1516
1517 for (rotation_index, rotation) in rotations.iter().enumerate() {
1518 let targeted =
1519 generate_targeted_viewpoints(object_dir, viewpoint_config, rotation, target_policy)?;
1520 let requests: Vec<batch::BatchRenderRequest> = targeted
1521 .viewpoints
1522 .iter()
1523 .map(|viewpoint| batch::BatchRenderRequest {
1524 object_dir: PathBuf::from(object_dir),
1525 viewpoint: *viewpoint,
1526 object_rotation: rotation.clone(),
1527 object_translation: Vec3::ZERO,
1528 object_scale: Vec3::ONE,
1529 render_config: render_config.clone(),
1530 target_point: targeted.target_point,
1531 targeting_policy: target_policy.clone(),
1532 })
1533 .collect();
1534
1535 let outputs = render_batch(requests, &batch::BatchRenderConfig::default())
1536 .map_err(|error| RenderError::RenderFailed(error.to_string()))?;
1537
1538 let mut center_hits = 0usize;
1539 let mut misses = Vec::new();
1540 for (viewpoint_index, output) in outputs.iter().enumerate() {
1541 if output.status != batch::RenderStatus::Success {
1542 return Err(RenderError::RenderFailed(format!(
1543 "Render failed for {} rotation {} viewpoint {}: {:?}",
1544 object_id, rotation_index, viewpoint_index, output.error_message
1545 )));
1546 }
1547
1548 if output.health.center_foreground {
1549 center_hits += 1;
1550 } else {
1551 let t = output.request.viewpoint.translation;
1552 let q = output.request.viewpoint.rotation;
1553 misses.push(CenterHitMiss {
1554 viewpoint_index,
1555 camera_position: [t.x, t.y, t.z],
1556 camera_rotation_xyzw: [q.x, q.y, q.z, q.w],
1557 health: output.health.clone(),
1558 });
1559 }
1560 }
1561
1562 rotation_reports.push(CenterHitRotationReport {
1563 rotation_index,
1564 rotation_euler: [rotation.pitch, rotation.yaw, rotation.roll],
1565 target_point: targeted.target_point.to_array(),
1566 mesh_bounds: targeted.mesh_bounds.map(MeshBoundsMetadata::from),
1567 total_viewpoints: outputs.len(),
1568 center_hits,
1569 center_misses: outputs.len().saturating_sub(center_hits),
1570 misses,
1571 });
1572 }
1573
1574 Ok(CenterHitValidationReport {
1575 object_id,
1576 object_dir: object_dir.display().to_string(),
1577 target_policy: target_policy.clone(),
1578 rotations: rotation_reports,
1579 })
1580}
1581
1582pub fn render_to_buffer_cached(
1657 object_dir: &Path,
1658 camera_transform: &Transform,
1659 object_rotation: &ObjectRotation,
1660 config: &RenderConfig,
1661 cache: &mut cache::ModelCache,
1662) -> Result<RenderOutput, RenderError> {
1663 render_to_buffer_cached_with_object_transform(
1664 object_dir,
1665 camera_transform,
1666 object_rotation,
1667 Vec3::ZERO,
1668 Vec3::ONE,
1669 config,
1670 cache,
1671 )
1672}
1673
1674pub fn render_to_buffer_cached_with_object_transform(
1676 object_dir: &Path,
1677 camera_transform: &Transform,
1678 object_rotation: &ObjectRotation,
1679 object_translation: Vec3,
1680 object_scale: Vec3,
1681 config: &RenderConfig,
1682 cache: &mut cache::ModelCache,
1683) -> Result<RenderOutput, RenderError> {
1684 let mesh_path = object_dir.join("google_16k/textured.obj");
1685 let texture_path = object_dir.join("google_16k/texture_map.png");
1686
1687 cache.cache_scene(mesh_path.clone());
1689 cache.cache_texture(texture_path.clone());
1690
1691 render::render_headless(
1693 object_dir,
1694 camera_transform,
1695 object_rotation,
1696 object_translation,
1697 object_scale,
1698 config,
1699 )
1700}
1701
1702pub fn render_to_files(
1719 object_dir: &Path,
1720 camera_transform: &Transform,
1721 object_rotation: &ObjectRotation,
1722 config: &RenderConfig,
1723 rgba_path: &Path,
1724 depth_path: &Path,
1725) -> Result<(), RenderError> {
1726 render_to_files_with_object_transform(
1727 object_dir,
1728 camera_transform,
1729 object_rotation,
1730 Vec3::ZERO,
1731 Vec3::ONE,
1732 config,
1733 rgba_path,
1734 depth_path,
1735 )
1736}
1737
1738#[allow(clippy::too_many_arguments)]
1740pub fn render_to_files_with_object_transform(
1741 object_dir: &Path,
1742 camera_transform: &Transform,
1743 object_rotation: &ObjectRotation,
1744 object_translation: Vec3,
1745 object_scale: Vec3,
1746 config: &RenderConfig,
1747 rgba_path: &Path,
1748 depth_path: &Path,
1749) -> Result<(), RenderError> {
1750 render::render_to_files(
1751 object_dir,
1752 camera_transform,
1753 object_rotation,
1754 object_translation,
1755 object_scale,
1756 config,
1757 rgba_path,
1758 depth_path,
1759 )
1760}
1761
1762pub use batch::{
1764 BatchRenderConfig, BatchRenderError, BatchRenderOutput, BatchRenderRequest, BatchRenderer,
1765 BatchState, RenderStatus,
1766};
1767
1768pub use render::RenderSession;
1771
1772pub use render::PersistentRenderer;
1778
1779pub fn create_batch_renderer(config: &BatchRenderConfig) -> Result<BatchRenderer, RenderError> {
1797 Ok(BatchRenderer::new(config.clone()))
1798}
1799
1800pub fn queue_render_request(
1827 renderer: &mut BatchRenderer,
1828 request: BatchRenderRequest,
1829) -> Result<(), RenderError> {
1830 renderer
1831 .queue_request(request)
1832 .map_err(|e| RenderError::RenderFailed(e.to_string()))
1833}
1834
1835pub fn render_next_in_batch(
1857 renderer: &mut BatchRenderer,
1858 _timeout_ms: u32,
1859) -> Result<Option<BatchRenderOutput>, RenderError> {
1860 if let Some(request) = renderer.pending_requests.pop_front() {
1861 let output = render_to_buffer_with_object_transform(
1862 &request.object_dir,
1863 &request.viewpoint,
1864 &request.object_rotation,
1865 request.object_translation,
1866 request.object_scale,
1867 &request.render_config,
1868 )?;
1869 let batch_output = BatchRenderOutput::from_render_output(request, output);
1870 renderer.completed_results.push(batch_output.clone());
1871 renderer.renders_processed += 1;
1872 Ok(Some(batch_output))
1873 } else {
1874 Ok(None)
1875 }
1876}
1877
1878pub fn render_batch(
1897 requests: Vec<BatchRenderRequest>,
1898 config: &BatchRenderConfig,
1899) -> Result<Vec<BatchRenderOutput>, RenderError> {
1900 if requests.is_empty() {
1901 return Ok(Vec::new());
1902 }
1903
1904 if requests.len() > 1 && requests_share_batch_context(&requests) {
1905 let first_request = requests[0].clone();
1906 let viewpoints: Vec<Transform> = requests.iter().map(|request| request.viewpoint).collect();
1907 let outputs = render::render_headless_sequence(
1908 &first_request.object_dir,
1909 &viewpoints,
1910 &first_request.object_rotation,
1911 first_request.object_translation,
1912 first_request.object_scale,
1913 &first_request.render_config,
1914 first_request.target_point,
1915 first_request.targeting_policy.clone(),
1916 )?;
1917
1918 return Ok(requests
1919 .into_iter()
1920 .zip(outputs)
1921 .map(|(request, output)| BatchRenderOutput::from_render_output(request, output))
1922 .collect());
1923 }
1924
1925 let mut renderer = create_batch_renderer(config)?;
1926
1927 for request in requests {
1929 queue_render_request(&mut renderer, request)?;
1930 }
1931
1932 let mut results = Vec::new();
1934 while let Some(output) = render_next_in_batch(&mut renderer, config.frame_timeout_ms)? {
1935 results.push(output);
1936 }
1937
1938 Ok(results)
1939}
1940
1941fn requests_share_batch_context(requests: &[BatchRenderRequest]) -> bool {
1942 let Some(first) = requests.first() else {
1943 return true;
1944 };
1945
1946 requests.iter().all(|request| {
1947 request.object_dir == first.object_dir
1948 && request.object_rotation == first.object_rotation
1949 && request.object_translation == first.object_translation
1950 && request.object_scale == first.object_scale
1951 && request.render_config == first.render_config
1952 && request.target_point == first.target_point
1953 && request.targeting_policy == first.targeting_policy
1954 })
1955}
1956
1957pub use bevy::prelude::{Quat, Transform, Vec3};
1959
1960#[cfg(test)]
1961mod tests {
1962 use super::*;
1963
1964 fn assert_vec3_close(actual: Vec3, expected: Vec3) {
1965 assert!(
1966 (actual - expected).length() < 1e-5,
1967 "expected {:?}, got {:?}",
1968 expected,
1969 actual
1970 );
1971 }
1972
1973 fn assert_point_close(actual: [f64; 3], expected: [f64; 3]) {
1974 for axis in 0..3 {
1975 assert!(
1976 (actual[axis] - expected[axis]).abs() < 1e-5,
1977 "axis {} expected {:?}, got {:?}",
1978 axis,
1979 expected,
1980 actual
1981 );
1982 }
1983 }
1984
1985 fn render_output_for_depth(
1986 width: u32,
1987 height: u32,
1988 depth: Vec<f64>,
1989 intrinsics: CameraIntrinsics,
1990 camera_transform: Transform,
1991 ) -> RenderOutput {
1992 RenderOutput {
1993 rgba: vec![0u8; (width * height * 4) as usize],
1994 depth,
1995 width,
1996 height,
1997 intrinsics,
1998 camera_transform,
1999 object_rotation: ObjectRotation::identity(),
2000 object_translation: Vec3::ZERO,
2001 object_scale: Vec3::ONE,
2002 target_point: Vec3::ZERO,
2003 targeting_policy: TargetingPolicy::Origin,
2004 }
2005 }
2006
2007 #[test]
2008 fn test_object_rotation_identity() {
2009 let rot = ObjectRotation::identity();
2010 assert_eq!(rot.pitch, 0.0);
2011 assert_eq!(rot.yaw, 0.0);
2012 assert_eq!(rot.roll, 0.0);
2013 }
2014
2015 #[test]
2016 fn test_object_rotation_from_array() {
2017 let rot = ObjectRotation::from_array([10.0, 20.0, 30.0]);
2018 assert_eq!(rot.pitch, 10.0);
2019 assert_eq!(rot.yaw, 20.0);
2020 assert_eq!(rot.roll, 30.0);
2021 }
2022
2023 #[test]
2024 fn test_requests_share_batch_context_for_homogeneous_batch() {
2025 let config = RenderConfig::tbp_default();
2026 let request = BatchRenderRequest {
2027 object_dir: "/tmp/ycb/003_cracker_box".into(),
2028 viewpoint: Transform::IDENTITY,
2029 object_rotation: ObjectRotation::identity(),
2030 object_translation: Vec3::ZERO,
2031 object_scale: Vec3::ONE,
2032 render_config: config.clone(),
2033 target_point: Vec3::ZERO,
2034 targeting_policy: TargetingPolicy::Origin,
2035 };
2036
2037 assert!(requests_share_batch_context(&[
2038 request.clone(),
2039 BatchRenderRequest {
2040 viewpoint: Transform::from_xyz(1.0, 0.0, 0.0),
2041 ..request
2042 },
2043 ]));
2044 }
2045
2046 #[test]
2047 fn test_requests_share_batch_context_rejects_mixed_objects() {
2048 let config = RenderConfig::tbp_default();
2049 let request = BatchRenderRequest {
2050 object_dir: "/tmp/ycb/003_cracker_box".into(),
2051 viewpoint: Transform::IDENTITY,
2052 object_rotation: ObjectRotation::identity(),
2053 object_translation: Vec3::ZERO,
2054 object_scale: Vec3::ONE,
2055 render_config: config.clone(),
2056 target_point: Vec3::ZERO,
2057 targeting_policy: TargetingPolicy::Origin,
2058 };
2059
2060 assert!(!requests_share_batch_context(&[
2061 request.clone(),
2062 BatchRenderRequest {
2063 object_dir: "/tmp/ycb/005_tomato_soup_can".into(),
2064 ..request
2065 },
2066 ]));
2067 }
2068
2069 #[test]
2070 fn test_requests_share_batch_context_rejects_mixed_object_translation() {
2071 let config = RenderConfig::tbp_default();
2072 let request = BatchRenderRequest {
2073 object_dir: "/tmp/ycb/003_cracker_box".into(),
2074 viewpoint: Transform::IDENTITY,
2075 object_rotation: ObjectRotation::identity(),
2076 object_translation: Vec3::ZERO,
2077 object_scale: Vec3::ONE,
2078 render_config: config.clone(),
2079 target_point: Vec3::ZERO,
2080 targeting_policy: TargetingPolicy::Origin,
2081 };
2082
2083 assert!(!requests_share_batch_context(&[
2084 request.clone(),
2085 BatchRenderRequest {
2086 object_translation: Vec3::new(0.1, 0.0, 0.0),
2087 ..request
2088 },
2089 ]));
2090 }
2091
2092 #[test]
2093 fn test_requests_share_batch_context_rejects_mixed_object_scale() {
2094 let config = RenderConfig::tbp_default();
2095 let request = BatchRenderRequest {
2096 object_dir: "/tmp/ycb/003_cracker_box".into(),
2097 viewpoint: Transform::IDENTITY,
2098 object_rotation: ObjectRotation::identity(),
2099 object_translation: Vec3::ZERO,
2100 object_scale: Vec3::ONE,
2101 render_config: config.clone(),
2102 target_point: Vec3::ZERO,
2103 targeting_policy: TargetingPolicy::Origin,
2104 };
2105
2106 assert!(!requests_share_batch_context(&[
2107 request.clone(),
2108 BatchRenderRequest {
2109 object_scale: Vec3::splat(1.25),
2110 ..request
2111 },
2112 ]));
2113 }
2114
2115 #[test]
2116 fn test_tbp_benchmark_rotations() {
2117 let rotations = ObjectRotation::tbp_benchmark_rotations();
2118 assert_eq!(rotations.len(), 3);
2119 assert_eq!(rotations[0], ObjectRotation::from_array([0.0, 0.0, 0.0]));
2120 assert_eq!(rotations[1], ObjectRotation::from_array([0.0, 90.0, 0.0]));
2121 assert_eq!(rotations[2], ObjectRotation::from_array([0.0, 180.0, 0.0]));
2122 }
2123
2124 #[test]
2125 fn test_tbp_known_orientations_count() {
2126 let orientations = ObjectRotation::tbp_known_orientations();
2127 assert_eq!(orientations.len(), 14);
2128 }
2129
2130 #[test]
2131 fn test_rotation_to_quat() {
2132 let rot = ObjectRotation::identity();
2133 let quat = rot.to_quat();
2134 assert!((quat.w - 1.0).abs() < 0.001);
2136 assert!(quat.x.abs() < 0.001);
2137 assert!(quat.y.abs() < 0.001);
2138 assert!(quat.z.abs() < 0.001);
2139 }
2140
2141 #[test]
2142 fn test_rotation_90_yaw() {
2143 let rot = ObjectRotation::new(0.0, 90.0, 0.0);
2144 let quat = rot.to_quat();
2145 assert!((quat.w - 0.707).abs() < 0.01);
2147 assert!((quat.y - 0.707).abs() < 0.01);
2148 }
2149
2150 #[test]
2151 fn test_viewpoint_config_default() {
2152 let config = ViewpointConfig::default();
2153 assert_eq!(config.radius, 0.5);
2154 assert_eq!(config.yaw_count, 8);
2155 assert_eq!(config.pitch_angles_deg.len(), 3);
2156 }
2157
2158 #[test]
2159 fn test_viewpoint_count() {
2160 let config = ViewpointConfig::default();
2161 assert_eq!(config.viewpoint_count(), 24); }
2163
2164 #[test]
2165 fn test_generate_viewpoints_count() {
2166 let config = ViewpointConfig::default();
2167 let viewpoints = generate_viewpoints(&config);
2168 assert_eq!(viewpoints.len(), 24);
2169 }
2170
2171 #[test]
2172 fn test_viewpoints_spherical_radius() {
2173 let config = ViewpointConfig::default();
2174 let viewpoints = generate_viewpoints(&config);
2175
2176 for (i, transform) in viewpoints.iter().enumerate() {
2177 let actual_radius = transform.translation.length();
2178 assert!(
2179 (actual_radius - config.radius).abs() < 0.001,
2180 "Viewpoint {} has incorrect radius: {} (expected {})",
2181 i,
2182 actual_radius,
2183 config.radius
2184 );
2185 }
2186 }
2187
2188 #[test]
2189 fn test_viewpoints_looking_at_origin() {
2190 let config = ViewpointConfig::default();
2191 let viewpoints = generate_viewpoints(&config);
2192
2193 for (i, transform) in viewpoints.iter().enumerate() {
2194 let forward = transform.forward();
2195 let to_origin = (Vec3::ZERO - transform.translation).normalize();
2196 let dot = forward.dot(to_origin);
2197 assert!(
2198 dot > 0.99,
2199 "Viewpoint {} not looking at origin, dot product: {}",
2200 i,
2201 dot
2202 );
2203 }
2204 }
2205
2206 #[test]
2207 fn test_generate_viewpoints_around_target_preserves_orbit() {
2208 let config = ViewpointConfig {
2209 radius: 2.0,
2210 yaw_count: 4,
2211 pitch_angles_deg: vec![0.0],
2212 };
2213 let target = Vec3::new(1.0, -0.5, 0.25);
2214 let viewpoints = generate_viewpoints_around_target(&config, target);
2215
2216 assert_eq!(viewpoints.len(), 4);
2217 for (i, transform) in viewpoints.iter().enumerate() {
2218 let offset = transform.translation - target;
2219 assert!(
2220 (offset.length() - config.radius).abs() < 1e-5,
2221 "viewpoint {} has radius {}, expected {}",
2222 i,
2223 offset.length(),
2224 config.radius
2225 );
2226
2227 let forward = transform.forward();
2228 let to_target = (target - transform.translation).normalize();
2229 assert!(
2230 forward.dot(to_target) > 0.99,
2231 "viewpoint {} is not looking at target",
2232 i
2233 );
2234 }
2235 }
2236
2237 #[test]
2238 fn test_generate_viewpoints_keeps_origin_targeting() {
2239 let config = ViewpointConfig {
2240 radius: 1.0,
2241 yaw_count: 1,
2242 pitch_angles_deg: vec![0.0],
2243 };
2244
2245 let origin_view = generate_viewpoints(&config)[0];
2246 let explicit_origin_view = generate_viewpoints_around_target(&config, Vec3::ZERO)[0];
2247
2248 assert_vec3_close(origin_view.translation, explicit_origin_view.translation);
2249 let forward = origin_view.forward();
2250 let to_origin = (Vec3::ZERO - origin_view.translation).normalize();
2251 assert!(forward.dot(to_origin) > 0.99);
2252 }
2253
2254 #[test]
2255 fn test_object_centered_viewpoints_apply_yaw_rotation_to_target() {
2256 let config = ViewpointConfig {
2257 radius: 1.0,
2258 yaw_count: 1,
2259 pitch_angles_deg: vec![0.0],
2260 };
2261 let mesh_center = Vec3::new(0.25, 0.0, 0.0);
2262 let rotation = ObjectRotation::new(0.0, 90.0, 0.0);
2263
2264 let target = rotated_mesh_center(mesh_center, &rotation);
2265 assert!(target.distance(mesh_center) > 0.1);
2266
2267 let origin_view = generate_viewpoints(&config)[0];
2268 let centered_view = generate_object_centered_viewpoints(&config, mesh_center, &rotation)[0];
2269
2270 assert_vec3_close(centered_view.translation, origin_view.translation + target);
2271 let forward = centered_view.forward();
2272 let to_target = (target - centered_view.translation).normalize();
2273 assert!(forward.dot(to_target) > 0.99);
2274 }
2275
2276 #[test]
2277 fn test_load_ycb_mesh_bounds_from_standard_obj_path() {
2278 let dir = tempfile::tempdir().unwrap();
2279 let mesh_dir = dir.path().join("google_16k");
2280 std::fs::create_dir_all(&mesh_dir).unwrap();
2281 std::fs::write(
2282 mesh_dir.join("textured.obj"),
2283 "v -1.0 -2.0 -3.0\nv 3.0 4.0 5.0\nv 1.0 0.0 2.0\nf 1 2 3\n",
2284 )
2285 .unwrap();
2286
2287 let bounds = load_ycb_mesh_bounds(dir.path()).unwrap();
2288
2289 assert_eq!(bounds.vertex_count, 3);
2290 assert_vec3_close(bounds.min, Vec3::new(-1.0, -2.0, -3.0));
2291 assert_vec3_close(bounds.max, Vec3::new(3.0, 4.0, 5.0));
2292 assert_vec3_close(bounds.center, Vec3::new(1.0, 1.0, 1.0));
2293 assert_vec3_close(bounds.extents(), Vec3::new(4.0, 6.0, 8.0));
2294 }
2295
2296 #[test]
2297 fn test_targeting_policy_serializes_stable_label() {
2298 assert_eq!(TargetingPolicy::Origin.label(), "origin");
2299 assert_eq!(TargetingPolicy::MeshCenter.label(), "mesh-center");
2300
2301 let json = serde_json::to_string(&TargetingPolicy::MeshCenter).unwrap();
2302 assert!(json.contains("mesh_center"));
2303 let loaded: TargetingPolicy = serde_json::from_str(&json).unwrap();
2304 assert_eq!(loaded, TargetingPolicy::MeshCenter);
2305 }
2306
2307 #[test]
2308 fn test_render_output_with_targeting_overrides_origin_default() {
2309 let target_point = Vec3::new(0.1, 0.2, -0.3);
2310 let output = render_output_for_depth(
2311 1,
2312 1,
2313 vec![1.0],
2314 RenderConfig::tbp_default().intrinsics(),
2315 Transform::IDENTITY,
2316 )
2317 .with_targeting(target_point, TargetingPolicy::MeshCenter);
2318
2319 assert_eq!(output.target_point, target_point);
2320 assert_eq!(output.targeting_policy, TargetingPolicy::MeshCenter);
2321 }
2322
2323 #[test]
2324 fn test_center_hit_validation_report_detects_zero_hit_rotation() {
2325 let report = CenterHitValidationReport {
2326 object_id: "test_object".to_string(),
2327 object_dir: "/tmp/ycb/test_object".to_string(),
2328 target_policy: TargetingPolicy::MeshCenter,
2329 rotations: vec![
2330 CenterHitRotationReport {
2331 rotation_index: 0,
2332 rotation_euler: [0.0, 0.0, 0.0],
2333 target_point: [0.0, 0.0, 0.0],
2334 mesh_bounds: None,
2335 total_viewpoints: 24,
2336 center_hits: 1,
2337 center_misses: 23,
2338 misses: Vec::new(),
2339 },
2340 CenterHitRotationReport {
2341 rotation_index: 1,
2342 rotation_euler: [0.0, 90.0, 0.0],
2343 target_point: [0.1, 0.0, 0.0],
2344 mesh_bounds: None,
2345 total_viewpoints: 24,
2346 center_hits: 0,
2347 center_misses: 24,
2348 misses: Vec::new(),
2349 },
2350 ],
2351 };
2352
2353 assert!(!report.is_valid());
2354 assert_eq!(report.zero_hit_rotations(), vec![1]);
2355 }
2356
2357 #[test]
2358 fn test_sensor_config_default() {
2359 let config = SensorConfig::default();
2360 assert_eq!(config.object_rotations.len(), 1);
2361 assert_eq!(config.total_captures(), 24);
2362 }
2363
2364 #[test]
2365 fn test_sensor_config_tbp_benchmark() {
2366 let config = SensorConfig::tbp_benchmark();
2367 assert_eq!(config.object_rotations.len(), 3);
2368 assert_eq!(config.total_captures(), 72); }
2370
2371 #[test]
2372 fn test_sensor_config_tbp_full() {
2373 let config = SensorConfig::tbp_full_training();
2374 assert_eq!(config.object_rotations.len(), 14);
2375 assert_eq!(config.total_captures(), 336); }
2377
2378 #[test]
2379 fn test_ycb_representative_objects() {
2380 assert_eq!(crate::ycb::REPRESENTATIVE_OBJECTS.len(), 3);
2382 assert!(crate::ycb::REPRESENTATIVE_OBJECTS.contains(&"003_cracker_box"));
2383 }
2384
2385 #[test]
2386 fn test_ycb_tbp_standard_objects() {
2387 assert_eq!(crate::ycb::TBP_STANDARD_OBJECTS.len(), 10);
2388 assert!(crate::ycb::TBP_STANDARD_OBJECTS.contains(&"025_mug"));
2389 }
2390
2391 #[test]
2392 fn test_ycb_tbp_similar_objects() {
2393 assert_eq!(crate::ycb::TBP_SIMILAR_OBJECTS.len(), 10);
2394 assert!(crate::ycb::TBP_SIMILAR_OBJECTS.contains(&"003_cracker_box"));
2395 }
2396
2397 #[test]
2398 fn test_ycb_object_mesh_path() {
2399 let path = crate::ycb::object_mesh_path("/tmp/ycb", "003_cracker_box");
2400 assert_eq!(
2401 path,
2402 std::path::Path::new("/tmp/ycb")
2403 .join("003_cracker_box")
2404 .join("google_16k")
2405 .join("textured.obj")
2406 );
2407 }
2408
2409 #[test]
2410 fn test_ycb_object_texture_path() {
2411 let path = crate::ycb::object_texture_path("/tmp/ycb", "003_cracker_box");
2412 assert_eq!(
2413 path,
2414 std::path::Path::new("/tmp/ycb")
2415 .join("003_cracker_box")
2416 .join("google_16k")
2417 .join("texture_map.png")
2418 );
2419 }
2420
2421 #[test]
2426 fn test_render_config_tbp_default() {
2427 let config = RenderConfig::tbp_default();
2428 assert_eq!(config.width, 64);
2430 assert_eq!(config.height, 64);
2431 assert!(config.zoom > 0.0);
2433 assert!(config.near_plane > 0.0);
2435 assert!(config.far_plane > config.near_plane);
2436 }
2437
2438 #[test]
2439 fn test_render_config_preview() {
2440 let config = RenderConfig::preview();
2441 assert_eq!(config.width, 256);
2442 assert_eq!(config.height, 256);
2443 }
2444
2445 #[test]
2446 fn test_render_config_default_is_tbp() {
2447 let default = RenderConfig::default();
2448 let tbp = RenderConfig::tbp_default();
2449 assert_eq!(default.width, tbp.width);
2450 assert_eq!(default.height, tbp.height);
2451 }
2452
2453 #[test]
2454 fn test_render_config_fov() {
2455 let config = RenderConfig::tbp_default();
2456 let fov = config.fov_radians();
2457 assert!(fov > 0.0);
2460 assert!(fov < PI);
2461
2462 let zoomed = RenderConfig {
2464 zoom: config.zoom * 2.0,
2465 ..config
2466 };
2467 assert!(zoomed.fov_radians() < fov);
2468 }
2469
2470 #[test]
2471 fn test_render_config_intrinsics() {
2472 let config = RenderConfig::tbp_default();
2473 let intrinsics = config.intrinsics();
2474
2475 assert_eq!(intrinsics.image_size, [config.width, config.height]);
2477 assert_eq!(
2478 intrinsics.principal_point,
2479 [config.width as f64 / 2.0, config.height as f64 / 2.0]
2480 );
2481 assert_eq!(intrinsics.focal_length[0], intrinsics.focal_length[1]);
2483 assert!(intrinsics.focal_length[0] > 0.0);
2484 }
2485
2486 #[test]
2487 fn test_render_config_intrinsics_for_size_uses_tbp_zoom_formula() {
2488 let config = RenderConfig {
2489 width: 64,
2490 height: 64,
2491 zoom: 4.0,
2492 ..RenderConfig::tbp_default()
2493 };
2494
2495 let intrinsics = config.intrinsics_for_size(64, 64);
2496
2497 assert!((intrinsics.focal_length[0] - 128.0).abs() < 1e-9);
2500 assert!((intrinsics.focal_length[1] - 128.0).abs() < 1e-9);
2501 assert_ne!(intrinsics.focal_length[0], 64.0 * config.zoom as f64);
2502 assert_eq!(intrinsics.principal_point, [32.0, 32.0]);
2503 assert_eq!(intrinsics.image_size, [64, 64]);
2504 }
2505
2506 #[test]
2507 fn test_render_config_intrinsics_for_size_tracks_actual_readback_size() {
2508 let config = RenderConfig {
2509 width: 64,
2510 height: 64,
2511 zoom: 4.0,
2512 ..RenderConfig::tbp_default()
2513 };
2514
2515 let intrinsics = config.intrinsics_for_size(128, 96);
2516
2517 assert!((intrinsics.focal_length[0] - 256.0).abs() < 1e-9);
2518 assert!((intrinsics.focal_length[1] - 256.0).abs() < 1e-9);
2519 assert_eq!(intrinsics.principal_point, [64.0, 48.0]);
2520 assert_eq!(intrinsics.image_size, [128, 96]);
2521 }
2522
2523 #[test]
2524 fn test_camera_intrinsics_project() {
2525 let intrinsics = CameraIntrinsics {
2526 focal_length: [100.0, 100.0],
2527 principal_point: [32.0, 32.0],
2528 image_size: [64, 64],
2529 };
2530
2531 let center = intrinsics.project(Vec3::new(0.0, 0.0, 1.0));
2533 assert!(center.is_some());
2534 let [x, y] = center.unwrap();
2535 assert!((x - 32.0).abs() < 0.001);
2536 assert!((y - 32.0).abs() < 0.001);
2537
2538 let behind = intrinsics.project(Vec3::new(0.0, 0.0, -1.0));
2540 assert!(behind.is_none());
2541 }
2542
2543 #[test]
2544 fn test_camera_intrinsics_unproject() {
2545 let intrinsics = CameraIntrinsics {
2546 focal_length: [100.0, 100.0],
2547 principal_point: [32.0, 32.0],
2548 image_size: [64, 64],
2549 };
2550
2551 let point = intrinsics.unproject([32.0, 32.0], 1.0);
2553 assert!((point[0]).abs() < 0.001); assert!((point[1]).abs() < 0.001); assert!((point[2] - 1.0).abs() < 0.001); }
2557
2558 #[test]
2559 fn test_render_output_get_rgba() {
2560 let output = RenderOutput {
2561 rgba: vec![
2562 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255,
2563 ],
2564 depth: vec![1.0, 2.0, 3.0, 4.0],
2565 width: 2,
2566 height: 2,
2567 intrinsics: RenderConfig::tbp_default().intrinsics(),
2568 camera_transform: Transform::IDENTITY,
2569 object_rotation: ObjectRotation::identity(),
2570 object_translation: Vec3::ZERO,
2571 object_scale: Vec3::ONE,
2572 target_point: Vec3::ZERO,
2573 targeting_policy: TargetingPolicy::Origin,
2574 };
2575
2576 assert_eq!(output.get_rgba(0, 0), Some([255, 0, 0, 255]));
2578 assert_eq!(output.get_rgba(1, 0), Some([0, 255, 0, 255]));
2580 assert_eq!(output.get_rgba(0, 1), Some([0, 0, 255, 255]));
2582 assert_eq!(output.get_rgba(1, 1), Some([255, 255, 255, 255]));
2584 assert_eq!(output.get_rgba(2, 0), None);
2586 }
2587
2588 #[test]
2589 fn test_render_output_get_depth() {
2590 let output = RenderOutput {
2591 rgba: vec![0u8; 16],
2592 depth: vec![1.0, 2.0, 3.0, 4.0],
2593 width: 2,
2594 height: 2,
2595 intrinsics: RenderConfig::tbp_default().intrinsics(),
2596 camera_transform: Transform::IDENTITY,
2597 object_rotation: ObjectRotation::identity(),
2598 object_translation: Vec3::ZERO,
2599 object_scale: Vec3::ONE,
2600 target_point: Vec3::ZERO,
2601 targeting_policy: TargetingPolicy::Origin,
2602 };
2603
2604 assert_eq!(output.get_depth(0, 0), Some(1.0));
2605 assert_eq!(output.get_depth(1, 0), Some(2.0));
2606 assert_eq!(output.get_depth(0, 1), Some(3.0));
2607 assert_eq!(output.get_depth(1, 1), Some(4.0));
2608 assert_eq!(output.get_depth(2, 0), None);
2609 }
2610
2611 #[test]
2612 fn test_render_output_to_rgb_image() {
2613 let output = RenderOutput {
2614 rgba: vec![
2615 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255,
2616 ],
2617 depth: vec![1.0, 2.0, 3.0, 4.0],
2618 width: 2,
2619 height: 2,
2620 intrinsics: RenderConfig::tbp_default().intrinsics(),
2621 camera_transform: Transform::IDENTITY,
2622 object_rotation: ObjectRotation::identity(),
2623 object_translation: Vec3::ZERO,
2624 object_scale: Vec3::ONE,
2625 target_point: Vec3::ZERO,
2626 targeting_policy: TargetingPolicy::Origin,
2627 };
2628
2629 let image = output.to_rgb_image();
2630 assert_eq!(image.len(), 2); 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]); }
2637
2638 #[test]
2639 fn test_render_output_to_depth_image() {
2640 let output = RenderOutput {
2641 rgba: vec![0u8; 16],
2642 depth: vec![1.0, 2.0, 3.0, 4.0],
2643 width: 2,
2644 height: 2,
2645 intrinsics: RenderConfig::tbp_default().intrinsics(),
2646 camera_transform: Transform::IDENTITY,
2647 object_rotation: ObjectRotation::identity(),
2648 object_translation: Vec3::ZERO,
2649 object_scale: Vec3::ONE,
2650 target_point: Vec3::ZERO,
2651 targeting_policy: TargetingPolicy::Origin,
2652 };
2653
2654 let depth_image = output.to_depth_image();
2655 assert_eq!(depth_image.len(), 2);
2656 assert_eq!(depth_image[0], vec![1.0, 2.0]);
2657 assert_eq!(depth_image[1], vec![3.0, 4.0]);
2658 }
2659
2660 #[test]
2661 fn test_render_output_semantic_3d_marks_foreground_and_background() {
2662 let output = render_output_for_depth(
2663 2,
2664 2,
2665 vec![0.25, 10.0, 0.5, f64::INFINITY],
2666 CameraIntrinsics {
2667 focal_length: [1.0, 1.0],
2668 principal_point: [0.0, 0.0],
2669 image_size: [2, 2],
2670 },
2671 Transform::IDENTITY,
2672 );
2673
2674 let semantic = output.semantic_3d(42);
2675
2676 assert_eq!(semantic.len(), 4);
2677 assert_eq!(semantic[0][3], 42.0);
2678 assert_eq!(semantic[1], [0.0, 0.0, 0.0, 0.0]);
2679 assert_eq!(semantic[2][3], 42.0);
2680 assert_eq!(semantic[3], [0.0, 0.0, 0.0, 0.0]);
2681 assert_point_close(
2682 [semantic[0][0], semantic[0][1], semantic[0][2]],
2683 [0.0, 0.0, -0.25],
2684 );
2685 assert_point_close(
2686 [semantic[2][0], semantic[2][1], semantic[2][2]],
2687 [0.0, -0.5, -0.5],
2688 );
2689 }
2690
2691 #[test]
2692 fn test_render_output_semantic_3d_matches_pixel_surface_points() {
2693 let output = render_output_for_depth(
2694 3,
2695 3,
2696 vec![10.0, 10.0, 2.0, 10.0, 0.25, 10.0, 10.0, 10.0, 10.0],
2697 CameraIntrinsics {
2698 focal_length: [1.0, 1.0],
2699 principal_point: [1.0, 1.0],
2700 image_size: [3, 3],
2701 },
2702 Transform::IDENTITY,
2703 );
2704
2705 let semantic = output.semantic_3d(3);
2706 let top_right = output
2707 .pixel_surface_point_world([2, 0])
2708 .expect("foreground point");
2709 let center = output
2710 .pixel_surface_point_world([1, 1])
2711 .expect("foreground point");
2712
2713 assert_point_close([semantic[2][0], semantic[2][1], semantic[2][2]], top_right);
2714 assert_eq!(semantic[2][3], 3.0);
2715 assert_point_close([semantic[4][0], semantic[4][1], semantic[4][2]], center);
2716 assert_eq!(semantic[4][3], 3.0);
2717 }
2718
2719 #[test]
2720 fn test_render_health_center_hit() {
2721 let mut depth = vec![10.0; 7 * 7];
2722 depth[3 * 7 + 3] = 0.25;
2723 depth[6 * 7 + 6] = 0.5;
2724 let output = render_output_for_depth(
2725 7,
2726 7,
2727 depth,
2728 CameraIntrinsics {
2729 focal_length: [10.0, 10.0],
2730 principal_point: [3.0, 3.0],
2731 image_size: [7, 7],
2732 },
2733 Transform::IDENTITY,
2734 );
2735
2736 let health = output.health();
2737
2738 assert_eq!(health.center_pixel, Some([3, 3]));
2739 assert_eq!(health.center_depth, Some(0.25));
2740 assert!(health.center_foreground);
2741 assert_eq!(health.foreground_pixel_count, 2);
2742 assert!((health.foreground_coverage - 2.0 / 49.0).abs() < 1e-12);
2743 assert_eq!(health.center_5x5_foreground_count, 1);
2744 assert_eq!(health.nearest_foreground_pixel, Some([3, 3]));
2745 assert_eq!(health.nearest_foreground_depth, Some(0.25));
2746 assert_eq!(health.nearest_foreground_distance_px, Some(0.0));
2747 }
2748
2749 #[test]
2750 fn test_render_health_far_center_uses_nearest_foreground() {
2751 let mut depth = vec![10.0; 7 * 7];
2752 depth[3 * 7 + 1] = 0.5;
2753 let output = render_output_for_depth(
2754 7,
2755 7,
2756 depth,
2757 CameraIntrinsics {
2758 focal_length: [10.0, 10.0],
2759 principal_point: [3.0, 3.0],
2760 image_size: [7, 7],
2761 },
2762 Transform::IDENTITY,
2763 );
2764
2765 let health = output.health();
2766
2767 assert_eq!(health.center_pixel, Some([3, 3]));
2768 assert_eq!(health.center_depth, Some(10.0));
2769 assert!(!health.center_foreground);
2770 assert_eq!(health.foreground_pixel_count, 1);
2771 assert_eq!(health.center_5x5_foreground_count, 1);
2772 assert_eq!(health.nearest_foreground_pixel, Some([1, 3]));
2773 assert_eq!(health.nearest_foreground_depth, Some(0.5));
2774 assert_eq!(health.nearest_foreground_distance_px, Some(2.0));
2775 }
2776
2777 #[test]
2778 fn test_center_surface_point_world_uses_bevy_camera_forward() {
2779 let mut depth = vec![10.0; 3 * 3];
2780 depth[3 + 1] = 0.25;
2781 let output = render_output_for_depth(
2782 3,
2783 3,
2784 depth,
2785 CameraIntrinsics {
2786 focal_length: [1.0, 1.0],
2787 principal_point: [1.0, 1.0],
2788 image_size: [3, 3],
2789 },
2790 Transform::IDENTITY,
2791 );
2792
2793 assert_eq!(output.center_pixel_depth(), Some(0.25));
2794 assert_point_close(
2795 output.center_surface_point_world().expect("surface point"),
2796 [0.0, 0.0, -0.25],
2797 );
2798 }
2799
2800 #[test]
2801 fn test_pixel_surface_point_world_maps_image_y_down_to_camera_y_up() {
2802 let mut depth = vec![10.0; 3 * 3];
2803 depth[2] = 2.0;
2804 let output = render_output_for_depth(
2805 3,
2806 3,
2807 depth,
2808 CameraIntrinsics {
2809 focal_length: [1.0, 1.0],
2810 principal_point: [1.0, 1.0],
2811 image_size: [3, 3],
2812 },
2813 Transform::IDENTITY,
2814 );
2815
2816 assert_point_close(
2817 output
2818 .pixel_surface_point_world([2, 0])
2819 .expect("surface point"),
2820 [2.0, 2.0, -2.0],
2821 );
2822 }
2823
2824 #[test]
2825 fn test_camera_world_point_helpers_roundtrip() {
2826 let output = render_output_for_depth(
2827 1,
2828 1,
2829 vec![0.25],
2830 CameraIntrinsics {
2831 focal_length: [1.0, 1.0],
2832 principal_point: [0.0, 0.0],
2833 image_size: [1, 1],
2834 },
2835 Transform::from_xyz(0.0, 0.0, 1.0).looking_at(Vec3::ZERO, Vec3::Y),
2836 );
2837
2838 assert_point_close(
2839 output.center_surface_point_world().expect("surface point"),
2840 [0.0, 0.0, 0.75],
2841 );
2842
2843 let world_point = [0.1, -0.2, 0.7];
2844 let camera_point = output.world_to_camera_point(world_point);
2845 assert_point_close(output.camera_to_world_point(camera_point), world_point);
2846 }
2847
2848 #[test]
2849 fn test_render_error_display() {
2850 let err = RenderError::MeshNotFound("/path/to/mesh.obj".to_string());
2851 assert!(err.to_string().contains("Mesh not found"));
2852 assert!(err.to_string().contains("/path/to/mesh.obj"));
2853 }
2854
2855 #[test]
2860 fn test_object_rotation_extreme_angles() {
2861 let rot = ObjectRotation::new(450.0, -720.0, 1080.0);
2863 let quat = rot.to_quat();
2864 assert!((quat.length() - 1.0).abs() < 0.001);
2866 }
2867
2868 #[test]
2869 fn test_object_rotation_to_transform() {
2870 let rot = ObjectRotation::new(45.0, 90.0, 0.0);
2871 let transform = rot.to_transform();
2872 assert_eq!(transform.translation, Vec3::ZERO);
2874 assert!(transform.rotation != Quat::IDENTITY);
2876 }
2877
2878 #[test]
2879 fn test_object_rotation_to_transform_with_translation_scale() {
2880 let rot = ObjectRotation::new(0.0, 90.0, 0.0);
2881 let translation = Vec3::new(0.25, -0.5, 1.25);
2882 let scale = Vec3::new(1.0, 1.5, 0.75);
2883 let transform = rot.to_transform_with_translation_scale(translation, scale);
2884
2885 assert_eq!(transform.translation, translation);
2886 assert_eq!(transform.scale, scale);
2887 assert_eq!(transform.rotation, rot.to_quat());
2888 }
2889
2890 #[test]
2891 fn test_viewpoint_config_single_viewpoint() {
2892 let config = ViewpointConfig {
2893 radius: 1.0,
2894 yaw_count: 1,
2895 pitch_angles_deg: vec![0.0],
2896 };
2897 assert_eq!(config.viewpoint_count(), 1);
2898 let viewpoints = generate_viewpoints(&config);
2899 assert_eq!(viewpoints.len(), 1);
2900 let pos = viewpoints[0].translation;
2902 assert!((pos.x).abs() < 0.001);
2903 assert!((pos.y).abs() < 0.001);
2904 assert!((pos.z - 1.0).abs() < 0.001);
2905 }
2906
2907 #[test]
2908 fn test_viewpoint_radius_scaling() {
2909 let config1 = ViewpointConfig {
2910 radius: 0.5,
2911 yaw_count: 4,
2912 pitch_angles_deg: vec![0.0],
2913 };
2914 let config2 = ViewpointConfig {
2915 radius: 2.0,
2916 yaw_count: 4,
2917 pitch_angles_deg: vec![0.0],
2918 };
2919
2920 let v1 = generate_viewpoints(&config1);
2921 let v2 = generate_viewpoints(&config2);
2922
2923 for (vp1, vp2) in v1.iter().zip(v2.iter()) {
2925 let ratio = vp2.translation.length() / vp1.translation.length();
2926 assert!((ratio - 4.0).abs() < 0.01); }
2928 }
2929
2930 #[test]
2931 fn test_camera_intrinsics_project_at_z_zero() {
2932 let intrinsics = CameraIntrinsics {
2933 focal_length: [100.0, 100.0],
2934 principal_point: [32.0, 32.0],
2935 image_size: [64, 64],
2936 };
2937
2938 let result = intrinsics.project(Vec3::new(1.0, 1.0, 0.0));
2940 assert!(result.is_none());
2941 }
2942
2943 #[test]
2944 fn test_camera_intrinsics_roundtrip() {
2945 let intrinsics = CameraIntrinsics {
2946 focal_length: [100.0, 100.0],
2947 principal_point: [32.0, 32.0],
2948 image_size: [64, 64],
2949 };
2950
2951 let original = Vec3::new(0.5, -0.3, 2.0);
2953 let projected = intrinsics.project(original).unwrap();
2954
2955 let unprojected = intrinsics.unproject(projected, original.z as f64);
2957
2958 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); }
2963
2964 #[test]
2965 fn test_render_output_empty() {
2966 let output = RenderOutput {
2967 rgba: vec![],
2968 depth: vec![],
2969 width: 0,
2970 height: 0,
2971 intrinsics: RenderConfig::tbp_default().intrinsics(),
2972 camera_transform: Transform::IDENTITY,
2973 object_rotation: ObjectRotation::identity(),
2974 object_translation: Vec3::ZERO,
2975 object_scale: Vec3::ONE,
2976 target_point: Vec3::ZERO,
2977 targeting_policy: TargetingPolicy::Origin,
2978 };
2979
2980 assert_eq!(output.get_rgba(0, 0), None);
2982 assert_eq!(output.get_depth(0, 0), None);
2983 assert!(output.to_rgb_image().is_empty());
2984 assert!(output.to_depth_image().is_empty());
2985 }
2986
2987 #[test]
2988 fn test_render_output_1x1() {
2989 let output = RenderOutput {
2990 rgba: vec![128, 64, 32, 255],
2991 depth: vec![0.5],
2992 width: 1,
2993 height: 1,
2994 intrinsics: RenderConfig::tbp_default().intrinsics(),
2995 camera_transform: Transform::IDENTITY,
2996 object_rotation: ObjectRotation::identity(),
2997 object_translation: Vec3::ZERO,
2998 object_scale: Vec3::ONE,
2999 target_point: Vec3::ZERO,
3000 targeting_policy: TargetingPolicy::Origin,
3001 };
3002
3003 assert_eq!(output.get_rgba(0, 0), Some([128, 64, 32, 255]));
3004 assert_eq!(output.get_depth(0, 0), Some(0.5));
3005 assert_eq!(output.get_rgb(0, 0), Some([128, 64, 32]));
3006
3007 let rgb_img = output.to_rgb_image();
3008 assert_eq!(rgb_img.len(), 1);
3009 assert_eq!(rgb_img[0].len(), 1);
3010 assert_eq!(rgb_img[0][0], [128, 64, 32]);
3011 }
3012
3013 #[test]
3014 fn test_render_config_high_res() {
3015 let config = RenderConfig::high_res();
3016 assert_eq!(config.width, 512);
3017 assert_eq!(config.height, 512);
3018
3019 let intrinsics = config.intrinsics();
3020 assert_eq!(intrinsics.image_size, [512, 512]);
3021 assert_eq!(intrinsics.principal_point, [256.0, 256.0]);
3022 }
3023
3024 #[test]
3025 fn test_render_config_zoom_affects_fov() {
3026 let base = RenderConfig {
3031 zoom: 2.0,
3032 ..RenderConfig::tbp_default()
3033 };
3034 let doubled = RenderConfig {
3035 zoom: 4.0,
3036 ..RenderConfig::tbp_default()
3037 };
3038
3039 assert!(doubled.fov_radians() < base.fov_radians());
3041
3042 let base_half_tan = (base.fov_radians() / 2.0).tan();
3044 let doubled_half_tan = (doubled.fov_radians() / 2.0).tan();
3045 assert!((base_half_tan / doubled_half_tan - 2.0).abs() < 1e-4);
3046 }
3047
3048 #[test]
3049 fn test_render_config_zoom_affects_intrinsics() {
3050 let a = RenderConfig {
3053 zoom: 2.0,
3054 ..RenderConfig::tbp_default()
3055 };
3056 let b = RenderConfig {
3057 zoom: 4.0,
3058 ..RenderConfig::tbp_default()
3059 };
3060
3061 let fx_a = a.intrinsics().focal_length[0];
3062 let fx_b = b.intrinsics().focal_length[0];
3063
3064 assert!(fx_b > fx_a);
3066
3067 assert!((fx_a / a.zoom as f64 - fx_b / b.zoom as f64).abs() < 1e-9);
3069 }
3070
3071 #[test]
3072 fn test_lighting_config_variants() {
3073 let default = LightingConfig::default();
3074 let bright = LightingConfig::bright();
3075 let soft = LightingConfig::soft();
3076 let unlit = LightingConfig::unlit();
3077
3078 assert!(bright.key_light_intensity > default.key_light_intensity);
3080
3081 assert_eq!(unlit.key_light_intensity, 0.0);
3083 assert_eq!(unlit.fill_light_intensity, 0.0);
3084 assert_eq!(unlit.ambient_brightness, 1.0);
3085
3086 assert!(soft.key_light_intensity < default.key_light_intensity);
3088 }
3089
3090 #[test]
3091 fn test_all_render_error_variants() {
3092 let errors = vec![
3093 RenderError::MeshNotFound("mesh.obj".to_string()),
3094 RenderError::TextureNotFound("texture.png".to_string()),
3095 RenderError::RenderFailed("GPU error".to_string()),
3096 RenderError::InvalidConfig("bad config".to_string()),
3097 ];
3098
3099 for err in errors {
3100 let msg = err.to_string();
3102 assert!(!msg.is_empty());
3103 }
3104 }
3105
3106 #[test]
3107 fn test_tbp_known_orientations_unique() {
3108 let orientations = ObjectRotation::tbp_known_orientations();
3109
3110 let quats: Vec<Quat> = orientations.iter().map(|r| r.to_quat()).collect();
3112
3113 for (i, q1) in quats.iter().enumerate() {
3114 for (j, q2) in quats.iter().enumerate() {
3115 if i != j {
3116 let dot = q1.dot(*q2).abs();
3118 assert!(
3119 dot < 0.999,
3120 "Orientations {} and {} produce same quaternion",
3121 i,
3122 j
3123 );
3124 }
3125 }
3126 }
3127 }
3128}