Skip to main content

brep_render/
camera.rs

1//! Pure-math cameras: the artifact framing (a faithful port of the retired
2//! artifact page's orthographic isometric camera) and a small orbit helper for
3//! the desktop shell. f64 math throughout; f32 only at the GPU boundary.
4
5/// Axis-aligned bounding box (world space, f64).
6#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
7pub struct Aabb {
8    pub min: [f64; 3],
9    pub max: [f64; 3],
10}
11
12impl Aabb {
13    pub fn empty() -> Self {
14        Self {
15            min: [f64::INFINITY; 3],
16            max: [f64::NEG_INFINITY; 3],
17        }
18    }
19
20    pub fn is_empty(&self) -> bool {
21        (0..3).any(|i| self.min[i] > self.max[i])
22    }
23
24    pub fn expand(&mut self, p: [f64; 3]) {
25        for i in 0..3 {
26            self.min[i] = self.min[i].min(p[i]);
27            self.max[i] = self.max[i].max(p[i]);
28        }
29    }
30
31    pub fn union(&mut self, other: &Aabb) {
32        if other.is_empty() {
33            return;
34        }
35        self.expand(other.min);
36        self.expand(other.max);
37    }
38
39    pub fn center(&self) -> [f64; 3] {
40        [
41            (self.min[0] + self.max[0]) * 0.5,
42            (self.min[1] + self.max[1]) * 0.5,
43            (self.min[2] + self.max[2]) * 0.5,
44        ]
45    }
46
47    pub fn size(&self) -> [f64; 3] {
48        [
49            self.max[0] - self.min[0],
50            self.max[1] - self.min[1],
51            self.max[2] - self.min[2],
52        ]
53    }
54}
55
56fn norm(v: [f64; 3]) -> [f64; 3] {
57    let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
58    if len <= 0.0 {
59        return [0.0, 0.0, 1.0];
60    }
61    [v[0] / len, v[1] / len, v[2] / len]
62}
63
64fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
65    [
66        a[1] * b[2] - a[2] * b[1],
67        a[2] * b[0] - a[0] * b[2],
68        a[0] * b[1] - a[1] * b[0],
69    ]
70}
71
72fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
73    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
74}
75
76/// A camera resolved to GPU form: column-major view-projection matrix plus the
77/// world-space view direction (camera → scene) the shader needs for
78/// double-sided normal flipping and specular.
79#[derive(Debug, Clone, Copy)]
80pub struct Camera {
81    /// Column-major 4x4, world → wgpu clip space (z in 0..1).
82    pub view_proj: [[f32; 4]; 4],
83    /// Normalized world-space view direction, from the camera toward the scene.
84    pub forward: [f32; 3],
85}
86
87/// Right-handed look-at view matrix (camera looks down its -Z), column-major.
88fn look_at(eye: [f64; 3], target: [f64; 3], up: [f64; 3]) -> [[f64; 4]; 4] {
89    let f = norm([target[0] - eye[0], target[1] - eye[1], target[2] - eye[2]]);
90    let r = norm(cross(f, up));
91    let u = cross(r, f);
92    // Columns of the view matrix (world → view).
93    [
94        [r[0], u[0], -f[0], 0.0],
95        [r[1], u[1], -f[1], 0.0],
96        [r[2], u[2], -f[2], 0.0],
97        [-dot(r, eye), -dot(u, eye), dot(f, eye), 1.0],
98    ]
99}
100
101/// Orthographic projection to wgpu clip space (x,y in -1..1, z in 0..1),
102/// column-major. View-space z of visible points is in [-far, -near].
103fn ortho(l: f64, r: f64, b: f64, t: f64, near: f64, far: f64) -> [[f64; 4]; 4] {
104    let sx = 2.0 / (r - l);
105    let sy = 2.0 / (t - b);
106    let sz = -1.0 / (far - near);
107    [
108        [sx, 0.0, 0.0, 0.0],
109        [0.0, sy, 0.0, 0.0],
110        [0.0, 0.0, sz, 0.0],
111        [
112            -(r + l) / (r - l),
113            -(t + b) / (t - b),
114            -near / (far - near),
115            1.0,
116        ],
117    ]
118}
119
120fn mul(a: [[f64; 4]; 4], b: [[f64; 4]; 4]) -> [[f64; 4]; 4] {
121    let mut out = [[0.0; 4]; 4];
122    for col in 0..4 {
123        for row in 0..4 {
124            let mut sum = 0.0;
125            for k in 0..4 {
126                sum += a[k][row] * b[col][k];
127            }
128            out[col][row] = sum;
129        }
130    }
131    out
132}
133
134fn to_f32(m: [[f64; 4]; 4]) -> [[f32; 4]; 4] {
135    let mut out = [[0.0f32; 4]; 4];
136    for c in 0..4 {
137        for r in 0..4 {
138            out[c][r] = m[c][r] as f32;
139        }
140    }
141    out
142}
143
144/// Build a camera from an explicit eye/target/up + ortho frustum (the shared
145/// path for the artifact framing and the desktop orbit).
146pub fn ortho_camera(
147    eye: [f64; 3],
148    target: [f64; 3],
149    up: [f64; 3],
150    half_width: f64,
151    half_height: f64,
152    near: f64,
153    far: f64,
154) -> Camera {
155    let view = look_at(eye, target, up);
156    let proj = ortho(-half_width, half_width, -half_height, half_height, near, far);
157    let forward = norm([
158        target[0] - eye[0],
159        target[1] - eye[1],
160        target[2] - eye[2],
161    ]);
162    Camera {
163        view_proj: to_f32(mul(proj, view)),
164        forward: [forward[0] as f32, forward[1] as f32, forward[2] as f32],
165    }
166}
167
168/// The artifact framing — a faithful port of the retired artifact renderer:
169/// orthographic, iso view direction (1, -1.2, 0.9), Z up, bbox-fit with a 1.15
170/// margin, camera at center + dir·radius·6, near 0.01 / far radius·20.
171pub fn artifact_camera(bbox: &Aabb, width: u32, height: u32) -> Camera {
172    let bbox = if bbox.is_empty() {
173        Aabb {
174            min: [-1.0, -1.0, -1.0],
175            max: [1.0, 1.0, 1.0],
176        }
177    } else {
178        *bbox
179    };
180    let center = bbox.center();
181    let size = bbox.size();
182    let radius = size[0].max(size[1]).max(size[2]).max(1e-9) * 0.75;
183    let dir = norm([1.0, -1.2, 0.9]);
184    let eye = [
185        center[0] + dir[0] * radius * 6.0,
186        center[1] + dir[1] * radius * 6.0,
187        center[2] + dir[2] * radius * 6.0,
188    ];
189    let aspect = width.max(1) as f64 / height.max(1) as f64;
190    ortho_camera(
191        eye,
192        center,
193        [0.0, 0.0, 1.0],
194        radius * aspect * 1.15,
195        radius * 1.15,
196        0.01,
197        radius * 20.0,
198    )
199}
200
201/// Simple Z-up orbit state for the desktop shell: azimuth/elevation around the
202/// scene bbox, distance-scaled ortho frustum (zoom = frustum scale).
203#[derive(Debug, Clone, Copy)]
204pub struct Orbit {
205    pub azimuth: f64,
206    pub elevation: f64,
207    pub zoom: f64,
208}
209
210impl Default for Orbit {
211    fn default() -> Self {
212        // Match the artifact iso direction: dir (1, -1.2, 0.9).
213        let dir = norm([1.0, -1.2, 0.9]);
214        Self {
215            azimuth: dir[1].atan2(dir[0]),
216            elevation: dir[2].asin(),
217            zoom: 1.0,
218        }
219    }
220}
221
222impl Orbit {
223    pub fn camera(&self, bbox: &Aabb, width: u32, height: u32) -> Camera {
224        let bbox = if bbox.is_empty() {
225            Aabb {
226                min: [-1.0, -1.0, -1.0],
227                max: [1.0, 1.0, 1.0],
228            }
229        } else {
230            *bbox
231        };
232        let center = bbox.center();
233        let size = bbox.size();
234        let radius = size[0].max(size[1]).max(size[2]).max(1e-9) * 0.75;
235        let el = self.elevation.clamp(-1.55, 1.55);
236        let dir = [
237            el.cos() * self.azimuth.cos(),
238            el.cos() * self.azimuth.sin(),
239            el.sin(),
240        ];
241        let eye = [
242            center[0] + dir[0] * radius * 6.0,
243            center[1] + dir[1] * radius * 6.0,
244            center[2] + dir[2] * radius * 6.0,
245        ];
246        let aspect = width.max(1) as f64 / height.max(1) as f64;
247        let half_h = radius * 1.15 * self.zoom;
248        ortho_camera(
249            eye,
250            center,
251            [0.0, 0.0, 1.0],
252            half_h * aspect,
253            half_h,
254            0.01,
255            radius * 20.0,
256        )
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    #[test]
265    fn artifact_camera_centers_bbox() {
266        let bbox = Aabb {
267            min: [0.0, 0.0, 0.0],
268            max: [10.0, 10.0, 10.0],
269        };
270        let cam = artifact_camera(&bbox, 640, 480);
271        // The bbox center must project to NDC (0, 0, z in 0..1).
272        let c = [5.0f32, 5.0, 5.0, 1.0];
273        let m = cam.view_proj;
274        let mut clip = [0.0f32; 4];
275        for row in 0..4 {
276            clip[row] = m[0][row] * c[0] + m[1][row] * c[1] + m[2][row] * c[2] + m[3][row] * c[3];
277        }
278        assert!((clip[0] / clip[3]).abs() < 1e-5);
279        assert!((clip[1] / clip[3]).abs() < 1e-5);
280        let z = clip[2] / clip[3];
281        assert!((0.0..=1.0).contains(&z), "z {z}");
282        // Corners stay inside the frustum (margin 1.15).
283        for corner in [[0.0f32, 0.0, 0.0, 1.0], [10.0, 10.0, 10.0, 1.0]] {
284            let mut clip = [0.0f32; 4];
285            for row in 0..4 {
286                clip[row] = m[0][row] * corner[0]
287                    + m[1][row] * corner[1]
288                    + m[2][row] * corner[2]
289                    + m[3][row] * corner[3];
290            }
291            assert!((clip[0] / clip[3]).abs() <= 1.0);
292            assert!((clip[1] / clip[3]).abs() <= 1.0);
293        }
294    }
295}