damascene_core/viewport.rs
1//! Pan/zoom [`viewport`](crate::tree::viewport) configuration, the
2//! content↔screen transform, programmatic requests, and read-back types.
3//!
4//! A `viewport()` is a clipped window onto a content layer the user can
5//! pan and zoom — the CSS `overflow: hidden` wrapper around a
6//! `transform: translate(pan) scale(zoom)` content box, with the wheel
7//! and drag gestures handled natively.
8//!
9//! The transform is **origin-anchored**: pan/zoom are expressed relative
10//! to the viewport's own inner top-left, so the reset state is always
11//! `pan = (0, 0)`, `zoom = 1.0` regardless of where the viewport sits on
12//! screen — it survives window resizes without recomputation.
13//!
14//! Apps push [`ViewportRequest`]s the same way they push
15//! [`crate::scroll::ScrollRequest`]s: fire-and-forget descriptors the
16//! layout pass resolves against the live viewport rect and content
17//! extents (only known mid-frame), writing the resulting pan/zoom into
18//! viewport state so the same frame renders the new framing.
19
20// Lock in full per-item documentation for this module (issue #73).
21#![warn(missing_docs)]
22
23use crate::event::{KeyModifiers, PointerButton};
24
25/// The pointer gesture that begins a pan drag inside a
26/// [`viewport`](crate::tree::viewport). Defaults to a plain primary-button
27/// drag: on empty content the pan starts at the press, and on a keyed
28/// child the press stays a click unless it travels a few pixels while
29/// held, at which point it converts into a pan (the map-app marker-tap
30/// vs map-drag arbitration — clicks stay clicks, drags pan from
31/// anywhere). Widgets that own their drag (text selection, sliders,
32/// text inputs) keep it. Set [`KeyModifiers`] and/or a different
33/// [`PointerButton`] (e.g. middle-button or space-drag, Figma-style) via
34/// [`El::pan_button`](crate::tree::El::pan_button) /
35/// [`El::pan_modifier`](crate::tree::El::pan_modifier); a dedicated
36/// trigger can't collide with clicks, so it pans at press from anywhere.
37/// Touch contacts count as the primary button: under the default
38/// trigger a drag pans (scrollables inside the content still scroll,
39/// and sub-threshold contacts stay taps), while a dedicated-trigger
40/// viewport is wheel/programmatic-only on touch.
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub struct PanTrigger {
43 /// Button that must be pressed to start a pan.
44 pub button: PointerButton,
45 /// Exact modifier mask that must be held. Extra modifiers held
46 /// beyond this mask do **not** match — mirrors
47 /// [`KeyChord`](crate::event::KeyChord).
48 pub modifiers: KeyModifiers,
49}
50
51impl Default for PanTrigger {
52 fn default() -> Self {
53 Self {
54 button: PointerButton::Primary,
55 modifiers: KeyModifiers::default(),
56 }
57 }
58}
59
60impl PanTrigger {
61 /// True when a press of `button` with the current `modifiers` mask
62 /// should begin a pan. The modifier match is exact.
63 pub fn matches(self, button: PointerButton, modifiers: KeyModifiers) -> bool {
64 self.button == button && self.modifiers == modifiers
65 }
66}
67
68/// How far a [`viewport`](crate::tree::viewport) may be panned relative
69/// to its content — the clamp policy applied to pan after every drag,
70/// wheel-zoom, and programmatic request. Set via
71/// [`El::pan_bounds`](crate::tree::El::pan_bounds).
72///
73/// The keyword names mirror CSS sizing keywords: pick the policy by what
74/// the content *is*, not by tuning a px margin.
75#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
76pub enum PanBounds {
77 /// Content can't be dragged off-screen: content larger than the
78 /// viewport shows no empty gutter past its edges, and content smaller
79 /// than the viewport stays fully inside. Good for documents, images,
80 /// and fixed-extent maps. This is the default.
81 #[default]
82 Contain,
83 /// Any content point can be parked at the viewport center: the
84 /// content bounding box is kept overlapping the viewport's center, so
85 /// the left-most node of a graph (or any node) can sit mid-frame.
86 /// Good for node graphs, DAGs, and freeform canvases.
87 Center,
88 /// No clamping at all — content can be panned anywhere, including
89 /// entirely out of view. The app owns any bounding it wants.
90 Free,
91}
92
93/// Declarative framing policy for a [`viewport`](crate::tree::viewport)
94/// — whether the widget itself keeps the content framed, and when it
95/// hands control to the user. Set via
96/// [`El::fit_policy`](crate::tree::El::fit_policy).
97///
98/// This is the *sustained* counterpart of the one-shot
99/// [`ViewportRequest::FitContent`]: the policy lives on the config and
100/// is maintained by the layout pass, so the framing survives container
101/// resizes and content changes without the app diffing rects across
102/// frames.
103#[derive(Clone, Copy, Debug, PartialEq, Default)]
104pub enum FitPolicy {
105 /// The app drives framing via [`ViewportRequest`]s; the widget only
106 /// clamps. This is the default and the pre-policy behaviour.
107 #[default]
108 Manual,
109 /// Keep the content fit to the frame — on mount, on every container
110 /// resize, and on content-extent changes — until the user pans or
111 /// zooms, which releases the policy to free pan/zoom. A
112 /// [`ViewportRequest::ResetView`] (or `FitContent`) re-arms it.
113 /// The photo / PDF / diagram-viewer default: opens fit, tracks the
114 /// window, hands over on first touch.
115 Contain {
116 /// Margin in logical px between the content bbox and the
117 /// viewport edge, as in [`ViewportRequest::FitContent`].
118 padding: f32,
119 },
120 /// Always keep the content fit; pan / zoom gestures are disabled
121 /// (the wheel falls through to any enclosing scroll). For
122 /// thumbnails and fixed previews that must always show everything.
123 Lock {
124 /// Margin in logical px between the content bbox and the
125 /// viewport edge.
126 padding: f32,
127 },
128}
129
130/// Configuration for a [`viewport`](crate::tree::viewport) container,
131/// carried on [`El`](crate::tree::El) and read by the layout / input
132/// passes. Present (`Some`) exactly on viewport nodes.
133#[derive(Clone, Copy, Debug, PartialEq)]
134pub struct ViewportConfig {
135 /// Smallest zoom factor the wheel / pinch may reach. `0.2` shows
136 /// content at one-fifth size.
137 pub min_zoom: f32,
138 /// Largest zoom factor the wheel / pinch may reach.
139 pub max_zoom: f32,
140 /// What pointer gesture pans the content.
141 pub pan_trigger: PanTrigger,
142 /// How far the content may be panned relative to the viewport.
143 pub pan_bounds: PanBounds,
144 /// Whether the widget maintains a content-fit framing itself. See
145 /// [`FitPolicy`]; defaults to [`FitPolicy::Manual`].
146 pub fit: FitPolicy,
147}
148
149impl Default for ViewportConfig {
150 fn default() -> Self {
151 Self {
152 min_zoom: 0.2,
153 max_zoom: 5.0,
154 pan_trigger: PanTrigger::default(),
155 pan_bounds: PanBounds::default(),
156 fit: FitPolicy::default(),
157 }
158 }
159}
160
161/// The current pan/zoom of a [`viewport`](crate::tree::viewport),
162/// persisted per node across rebuilds and readable with
163/// [`UiState::viewport_view`](crate::state::UiState::viewport_view) (e.g.
164/// to display a zoom percentage).
165///
166/// The transform maps a **content-space** point `c` (the coordinate a
167/// child was laid out at, with no pan/zoom) to a **screen** point, given
168/// the viewport's inner top-left `origin`:
169///
170/// ```text
171/// screen = origin + pan + zoom * (c - origin)
172/// ```
173#[derive(Clone, Copy, Debug, PartialEq)]
174pub struct ViewportView {
175 /// Screen-space translation of the content layer, in logical px.
176 pub pan: (f32, f32),
177 /// Uniform zoom factor. `1.0` means content space equals screen
178 /// space.
179 pub zoom: f32,
180}
181
182impl Default for ViewportView {
183 /// The reset framing: no pan, unit zoom.
184 fn default() -> Self {
185 Self {
186 pan: (0.0, 0.0),
187 zoom: 1.0,
188 }
189 }
190}
191
192impl ViewportView {
193 /// Map a content-space point to screen space about `origin` (the
194 /// viewport's inner top-left).
195 pub fn project(self, p: (f32, f32), origin: (f32, f32)) -> (f32, f32) {
196 (
197 origin.0 + self.pan.0 + self.zoom * (p.0 - origin.0),
198 origin.1 + self.pan.1 + self.zoom * (p.1 - origin.1),
199 )
200 }
201
202 /// Inverse of [`Self::project`]: map a screen-space point back into
203 /// content space.
204 pub fn unproject(self, s: (f32, f32), origin: (f32, f32)) -> (f32, f32) {
205 (
206 origin.0 + (s.0 - origin.0 - self.pan.0) / self.zoom,
207 origin.1 + (s.1 - origin.1 - self.pan.1) / self.zoom,
208 )
209 }
210
211 /// Recompute `pan` for a zoom change to `new_zoom` that keeps the
212 /// content point currently under the screen point `anchor` fixed —
213 /// the cursor-anchored ("zoom toward the mouse") behaviour. `origin`
214 /// is the viewport's inner top-left.
215 pub fn zoom_about(self, new_zoom: f32, anchor: (f32, f32), origin: (f32, f32)) -> Self {
216 // Solve project(unproject(anchor)) == anchor for the new pan:
217 // pan' = (anchor - origin) * (1 - r) + pan * r, r = new/old.
218 let r = new_zoom / self.zoom;
219 let dx = anchor.0 - origin.0;
220 let dy = anchor.1 - origin.1;
221 Self {
222 pan: (
223 dx * (1.0 - r) + self.pan.0 * r,
224 dy * (1.0 - r) + self.pan.1 * r,
225 ),
226 zoom: new_zoom,
227 }
228 }
229}
230
231/// How a [`ViewportRequest`] moves the view to its target framing —
232/// mirrors the DOM's `ScrollBehavior` (`scrollIntoView({ behavior })`).
233#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
234pub enum ViewportBehavior {
235 /// Jump to the target framing immediately (DOM `"instant"`). The
236 /// default.
237 #[default]
238 Instant,
239 /// Fly to the target along a smooth zoom-out / translate / zoom-in
240 /// path (DOM `"smooth"`; see [`ZoomPath`]), over a duration derived
241 /// from the path length — there is deliberately no duration knob,
242 /// as with DOM smooth scrolling. A user pan/zoom gesture mid-flight
243 /// cancels the flight where it is; a new request retargets from the
244 /// in-flight view. Degrades to [`Instant`](Self::Instant) under
245 /// [`AnimationMode::Settled`](crate::state::AnimationMode::Settled)
246 /// (headless / snapshot rendering) and on a [`FitPolicy::Lock`]
247 /// viewport (whose framing is not free to leave home).
248 Smooth,
249}
250
251/// What an app produces to drive a [`viewport`](crate::tree::viewport)
252/// programmatically. Each request targets a viewport by its `.key(...)`
253/// and is consumed during that viewport's layout, where the live inner
254/// rect and content extents are known. Push them once per build via
255/// [`UiState::push_viewport_requests`](crate::state::UiState::push_viewport_requests).
256#[derive(Clone, Debug, PartialEq)]
257pub enum ViewportRequest {
258 /// Frame all content: choose the largest zoom (within the configured
259 /// `min_zoom..=max_zoom`) that fits the content bounding box inside
260 /// the viewport with `padding` logical px of margin on every side,
261 /// then center it. The "fit to view" / "zoom to fit" command.
262 ///
263 /// On a viewport with an armed [`FitPolicy::Contain`] /
264 /// [`FitPolicy::Lock`], this re-arms the policy and the policy's own
265 /// `padding` wins (the request's is ignored) — the sustained fit is
266 /// the single source of the framing there. Same for [`Self::ResetView`],
267 /// whose home framing under those policies is the fit, not 1:1.
268 /// With [`ViewportBehavior::Smooth`], the policy re-arms when the
269 /// flight *arrives*, so it doesn't snap over the animation.
270 FitContent {
271 /// `.key(...)` of the target viewport.
272 key: String,
273 /// Margin in logical px between the content bbox and the
274 /// viewport edge.
275 padding: f32,
276 /// Jump or fly. Defaults to [`ViewportBehavior::Instant`].
277 behavior: ViewportBehavior,
278 },
279 /// Snap back to the reset framing: `pan = (0, 0)`, `zoom = 1.0`.
280 ResetView {
281 /// `.key(...)` of the target viewport.
282 key: String,
283 /// Jump or fly. Defaults to [`ViewportBehavior::Instant`].
284 behavior: ViewportBehavior,
285 },
286 /// Pan (keeping the current zoom) so the given content-space point
287 /// lands at the center of the viewport.
288 CenterOn {
289 /// `.key(...)` of the target viewport.
290 key: String,
291 /// Point in content coordinates (the same space children are
292 /// laid out in: logical px, pre-transform).
293 point: (f32, f32),
294 /// Jump or fly. Defaults to [`ViewportBehavior::Instant`].
295 behavior: ViewportBehavior,
296 },
297 /// Frame an arbitrary content-space rect (issue #122): choose the
298 /// largest zoom (within the configured `min_zoom..=max_zoom`) that
299 /// fits `rect` inside the viewport with `padding` logical px of
300 /// margin on every side, then center it — `scrollIntoView()` for a
301 /// pan/zoom canvas. The scope-as-camera primitive: lay content out
302 /// once and fly the camera to a region instead of re-rooting the
303 /// layout.
304 ///
305 /// Unlike [`Self::FitContent`], this deliberately frames a *sub*-rect
306 /// of larger content, so it does **not** re-arm an armed
307 /// [`FitPolicy::Contain`] — it takes the view over exactly like a
308 /// user pan/zoom or a [`Self::CenterOn`] (one-shot, off home). A
309 /// degenerate rect (`w` and `h` both `<= 0`) is treated as a point:
310 /// centered at the current zoom, i.e. [`Self::CenterOn`] its origin.
311 FrameRect {
312 /// `.key(...)` of the target viewport.
313 key: String,
314 /// The region to frame, in content coordinates (the same space
315 /// children are laid out in: logical px, pre-transform).
316 rect: crate::tree::Rect,
317 /// Margin in logical px between `rect` and the viewport edge.
318 padding: f32,
319 /// Jump or fly. Defaults to [`ViewportBehavior::Instant`].
320 behavior: ViewportBehavior,
321 },
322}
323
324impl ViewportRequest {
325 /// The `.key(...)` of the viewport this request targets.
326 pub fn key(&self) -> &str {
327 match self {
328 ViewportRequest::FitContent { key, .. }
329 | ViewportRequest::ResetView { key, .. }
330 | ViewportRequest::CenterOn { key, .. }
331 | ViewportRequest::FrameRect { key, .. } => key,
332 }
333 }
334
335 /// How this request moves the view (jump or fly).
336 pub fn behavior(&self) -> ViewportBehavior {
337 match self {
338 ViewportRequest::FitContent { behavior, .. }
339 | ViewportRequest::ResetView { behavior, .. }
340 | ViewportRequest::CenterOn { behavior, .. }
341 | ViewportRequest::FrameRect { behavior, .. } => *behavior,
342 }
343 }
344}
345
346/// The zoom-out aggressiveness of a [`ZoomPath`] — van Wijk & Nuij's ρ,
347/// at the paper's (and d3's) recommended `√2`. Larger values arc further
348/// out during the translate phase.
349const ZOOM_PATH_RHO: f64 = std::f64::consts::SQRT_2;
350
351/// Absolute floor (content-space px) under which a translation flies as
352/// a pure zoom. The effective guard is relative — see
353/// [`ZoomPath::new`] — since the arc parameters degenerate whenever the
354/// translation is small *relative to the widths*, not just near zero.
355const ZOOM_PATH_MIN_TRANSLATION: f64 = 1e-3;
356
357/// A smooth zoom-and-pan path between two viewport framings, after
358/// van Wijk & Nuij (*Smooth and efficient zooming and panning*, 2003) —
359/// the same path `d3.interpolateZoom` implements. A framing is
360/// `(cx, cy, w)`: the content-space point at the viewport's center and
361/// the content-space width the viewport shows (`inner.w / zoom`). The
362/// path zooms out, translates, and zooms back in along a hyperbolic arc,
363/// so mid-flight frames keep both endpoints' context on screen instead
364/// of tunneling across the canvas at full magnification.
365///
366/// Pure math with no clock: sample with a progress fraction `t ∈ [0, 1]`
367/// and pace `t` however you like. [`length`](Self::length) is the
368/// perceptual path length `S` — the natural basis for a duration. This
369/// is a Layer-3 primitive: the smooth [`ViewportRequest`]s ride on it,
370/// and a custom host or widget can sample it directly.
371#[derive(Clone, Copy, Debug)]
372pub struct ZoomPath {
373 start: (f32, f32, f32),
374 end: (f32, f32, f32),
375 kind: PathKind,
376 length: f32,
377}
378
379/// The two path shapes: a degenerate straight blend when the centers
380/// coincide, and the general hyperbolic arc.
381#[derive(Clone, Copy, Debug)]
382enum PathKind {
383 /// Centers (nearly) coincide: geometric width interpolation, linear
384 /// center blend across the (sub-pixel) gap.
385 Blend,
386 /// The general van Wijk arc, precomputed at construction.
387 Arc {
388 /// Distance between the centers, content-space px.
389 d1: f64,
390 /// The start parameter `r0` on the hyperbola.
391 r0: f64,
392 /// `cosh(r0)` / `sinh(r0)`, hoisted out of the per-sample math.
393 cosh_r0: f64,
394 sinh_r0: f64,
395 },
396}
397
398impl ZoomPath {
399 /// The path from `start` to `end`, each a `(cx, cy, w)` framing with
400 /// `w > 0` (non-positive widths are clamped to a tiny epsilon).
401 pub fn new(start: (f32, f32, f32), end: (f32, f32, f32)) -> Self {
402 let w0 = f64::from(start.2).max(1e-6);
403 let w1 = f64::from(end.2).max(1e-6);
404 let dx = f64::from(end.0) - f64::from(start.0);
405 let dy = f64::from(end.1) - f64::from(start.1);
406 let d2 = dx * dx + dy * dy;
407 let d1 = d2.sqrt();
408 let rho = ZOOM_PATH_RHO;
409 let blend = |start, end| Self {
410 start,
411 end,
412 kind: PathKind::Blend,
413 length: ((w1 / w0).ln().abs() / rho) as f32,
414 };
415 // Fly as a pure zoom when the translation is negligible —
416 // *relative to the widths*: the arc parameter `b` grows like
417 // `w²/(w·d)`, so a sub-pixel translation paired with a large
418 // width change would push it into catastrophic-cancellation
419 // territory even though the flight is visually a straight zoom.
420 if d1 < ZOOM_PATH_MIN_TRANSLATION.max(1e-4 * w0.max(w1)) {
421 return blend(start, end);
422 }
423 let rho2 = rho * rho;
424 let b0 = (w1 * w1 - w0 * w0 + rho2 * rho2 * d2) / (2.0 * w0 * rho2 * d1);
425 let b1 = (w1 * w1 - w0 * w0 - rho2 * rho2 * d2) / (2.0 * w1 * rho2 * d1);
426 // r = log(√(b²+1) − b) = −asinh(b); `asinh` stays exact where the
427 // naive form cancels to `ln(0)` for large `b`.
428 let r0 = -b0.asinh();
429 let r1 = -b1.asinh();
430 let length = ((r1 - r0) / rho) as f32;
431 // Overflow belt (e.g. `d²` or `w²` past f64 range): degrade to
432 // the always-finite blend rather than sampling NaN framings.
433 if !(length.is_finite() && length > 0.0) {
434 return blend(start, end);
435 }
436 Self {
437 start,
438 end,
439 kind: PathKind::Arc {
440 d1,
441 r0,
442 cosh_r0: r0.cosh(),
443 sinh_r0: r0.sinh(),
444 },
445 length,
446 }
447 }
448
449 /// The perceptual path length `S`, in ρ-normalized units: `0` for a
450 /// no-op path, ~1 per zoom factor of `e^√2 ≈ 4`, growing with the
451 /// translation distance relative to the viewport width.
452 pub fn length(&self) -> f32 {
453 self.length
454 }
455
456 /// The framing at progress `t` (clamped to `[0, 1]`). The endpoints
457 /// return the constructor inputs bit-exactly, so arrival lands on
458 /// the resolved target with no float drift.
459 pub fn sample(&self, t: f32) -> (f32, f32, f32) {
460 if t <= 0.0 || self.length == 0.0 {
461 return self.start;
462 }
463 if t >= 1.0 {
464 return self.end;
465 }
466 let (cx0, cy0, w0) = (
467 f64::from(self.start.0),
468 f64::from(self.start.1),
469 f64::from(self.start.2).max(1e-6),
470 );
471 let (cx1, cy1, w1) = (
472 f64::from(self.end.0),
473 f64::from(self.end.1),
474 f64::from(self.end.2).max(1e-6),
475 );
476 let t = f64::from(t);
477 match self.kind {
478 PathKind::Blend => {
479 let w = w0 * (w1 / w0).powf(t);
480 (
481 (cx0 + t * (cx1 - cx0)) as f32,
482 (cy0 + t * (cy1 - cy0)) as f32,
483 w as f32,
484 )
485 }
486 PathKind::Arc {
487 d1,
488 r0,
489 cosh_r0,
490 sinh_r0,
491 } => {
492 let rho = ZOOM_PATH_RHO;
493 let s = t * f64::from(self.length);
494 let r = rho * s + r0;
495 // u runs 0 → 1 along the center-to-center line.
496 let u = w0 / (rho * rho * d1) * (cosh_r0 * r.tanh() - sinh_r0);
497 let w = w0 * cosh_r0 / r.cosh();
498 (
499 (cx0 + u * (cx1 - cx0)) as f32,
500 (cy0 + u * (cy1 - cy0)) as f32,
501 w as f32,
502 )
503 }
504 }
505 }
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511
512 fn approx(a: (f32, f32), b: (f32, f32)) {
513 assert!(
514 (a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3,
515 "{a:?} != {b:?}"
516 );
517 }
518
519 #[test]
520 fn project_unproject_roundtrip() {
521 let origin = (100.0, 50.0);
522 let v = ViewportView {
523 pan: (30.0, -20.0),
524 zoom: 2.5,
525 };
526 let p = (140.0, 75.0);
527 approx(v.unproject(v.project(p, origin), origin), p);
528 }
529
530 #[test]
531 fn identity_is_a_noop() {
532 let origin = (10.0, 10.0);
533 let v = ViewportView::default();
534 approx(v.project((200.0, 300.0), origin), (200.0, 300.0));
535 }
536
537 #[test]
538 fn zoom_about_keeps_cursor_point_fixed() {
539 // The content point under the cursor before the zoom must remain
540 // under the cursor after the zoom — the cursor-anchored invariant.
541 let origin = (0.0, 0.0);
542 let v = ViewportView {
543 pan: (15.0, 40.0),
544 zoom: 1.0,
545 };
546 let cursor = (250.0, 120.0);
547 let before = v.unproject(cursor, origin);
548 let zoomed = v.zoom_about(3.0, cursor, origin);
549 let after = zoomed.unproject(cursor, origin);
550 approx(before, after);
551 // And the cursor still projects from that same content point.
552 approx(zoomed.project(before, origin), cursor);
553 }
554
555 #[test]
556 fn pan_trigger_matches_exactly() {
557 let t = PanTrigger::default();
558 assert!(t.matches(PointerButton::Primary, KeyModifiers::default()));
559 assert!(!t.matches(PointerButton::Middle, KeyModifiers::default()));
560 // Extra modifier beyond the (empty) mask does not match.
561 let shift = KeyModifiers {
562 shift: true,
563 ..Default::default()
564 };
565 assert!(!t.matches(PointerButton::Primary, shift));
566 }
567
568 #[test]
569 fn zoom_path_endpoints_are_bit_exact() {
570 let a = (100.0, 200.0, 800.0);
571 let b = (5000.0, -300.0, 120.0);
572 let p = ZoomPath::new(a, b);
573 assert_eq!(p.sample(0.0), a);
574 assert_eq!(p.sample(1.0), b);
575 // Out-of-range progress clamps to the endpoints.
576 assert_eq!(p.sample(-0.5), a);
577 assert_eq!(p.sample(2.0), b);
578 assert!(p.length() > 0.0);
579 }
580
581 #[test]
582 fn zoom_path_arcs_out_for_long_translations() {
583 // A same-zoom flight across the canvas must zoom out mid-path —
584 // the width hump is the whole point of the van Wijk arc.
585 let p = ZoomPath::new((0.0, 0.0, 800.0), (10_000.0, 0.0, 800.0));
586 let (_, _, w_mid) = p.sample(0.5);
587 assert!(w_mid > 800.0 * 1.5, "mid-flight width: {w_mid}");
588 // And the center crosses the halfway line at half progress
589 // (the arc is symmetric for symmetric endpoints).
590 let (cx_mid, _, _) = p.sample(0.5);
591 assert!((cx_mid - 5_000.0).abs() < 1.0, "mid center: {cx_mid}");
592 }
593
594 #[test]
595 fn zoom_path_center_progress_is_monotone() {
596 let p = ZoomPath::new((0.0, 0.0, 400.0), (3_000.0, 1_500.0, 900.0));
597 let mut last = f32::NEG_INFINITY;
598 for i in 0..=20 {
599 let (cx, _, w) = p.sample(i as f32 / 20.0);
600 assert!(cx >= last - 1e-3, "center backtracked at step {i}: {cx}");
601 assert!(w > 0.0);
602 last = cx;
603 }
604 }
605
606 #[test]
607 fn zoom_path_pure_zoom_is_geometric() {
608 let p = ZoomPath::new((50.0, 50.0, 100.0), (50.0, 50.0, 1_600.0));
609 let (cx, cy, w) = p.sample(0.5);
610 assert!((cx - 50.0).abs() < 1e-3 && (cy - 50.0).abs() < 1e-3);
611 // Geometric midpoint of 100 → 1600 is 400.
612 assert!((w - 400.0).abs() < 0.5, "geometric mid width: {w}");
613 assert!(p.length() > 0.0);
614 }
615
616 #[test]
617 fn zoom_path_noop_has_zero_length() {
618 let a = (10.0, 20.0, 300.0);
619 let p = ZoomPath::new(a, a);
620 assert_eq!(p.length(), 0.0);
621 assert_eq!(p.sample(0.5), a);
622 }
623
624 /// Review finding: an f32-noise translation paired with a large
625 /// width change must fly as a pure zoom — the arc parameters hit
626 /// catastrophic cancellation there (`ln(√(b²+1) − b)` → `ln(0)`).
627 #[test]
628 fn zoom_path_near_pure_zoom_stays_finite() {
629 let p = ZoomPath::new((16_384.0, 8_000.0, 800.0), (16_384.002, 8_000.0, 30_000.0));
630 assert!(p.length().is_finite() && p.length() > 0.0);
631 for i in 0..=10 {
632 let (cx, cy, w) = p.sample(i as f32 / 10.0);
633 assert!(
634 cx.is_finite() && cy.is_finite() && w.is_finite() && w > 0.0,
635 "sample {i}: ({cx}, {cy}, {w})"
636 );
637 }
638 }
639
640 /// Review finding: extreme width ratios push the arc parameter past
641 /// the naive formula's precision even with a real translation —
642 /// `asinh` keeps it exact.
643 #[test]
644 fn zoom_path_extreme_width_ratio_stays_finite() {
645 let p = ZoomPath::new((0.0, 0.0, 800.0), (6_000.0, 0.0, 5.0e7));
646 assert!(p.length().is_finite() && p.length() > 0.0);
647 for i in 0..=10 {
648 let (cx, cy, w) = p.sample(i as f32 / 10.0);
649 assert!(
650 cx.is_finite() && cy.is_finite() && w.is_finite() && w > 0.0,
651 "sample {i}: ({cx}, {cy}, {w})"
652 );
653 }
654 }
655
656 #[test]
657 fn zoom_path_is_reversible() {
658 let a = (0.0, 0.0, 500.0);
659 let b = (2_000.0, 800.0, 250.0);
660 let fwd = ZoomPath::new(a, b);
661 let back = ZoomPath::new(b, a);
662 assert!((fwd.length() - back.length()).abs() < 1e-4);
663 for i in 1..10 {
664 let t = i as f32 / 10.0;
665 let (fx, fy, fw) = fwd.sample(t);
666 let (bx, by, bw) = back.sample(1.0 - t);
667 assert!((fx - bx).abs() < 0.1, "x at t={t}: {fx} vs {bx}");
668 assert!((fy - by).abs() < 0.1, "y at t={t}: {fy} vs {by}");
669 assert!((fw - bw).abs() < 0.1, "w at t={t}: {fw} vs {bw}");
670 }
671 }
672}