Skip to main content

brep_render/
camera.rs

1//! Orthographic artifact framing and orbit-camera math.
2//! Calculations use f64, converting to f32 at the GPU boundary.
3
4use crate::geometry3d::{cross3 as cross, dot3 as dot, norm3 as norm};
5
6/// Axis-aligned bounding box (world space, f64).
7#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
8pub struct Aabb {
9    pub min: [f64; 3],
10    pub max: [f64; 3],
11}
12
13impl Aabb {
14    pub fn empty() -> Self {
15        Self {
16            min: [f64::INFINITY; 3],
17            max: [f64::NEG_INFINITY; 3],
18        }
19    }
20
21    pub fn is_empty(&self) -> bool {
22        (0..3).any(|i| self.min[i] > self.max[i])
23    }
24
25    pub fn expand(&mut self, p: [f64; 3]) {
26        for i in 0..3 {
27            self.min[i] = self.min[i].min(p[i]);
28            self.max[i] = self.max[i].max(p[i]);
29        }
30    }
31
32    pub fn union(&mut self, other: &Aabb) {
33        if other.is_empty() {
34            return;
35        }
36        self.expand(other.min);
37        self.expand(other.max);
38    }
39
40    pub fn center(&self) -> [f64; 3] {
41        [
42            (self.min[0] + self.max[0]) * 0.5,
43            (self.min[1] + self.max[1]) * 0.5,
44            (self.min[2] + self.max[2]) * 0.5,
45        ]
46    }
47
48    pub fn size(&self) -> [f64; 3] {
49        [
50            self.max[0] - self.min[0],
51            self.max[1] - self.min[1],
52            self.max[2] - self.min[2],
53        ]
54    }
55}
56
57/// A camera resolved to GPU form: column-major view-projection matrix plus the
58/// world-space view direction (camera → scene) the shader needs for
59/// double-sided normal flipping and specular.
60#[derive(Debug, Clone, Copy)]
61pub struct Camera {
62    /// Column-major 4x4, world → wgpu clip space (z in 0..1).
63    pub view_proj: [[f32; 4]; 4],
64    /// Normalized world-space view direction, from the camera toward the scene.
65    pub forward: [f32; 3],
66}
67
68/// Right-handed look-at view matrix (camera looks down its -Z), column-major.
69fn look_at(eye: [f64; 3], target: [f64; 3], up: [f64; 3]) -> [[f64; 4]; 4] {
70    let f = norm([target[0] - eye[0], target[1] - eye[1], target[2] - eye[2]]);
71    let r = norm(cross(f, up));
72    let u = cross(r, f);
73    // Columns of the view matrix (world → view).
74    [
75        [r[0], u[0], -f[0], 0.0],
76        [r[1], u[1], -f[1], 0.0],
77        [r[2], u[2], -f[2], 0.0],
78        [-dot(r, eye), -dot(u, eye), dot(f, eye), 1.0],
79    ]
80}
81
82/// Orthographic projection to wgpu clip space (x,y in -1..1, z in 0..1),
83/// column-major. View-space z of visible points is in [-far, -near].
84fn ortho(l: f64, r: f64, b: f64, t: f64, near: f64, far: f64) -> [[f64; 4]; 4] {
85    let sx = 2.0 / (r - l);
86    let sy = 2.0 / (t - b);
87    let sz = -1.0 / (far - near);
88    [
89        [sx, 0.0, 0.0, 0.0],
90        [0.0, sy, 0.0, 0.0],
91        [0.0, 0.0, sz, 0.0],
92        [
93            -(r + l) / (r - l),
94            -(t + b) / (t - b),
95            -near / (far - near),
96            1.0,
97        ],
98    ]
99}
100
101fn mul(a: [[f64; 4]; 4], b: [[f64; 4]; 4]) -> [[f64; 4]; 4] {
102    let mut out = [[0.0; 4]; 4];
103    for col in 0..4 {
104        for row in 0..4 {
105            let mut sum = 0.0;
106            for k in 0..4 {
107                sum += a[k][row] * b[col][k];
108            }
109            out[col][row] = sum;
110        }
111    }
112    out
113}
114
115fn to_f32(m: [[f64; 4]; 4]) -> [[f32; 4]; 4] {
116    let mut out = [[0.0f32; 4]; 4];
117    for c in 0..4 {
118        for r in 0..4 {
119            out[c][r] = m[c][r] as f32;
120        }
121    }
122    out
123}
124
125/// Build a camera from an explicit eye/target/up + ortho frustum (the shared
126/// path for the artifact framing and the desktop orbit).
127pub fn ortho_camera(
128    eye: [f64; 3],
129    target: [f64; 3],
130    up: [f64; 3],
131    half_width: f64,
132    half_height: f64,
133    near: f64,
134    far: f64,
135) -> Camera {
136    let view = look_at(eye, target, up);
137    let proj = ortho(-half_width, half_width, -half_height, half_height, near, far);
138    let forward = norm([
139        target[0] - eye[0],
140        target[1] - eye[1],
141        target[2] - eye[2],
142    ]);
143    Camera {
144        view_proj: to_f32(mul(proj, view)),
145        forward: [forward[0] as f32, forward[1] as f32, forward[2] as f32],
146    }
147}
148
149/// The artifact framing — a faithful port of the retired artifact renderer:
150/// orthographic, iso view direction (1, -1.2, 0.9), Z up, bbox-fit with a 1.15
151/// margin, camera at center + dir·radius·6, near 0.01 / far radius·20.
152///
153/// The Z up is that retired renderer's own convention, kept because this is a
154/// port of it — NOT the application's world, which is +Y up (`ViewCamera`, the
155/// ViewCube, the kernel feature pipeline's `world_up`). A model authored in the
156/// app is therefore framed rolled here, not standing the way the viewport draws
157/// it. Nothing in the app's viewport path calls this: it is the offline
158/// `bin/artifact` renderer's framing, and the render tests'.
159pub fn artifact_camera(bbox: &Aabb, width: u32, height: u32) -> Camera {
160    let bbox = if bbox.is_empty() {
161        Aabb {
162            min: [-1.0, -1.0, -1.0],
163            max: [1.0, 1.0, 1.0],
164        }
165    } else {
166        *bbox
167    };
168    let center = bbox.center();
169    let size = bbox.size();
170    let radius = size[0].max(size[1]).max(size[2]).max(1e-9) * 0.75;
171    let dir = norm([1.0, -1.2, 0.9]);
172    let eye = [
173        center[0] + dir[0] * radius * 6.0,
174        center[1] + dir[1] * radius * 6.0,
175        center[2] + dir[2] * radius * 6.0,
176    ];
177    let aspect = width.max(1) as f64 / height.max(1) as f64;
178    ortho_camera(
179        eye,
180        center,
181        [0.0, 0.0, 1.0],
182        radius * aspect * 1.15,
183        radius * 1.15,
184        0.01,
185        radius * 20.0,
186    )
187}
188
189/// Simple Z-up orbit state matching [`artifact_camera`]'s framing: azimuth /
190/// elevation around the scene bbox, distance-scaled ortho frustum (zoom =
191/// frustum scale).
192///
193/// Z up for the same reason [`artifact_camera`] is, and unused: the shell's
194/// viewport camera is `crate::view::ViewCamera` — a free arcball, +Y up — and
195/// nothing outside this module constructs an `Orbit`.
196#[derive(Debug, Clone, Copy)]
197pub struct Orbit {
198    pub azimuth: f64,
199    pub elevation: f64,
200    pub zoom: f64,
201}
202
203impl Default for Orbit {
204    fn default() -> Self {
205        // Match the artifact iso direction: dir (1, -1.2, 0.9).
206        let dir = norm([1.0, -1.2, 0.9]);
207        Self {
208            azimuth: dir[1].atan2(dir[0]),
209            elevation: dir[2].asin(),
210            zoom: 1.0,
211        }
212    }
213}
214
215impl Orbit {
216    pub fn camera(&self, bbox: &Aabb, width: u32, height: u32) -> Camera {
217        let bbox = if bbox.is_empty() {
218            Aabb {
219                min: [-1.0, -1.0, -1.0],
220                max: [1.0, 1.0, 1.0],
221            }
222        } else {
223            *bbox
224        };
225        let center = bbox.center();
226        let size = bbox.size();
227        let radius = size[0].max(size[1]).max(size[2]).max(1e-9) * 0.75;
228        let el = self.elevation.clamp(-1.55, 1.55);
229        let dir = [
230            el.cos() * self.azimuth.cos(),
231            el.cos() * self.azimuth.sin(),
232            el.sin(),
233        ];
234        let eye = [
235            center[0] + dir[0] * radius * 6.0,
236            center[1] + dir[1] * radius * 6.0,
237            center[2] + dir[2] * radius * 6.0,
238        ];
239        let aspect = width.max(1) as f64 / height.max(1) as f64;
240        let half_h = radius * 1.15 * self.zoom;
241        ortho_camera(
242            eye,
243            center,
244            [0.0, 0.0, 1.0],
245            half_h * aspect,
246            half_h,
247            0.01,
248            radius * 20.0,
249        )
250    }
251}
252