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_to_buffer(object_dir, camera_transform, object_rotation, config)
1365 .map(|output| output.with_targeting(target_point, targeting_policy))
1366}
1367
1368#[allow(clippy::too_many_arguments)]
1370pub fn render_to_buffer_with_target_and_object_transform(
1371 object_dir: &Path,
1372 camera_transform: &Transform,
1373 object_rotation: &ObjectRotation,
1374 object_translation: Vec3,
1375 object_scale: Vec3,
1376 config: &RenderConfig,
1377 target_point: Vec3,
1378 targeting_policy: TargetingPolicy,
1379) -> Result<RenderOutput, RenderError> {
1380 render_to_buffer_with_object_transform(
1381 object_dir,
1382 camera_transform,
1383 object_rotation,
1384 object_translation,
1385 object_scale,
1386 config,
1387 )
1388 .map(|output| output.with_targeting(target_point, targeting_policy))
1389}
1390
1391pub fn render_all_viewpoints(
1404 object_dir: &Path,
1405 viewpoint_config: &ViewpointConfig,
1406 rotations: &[ObjectRotation],
1407 render_config: &RenderConfig,
1408) -> Result<Vec<RenderOutput>, RenderError> {
1409 let viewpoints = generate_viewpoints(viewpoint_config);
1410 let mut outputs = Vec::with_capacity(viewpoints.len() * rotations.len());
1411
1412 for rotation in rotations {
1413 for viewpoint in &viewpoints {
1414 let output = render_to_buffer(object_dir, viewpoint, rotation, render_config)?;
1415 outputs.push(output);
1416 }
1417 }
1418
1419 Ok(outputs)
1420}
1421
1422#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1424pub struct CenterHitValidationReport {
1425 pub object_id: String,
1427 pub object_dir: String,
1429 pub target_policy: TargetingPolicy,
1431 pub rotations: Vec<CenterHitRotationReport>,
1433}
1434
1435impl CenterHitValidationReport {
1436 pub fn is_valid(&self) -> bool {
1438 self.rotations
1439 .iter()
1440 .all(|rotation| rotation.center_hits > 0)
1441 }
1442
1443 pub fn zero_hit_rotations(&self) -> Vec<usize> {
1445 self.rotations
1446 .iter()
1447 .filter(|rotation| rotation.center_hits == 0)
1448 .map(|rotation| rotation.rotation_index)
1449 .collect()
1450 }
1451}
1452
1453#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1455pub struct CenterHitRotationReport {
1456 pub rotation_index: usize,
1457 pub rotation_euler: [f64; 3],
1458 pub target_point: [f32; 3],
1459 pub mesh_bounds: Option<MeshBoundsMetadata>,
1460 pub total_viewpoints: usize,
1461 pub center_hits: usize,
1462 pub center_misses: usize,
1463 pub misses: Vec<CenterHitMiss>,
1464}
1465
1466#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
1468pub struct MeshBoundsMetadata {
1469 pub min: [f32; 3],
1470 pub max: [f32; 3],
1471 pub center: [f32; 3],
1472 pub vertex_count: usize,
1473}
1474
1475impl From<MeshBounds> for MeshBoundsMetadata {
1476 fn from(bounds: MeshBounds) -> Self {
1477 Self {
1478 min: bounds.min.to_array(),
1479 max: bounds.max.to_array(),
1480 center: bounds.center.to_array(),
1481 vertex_count: bounds.vertex_count,
1482 }
1483 }
1484}
1485
1486#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1488pub struct CenterHitMiss {
1489 pub viewpoint_index: usize,
1490 pub camera_position: [f32; 3],
1491 pub camera_rotation_xyzw: [f32; 4],
1492 pub health: RenderHealth,
1493}
1494
1495pub fn validate_center_hits(
1498 object_id: impl Into<String>,
1499 object_dir: &Path,
1500 viewpoint_config: &ViewpointConfig,
1501 rotations: &[ObjectRotation],
1502 render_config: &RenderConfig,
1503 target_policy: &TargetingPolicy,
1504) -> Result<CenterHitValidationReport, RenderError> {
1505 let object_id = object_id.into();
1506 let mut rotation_reports = Vec::with_capacity(rotations.len());
1507
1508 for (rotation_index, rotation) in rotations.iter().enumerate() {
1509 let targeted =
1510 generate_targeted_viewpoints(object_dir, viewpoint_config, rotation, target_policy)?;
1511 let requests: Vec<batch::BatchRenderRequest> = targeted
1512 .viewpoints
1513 .iter()
1514 .map(|viewpoint| batch::BatchRenderRequest {
1515 object_dir: PathBuf::from(object_dir),
1516 viewpoint: *viewpoint,
1517 object_rotation: rotation.clone(),
1518 object_translation: Vec3::ZERO,
1519 object_scale: Vec3::ONE,
1520 render_config: render_config.clone(),
1521 target_point: targeted.target_point,
1522 targeting_policy: target_policy.clone(),
1523 })
1524 .collect();
1525
1526 let outputs = render_batch(requests, &batch::BatchRenderConfig::default())
1527 .map_err(|error| RenderError::RenderFailed(error.to_string()))?;
1528
1529 let mut center_hits = 0usize;
1530 let mut misses = Vec::new();
1531 for (viewpoint_index, output) in outputs.iter().enumerate() {
1532 if output.status != batch::RenderStatus::Success {
1533 return Err(RenderError::RenderFailed(format!(
1534 "Render failed for {} rotation {} viewpoint {}: {:?}",
1535 object_id, rotation_index, viewpoint_index, output.error_message
1536 )));
1537 }
1538
1539 if output.health.center_foreground {
1540 center_hits += 1;
1541 } else {
1542 let t = output.request.viewpoint.translation;
1543 let q = output.request.viewpoint.rotation;
1544 misses.push(CenterHitMiss {
1545 viewpoint_index,
1546 camera_position: [t.x, t.y, t.z],
1547 camera_rotation_xyzw: [q.x, q.y, q.z, q.w],
1548 health: output.health.clone(),
1549 });
1550 }
1551 }
1552
1553 rotation_reports.push(CenterHitRotationReport {
1554 rotation_index,
1555 rotation_euler: [rotation.pitch, rotation.yaw, rotation.roll],
1556 target_point: targeted.target_point.to_array(),
1557 mesh_bounds: targeted.mesh_bounds.map(MeshBoundsMetadata::from),
1558 total_viewpoints: outputs.len(),
1559 center_hits,
1560 center_misses: outputs.len().saturating_sub(center_hits),
1561 misses,
1562 });
1563 }
1564
1565 Ok(CenterHitValidationReport {
1566 object_id,
1567 object_dir: object_dir.display().to_string(),
1568 target_policy: target_policy.clone(),
1569 rotations: rotation_reports,
1570 })
1571}
1572
1573pub fn render_to_buffer_cached(
1648 object_dir: &Path,
1649 camera_transform: &Transform,
1650 object_rotation: &ObjectRotation,
1651 config: &RenderConfig,
1652 cache: &mut cache::ModelCache,
1653) -> Result<RenderOutput, RenderError> {
1654 render_to_buffer_cached_with_object_transform(
1655 object_dir,
1656 camera_transform,
1657 object_rotation,
1658 Vec3::ZERO,
1659 Vec3::ONE,
1660 config,
1661 cache,
1662 )
1663}
1664
1665pub fn render_to_buffer_cached_with_object_transform(
1667 object_dir: &Path,
1668 camera_transform: &Transform,
1669 object_rotation: &ObjectRotation,
1670 object_translation: Vec3,
1671 object_scale: Vec3,
1672 config: &RenderConfig,
1673 cache: &mut cache::ModelCache,
1674) -> Result<RenderOutput, RenderError> {
1675 let mesh_path = object_dir.join("google_16k/textured.obj");
1676 let texture_path = object_dir.join("google_16k/texture_map.png");
1677
1678 cache.cache_scene(mesh_path.clone());
1680 cache.cache_texture(texture_path.clone());
1681
1682 render::render_headless(
1684 object_dir,
1685 camera_transform,
1686 object_rotation,
1687 object_translation,
1688 object_scale,
1689 config,
1690 )
1691}
1692
1693pub fn render_to_files(
1710 object_dir: &Path,
1711 camera_transform: &Transform,
1712 object_rotation: &ObjectRotation,
1713 config: &RenderConfig,
1714 rgba_path: &Path,
1715 depth_path: &Path,
1716) -> Result<(), RenderError> {
1717 render_to_files_with_object_transform(
1718 object_dir,
1719 camera_transform,
1720 object_rotation,
1721 Vec3::ZERO,
1722 Vec3::ONE,
1723 config,
1724 rgba_path,
1725 depth_path,
1726 )
1727}
1728
1729#[allow(clippy::too_many_arguments)]
1731pub fn render_to_files_with_object_transform(
1732 object_dir: &Path,
1733 camera_transform: &Transform,
1734 object_rotation: &ObjectRotation,
1735 object_translation: Vec3,
1736 object_scale: Vec3,
1737 config: &RenderConfig,
1738 rgba_path: &Path,
1739 depth_path: &Path,
1740) -> Result<(), RenderError> {
1741 render::render_to_files(
1742 object_dir,
1743 camera_transform,
1744 object_rotation,
1745 object_translation,
1746 object_scale,
1747 config,
1748 rgba_path,
1749 depth_path,
1750 )
1751}
1752
1753pub use batch::{
1755 BatchRenderConfig, BatchRenderError, BatchRenderOutput, BatchRenderRequest, BatchRenderer,
1756 BatchState, RenderStatus,
1757};
1758
1759pub use render::RenderSession;
1762
1763pub use render::PersistentRenderer;
1769
1770pub fn create_batch_renderer(config: &BatchRenderConfig) -> Result<BatchRenderer, RenderError> {
1788 Ok(BatchRenderer::new(config.clone()))
1789}
1790
1791pub fn queue_render_request(
1818 renderer: &mut BatchRenderer,
1819 request: BatchRenderRequest,
1820) -> Result<(), RenderError> {
1821 renderer
1822 .queue_request(request)
1823 .map_err(|e| RenderError::RenderFailed(e.to_string()))
1824}
1825
1826pub fn render_next_in_batch(
1848 renderer: &mut BatchRenderer,
1849 _timeout_ms: u32,
1850) -> Result<Option<BatchRenderOutput>, RenderError> {
1851 if let Some(request) = renderer.pending_requests.pop_front() {
1852 let output = render_to_buffer_with_object_transform(
1853 &request.object_dir,
1854 &request.viewpoint,
1855 &request.object_rotation,
1856 request.object_translation,
1857 request.object_scale,
1858 &request.render_config,
1859 )?;
1860 let batch_output = BatchRenderOutput::from_render_output(request, output);
1861 renderer.completed_results.push(batch_output.clone());
1862 renderer.renders_processed += 1;
1863 Ok(Some(batch_output))
1864 } else {
1865 Ok(None)
1866 }
1867}
1868
1869pub fn render_batch(
1888 requests: Vec<BatchRenderRequest>,
1889 config: &BatchRenderConfig,
1890) -> Result<Vec<BatchRenderOutput>, RenderError> {
1891 if requests.is_empty() {
1892 return Ok(Vec::new());
1893 }
1894
1895 if requests.len() > 1 && requests_share_batch_context(&requests) {
1896 let first_request = requests[0].clone();
1897 let viewpoints: Vec<Transform> = requests.iter().map(|request| request.viewpoint).collect();
1898 let outputs = render::render_headless_sequence(
1899 &first_request.object_dir,
1900 &viewpoints,
1901 &first_request.object_rotation,
1902 first_request.object_translation,
1903 first_request.object_scale,
1904 &first_request.render_config,
1905 )?;
1906
1907 return Ok(requests
1908 .into_iter()
1909 .zip(outputs)
1910 .map(|(request, output)| BatchRenderOutput::from_render_output(request, output))
1911 .collect());
1912 }
1913
1914 let mut renderer = create_batch_renderer(config)?;
1915
1916 for request in requests {
1918 queue_render_request(&mut renderer, request)?;
1919 }
1920
1921 let mut results = Vec::new();
1923 while let Some(output) = render_next_in_batch(&mut renderer, config.frame_timeout_ms)? {
1924 results.push(output);
1925 }
1926
1927 Ok(results)
1928}
1929
1930fn requests_share_batch_context(requests: &[BatchRenderRequest]) -> bool {
1931 let Some(first) = requests.first() else {
1932 return true;
1933 };
1934
1935 requests.iter().all(|request| {
1936 request.object_dir == first.object_dir
1937 && request.object_rotation == first.object_rotation
1938 && request.object_translation == first.object_translation
1939 && request.object_scale == first.object_scale
1940 && request.render_config == first.render_config
1941 })
1942}
1943
1944pub use bevy::prelude::{Quat, Transform, Vec3};
1946
1947#[cfg(test)]
1948mod tests {
1949 use super::*;
1950
1951 fn assert_vec3_close(actual: Vec3, expected: Vec3) {
1952 assert!(
1953 (actual - expected).length() < 1e-5,
1954 "expected {:?}, got {:?}",
1955 expected,
1956 actual
1957 );
1958 }
1959
1960 fn assert_point_close(actual: [f64; 3], expected: [f64; 3]) {
1961 for axis in 0..3 {
1962 assert!(
1963 (actual[axis] - expected[axis]).abs() < 1e-5,
1964 "axis {} expected {:?}, got {:?}",
1965 axis,
1966 expected,
1967 actual
1968 );
1969 }
1970 }
1971
1972 fn render_output_for_depth(
1973 width: u32,
1974 height: u32,
1975 depth: Vec<f64>,
1976 intrinsics: CameraIntrinsics,
1977 camera_transform: Transform,
1978 ) -> RenderOutput {
1979 RenderOutput {
1980 rgba: vec![0u8; (width * height * 4) as usize],
1981 depth,
1982 width,
1983 height,
1984 intrinsics,
1985 camera_transform,
1986 object_rotation: ObjectRotation::identity(),
1987 object_translation: Vec3::ZERO,
1988 object_scale: Vec3::ONE,
1989 target_point: Vec3::ZERO,
1990 targeting_policy: TargetingPolicy::Origin,
1991 }
1992 }
1993
1994 #[test]
1995 fn test_object_rotation_identity() {
1996 let rot = ObjectRotation::identity();
1997 assert_eq!(rot.pitch, 0.0);
1998 assert_eq!(rot.yaw, 0.0);
1999 assert_eq!(rot.roll, 0.0);
2000 }
2001
2002 #[test]
2003 fn test_object_rotation_from_array() {
2004 let rot = ObjectRotation::from_array([10.0, 20.0, 30.0]);
2005 assert_eq!(rot.pitch, 10.0);
2006 assert_eq!(rot.yaw, 20.0);
2007 assert_eq!(rot.roll, 30.0);
2008 }
2009
2010 #[test]
2011 fn test_requests_share_batch_context_for_homogeneous_batch() {
2012 let config = RenderConfig::tbp_default();
2013 let request = BatchRenderRequest {
2014 object_dir: "/tmp/ycb/003_cracker_box".into(),
2015 viewpoint: Transform::IDENTITY,
2016 object_rotation: ObjectRotation::identity(),
2017 object_translation: Vec3::ZERO,
2018 object_scale: Vec3::ONE,
2019 render_config: config.clone(),
2020 target_point: Vec3::ZERO,
2021 targeting_policy: TargetingPolicy::Origin,
2022 };
2023
2024 assert!(requests_share_batch_context(&[
2025 request.clone(),
2026 BatchRenderRequest {
2027 viewpoint: Transform::from_xyz(1.0, 0.0, 0.0),
2028 ..request
2029 },
2030 ]));
2031 }
2032
2033 #[test]
2034 fn test_requests_share_batch_context_rejects_mixed_objects() {
2035 let config = RenderConfig::tbp_default();
2036 let request = BatchRenderRequest {
2037 object_dir: "/tmp/ycb/003_cracker_box".into(),
2038 viewpoint: Transform::IDENTITY,
2039 object_rotation: ObjectRotation::identity(),
2040 object_translation: Vec3::ZERO,
2041 object_scale: Vec3::ONE,
2042 render_config: config.clone(),
2043 target_point: Vec3::ZERO,
2044 targeting_policy: TargetingPolicy::Origin,
2045 };
2046
2047 assert!(!requests_share_batch_context(&[
2048 request.clone(),
2049 BatchRenderRequest {
2050 object_dir: "/tmp/ycb/005_tomato_soup_can".into(),
2051 ..request
2052 },
2053 ]));
2054 }
2055
2056 #[test]
2057 fn test_requests_share_batch_context_rejects_mixed_object_translation() {
2058 let config = RenderConfig::tbp_default();
2059 let request = BatchRenderRequest {
2060 object_dir: "/tmp/ycb/003_cracker_box".into(),
2061 viewpoint: Transform::IDENTITY,
2062 object_rotation: ObjectRotation::identity(),
2063 object_translation: Vec3::ZERO,
2064 object_scale: Vec3::ONE,
2065 render_config: config.clone(),
2066 target_point: Vec3::ZERO,
2067 targeting_policy: TargetingPolicy::Origin,
2068 };
2069
2070 assert!(!requests_share_batch_context(&[
2071 request.clone(),
2072 BatchRenderRequest {
2073 object_translation: Vec3::new(0.1, 0.0, 0.0),
2074 ..request
2075 },
2076 ]));
2077 }
2078
2079 #[test]
2080 fn test_requests_share_batch_context_rejects_mixed_object_scale() {
2081 let config = RenderConfig::tbp_default();
2082 let request = BatchRenderRequest {
2083 object_dir: "/tmp/ycb/003_cracker_box".into(),
2084 viewpoint: Transform::IDENTITY,
2085 object_rotation: ObjectRotation::identity(),
2086 object_translation: Vec3::ZERO,
2087 object_scale: Vec3::ONE,
2088 render_config: config.clone(),
2089 target_point: Vec3::ZERO,
2090 targeting_policy: TargetingPolicy::Origin,
2091 };
2092
2093 assert!(!requests_share_batch_context(&[
2094 request.clone(),
2095 BatchRenderRequest {
2096 object_scale: Vec3::splat(1.25),
2097 ..request
2098 },
2099 ]));
2100 }
2101
2102 #[test]
2103 fn test_tbp_benchmark_rotations() {
2104 let rotations = ObjectRotation::tbp_benchmark_rotations();
2105 assert_eq!(rotations.len(), 3);
2106 assert_eq!(rotations[0], ObjectRotation::from_array([0.0, 0.0, 0.0]));
2107 assert_eq!(rotations[1], ObjectRotation::from_array([0.0, 90.0, 0.0]));
2108 assert_eq!(rotations[2], ObjectRotation::from_array([0.0, 180.0, 0.0]));
2109 }
2110
2111 #[test]
2112 fn test_tbp_known_orientations_count() {
2113 let orientations = ObjectRotation::tbp_known_orientations();
2114 assert_eq!(orientations.len(), 14);
2115 }
2116
2117 #[test]
2118 fn test_rotation_to_quat() {
2119 let rot = ObjectRotation::identity();
2120 let quat = rot.to_quat();
2121 assert!((quat.w - 1.0).abs() < 0.001);
2123 assert!(quat.x.abs() < 0.001);
2124 assert!(quat.y.abs() < 0.001);
2125 assert!(quat.z.abs() < 0.001);
2126 }
2127
2128 #[test]
2129 fn test_rotation_90_yaw() {
2130 let rot = ObjectRotation::new(0.0, 90.0, 0.0);
2131 let quat = rot.to_quat();
2132 assert!((quat.w - 0.707).abs() < 0.01);
2134 assert!((quat.y - 0.707).abs() < 0.01);
2135 }
2136
2137 #[test]
2138 fn test_viewpoint_config_default() {
2139 let config = ViewpointConfig::default();
2140 assert_eq!(config.radius, 0.5);
2141 assert_eq!(config.yaw_count, 8);
2142 assert_eq!(config.pitch_angles_deg.len(), 3);
2143 }
2144
2145 #[test]
2146 fn test_viewpoint_count() {
2147 let config = ViewpointConfig::default();
2148 assert_eq!(config.viewpoint_count(), 24); }
2150
2151 #[test]
2152 fn test_generate_viewpoints_count() {
2153 let config = ViewpointConfig::default();
2154 let viewpoints = generate_viewpoints(&config);
2155 assert_eq!(viewpoints.len(), 24);
2156 }
2157
2158 #[test]
2159 fn test_viewpoints_spherical_radius() {
2160 let config = ViewpointConfig::default();
2161 let viewpoints = generate_viewpoints(&config);
2162
2163 for (i, transform) in viewpoints.iter().enumerate() {
2164 let actual_radius = transform.translation.length();
2165 assert!(
2166 (actual_radius - config.radius).abs() < 0.001,
2167 "Viewpoint {} has incorrect radius: {} (expected {})",
2168 i,
2169 actual_radius,
2170 config.radius
2171 );
2172 }
2173 }
2174
2175 #[test]
2176 fn test_viewpoints_looking_at_origin() {
2177 let config = ViewpointConfig::default();
2178 let viewpoints = generate_viewpoints(&config);
2179
2180 for (i, transform) in viewpoints.iter().enumerate() {
2181 let forward = transform.forward();
2182 let to_origin = (Vec3::ZERO - transform.translation).normalize();
2183 let dot = forward.dot(to_origin);
2184 assert!(
2185 dot > 0.99,
2186 "Viewpoint {} not looking at origin, dot product: {}",
2187 i,
2188 dot
2189 );
2190 }
2191 }
2192
2193 #[test]
2194 fn test_generate_viewpoints_around_target_preserves_orbit() {
2195 let config = ViewpointConfig {
2196 radius: 2.0,
2197 yaw_count: 4,
2198 pitch_angles_deg: vec![0.0],
2199 };
2200 let target = Vec3::new(1.0, -0.5, 0.25);
2201 let viewpoints = generate_viewpoints_around_target(&config, target);
2202
2203 assert_eq!(viewpoints.len(), 4);
2204 for (i, transform) in viewpoints.iter().enumerate() {
2205 let offset = transform.translation - target;
2206 assert!(
2207 (offset.length() - config.radius).abs() < 1e-5,
2208 "viewpoint {} has radius {}, expected {}",
2209 i,
2210 offset.length(),
2211 config.radius
2212 );
2213
2214 let forward = transform.forward();
2215 let to_target = (target - transform.translation).normalize();
2216 assert!(
2217 forward.dot(to_target) > 0.99,
2218 "viewpoint {} is not looking at target",
2219 i
2220 );
2221 }
2222 }
2223
2224 #[test]
2225 fn test_generate_viewpoints_keeps_origin_targeting() {
2226 let config = ViewpointConfig {
2227 radius: 1.0,
2228 yaw_count: 1,
2229 pitch_angles_deg: vec![0.0],
2230 };
2231
2232 let origin_view = generate_viewpoints(&config)[0];
2233 let explicit_origin_view = generate_viewpoints_around_target(&config, Vec3::ZERO)[0];
2234
2235 assert_vec3_close(origin_view.translation, explicit_origin_view.translation);
2236 let forward = origin_view.forward();
2237 let to_origin = (Vec3::ZERO - origin_view.translation).normalize();
2238 assert!(forward.dot(to_origin) > 0.99);
2239 }
2240
2241 #[test]
2242 fn test_object_centered_viewpoints_apply_yaw_rotation_to_target() {
2243 let config = ViewpointConfig {
2244 radius: 1.0,
2245 yaw_count: 1,
2246 pitch_angles_deg: vec![0.0],
2247 };
2248 let mesh_center = Vec3::new(0.25, 0.0, 0.0);
2249 let rotation = ObjectRotation::new(0.0, 90.0, 0.0);
2250
2251 let target = rotated_mesh_center(mesh_center, &rotation);
2252 assert!(target.distance(mesh_center) > 0.1);
2253
2254 let origin_view = generate_viewpoints(&config)[0];
2255 let centered_view = generate_object_centered_viewpoints(&config, mesh_center, &rotation)[0];
2256
2257 assert_vec3_close(centered_view.translation, origin_view.translation + target);
2258 let forward = centered_view.forward();
2259 let to_target = (target - centered_view.translation).normalize();
2260 assert!(forward.dot(to_target) > 0.99);
2261 }
2262
2263 #[test]
2264 fn test_load_ycb_mesh_bounds_from_standard_obj_path() {
2265 let dir = tempfile::tempdir().unwrap();
2266 let mesh_dir = dir.path().join("google_16k");
2267 std::fs::create_dir_all(&mesh_dir).unwrap();
2268 std::fs::write(
2269 mesh_dir.join("textured.obj"),
2270 "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",
2271 )
2272 .unwrap();
2273
2274 let bounds = load_ycb_mesh_bounds(dir.path()).unwrap();
2275
2276 assert_eq!(bounds.vertex_count, 3);
2277 assert_vec3_close(bounds.min, Vec3::new(-1.0, -2.0, -3.0));
2278 assert_vec3_close(bounds.max, Vec3::new(3.0, 4.0, 5.0));
2279 assert_vec3_close(bounds.center, Vec3::new(1.0, 1.0, 1.0));
2280 assert_vec3_close(bounds.extents(), Vec3::new(4.0, 6.0, 8.0));
2281 }
2282
2283 #[test]
2284 fn test_targeting_policy_serializes_stable_label() {
2285 assert_eq!(TargetingPolicy::Origin.label(), "origin");
2286 assert_eq!(TargetingPolicy::MeshCenter.label(), "mesh-center");
2287
2288 let json = serde_json::to_string(&TargetingPolicy::MeshCenter).unwrap();
2289 assert!(json.contains("mesh_center"));
2290 let loaded: TargetingPolicy = serde_json::from_str(&json).unwrap();
2291 assert_eq!(loaded, TargetingPolicy::MeshCenter);
2292 }
2293
2294 #[test]
2295 fn test_render_output_with_targeting_overrides_origin_default() {
2296 let target_point = Vec3::new(0.1, 0.2, -0.3);
2297 let output = render_output_for_depth(
2298 1,
2299 1,
2300 vec![1.0],
2301 RenderConfig::tbp_default().intrinsics(),
2302 Transform::IDENTITY,
2303 )
2304 .with_targeting(target_point, TargetingPolicy::MeshCenter);
2305
2306 assert_eq!(output.target_point, target_point);
2307 assert_eq!(output.targeting_policy, TargetingPolicy::MeshCenter);
2308 }
2309
2310 #[test]
2311 fn test_center_hit_validation_report_detects_zero_hit_rotation() {
2312 let report = CenterHitValidationReport {
2313 object_id: "test_object".to_string(),
2314 object_dir: "/tmp/ycb/test_object".to_string(),
2315 target_policy: TargetingPolicy::MeshCenter,
2316 rotations: vec![
2317 CenterHitRotationReport {
2318 rotation_index: 0,
2319 rotation_euler: [0.0, 0.0, 0.0],
2320 target_point: [0.0, 0.0, 0.0],
2321 mesh_bounds: None,
2322 total_viewpoints: 24,
2323 center_hits: 1,
2324 center_misses: 23,
2325 misses: Vec::new(),
2326 },
2327 CenterHitRotationReport {
2328 rotation_index: 1,
2329 rotation_euler: [0.0, 90.0, 0.0],
2330 target_point: [0.1, 0.0, 0.0],
2331 mesh_bounds: None,
2332 total_viewpoints: 24,
2333 center_hits: 0,
2334 center_misses: 24,
2335 misses: Vec::new(),
2336 },
2337 ],
2338 };
2339
2340 assert!(!report.is_valid());
2341 assert_eq!(report.zero_hit_rotations(), vec![1]);
2342 }
2343
2344 #[test]
2345 fn test_sensor_config_default() {
2346 let config = SensorConfig::default();
2347 assert_eq!(config.object_rotations.len(), 1);
2348 assert_eq!(config.total_captures(), 24);
2349 }
2350
2351 #[test]
2352 fn test_sensor_config_tbp_benchmark() {
2353 let config = SensorConfig::tbp_benchmark();
2354 assert_eq!(config.object_rotations.len(), 3);
2355 assert_eq!(config.total_captures(), 72); }
2357
2358 #[test]
2359 fn test_sensor_config_tbp_full() {
2360 let config = SensorConfig::tbp_full_training();
2361 assert_eq!(config.object_rotations.len(), 14);
2362 assert_eq!(config.total_captures(), 336); }
2364
2365 #[test]
2366 fn test_ycb_representative_objects() {
2367 assert_eq!(crate::ycb::REPRESENTATIVE_OBJECTS.len(), 3);
2369 assert!(crate::ycb::REPRESENTATIVE_OBJECTS.contains(&"003_cracker_box"));
2370 }
2371
2372 #[test]
2373 fn test_ycb_tbp_standard_objects() {
2374 assert_eq!(crate::ycb::TBP_STANDARD_OBJECTS.len(), 10);
2375 assert!(crate::ycb::TBP_STANDARD_OBJECTS.contains(&"025_mug"));
2376 }
2377
2378 #[test]
2379 fn test_ycb_tbp_similar_objects() {
2380 assert_eq!(crate::ycb::TBP_SIMILAR_OBJECTS.len(), 10);
2381 assert!(crate::ycb::TBP_SIMILAR_OBJECTS.contains(&"003_cracker_box"));
2382 }
2383
2384 #[test]
2385 fn test_ycb_object_mesh_path() {
2386 let path = crate::ycb::object_mesh_path("/tmp/ycb", "003_cracker_box");
2387 assert_eq!(
2388 path,
2389 std::path::Path::new("/tmp/ycb")
2390 .join("003_cracker_box")
2391 .join("google_16k")
2392 .join("textured.obj")
2393 );
2394 }
2395
2396 #[test]
2397 fn test_ycb_object_texture_path() {
2398 let path = crate::ycb::object_texture_path("/tmp/ycb", "003_cracker_box");
2399 assert_eq!(
2400 path,
2401 std::path::Path::new("/tmp/ycb")
2402 .join("003_cracker_box")
2403 .join("google_16k")
2404 .join("texture_map.png")
2405 );
2406 }
2407
2408 #[test]
2413 fn test_render_config_tbp_default() {
2414 let config = RenderConfig::tbp_default();
2415 assert_eq!(config.width, 64);
2417 assert_eq!(config.height, 64);
2418 assert!(config.zoom > 0.0);
2420 assert!(config.near_plane > 0.0);
2422 assert!(config.far_plane > config.near_plane);
2423 }
2424
2425 #[test]
2426 fn test_render_config_preview() {
2427 let config = RenderConfig::preview();
2428 assert_eq!(config.width, 256);
2429 assert_eq!(config.height, 256);
2430 }
2431
2432 #[test]
2433 fn test_render_config_default_is_tbp() {
2434 let default = RenderConfig::default();
2435 let tbp = RenderConfig::tbp_default();
2436 assert_eq!(default.width, tbp.width);
2437 assert_eq!(default.height, tbp.height);
2438 }
2439
2440 #[test]
2441 fn test_render_config_fov() {
2442 let config = RenderConfig::tbp_default();
2443 let fov = config.fov_radians();
2444 assert!(fov > 0.0);
2447 assert!(fov < PI);
2448
2449 let zoomed = RenderConfig {
2451 zoom: config.zoom * 2.0,
2452 ..config
2453 };
2454 assert!(zoomed.fov_radians() < fov);
2455 }
2456
2457 #[test]
2458 fn test_render_config_intrinsics() {
2459 let config = RenderConfig::tbp_default();
2460 let intrinsics = config.intrinsics();
2461
2462 assert_eq!(intrinsics.image_size, [config.width, config.height]);
2464 assert_eq!(
2465 intrinsics.principal_point,
2466 [config.width as f64 / 2.0, config.height as f64 / 2.0]
2467 );
2468 assert_eq!(intrinsics.focal_length[0], intrinsics.focal_length[1]);
2470 assert!(intrinsics.focal_length[0] > 0.0);
2471 }
2472
2473 #[test]
2474 fn test_render_config_intrinsics_for_size_uses_tbp_zoom_formula() {
2475 let config = RenderConfig {
2476 width: 64,
2477 height: 64,
2478 zoom: 4.0,
2479 ..RenderConfig::tbp_default()
2480 };
2481
2482 let intrinsics = config.intrinsics_for_size(64, 64);
2483
2484 assert!((intrinsics.focal_length[0] - 128.0).abs() < 1e-9);
2487 assert!((intrinsics.focal_length[1] - 128.0).abs() < 1e-9);
2488 assert_ne!(intrinsics.focal_length[0], 64.0 * config.zoom as f64);
2489 assert_eq!(intrinsics.principal_point, [32.0, 32.0]);
2490 assert_eq!(intrinsics.image_size, [64, 64]);
2491 }
2492
2493 #[test]
2494 fn test_render_config_intrinsics_for_size_tracks_actual_readback_size() {
2495 let config = RenderConfig {
2496 width: 64,
2497 height: 64,
2498 zoom: 4.0,
2499 ..RenderConfig::tbp_default()
2500 };
2501
2502 let intrinsics = config.intrinsics_for_size(128, 96);
2503
2504 assert!((intrinsics.focal_length[0] - 256.0).abs() < 1e-9);
2505 assert!((intrinsics.focal_length[1] - 256.0).abs() < 1e-9);
2506 assert_eq!(intrinsics.principal_point, [64.0, 48.0]);
2507 assert_eq!(intrinsics.image_size, [128, 96]);
2508 }
2509
2510 #[test]
2511 fn test_camera_intrinsics_project() {
2512 let intrinsics = CameraIntrinsics {
2513 focal_length: [100.0, 100.0],
2514 principal_point: [32.0, 32.0],
2515 image_size: [64, 64],
2516 };
2517
2518 let center = intrinsics.project(Vec3::new(0.0, 0.0, 1.0));
2520 assert!(center.is_some());
2521 let [x, y] = center.unwrap();
2522 assert!((x - 32.0).abs() < 0.001);
2523 assert!((y - 32.0).abs() < 0.001);
2524
2525 let behind = intrinsics.project(Vec3::new(0.0, 0.0, -1.0));
2527 assert!(behind.is_none());
2528 }
2529
2530 #[test]
2531 fn test_camera_intrinsics_unproject() {
2532 let intrinsics = CameraIntrinsics {
2533 focal_length: [100.0, 100.0],
2534 principal_point: [32.0, 32.0],
2535 image_size: [64, 64],
2536 };
2537
2538 let point = intrinsics.unproject([32.0, 32.0], 1.0);
2540 assert!((point[0]).abs() < 0.001); assert!((point[1]).abs() < 0.001); assert!((point[2] - 1.0).abs() < 0.001); }
2544
2545 #[test]
2546 fn test_render_output_get_rgba() {
2547 let output = RenderOutput {
2548 rgba: vec![
2549 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255,
2550 ],
2551 depth: vec![1.0, 2.0, 3.0, 4.0],
2552 width: 2,
2553 height: 2,
2554 intrinsics: RenderConfig::tbp_default().intrinsics(),
2555 camera_transform: Transform::IDENTITY,
2556 object_rotation: ObjectRotation::identity(),
2557 object_translation: Vec3::ZERO,
2558 object_scale: Vec3::ONE,
2559 target_point: Vec3::ZERO,
2560 targeting_policy: TargetingPolicy::Origin,
2561 };
2562
2563 assert_eq!(output.get_rgba(0, 0), Some([255, 0, 0, 255]));
2565 assert_eq!(output.get_rgba(1, 0), Some([0, 255, 0, 255]));
2567 assert_eq!(output.get_rgba(0, 1), Some([0, 0, 255, 255]));
2569 assert_eq!(output.get_rgba(1, 1), Some([255, 255, 255, 255]));
2571 assert_eq!(output.get_rgba(2, 0), None);
2573 }
2574
2575 #[test]
2576 fn test_render_output_get_depth() {
2577 let output = RenderOutput {
2578 rgba: vec![0u8; 16],
2579 depth: vec![1.0, 2.0, 3.0, 4.0],
2580 width: 2,
2581 height: 2,
2582 intrinsics: RenderConfig::tbp_default().intrinsics(),
2583 camera_transform: Transform::IDENTITY,
2584 object_rotation: ObjectRotation::identity(),
2585 object_translation: Vec3::ZERO,
2586 object_scale: Vec3::ONE,
2587 target_point: Vec3::ZERO,
2588 targeting_policy: TargetingPolicy::Origin,
2589 };
2590
2591 assert_eq!(output.get_depth(0, 0), Some(1.0));
2592 assert_eq!(output.get_depth(1, 0), Some(2.0));
2593 assert_eq!(output.get_depth(0, 1), Some(3.0));
2594 assert_eq!(output.get_depth(1, 1), Some(4.0));
2595 assert_eq!(output.get_depth(2, 0), None);
2596 }
2597
2598 #[test]
2599 fn test_render_output_to_rgb_image() {
2600 let output = RenderOutput {
2601 rgba: vec![
2602 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255,
2603 ],
2604 depth: vec![1.0, 2.0, 3.0, 4.0],
2605 width: 2,
2606 height: 2,
2607 intrinsics: RenderConfig::tbp_default().intrinsics(),
2608 camera_transform: Transform::IDENTITY,
2609 object_rotation: ObjectRotation::identity(),
2610 object_translation: Vec3::ZERO,
2611 object_scale: Vec3::ONE,
2612 target_point: Vec3::ZERO,
2613 targeting_policy: TargetingPolicy::Origin,
2614 };
2615
2616 let image = output.to_rgb_image();
2617 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]); }
2624
2625 #[test]
2626 fn test_render_output_to_depth_image() {
2627 let output = RenderOutput {
2628 rgba: vec![0u8; 16],
2629 depth: vec![1.0, 2.0, 3.0, 4.0],
2630 width: 2,
2631 height: 2,
2632 intrinsics: RenderConfig::tbp_default().intrinsics(),
2633 camera_transform: Transform::IDENTITY,
2634 object_rotation: ObjectRotation::identity(),
2635 object_translation: Vec3::ZERO,
2636 object_scale: Vec3::ONE,
2637 target_point: Vec3::ZERO,
2638 targeting_policy: TargetingPolicy::Origin,
2639 };
2640
2641 let depth_image = output.to_depth_image();
2642 assert_eq!(depth_image.len(), 2);
2643 assert_eq!(depth_image[0], vec![1.0, 2.0]);
2644 assert_eq!(depth_image[1], vec![3.0, 4.0]);
2645 }
2646
2647 #[test]
2648 fn test_render_output_semantic_3d_marks_foreground_and_background() {
2649 let output = render_output_for_depth(
2650 2,
2651 2,
2652 vec![0.25, 10.0, 0.5, f64::INFINITY],
2653 CameraIntrinsics {
2654 focal_length: [1.0, 1.0],
2655 principal_point: [0.0, 0.0],
2656 image_size: [2, 2],
2657 },
2658 Transform::IDENTITY,
2659 );
2660
2661 let semantic = output.semantic_3d(42);
2662
2663 assert_eq!(semantic.len(), 4);
2664 assert_eq!(semantic[0][3], 42.0);
2665 assert_eq!(semantic[1], [0.0, 0.0, 0.0, 0.0]);
2666 assert_eq!(semantic[2][3], 42.0);
2667 assert_eq!(semantic[3], [0.0, 0.0, 0.0, 0.0]);
2668 assert_point_close(
2669 [semantic[0][0], semantic[0][1], semantic[0][2]],
2670 [0.0, 0.0, -0.25],
2671 );
2672 assert_point_close(
2673 [semantic[2][0], semantic[2][1], semantic[2][2]],
2674 [0.0, -0.5, -0.5],
2675 );
2676 }
2677
2678 #[test]
2679 fn test_render_output_semantic_3d_matches_pixel_surface_points() {
2680 let output = render_output_for_depth(
2681 3,
2682 3,
2683 vec![10.0, 10.0, 2.0, 10.0, 0.25, 10.0, 10.0, 10.0, 10.0],
2684 CameraIntrinsics {
2685 focal_length: [1.0, 1.0],
2686 principal_point: [1.0, 1.0],
2687 image_size: [3, 3],
2688 },
2689 Transform::IDENTITY,
2690 );
2691
2692 let semantic = output.semantic_3d(3);
2693 let top_right = output
2694 .pixel_surface_point_world([2, 0])
2695 .expect("foreground point");
2696 let center = output
2697 .pixel_surface_point_world([1, 1])
2698 .expect("foreground point");
2699
2700 assert_point_close([semantic[2][0], semantic[2][1], semantic[2][2]], top_right);
2701 assert_eq!(semantic[2][3], 3.0);
2702 assert_point_close([semantic[4][0], semantic[4][1], semantic[4][2]], center);
2703 assert_eq!(semantic[4][3], 3.0);
2704 }
2705
2706 #[test]
2707 fn test_render_health_center_hit() {
2708 let mut depth = vec![10.0; 7 * 7];
2709 depth[3 * 7 + 3] = 0.25;
2710 depth[6 * 7 + 6] = 0.5;
2711 let output = render_output_for_depth(
2712 7,
2713 7,
2714 depth,
2715 CameraIntrinsics {
2716 focal_length: [10.0, 10.0],
2717 principal_point: [3.0, 3.0],
2718 image_size: [7, 7],
2719 },
2720 Transform::IDENTITY,
2721 );
2722
2723 let health = output.health();
2724
2725 assert_eq!(health.center_pixel, Some([3, 3]));
2726 assert_eq!(health.center_depth, Some(0.25));
2727 assert!(health.center_foreground);
2728 assert_eq!(health.foreground_pixel_count, 2);
2729 assert!((health.foreground_coverage - 2.0 / 49.0).abs() < 1e-12);
2730 assert_eq!(health.center_5x5_foreground_count, 1);
2731 assert_eq!(health.nearest_foreground_pixel, Some([3, 3]));
2732 assert_eq!(health.nearest_foreground_depth, Some(0.25));
2733 assert_eq!(health.nearest_foreground_distance_px, Some(0.0));
2734 }
2735
2736 #[test]
2737 fn test_render_health_far_center_uses_nearest_foreground() {
2738 let mut depth = vec![10.0; 7 * 7];
2739 depth[3 * 7 + 1] = 0.5;
2740 let output = render_output_for_depth(
2741 7,
2742 7,
2743 depth,
2744 CameraIntrinsics {
2745 focal_length: [10.0, 10.0],
2746 principal_point: [3.0, 3.0],
2747 image_size: [7, 7],
2748 },
2749 Transform::IDENTITY,
2750 );
2751
2752 let health = output.health();
2753
2754 assert_eq!(health.center_pixel, Some([3, 3]));
2755 assert_eq!(health.center_depth, Some(10.0));
2756 assert!(!health.center_foreground);
2757 assert_eq!(health.foreground_pixel_count, 1);
2758 assert_eq!(health.center_5x5_foreground_count, 1);
2759 assert_eq!(health.nearest_foreground_pixel, Some([1, 3]));
2760 assert_eq!(health.nearest_foreground_depth, Some(0.5));
2761 assert_eq!(health.nearest_foreground_distance_px, Some(2.0));
2762 }
2763
2764 #[test]
2765 fn test_center_surface_point_world_uses_bevy_camera_forward() {
2766 let mut depth = vec![10.0; 3 * 3];
2767 depth[3 + 1] = 0.25;
2768 let output = render_output_for_depth(
2769 3,
2770 3,
2771 depth,
2772 CameraIntrinsics {
2773 focal_length: [1.0, 1.0],
2774 principal_point: [1.0, 1.0],
2775 image_size: [3, 3],
2776 },
2777 Transform::IDENTITY,
2778 );
2779
2780 assert_eq!(output.center_pixel_depth(), Some(0.25));
2781 assert_point_close(
2782 output.center_surface_point_world().expect("surface point"),
2783 [0.0, 0.0, -0.25],
2784 );
2785 }
2786
2787 #[test]
2788 fn test_pixel_surface_point_world_maps_image_y_down_to_camera_y_up() {
2789 let mut depth = vec![10.0; 3 * 3];
2790 depth[2] = 2.0;
2791 let output = render_output_for_depth(
2792 3,
2793 3,
2794 depth,
2795 CameraIntrinsics {
2796 focal_length: [1.0, 1.0],
2797 principal_point: [1.0, 1.0],
2798 image_size: [3, 3],
2799 },
2800 Transform::IDENTITY,
2801 );
2802
2803 assert_point_close(
2804 output
2805 .pixel_surface_point_world([2, 0])
2806 .expect("surface point"),
2807 [2.0, 2.0, -2.0],
2808 );
2809 }
2810
2811 #[test]
2812 fn test_camera_world_point_helpers_roundtrip() {
2813 let output = render_output_for_depth(
2814 1,
2815 1,
2816 vec![0.25],
2817 CameraIntrinsics {
2818 focal_length: [1.0, 1.0],
2819 principal_point: [0.0, 0.0],
2820 image_size: [1, 1],
2821 },
2822 Transform::from_xyz(0.0, 0.0, 1.0).looking_at(Vec3::ZERO, Vec3::Y),
2823 );
2824
2825 assert_point_close(
2826 output.center_surface_point_world().expect("surface point"),
2827 [0.0, 0.0, 0.75],
2828 );
2829
2830 let world_point = [0.1, -0.2, 0.7];
2831 let camera_point = output.world_to_camera_point(world_point);
2832 assert_point_close(output.camera_to_world_point(camera_point), world_point);
2833 }
2834
2835 #[test]
2836 fn test_render_error_display() {
2837 let err = RenderError::MeshNotFound("/path/to/mesh.obj".to_string());
2838 assert!(err.to_string().contains("Mesh not found"));
2839 assert!(err.to_string().contains("/path/to/mesh.obj"));
2840 }
2841
2842 #[test]
2847 fn test_object_rotation_extreme_angles() {
2848 let rot = ObjectRotation::new(450.0, -720.0, 1080.0);
2850 let quat = rot.to_quat();
2851 assert!((quat.length() - 1.0).abs() < 0.001);
2853 }
2854
2855 #[test]
2856 fn test_object_rotation_to_transform() {
2857 let rot = ObjectRotation::new(45.0, 90.0, 0.0);
2858 let transform = rot.to_transform();
2859 assert_eq!(transform.translation, Vec3::ZERO);
2861 assert!(transform.rotation != Quat::IDENTITY);
2863 }
2864
2865 #[test]
2866 fn test_object_rotation_to_transform_with_translation_scale() {
2867 let rot = ObjectRotation::new(0.0, 90.0, 0.0);
2868 let translation = Vec3::new(0.25, -0.5, 1.25);
2869 let scale = Vec3::new(1.0, 1.5, 0.75);
2870 let transform = rot.to_transform_with_translation_scale(translation, scale);
2871
2872 assert_eq!(transform.translation, translation);
2873 assert_eq!(transform.scale, scale);
2874 assert_eq!(transform.rotation, rot.to_quat());
2875 }
2876
2877 #[test]
2878 fn test_viewpoint_config_single_viewpoint() {
2879 let config = ViewpointConfig {
2880 radius: 1.0,
2881 yaw_count: 1,
2882 pitch_angles_deg: vec![0.0],
2883 };
2884 assert_eq!(config.viewpoint_count(), 1);
2885 let viewpoints = generate_viewpoints(&config);
2886 assert_eq!(viewpoints.len(), 1);
2887 let pos = viewpoints[0].translation;
2889 assert!((pos.x).abs() < 0.001);
2890 assert!((pos.y).abs() < 0.001);
2891 assert!((pos.z - 1.0).abs() < 0.001);
2892 }
2893
2894 #[test]
2895 fn test_viewpoint_radius_scaling() {
2896 let config1 = ViewpointConfig {
2897 radius: 0.5,
2898 yaw_count: 4,
2899 pitch_angles_deg: vec![0.0],
2900 };
2901 let config2 = ViewpointConfig {
2902 radius: 2.0,
2903 yaw_count: 4,
2904 pitch_angles_deg: vec![0.0],
2905 };
2906
2907 let v1 = generate_viewpoints(&config1);
2908 let v2 = generate_viewpoints(&config2);
2909
2910 for (vp1, vp2) in v1.iter().zip(v2.iter()) {
2912 let ratio = vp2.translation.length() / vp1.translation.length();
2913 assert!((ratio - 4.0).abs() < 0.01); }
2915 }
2916
2917 #[test]
2918 fn test_camera_intrinsics_project_at_z_zero() {
2919 let intrinsics = CameraIntrinsics {
2920 focal_length: [100.0, 100.0],
2921 principal_point: [32.0, 32.0],
2922 image_size: [64, 64],
2923 };
2924
2925 let result = intrinsics.project(Vec3::new(1.0, 1.0, 0.0));
2927 assert!(result.is_none());
2928 }
2929
2930 #[test]
2931 fn test_camera_intrinsics_roundtrip() {
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 original = Vec3::new(0.5, -0.3, 2.0);
2940 let projected = intrinsics.project(original).unwrap();
2941
2942 let unprojected = intrinsics.unproject(projected, original.z as f64);
2944
2945 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); }
2950
2951 #[test]
2952 fn test_render_output_empty() {
2953 let output = RenderOutput {
2954 rgba: vec![],
2955 depth: vec![],
2956 width: 0,
2957 height: 0,
2958 intrinsics: RenderConfig::tbp_default().intrinsics(),
2959 camera_transform: Transform::IDENTITY,
2960 object_rotation: ObjectRotation::identity(),
2961 object_translation: Vec3::ZERO,
2962 object_scale: Vec3::ONE,
2963 target_point: Vec3::ZERO,
2964 targeting_policy: TargetingPolicy::Origin,
2965 };
2966
2967 assert_eq!(output.get_rgba(0, 0), None);
2969 assert_eq!(output.get_depth(0, 0), None);
2970 assert!(output.to_rgb_image().is_empty());
2971 assert!(output.to_depth_image().is_empty());
2972 }
2973
2974 #[test]
2975 fn test_render_output_1x1() {
2976 let output = RenderOutput {
2977 rgba: vec![128, 64, 32, 255],
2978 depth: vec![0.5],
2979 width: 1,
2980 height: 1,
2981 intrinsics: RenderConfig::tbp_default().intrinsics(),
2982 camera_transform: Transform::IDENTITY,
2983 object_rotation: ObjectRotation::identity(),
2984 object_translation: Vec3::ZERO,
2985 object_scale: Vec3::ONE,
2986 target_point: Vec3::ZERO,
2987 targeting_policy: TargetingPolicy::Origin,
2988 };
2989
2990 assert_eq!(output.get_rgba(0, 0), Some([128, 64, 32, 255]));
2991 assert_eq!(output.get_depth(0, 0), Some(0.5));
2992 assert_eq!(output.get_rgb(0, 0), Some([128, 64, 32]));
2993
2994 let rgb_img = output.to_rgb_image();
2995 assert_eq!(rgb_img.len(), 1);
2996 assert_eq!(rgb_img[0].len(), 1);
2997 assert_eq!(rgb_img[0][0], [128, 64, 32]);
2998 }
2999
3000 #[test]
3001 fn test_render_config_high_res() {
3002 let config = RenderConfig::high_res();
3003 assert_eq!(config.width, 512);
3004 assert_eq!(config.height, 512);
3005
3006 let intrinsics = config.intrinsics();
3007 assert_eq!(intrinsics.image_size, [512, 512]);
3008 assert_eq!(intrinsics.principal_point, [256.0, 256.0]);
3009 }
3010
3011 #[test]
3012 fn test_render_config_zoom_affects_fov() {
3013 let base = RenderConfig {
3018 zoom: 2.0,
3019 ..RenderConfig::tbp_default()
3020 };
3021 let doubled = RenderConfig {
3022 zoom: 4.0,
3023 ..RenderConfig::tbp_default()
3024 };
3025
3026 assert!(doubled.fov_radians() < base.fov_radians());
3028
3029 let base_half_tan = (base.fov_radians() / 2.0).tan();
3031 let doubled_half_tan = (doubled.fov_radians() / 2.0).tan();
3032 assert!((base_half_tan / doubled_half_tan - 2.0).abs() < 1e-4);
3033 }
3034
3035 #[test]
3036 fn test_render_config_zoom_affects_intrinsics() {
3037 let a = RenderConfig {
3040 zoom: 2.0,
3041 ..RenderConfig::tbp_default()
3042 };
3043 let b = RenderConfig {
3044 zoom: 4.0,
3045 ..RenderConfig::tbp_default()
3046 };
3047
3048 let fx_a = a.intrinsics().focal_length[0];
3049 let fx_b = b.intrinsics().focal_length[0];
3050
3051 assert!(fx_b > fx_a);
3053
3054 assert!((fx_a / a.zoom as f64 - fx_b / b.zoom as f64).abs() < 1e-9);
3056 }
3057
3058 #[test]
3059 fn test_lighting_config_variants() {
3060 let default = LightingConfig::default();
3061 let bright = LightingConfig::bright();
3062 let soft = LightingConfig::soft();
3063 let unlit = LightingConfig::unlit();
3064
3065 assert!(bright.key_light_intensity > default.key_light_intensity);
3067
3068 assert_eq!(unlit.key_light_intensity, 0.0);
3070 assert_eq!(unlit.fill_light_intensity, 0.0);
3071 assert_eq!(unlit.ambient_brightness, 1.0);
3072
3073 assert!(soft.key_light_intensity < default.key_light_intensity);
3075 }
3076
3077 #[test]
3078 fn test_all_render_error_variants() {
3079 let errors = vec![
3080 RenderError::MeshNotFound("mesh.obj".to_string()),
3081 RenderError::TextureNotFound("texture.png".to_string()),
3082 RenderError::RenderFailed("GPU error".to_string()),
3083 RenderError::InvalidConfig("bad config".to_string()),
3084 ];
3085
3086 for err in errors {
3087 let msg = err.to_string();
3089 assert!(!msg.is_empty());
3090 }
3091 }
3092
3093 #[test]
3094 fn test_tbp_known_orientations_unique() {
3095 let orientations = ObjectRotation::tbp_known_orientations();
3096
3097 let quats: Vec<Quat> = orientations.iter().map(|r| r.to_quat()).collect();
3099
3100 for (i, q1) in quats.iter().enumerate() {
3101 for (j, q2) in quats.iter().enumerate() {
3102 if i != j {
3103 let dot = q1.dot(*q2).abs();
3105 assert!(
3106 dot < 0.999,
3107 "Orientations {} and {} produce same quaternion",
3108 i,
3109 j
3110 );
3111 }
3112 }
3113 }
3114 }
3115}