uzor-graph 1.5.1

Reusable force-directed graph visualization engine for uzor — generic node/edge model, Barnes-Hut force simulation, camera, native drag/pick interaction, and an agent-api blackbox surface.
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
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
//! `Camera3D` — shared orbit and free-look state for 3D graph mode,
//! producing a fresh `uzor_urx_3d::PerspectiveCamera` each frame.
//!
//! `Camera2D` is the house precedent for this split (plan §1.4's own
//! reasoning): interaction state lives in `uzor-graph`, the render
//! primitive (`PerspectiveCamera`) stays a generic-renderer type with no
//! graph-specific interaction semantics baked in — this is deliberately
//! NOT a method added onto `PerspectiveCamera` itself.

use glam::Vec3;
use uzor_urx_3d::PerspectiveCamera;

/// Screen-px -> radians conversion for [`Camera3D::orbit`] — mirrors the
/// 2D engine's own "delta ÷ zoom" drag-tick convention (W2.1), just with
/// a fixed divisor instead of a variable zoom (orbit has no zoom-
/// equivalent axis of its own).
const ORBIT_RADIANS_PER_PX: f32 = 0.008;

/// Pitch clamp — stays strictly inside ±90° so [`Camera3D::orbit_offset`]'s
/// `right = forward.cross(Vec3::Y)` never degenerates (a forward vector
/// exactly parallel to the world-up axis has no well-defined right).
const PITCH_LIMIT: f32 = 1.483_53; // ~85 degrees, radians

const MIN_DISTANCE: f32 = 1.0;
const MAX_DISTANCE: f32 = 100_000.0;

/// Default vertical field of view — graph-strengthening arc item 5: a
/// literal copy of `uzor_urx_3d::PerspectiveCamera::new`'s own hardcoded
/// `60_f32.to_radians()` (that field is `pub` there already, so it was
/// technically reachable by mutating a constructed `PerspectiveCamera`
/// directly — but [`Camera3D::to_perspective`] never exposed a way to
/// CHANGE it, always producing that one fixed value every frame). Same
/// "redeclare a small cross-crate constant" convention this crate already
/// uses for [`super::render3d::EDGE_WIDTH_SCALE_MAX`]'s own mirror of
/// `uzor_urx_3d::pipeline`'s private `DEFAULT_EDGE_WIDTH_PX` — kept in
/// sync by direct value copy, not a re-export (`fov_y` isn't a `const` on
/// the other side to `pub use`).
const DEFAULT_FOV_Y: f32 = 1.047_197_6; // 60 degrees, radians (60_f32.to_radians())

/// Pan speed scales with `distance` (further from `target` => a screen
/// pixel spans more world space) — the standard orbit-camera convention
/// (three.js `OrbitControls`, Blender's own viewport nav) so a shift-drag
/// pan feels the same speed regardless of current zoom/distance.
const PAN_UNITS_PER_PX_PER_DISTANCE: f32 = 0.002;

/// Camera state shared by orbit and fly-style controls. In orbit use,
/// `target` is the fixed center and `distance` is the radius. In free-look
/// use, `eye()` is the fixed camera position and `target` is an aim point
/// on its forward axis at `distance`.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Camera3D {
    pub target: Vec3,
    pub distance: f32,
    pub yaw: f32,
    pub pitch: f32,
    /// Vertical field of view, radians — graph-strengthening arc item 5:
    /// was hardcoded to `uzor_urx_3d::PerspectiveCamera::new`'s own fixed
    /// 60° default forever (see [`Camera3D::to_perspective`]'s own doc
    /// comment). A bare `pub` field, matching every other field on this
    /// struct — this crate's own established convention for `Camera3D`
    /// (no getter/setter pair; a caller mutates the field directly, same
    /// as `target`/`distance`/`yaw`/`pitch` already do).
    pub fov_y: f32,
}

impl Default for Camera3D {
    fn default() -> Self {
        Self { target: Vec3::ZERO, distance: 500.0, yaw: 0.0, pitch: 0.35, fov_y: DEFAULT_FOV_Y }
    }
}

