1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
//! Camera frustum geometry for virtual set rendering.
//!
//! Provides a pure-Rust frustum implementation (independent of nalgebra)
//! for use in virtual production pipelines. Uses the `TrackedPose` from the
//! local `camera_tracking` module.
#![allow(dead_code)]
use crate::camera_tracking::TrackedPose;
// ---------------------------------------------------------------------------
// Frustum
// ---------------------------------------------------------------------------
/// A symmetric perspective frustum defined by horizontal FOV and aspect ratio.
#[derive(Debug, Clone, PartialEq)]
pub struct Frustum {
/// Horizontal field-of-view in degrees.
pub fov_h_deg: f64,
/// Vertical field-of-view in degrees (derived from horizontal FOV and aspect ratio).
pub fov_v_deg: f64,
/// Near clip plane distance in metres.
pub near: f64,
/// Far clip plane distance in metres.
pub far: f64,
}
impl Frustum {
/// Create a frustum from horizontal FOV and aspect ratio (width / height).
///
/// Vertical FOV is derived automatically.
#[must_use]
pub fn new(fov_h_deg: f64, aspect_ratio: f64, near: f64, far: f64) -> Self {
let fov_v = Self::fov_v_from_fov_h(fov_h_deg, aspect_ratio);
Self {
fov_h_deg,
fov_v_deg: fov_v,
near,
far,
}
}
/// Compute vertical FOV from horizontal FOV and aspect ratio (width / height).
///
/// Uses `fov_v = 2 * atan(tan(fov_h / 2) / aspect)`.
#[must_use]
pub fn fov_v_from_fov_h(fov_h: f64, aspect: f64) -> f64 {
if aspect <= 0.0 {
return 0.0;
}
let half_h = (fov_h.to_radians() / 2.0).tan();
let half_v = half_h / aspect;
half_v.atan().to_degrees() * 2.0
}
/// Returns `true` if the point `(x, y, z)` in camera space lies inside the frustum.
///
/// Assumes the camera looks along the positive Z axis.
#[must_use]
pub fn contains_point(&self, x: f64, y: f64, z: f64) -> bool {
if z < self.near || z > self.far {
return false;
}
let half_h = (self.fov_h_deg.to_radians() / 2.0).tan() * z;
let half_v = (self.fov_v_deg.to_radians() / 2.0).tan() * z;
x.abs() <= half_h && y.abs() <= half_v
}
/// Approximate solid angle of the frustum in steradians.
///
/// Formula: `4 * atan(sin(fov_h/2) * sin(fov_v/2) / cos(fov_h/2 + fov_v/2 - π/2 + π/2))`.
/// For small angles, this simplifies to `fov_h_rad * fov_v_rad`.
#[must_use]
pub fn solid_angle(&self) -> f64 {
let h_rad = self.fov_h_deg.to_radians();
let v_rad = self.fov_v_deg.to_radians();
// Spherical rectangle solid angle approximation.
4.0 * ((h_rad / 2.0).sin() * (v_rad / 2.0).sin()).asin()
}
}
// ---------------------------------------------------------------------------
// View frustum (frustum + pose)
// ---------------------------------------------------------------------------
/// A frustum anchored at a tracked camera pose in world space.
#[derive(Debug, Clone)]
pub struct ViewFrustum {
/// The frustum geometry.
pub frustum: Frustum,
/// The current camera pose.
pub pose: TrackedPose,
}
impl ViewFrustum {
/// Create a new view frustum with identity pose.
#[must_use]
pub fn new(frustum: Frustum) -> Self {
Self {
frustum,
pose: TrackedPose::new(0.0, 0.0, 0.0),
}
}
/// Update the camera pose.
pub fn update_pose(&mut self, pose: TrackedPose) {
self.pose = pose;
}
/// Transform a world-space point into camera space.
///
/// This is a simplified implementation that only applies translation
/// (no rotation matrix). Rotation support can be layered on top when
/// a full matrix library is available.
#[must_use]
pub fn world_to_camera(&self, wx: f64, wy: f64, wz: f64) -> (f64, f64, f64) {
(wx - self.pose.x, wy - self.pose.y, wz - self.pose.z)
}
}
// ---------------------------------------------------------------------------
// Screen projection
// ---------------------------------------------------------------------------
/// Project a camera-space point onto a screen of given dimensions.
///
/// Returns `None` if the point is behind the camera (z ≤ 0).
///
/// # Arguments
/// * `x`, `y`, `z` – point in camera space (z is depth; must be > 0)
/// * `width`, `height` – screen dimensions in pixels
/// * `fov_h` – horizontal field-of-view in degrees
#[must_use]
pub fn project_to_screen(
x: f64,
y: f64,
z: f64,
width: u32,
height: u32,
fov_h: f64,
) -> Option<(f32, f32)> {
if z <= 0.0 {
return None;
}
let aspect = f64::from(width) / f64::from(height.max(1));
let tan_half_h = (fov_h.to_radians() / 2.0).tan();
let ndc_x = x / (z * tan_half_h);
let ndc_y = -y / (z * tan_half_h / aspect); // flip Y for screen space
// Map NDC [-1,1] to pixel coordinates
let px = ((ndc_x + 1.0) / 2.0 * f64::from(width)) as f32;
let py = ((ndc_y + 1.0) / 2.0 * f64::from(height)) as f32;
Some((px, py))
}
// ---------------------------------------------------------------------------
// Unit tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_frustum_new_fov_v_derived() {
let f = Frustum::new(90.0, 1.0, 0.1, 1000.0);
// For aspect=1, fov_v should equal fov_h
assert!((f.fov_v_deg - 90.0).abs() < 1e-6);
}
#[test]
fn test_frustum_fov_v_from_fov_h_16x9() {
// 16:9 aspect → fov_v < fov_h
let fov_v = Frustum::fov_v_from_fov_h(90.0, 16.0 / 9.0);
assert!(fov_v < 90.0);
assert!(fov_v > 0.0);
}
#[test]
fn test_frustum_fov_v_zero_aspect() {
let fov_v = Frustum::fov_v_from_fov_h(90.0, 0.0);
assert_eq!(fov_v, 0.0);
}
#[test]
fn test_frustum_contains_point_center() {
let f = Frustum::new(90.0, 1.0, 0.1, 1000.0);
// A point directly ahead at z=10 should be inside.
assert!(f.contains_point(0.0, 0.0, 10.0));
}
#[test]
fn test_frustum_contains_point_behind() {
let f = Frustum::new(90.0, 1.0, 0.1, 1000.0);
assert!(!f.contains_point(0.0, 0.0, -1.0));
}
#[test]
fn test_frustum_contains_point_beyond_far() {
let f = Frustum::new(90.0, 1.0, 0.1, 100.0);
assert!(!f.contains_point(0.0, 0.0, 200.0));
}
#[test]
fn test_frustum_contains_point_outside_edge() {
let f = Frustum::new(90.0, 1.0, 0.1, 1000.0);
// At z=10 with fov_h=90°, half-width = 10; point at x=15 is outside.
assert!(!f.contains_point(15.0, 0.0, 10.0));
}
#[test]
fn test_frustum_solid_angle_positive() {
let f = Frustum::new(60.0, 16.0 / 9.0, 0.1, 1000.0);
assert!(f.solid_angle() > 0.0);
}
#[test]
fn test_view_frustum_new_identity_pose() {
let f = Frustum::new(90.0, 1.0, 0.1, 1000.0);
let vf = ViewFrustum::new(f);
assert_eq!(vf.pose.x, 0.0);
}
#[test]
fn test_view_frustum_update_pose() {
let f = Frustum::new(90.0, 1.0, 0.1, 1000.0);
let mut vf = ViewFrustum::new(f);
vf.update_pose(TrackedPose::new(5.0, 2.0, 0.0));
assert_eq!(vf.pose.x, 5.0);
}
#[test]
fn test_view_frustum_world_to_camera() {
let f = Frustum::new(90.0, 1.0, 0.1, 1000.0);
let mut vf = ViewFrustum::new(f);
vf.update_pose(TrackedPose::new(1.0, 2.0, 3.0));
let (cx, cy, cz) = vf.world_to_camera(4.0, 6.0, 8.0);
assert!((cx - 3.0).abs() < 1e-10);
assert!((cy - 4.0).abs() < 1e-10);
assert!((cz - 5.0).abs() < 1e-10);
}
#[test]
fn test_project_to_screen_center() {
// A point at camera origin projected straight ahead
let result = project_to_screen(0.0, 0.0, 10.0, 1920, 1080, 90.0);
assert!(result.is_some());
let (px, py) = result.expect("should succeed in test");
// Should land roughly at the screen center (960, 540)
assert!((px - 960.0).abs() < 1.0);
assert!((py - 540.0).abs() < 1.0);
}
#[test]
fn test_project_to_screen_behind_camera() {
let result = project_to_screen(0.0, 0.0, -5.0, 1920, 1080, 90.0);
assert!(result.is_none());
}
}