Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 139 additions & 42 deletions src/component/slider/SliderComponent.ts

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/component/slider/SliderGLRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,10 @@ export class SliderGLRenderer {
public render(
perspectiveCamera: THREE.PerspectiveCamera,
renderer: THREE.WebGLRenderer): void {
// The comparison owns the background: never composite it over another
// background renderer still active in the same frame (e.g. the plain
// image while it hands over), which shows a mix of both views.
renderer.clear();
if (!this.disabled) {
renderer.render(
this._scene.sceneOld,
Expand Down
8 changes: 8 additions & 0 deletions src/state/StateService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,14 @@ export class StateService {
return this._currentState$;
}

/**
* The latest animation frame, replayed to new subscribers. Its state is
* the live context, so reading it never waits for the next frame.
*/
public get lastState$(): Observable<AnimationFrame> {
return this._lastState$;
}

public get currentImage$(): Observable<Image> {
return this._currentImage$;
}
Expand Down
6 changes: 4 additions & 2 deletions src/tile/TextureProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,8 @@ export class TextureProvider {
* @param {TileRegionOfInterest} roi - Spatial edges to cache.
*/
public setRegionOfInterest(roi: TileRegionOfInterest): void {
if (!verifySize(this._size)) { return; }
// A disposed provider has no renderer; late requests must not fetch.
if (this._disposed || !verifySize(this._size)) { return; }

const virtualWidth = 1 / roi.pixelWidth;
const virtualHeight = 1 / roi.pixelHeight;
Expand Down Expand Up @@ -303,6 +304,7 @@ export class TextureProvider {

const subscription = tile$.subscribe(
(image: HTMLImageElement): void => {
if (this._disposed) { return; }
const pixels = tileToPixelCoords2D(
tile,
this._size,
Expand Down Expand Up @@ -358,7 +360,7 @@ export class TextureProvider {

const subscription = urls$.subscribe(
(): void => {
if (level !== this._level.z) { return; }
if (this._disposed || level !== this._level.z) { return; }
for (const tile of tiles) {
const ent: ImageTileEnt = {
x: tile.x,
Expand Down
22 changes: 18 additions & 4 deletions test/component/slider/SliderAutomaticMotion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ describe("Slider automatic motion safety", () => {
s.dispose();
});
}
describe("relaxed policy", () => {
describe("relaxed policy (prototype)", () => {
function relaxed(): any {
const f = fixture();
f.configuration.automaticMotionPolicy = "relaxed";
Expand Down Expand Up @@ -436,7 +436,7 @@ describe("Slider automatic rendering", () => {
for (const position of [0, 0.25, 0.5, 0.75, 1]) {
r.updateCurtain(position, true);
const render = jasmine.createSpy("render");
r.render(f.camera, { render });
r.render(f.camera, <any>{ render, clear: jasmine.createSpy("clear") });
const cameras = render.calls.allArgs().map(args => args[1]);
expect(cameras.length).toBe(2);
cameras.forEach(camera => {
Expand Down Expand Up @@ -761,11 +761,25 @@ describe("Slider automatic shared decision and interactive state", () => {
expect(f.navigator.stateService.wait).not.toHaveBeenCalled();
f.component.deactivate();
});
it("never enables a stale pair and mode button cannot override an unsafe rejection", () => {
it("keeps the shown pair's checked decision while a new pair loads, then checks the new pair", () => {
const f = componentFixture(); jest.advanceTimersByTime(100);
expect(f.update.calls.mostRecent().args[1]).toBe(Mode.Motion);
const covered = spyOn(SliderAutomaticMotion.prototype as any, "_covered").and.callThrough();
// Selecting a pair that is still loading must not re-render the shown pair differently.
f.component.configure({ ids: { background: "replacement", foreground: "current" } });
f.emitFrame();
expect(f.update.calls.mostRecent().args[1]).toBe(Mode.Motion);
expect(covered).not.toHaveBeenCalled();
// The new pair is never treated as checked: it is evaluated once shown.
const replacement = image("replacement");
f.state.previousImage = replacement;
f.state.previousTransform = transform(replacement, 0);
f.state.trajectory = [replacement, f.state.currentImage];
f.emitFrame();
expect(covered).toHaveBeenCalled();
f.state.currentImage.mergeId = "different-component";
f.emitFrame();
expect(f.update.calls.mostRecent().args[1]).toBe(Mode.Stationary);
expect(f.dom.calls.mostRecent().args[2]).toBe(true);
(f.component as any)._domRenderer._notifyModeChanged$.next(Mode.Motion);
expect(f.update.calls.mostRecent().args[1]).toBe(Mode.Stationary);
f.component.deactivate();
Expand Down
15 changes: 15 additions & 0 deletions test/component/slider/SliderExclusiveBackground.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import * as THREE from "three";
import { SliderGLRenderer } from "../../../src/component/slider/SliderGLRenderer";

describe("SliderGLRenderer background ownership", () => {
it("clears earlier background renders before drawing the comparison", () => {
const calls: string[] = [];
const renderer = <THREE.WebGLRenderer><unknown>{
clear: () => calls.push("clear"),
render: () => calls.push("render"),
};
new SliderGLRenderer().render(new THREE.PerspectiveCamera(), renderer);
expect(calls[0]).toBe("clear");
expect(calls.slice(1).every(call => call === "render")).toBe(true);
});
});
79 changes: 79 additions & 0 deletions test/component/slider/SliderPairConfiguration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { Subject } from "rxjs";
import { SliderComponent, sliderShowsPair } from "../../../src/component/slider/SliderComponent";
import { SliderDOMRenderer } from "../../../src/component/slider/SliderDOMRenderer";
import { SliderGLRenderer } from "../../../src/component/slider/SliderGLRenderer";
import { ContainerMockCreator } from "../../helper/ContainerMockCreator";
import { NavigatorMockCreator } from "../../helper/NavigatorMockCreator";
import { MockCreator } from "../../helper/MockCreator";

describe("SliderComponent pair configuration", () => {
const image = (id: string, cameraType: string) => <any>{ id, cameraType };
function frame(foreground: any, background: any): any {
return { state: { currentImage: foreground, previousImage: background, motionless: true } };
}
function fixture(): any {
const container = new ContainerMockCreator().create();
const navigator = new NavigatorMockCreator().create();
new MockCreator().mockProperty(container, "domContainer", document.createElement("div"));
(<jasmine.Spy>navigator.graphService.cacheImage$).and.returnValue(new Subject());
const render = spyOn(SliderDOMRenderer.prototype, "render").and.returnValue(<any>{});
const update = spyOn(SliderGLRenderer.prototype, "update");
const component = new SliderComponent("slider", container, navigator);
const original = image("original", "spherical");
const photo = image("photo", "perspective");
component.configure({ initialPosition: 0.5, viewAligned: true, ids: { foreground: "original", background: "photo" } });
component.activate();
(<Subject<any>>container.renderService.size$).next({ width: 1440, height: 900 });
const state$ = <Subject<any>>navigator.stateService.currentState$;
state$.next(frame(original, photo));
return { component, render, update, state$, original, photo };
}

it("keeps the shown pair's options while the selected pair loads", () => {
const f = fixture();
expect(f.render.calls.mostRecent().args[4]).toBe(true);
// A 360° comparison: not view aligned, which would hide the curtain
// for the shown 360°-to-photo pair if applied before it loads.
f.component.configure({ viewAligned: false, ids: { foreground: "original", background: "pano" } });
f.state$.next(frame(f.original, f.photo));
expect(f.render.calls.mostRecent().args[4]).toBe(true);
expect(f.update.calls.mostRecent().args[3]).toBe(true);
f.state$.next(frame(f.original, image("pano", "spherical")));
expect(f.render.calls.mostRecent().args[4]).toBe(true);
expect(f.update.calls.mostRecent().args[3]).toBe(false);
});

it("applies options for the shown pair immediately", () => {
const f = fixture();
f.component.configure({ sliderVisible: false });
expect(f.render.calls.mostRecent().args[4]).toBe(false);
});

it("applies the configured curtain before the first frame of a new activation renders", () => {
const container = new ContainerMockCreator().create();
const navigator = new NavigatorMockCreator().create();
new MockCreator().mockProperty(container, "domContainer", document.createElement("div"));
(<jasmine.Spy>navigator.graphService.cacheImage$).and.returnValue(new Subject());
spyOn(SliderDOMRenderer.prototype, "render").and.returnValue(<any>{});
const order: any[] = [];
spyOn(SliderGLRenderer.prototype, "update").and.callFake(() => order.push("update"));
spyOn(SliderGLRenderer.prototype, "updateCurtain").and.callFake((c: number, s: boolean) => order.push([c, s]));
const component = new SliderComponent("slider", container, navigator);
component.configure({ initialPosition: 0.3, screenSpace: true, viewAligned: true,
ids: { foreground: "original", background: "photo" } });
component.activate();
(<Subject<any>>navigator.stateService.currentState$).next(
frame(image("original", "spherical"), image("photo", "spherical")));
const first = order.indexOf("update");
expect(first).toBeGreaterThan(0);
expect(order.slice(0, first)).toContainEqual([0.3, true]);
component.deactivate();
});
it("matches only the configured foreground and background", () => {
const ids = { foreground: "a", background: "b" };
expect(sliderShowsPair({}, <any>{})).toBe(true);
expect(sliderShowsPair({ ids }, <any>{ currentImage: { id: "a" }, previousImage: { id: "b" } })).toBe(true);
expect(sliderShowsPair({ ids }, <any>{ currentImage: { id: "b" }, previousImage: { id: "a" } })).toBe(false);
expect(sliderShowsPair({ ids }, <any>{ currentImage: { id: "a" }, previousImage: null })).toBe(false);
});
});
54 changes: 54 additions & 0 deletions test/component/slider/SliderPreload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { of as observableOf, Subject, throwError } from "rxjs";
import { SliderComponent } from "../../../src/component/slider/SliderComponent";
import { ContainerMockCreator } from "../../helper/ContainerMockCreator";
import { NavigatorMockCreator } from "../../helper/NavigatorMockCreator";

describe("SliderComponent.preload", () => {
function fixture(): any {
const container = new ContainerMockCreator().create();
const navigator = new NavigatorMockCreator().create();
const component = new SliderComponent("slider", container, navigator);
return { component, navigator };
}

it("caches both images without activating or configuring the slider", async () => {
const f = fixture();
const cache = (<jasmine.Spy>f.navigator.graphService.cacheImage$)
.and.callFake((id: string) => observableOf(<any>{ id }));
const configure = spyOn(f.component, "configure").and.callThrough();
await f.component.preload({ background: "bg", foreground: "fg" });
expect(cache.calls.allArgs()).toEqual([["bg"], ["fg"]]);
expect(configure).not.toHaveBeenCalled();
expect(f.component.activated).toBe(false);
});

it("resolves only after both images are cached", async () => {
const f = fixture();
const background = new Subject<any>();
const foreground = new Subject<any>();
(<jasmine.Spy>f.navigator.graphService.cacheImage$)
.and.callFake((id: string) => id === "bg" ? background : foreground);
let resolved = false;
const done = f.component.preload({ background: "bg", foreground: "fg" }).then(() => resolved = true);
foreground.next({ id: "fg" });
await Promise.resolve();
expect(resolved).toBe(false);
background.next({ id: "bg" });
await done;
expect(resolved).toBe(true);
});

it("rejects when an image fails to cache", async () => {
const f = fixture();
(<jasmine.Spy>f.navigator.graphService.cacheImage$)
.and.callFake((id: string) => id === "bg" ? throwError(new Error("missing")) : observableOf(<any>{ id }));
await expect(f.component.preload({ background: "bg", foreground: "fg" })).rejects.toThrow("missing");
});

it("rejects an incomplete pair without caching", async () => {
const f = fixture();
const cache = (<jasmine.Spy>f.navigator.graphService.cacheImage$);
await expect(f.component.preload(<any>{ background: "bg" })).rejects.toThrow();
expect(cache).not.toHaveBeenCalled();
});
});
13 changes: 10 additions & 3 deletions test/component/slider/SliderSelectionFraming.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,17 +52,24 @@ describe("Slider comparison selection framing", () => {
expect(f.navigator.stateService.setZoom).not.toHaveBeenCalled();
f.component.deactivate();
});
it("does not drop synchronously cached pairs before a new state frame arrives", () => {
it("switches a synchronously cached pair before the next state frame renders", () => {
const f = fixture();
const cache = <jasmine.Spy><any>f.component._catchCacheImage$;
cache.and.callFake(id => of(id === "new" ? { id: "new" } : f.original));
f.component.configure({ ids: { foreground: "original", background: "new" }, viewAligned: true });
expect(f.navigator.stateService.setImages).not.toHaveBeenCalled();
f.frame();
expect(f.navigator.stateService.setImages).toHaveBeenCalledTimes(2);
expect(f.navigator.stateService.setCenter).toHaveBeenCalledWith([0.51, 0.49]);
f.component.deactivate();
});
it("leaves traversal before the trajectory changes so the first frame is not animated", () => {
const f = fixture(true, true);
const order: string[] = [];
f.navigator.stateService.waitInteractively.and.callFake(() => order.push("waitInteractively"));
f.navigator.stateService.prependImages.and.callFake(() => order.push("prepend"));
f.background$.next({ id: "new" }); f.foreground$.next(f.original);
expect(order).toEqual(["waitInteractively", "prepend"]);
f.component.deactivate();
});
it("does not change legacy non-view-aligned pair handling", () => {
const f = fixture(false);
f.background$.next({ id: "new" }); f.foreground$.next(f.original); f.frame();
Expand Down
2 changes: 1 addition & 1 deletion test/component/slider/SliderViewAligned.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ describe("Slider opt-in common-world view", () => {
r.update(frame(), SliderConfigurationMode.Stationary, false, true);
const live = liveCamera();
const render = jasmine.createSpy("render");
r.render(live, { render });
r.render(live, <any>{ render, clear: jasmine.createSpy("clear") });
const calls = render.calls.allArgs();
expect(calls[0][0]).toBe(r._scene.sceneOld);
expectSameView(calls[0][1], live);
Expand Down
8 changes: 6 additions & 2 deletions test/helper/StateServiceMockCreator.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Subject } from "rxjs";
import { ReplaySubject, Subject } from "rxjs";

import { MockCreator } from "./MockCreator";
import { MockCreatorBase } from "./MockCreatorBase";
Expand All @@ -18,7 +18,11 @@ export class StateServiceMockCreator extends MockCreatorBase<StateService> {
this._mockProperty(mock, "currentId$", new Subject<Image>());
this._mockProperty(mock, "currentImage$", new Subject<Image>());
this._mockProperty(mock, "currentImageExternal$", new Subject<Image>());
this._mockProperty(mock, "currentState$", new Subject<AnimationFrame>());
const currentState$ = new Subject<AnimationFrame>();
const lastState$ = new ReplaySubject<AnimationFrame>(1);
currentState$.subscribe(lastState$);
this._mockProperty(mock, "currentState$", currentState$);
this._mockProperty(mock, "lastState$", lastState$);
this._mockProperty(mock, "currentTransform$", new Subject<Transform>());
this._mockProperty(mock, "inMotion$", new Subject<boolean>());
this._mockProperty(mock, "inTranslation$", new Subject<boolean>());
Expand Down
52 changes: 52 additions & 0 deletions test/tile/TextureProviderDisposed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import * as THREE from "three";
import { Subject } from "rxjs";

import { TextureProvider } from "../../src/tile/TextureProvider";
import { TileLoader } from "../../src/tile/TileLoader";
import { TileStore } from "../../src/tile/TileStore";
import { TILE_SIZE } from "../../src/tile/interfaces/TileTypes";
import { ImageTileEnt } from "../../src/api/ents/ImageTileEnt";
import { MockCreator } from "../helper/MockCreator";
import { RendererMock } from "../helper/WebGLRenderer";

describe("TextureProvider after dispose", () => {
function fixture(): any {
spyOn(console, "warn").and.stub();
const mockCreator = new MockCreator();
const loader = mockCreator.create(TileLoader, "TileLoader");
const store = mockCreator.create(TileStore, "TileStore");
const renderer = <THREE.WebGLRenderer><unknown>new RendererMock();
spyOn(renderer, "getContext").and.returnValue(
<WebGLRenderingContext><unknown>{ getParameter: () => 2048 });
const width = TILE_SIZE + 1;
const height = TILE_SIZE;
const provider = new TextureProvider("image-id", width, height, new Image(), loader, store, renderer);
const roi = {
bbox: { maxX: 0.75, maxY: 0.75, minX: 0.25, minY: 0.25 },
pixelHeight: 1 / height,
pixelWidth: 1 / width,
};
return { loader, store, provider, roi };
}

it("ignores region of interest updates", () => {
const f = fixture();
f.provider.dispose();
f.provider.setRegionOfInterest(f.roi);
expect(<jasmine.Spy>f.loader.getURLs$).not.toHaveBeenCalled();
});

it("never renders a tile that arrives after dispose", () => {
const f = fixture();
const urls = new Subject<ImageTileEnt[]>();
const image = new Subject<HTMLImageElement>();
(<jasmine.Spy>f.loader.getURLs$).and.returnValue(urls);
(<jasmine.Spy>f.loader.getImage$).and.returnValue([image, (): void => { /* noop */ }]);
(<jasmine.Spy>f.store.getURL).and.returnValue("url");
f.provider.setRegionOfInterest(f.roi);
urls.next([{ url: "url", x: 0, y: 0, z: 11 }]);
f.provider.dispose();
expect(() => image.next(new Image())).not.toThrow();
expect(<jasmine.Spy>f.store.add).not.toHaveBeenCalled();
});
});
Loading