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:
1 parent
8452a99c64
commit
cf6043a066
21 files changed
+779
-49
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user