impl Camera3D {
    /// `eye = target + distance * spherical(yaw, pitch)` (plan §1.4) —
    /// yaw rotates around the world-up (`Y`) axis, pitch tilts up/down
    /// from the horizontal plane.
    fn orbit_offset(&self) -> Vec3 {
        let (sp, cp) = self.pitch.sin_cos();
        let (sy, cy) = self.yaw.sin_cos();
        Vec3::new(self.distance * cp * sy, self.distance * sp, self.distance * cp * cy)
    }

    /// World-space eye position for the current orbit state.
    pub fn eye(&self) -> Vec3 {
        self.target + self.orbit_offset()
    }

    /// Unit vector from the camera position through the center of the
    /// viewport. This is the axis a captured-mouse crosshair represents.
    pub fn forward(&self) -> Vec3 {
        -self.orbit_offset().normalize_or_zero()
    }

    /// Camera-local right axis with world-Y kept as the stable up reference.
    pub fn right(&self) -> Vec3 {
        self.forward().cross(Vec3::Y).normalize_or_zero()
    }

    /// Fresh `PerspectiveCamera` for the current orbit state — `up` stays
    /// world-`Y` (`PerspectiveCamera::new`'s own default); `eye`/`target`/
    /// `aspect` vary per frame, and so do `z_near`/`z_far`/`fov_y` (see
    /// the divergence notes below).
    ///
    /// **Wave 2 divergence (`uzor-graph/CLAUDE.md`)**: `PerspectiveCamera::new`
    /// hardcodes `z_near = 0.1` / `z_far = 100.0` — tuned for
    /// `uzor-urx-3d`'s own small-scene demos (every example/test camera
    /// sits 3-7 world units from the origin). Graph world-space spans
    /// hundreds of units at this crate's default `distance = 500.0`
    /// (`[MIN_DISTANCE, MAX_DISTANCE]` = `[1.0, 100_000.0]`) — a fixed
    /// `z_far = 100.0` would clip the orbit TARGET itself the moment
    /// `distance` exceeds ~100, well inside this camera's normal range.
    /// `PerspectiveCamera`'s fields are `pub` (`uzor-urx-3d/src/camera.rs`),
    /// so both planes are overridden here, scaled to `distance`, with no
    /// `uzor-urx-3d` change: `z_far` comfortably contains the target plus
    /// a margin for nodes spread around it, `z_near` shrinks with `distance`
    /// so dollying in close never clips the target either.
    ///
    /// **Graph-strengthening arc item 5**: `fov_y` is likewise overridden
    /// from `self.fov_y` (default [`DEFAULT_FOV_Y`], byte-identical to
    /// `PerspectiveCamera::new`'s own hardcoded 60° — a caller that never
    /// touches [`Camera3D::fov_y`] sees no behavior change) instead of
    /// silently keeping whatever `PerspectiveCamera::new` happened to set.
    pub fn to_perspective(&self, aspect: f32) -> PerspectiveCamera {
        let mut camera = PerspectiveCamera::new(self.eye(), self.target, aspect);
        camera.z_near = (self.distance * 0.001).max(0.05);
        camera.z_far = (self.distance * 4.0).max(2_000.0);
        camera.fov_y = self.fov_y;
        camera
    }

    /// Drag-to-orbit: `delta_x`/`delta_y` are raw screen-pixel deltas,
    /// converted internally via [`ORBIT_RADIANS_PER_PX`] (plan §1.4).
    /// Pitch is clamped to [`PITCH_LIMIT`] so the camera can never flip
    /// past looking straight up/down.
    pub fn orbit(&mut self, delta_x: f32, delta_y: f32) {
        self.yaw += delta_x * ORBIT_RADIANS_PER_PX;
        self.pitch = (self.pitch + delta_y * ORBIT_RADIANS_PER_PX).clamp(-PITCH_LIMIT, PITCH_LIMIT);
    }

