diff --git a/README.md b/README.md
index 6918a9d9..787abd50 100644
--- a/README.md
+++ b/README.md
@@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU:
```bash
-node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2017 checks (GM_STEPS=20 for one step)
+node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2018 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
diff --git a/src/editor/animation/TimelineCanvas.ts b/src/editor/animation/TimelineCanvas.ts
index aa9ca4e0..195a928c 100644
--- a/src/editor/animation/TimelineCanvas.ts
+++ b/src/editor/animation/TimelineCanvas.ts
@@ -554,8 +554,9 @@ export class TimelineCanvas extends Widget {
const local = this.toLocal(e.global);
const point = { x: local.x, y: local.y };
if (e.button === 1 || (e.button === 0 && e.shiftKey && local.x >= HEADER_WIDTH)) {
- this.tween.finish();
- this.panning = { pointer: point, panX: this.view.panX };
+ this.tween.target(() => {
+ this.panning = { pointer: point, panX: this.view.panX };
+ });
e.stopPropagation();
return;
}
@@ -616,7 +617,10 @@ export class TimelineCanvas extends Widget {
private onPointerMove(e: FederatedPointerEvent): void {
const local = this.toLocal(e.global);
if (this.panning) {
- this.view = clampPan({ ...this.view, panX: this.panning.panX + (local.x - this.panning.pointer.x) });
+ const { panning } = this;
+ this.tween.carry(() => {
+ this.view = clampPan({ ...this.view, panX: panning.panX + (local.x - panning.pointer.x) });
+ });
this.redraw();
return;
}
diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts
index c511cd85..2944db26 100644
--- a/src/editor/graph/GraphCanvas.ts
+++ b/src/editor/graph/GraphCanvas.ts
@@ -593,8 +593,9 @@ export class GraphCanvas extends Widget {
// The middle button pans from anywhere, a node under the pointer included – it is the one
// gesture that never means "grab this". Nodes let it through for exactly that reason.
const startPan = () => {
- this.tween.finish();
- this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y };
+ this.tween.target(() => {
+ this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y };
+ });
};
if (e.button === 1) {
startPan();
@@ -782,9 +783,14 @@ export class GraphCanvas extends Widget {
this.trackIncoming(dragged, e);
return;
}
- if (this.panning) {
- this.pan.x = this.panning.panX + (e.global.x - this.panning.startX);
- this.pan.y = this.panning.panY + (e.global.y - this.panning.startY);
+ const { panning } = this;
+ if (panning) {
+ this.tween.carry(() => {
+ this.pan = {
+ x: panning.panX + (e.global.x - panning.startX),
+ y: panning.panY + (e.global.y - panning.startY),
+ };
+ });
this.applyView();
return;
}
diff --git a/src/editor/graph/graphView.ts b/src/editor/graph/graphView.ts
index 7be9f6ea..c7cc0967 100644
--- a/src/editor/graph/graphView.ts
+++ b/src/editor/graph/graphView.ts
@@ -76,13 +76,16 @@ export function panAndZoom(
): void {
let panning: { start: Point; pan: Point } | null = null;
surface.on('pointerdown', (e: FederatedPointerEvent) => {
- tween.finish();
- panning = { start: { x: e.global.x, y: e.global.y }, pan: { ...get().pan } };
+ tween.target(() => {
+ panning = { start: { x: e.global.x, y: e.global.y }, pan: { ...get().pan } };
+ });
});
surface.on('globalpointermove', (e: FederatedPointerEvent) => {
if (!panning) return;
const { start, pan } = panning;
- set({ ...get(), pan: { x: pan.x + e.global.x - start.x, y: pan.y + e.global.y - start.y } }, 'pan');
+ tween.carry(() =>
+ set({ ...get(), pan: { x: pan.x + e.global.x - start.x, y: pan.y + e.global.y - start.y } }, 'pan'),
+ );
});
surface.on('pointerup', () => (panning = null));
surface.on('pointerupoutside', () => (panning = null));
diff --git a/src/editor/pattern/PianoRoll.ts b/src/editor/pattern/PianoRoll.ts
index 0819fcba..51b3904c 100644
--- a/src/editor/pattern/PianoRoll.ts
+++ b/src/editor/pattern/PianoRoll.ts
@@ -566,9 +566,11 @@ export class PianoRoll extends Widget {
e.stopPropagation();
ui().focus.focus(this);
if (e.button === 1) {
- this.timeTween.finish();
- this.rowTween.finish();
- this.gesture = { kind: 'pan', pointer: p, panX: this.view.panX, panY: this.view.panY };
+ this.timeTween.target(() =>
+ this.rowTween.target(() => {
+ this.gesture = { kind: 'pan', pointer: p, panX: this.view.panX, panY: this.view.panY };
+ }),
+ );
return;
}
const g = this.inGrid(p);
@@ -660,17 +662,21 @@ export class PianoRoll extends Widget {
const free = e.altKey || e.shiftKey;
switch (gesture.kind) {
case 'pan':
- this.view = clampRows(
- clampTimePan(
- {
- ...this.view,
- panX: gesture.panX + (local.x - gesture.pointer.x),
- panY: gesture.panY + (local.y - gesture.pointer.y),
- },
- this.gridWidth,
- this.earliest,
- ),
- this.gridHeight,
+ this.timeTween.carry(() =>
+ this.rowTween.carry(() => {
+ this.view = clampRows(
+ clampTimePan(
+ {
+ ...this.view,
+ panX: gesture.panX + (local.x - gesture.pointer.x),
+ panY: gesture.panY + (local.y - gesture.pointer.y),
+ },
+ this.gridWidth,
+ this.earliest,
+ ),
+ this.gridHeight,
+ );
+ }),
);
break;
case 'key': {
diff --git a/src/editor/song/SongTimeline.ts b/src/editor/song/SongTimeline.ts
index 26d6073d..f4163129 100644
--- a/src/editor/song/SongTimeline.ts
+++ b/src/editor/song/SongTimeline.ts
@@ -756,9 +756,11 @@ export class SongTimeline extends Widget {
e.stopPropagation();
ui().focus.focus(this);
if (e.button === 1) {
- this.timeTween.finish();
- this.trackTween.finish();
- this.gesture = { kind: 'pan', pointer: p, panX: this.view.panX, panY: this.view.panY };
+ this.timeTween.target(() =>
+ this.trackTween.target(() => {
+ this.gesture = { kind: 'pan', pointer: p, panX: this.view.panX, panY: this.view.panY };
+ }),
+ );
return;
}
if (this.take) return;
@@ -911,17 +913,21 @@ export class SongTimeline extends Widget {
const picked = new Set(this.pickedIds);
switch (gesture.kind) {
case 'pan':
- this.view = clampTracks(
- clampTimePan(
- {
- ...this.view,
- panX: gesture.panX + (local.x - gesture.pointer.x),
- panY: gesture.panY + (local.y - gesture.pointer.y),
- },
- this.lanesWidth,
- ),
- asset.tracks.length,
- this.lanesHeight,
+ this.timeTween.carry(() =>
+ this.trackTween.carry(() => {
+ this.view = clampTracks(
+ clampTimePan(
+ {
+ ...this.view,
+ panX: gesture.panX + (local.x - gesture.pointer.x),
+ panY: gesture.panY + (local.y - gesture.pointer.y),
+ },
+ this.lanesWidth,
+ ),
+ asset.tracks.length,
+ this.lanesHeight,
+ );
+ }),
);
break;
case 'loop': {
diff --git a/src/editor/sound/WaveCanvas.ts b/src/editor/sound/WaveCanvas.ts
index 989821c9..cc56eb60 100644
--- a/src/editor/sound/WaveCanvas.ts
+++ b/src/editor/sound/WaveCanvas.ts
@@ -380,8 +380,9 @@ export class WaveCanvas extends Widget {
e.stopPropagation();
ui().focus.focus(this);
if (e.button === 1) {
- this.timeTween.finish();
- this.gesture = { kind: 'pan', pointerX: p.x, panX: this.view.panX };
+ this.timeTween.target(() => {
+ this.gesture = { kind: 'pan', pointerX: p.x, panX: this.view.panX };
+ });
return;
}
if (e.button !== 0 || p.x < 0) return;
@@ -410,7 +411,13 @@ export class WaveCanvas extends Widget {
return;
}
if (g.kind === 'pan') {
- this.view = clampWave({ ...this.view, panX: g.panX + p.x - g.pointerX }, wave.duration, this.areaWidth);
+ this.timeTween.carry(() => {
+ this.view = clampWave(
+ { ...this.view, panX: g.panX + p.x - g.pointerX },
+ wave.duration,
+ this.areaWidth,
+ );
+ });
this.needsFit = false;
this.redraw();
return;
diff --git a/src/editor/texture/SheetCanvas.ts b/src/editor/texture/SheetCanvas.ts
index 7284eff1..edf3f6ed 100644
--- a/src/editor/texture/SheetCanvas.ts
+++ b/src/editor/texture/SheetCanvas.ts
@@ -142,8 +142,9 @@ export abstract class SheetCanvas extends Widget {
const local = this.toLocal(e.global);
const pans = e.button === 0 && e.shiftKey && !this.shiftPresses(target, { x: local.x, y: local.y });
if (e.button === 1 || pans) {
- this.tween.finish();
- this.panning = { pointer: { x: local.x, y: local.y }, panX: this.view.panX, panY: this.view.panY };
+ this.tween.target(() => {
+ this.panning = { pointer: { x: local.x, y: local.y }, panX: this.view.panX, panY: this.view.panY };
+ });
e.stopPropagation();
return;
}
@@ -160,7 +161,9 @@ export abstract class SheetCanvas extends Widget {
this.move({ x: local.x, y: local.y }, e);
return;
}
- this.panTo(panning.panX + (local.x - panning.pointer.x), panning.panY + (local.y - panning.pointer.y));
+ this.tween.carry(() =>
+ this.panTo(panning.panX + (local.x - panning.pointer.x), panning.panY + (local.y - panning.pointer.y)),
+ );
this.panned();
}
diff --git a/src/editor/viewTween.ts b/src/editor/viewTween.ts
index 23058227..8cd7ca88 100644
--- a/src/editor/viewTween.ts
+++ b/src/editor/viewTween.ts
@@ -13,8 +13,60 @@ export interface ViewMap {
/** Quick at first and gentle at the end: the zoom answers the wheel at once and settles softly. */
export function easeOut(t: number): number {
+ return glide(t, 3);
+}
+
+/** The steepest start `glide` takes without running past its end. */
+const STEEPEST = 4;
+
+/**
+ * The share of the way at `t`, setting off at `speed` (shares per duration) and settling with
+ * neither speed nor pull left at the end. At 3 it is the cubic ease-out, at 0 it starts at rest.
+ */
+export function glide(t: number, speed: number): number {
const rest = 1 - Math.min(1, Math.max(0, t));
- return 1 - rest * rest * rest;
+ return 1 - (4 - speed) * rest ** 3 - (speed - 3) * rest ** 4;
+}
+
+/** How fast `glide` moves at `t`, in shares per duration. */
+export function glideSlope(t: number, speed: number): number {
+ const rest = 1 - Math.min(1, Math.max(0, t));
+ return 3 * (4 - speed) * rest ** 2 + 4 * (speed - 3) * rest ** 3;
+}
+
+/**
+ * The speed a new way from `from` to `to`, `ms` long, sets off at to go on as fast as the old one
+ * moved: `rate` shares of `old` per ms, at share `at`. Along the scale where the new way zooms,
+ * along its line where it slides. Moving against the new way starts it at rest.
+ */
+export function carriedSpeed(
+ old: { from: ViewMap; to: ViewMap },
+ at: number,
+ rate: number,
+ from: ViewMap,
+ to: ViewMap,
+ ms: number,
+): number {
+ const h = at < 0.5 ? 1e-4 : -1e-4;
+ const a = tweenView(old.from, old.to, at);
+ const b = tweenView(old.from, old.to, at + h);
+ const per = (p: number, q: number) => ((q - p) / h) * rate * ms;
+ const zoom = Math.log(to.scale / from.scale);
+ let speed: number;
+ if (Math.abs(zoom) > 1e-9) speed = per(Math.log(a.scale), Math.log(b.scale)) / zoom;
+ else {
+ const dx = to.x - from.x;
+ const dy = to.y - from.y;
+ const length = dx * dx + dy * dy;
+ speed = length > 0 ? (per(a.x, b.x) * dx + per(a.y, b.y) * dy) / length : 0;
+ }
+ return Number.isFinite(speed) ? Math.max(0, speed) : 0;
+}
+
+/** The move that takes the screen of view `a` to that of `b`, applied to `view`. */
+function moved(view: ViewMap, a: ViewMap, b: ViewMap): ViewMap {
+ const k = b.scale / a.scale;
+ return { scale: view.scale * k, x: b.x + (view.x - a.x) * k, y: b.y + (view.y - a.y) * k };
}
/**
@@ -67,11 +119,19 @@ const tickerClock: TweenClock = {
* 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.
+ * A pan made through `carry` moves the transition along with it. Changing the view by other means –
+ * another document, a resize, a jump – 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 run: {
+ from: ViewMap;
+ to: ViewMap;
+ start: number;
+ ms: number;
+ speed: number;
+ left: ViewMap;
+ } | null = null;
private readonly tick = () => this.step();
constructor(
@@ -93,7 +153,7 @@ export class ViewTween {
/**
* 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.
+ * while the way to the new view starts from what is on screen, as fast as the screen moved.
*/
change(change: () => void): void {
const shown = this.view.read();
@@ -101,7 +161,7 @@ export class ViewTween {
if (running) this.view.write(running.to);
change();
const to = this.view.read();
- const ms = this.duration();
+ let ms = this.duration();
if (ms <= 0 || near(shown, to)) {
this.stop();
if (running) this.view.draw();
@@ -111,18 +171,60 @@ export class ViewTween {
this.view.write(shown);
return;
}
- if (!running) this.clock.add(this.tick);
+ const now = this.clock.now();
+ let speed = 3;
+ if (running) {
+ const t = Math.min(1, (now - running.start) / running.ms);
+ const rate = glideSlope(t, running.speed) / running.ms;
+ speed = carriedSpeed(running, glide(t, running.speed), rate, shown, to, ms);
+ // Faster than the rest of the way allows without running past it: get there sooner instead.
+ if (speed > STEEPEST) {
+ ms *= STEEPEST / speed;
+ speed = STEEPEST;
+ }
+ } else 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() };
+ this.run = { from: shown, to, start: now, ms, speed, 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();
+ /**
+ * Runs `fn` on the view a transition is heading for rather than the one on screen – where a pan
+ * takes its hold, so a pan made through `carry` moves on from there.
+ */
+ target(fn: () => void): void {
+ const run = this.run;
+ if (!run) {
+ fn();
+ return;
+ }
+ const shown = this.view.read();
+ this.view.write(run.to);
+ fn();
+ this.view.write(shown);
+ }
+
+ /**
+ * Carries out a pan during a transition without ending it: `fn` moves the view the transition is
+ * heading for, and the view on screen and the rest of the way move with it.
+ */
+ carry(fn: () => void): void {
+ const run = this.run;
+ if (!run) {
+ fn();
+ return;
+ }
+ const shown = this.view.read();
+ this.view.write(run.to);
+ fn();
+ const to = this.view.read();
+ const left = moved(shown, run.to, to);
+ run.from = moved(run.from, run.to, to);
+ run.to = to;
+ this.view.write(left);
+ run.left = this.view.read();
+ requestRender();
}
/** Puts the view where the transition was heading, without drawing – before it is saved. */
@@ -141,7 +243,7 @@ export class ViewTween {
return;
}
const t = (this.clock.now() - run.start) / run.ms;
- this.view.write(t >= 1 ? run.to : tweenView(run.from, run.to, easeOut(t)));
+ this.view.write(t >= 1 ? run.to : tweenView(run.from, run.to, glide(t, run.speed)));
run.left = this.view.read();
if (t >= 1) this.stop();
this.view.draw();
diff --git a/src/editor/viewport/FreeViewport.ts b/src/editor/viewport/FreeViewport.ts
index 2fa0f418..3a76740f 100644
--- a/src/editor/viewport/FreeViewport.ts
+++ b/src/editor/viewport/FreeViewport.ts
@@ -133,17 +133,21 @@ export abstract class FreeViewport extends WorldViewport {
}
protected startPan(e: FederatedPointerEvent): void {
- this.tween.finish();
- this.panning = { startX: e.global.x, startY: e.global.y, viewX: this.view.x, viewY: this.view.y };
+ this.tween.target(() => {
+ this.panning = { startX: e.global.x, startY: e.global.y, viewX: this.view.x, viewY: this.view.y };
+ });
e.stopPropagation();
}
/** Follows the pointer while a pan is on; false when there is none, so the move is someone else's. */
protected panTo(e: FederatedPointerEvent): boolean {
- if (!this.panning) return false;
- const scale = this.worldScale();
- this.view.x = this.panning.viewX - (e.global.x - this.panning.startX) / scale;
- this.view.y = this.panning.viewY - (e.global.y - this.panning.startY) / scale;
+ const { panning } = this;
+ if (!panning) return false;
+ this.tween.carry(() => {
+ const scale = this.worldScale();
+ this.view.x = panning.viewX - (e.global.x - panning.startX) / scale;
+ this.view.y = panning.viewY - (e.global.y - panning.startY) / scale;
+ });
this.moveCamera();
return true;
}
diff --git a/tests/editor/view-tween.test.ts b/tests/editor/view-tween.test.ts
index de4032c9..d61e5356 100644
--- a/tests/editor/view-tween.test.ts
+++ b/tests/editor/view-tween.test.ts
@@ -11,7 +11,10 @@ import { zoomedAbout } from '../../src/editor/graph/graphView';
import { localToImage, zoomAbout } from '../../src/editor/texture/sheetView';
import {
axisTween,
+ carriedSpeed,
easeOut,
+ glide,
+ glideSlope,
scrollTween,
tweenView,
type ViewMap,
@@ -178,7 +181,7 @@ describe('the transition of a view', () => {
expect(clock.attached).toBe(0);
});
- it('lands on the target without drawing, and finishes by drawing it', () => {
+ it('lands on the target without drawing', () => {
const clock = fakeClock();
const { s, target } = surface();
const tween = new ViewTween(target, () => 200, clock);
@@ -188,12 +191,82 @@ describe('the transition of a view', () => {
tween.land();
expect(s.view.scale).toBe(2);
expect(s.draws).toBe(draws);
- tween.change(double(s));
- tween.finish();
- expect(s.view.scale).toBe(4);
- expect(s.draws).toBe(draws + 1);
expect(clock.attached).toBe(0);
});
+
+ /** How fast the scale moves, in ratio per ms, over the next `ms`. */
+ const pace = (s: { view: ViewMap }, clock: ReturnType, ms: number) => {
+ const before = s.view.scale;
+ clock.advance(ms);
+ return Math.log(s.view.scale / before) / ms;
+ };
+
+ it('goes on at the pace it had when a quick step comes in, instead of starting over', () => {
+ const clock = fakeClock();
+ const { s, target } = surface();
+ const tween = new ViewTween(target, () => 200, clock);
+ tween.change(double(s));
+ clock.advance(48);
+ const before = pace(s, clock, 2);
+ tween.change(double(s));
+ const after = pace(s, clock, 2);
+ expect(after / before).toBeGreaterThan(0.9);
+ expect(after / before).toBeLessThan(1.1);
+ clock.advance(200);
+ expect(s.view.scale).toBe(4);
+ });
+
+ it('pans along during a transition: the rest of the way moves with the pan, and it lands moved', () => {
+ const clock = fakeClock();
+ const { s, target } = surface();
+ const tween = new ViewTween(target, () => 200, clock);
+ tween.change(double(s));
+ clock.advance(60);
+ let hold = { x: 0, y: 0 };
+ tween.target(() => (hold = { x: s.view.x, y: s.view.y }));
+ expect(hold).toEqual({ x: 0, y: 0 });
+ const shown = { ...s.view };
+ tween.carry(() => (s.view = { ...s.view, x: hold.x + 30, y: hold.y - 10 }));
+ expect(s.view).toEqual({ ...shown, x: shown.x + 30, y: shown.y - 10 });
+ expect(tween.moving).toBe(true);
+ clock.advance(16);
+ expect(tween.moving).toBe(true);
+ expect(s.view.scale).toBeGreaterThan(shown.scale);
+ clock.advance(200);
+ expect(s.view).toEqual({ scale: 2, x: 30, y: -10 });
+ });
+});
+
+describe('the curve of a transition', () => {
+ it('starts at the speed it is given and settles at the end', () => {
+ for (const speed of [0, 1.5, 3, 4]) {
+ expect(glide(0, speed)).toBe(0);
+ expect(glide(1, speed)).toBe(1);
+ expect(glideSlope(0, speed)).toBeCloseTo(speed, 12);
+ expect(glideSlope(1, speed)).toBe(0);
+ expect((glide(1e-6, speed) - glide(0, speed)) / 1e-6).toBeCloseTo(speed, 4);
+ }
+ });
+
+ it('never runs back or past its end up to the steepest start', () => {
+ for (const speed of [0, 2, 4]) {
+ let last = 0;
+ for (let i = 1; i <= 100; i++) {
+ const share = glide(i / 100, speed);
+ expect(share).toBeGreaterThanOrEqual(last);
+ expect(share).toBeLessThanOrEqual(1);
+ last = share;
+ }
+ }
+ });
+
+ it('carries the pace of a slide over to a longer slide the same way, and starts at rest against it', () => {
+ const old = { from: { scale: 1, x: 0, y: 0 }, to: { scale: 1, x: 100, y: 0 } };
+ const from = tweenView(old.from, old.to, 0.5);
+ // 0.01 of 100 px per ms is 1 px/ms; over 200 ms on a way of 150 px that is a speed of 4/3.
+ expect(carriedSpeed(old, 0.5, 0.01, from, { scale: 1, x: 200, y: 0 }, 200)).toBeCloseTo(4 / 3, 6);
+ expect(carriedSpeed(old, 0.5, 0.01, from, { scale: 1, x: -50, y: 0 }, 200)).toBe(0);
+ });
});
describe('a view whose axes zoom apart (#377)', () => {
diff --git a/tools/e2e/46-topbar.mjs b/tools/e2e/46-topbar.mjs
index 2458f768..7dcecde5 100644
--- a/tools/e2e/46-topbar.mjs
+++ b/tools/e2e/46-topbar.mjs
@@ -471,7 +471,28 @@ export async function topbar(t) {
samePoint(landed.world, start.world),
JSON.stringify({ jumped, landed }),
);
- // Handed back as found: no transition for the steps after this one, and the zoom it had.
+ // A pan during the transition takes it along instead of jumping to its end.
+ const by = { x: 40, y: 30 };
+ const moved = { x: at.x + by.x, y: at.y + by.y };
+ await t.mouse('mouseMoved', at.x, at.y);
+ await t.mouse('mouseWheel', at.x, at.y, 'none', { deltaX: 0, deltaY: -100 });
+ await t.drag(at.x, at.y, moved.x, moved.y, { steps: 4, button: 'middle' });
+ const panned = await json('__gm.sceneView()');
+ await waitUntil('!__gm.sceneView().moving', { label: 'the zoom transition ended after the pan' });
+ const settled = await json('__gm.sceneView()');
+ const heldAfter = await json(`__gm.sceneWorldAt(${moved.x}, ${moved.y})`);
+ check(
+ 'a pan during the transition carries it on: still on its way, then landed one step further, moved by the pan',
+ panned.moving === true &&
+ panned.zoom > landed.zoom &&
+ panned.zoom < settled.zoom &&
+ settled.zoom > landed.zoom &&
+ samePoint(heldAfter, start.world),
+ JSON.stringify({ landed, panned, settled, heldAfter, world: start.world }),
+ );
+ // Handed back as found: no transition for the steps after this one, and the view it had.
await t.evaluate('__gm.zoomTransition(0)');
+ await t.drag(moved.x, moved.y, at.x, at.y, { steps: 4, button: 'middle' });
+ await t.wheel(at.x, at.y, 100);
await t.wheel(at.x, at.y, 100);
}