Show a wheel step or Fit as a short zoom transition (#309)

The wheel and the Fit button used to jump to the new view. Now the
view takes a short way there, 200 ms by default, everywhere the editor
zooms. The zoom steps stay the same, only the change is shown.

- viewTween: tweenView is the way between two views as a pure
  function. The scale moves by equal ratios, and the world point that
  sits at the same place on screen in both views stays there. After a
  wheel step that is the point under the pointer.
- ViewTween drives it on the ticker. A surface hands it reading,
  writing and drawing its own view and wraps its wheel and Fit code in
  change(). A step during a transition starts from where that one was
  heading, so quick steps add up to exactly as many steps.
- Anything else that moves the view ends the transition. A pan jumps
  to the target first, and saving a view saves the target.
- Wired into FreeViewport (Scene, Container, animation preview),
  SheetCanvas (Texture, Curve, Pixel art, Vector art), GraphCanvas,
  panAndZoom (Tile board, plugin node board) and the timeline.
- Menu > General settings... sets the time in ms, 0 jumps at once. It
  is kept in this browser like the panel sizes, not in the project,
  and it is not an undo step.
- A document that opens still comes up framed at once. The animation
  preview frames a new subject itself instead of calling fit().
- The pixel art readout names the step the view is heading for.

Measured in the scene view at 200 ms on melektaus-atx, where every
frame of the transition is a full redraw:

- 500 sprites: redraw 6.7 ms, 16 frames, mean 18 ms
- 1000 sprites: redraw 13.0 ms, 9 frames, mean 27 ms
- 2000 sprites: redraw 32.2 ms, 5 frames, mean 58 ms

Tests:
- The harness sets 0 ms on every load, so the existing modules read
  the step and not the way.
- e2e 46i samples one wheel step at 1000 ms a frame later: zoom 1.033
  between 1 and 2, the point under the pointer kept at (134, -40). It
  lands on the view a 0 ms jump reaches.
- e2e 46 and 49 expect the new menu entry.
- Gate (atx): 234 files, 3563 tests, CHECK EXIT 0 (+15)
- e2e 46,49 (atx): ALL PASSED, 43 checks (+3, sum 1735)
- Three counter-checks fail view-tween.test.ts as expected. e2e 46i
  fails when the scene view does not carry its centre along.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-03 21:01:10 +02:00
1 parent 8452a99c64
commit cf6043a066
21 files changed
+779 -49

No files matched your search

+156
View File
@@ -0,0 +1,156 @@
import { requestRender, ui } from '../ui';
import { generalSettings } from './generalSettings';
/**
* A view as every zoomable surface of the editor has one, whatever its fields are called: a world
* point `p` is shown at `p * scale + (x, y)`.
*/
export interface ViewMap {
scale: number;
x: number;
y: number;
}
/** Quick at first and gentle at the end: the zoom answers the wheel at once and settles softly. */
export function easeOut(t: number): number {
const rest = 1 - Math.min(1, Math.max(0, t));
return 1 - rest * rest * rest;
}
/**
* The view `t` of the way from `from` to `to` (#309). The scale moves by equal ratios, so zooming
* in and out feel equally fast. The world point that sits at the same place on screen in both
* views stays there all the way – after a wheel step that is the point under the pointer. Two
* views of the same scale have no such point, and between them the view slides.
*/
export function tweenView(from: ViewMap, to: ViewMap, t: number): ViewMap {
if (t <= 0) return { ...from };
if (t >= 1) return { ...to };
const scale = from.scale * (to.scale / from.scale) ** t;
const gap = from.scale - to.scale;
if (Math.abs(gap) <= 1e-9 * from.scale)
return { scale, x: from.x + (to.x - from.x) * t, y: from.y + (to.y - from.y) * t };
const held = (a: number, b: number) => {
const world = (b - a) / gap;
return a + world * from.scale - world * scale;
};
return { scale, x: held(from.x, to.x), y: held(from.y, to.y) };
}
function near(a: ViewMap, b: ViewMap): boolean {
const close = (p: number, q: number) => Math.abs(p - q) <= 1e-6 * Math.max(1, Math.abs(p), Math.abs(q));
return close(a.scale, b.scale) && close(a.x, b.x) && close(a.y, b.y);
}
/** How a surface hands its view to the tween. */
export interface TweenedView {
read(): ViewMap;
/** Puts a view in place without drawing it. */
write(view: ViewMap): void;
draw(): void;
}
/** Time and frames; the editor's are the ticker's, a test brings its own. */
export interface TweenClock {
now(): number;
add(fn: () => void): void;
remove(fn: () => void): void;
}
const tickerClock: TweenClock = {
now: () => performance.now(),
add: (fn) => ui().app.ticker.add(fn),
remove: (fn) => ui().app.ticker.remove(fn),
};
/**
* Shows a zoom or a Fit as a short transition instead of a jump (#309). The surface changes its view
* as it always did, and the tween shows the way from what was on screen to it.
*
* Changing the view by other means – a pan, another document, a resize – ends the transition where
* it stands: the tween notices that the view is no longer the one it left and lets go.
*/
export class ViewTween {
private run: { from: ViewMap; to: ViewMap; start: number; ms: number; left: ViewMap } | null = null;
private readonly tick = () => this.step();
constructor(
private readonly view: TweenedView,
private readonly duration: () => number = () => generalSettings().zoomTransitionMs,
private readonly clock: TweenClock = tickerClock,
) {}
/** Whether a transition is under way – the check scripts sample it. */
get moving(): boolean {
return this.run !== null;
}
/** Where the transition is heading, null while none is under way. */
get heading(): ViewMap | null {
return this.run ? { ...this.run.to } : null;
}
/**
* Carries out `change` and shows the way to the view it leaves. A change made during a transition
* starts from where that one was heading, so quick wheel steps add up to exactly as many steps,
* while the way to the new view starts from what is on screen.
*/
change(change: () => void): void {
const shown = this.view.read();
const running = this.run;
if (running) this.view.write(running.to);
change();
const to = this.view.read();
const ms = this.duration();
if (ms <= 0 || near(shown, to)) {
this.stop();
if (running) this.view.draw();
return;
}
if (running && near(running.to, to)) {
this.view.write(shown);
return;
}
if (!running) this.clock.add(this.tick);
// Not drawn here: the next frame draws the first step of the way before it is rendered.
this.view.write(shown);
this.run = { from: shown, to, start: this.clock.now(), ms, left: this.view.read() };
requestRender();
}
/** Jumps to where the transition was heading and draws it – before a pan takes the view. */
finish(): void {
if (!this.run) return;
this.land();
this.view.draw();
}
/** Puts the view where the transition was heading, without drawing – before it is saved. */
land(): void {
const run = this.run;
if (!run) return;
this.stop();
this.view.write(run.to);
}
private step(): void {
const run = this.run;
if (!run) return;
if (!near(this.view.read(), run.left)) {
this.stop();
return;
}
const t = (this.clock.now() - run.start) / run.ms;
this.view.write(t >= 1 ? run.to : tweenView(run.from, run.to, easeOut(t)));
run.left = this.view.read();
if (t >= 1) this.stop();
this.view.draw();
requestRender();
}
private stop(): void {
if (!this.run) return;
this.run = null;
this.clock.remove(this.tick);
}
}