    /// Captured-mouse free look. Unlike [`Self::orbit`], this preserves the
    /// camera's world-space eye position and rotates the forward axis through
    /// it. `target` becomes the moving aim point at the existing `distance`.
    pub fn free_look(&mut self, delta_x: f32, delta_y: f32) {
        let eye = self.eye();
        self.orbit(delta_x, delta_y);
        self.target = eye - self.orbit_offset();
    }

    /// Translate the complete camera frame in local units. Eye and aim point
    /// move by the same vector, so orientation and focus distance are stable.
    pub fn translate_local(&mut self, right: f32, up: f32, forward: f32) {
        let forward_axis = self.forward();
        let right_axis = self.right();
        let up_axis = right_axis.cross(forward_axis).normalize_or_zero();
        self.target += right_axis * right + up_axis * up + forward_axis * forward;
    }

    /// Wheel-to-dolly: multiplicative distance change, clamped to
    /// [`MIN_DISTANCE`, `MAX_DISTANCE`] (plan §1.4).
    pub fn dolly(&mut self, factor: f32) {
        self.distance = (self.distance * factor).clamp(MIN_DISTANCE, MAX_DISTANCE);
    }

    /// Shift/middle-drag-to-pan: moves `target` in the camera's own
    /// right/up plane (plan §1.4), scaled by `distance` so pan speed
    /// feels zoom-independent (see [`PAN_UNITS_PER_PX_PER_DISTANCE`]).
    pub fn pan(&mut self, delta_x: f32, delta_y: f32) {
        let forward = self.forward();
        let right = self.right();
        let up = right.cross(forward).normalize_or_zero();
        let scale = PAN_UNITS_PER_PX_PER_DISTANCE * self.distance;
        self.target -= right * (delta_x * scale);
        self.target += up * (delta_y * scale);
    }

    /// Frame an axis-aligned world-space box (`min`, `max`) — dolly +
    /// retarget only, `yaw`/`pitch` are left exactly as they are (mirrors
    /// the 2D engine's own `fit_view`'s "keep the user's orientation"
    /// spirit: this is a camera MOVE, not a re-orientation). `target`
    /// becomes the box's own center; `distance` is derived so every
    /// corner of the box stays inside the frustum for the CURRENT
    /// orientation, accounting for both the vertical field of view and
    /// the horizontal one (`aspect`-derived, since a wide/narrow viewport
    /// can make either axis the binding constraint depending on view
    /// direction).
    ///
    /// Method: for each of the 8 corners, decompose its offset from the
    /// box center into the camera's own local basis (`right`/`up`/
    /// `forward`, held fixed by the untouched yaw/pitch) and solve for
    /// the smallest `distance` that keeps that corner's up/right angle
    /// within the vertical/horizontal half-fov — this is exact for an
    /// arbitrary fixed orientation, unlike a bounding-sphere shortcut
    /// (which only exactly matches an orientation where the box's
    /// diagonal is perpendicular to the view axis). `padding` is a
    /// multiplicative margin applied to the final distance (the owner's
    /// own spec: "~1.1" dollies out a bit further than the tightest fit);
    /// non-finite/non-positive `aspect`/`padding` fall back to a sane
    /// default (16:9 / no margin) rather than propagating NaN into
    /// `distance`. A degenerate (zero-volume) box still produces a valid,
    /// clamped `distance` — every corner collapses onto the center, so
    /// the derived distance floors at [`MIN_DISTANCE`].
    pub fn fit_bounds(&mut self, min: Vec3, max: Vec3, aspect: f32, padding: f32) {
        let center = (min + max) * 0.5;
        self.target = center;

        let aspect = if aspect.is_finite() && aspect > 0.0 { aspect } else { 16.0 / 9.0 };
        // Reads back THIS camera's own current `fov_y` (`self.fov_y`,
        // graph-strengthening arc item 5 — was always
        // `PerspectiveCamera::new`'s hardcoded default before that field
        // existed) via `to_perspective` rather than duplicating the
        // value, so a caller-overridden fov automatically fits correctly
        // too, not just the default one.
        let half_fov_y = (self.to_perspective(aspect).fov_y * 0.5).max(1e-4);
        let tan_half_y = half_fov_y.tan().max(1e-6);
        let tan_half_x = (tan_half_y * aspect).max(1e-6);

        let forward = self.forward();
        let right = self.right();
        let up = right.cross(forward).normalize_or_zero();

        let corners = [
            Vec3::new(min.x, min.y, min.z),
            Vec3::new(min.x, min.y, max.z),
            Vec3::new(min.x, max.y, min.z),
            Vec3::new(min.x, max.y, max.z),
            Vec3::new(max.x, min.y, min.z),
            Vec3::new(max.x, min.y, max.z),
            Vec3::new(max.x, max.y, min.z),
            Vec3::new(max.x, max.y, max.z),
        ];

        let mut required_distance = 0.0f32;
        for corner in corners {
            let local = corner - center;
            // `forward_offset` is how much closer to the camera (positive
            // = toward the eye) this corner sits than the center — a
            // corner nearer the eye needs a LARGER overall distance to
            // keep its angular size within the fov, since its own depth
            // budget is smaller.
            let forward_offset = local.dot(forward);
            let vertical = local.dot(up).abs() / tan_half_y - forward_offset;
            let horizontal = local.dot(right).abs() / tan_half_x - forward_offset;
            required_distance = required_distance.max(vertical).max(horizontal);
        }

        let padding = if padding.is_finite() && padding > 0.0 { padding } else { 1.0 };
        self.distance = (required_distance.max(MIN_DISTANCE) * padding).clamp(MIN_DISTANCE, MAX_DISTANCE);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn to_perspective_places_the_eye_at_distance_from_target_along_the_orbit_offset() {
        let camera = Camera3D { target: Vec3::ZERO, distance: 10.0, yaw: 0.0, pitch: 0.0, ..Camera3D::default() };
        let persp = camera.to_perspective(16.0 / 9.0);
        assert!((persp.eye - Vec3::new(0.0, 0.0, 10.0)).length() < 1e-4);
        assert_eq!(persp.target, Vec3::ZERO);
    }

    // ── Graph-strengthening arc item 5 — `Camera3D::fov_y` ──────────────

    /// A caller that never touches `fov_y` must see byte-identical
    /// behavior to before this field existed — same value
    /// `uzor_urx_3d::PerspectiveCamera::new` hardcodes.
    #[test]
    fn fov_y_defaults_to_the_prior_hardcoded_perspective_camera_value() {
        let default_camera = Camera3D::default();
        assert_eq!(default_camera.fov_y, DEFAULT_FOV_Y);
        let baseline = PerspectiveCamera::new(Vec3::ZERO, Vec3::ZERO, 1.0);
        assert!((default_camera.to_perspective(1.0).fov_y - baseline.fov_y).abs() < 1e-7);
    }

    /// A caller-overridden `fov_y` must actually reach the produced
    /// `PerspectiveCamera` — the graph-strengthening arc's actual fix
    /// (previously always the hardcoded 60° regardless of this field,
    /// since this field didn't exist).
    #[test]
    fn a_caller_overridden_fov_y_reaches_the_produced_perspective_camera() {
        let camera = Camera3D { fov_y: 30_f32.to_radians(), ..Camera3D::default() };
        let persp = camera.to_perspective(16.0 / 9.0);
        assert!((persp.fov_y - 30_f32.to_radians()).abs() < 1e-6);
        assert_ne!(persp.fov_y, DEFAULT_FOV_Y, "sanity: the override must actually differ from the default");
    }

    /// `fit_bounds`'s own distance solve reads back `self.fov_y` (via
    /// `to_perspective`) — a wider fov needs a SHORTER distance to fit
    /// the identical box (more of the world is visible per unit
    /// distance), proving the override reaches that math too, not just
    /// the raw `PerspectiveCamera` struct.
    #[test]
    fn fit_bounds_uses_a_shorter_distance_for_a_wider_overridden_fov() {
        let mut narrow = Camera3D { fov_y: 20_f32.to_radians(), ..Camera3D::default() };
        narrow.fit_bounds(Vec3::splat(-10.0), Vec3::splat(10.0), 16.0 / 9.0, 1.1);
        let mut wide = Camera3D { fov_y: 90_f32.to_radians(), ..Camera3D::default() };
        wide.fit_bounds(Vec3::splat(-10.0), Vec3::splat(10.0), 16.0 / 9.0, 1.1);
        assert!(wide.distance < narrow.distance, "a wider fov must fit the same box at a shorter distance: narrow={} wide={}", narrow.distance, wide.distance);
    }

    #[test]
    fn orbit_advances_yaw_and_clamps_pitch_within_limits() {
        let mut camera = Camera3D { yaw: 0.0, pitch: 0.0, ..Camera3D::default() };
        camera.orbit(100.0, 100_000.0);
        assert!(camera.yaw > 0.0);
        assert!(camera.pitch <= PITCH_LIMIT + 1e-6);

        camera.pitch = 0.0;
        camera.orbit(0.0, -100_000.0);
        assert!(camera.pitch >= -PITCH_LIMIT - 1e-6);
    }

    #[test]
    fn free_look_rotates_forward_axis_without_orbiting_the_eye() {
        let mut camera = Camera3D {
            target: Vec3::new(12.0, -4.0, 8.0),
            distance: 25.0,
            yaw: 0.3,
            pitch: -0.2,
            ..Camera3D::default()
        };
        let eye_before = camera.eye();
        let target_before = camera.target;
        let forward_before = camera.forward();

        camera.free_look(40.0, -15.0);

        assert!((camera.eye() - eye_before).length() < 1e-4, "free look must preserve camera position");
        assert!((camera.target - target_before).length() > 1.0, "the aim point must move with orientation");
        assert!((camera.forward() - forward_before).length() > 0.1, "the forward axis must rotate");
        assert!(((camera.target - camera.eye()).length() - camera.distance).abs() < 1e-4);
    }

    #[test]
    fn local_translation_moves_eye_and_aim_together() {
        let mut camera = Camera3D {
            target: Vec3::new(-3.0, 5.0, 9.0),
            distance: 18.0,
            yaw: 0.7,
            pitch: 0.25,
            ..Camera3D::default()
        };
        let eye_before = camera.eye();
        let target_before = camera.target;
        let forward_before = camera.forward();

        camera.translate_local(6.0, -2.0, 11.0);

        let eye_delta = camera.eye() - eye_before;
        let target_delta = camera.target - target_before;
        assert!((eye_delta - target_delta).length() < 1e-4);
        assert!((camera.forward() - forward_before).length() < 1e-6);
        assert!(((camera.target - camera.eye()).length() - camera.distance).abs() < 1e-4);
    }

    #[test]
    fn dolly_scales_distance_and_clamps_within_bounds() {
        let mut camera = Camera3D { distance: 10.0, ..Camera3D::default() };
        camera.dolly(2.0);
        assert!((camera.distance - 20.0).abs() < 1e-4);

        camera.dolly(1e12);
        assert!(camera.distance <= MAX_DISTANCE);

        camera.distance = 10.0;
        camera.dolly(1e-12);
        assert!(camera.distance >= MIN_DISTANCE);
    }

    #[test]
    fn pan_moves_the_target_and_leaves_distance_untouched() {
        let mut camera = Camera3D { target: Vec3::ZERO, distance: 10.0, yaw: 0.0, pitch: 0.0, ..Camera3D::default() };
        camera.pan(50.0, 0.0);
        assert!(camera.target.length() > 0.0);
        assert!((camera.distance - 10.0).abs() < 1e-6);
    }

    // ── fit_bounds (Wave 5 fit-to-bounds) ───────────────────────────────

    #[test]
    fn fit_bounds_centers_the_target_on_the_aabb_and_keeps_every_corner_inside_ndc() {
        let mut camera = Camera3D { target: Vec3::new(999.0, -50.0, 12.0), distance: 5.0, yaw: 0.6, pitch: -0.3, ..Camera3D::default() };
        let min = Vec3::new(-40.0, -10.0, -25.0);
        let max = Vec3::new(60.0, 30.0, 15.0);
        let aspect = 16.0 / 9.0;

        camera.fit_bounds(min, max, aspect, 1.1);

        let center = (min + max) * 0.5;
        assert!((camera.target - center).length() < 1e-3);

        let persp = camera.to_perspective(aspect);
        let view_proj = persp.view_proj();
        let corners = [
            Vec3::new(min.x, min.y, min.z),
            Vec3::new(min.x, min.y, max.z),
            Vec3::new(min.x, max.y, min.z),
            Vec3::new(min.x, max.y, max.z),
            Vec3::new(max.x, min.y, min.z),
            Vec3::new(max.x, min.y, max.z),
            Vec3::new(max.x, max.y, min.z),
            Vec3::new(max.x, max.y, max.z),
        ];
        for corner in corners {
            let clip = view_proj * corner.extend(1.0);
            assert!(clip.w > 1e-5, "corner {corner:?} must be in front of the fitted camera");
            let ndc_x = clip.x / clip.w;
            let ndc_y = clip.y / clip.w;
            assert!(ndc_x.abs() <= 1.0 + 1e-3, "corner {corner:?} escaped horizontal NDC: {ndc_x}");
            assert!(ndc_y.abs() <= 1.0 + 1e-3, "corner {corner:?} escaped vertical NDC: {ndc_y}");
        }
    }

    #[test]
    fn fit_bounds_preserves_yaw_and_pitch_a_dolly_and_retarget_only() {
        let mut camera = Camera3D { target: Vec3::ZERO, distance: 5.0, yaw: 1.2, pitch: -0.4, ..Camera3D::default() };
        camera.fit_bounds(Vec3::new(-10.0, -10.0, -10.0), Vec3::new(10.0, 10.0, 10.0), 1.5, 1.1);
        assert_eq!(camera.yaw, 1.2, "fit_bounds must not re-orient the camera");
        assert_eq!(camera.pitch, -0.4, "fit_bounds must not re-orient the camera");
    }

    #[test]
    fn fit_bounds_on_a_degenerate_point_aabb_does_not_panic_and_clamps_distance() {
        let mut camera = Camera3D::default();
        let point = Vec3::new(5.0, 5.0, 5.0);
        camera.fit_bounds(point, point, 16.0 / 9.0, 1.1);
        assert_eq!(camera.target, point);
        assert!(camera.distance >= MIN_DISTANCE && camera.distance <= MAX_DISTANCE);
    }

    #[test]
    fn fit_bounds_uses_a_larger_distance_for_a_bigger_box() {
        let mut small = Camera3D::default();
        small.fit_bounds(Vec3::splat(-5.0), Vec3::splat(5.0), 16.0 / 9.0, 1.1);
        let mut big = Camera3D::default();
        big.fit_bounds(Vec3::splat(-50.0), Vec3::splat(50.0), 16.0 / 9.0, 1.1);
        assert!(big.distance > small.distance, "a larger AABB must need a larger fitted distance");
    }

    #[test]
    fn fit_bounds_padding_scales_the_resulting_distance() {
        let mut tight = Camera3D::default();
        tight.fit_bounds(Vec3::splat(-10.0), Vec3::splat(10.0), 16.0 / 9.0, 1.0);
        let mut padded = Camera3D::default();
        padded.fit_bounds(Vec3::splat(-10.0), Vec3::splat(10.0), 16.0 / 9.0, 1.1);
        assert!((padded.distance / tight.distance - 1.1).abs() < 1e-3, "a 1.1 padding must scale the distance by ~10%: tight={} padded={}", tight.distance, padded.distance);
    }
}