From 2cea3e4fa9d0c141e57941027d5858310a0cab83 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Mon, 2 Nov 2026 08:39:36 +0100 Subject: [PATCH 01/16] Keep cone bearing stable during sequence steps --- .../reorientation/ReorientationComponent.ts | 20 +++++++++- .../ReorientationNavigation.test.ts | 37 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 test/component/reorientation/ReorientationNavigation.test.ts diff --git a/src/component/reorientation/ReorientationComponent.ts b/src/component/reorientation/ReorientationComponent.ts index f7c5a7888..dd4da8e3e 100644 --- a/src/component/reorientation/ReorientationComponent.ts +++ b/src/component/reorientation/ReorientationComponent.ts @@ -65,6 +65,20 @@ export function isLevelPlausible( Math.abs(pitchDeg - baselinePitchDeg) <= MAX_LEVEL_PITCH_DELTA_DEG; } +// getCenter can still describe an interpolated camera when async preparation +// resolves. Sequence steps carry the incoming bearing, so use its snapshot. +export function settledViewX( + direction: NavigationDirection, + incomingBearing: number, + compassAngle: number, + sampledX: number): number { + const sequenceStep = direction === NavigationDirection.Next || + direction === NavigationDirection.Prev; + return sequenceStep && Number.isFinite(incomingBearing) && + Number.isFinite(compassAngle) ? + bearingToBasicX(incomingBearing, compassAngle) : sampledX; +} + /** * The heading an image ended up being shown at, once reorientation has decided * whether to turn it. Emitted on {@link ReorientationComponent.settled$}. @@ -662,7 +676,11 @@ export class ReorientationComponent // host may not have applied them yet, and steering there // ourselves would animate a rotation the user did not // ask for. Equal to targetX, so this image never moves. - let viewX = center[0]; + let viewX = settledViewX( + direction, + this._incomingBearing, + result.cca, + center[0]); if (this._adoptedView != null) { const adopted = this._adoptedView; this._clearAdoptedView(); diff --git a/test/component/reorientation/ReorientationNavigation.test.ts b/test/component/reorientation/ReorientationNavigation.test.ts new file mode 100644 index 000000000..82bd472a8 --- /dev/null +++ b/test/component/reorientation/ReorientationNavigation.test.ts @@ -0,0 +1,37 @@ +import { NavigationDirection } from "../../../src/graph/edge/NavigationDirection"; +import { settledViewX } + from "../../../src/component/reorientation/ReorientationComponent"; + +describe("settledViewX", () => { + it("uses the incoming bearing for a previous sequence step", () => { + expect(settledViewX( + NavigationDirection.Prev, + 104.328, + 336.612, + 0.8366)).toBeCloseTo(0.8548, 4); + }); + + it("uses the incoming bearing for a next sequence step", () => { + expect(settledViewX( + NavigationDirection.Next, + 12, + 300, + 0.25)).toBeCloseTo(0.7, 6); + }); + + it("keeps the sampled view for spatial navigation", () => { + expect(settledViewX( + NavigationDirection.StepForward, + 104.328, + 336.612, + 0.8366)).toBe(0.8366); + }); + + it("keeps the sampled view when no incoming bearing is available", () => { + expect(settledViewX( + NavigationDirection.Prev, + NaN, + 336.612, + 0.8366)).toBe(0.8366); + }); +}); From 4503c474b3ce61a60126f7b828f73949d3f6e5f6 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Mon, 2 Nov 2026 08:39:36 +0100 Subject: [PATCH 02/16] Add tooltip to bearing component --- src/component/bearing/BearingComponent.ts | 6 +++++- styles/tooltip.css | 13 ++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/component/bearing/BearingComponent.ts b/src/component/bearing/BearingComponent.ts index 212ca65ca..4ddb82b2d 100644 --- a/src/component/bearing/BearingComponent.ts +++ b/src/component/bearing/BearingComponent.ts @@ -30,6 +30,7 @@ import { ViewportSize } from "../../render/interfaces/ViewportSize"; import { VirtualNodeHash } from "../../render/interfaces/VirtualNodeHash"; import { AnimationFrame } from "../../state/interfaces/AnimationFrame"; import { ComponentSize } from "../util/ComponentSize"; +import { createTooltipProperties } from "../util/Tooltip"; import { Container } from "../../viewer/Container"; import { Navigator } from "../../viewer/Navigator"; import { isSpherical } from "../../geo/Geo"; @@ -276,7 +277,10 @@ export class BearingComponent extends Component { name: this._name, vNode: vd.h( "div.mapillary-bearing-indicator-container" + compact, - { oncontextmenu: (event: MouseEvent): void => { event.preventDefault(); } }, + createTooltipProperties( + "Bearing", + { oncontextmenu: (event: MouseEvent): void => { event.preventDefault(); } }, + "left"), [ background, fovIndicator, diff --git a/styles/tooltip.css b/styles/tooltip.css index 131eb3ca9..e3f860c93 100644 --- a/styles/tooltip.css +++ b/styles/tooltip.css @@ -1,4 +1,4 @@ -.mapillary-viewer [data-mapillary-tooltip] { +.mapillary-viewer [data-mapillary-tooltip]:not(.mapillary-bearing-indicator-container) { position: relative; } @@ -56,6 +56,17 @@ top: calc(50% - 4px); } +/* The compact bearing scales its pseudo-elements too; invert that scale so + * its tooltip remains the same size as the other controls. */ +.mapillary-viewer .mapillary-bearing-compact[data-mapillary-tooltip-position="left"]::before { + transform: translateY(-50%) scale(1.6666667); + transform-origin: right center; +} + +.mapillary-viewer .mapillary-bearing-compact[data-mapillary-tooltip-position="left"]::after { + transform: rotate(45deg) scale(1.6666667); +} + @media (hover: hover) { .mapillary-viewer [data-mapillary-tooltip]:hover:not([data-mapillary-tooltip-dismissed])::before, .mapillary-viewer [data-mapillary-tooltip]:hover:not([data-mapillary-tooltip-dismissed])::after { From ce758f2ca71115d7072afff1178ae1d244246c01 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Mon, 2 Nov 2026 08:39:37 +0100 Subject: [PATCH 03/16] Reuse bearing tooltip properties across renders Create the bearing indicator's tooltip properties once instead of on every render so the virtual DOM does not rebuild the tooltip each frame. --- src/component/bearing/BearingComponent.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/component/bearing/BearingComponent.ts b/src/component/bearing/BearingComponent.ts index 4ddb82b2d..f648e5595 100644 --- a/src/component/bearing/BearingComponent.ts +++ b/src/component/bearing/BearingComponent.ts @@ -72,6 +72,7 @@ export class BearingComponent extends Component { private _distinctThreshold: number; private _animationSpeed: number; + private _tooltipProperties: vd.createProperties; /** @ignore */ constructor(name: string, container: Container, navigator: Navigator) { @@ -84,6 +85,10 @@ export class BearingComponent extends Component { this._distinctThreshold = Math.PI / 360; this._animationSpeed = 0.075; + this._tooltipProperties = createTooltipProperties( + "Bearing", + { oncontextmenu: (event: MouseEvent): void => { event.preventDefault(); } }, + "left"); } protected _activate(): void { @@ -277,10 +282,7 @@ export class BearingComponent extends Component { name: this._name, vNode: vd.h( "div.mapillary-bearing-indicator-container" + compact, - createTooltipProperties( - "Bearing", - { oncontextmenu: (event: MouseEvent): void => { event.preventDefault(); } }, - "left"), + this._tooltipProperties, [ background, fovIndicator, From 658846c0fba73c7a93652531336cb1462186ce14 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Mon, 2 Nov 2026 08:39:37 +0100 Subject: [PATCH 04/16] Keep horizon leveling at sequence endpoints --- .../reorientation/ReorientationComponent.ts | 73 ++++++++++++++++--- 1 file changed, 62 insertions(+), 11 deletions(-) diff --git a/src/component/reorientation/ReorientationComponent.ts b/src/component/reorientation/ReorientationComponent.ts index dd4da8e3e..6561ac378 100644 --- a/src/component/reorientation/ReorientationComponent.ts +++ b/src/component/reorientation/ReorientationComponent.ts @@ -556,22 +556,73 @@ export class ReorientationComponent } if (!result || !result.valid) { - this._levelAccepted = false; - // Switching out of Gravity can reset a center queued before - // the image loaded, so restore an explicit shared-link view - // after the fallback transition. Keep it pending because an - // endpoint has no travel result from which to derive the - // offset that its first valid same-sequence neighbor needs. + // Travel can be unavailable even when the reconstruction + // still provides a valid gravity frame. const adoptedView = this._adoptedView; - this._navigator.stateService.traverse(); + const fallback = (): void => { + this._levelAccepted = false; + this._navigator.stateService.traverse(); + if (adoptedView != null) { + this._navigator.stateService.setCenter(adoptedView); + } else if (resetView) { + this._navigator.stateService.setCenter([0.5, 0.5]); + } + }; if (resetView) { this._resetOffset(); } - if (adoptedView != null) { - this._navigator.stateService.setCenter(adoptedView); - } else if (resetView) { - this._navigator.stateService.setCenter([0.5, 0.5]); + if (!levelingActive) { + fallback(); + return; } + this._navigator.stateService.getCenter().pipe(first()) + .subscribe((center: number[]): void => { + if (this._activeId !== id || + this._engine !== engine) { + return; + } + const viewX = adoptedView?.[0] ?? settledViewX( + direction, + this._incomingBearing, + image.compassAngle, + center[0]); + const endpointHorizonRow = + this._horizonRow(viewX); + const endpointRollDeg = + endpointHorizonRow == null ? null : + this._rollDeg([ + viewX, + endpointHorizonRow, + ]); + if (endpointHorizonRow == null || + endpointRollDeg == null || + endpointRollDeg > + MAX_REORIENTATION_ROLL_DEG || + !this._acceptLevel( + endpointRollDeg, + endpointHorizonRow)) { + fallback(); + return; + } + this._levelAccepted = true; + this._computedBasicX = viewX; + this._navigator.stateService.gravityTraverse(); + if (adoptedView != null) { + this._navigator.stateService + .setCenter(adoptedView); + return; + } + if (!this._ySeeded) { + this._userOffsetY = resetView ? 0 : + center[1] - this._horizonY(center[0]); + this._ySeeded = true; + } + const targetY = Math.max(0, Math.min( + 1, + endpointHorizonRow + this._userOffsetY)); + this._navigator.stateService + .setCenter([viewX, targetY]); + }); return; } From 5b47763474e9dc1ccb2c5b31d19f147df3bf3f9f Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Mon, 2 Nov 2026 08:39:37 +0100 Subject: [PATCH 05/16] Orient final sequence images from incoming travel --- .../reorientation/ReorientationEngine.ts | 147 +++++++++++++----- .../reorientation/ReorientationEngine.test.ts | 38 ++++- 2 files changed, 141 insertions(+), 44 deletions(-) diff --git a/src/component/reorientation/ReorientationEngine.ts b/src/component/reorientation/ReorientationEngine.ts index 26a255e81..748ea2d6d 100644 --- a/src/component/reorientation/ReorientationEngine.ts +++ b/src/component/reorientation/ReorientationEngine.ts @@ -144,10 +144,18 @@ interface Segment { originalTravel?: number; } +interface CompassFrame { + viewCompassAngle: number; + computedCompassOutlier: boolean; + reconstructionDiscontinuity: boolean; + computedCompassOffset?: number; +} + /** * Resolves, for an image in a sequence, the basic-x that frames the - * direction of travel toward the next image. Motion is detected from GPS - * speed with a low-speed-turn allowance, GPS outliers are rejected against + * direction of travel. The final image uses its incoming segment because it + * has no following image. Motion is detected from GPS speed with a + * low-speed-turn allowance, GPS outliers are rejected against * recent bearings, and stationary images fall back to the last confirmed * moving bearing so the view is preserved rather than spun by noise. * @@ -241,8 +249,7 @@ export class ReorientationEngine { return; } if (idx >= ids.length - 1) { - this._invalid(imgId, "End of sequence"); - return; + return this._resolveEndpoint(imgId, cur, ids, idx); } const nextId = ids[idx + 1]; let warm: Promise = Promise.resolve([]); @@ -271,6 +278,45 @@ export class ReorientationEngine { }); } + private _resolveEndpoint( + imgId: string, + cur: ReorientationImage, + ids: string[], + idx: number): Promise { + if (idx === 0) { + this._invalid(imgId, "End of sequence"); + return Promise.resolve(); + } + const prevId = ids[idx - 1]; + return this.precompute(prevId, undefined, 0).then((): void => { + const previous = this._cache.get(prevId); + if (!previous?.valid || !isNum(previous.travel)) { + this._invalid(imgId, "Previous image has no travel direction"); + return; + } + const frame = this._compassFrame( + cur, + previous.travel, + previous.computedCompassOffset, + previous.computedCompassOutlier); + this._cache.set(imgId, { + valid: true, + prevId, + travel: previous.travel, + computedTravel: previous.computedTravel, + basicX: bearingToBasicX( + previous.travel, + frame.viewCompassAngle), + dist: previous.dist, + cca: cur.cca, + ...frame, + speed: previous.speed, + moving: previous.moving, + seq: cur.seq, + }); + }); + } + private _decide( imgId: string, cur: ReorientationImage, @@ -423,53 +469,22 @@ export class ReorientationEngine { } } - const hasCurrentCompassCalibration = - isNum(cur.computedCca) && isNum(cur.originalCca); - const hasNextCompassCalibration = - isNum(nxt.computedCca) && isNum(nxt.originalCca); - const currentCompassOffset = hasCurrentCompassCalibration ? - (cur.computedCca - cur.originalCca + 360) % 360 : 0; - const nextCompassOffset = hasNextCompassCalibration ? - (nxt.computedCca - nxt.originalCca + 360) % 360 : 0; - const reconstructionDiscontinuity = - hasCurrentCompassCalibration && - prev?.compassOffset != null && - angleDelta(currentCompassOffset, prev.compassOffset) > - MAX_COMPUTED_COMPASS_DELTA_DEG; - const continuesRejectedCalibration = - hasCurrentCompassCalibration && - prev?.computedCompassOutlier === true && - prev.compassOffset != null && - angleDelta(currentCompassOffset, prev.compassOffset) <= - MAX_COMPUTED_COMPASS_DELTA_DEG; - const computedCompassOutlier = - hasCurrentCompassCalibration && - angleDelta(cur.computedCca, cur.originalCca) > - MAX_COMPUTED_COMPASS_DELTA_DEG && - angleDelta(tb, cur.originalCca) < - cfg.lowSpeedTurnMaxDeltaDeg && - ((hasNextCompassCalibration && - angleDelta(currentCompassOffset, nextCompassOffset) > - MAX_COMPUTED_COMPASS_DELTA_DEG) || - reconstructionDiscontinuity || - continuesRejectedCalibration); - const viewCompassAngle = computedCompassOutlier ? - cur.originalCca : - (isNum(cur.viewCompassAngle) ? cur.viewCompassAngle : cur.cca); + const frame = this._compassFrame( + cur, + tb, + prev?.compassOffset, + prev?.computedCompassOutlier, + nxt); const result: ReorientationResult = { valid: true, nextId, prevId: idx > 0 ? ids[idx - 1] : undefined, travel: tb, computedTravel: segment.computedTravel, - basicX: bearingToBasicX(tb, viewCompassAngle), + basicX: bearingToBasicX(tb, frame.viewCompassAngle), dist, cca: cur.cca, - viewCompassAngle, - computedCompassOutlier, - reconstructionDiscontinuity, - computedCompassOffset: hasCurrentCompassCalibration ? - currentCompassOffset : undefined, + ...frame, speed, moving, seq: cur.seq, @@ -489,6 +504,52 @@ export class ReorientationEngine { }); } + private _compassFrame( + cur: ReorientationImage, + travel: number, + previousCompassOffset?: number, + previousComputedCompassOutlier?: boolean, + next?: ReorientationImage): CompassFrame { + const hasCurrentCalibration = + isNum(cur.computedCca) && isNum(cur.originalCca); + const hasNextCalibration = next != null && + isNum(next.computedCca) && isNum(next.originalCca); + const currentOffset = hasCurrentCalibration ? + (cur.computedCca - cur.originalCca + 360) % 360 : 0; + const nextOffset = hasNextCalibration ? + (next.computedCca - next.originalCca + 360) % 360 : 0; + const reconstructionDiscontinuity = + hasCurrentCalibration && previousCompassOffset != null && + angleDelta(currentOffset, previousCompassOffset) > + MAX_COMPUTED_COMPASS_DELTA_DEG; + const continuesRejectedCalibration = + hasCurrentCalibration && + previousComputedCompassOutlier === true && + previousCompassOffset != null && + angleDelta(currentOffset, previousCompassOffset) <= + MAX_COMPUTED_COMPASS_DELTA_DEG; + const computedCompassOutlier = + hasCurrentCalibration && + angleDelta(cur.computedCca, cur.originalCca) > + MAX_COMPUTED_COMPASS_DELTA_DEG && + angleDelta(travel, cur.originalCca) < + this._config.lowSpeedTurnMaxDeltaDeg && + ((hasNextCalibration && + angleDelta(currentOffset, nextOffset) > + MAX_COMPUTED_COMPASS_DELTA_DEG) || + reconstructionDiscontinuity || + continuesRejectedCalibration); + return { + viewCompassAngle: computedCompassOutlier ? + cur.originalCca : + (isNum(cur.viewCompassAngle) ? cur.viewCompassAngle : cur.cca), + computedCompassOutlier, + reconstructionDiscontinuity, + computedCompassOffset: hasCurrentCalibration ? + currentOffset : undefined, + }; + } + private _segment(cur: ReorientationImage, nxt: ReorientationImage): Segment { const dt = (nxt.ts && cur.ts) ? (nxt.ts - cur.ts) / 1000 : 0; const computedDist = haversineDist(cur.lat, cur.lng, nxt.lat, nxt.lng); diff --git a/test/component/reorientation/ReorientationEngine.test.ts b/test/component/reorientation/ReorientationEngine.test.ts index 0def50579..2002e8025 100644 --- a/test/component/reorientation/ReorientationEngine.test.ts +++ b/test/component/reorientation/ReorientationEngine.test.ts @@ -377,7 +377,7 @@ describe("ReorientationEngine.precompute", () => { expect(result.moving).toBe(true); }); - it("invalidates the last image in a sequence", async () => { + it("faces incoming travel on the last image in a sequence", async () => { const images: Fixture = { a: spherical("a", 0, 0.0000, 0, 0), b: spherical("b", 0, 0.0001, 0, 1000), @@ -387,6 +387,42 @@ describe("ReorientationEngine.precompute", () => { await engine.precompute("b"); const result = engine.get("b"); + expect(result.valid).toBe(true); + expect(result.prevId).toBe("a"); + expect(result.nextId).toBeUndefined(); + expect(result.travel).toBeCloseTo(90, 1); + expect(result.basicX).toBeCloseTo(0.75, 2); + }); + + it("uses raw compass at an endpoint calibration discontinuity", async () => { + const previous = spherical("a", 0, 0.0000, 90, 0); + previous.computedCca = 90; + previous.originalCca = 90; + const endpoint = spherical("b", 0, 0.0001, 270, 1000); + endpoint.computedCca = 270; + endpoint.originalCca = 90; + const engine = new ReorientationEngine(provider( + { a: previous, b: endpoint }, ["a", "b"])); + + await engine.precompute("b"); + const result = engine.get("b"); + + expect(result.valid).toBe(true); + expect(result.computedCompassOutlier).toBe(true); + expect(result.reconstructionDiscontinuity).toBe(true); + expect(result.viewCompassAngle).toBeCloseTo(90, 1); + expect(result.basicX).toBeCloseTo(0.5, 2); + }); + + it("invalidates a sequence with no travel segment", async () => { + const images: Fixture = { + a: spherical("a", 0, 0.0000, 0, 0), + }; + const engine = new ReorientationEngine(provider(images, ["a"])); + + await engine.precompute("a"); + const result = engine.get("a"); + expect(result.valid).toBe(false); expect(result.reason).toBe("End of sequence"); }); From f79f3e46875cab0bc30bbcba57d0f48ef4ea94b4 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Mon, 2 Nov 2026 08:39:37 +0100 Subject: [PATCH 06/16] Validate perspective reconstruction before leveling --- .../reorientation/ReorientationComponent.ts | 97 +++++++++++++++---- .../reorientation/ReorientationEngine.ts | 31 ++++-- .../reorientation/ReorientationEngine.test.ts | 46 ++++++++- .../ReorientationLeveling.test.ts | 15 +++ 4 files changed, 160 insertions(+), 29 deletions(-) diff --git a/src/component/reorientation/ReorientationComponent.ts b/src/component/reorientation/ReorientationComponent.ts index 6561ac378..ed84631bb 100644 --- a/src/component/reorientation/ReorientationComponent.ts +++ b/src/component/reorientation/ReorientationComponent.ts @@ -32,6 +32,7 @@ import { Navigator } from "../../viewer/Navigator"; const MIN_REORIENT_DEG = 15; const MAX_HORIZON_CORRECTION_DEG = 75; const MAX_REORIENTATION_ROLL_DEG = 45; +const MIN_PERSPECTIVE_LEVEL_ROLL_DEG = 7; // Abrupt roll or pitch changes between neighboring captures are reconstruction // errors, not plausible camera motion, so their horizon is not worth using. const MAX_LEVEL_ROLL_DELTA_DEG = 15; @@ -46,6 +47,16 @@ export function horizonPitchDeg(horizonY: number): number { return (0.5 - horizonY) * 180; } +/** + * Small perspective corrections are indistinguishable from a tilted SfM frame + * without an independent gravity signal, so the raw camera frame is safer. + */ +export function shouldLevelPerspective(rollDeg: number): boolean { + return Number.isFinite(rollDeg) && + rollDeg >= MIN_PERSPECTIVE_LEVEL_ROLL_DEG && + rollDeg <= MAX_REORIENTATION_ROLL_DEG; +} + /** * Whether a reconstructed pose is plausible enough to level to. Judged on its * roll, and — once the sequence has an accepted pose to compare against — on @@ -204,6 +215,13 @@ export class ReorientationComponent if (!result || !result.valid || typeof result.travel !== "number") { return null; } + if (result.cam === "perspective") { + if (id === this._activeId && typeof this._liveBearing === "number") { + return this._mapBearing(result, this._liveBearing); + } + return typeof result.viewCompassAngle === "number" ? + result.viewCompassAngle : null; + } if (!this._reorientToFront && result.seq != null && result.seq === this._lastSeq) { const active = this._engine.get(this._activeId); @@ -482,7 +500,12 @@ export class ReorientationComponent return; } if (!isSpherical(image.cameraType)) { - this._levelPerspective(id); + if (freshSequence) { + this._levelRollDeg = null; + this._levelPitchDeg = null; + } + this._lastSeq = sequenceId; + this._levelPerspective(id, result); return; } // A disconnected image still hard-cuts after navigation so its @@ -923,55 +946,89 @@ export class ReorientationComponent .catch((): void => { /* skip */ }); } - private _levelPerspective(id: string): void { - if (!this._automaticHorizonLeveling) { - this._clearAdoptedView(); - return; - } - if (this._adoptedView != null) { - this._navigator.stateService.gravityTraverse(); + private _levelPerspective( + id: string, + result: ReorientationResult): void { + const adoptedView = this._adoptedView; + const stateService = this._navigator.stateService; + const settle = (): void => { + const bearing = this.getReorientedBearing(id); + if (typeof bearing === "number") { + this._settled$.next({ id, bearing }); + } + }; + const fallback = (render?: RenderCamera): void => { + this._levelAccepted = false; + stateService.traverse(); + if (adoptedView != null) { + stateService.setCenter(adoptedView); + } else if (render != null) { + stateService.zoomTo( + this._userZoomOverride ? render.zoom : 0); + this._appliedPerspectiveZoom = 0; + } this._clearAdoptedView(); + settle(); + }; + if (!this._automaticHorizonLeveling || + result?.computedCompassOutlier === true) { + fallback(); return; } this._navigator.stateService.getCenter().pipe(first()).subscribe( (): void => { - if (this._activeId !== id || this._adoptedView != null || - this._dragging || this._userViewChanged) { + if (this._activeId !== id || this._dragging || + this._userViewChanged) { this._clearAdoptedView(); return; } - const target = [0.5, this._horizonY(0.5)]; + const targetX = adoptedView?.[0] ?? 0.5; + const horizonY = this._horizonY(targetX); + const target = [targetX, horizonY]; + const rollDeg = horizonY == null ? null : + this._rollDeg(target); + if (!shouldLevelPerspective(rollDeg) || + !this._acceptLevel(rollDeg, horizonY)) { + fallback(); + return; + } this._container.renderService.renderCameraFrame$.pipe( filter((render: RenderCamera): boolean => render.currentImageId === id), first(), ).subscribe((render: RenderCamera): void => { - if (this._activeId !== id || this._adoptedView != null || - this._dragging || this._userViewChanged) { + if (this._activeId !== id || this._dragging || + this._userViewChanged) { this._clearAdoptedView(); return; } - const zoom = this._perspectiveAutoZoom( - target, render.unzoomedCurrentFov); - const stateService = this._navigator.stateService; if (!this._sequenceChanged && Math.abs(render.zoom - this._appliedPerspectiveZoom) > USER_ZOOM_EPSILON) { this._userZoomOverride = true; } + if (adoptedView != null) { + this._levelAccepted = true; + stateService.gravityTraverse(); + stateService.setCenter(adoptedView); + this._clearAdoptedView(); + settle(); + return; + } + const zoom = this._perspectiveAutoZoom( + target, render.unzoomedCurrentFov); if (zoom == null) { - stateService.traverse(); - stateService.zoomTo( - this._userZoomOverride ? render.zoom : 0); - this._appliedPerspectiveZoom = 0; + fallback(render); return; } + this._levelAccepted = true; stateService.gravityTraverse(); stateService.rotateToBasicSmooth(target); stateService.zoomTo( this._userZoomOverride ? render.zoom : zoom); this._appliedPerspectiveZoom = zoom; + settle(); }); }); } diff --git a/src/component/reorientation/ReorientationEngine.ts b/src/component/reorientation/ReorientationEngine.ts index 748ea2d6d..36b0b3675 100644 --- a/src/component/reorientation/ReorientationEngine.ts +++ b/src/component/reorientation/ReorientationEngine.ts @@ -42,6 +42,7 @@ export interface ReorientationResult { prevId?: string; travel?: number; computedTravel?: number; + originalTravel?: number; basicX?: number; dist?: number; cca?: number; @@ -52,6 +53,7 @@ export interface ReorientationResult { speed?: number; moving?: boolean; seq?: string; + cam?: string; } export const DEFAULT_REORIENTATION_CONFIGURATION: @@ -298,12 +300,15 @@ export class ReorientationEngine { cur, previous.travel, previous.computedCompassOffset, - previous.computedCompassOutlier); + previous.computedCompassOutlier, + undefined, + previous.originalTravel); this._cache.set(imgId, { valid: true, prevId, travel: previous.travel, computedTravel: previous.computedTravel, + originalTravel: previous.originalTravel, basicX: bearingToBasicX( previous.travel, frame.viewCompassAngle), @@ -313,6 +318,7 @@ export class ReorientationEngine { speed: previous.speed, moving: previous.moving, seq: cur.seq, + cam: cur.cam, }); }); } @@ -474,13 +480,15 @@ export class ReorientationEngine { tb, prev?.compassOffset, prev?.computedCompassOutlier, - nxt); + nxt, + segment.originalTravel); const result: ReorientationResult = { valid: true, nextId, prevId: idx > 0 ? ids[idx - 1] : undefined, travel: tb, computedTravel: segment.computedTravel, + originalTravel: segment.originalTravel, basicX: bearingToBasicX(tb, frame.viewCompassAngle), dist, cca: cur.cca, @@ -488,6 +496,7 @@ export class ReorientationEngine { speed, moving, seq: cur.seq, + cam: cur.cam, }; this._cache.set(imgId, result); this._prefetchNext(result, depth); @@ -509,7 +518,8 @@ export class ReorientationEngine { travel: number, previousCompassOffset?: number, previousComputedCompassOutlier?: boolean, - next?: ReorientationImage): CompassFrame { + next?: ReorientationImage, + originalTravel?: number): CompassFrame { const hasCurrentCalibration = isNum(cur.computedCca) && isNum(cur.originalCca); const hasNextCalibration = next != null && @@ -528,10 +538,16 @@ export class ReorientationEngine { previousCompassOffset != null && angleDelta(currentOffset, previousCompassOffset) <= MAX_COMPUTED_COMPASS_DELTA_DEG; - const computedCompassOutlier = - hasCurrentCalibration && + const rawTravel = isNum(originalTravel) ? originalTravel : travel; + const calibrationConflict = hasCurrentCalibration && angleDelta(cur.computedCca, cur.originalCca) > - MAX_COMPUTED_COMPASS_DELTA_DEG && + MAX_COMPUTED_COMPASS_DELTA_DEG; + const perspectiveOutlier = cur.cam === "perspective" && + calibrationConflict && + angleDelta(rawTravel, cur.originalCca) < + this._config.lowSpeedTurnMaxDeltaDeg; + const computedCompassOutlier = perspectiveOutlier || + calibrationConflict && angleDelta(travel, cur.originalCca) < this._config.lowSpeedTurnMaxDeltaDeg && ((hasNextCalibration && @@ -626,7 +642,8 @@ export class ReorientationEngine { if (!cur) { return "Missing image metadata"; } - if (cur.cam !== "spherical" && cur.cam !== "equirectangular") { + if (cur.cam !== "spherical" && cur.cam !== "equirectangular" && + cur.cam !== "perspective") { return "Camera: " + (cur.cam || "unknown"); } if (!hasPosition(cur)) { diff --git a/test/component/reorientation/ReorientationEngine.test.ts b/test/component/reorientation/ReorientationEngine.test.ts index 2002e8025..5394e5bf6 100644 --- a/test/component/reorientation/ReorientationEngine.test.ts +++ b/test/component/reorientation/ReorientationEngine.test.ts @@ -185,6 +185,48 @@ describe("ReorientationEngine.precompute", () => { expect(result.basicX).toBeCloseTo(0.5, 2); }); + it("rejects a perspective heading that conflicts with raw travel", async () => { + const current = spherical("b", 0, 0, 150, 2000, 0, 0); + current.cam = "perspective"; + current.computedCca = 150; + current.originalCca = 90; + const next = spherical("c", 0, 0.0001, 150, 3000, 0, 0.0001); + next.cam = "perspective"; + next.computedCca = 150; + next.originalCca = 90; + const engine = new ReorientationEngine(provider( + { b: current, c: next }, ["b", "c"])); + + await engine.precompute("b"); + const result = engine.get("b"); + + expect(result.valid).toBe(true); + expect(result.cam).toBe("perspective"); + expect(result.computedCompassOutlier).toBe(true); + expect(result.originalTravel).toBeCloseTo(90, 1); + expect(result.viewCompassAngle).toBeCloseTo(90, 1); + }); + + it("keeps a perspective heading that agrees with raw capture data", async () => { + const current = spherical("b", 0, 0, 100, 2000, 0, 0); + current.cam = "perspective"; + current.computedCca = 100; + current.originalCca = 90; + const next = spherical("c", 0, 0.0001, 100, 3000, 0, 0.0001); + next.cam = "perspective"; + next.computedCca = 100; + next.originalCca = 90; + const engine = new ReorientationEngine(provider( + { b: current, c: next }, ["b", "c"])); + + await engine.precompute("b"); + const result = engine.get("b"); + + expect(result.valid).toBe(true); + expect(result.computedCompassOutlier).toBe(false); + expect(result.viewCompassAngle).toBeCloseTo(100, 1); + }); + it("keeps reconstructed heading when raw compass conflicts with travel", async () => { const current = spherical("b", 0, 0, 0, 2000); current.computedCca = 0; @@ -427,9 +469,9 @@ describe("ReorientationEngine.precompute", () => { expect(result.reason).toBe("End of sequence"); }); - it("skips non-spherical images", async () => { + it("skips unsupported camera types", async () => { const images: Fixture = { - a: { id: "a", lat: 0, lng: 0, cca: 0, cam: "perspective", seq: "s", ts: 0 }, + a: { id: "a", lat: 0, lng: 0, cca: 0, cam: "fisheye", seq: "s", ts: 0 }, b: spherical("b", 0, 0.0001, 0, 1000), }; const engine = new ReorientationEngine(provider(images, ["a", "b"])); diff --git a/test/component/reorientation/ReorientationLeveling.test.ts b/test/component/reorientation/ReorientationLeveling.test.ts index 514f751e4..d0340bde0 100644 --- a/test/component/reorientation/ReorientationLeveling.test.ts +++ b/test/component/reorientation/ReorientationLeveling.test.ts @@ -1,6 +1,7 @@ import { horizonPitchDeg, isLevelPlausible, + shouldLevelPerspective, } from "../../../src/component/reorientation/ReorientationComponent"; /** @@ -79,6 +80,20 @@ describe("isLevelPlausible", () => { }); }); +describe("shouldLevelPerspective", () => { + it("leaves small untrusted corrections in the raw camera frame", () => { + expect(shouldLevelPerspective(5.85)).toBe(false); + }); + + it("accepts a material perspective correction", () => { + expect(shouldLevelPerspective(7)).toBe(true); + }); + + it("rejects an implausibly large correction", () => { + expect(shouldLevelPerspective(46)).toBe(false); + }); +}); + describe("horizonPitchDeg", () => { it("reports no correction for a centred horizon", () => { expect(horizonPitchDeg(0.5)).toBeCloseTo(0, 6); From 28f11c1880a5beabad860f67987474554fcb56ce Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Mon, 2 Nov 2026 08:39:38 +0100 Subject: [PATCH 07/16] Add capture timezone, CDN host normalization, gravity-aligned slider and adopted zoom - Request the capture location's IANA `timezone` and show capture dates in it in the attribution. - Normalize POP-sharded fbcdn hosts to their unsharded form before fetching. - Add `SliderConfiguration.gravityAligned` to keep stationary comparison cameras aligned to gravity. - Let `ReorientationComponent.adoptView` carry a zoom level and add `reorientCurrent()` to re-run reorientation without navigating. - Regression tests for each. --- src/api/Common.ts | 20 ++- src/api/ents/SpatialImageEnt.ts | 5 + src/api/provider/GraphQueryCreator.ts | 1 + .../attribution/AttributionComponent.ts | 60 ++++++-- .../interfaces/SliderConfiguration.ts | 6 + .../reorientation/ReorientationComponent.ts | 141 ++++++++++-------- src/component/slider/SliderComponent.ts | 21 ++- src/component/slider/SliderGLRenderer.ts | 107 ++++++++++--- src/component/slider/SliderMeshFactory.ts | 31 +++- src/graph/Image.ts | 11 ++ test/api/Common.test.ts | 21 ++- test/api/provider/GraphConverter.test.ts | 2 + .../attribution/AttributionComponent.test.ts | 84 +++++++++++ test/graph/Image.test.ts | 24 +++ 14 files changed, 419 insertions(+), 115 deletions(-) create mode 100644 test/component/attribution/AttributionComponent.test.ts diff --git a/src/api/Common.ts b/src/api/Common.ts index 2aa59b59b..c080aec7c 100644 --- a/src/api/Common.ts +++ b/src/api/Common.ts @@ -8,6 +8,13 @@ export interface XMLHttpRequestHeader { value: string, } +const POP_SHARDED_FBCDN_HOST = + /^https:\/\/(external|scontent)-[^.]+\.xx\.fbcdn\.net/; + +export function normalizeFbCdnUrl(url: string): string { + return url.replace(POP_SHARDED_FBCDN_HOST, "https://$1.xx.fbcdn.net"); +} + /** * Decompress and parse an array buffer containing zipped * json data and return as a json object. @@ -67,10 +74,11 @@ export function xhrFetch( body?: string, abort?: Promise): Promise { + const requestUrl = normalizeFbCdnUrl(url); const xhr = new XMLHttpRequest(); const promise = new Promise( (resolve, reject) => { - xhr.open(method, url, true); + xhr.open(method, requestUrl, true); for (const header of headers) { xhr.setRequestHeader(header.name, header.value); } @@ -80,25 +88,25 @@ export function xhrFetch( xhr.onload = () => { if (xhr.status !== 200) { const error = xhr.response ?? - new MapillaryError(`Response status error: ${url}`); + new MapillaryError(`Response status error: ${requestUrl}`); reject(error); } if (!xhr.response) { - reject(new MapillaryError(`Response empty: ${url}`)); + reject(new MapillaryError(`Response empty: ${requestUrl}`)); } resolve(xhr.response); }; xhr.onerror = () => { - reject(new MapillaryError(`Request error: ${url}`)); + reject(new MapillaryError(`Request error: ${requestUrl}`)); }; xhr.ontimeout = () => { - reject(new MapillaryError(`Request timeout: ${url}`)); + reject(new MapillaryError(`Request timeout: ${requestUrl}`)); }; xhr.onabort = () => { - reject(new MapillaryError(`Request aborted: ${url}`)); + reject(new MapillaryError(`Request aborted: ${requestUrl}`)); }; xhr.send(method === "POST" ? body : null); }); diff --git a/src/api/ents/SpatialImageEnt.ts b/src/api/ents/SpatialImageEnt.ts index f9de7889c..500bd81bb 100644 --- a/src/api/ents/SpatialImageEnt.ts +++ b/src/api/ents/SpatialImageEnt.ts @@ -27,6 +27,11 @@ export interface SpatialImageEnt extends CameraEnt, IDEnt { */ captured_at: number; + /** + * IANA timezone at the image capture location. + */ + timezone?: string; + /** * Original EXIF compass angle, measured in degrees. */ diff --git a/src/api/provider/GraphQueryCreator.ts b/src/api/provider/GraphQueryCreator.ts index 8a075bd40..45dbd53a5 100644 --- a/src/api/provider/GraphQueryCreator.ts +++ b/src/api/provider/GraphQueryCreator.ts @@ -36,6 +36,7 @@ export class GraphQueryCreator { 'sfm_cluster', 'thumb_1024_url', 'thumb_2048_url', + 'timezone', 'width', ]; this.imageTileFields = ['url', 'z', 'x', 'y']; diff --git a/src/component/attribution/AttributionComponent.ts b/src/component/attribution/AttributionComponent.ts index 8d19b6552..c13aeb21a 100644 --- a/src/component/attribution/AttributionComponent.ts +++ b/src/component/attribution/AttributionComponent.ts @@ -11,6 +11,37 @@ import { Component } from "../Component"; import { ComponentConfiguration } from "../interfaces/ComponentConfiguration"; import { ComponentName } from "../ComponentName"; +export function formatCaptureDate( + capturedAt: number, + compact: boolean, + locales?: string | string[], + timeZone?: string): string { + const date = new Date(capturedAt); + + if (isNaN(date.getTime())) { + return date.toString(); + } + + const options: Intl.DateTimeFormatOptions = compact ? + { year: "numeric" } : + { day: "numeric", month: "short", year: "numeric" }; + + if (timeZone) { + options.timeZone = timeZone; + } + + try { + return new Intl.DateTimeFormat( + compact ? "en-US" : locales, + options).format(date); + } catch { + options.timeZone = "UTC"; + return new Intl.DateTimeFormat( + compact ? "en-US" : locales, + options).format(date); + } +} + export class AttributionComponent extends Component { public static componentName: ComponentName = "attribution"; @@ -28,6 +59,7 @@ export class AttributionComponent extends Component { exploreUrl, image.id, image.capturedAt, + image.timezone, size.width); return { name: this._name, @@ -54,11 +86,12 @@ export class AttributionComponent extends Component { exploreUrl: string, imageId: string, capturedAt: number, + timeZone: string | undefined, viewportWidth: number) : vd.VNode { const compact = viewportWidth <= 640; - const date = this._makeDate(capturedAt, compact); + const date = [this._makeDate(capturedAt, compact, timeZone)]; const by = this._makeBy(creatorUsername, exploreUrl, imageId, compact); const compactClass = compact ? @@ -67,7 +100,7 @@ export class AttributionComponent extends Component { return vd.h( "div.mapillary-attribution-container" + compactClass, {}, - [...by, date]); + [...by, ...date]); } private _makeBy( @@ -152,20 +185,19 @@ export class AttributionComponent extends Component { return [image]; } - private _makeDate(capturedAt: number, compact: boolean): vd.VNode { - const date = new Date(capturedAt) - .toDateString() - .split(" "); - - const formatted = (date.length > 3 ? - compact ? - [date[3]] : - [date[1], date[2] + ",", date[3]] : - date).join(" "); - + private _makeDate( + capturedAt: number, + compact: boolean, + timeZone?: string): vd.VNode { return vd.h( "div.mapillary-attribution-date", - { textContent: formatted }, + { + textContent: formatCaptureDate( + capturedAt, + compact, + undefined, + timeZone), + }, []); } } diff --git a/src/component/interfaces/SliderConfiguration.ts b/src/component/interfaces/SliderConfiguration.ts index 2979ecd5b..0ea59689d 100644 --- a/src/component/interfaces/SliderConfiguration.ts +++ b/src/component/interfaces/SliderConfiguration.ts @@ -71,6 +71,12 @@ export interface SliderConfigurationIds { * ``` */ export interface SliderConfiguration extends ComponentConfiguration { + /** + * Keep stationary comparison cameras aligned to gravity. + * + * @default false + */ + gravityAligned?: boolean; /** * Initial position of the slider on the interval [0, 1]. * diff --git a/src/component/reorientation/ReorientationComponent.ts b/src/component/reorientation/ReorientationComponent.ts index ed84631bb..2e14bdaa8 100644 --- a/src/component/reorientation/ReorientationComponent.ts +++ b/src/component/reorientation/ReorientationComponent.ts @@ -146,6 +146,7 @@ export class ReorientationComponent private _reorientToFront: boolean; private _reorientOnSpatialNav: boolean; private _adoptedView: number[]; + private _adoptedZoom: number; private _adoptedSequence: string; private _settled$: Subject = new Subject(); @@ -279,13 +280,21 @@ export class ReorientationComponent * travel direction. Coordinates are passed in rather than read from the * viewer so the call does not race the host applying them. */ - public adoptView(basic: number[]): void { + public adoptView(basic: number[], zoom?: number): void { this._adoptedView = basic != null && basic.length === 2 ? [basic[0], basic[1]] : null; + this._adoptedZoom = this._adoptedView != null && + typeof zoom === "number" && Number.isFinite(zoom) ? zoom : null; this._adoptedSequence = this._adoptedView != null ? this._imageSequence : null; } + /** Re-run reorientation for the displayed image without navigating. */ + public reorientCurrent(): void { + this._navigator.stateService.currentImage$.pipe(first()).subscribe( + (image: Image): void => { this._reorientImage(image); }); + } + /** * The heading the given image would be shown at if it were reached right * now with a direction arrow, or null when that cannot be determined @@ -374,54 +383,7 @@ export class ReorientationComponent // currentImage$ fires reliably on every navigation, including in-app // jumps to another sequence (currentState$ did not deliver those). subs.push(this._navigator.stateService.currentImage$.subscribe( - (image: Image): void => { - if (!image) { - return; - } - this._currentTransform = new Transform( - image.exifOrientation, - image.width, - image.height, - image.scale, - image.rotation, - [0, 0, 0], - image.image, - image.camera); - this._levelAccepted = false; - const fromId = this._activeId; - if (this._adoptedView != null && - this._adoptedSequence == null) { - this._adoptedSequence = image.sequenceId; - } - this._sequenceChanged = fromId != null && - image.sequenceId !== this._imageSequence; - if (this._sequenceChanged) { - this._navigator.stateService.setZoom(0); - this._appliedPerspectiveZoom = 0; - this._userZoomOverride = false; - } - this._activeId = image.id; - this._imageSequence = image.sequenceId; - // Snapshot now, synchronously with the change. By the time the - // engine resolves, the transition has already begun moving the - // camera, so neither the live bearing nor getCenter() still - // describes the view the user carried in. - this._incomingBearing = this._liveBearing; - // Consume the direction here, synchronously with the landing, so - // it attributes to this image and not a later re-emit. - const direction = this._navigator.consumeMoveDirection(); - const commitSequenceView = this._userViewChanged && - (direction === NavigationDirection.Next || - direction === NavigationDirection.Prev); - this._userViewChanged = false; - this._reorient( - image, - direction, - fromId, - this._dragging, - commitSequenceView, - this._userViewRevision); - })); + (image: Image): void => { this._reorientImage(image); })); subs.push(this._container.renderService.bearing$.subscribe( (bearing: number): void => { this._liveBearing = bearing; })); @@ -458,6 +420,55 @@ export class ReorientationComponent return {}; } + private _reorientImage(image: Image): void { + if (!image) { + return; + } + this._currentTransform = new Transform( + image.exifOrientation, + image.width, + image.height, + image.scale, + image.rotation, + [0, 0, 0], + image.image, + image.camera); + this._levelAccepted = false; + const fromId = this._activeId; + if (this._adoptedView != null && + this._adoptedSequence == null) { + this._adoptedSequence = image.sequenceId; + } + this._sequenceChanged = fromId != null && + image.sequenceId !== this._imageSequence; + if (this._sequenceChanged) { + this._navigator.stateService.setZoom(0); + this._appliedPerspectiveZoom = 0; + this._userZoomOverride = false; + } + this._activeId = image.id; + this._imageSequence = image.sequenceId; + // Snapshot now, synchronously with the change. By the time the engine + // resolves, the transition has already begun moving the camera, so + // neither the live bearing nor getCenter() still describes the view + // the user carried in. + this._incomingBearing = this._liveBearing; + // Consume the direction here, synchronously with the landing, so it + // attributes to this image and not a later re-emit. + const direction = this._navigator.consumeMoveDirection(); + const commitSequenceView = this._userViewChanged && + (direction === NavigationDirection.Next || + direction === NavigationDirection.Prev); + this._userViewChanged = false; + this._reorient( + image, + direction, + fromId, + this._dragging, + commitSequenceView, + this._userViewRevision); + } + private _reorient( image: Image, direction: NavigationDirection, @@ -882,6 +893,7 @@ export class ReorientationComponent private _clearAdoptedView(): void { this._adoptedView = null; + this._adoptedZoom = null; this._adoptedSequence = null; } @@ -950,6 +962,7 @@ export class ReorientationComponent id: string, result: ReorientationResult): void { const adoptedView = this._adoptedView; + const adoptedZoom = this._adoptedZoom; const stateService = this._navigator.stateService; const settle = (): void => { const bearing = this.getReorientedBearing(id); @@ -962,6 +975,9 @@ export class ReorientationComponent stateService.traverse(); if (adoptedView != null) { stateService.setCenter(adoptedView); + if (adoptedZoom != null) { + stateService.setZoom(adoptedZoom); + } } else if (render != null) { stateService.zoomTo( this._userZoomOverride ? render.zoom : 0); @@ -1008,26 +1024,27 @@ export class ReorientationComponent USER_ZOOM_EPSILON) { this._userZoomOverride = true; } - if (adoptedView != null) { - this._levelAccepted = true; - stateService.gravityTraverse(); - stateService.setCenter(adoptedView); - this._clearAdoptedView(); - settle(); - return; - } + const fittedTarget = adoptedView ?? target; const zoom = this._perspectiveAutoZoom( - target, render.unzoomedCurrentFov); + fittedTarget, render.unzoomedCurrentFov); if (zoom == null) { fallback(render); return; } this._levelAccepted = true; stateService.gravityTraverse(); - stateService.rotateToBasicSmooth(target); - stateService.zoomTo( - this._userZoomOverride ? render.zoom : zoom); - this._appliedPerspectiveZoom = zoom; + const appliedZoom = adoptedView != null ? + Math.max(zoom, adoptedZoom ?? 0) : + this._userZoomOverride ? render.zoom : zoom; + if (adoptedView != null) { + stateService.setCenter(adoptedView); + stateService.setZoom(appliedZoom); + this._clearAdoptedView(); + } else { + stateService.rotateToBasicSmooth(target); + stateService.zoomTo(appliedZoom); + } + this._appliedPerspectiveZoom = appliedZoom; settle(); }); }); diff --git a/src/component/slider/SliderComponent.ts b/src/component/slider/SliderComponent.ts index 6f6c72db9..f13a8d97b 100644 --- a/src/component/slider/SliderComponent.ts +++ b/src/component/slider/SliderComponent.ts @@ -186,6 +186,11 @@ export class SliderComponent extends Component { return configuration.mode; }), distinctUntilChanged()); + const gravityAligned$ = this.configuration$.pipe( + map( + (configuration: SliderConfiguration): boolean => + configuration.gravityAligned === true), + distinctUntilChanged()); const motionless$ = this._navigator.stateService.currentState$.pipe( map( (frame: AnimationFrame): boolean => { @@ -224,11 +229,11 @@ export class SliderComponent extends Component { mode$, motionless$, spherical$, - sliderVisible$).pipe( - withLatestFrom(this._navigator.stateService.state$)) + sliderVisible$, + this._navigator.stateService.state$) .subscribe( - ([[mode, motionless, spherical, sliderVisible], state]: - [[SliderConfigurationMode, boolean, boolean, boolean], State]): void => { + ([mode, motionless, spherical, sliderVisible, state]: + [SliderConfigurationMode, boolean, boolean, boolean, State]): void => { const interactive: boolean = sliderVisible && (motionless || mode === SliderConfigurationMode.Stationary || @@ -297,11 +302,13 @@ export class SliderComponent extends Component { .subscribe(this._glRendererOperation$)); subs.push(observableCombineLatest( this._navigator.stateService.currentState$, - mode$).pipe( + mode$, + gravityAligned$).pipe( map( - ([frame, mode]: [AnimationFrame, SliderConfigurationMode]): GLRendererOperation => { + ([frame, mode, gravityAligned]: + [AnimationFrame, SliderConfigurationMode, boolean]): GLRendererOperation => { return (glRenderer: SliderGLRenderer): SliderGLRenderer => { - glRenderer.update(frame, mode); + glRenderer.update(frame, mode, gravityAligned); return glRenderer; }; })) diff --git a/src/component/slider/SliderGLRenderer.ts b/src/component/slider/SliderGLRenderer.ts index ebeca06e0..77af39732 100644 --- a/src/component/slider/SliderGLRenderer.ts +++ b/src/component/slider/SliderGLRenderer.ts @@ -10,6 +10,7 @@ import { SliderConfigurationMode } from "../interfaces/SliderConfiguration"; import { SliderMeshFactory } from "./SliderMeshFactory"; import { isSpherical } from "../../geo/Geo"; import { CameraType } from "../../geo/interfaces/CameraType"; +import { Camera } from "../../geo/Camera"; import { SliderTransform } from "./SliderTransform"; import { SliderMeshScene, SliderMeshSceneItem } from "./SliderMeshScene"; export class SliderGLRenderer { @@ -23,6 +24,10 @@ export class SliderGLRenderer { private _frameId: number; private _needsRender: boolean; private _mode: SliderConfigurationMode; + private _gravityAligned: boolean; + private _currentPerspective: THREE.PerspectiveCamera; + private _previousCamera: Camera; + private _previousPerspective: THREE.PerspectiveCamera; private _currentProviderDisposers: { [key: string]: () => void }; private _previousProviderDisposers: { [key: string]: () => void }; constructor() { @@ -36,6 +41,10 @@ export class SliderGLRenderer { this._frameId = 0; this._needsRender = false; this._mode = null; + this._gravityAligned = false; + this._currentPerspective = null; + this._previousCamera = null; + this._previousPerspective = null; this._currentProviderDisposers = {}; this._previousProviderDisposers = {}; } @@ -64,9 +73,12 @@ export class SliderGLRenderer { this._previousProviderDisposers, this._updateTexturePrev.bind(this)); } - public update(frame: AnimationFrame, mode: SliderConfigurationMode): void { + public update( + frame: AnimationFrame, + mode: SliderConfigurationMode, + gravityAligned: boolean = false): void { this._updateFrameId(frame.id); - this._updateImagePlanes(frame.state, mode); + this._updateImagePlanes(frame.state, mode, gravityAligned); } public updateCurtain(curtain: number): void { if (this._curtain === curtain) { @@ -117,9 +129,13 @@ export class SliderGLRenderer { perspectiveCamera: THREE.PerspectiveCamera, renderer: THREE.WebGLRenderer): void { if (!this.disabled) { - renderer.render(this._scene.sceneOld, perspectiveCamera); + renderer.render( + this._scene.sceneOld, + this._getPreviousPerspective(perspectiveCamera)); } - renderer.render(this._scene.scene, perspectiveCamera); + renderer.render( + this._scene.scene, + this._getCurrentPerspective(perspectiveCamera)); this._needsRender = false; } public dispose(): void { @@ -151,6 +167,52 @@ export class SliderGLRenderer { } return [[0.5 - offsetX, 0.5 - offsetY], [0.5 + offsetX, 0.5 + offsetY]]; } + private _getCurrentPerspective( + perspectiveCamera: THREE.PerspectiveCamera) + : THREE.PerspectiveCamera { + if (!this._gravityAligned) { + return perspectiveCamera; + } + if (this._currentPerspective == null) { + this._currentPerspective = perspectiveCamera.clone(); + } else { + this._currentPerspective.copy(perspectiveCamera); + } + const direction = perspectiveCamera.getWorldDirection( + new THREE.Vector3()); + this._currentPerspective.up.set(0, 0, 1); + this._currentPerspective.matrixAutoUpdate = true; + this._currentPerspective.lookAt( + this._currentPerspective.position.clone().add(direction)); + this._currentPerspective.matrixAutoUpdate = false; + this._currentPerspective.updateMatrix(); + this._currentPerspective.updateMatrixWorld(false); + + return this._currentPerspective; + } + private _getPreviousPerspective( + perspectiveCamera: THREE.PerspectiveCamera) + : THREE.PerspectiveCamera { + if (this._mode === SliderConfigurationMode.Motion) { + return this._getCurrentPerspective(perspectiveCamera); + } + if (this._previousCamera == null) { + return perspectiveCamera; + } + if (this._previousPerspective == null) { + this._previousPerspective = perspectiveCamera.clone(); + this._previousPerspective.position.copy( + this._previousCamera.position); + this._previousPerspective.up.copy(this._previousCamera.up); + this._previousPerspective.matrixAutoUpdate = true; + this._previousPerspective.lookAt(this._previousCamera.lookat); + this._previousPerspective.matrixAutoUpdate = false; + this._previousPerspective.updateMatrix(); + this._previousPerspective.updateMatrixWorld(false); + } + + return this._previousPerspective; + } private _setDisabled(state: IAnimationState): void { this._disabled = state.currentImage == null || state.previousImage == null || @@ -197,16 +259,30 @@ export class SliderGLRenderer { private _updateFrameId(frameId: number): void { this._frameId = frameId; } - private _updateImagePlanes(state: IAnimationState, mode: SliderConfigurationMode): void { + private _updateImagePlanes( + state: IAnimationState, + mode: SliderConfigurationMode, + gravityAligned: boolean): void { const currentChanged: boolean = state.currentImage != null && this._currentKey !== state.currentImage.id; const previousChanged: boolean = state.previousImage != null && this._previousKey !== state.previousImage.id; const modeChanged: boolean = this._mode !== mode; - if (!(currentChanged || previousChanged || modeChanged)) { + const gravityAlignmentChanged: boolean = + this._gravityAligned !== gravityAligned; + if (!(currentChanged || previousChanged || modeChanged || + gravityAlignmentChanged)) { return; } this._setDisabled(state); this._needsRender = true; this._mode = mode; + this._gravityAligned = gravityAligned; + this._previousCamera = state.previousCamera == null ? null : + gravityAligned ? new Camera(state.previousTransform) : + state.previousCamera.clone(); + if (gravityAligned && this._previousCamera != null) { + this._previousCamera.up.set(0, 0, 1); + } + this._previousPerspective = null; const motionless = state.motionless || mode === SliderConfigurationMode.Stationary || @@ -220,7 +296,7 @@ export class SliderGLRenderer { if (this.disabled) { this._scene.setImagePlanesOld([]); } else { - if (previousChanged || modeChanged) { + if (previousChanged || modeChanged || gravityAlignmentChanged) { const previousNode: Image = state.previousImage; this._previousKey = previousNode.id; const elements: number[] = state.currentTransform.rt.elements; @@ -297,14 +373,10 @@ export class SliderGLRenderer { transform : previousTransform); } else { if (motionless) { - const [[basicX0, basicY0], [basicX1, basicY1]]: number[][] = this._getBasicCorners(currentAspect, previousAspect); - mesh = this._factory.createFlatMesh( + mesh = this._factory.createMesh( state.previousImage, - transform, - basicX0, - basicX1, - basicY0, - basicY1); + previousTransform, + false); } else { mesh = this._factory.createMesh(state.previousImage, previousTransform); } @@ -313,7 +385,7 @@ export class SliderGLRenderer { this._scene.setImagePlanesOld(previousPlanes); } } - if (currentChanged || modeChanged) { + if (currentChanged || modeChanged || gravityAlignmentChanged) { if (this._currentKey in this._currentProviderDisposers) { this._currentProviderDisposers[this._currentKey](); delete this._currentProviderDisposers[this._currentKey]; @@ -344,9 +416,10 @@ export class SliderGLRenderer { } else { if (motionless) { planes.push( - {imageId: state.currentImage.id, mesh: this._factory.createDistortedCurtainMesh( + {imageId: state.currentImage.id, mesh: this._factory.createCurtainMesh( state.currentImage, - currentTransform), camera: state.currentImage.camera} + currentTransform, + false), camera: state.currentImage.camera} ); } else { planes.push( diff --git a/src/component/slider/SliderMeshFactory.ts b/src/component/slider/SliderMeshFactory.ts index 5bd9376fd..c3506143f 100644 --- a/src/component/slider/SliderMeshFactory.ts +++ b/src/component/slider/SliderMeshFactory.ts @@ -16,13 +16,16 @@ export class SliderMeshFactory { this._imageSphereRadius = imageSphereRadius != null ? imageSphereRadius : 200; } - public createMesh(image: Image, transform: SliderTransform): THREE.Mesh { + public createMesh( + image: Image, + transform: SliderTransform, + useReconstruction: boolean = true): THREE.Mesh { if (isSpherical(transform.cameraType)) { return this._createImageSphere(image, transform); } else if (isFisheye(transform.cameraType)) { return this._createImagePlaneFisheye(image, transform); } else { - return this._createImagePlane(image, transform); + return this._createImagePlane(image, transform, useReconstruction); } } @@ -44,13 +47,19 @@ export class SliderMeshFactory { return new THREE.Mesh(geometry, material); } - public createCurtainMesh(image: Image, transform: SliderTransform): THREE.Mesh { + public createCurtainMesh( + image: Image, + transform: SliderTransform, + useReconstruction: boolean = true): THREE.Mesh { if (isSpherical(transform.cameraType)) { return this._createSphereCurtainMesh(image, transform); } else if (isFisheye(transform.cameraType)) { return this._createCurtainMeshFisheye(image, transform); } else { - return this._createCurtainMesh(image, transform); + return this._createCurtainMesh( + image, + transform, + useReconstruction); } } @@ -58,13 +67,16 @@ export class SliderMeshFactory { return this._createDistortedCurtainMesh(image, transform); } - private _createCurtainMesh(image: Image, transform: SliderTransform): THREE.Mesh { + private _createCurtainMesh( + image: Image, + transform: SliderTransform, + useReconstruction: boolean): THREE.Mesh { let texture: THREE.Texture = this._createTexture(image.image); let materialParameters: THREE.ShaderMaterialParameters = this._createCurtainPlaneMaterialParameters(transform, texture); let material: THREE.ShaderMaterial = new THREE.ShaderMaterial(materialParameters); - let geometry: THREE.BufferGeometry = this._useMesh(transform, image) ? + let geometry: THREE.BufferGeometry = useReconstruction && this._useMesh(transform, image) ? this._getImagePlaneGeo(transform, image) : this._getRegularFlatImagePlaneGeo(transform); @@ -118,12 +130,15 @@ export class SliderMeshFactory { return mesh; } - private _createImagePlane(image: Image, transform: SliderTransform): THREE.Mesh { + private _createImagePlane( + image: Image, + transform: SliderTransform, + useReconstruction: boolean): THREE.Mesh { let texture: THREE.Texture = this._createTexture(image.image); let materialParameters: THREE.ShaderMaterialParameters = this._createPlaneMaterialParameters(transform, texture); let material: THREE.ShaderMaterial = new THREE.ShaderMaterial(materialParameters); - let geometry: THREE.BufferGeometry = this._useMesh(transform, image) ? + let geometry: THREE.BufferGeometry = useReconstruction && this._useMesh(transform, image) ? this._getImagePlaneGeo(transform, image) : this._getRegularFlatImagePlaneGeo(transform); diff --git a/src/graph/Image.ts b/src/graph/Image.ts index 30726ed55..e6c42ea97 100644 --- a/src/graph/Image.ts +++ b/src/graph/Image.ts @@ -148,6 +148,17 @@ export class Image { return this._spatial.captured_at; } + /** + * Get timezone. + * + * @description IANA timezone at the image capture location. + * + * @returns {string} Image capture timezone. + */ + public get timezone(): string | undefined { + return this._spatial.timezone; + } + /** * Get clusterId. * diff --git a/test/api/Common.test.ts b/test/api/Common.test.ts index 3ebad926c..ec3648587 100644 --- a/test/api/Common.test.ts +++ b/test/api/Common.test.ts @@ -1,5 +1,5 @@ import * as pako from "pako"; -import { decompress, xhrFetch } from "../../src/api/Common"; +import { decompress, normalizeFbCdnUrl, xhrFetch } from "../../src/api/Common"; import { IDEnt } from "../../src/api/ents/IDEnt"; import { GraphClusterContract } from "../../src/api/provider/GraphContracts"; import { MapillaryError } from "../../src/error/MapillaryError"; @@ -26,6 +26,25 @@ describe("decompress", () => { }); }); +describe("normalizeFbCdnUrl", () => { + it("should replace POP-sharded external and scontent hosts", () => { + expect(normalizeFbCdnUrl( + "https://external-sea5-1.xx.fbcdn.net/maps/vt/style/mapillary/")) + .toBe("https://external.xx.fbcdn.net/maps/vt/style/mapillary/"); + expect(normalizeFbCdnUrl( + "https://scontent-ams4-1.xx.fbcdn.net/m1/v/image")) + .toBe("https://scontent.xx.fbcdn.net/m1/v/image"); + }); + + it("should keep stable and unrelated hosts unchanged", () => { + const stableUrl = "https://scontent.xx.fbcdn.net/m1/v/image"; + const unrelatedUrl = "https://example.com/image"; + + expect(normalizeFbCdnUrl(stableUrl)).toBe(stableUrl); + expect(normalizeFbCdnUrl(unrelatedUrl)).toBe(unrelatedUrl); + }); +}); + describe("xhrFetch", () => { it("should resolve array buffer", (done: Function) => { const xhrMock = new XMLHTTPRequestMock(); diff --git a/test/api/provider/GraphConverter.test.ts b/test/api/provider/GraphConverter.test.ts index 4fff75e3d..f464f55ea 100644 --- a/test/api/provider/GraphConverter.test.ts +++ b/test/api/provider/GraphConverter.test.ts @@ -193,6 +193,7 @@ describe("GraphConverter.spatialImage", () => { thumb: null, thumb_1024_url: '1024', thumb_2048_url: '2048', + timezone: 'Europe/Zurich', width: 4000, }; @@ -222,6 +223,7 @@ describe("GraphConverter.spatialImage", () => { expect(image.owner.id).toBe(ent.organization.id); expect(image.quality_score).toBe(ent.quality_score); expect(image.thumb.url).toBe(ent.thumb_1024_url); + expect(image.timezone).toBe(ent.timezone); expect(image.width).toBe(ent.width); expect(image.private).toBeNull(); }); diff --git a/test/component/attribution/AttributionComponent.test.ts b/test/component/attribution/AttributionComponent.test.ts new file mode 100644 index 000000000..b3608fc8f --- /dev/null +++ b/test/component/attribution/AttributionComponent.test.ts @@ -0,0 +1,84 @@ +import { formatCaptureDate } from "../../../src/component/attribution/AttributionComponent"; + +const capturedAt = new Date(2021, 9, 26, 12).getTime(); + +describe("formatCaptureDate", () => { + it("uses an abbreviated month", () => { + expect(formatCaptureDate(capturedAt, false, "en-US")) + .toBe("Oct 26, 2021"); + }); + + it("lets Intl determine locale ordering", () => { + const format = jasmine.createSpy("format") + .and.returnValue("26 Oct 2021"); + const dateTimeFormat = spyOn(Intl, "DateTimeFormat") + .and.returnValue({ format } as unknown as Intl.DateTimeFormat); + + expect(formatCaptureDate(capturedAt, false, "en-GB")) + .toBe("26 Oct 2021"); + expect(dateTimeFormat).toHaveBeenCalledWith( + "en-GB", + { day: "numeric", month: "short", year: "numeric" }); + expect(format).toHaveBeenCalledWith(new Date(capturedAt)); + }); + + it("keeps compact attribution year-only", () => { + expect(formatCaptureDate(capturedAt, true, "en-US")) + .toBe("2021"); + expect(formatCaptureDate(capturedAt, true, "ja-JP")) + .toBe("2021"); + }); + + it("uses the capture timezone for the date and compact year", () => { + const nearMidnight = Date.UTC(2021, 0, 1, 0, 30); + + expect(formatCaptureDate( + nearMidnight, + false, + "en-US", + "America/Los_Angeles")) + .toBe("Dec 31, 2020"); + expect(formatCaptureDate( + nearMidnight, + true, + "en-US", + "America/Los_Angeles")) + .toBe("2020"); + expect(formatCaptureDate( + nearMidnight, + false, + "en-US", + "Europe/Zurich")) + .toBe("Jan 1, 2021"); + + const minnesotaCapture = 1759293346261; + expect(formatCaptureDate( + minnesotaCapture, + false, + "en-US", + "America/Chicago")) + .toBe("Sep 30, 2025"); + expect(formatCaptureDate( + minnesotaCapture, + false, + "en-US", + "Europe/Rome")) + .toBe("Oct 1, 2025"); + }); + + it("falls back to UTC for an invalid timezone", () => { + const nearMidnight = Date.UTC(2021, 0, 1, 0, 30); + + expect(formatCaptureDate( + nearMidnight, + false, + "en-US", + "invalid")) + .toBe("Jan 1, 2021"); + }); + + it("preserves the invalid date fallback", () => { + expect(formatCaptureDate(NaN, false, "en-US")) + .toBe("Invalid Date"); + }); +}); diff --git a/test/graph/Image.test.ts b/test/graph/Image.test.ts index c81fa0758..aab06355b 100644 --- a/test/graph/Image.test.ts +++ b/test/graph/Image.test.ts @@ -50,6 +50,30 @@ describe("Image.complete", () => { }); }); +describe("Image.timezone", () => { + let helper: ImageHelper; + + beforeEach(() => { + helper = new ImageHelper(); + }); + + it("should return the capture timezone", () => { + const image = new MImage(helper.createCoreImageEnt()); + const spatial = helper.createSpatialImageEnt(); + spatial.timezone = "Asia/Kuala_Lumpur"; + image.makeComplete(spatial); + + expect(image.timezone).toBe("Asia/Kuala_Lumpur"); + }); + + it("should stay undefined when capture timezone is unavailable", () => { + const image = new MImage(helper.createCoreImageEnt()); + image.makeComplete(helper.createSpatialImageEnt()); + + expect(image.timezone).toBeUndefined(); + }); +}); + describe("Image.dispose", () => { let helper: ImageHelper; From aba9635e7c5e3dedf14190cd4eb546979373aab9 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Mon, 2 Nov 2026 08:39:38 +0100 Subject: [PATCH 08/16] Keep spatial edge caching consistent with image lifecycles - Refuse to cache spatial edges for an image whose cache is not initialized, and record the cached node before notifying edge subscribers. - Make `resetSpatialEdges` tolerate re-entrant caching and disposed images, and forget cached spatial edges when images are uncached. - Only warn about an incorrect mesh URL when a mesh id or URL was provided. - Guard state transitions against disposed images. - Spatial cache lifecycle, image cache and state transition regression tests. --- src/graph/Graph.ts | 22 +- src/graph/Image.ts | 4 +- src/graph/ImageCache.ts | 10 +- src/state/StateContext.ts | 5 +- test/graph/GraphSpatialCache.test.ts | 302 +++++++++++++++++++++++++++ test/graph/ImageCache.test.ts | 140 +++++++++++++ test/state/StateContext.test.ts | 198 ++++++++++++++++++ 7 files changed, 668 insertions(+), 13 deletions(-) create mode 100644 test/graph/GraphSpatialCache.test.ts create mode 100644 test/state/StateContext.test.ts diff --git a/src/graph/Graph.ts b/src/graph/Graph.ts index 170d7284b..652277b44 100644 --- a/src/graph/Graph.ts +++ b/src/graph/Graph.ts @@ -985,6 +985,9 @@ export class Graph { } const node: Image = this.getNode(key); + if (!node.hasInitializedCache()) { + throw new GraphMapillaryError(`Cannot cache spatial edges before initializing cache (${key}).`); + } const targetKeys = this.getSequenceSpatialTargetIds(key); const prevKey = targetKeys[0]; const nextKey = targetKeys[1]; @@ -1021,11 +1024,11 @@ export class Graph { const edges: NavigationEdge[] = this._computeSpatialEdges(node, potentialNodes, prevKey, nextKey, fallbackKeys); - node.cacheSpatialEdges(edges); - this._cachedSpatialEdges[key] = node; delete this._requiredSpatialArea[key]; delete this._cachedNodeTiles[key]; + + node.cacheSpatialEdges(edges); } /** @@ -1433,12 +1436,15 @@ export class Graph { * Reset all spatial edges of the graph nodes. */ public resetSpatialEdges(): void { - for (const nodeId of Object.keys(this._cachedSpatialEdges)) { - const node = this._cachedSpatialEdges[nodeId]; - node.resetSpatialEdges(); - } - + const cachedSpatialEdges = this._cachedSpatialEdges; this._cachedSpatialEdges = {}; + + for (const nodeId of Object.keys(cachedSpatialEdges)) { + const node = cachedSpatialEdges[nodeId]; + if (!(nodeId in this._cachedSpatialEdges) && node.hasInitializedCache()) { + node.resetSpatialEdges(); + } + } } /** @@ -1692,6 +1698,7 @@ export class Graph { if (nodeId in this._cachedNodes) { delete this._cachedNodes[nodeId]; } + delete this._cachedSpatialEdges[nodeId]; this._preDeletedNodes.delete(nodeId); node.dispose(); @@ -2223,6 +2230,7 @@ export class Graph { if (key in this._cachedNodes) { delete this._cachedNodes[key]; } + delete this._cachedSpatialEdges[key]; const node = this._preStored[h][key]; if (node.sequenceId in this._cachedSequenceNodes) { diff --git a/src/graph/Image.ts b/src/graph/Image.ts index e6c42ea97..9ad312adc 100644 --- a/src/graph/Image.ts +++ b/src/graph/Image.ts @@ -727,7 +727,9 @@ export class Image { * @ignore */ public resetSpatialEdges(): void { - this._cache.resetSpatialEdges(); + if (this._cache != null) { + this._cache.resetSpatialEdges(); + } } /** diff --git a/src/graph/ImageCache.ts b/src/graph/ImageCache.ts index 322f8efa9..ea5cff185 100644 --- a/src/graph/ImageCache.ts +++ b/src/graph/ImageCache.ts @@ -430,10 +430,12 @@ export class ImageCache { const url = spatial.mesh.url; if (!url) { const meshId = spatial.mesh.id; - const message = - `Incorrect mesh URL for ${spatial.id} ` + - `(${meshId}, ${url})`; - console.warn(message); + if (meshId != null || url != null) { + const message = + `Incorrect mesh URL for ${spatial.id} ` + + `(${meshId}, ${url})`; + console.warn(message); + } subscriber.next(this._createEmptyMesh()); subscriber.complete(); return; diff --git a/src/state/StateContext.ts b/src/state/StateContext.ts index 0cbcb599e..ad0c2e403 100644 --- a/src/state/StateContext.ts +++ b/src/state/StateContext.ts @@ -257,8 +257,11 @@ export class StateContext implements IStateContext { } private _transition(to: State): void { + const from = this._transitions.getState(this._state); + if (from === to) { + return; + } if (!this._transitions.validate(this._state, to)) { - const from = this._transitions.getState(this._state); console.warn( `Transition not valid (${State[from]} - ${State[to]})`); return; diff --git a/test/graph/GraphSpatialCache.test.ts b/test/graph/GraphSpatialCache.test.ts new file mode 100644 index 000000000..da959360b --- /dev/null +++ b/test/graph/GraphSpatialCache.test.ts @@ -0,0 +1,302 @@ +import { bootstrap } from "../Bootstrap"; +bootstrap(); + +import { of as observableOf } from "rxjs"; +import { NavigationEdgeStatus } from "../../src/graph/interfaces/NavigationEdgeStatus"; +import { ImageHelper } from "../helper/ImageHelper"; +import { DataProvider } from "../helper/ProviderHelper"; +import { APIWrapper } from "../../src/api/APIWrapper"; +import { Graph } from "../../src/graph/Graph"; +import { GraphService } from "../../src/graph/GraphService"; +import { Image } from "../../src/graph/Image"; +import { ImageCache } from "../../src/graph/ImageCache"; +import { GraphMapillaryError } from "../../src/error/GraphMapillaryError"; + +function createGraph(maxUnusedImages: number = 0): Graph { + const provider = new DataProvider(); + spyOn(provider.geometry, "lngLatToCellId").and.returnValue("cell-id"); + const api = new APIWrapper(provider); + spyOn(api, "getImages$").and.callFake((ids: string[]) => { + return observableOf(ids.map((id: string) => { + const ent = new ImageHelper().createImageEnt(); + ent.id = id; + return { node: ent, node_id: id }; + })); + }); + spyOn(api, "getCoreImages$").and.returnValue(observableOf({ + cell_id: "cell-id", + images: [new ImageHelper().createCoreImageEnt()], + })); + spyOn(api, "getSpatialImages$").and.returnValue(observableOf([{ + node: new ImageHelper().createSpatialImageEnt(), + node_id: "iid", + }])); + spyOn(api, "getSequence$").and.returnValue(observableOf({ + id: "sid", + image_ids: ["first", "second"], + })); + return new Graph(api, undefined, undefined, undefined, undefined, undefined, { + maxSequences: 50, + maxUnusedImages, + maxUnusedPreStoredImages: 0, + maxUnusedTiles: 20, + }); +} + +function cacheNode(graph: Graph, id: string): Image { + if (!graph.hasNode(id)) { + graph.cacheFull$(id).subscribe(); + } + if (!graph.hasNodeSequence(id)) { + graph.cacheNodeSequence$(id).subscribe(); + } + graph.initializeCache(id); + expect(graph.hasSpatialArea(id)).toBe(true); + return graph.getNode(id); +} + +function cacheEdges(graph: Graph, id: string): void { + expect(graph.hasSpatialArea(id)).toBe(true); + graph.cacheSpatialEdges(id); +} + +describe("Graph spatial cache lifecycle", () => { + it("should forget edges when a prestored image is disposed", () => { + const graph = createGraph(); + const node = cacheNode(graph, "first"); + cacheEdges(graph, "first"); + graph.uncache([], []); + + expect(node.hasInitializedCache()).toBe(false); + expect(graph.hasNode("first")).toBe(false); + expect(() => graph.resetSpatialEdges()).not.toThrow(); + }); + + it("should recache a prestored image with the same id after eviction", () => { + const graph = createGraph(); + cacheNode(graph, "first"); + cacheEdges(graph, "first"); + graph.uncache([], []); + + const node = cacheNode(graph, "first"); + expect(() => cacheEdges(graph, "first")).not.toThrow(); + expect(node.spatialEdges.cached).toBe(true); + }); + + it("should clear edges before disposing a prestored image", () => { + const graph = createGraph(); + const node = cacheNode(graph, "first"); + cacheEdges(graph, "first"); + const reset = spyOn(node, "resetSpatialEdges").and.callThrough(); + const subscription = node.spatialEdges$.subscribe((status: NavigationEdgeStatus): void => { + if (!status.cached && reset.calls.count() === 0) { + graph.resetSpatialEdges(); + } + }); + + graph.uncache([], []); + subscription.unsubscribe(); + expect(reset.calls.count()).toBe(0); + }); + + it("should forget edges before purging a deleted image", () => { + const graph = createGraph(100); + graph.cacheCell$("cell-id").subscribe(); + const node = cacheNode(graph, "iid"); + cacheEdges(graph, "iid"); + graph.deleteClusters$(["clid"]).subscribe(); + graph.uncache([], ["cell-id"]); + + expect(node.hasInitializedCache()).toBe(false); + expect(() => graph.resetSpatialEdges()).not.toThrow(); + const reset = spyOn(node, "resetSpatialEdges").and.callThrough(); + graph.resetSpatialEdges(); + expect(reset.calls.count()).toBe(0); + }); + + it("should reset a deleted image while it remains in use", () => { + const graph = createGraph(100); + graph.cacheCell$("cell-id").subscribe(); + const node = cacheNode(graph, "iid"); + cacheEdges(graph, "iid"); + graph.deleteClusters$(["clid"]).subscribe(); + graph.uncache(["iid"], ["cell-id"]); + + expect(node.hasInitializedCache()).toBe(true); + graph.resetSpatialEdges(); + expect(node.spatialEdges.cached).toBe(false); + }); + + it("should retain edges when uncaching keeps the image", () => { + const graph = createGraph(); + const node = cacheNode(graph, "first"); + cacheEdges(graph, "first"); + graph.uncache(["first"], ["cell-id"]); + + expect(node.spatialEdges.cached).toBe(true); + graph.resetSpatialEdges(); + expect(node.spatialEdges.cached).toBe(false); + }); + + it("should reset edges cached during a cache notification", () => { + const graph = createGraph(); + const node = cacheNode(graph, "first"); + const subscription = node.spatialEdges$.subscribe((status: NavigationEdgeStatus): void => { + if (status.cached) { + graph.resetSpatialEdges(); + } + }); + + cacheEdges(graph, "first"); + subscription.unsubscribe(); + expect(node.spatialEdges.cached).toBe(false); + expect(() => cacheEdges(graph, "first")).not.toThrow(); + }); + + it("should preserve edges recached during a reset notification", () => { + const graph = createGraph(); + const node = cacheNode(graph, "first"); + cacheEdges(graph, "first"); + const errors: Error[] = []; + const subscription = node.spatialEdges$.subscribe((status: NavigationEdgeStatus): void => { + if (!status.cached) { + try { + cacheEdges(graph, "first"); + } catch (error) { + errors.push(error); + } + } + }); + + graph.resetSpatialEdges(); + subscription.unsubscribe(); + expect(errors).toEqual([]); + expect(node.spatialEdges.cached).toBe(true); + graph.resetSpatialEdges(); + expect(node.spatialEdges.cached).toBe(false); + }); + + it("should tolerate a nested reset without resetting twice", () => { + const graph = createGraph(); + const node = cacheNode(graph, "first"); + cacheEdges(graph, "first"); + const reset = spyOn(node, "resetSpatialEdges").and.callThrough(); + const subscription = node.spatialEdges$.subscribe((status: NavigationEdgeStatus): void => { + if (!status.cached && reset.calls.count() === 1) { + graph.resetSpatialEdges(); + } + }); + + graph.resetSpatialEdges(); + subscription.unsubscribe(); + expect(reset.calls.count()).toBe(1); + }); + + it("should tolerate another image being uncached during reset", () => { + const graph = createGraph(); + const first = cacheNode(graph, "first"); + const second = cacheNode(graph, "second"); + cacheEdges(graph, "first"); + cacheEdges(graph, "second"); + const subscription = first.spatialEdges$.subscribe((status: NavigationEdgeStatus): void => { + if (!status.cached) { + second.uncache(); + } + }); + + expect(() => graph.resetSpatialEdges()).not.toThrow(); + subscription.unsubscribe(); + }); + + it("should not reset another image recached during a reset notification", () => { + const graph = createGraph(); + const first = cacheNode(graph, "first"); + const second = cacheNode(graph, "second"); + cacheEdges(graph, "first"); + cacheEdges(graph, "second"); + const errors: Error[] = []; + const subscription = first.spatialEdges$.subscribe((status: NavigationEdgeStatus): void => { + if (!status.cached) { + try { + cacheEdges(graph, "second"); + } catch (error) { + errors.push(error); + } + } + }); + + graph.resetSpatialEdges(); + subscription.unsubscribe(); + expect(errors).toEqual([]); + expect(second.spatialEdges.cached).toBe(true); + graph.resetSpatialEdges(); + expect(second.spatialEdges.cached).toBe(false); + }); + + it("should not record spatial edges when the image cache is uninitialized", () => { + const graph = createGraph(); + graph.cacheFull$("first").subscribe(); + graph.cacheNodeSequence$("first").subscribe(); + expect(graph.hasSpatialArea("first")).toBe(true); + + expect(() => graph.cacheSpatialEdges("first")) + .toThrowError(GraphMapillaryError); + graph.initializeCache("first"); + expect(() => cacheEdges(graph, "first")).not.toThrow(); + }); + + it("should still reject a genuinely duplicate cache operation", () => { + const graph = createGraph(); + cacheNode(graph, "first"); + cacheEdges(graph, "first"); + + expect(() => graph.cacheSpatialEdges("first")) + .toThrowError(GraphMapillaryError); + }); + + it("should apply a filter after an evicted image is disposed", () => { + const graph = createGraph(); + cacheNode(graph, "first"); + cacheEdges(graph, "first"); + const service = new GraphService(graph); + graph.uncache([], []); + let completed = false; + const error = jasmine.createSpy("filterError"); + + service.setFilter$(["==", "sequenceId", "sid"]).subscribe({ + complete: () => { completed = true; }, + error, + }); + + expect(error).not.toHaveBeenCalled(); + expect(completed).toBe(true); + service.dispose(); + }); +}); + +describe("Image.resetSpatialEdges lifecycle", () => { + it("should be a no-op before cache initialization", () => { + const image = new Image(new ImageHelper().createCoreImageEnt()); + expect(() => image.resetSpatialEdges()).not.toThrow(); + expect(image.hasInitializedCache()).toBe(false); + }); + + it("should be a no-op after uncaching or disposing", () => { + const image = new Image(new ImageHelper().createCoreImageEnt()); + image.initializeCache(new ImageCache(undefined)); + image.uncache(); + expect(() => image.resetSpatialEdges()).not.toThrow(); + image.dispose(); + expect(() => image.resetSpatialEdges()).not.toThrow(); + }); + + it("should reset a new cache after reinitialization", () => { + const image = new Image(new ImageHelper().createCoreImageEnt()); + image.initializeCache(new ImageCache(undefined)); + image.uncache(); + image.initializeCache(new ImageCache(undefined)); + image.cacheSpatialEdges([]); + image.resetSpatialEdges(); + expect(image.spatialEdges).toEqual({ cached: false, edges: [] }); + }); +}); diff --git a/test/graph/ImageCache.test.ts b/test/graph/ImageCache.test.ts index f7aac6e08..a6e22a81e 100644 --- a/test/graph/ImageCache.test.ts +++ b/test/graph/ImageCache.test.ts @@ -2,6 +2,9 @@ import { first, skip, } from "rxjs/operators"; +import { firstValueFrom } from "rxjs"; +import { MeshContract } from "../../src/api/contracts/MeshContract"; +import { SpatialImageEnt } from "../../src/api/ents/SpatialImageEnt"; import { NavigationDirection } from "../../src/graph/edge/NavigationDirection"; import { NavigationEdge } from "../../src/graph/edge/interfaces/NavigationEdge"; import { NavigationEdgeStatus } @@ -316,3 +319,140 @@ describe("ImageCache.cacheImage$", () => { imageMock.dispatchEvent(new CustomEvent("load")); }); }); + +type MeshFixture = { + cache: ImageCache; + provider: DataProvider; + spatial: SpatialImageEnt; + image: HTMLImageElement; + getMesh: jasmine.Spy; +}; + +function createMeshFixture(): MeshFixture { + const provider = new DataProvider(); + spyOn(provider, "getImageBuffer").and.returnValue(Promise.resolve(new ArrayBuffer(0))); + const getMesh = spyOn(provider, "getMesh").and.returnValue(Promise.resolve({ faces: [], vertices: [] })); + const image = new Image(); + spyOn(window, "Image").and.returnValue(image); + new MockCreator().mockProperty(image, "src", ""); + spyOn(window, "Blob").and.returnValue({}); + spyOn(window.URL, "createObjectURL").and.returnValue("image-url"); + return { + cache: new ImageCache(provider), + provider, + spatial: new ImageHelper().createSpatialImageEnt(), + image, + getMesh, + }; +} + +async function cacheMesh(fixture: MeshFixture, merged: boolean = true): Promise { + const result = firstValueFrom(fixture.cache.cacheAssets$(fixture.spatial, merged)); + await Promise.resolve(); + fixture.image.dispatchEvent(new CustomEvent("load")); + return result; +} + +const absentMeshValues: (string | null | undefined)[] = [null, undefined]; + +describe("ImageCache mesh metadata and downloads", () => { + for (const meshId of absentMeshValues) { + for (const url of absentMeshValues) { + it(`should quietly use an empty mesh for absent metadata (${meshId}, ${url})`, async () => { + const fixture = createMeshFixture(); + fixture.spatial.mesh = { id: meshId, url }; + const warn = spyOn(console, "warn").and.stub(); + const error = spyOn(console, "error").and.stub(); + + const cached = await cacheMesh(fixture); + + expect(cached).toBe(fixture.cache); + expect(cached.mesh).toEqual({ faces: [], vertices: [] }); + expect(cached.image).toBe(fixture.image); + expect(fixture.getMesh).not.toHaveBeenCalled(); + expect(warn).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + }); + } + } + + for (const url of absentMeshValues) { + it(`should warn when an advertised mesh has no URL (${url})`, async () => { + const fixture = createMeshFixture(); + fixture.spatial.mesh = { id: "advertised-mesh", url }; + const warn = spyOn(console, "warn").and.stub(); + + const cached = await cacheMesh(fixture); + + expect(cached.mesh).toEqual({ faces: [], vertices: [] }); + expect(fixture.getMesh).not.toHaveBeenCalled(); + expect(warn.calls.count()).toBe(1); + expect(warn.calls.first().args).toEqual([ + `Incorrect mesh URL for ${fixture.spatial.id} (advertised-mesh, ${url})`, + ]); + }); + } + + for (const meshId of [null, "advertised-mesh"]) { + it(`should warn for a malformed empty URL even with mesh id ${meshId}`, async () => { + const fixture = createMeshFixture(); + fixture.spatial.mesh = { id: meshId, url: "" }; + const warn = spyOn(console, "warn").and.stub(); + + const cached = await cacheMesh(fixture); + + expect(cached.mesh).toEqual({ faces: [], vertices: [] }); + expect(fixture.getMesh).not.toHaveBeenCalled(); + expect(warn.calls.count()).toBe(1); + expect(warn.calls.first().args).toEqual([ + `Incorrect mesh URL for ${fixture.spatial.id} (${meshId}, )`, + ]); + }); + } + + it("should not request or warn about a mesh for an unmerged image", async () => { + const fixture = createMeshFixture(); + const warn = spyOn(console, "warn").and.stub(); + const error = spyOn(console, "error").and.stub(); + + const cached = await cacheMesh(fixture, false); + + expect(cached.mesh).toEqual({ faces: [], vertices: [] }); + expect(fixture.getMesh).not.toHaveBeenCalled(); + expect(warn).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + }); + + it("should retain a successfully downloaded mesh", async () => { + const fixture = createMeshFixture(); + const mesh: MeshContract = { faces: [0, 1, 2], vertices: [1, 2, 3] }; + fixture.getMesh.and.returnValue(Promise.resolve(mesh)); + const warn = spyOn(console, "warn").and.stub(); + const error = spyOn(console, "error").and.stub(); + + const cached = await cacheMesh(fixture); + + expect(cached.mesh).toBe(mesh); + expect(fixture.getMesh.calls.count()).toBe(1); + expect(fixture.getMesh.calls.first().args[0]).toBe(fixture.spatial.mesh.url); + expect(fixture.getMesh.calls.first().args[1]).toBeInstanceOf(Promise); + expect(warn).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + }); + + it("should log a failed download and retain the empty-mesh fallback", async () => { + const fixture = createMeshFixture(); + const downloadError = new Error("mesh download failed"); + fixture.getMesh.and.returnValue(Promise.reject(downloadError)); + const warn = spyOn(console, "warn").and.stub(); + const error = spyOn(console, "error").and.stub(); + + const cached = await cacheMesh(fixture); + + expect(cached.mesh).toEqual({ faces: [], vertices: [] }); + expect(fixture.getMesh.calls.count()).toBe(1); + expect(warn).not.toHaveBeenCalled(); + expect(error.calls.count()).toBe(1); + expect(error.calls.first().args).toEqual([downloadError]); + }); +}); diff --git a/test/state/StateContext.test.ts b/test/state/StateContext.test.ts new file mode 100644 index 000000000..b48c35cb7 --- /dev/null +++ b/test/state/StateContext.test.ts @@ -0,0 +1,198 @@ +import { bootstrap } from "../Bootstrap"; +bootstrap(); + +import { ImageHelper } from "../helper/ImageHelper"; +import { DataProvider } from "../helper/ProviderHelper"; +import { generateStateParams } from "../helper/StateHelper"; +import { TestImage } from "../helper/TestImage"; +import { Camera } from "../../src/geo/Camera"; +import { ImageCache } from "../../src/graph/ImageCache"; +import { State } from "../../src/state/State"; +import { StateContext } from "../../src/state/StateContext"; +import { StateTransitionMatrix } from "../../src/state/StateTransitionMatrix"; +import { TransitionMode } from "../../src/state/TransitionMode"; +import { StateBase } from "../../src/state/state/StateBase"; +import { ProjectionService } from "../../src/viewer/ProjectionService"; + +const modes = [ + State.Custom, + State.Earth, + State.GravityTraversing, + State.Traversing, + State.Waiting, + State.WaitingInteractively, +]; + +const requests: { [state: number]: (context: StateContext) => void } = { + [State.Custom]: (context: StateContext): void => { context.custom(); }, + [State.Earth]: (context: StateContext): void => { context.earth(); }, + [State.GravityTraversing]: (context: StateContext): void => { context.gravityTraverse(); }, + [State.Traversing]: (context: StateContext): void => { context.traverse(); }, + [State.Waiting]: (context: StateContext): void => { context.wait(); }, + [State.WaitingInteractively]: (context: StateContext): void => { context.waitInteractively(); }, +}; + +const allowed: { [state: number]: State[] } = { + [State.Custom]: [State.Earth, State.GravityTraversing, State.Traversing], + [State.Earth]: [State.Custom, State.GravityTraversing, State.Traversing], + [State.GravityTraversing]: [State.Custom, State.Earth, State.Traversing, State.Waiting, State.WaitingInteractively], + [State.Traversing]: [State.Custom, State.Earth, State.GravityTraversing, State.Waiting, State.WaitingInteractively], + [State.Waiting]: [State.GravityTraversing, State.Traversing, State.WaitingInteractively], + [State.WaitingInteractively]: [State.GravityTraversing, State.Traversing, State.Waiting], +}; + +type ContextInternals = { + _state: StateBase; + _transitions: StateTransitionMatrix; +}; + +function internals(context: StateContext): ContextInternals { + return context as unknown as ContextInternals; +} + +function createContext(mode: State, transitionMode: TransitionMode): StateContext { + const options = generateStateParams(); + options.alpha = 0.37; + options.zoom = 1.5; + options.currentIndex = 1; + options.transitionMode = transitionMode; + options.camera.position.fromArray([10, 20, 30]); + options.camera.lookat.fromArray([11, 22, 37]); + options.camera.up.fromArray([0, 0, 1]); + options.reorientations = new Map(); + options.reorientations.set("current", { basic: [0.3, 0.7], forceOnReconstruction: true }); + options.trajectory = ["previous", "current"].map((id: string): TestImage => { + const ent = new ImageHelper().createImageEnt(); + ent.id = id; + ent.atomic_scale = 1; + const image = new TestImage(ent); + image.makeComplete(ent); + image.mesh = { vertices: [], faces: [] }; + image.initializeCache(new ImageCache(new DataProvider())); + image.cacheCamera(new ProjectionService()); + return image; + }); + const context = new StateContext(mode, options.geometry, transitionMode); + const current = internals(context); + current._state = current._transitions.generate(mode, options); + context.rotate({ phi: 0.1, theta: 0.2 }); + context.setCenter([0.2, 0.7]); + context.zoomTo(2.1); + context.setSpeed(2); + return context; +} + +function cameraView(camera: Camera): number[][] { + return [camera.position.toArray(), camera.lookat.toArray(), camera.up.toArray(), [camera.focal]]; +} + +function view(context: StateContext): unknown[] { + return [ + cameraView(context.camera), + cameraView(context.currentCamera), + cameraView(context.previousCamera), + context.getCenter(), + context.zoom, + context.alpha, + context.stateTransitionAlpha, + context.currentIndex, + context.motionless, + context.trajectory.slice(), + { ...context.reference }, + Array.from(internals(context)._state.reorientations.entries()), + ]; +} + +describe("StateContext same-state requests", () => { + for (const mode of modes) { + for (const transitionMode of [TransitionMode.Default, TransitionMode.Instantaneous]) { + it(`should preserve ${State[mode]} in ${TransitionMode[transitionMode]} mode without warning or construction`, () => { + const context = createContext(mode, transitionMode); + const current = internals(context); + const state = current._state; + const properties = { ...state } as { [key: string]: unknown }; + const before = view(context); + expect(current._transitions.validate(state, mode)).toBe(false); + const validate = spyOn(current._transitions, "validate").and.callThrough(); + const transition = spyOn(current._transitions, "transition").and.callThrough(); + const generate = spyOn(current._transitions, "generate").and.callThrough(); + const warn = spyOn(console, "warn").and.stub(); + + requests[mode](context); + requests[mode](context); + + expect(warn).not.toHaveBeenCalled(); + expect(validate).not.toHaveBeenCalled(); + expect(transition).not.toHaveBeenCalled(); + expect(generate).not.toHaveBeenCalled(); + expect(context.state).toBe(mode); + expect(current._state).toBe(state); + const after = state as unknown as { [key: string]: unknown }; + expect(Object.keys(after)).toEqual(Object.keys(properties)); + for (const key of Object.keys(properties)) { + expect(after[key]).toBe(properties[key]); + } + expect(view(context)).toEqual(before); + }); + } + } + + it("should make repeated traverse requests harmless after a valid transition", () => { + const context = createContext(State.Custom, TransitionMode.Default); + const current = internals(context); + const generate = spyOn(current._transitions, "generate").and.callThrough(); + const warn = spyOn(console, "warn").and.stub(); + context.traverse(); + const state = current._state; + const before = view(context); + + for (let i = 0; i < 100; i++) { + context.traverse(); + } + + expect(context.state).toBe(State.Traversing); + expect(current._state).toBe(state); + expect(view(context)).toEqual(before); + expect(generate.calls.count()).toBe(1); + expect(warn).not.toHaveBeenCalled(); + }); +}); + +describe("StateContext different-state requests", () => { + for (const from of modes) { + for (const to of modes.filter((mode: State): boolean => mode !== from)) { + const valid = allowed[from].includes(to); + it(`should ${valid ? "perform" : "warn and reject"} ${State[from]} - ${State[to]}`, () => { + const context = createContext(from, TransitionMode.Default); + const current = internals(context); + const state = current._state; + const before = view(context); + const transition = spyOn(current._transitions, "transition").and.callThrough(); + const generate = spyOn(current._transitions, "generate").and.callThrough(); + const warn = spyOn(console, "warn").and.stub(); + + requests[to](context); + + if (valid) { + expect(context.state).toBe(to); + expect(current._state).not.toBe(state); + expect(transition.calls.count()).toBe(1); + expect(generate.calls.count()).toBe(1); + expect(warn).not.toHaveBeenCalled(); + expect(current._state.reorientations).toBe(state.reorientations); + expect(context.trajectory).toEqual(state.trajectory); + } else { + expect(context.state).toBe(from); + expect(current._state).toBe(state); + expect(view(context)).toEqual(before); + expect(transition).not.toHaveBeenCalled(); + expect(generate).not.toHaveBeenCalled(); + expect(warn.calls.count()).toBe(1); + expect(warn.calls.first().args).toEqual([ + `Transition not valid (${State[from]} - ${State[to]})`, + ]); + } + }); + } + } +}); From a1bfec60cfc67024b8d91dce73b002c9810523e1 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Mon, 2 Nov 2026 08:39:38 +0100 Subject: [PATCH 09/16] Align slider comparisons in screen space Align the two slider images by heading, roll, position and field of view so a side-by-side comparison shows the same subject, add guarded automatic motion between reconstructions, consistent fisheye axes and texture handoff, and make the zoom control keyboard/zoom-state aware. Extensive slider regression tests. --- src/component/bearing/BearingComponent.ts | 40 +- .../interfaces/SliderConfiguration.ts | 32 + .../reorientation/ReorientationComponent.ts | 11 + src/component/slider/SliderAlignment.ts | 23 + src/component/slider/SliderAutomaticMotion.ts | 236 ++++++ src/component/slider/SliderComponent.ts | 362 ++++++--- src/component/slider/SliderGLRenderer.ts | 260 ++++++- src/component/slider/SliderHeading.ts | 17 + src/component/slider/SliderMeshFactory.ts | 15 + src/component/slider/SliderReconstruction.ts | 72 ++ src/component/slider/SliderRoll.ts | 46 ++ src/component/slider/SliderTransform.ts | 3 + .../slider/shaders/fisheye.fragment.glsl.ts | 6 +- .../shaders/fisheye_curtain.fragment.glsl.ts | 16 +- .../shaders/fisheye_curtain.vertex.glsl.ts | 2 + .../perspective_curtain.fragment.glsl.ts | 10 +- .../perspective_curtain.vertex.glsl.ts | 2 + ...pective_distorted_curtain.fragment.glsl.ts | 10 +- ...rspective_distorted_curtain.vertex.glsl.ts | 2 + .../spherical_curtain.fragment.glsl.ts | 12 +- .../shaders/spherical_curtain.vertex.glsl.ts | 2 + src/component/zoom/ZoomComponent.ts | 17 +- src/geometry/camera/FisheyeCamera.ts | 13 +- styles/zoom.css | 17 + .../bearing/BearingComponent.test.ts | 34 + .../ReorientationAdoptedView.test.ts | 70 ++ test/component/slider/SliderAlignment.test.ts | 96 +++ .../slider/SliderAutomaticMotion.test.ts | 718 ++++++++++++++++++ test/component/slider/SliderEntry.test.ts | 77 ++ .../slider/SliderFisheyeAxis.test.ts | 37 + test/component/slider/SliderHeading.test.ts | 67 ++ test/component/slider/SliderPosition.test.ts | 67 ++ test/component/slider/SliderRoll.test.ts | 58 ++ test/component/slider/SliderRotation.test.ts | 113 +++ .../slider/SliderScreenSpace.test.ts | 102 +++ .../slider/SliderSelectionFraming.test.ts | 72 ++ .../slider/SliderTextureHandoff.test.ts | 36 + .../slider/SliderViewAligned.test.ts | 310 ++++++++ test/component/zoom/ZoomComponent.test.ts | 21 + 39 files changed, 2946 insertions(+), 158 deletions(-) create mode 100644 src/component/slider/SliderAlignment.ts create mode 100644 src/component/slider/SliderAutomaticMotion.ts create mode 100644 src/component/slider/SliderHeading.ts create mode 100644 src/component/slider/SliderReconstruction.ts create mode 100644 src/component/slider/SliderRoll.ts create mode 100644 test/component/bearing/BearingComponent.test.ts create mode 100644 test/component/reorientation/ReorientationAdoptedView.test.ts create mode 100644 test/component/slider/SliderAlignment.test.ts create mode 100644 test/component/slider/SliderAutomaticMotion.test.ts create mode 100644 test/component/slider/SliderEntry.test.ts create mode 100644 test/component/slider/SliderFisheyeAxis.test.ts create mode 100644 test/component/slider/SliderHeading.test.ts create mode 100644 test/component/slider/SliderPosition.test.ts create mode 100644 test/component/slider/SliderRoll.test.ts create mode 100644 test/component/slider/SliderRotation.test.ts create mode 100644 test/component/slider/SliderScreenSpace.test.ts create mode 100644 test/component/slider/SliderSelectionFraming.test.ts create mode 100644 test/component/slider/SliderTextureHandoff.test.ts create mode 100644 test/component/slider/SliderViewAligned.test.ts create mode 100644 test/component/zoom/ZoomComponent.test.ts diff --git a/src/component/bearing/BearingComponent.ts b/src/component/bearing/BearingComponent.ts index f648e5595..6df97b5ac 100644 --- a/src/component/bearing/BearingComponent.ts +++ b/src/component/bearing/BearingComponent.ts @@ -50,6 +50,31 @@ interface ImageFovOperation { (state: ImageFovState): ImageFovState; } +/** + * Create the bearing indicator container. + * + * @description virtual-dom writes the selector class names into the + * properties object passed to `h`. The properties must therefore be + * created for every render; a reused object makes the previous and next + * properties identical, so class changes (e.g. switching to and from the + * compact size when the viewer is resized) are never patched. + * + * @ignore + */ +export function createBearingContainer( + compact: boolean, + children: vd.VNode[]): vd.VNode { + + return vd.h( + "div.mapillary-bearing-indicator-container" + + (compact ? ".mapillary-bearing-compact" : ""), + createTooltipProperties( + "Bearing", + { oncontextmenu: (event: MouseEvent): void => { event.preventDefault(); } }, + "left"), + children); +} + /** * @class BearingComponent * @@ -72,7 +97,6 @@ export class BearingComponent extends Component { private _distinctThreshold: number; private _animationSpeed: number; - private _tooltipProperties: vd.createProperties; /** @ignore */ constructor(name: string, container: Container, navigator: Navigator) { @@ -85,10 +109,6 @@ export class BearingComponent extends Component { this._distinctThreshold = Math.PI / 360; this._animationSpeed = 0.075; - this._tooltipProperties = createTooltipProperties( - "Bearing", - { oncontextmenu: (event: MouseEvent): void => { event.preventDefault(); } }, - "left"); } protected _activate(): void { @@ -274,15 +294,13 @@ export class BearingComponent extends Component { const cameraSector: vd.VNode = this._createCircleSectorCompass( this._createCircleSector(Math.max(Math.PI / 20, cf), "#FFF")); - const compact: string = configuration.size === ComponentSize.Small || - configuration.size === ComponentSize.Automatic && size.width < 640 ? - ".mapillary-bearing-compact" : ""; + const compact: boolean = configuration.size === ComponentSize.Small || + configuration.size === ComponentSize.Automatic && size.width < 640; return { name: this._name, - vNode: vd.h( - "div.mapillary-bearing-indicator-container" + compact, - this._tooltipProperties, + vNode: createBearingContainer( + compact, [ background, fovIndicator, diff --git a/src/component/interfaces/SliderConfiguration.ts b/src/component/interfaces/SliderConfiguration.ts index 0ea59689d..b72e710f1 100644 --- a/src/component/interfaces/SliderConfiguration.ts +++ b/src/component/interfaces/SliderConfiguration.ts @@ -77,6 +77,38 @@ export interface SliderConfiguration extends ComponentConfiguration { * @default false */ gravityAligned?: boolean; + /** + * Reproject the background into the foreground's live world view. + * + * @description Opt-in for calibrated common-view comparisons. Preserves + * the original foreground camera and each image's lens projection. Use + * Stationary mode; the caller must validate shared coverage, crop the + * view, and prevent interaction into uncovered regions. Overrides + * gravityAligned camera leveling for this comparison. + * + * @default false + */ + viewAligned?: boolean; + /** + * Opt in to guarded, interactive 3D translation for viewAligned pairs. + * + * @description Only hydrated perspective pairs in the same nonempty merged + * component, with genuine meshes, valid poses/scales, <=2m baseline, + * <=10-degree sampled hit parallax and shared photo/mesh coverage can use Motion. + * Unsafe or unavailable data always uses Stationary; mode controls cannot + * override rejection. Explicit Stationary remains manual 2D. Coverage is a + * conservative bounded heuristic, not a mathematical guarantee. + * @default false + */ + automaticMotion?: boolean; + /** + * Clip the foreground at a fraction of the visible viewport width. + * + * @description Keeps the split independent of image projection, pan, + * horizon leveling and zoom. Image-coordinate clipping remains the default. + * @default false + */ + screenSpace?: boolean; /** * Initial position of the slider on the interval [0, 1]. * diff --git a/src/component/reorientation/ReorientationComponent.ts b/src/component/reorientation/ReorientationComponent.ts index 2e14bdaa8..2acf9fe4d 100644 --- a/src/component/reorientation/ReorientationComponent.ts +++ b/src/component/reorientation/ReorientationComponent.ts @@ -768,7 +768,18 @@ export class ReorientationComponent center[0]); if (this._adoptedView != null) { const adopted = this._adoptedView; + const adoptedZoom = this._adoptedZoom; this._clearAdoptedView(); + // The host may have set this view before the first + // image existed. State transitions above can discard + // that request, so apply it in the loaded image's + // final frame rather than only adopting its offset. + this._navigator.stateService.setCenter(adopted); + if (adoptedZoom != null) { + this._navigator.stateService.setZoom(adoptedZoom); + } + center = adopted; + pitchResetDeg = 0; viewX = adopted[0]; this._userOffsetX = wrapDelta(adopted[0] - result.basicX); diff --git a/src/component/slider/SliderAlignment.ts b/src/component/slider/SliderAlignment.ts new file mode 100644 index 000000000..5cd1b475a --- /dev/null +++ b/src/component/slider/SliderAlignment.ts @@ -0,0 +1,23 @@ +import * as THREE from "three"; +import { Image } from "../../graph/Image"; +import { Transform } from "../../geo/Transform"; +import { isSpherical } from "../../geo/Geo"; +import { hasReconstructionMesh } from "../../util/Mesh"; + +/** Match the ordinary viewer's refusal to level an uncalibrated panorama. */ +export function canLevelSliderImage(image: Image, transform: Transform): boolean { + if (!hasReconstructionMesh(image.mesh)) { + return false; + } + const cameraUp = transform.upVector().normalize(); + const forward = new THREE.Vector3().fromArray(transform.unprojectBasic([0.5, 0.5], 10)) + .sub(new THREE.Vector3().fromArray(transform.unprojectBasic([0.5, 0.5], 0))) + .normalize(); + const imageUp = cameraUp.clone().addScaledVector(forward, -cameraUp.dot(forward)).normalize(); + const gravityUp = new THREE.Vector3(0, 0, 1).addScaledVector(forward, -forward.z).normalize(); + const roll = imageUp.angleTo(gravityUp) * 180 / Math.PI; + const pitch = Math.asin(Math.max(-1, Math.min(1, forward.z))) * 180 / Math.PI; + return Number.isFinite(roll) && Number.isFinite(pitch) && + Math.abs(pitch) <= 75 && roll <= 45 && + (isSpherical(image.cameraType) || roll >= 7); +} diff --git a/src/component/slider/SliderAutomaticMotion.ts b/src/component/slider/SliderAutomaticMotion.ts new file mode 100644 index 000000000..b2bb1daae --- /dev/null +++ b/src/component/slider/SliderAutomaticMotion.ts @@ -0,0 +1,236 @@ +import * as THREE from "three"; +import { Image } from "../../graph/Image"; +import { IAnimationState } from "../../state/interfaces/IAnimationState"; +import { ViewportSize } from "../../render/interfaces/ViewportSize"; +import { SliderConfiguration, SliderConfigurationMode } from "../interfaces/SliderConfiguration"; +import { hasReconstructionMesh } from "../../util/Mesh"; +import { SliderMeshFactory } from "./SliderMeshFactory"; +import { SliderTransform } from "./SliderTransform"; +import { sliderReconstructionImage, sliderPairKey, sliderCameraOrigin } from "./SliderReconstruction"; + +/** A camera-only translation: never interpolate direction/up or regenerate the + * live projection (including its off-center crop). Curtain 0 is background. */ +export function translatedSliderPerspective( + live: THREE.PerspectiveCamera, + previousOrigin: THREE.Vector3, + currentOrigin: THREE.Vector3, + curtain: number): THREE.PerspectiveCamera { + const camera = live.clone(); + camera.position.copy(previousOrigin).lerp(currentOrigin, curtain); + // Preserve the actual live world frame even with matrixAutoUpdate=false + // (custom projections/controllers may maintain the matrices directly). + camera.matrixAutoUpdate = false; + camera.matrix.copy(live.matrixWorld).setPosition(camera.position); + // A matrix-only edit does not set matrixWorldNeedsUpdate. Write both world + // matrices explicitly so frozen/custom cameras really translate in GL and + // raycasting, rather than only changing the public position property. + camera.matrixWorld.copy(camera.matrix); + camera.matrixWorldInverse.copy(camera.matrixWorld).invert(); + return camera; +} + +/** Conservative sampled coverage, NOT a mathematical proof of continuous + * visibility. Both nearest mesh hits must be inside BOTH calibrated photos. + * Uses the renderer's exact 5..200m clamped/expanded reconstruction geometry. + * Requires one nonempty shared merge component, <=2m baseline (no cluster + * exception), and <=10-degree native-to-translated parallax at every hit. + * 7x7 rays cover the viewport plus 0.5% overscan; >=9 path stops (<=2m apart), + * 1% photo inset and <=10k triangles per mesh bound the work. These guards + * remain conservative heuristics, NOT a visual quality guarantee. + * The component invalidates immediately on view change, then checks only a + * settled view (not animation frames). One pair's geometry and at most eight + * projection/viewport decisions are retained; manual 2D/3D reuses them. + */ +export class SliderAutomaticMotion { + private _factory = new SliderMeshFactory(); + private _pair: string; + private _meshes: object[] = []; + private _geometries: THREE.BufferGeometry[] = []; + private _transforms: SliderTransform[] = []; + private _triangles: THREE.Triangle[][] = []; + private _coverage = new Map(); + private _origins: THREE.Vector3[] = []; + private _prepared: boolean = false; + + public resolve( + configuration: SliderConfiguration, + state: IAnimationState, + perspective: THREE.PerspectiveCamera, + size: ViewportSize, + renderedImageId: string): SliderConfigurationMode { + if (!(configuration.automaticMotion === true && configuration.viewAligned === true)) { + return configuration.mode; + } + try { + const ids = configuration.ids; + if (!state || !ids || !state.currentImage || !state.previousImage || + state.currentImage.id !== ids.foreground || state.previousImage.id !== ids.background || + renderedImageId !== ids.foreground || state.motionless !== false || + !perspective || !size || ![size.width, size.height].every(n => Number.isFinite(n) && n > 0)) { + return SliderConfigurationMode.Stationary; + } + if (!this._prepare(state)) { + return SliderConfigurationMode.Stationary; + } + const values = perspective.projectionMatrix.elements.concat( + perspective.projectionMatrixInverse.elements, perspective.quaternion.toArray(), perspective.up.toArray(), + perspective.matrixWorld.elements.slice(0, 12)); + if (!values.every(Number.isFinite) || !Number.isFinite(perspective.near) || + !Number.isFinite(perspective.far) || perspective.near <= 0 || perspective.far <= perspective.near || + Math.abs(perspective.projectionMatrix.determinant()) < 1e-12) { + return SliderConfigurationMode.Stationary; + } + const key = values.concat([size.width, size.height, perspective.near, perspective.far]).join(","); + if (!this._coverage.has(key)) { + if (this._coverage.size >= 8) { this._coverage.delete(this._coverage.keys().next().value); } + this._coverage.set(key, this._covered(perspective)); + } + return this._coverage.get(key) && configuration.mode === SliderConfigurationMode.Motion ? + SliderConfigurationMode.Motion : SliderConfigurationMode.Stationary; + } catch (_) { + // Partial hydration or corrupt camera/mesh data must fail closed. + this.dispose(); + return SliderConfigurationMode.Stationary; + } + } + + public dispose(): void { + this._geometries.forEach(g => g.dispose()); + this._geometries = []; + this._transforms = []; + this._triangles = []; + this._origins = []; + this._meshes = []; + this._pair = null; + this._prepared = false; + this._coverage.clear(); + } + + private _prepare(state: IAnimationState): boolean { + const pairImages = [state.previousImage, state.currentImage]; + const mergeId = pairImages[0].mergeId; + // "Merged" independently is insufficient: computed GPS can place + // unrelated reconstruction frames together (e.g. parking under a street). + // Ray/photo hits only test local coverage, not global pose coherence. + // Require the same nonempty aligned component BEFORE using any meshes. + if (typeof mergeId !== "string" || mergeId.trim().length === 0 || + mergeId !== pairImages[1].mergeId) { return false; } + const images = pairImages.map(sliderReconstructionImage); + const transforms = [state.previousTransform, state.currentTransform]; + if (images.some(i => i.cameraType !== "perspective" || i.merged !== true) || + transforms.some(t => !t || t.cameraType !== "perspective")) { return false; } + // The same bounded primitive pose/calibration/version key is used by + // GL, including same-object mutations. Mesh snapshots are immutable. + const pair = sliderPairKey(state); + const meshes = images.map(i => i.mesh); + if (pair === this._pair && meshes.every((m, n) => m === this._meshes[n])) { + return this._prepared; + } + this.dispose(); + this._pair = pair; + this._meshes = meshes; + for (let n = 0; n < 2; n++) { + const i = images[n]; + const t = transforms[n]; + if (!this._validImage(i) || !Number.isFinite(t.scale) || !t.hasValidScale || + !t.rt.elements.every(Number.isFinite) || Math.abs(t.rt.determinant()) < 1e-8) { return false; } + const e = t.rt.elements; + this._transforms.push(new SliderTransform(i.exifOrientation, i.width, i.height, i.scale, + i.rotation, [e[12], e[13], e[14]], i.image, undefined, + Object.values(i.camera.parameters), "perspective")); + } + this._origins = transforms.map(sliderCameraOrigin); + const distance = this._origins[0].distanceTo(this._origins[1]); + const ups = this._transforms.map(t => t.upVector()); + // Automatic opt-in only: sparse clamped meshes can have ample photo + // coverage yet stretch badly across a large baseline. No cluster + // exception to this conservative quality cap; legacy motion is unchanged. + if (!Number.isFinite(distance) || distance > 2 || + ups.some(u => !u.toArray().every(Number.isFinite) || u.lengthSq() < 0.99) || + ups[0].angleTo(ups[1]) > Math.PI / 6) { return false; } + for (let n = 0; n < 2; n++) { + const geometry = this._factory.createReconstructionGeometry(images[n], this._transforms[n]); + if (!geometry) { return false; } + this._geometries.push(geometry); + const positions = geometry.getAttribute("position") as THREE.BufferAttribute; + const indices = geometry.index; + const triangles: THREE.Triangle[] = []; + for (let j = 0; j < indices.count; j += 3) { + const points = [0, 1, 2].map(k => new THREE.Vector3().fromBufferAttribute(positions, indices.getX(j + k))); + if (points.some(p => !p.toArray().every(Number.isFinite))) { return false; } + triangles.push(new THREE.Triangle(points[0], points[1], points[2])); + } + this._triangles.push(triangles); + } + this._prepared = true; + return true; + } + + private _validImage(image: Image): boolean { + const mesh = image.mesh; + return image.camera && image.camera.type === "perspective" && + [1, 3, 6, 8].indexOf(image.exifOrientation) >= 0 && + [image.width, image.height, image.scale, image.camera.parameters.focal].every(n => Number.isFinite(n) && n > 0) && + image.scale > 0.01 && image.scale < 50 && + Array.isArray(image.rotation) && image.rotation.length === 3 && image.rotation.every(Number.isFinite) && + Object.values(image.camera.parameters).length <= 3 && + Object.values(image.camera.parameters).every(Number.isFinite) && + mesh && Array.isArray(mesh.vertices) && Array.isArray(mesh.faces) && + mesh.vertices.length >= 9 && mesh.vertices.length <= 3 * 65535 && mesh.vertices.length % 3 === 0 && + mesh.faces.length >= 3 && mesh.faces.length <= 30000 && mesh.faces.length % 3 === 0 && + hasReconstructionMesh(mesh) && mesh.vertices.every((v, n) => Number.isFinite(v) && (n % 3 !== 2 || v > 0)) && + mesh.faces.every(f => Number.isInteger(f) && f >= 0 && f < mesh.vertices.length / 3); + } + + private _covered(live: THREE.PerspectiveCamera): boolean { + const stops = Math.max(8, Math.ceil(this._origins[0].distanceTo(this._origins[1]) / 2)); + const ray = new THREE.Raycaster(); + const intersection = new THREE.Vector3(); + for (let stop = 0; stop <= stops; stop++) { + const camera = translatedSliderPerspective(live, this._origins[0], this._origins[1], stop / stops); + for (let y = 0; y < 7; y++) { + for (let x = 0; x < 7; x++) { + ray.setFromCamera(new THREE.Vector2(1.01 * (x / 3 - 1), 1.01 * (y / 3 - 1)), camera); + if (!ray.ray.direction.toArray().every(Number.isFinite)) { return false; } + for (let meshIndex = 0; meshIndex < this._triangles.length; meshIndex++) { + const triangles = this._triangles[meshIndex]; + let nearest = Infinity; + let hit: THREE.Vector3 = null; + for (const triangle of triangles) { + if (ray.ray.intersectTriangle(triangle.a, triangle.b, triangle.c, false, intersection)) { + const distance = intersection.distanceTo(camera.position); + if (distance < nearest) { nearest = distance; hit = intersection.clone(); } + } + } + // Also respect clipping; never count a clipped surface. + if (!hit || this._transforms.some(t => !this._insidePhoto(hit, t))) { return false; } + const nativeRay = hit.clone().sub(this._origins[meshIndex]); + const translatedRay = hit.clone().sub(camera.position); + const denominator = nativeRay.length() * translatedRay.length(); + const parallaxCosine = nativeRay.dot(translatedRay) / denominator; + // Coverage alone does not rule out severe stretching of + // sparse reconstruction triangles. Bound reprojection + // parallax relative to this mesh's own photo origin. + // This conservative heuristic is NOT a quality proof. + if (!(denominator > 1e-8) || !Number.isFinite(parallaxCosine) || + parallaxCosine < Math.cos(Math.PI / 18)) { return false; } + const depth = hit.clone().applyMatrix4(camera.matrixWorldInverse).z; + if (-depth < camera.near || -depth > camera.far) { return false; } + } + } + } + } + return true; + } + + private _insidePhoto(point: THREE.Vector3, transform: SliderTransform): boolean { + const local = point.clone().applyMatrix4(transform.basicRt); + if (!(local.z > 0)) { return false; } + const radius = Math.hypot(local.x / local.z, local.y / local.z); + // Reject the ambiguous/non-monotonic lens branch instead of trusting + // the shader's radial clamp to manufacture coverage. + if (Number.isFinite(transform.radialPeak) && radius >= transform.radialPeak) { return false; } + const basic = transform.projectBasic(point.toArray()); + return basic.every(v => Number.isFinite(v) && v >= 0.01 && v <= 0.99); + } +} diff --git a/src/component/slider/SliderComponent.ts b/src/component/slider/SliderComponent.ts index f13a8d97b..fc1f37050 100644 --- a/src/component/slider/SliderComponent.ts +++ b/src/component/slider/SliderComponent.ts @@ -2,7 +2,10 @@ import * as THREE from "three"; import { combineLatest as observableCombineLatest, concat as observableConcat, + merge as observableMerge, empty as observableEmpty, + of as observableOf, + timer as observableTimer, zip as observableZip, Observable, Subscription, @@ -25,7 +28,6 @@ import { withLatestFrom, } from "rxjs/operators"; import { - SliderImages, SliderCombination, GLRendererOperation, PositionLookat, @@ -61,6 +63,8 @@ import { SliderDOMRenderer } from "./SliderDOMRenderer"; import { isSpherical } from "../../geo/Geo"; import { ComponentName } from "../ComponentName"; import { SliderGLRenderer } from "./SliderGLRenderer"; +import { SliderAutomaticMotion, translatedSliderPerspective } from "./SliderAutomaticMotion"; +import { sliderPairKey, sliderCameraOrigin, sliderViewAlignedStationary } from "./SliderReconstruction"; /** * @class SliderComponent * @@ -97,6 +101,7 @@ export class SliderComponent extends Component { private _glRenderer$: Observable; private _glRendererCreator$: Subject; private _glRendererDisposer$: Subject; + private _position$: Subject = new Subject(); private _waitSubscription: Subscription; /** @ignore */ constructor( @@ -149,6 +154,19 @@ export class SliderComponent extends Component { })) .subscribe(this._glRendererOperation$); } + /** + * Set the curtain position without restarting the comparison renderer. + * + * @param {number} position Position on the interval [0, 1]. + * @throws {RangeError} If position is outside the interval or not finite. + */ + public setPosition(position: number): void { + if (!Number.isFinite(position) || position < 0 || position > 1) { + throw new RangeError("Slider position must be finite and between 0 and 1"); + } + this._position$.next(position); + } + protected _activate(): void { const subs = this._subscriptions; subs.push(this._domRenderer.mode$ @@ -179,24 +197,80 @@ export class SliderComponent extends Component { configuration.initialPosition : 1; }), first()), - this._domRenderer.position$); - const mode$ = this.configuration$.pipe( - map( - (configuration: SliderConfiguration): SliderConfigurationMode => { - return configuration.mode; + observableMerge(this._domRenderer.position$, this._position$)); + const safety = new SliderAutomaticMotion(); + subs.push(new Subscription(() => safety.dispose())); + // Snapshot only changed views. Immediately revoke coverage, then check + // after 100ms of stability: never raycast on each animation/drag frame. + const coverageView$ = this.configuration$.pipe( + map(c => c.automaticMotion === true && c.viewAligned === true), + distinctUntilChanged(), + switchMap(automatic => automatic ? observableCombineLatest( + observableMerge(this._container.renderService.renderCamera$, + this._container.renderService.renderCameraFrame$), + this._container.renderService.size$).pipe( + map(([render, size]: [RenderCamera, ViewportSize]) => ({ + render, size, + key: render && render.perspective ? + [render.currentImageId, ...render.perspective.projectionMatrix.elements, + ...render.perspective.projectionMatrixInverse.elements, + ...render.perspective.quaternion.toArray(), ...render.perspective.up.toArray(), + ...render.perspective.matrixWorld.elements.slice(0, 12), + render.perspective.near, render.perspective.far, size.width, size.height].join(",") : "invalid", + })), + distinctUntilChanged((a, b) => a.key === b.key), + switchMap(({ render, size }) => { + if (!render || !render.perspective) { return observableOf(null); } + const view = { perspective: render.perspective.clone(), imageId: render.currentImageId, + size: { width: size.width, height: size.height } }; + return observableConcat(observableOf(null), observableTimer(100).pipe(map(() => view))); + }), + startWith(null)) : observableOf(null))); + // One decision (including its exact frame/config) is shared by DOM and + // GL. In particular, a new ids selection can never enable the old pair. + const decision$ = observableCombineLatest( + this.configuration$, + this._navigator.stateService.currentState$, + coverageView$, + this._container.renderService.size$.pipe(startWith(null))).pipe( + map(([configuration, frame, view, size]) => { + const automatic = configuration.automaticMotion === true && configuration.viewAligned === true; + const stationaryOnly = sliderViewAlignedStationary(frame.state, configuration.viewAligned === true); + const currentView = view && size && view.size.width === size.width && view.size.height === size.height; + const eligible = automatic && !stationaryOnly && safety.resolve( + { ...configuration, mode: SliderConfigurationMode.Motion }, frame.state, + currentView ? view.perspective : null, size, view && view.imageId) === SliderConfigurationMode.Motion; + const mode = stationaryOnly ? SliderConfigurationMode.Stationary : + automatic ? eligible && configuration.mode === SliderConfigurationMode.Motion ? + SliderConfigurationMode.Motion : SliderConfigurationMode.Stationary : configuration.mode; + return { configuration, frame, mode, automatic, eligible, stationaryOnly }; }), - distinctUntilChanged()); - const gravityAligned$ = this.configuration$.pipe( + publishReplay(1), + refCount()); + const viewAligned$ = this.configuration$.pipe( map( (configuration: SliderConfiguration): boolean => - configuration.gravityAligned === true), + configuration.viewAligned === true), distinctUntilChanged()); - const motionless$ = this._navigator.stateService.currentState$.pipe( - map( - (frame: AnimationFrame): boolean => { - return frame.state.motionless; - }), + const screenSpace$ = this.configuration$.pipe( + map((configuration: SliderConfiguration): boolean => + configuration.screenSpace === true), distinctUntilChanged()); + const interactionDecision$ = decision$.pipe( + // AnimationFrame.state can be a shared mutable StateContext. Copy + // primitives BEFORE distinct, never compare aliases to old frames. + map(d => ({ + mode: d.mode, automatic: d.automatic, viewAligned: d.configuration.viewAligned === true, motionless: d.frame.state.motionless, + paired: !!d.frame.state.currentImage && !!d.frame.state.previousImage, + spherical: !!d.frame.state.currentImage && isSpherical(d.frame.state.currentImage.cameraType), + key: JSON.stringify([d.mode, d.automatic, d.configuration.viewAligned === true, d.frame.state.motionless, + !!d.frame.state.currentImage, !!d.frame.state.previousImage, + d.frame.state.currentImage && d.frame.state.currentImage.id, + d.frame.state.previousImage && d.frame.state.previousImage.id, + d.frame.state.currentImage && d.frame.state.currentImage.cameraType, + d.frame.state.previousImage && d.frame.state.previousImage.cameraType]), + })), + distinctUntilChanged((a, b) => a.key === b.key)); const spherical$ = this._navigator.stateService.currentState$.pipe( map( (frame: AnimationFrame): boolean => { @@ -209,70 +283,54 @@ export class SliderComponent extends Component { (configuration: SliderConfiguration): boolean => { return configuration.sliderVisible; })), - this._navigator.stateService.currentState$.pipe( + this._navigator.stateService.currentState$, + viewAligned$).pipe( map( - (frame: AnimationFrame): boolean => { - return !(frame.state.currentImage == null || + ([sliderVisible, frame, viewAligned]: + [boolean, AnimationFrame, boolean]): boolean => { + return sliderVisible && !(frame.state.currentImage == null || frame.state.previousImage == null || - (isSpherical( + (!viewAligned && isSpherical( frame.state.currentImage.cameraType) && - !isSpherical( - frame.state.previousImage.cameraType))); + !isSpherical(frame.state.previousImage.cameraType))); }), - distinctUntilChanged())).pipe( - map( - ([sliderVisible, enabledState]: [boolean, boolean]): boolean => { - return sliderVisible && enabledState; - }), - distinctUntilChanged()); + distinctUntilChanged()); this._waitSubscription = observableCombineLatest( - mode$, - motionless$, - spherical$, + interactionDecision$, sliderVisible$, this._navigator.stateService.state$) .subscribe( - ([mode, motionless, spherical, sliderVisible, state]: - [SliderConfigurationMode, boolean, boolean, boolean, State]): void => { - const interactive: boolean = sliderVisible && - (motionless || - mode === SliderConfigurationMode.Stationary || - spherical); + ([decision, sliderVisible, state]): void => { + // Waiting resets zoom and the selected direction. View-aligned + // comparisons MUST stay interactive even in effective Motion; + // only the GL clone translates, never the native source view. + if (!sliderVisible || !decision.paired) { return; } + const interactive = decision.viewAligned || decision.automatic || decision.motionless || + decision.mode === SliderConfigurationMode.Stationary || decision.spherical; if (interactive && state !== State.WaitingInteractively) { this._navigator.stateService.waitInteractively(); } else if (!interactive && state !== State.Waiting) { this._navigator.stateService.wait(); } }); + subs.push(observableCombineLatest(position$, interactionDecision$, sliderVisible$) + .subscribe(([position, decision]): void => { + if (decision.viewAligned || decision.automatic || decision.motionless || + decision.mode === SliderConfigurationMode.Stationary || decision.spherical) { + this._navigator.stateService.moveTo(1); + } else { + this._navigator.stateService.moveTo(position); + } + })); subs.push(observableCombineLatest( - position$, - mode$, - motionless$, - spherical$, - sliderVisible$) - .subscribe( - ([position, mode, motionless, spherical]: [number, SliderConfigurationMode, boolean, boolean, boolean]): void => { - if (motionless || mode === SliderConfigurationMode.Stationary || spherical) { - this._navigator.stateService.moveTo(1); - } else { - this._navigator.stateService.moveTo(position); - } - })); - subs.push(observableCombineLatest( - position$, - mode$, - motionless$, - spherical$, - sliderVisible$, - this._container.renderService.size$).pipe( - map( - ([position, mode, motionless, spherical, sliderVisible]: - [number, SliderConfigurationMode, boolean, boolean, boolean, ViewportSize]): VirtualNodeHash => { - return { - name: this._name, - vNode: this._domRenderer.render(position, mode, motionless, spherical, sliderVisible), - }; - })) + position$, decision$, sliderVisible$, this._container.renderService.size$).pipe( + map(([position, decision, sliderVisible]): VirtualNodeHash => ({ + name: this._name, + vNode: this._domRenderer.render(position, decision.mode, + decision.frame.state.motionless || decision.stationaryOnly || decision.automatic && !decision.eligible, + !!decision.frame.state.currentImage && isSpherical(decision.frame.state.currentImage.cameraType), + sliderVisible), + }))) .subscribe(this._container.domRenderer.render$)); this._glRendererCreator$.next(null); subs.push(observableCombineLatest( @@ -280,38 +338,43 @@ export class SliderComponent extends Component { spherical$, sliderVisible$, this._container.renderService.renderCamera$, - this._navigator.stateService.currentTransform$).pipe( + this._navigator.stateService.currentTransform$, + screenSpace$).pipe( map( - ([position, spherical, visible, render, transform]: [number, boolean, boolean, RenderCamera, Transform]): number => { + ([position, spherical, visible, render, transform, screenSpace]: + [number, boolean, boolean, RenderCamera, Transform, boolean]): [number, boolean] => { + if (screenSpace) { + return [visible ? position : 1, true]; + } if (!spherical) { - return visible ? position : 1; + return [visible ? position : 1, false]; } const basicMin: number[] = this._viewportCoords.viewportToBasic(-1.15, 0, transform, render.perspective); const basicMax: number[] = this._viewportCoords.viewportToBasic(1.15, 0, transform, render.perspective); const shiftedMax: number = basicMax[0] < basicMin[0] ? basicMax[0] + 1 : basicMax[0]; const basicPosition: number = basicMin[0] + position * (shiftedMax - basicMin[0]); - return basicPosition > 1 ? basicPosition - 1 : basicPosition; + return [basicPosition > 1 ? basicPosition - 1 : basicPosition, false]; }), map( - (position: number): GLRendererOperation => { + ([position, screenSpace]: [number, boolean]): GLRendererOperation => { return (glRenderer: SliderGLRenderer): SliderGLRenderer => { - glRenderer.updateCurtain(position); + glRenderer.updateCurtain(position, screenSpace); return glRenderer; }; })) .subscribe(this._glRendererOperation$)); - subs.push(observableCombineLatest( - this._navigator.stateService.currentState$, - mode$, - gravityAligned$).pipe( - map( - ([frame, mode, gravityAligned]: - [AnimationFrame, SliderConfigurationMode, boolean]): GLRendererOperation => { - return (glRenderer: SliderGLRenderer): SliderGLRenderer => { - glRenderer.update(frame, mode, gravityAligned); - return glRenderer; - }; - })) + subs.push(decision$.pipe( + map((decision): GLRendererOperation => (glRenderer): SliderGLRenderer => { + const c = decision.configuration; + if (decision.automatic) { + glRenderer.update(decision.frame, decision.mode, + c.gravityAligned === true, c.viewAligned === true, true); + } else { + glRenderer.update(decision.frame, decision.mode, + c.gravityAligned === true, c.viewAligned === true); + } + return glRenderer; + })) .subscribe(this._glRendererOperation$)); subs.push(this._configuration$.pipe( filter( @@ -319,43 +382,47 @@ export class SliderComponent extends Component { return configuration.ids != null; }), switchMap( - (configuration: SliderConfiguration): Observable => { + (configuration: SliderConfiguration): Observable => { return observableZip( - observableZip( - this._catchCacheImage$( - configuration.ids.background), - this._catchCacheImage$( - configuration.ids.foreground)).pipe( - map( - (images: [Image, Image]) - : SliderImages => { - return { background: images[0], foreground: images[1] }; - })), - this._navigator.stateService.currentState$.pipe(first())).pipe( - map( - (nf: [SliderImages, AnimationFrame]): SliderCombination => { - return { images: nf[0], state: nf[1].state }; - })); + this._catchCacheImage$(configuration.ids.background), + this._catchCacheImage$(configuration.ids.foreground)).pipe( + // Read the live source view after hydration, so wheel + // zoom while the new background loads is not undone. + switchMap((images: [Image, Image]) => + this._navigator.stateService.currentState$.pipe( + first(), + map((frame: AnimationFrame): SliderCombination & { viewAligned: boolean } => ({ + images: { background: images[0], foreground: images[1] }, + state: frame.state, + viewAligned: configuration.viewAligned === true, + }))))); })) .subscribe( - (co: SliderCombination): void => { + (co: SliderCombination & { viewAligned: boolean }): void => { if (co.state.currentImage != null && co.state.previousImage != null && co.state.currentImage.id === co.images.foreground.id && co.state.previousImage.id === co.images.background.id) { return; } + const center = co.viewAligned && co.state.currentImage.id === co.images.foreground.id ? + co.state.currentTransform.projectBasic(co.state.camera.lookat.toArray()) : null; if (co.state.currentImage.id === co.images.background.id) { this._navigator.stateService.setImages([co.images.foreground]); - return; - } - if (co.state.currentImage.id === co.images.foreground.id && + } else if (co.state.currentImage.id === co.images.foreground.id && co.state.trajectory.length === 1) { this._navigator.stateService.prependImages([co.images.background]); - return; + } else { + this._navigator.stateService.setImages([co.images.background]); + this._navigator.stateService.setImages([co.images.foreground]); + } + // Replacing a regular-photo trajectory recreates its native + // cameras. Retain the source's live center (including wheel + // zoom's anchor) instead of snapping back to the optical axis. + // Zoom is already retained by state; never overwrite it here. + if (center != null) { + this._navigator.stateService.setCenter(center); } - this._navigator.stateService.setImages([co.images.background]); - this._navigator.stateService.setImages([co.images.foreground]); }, (e: Error): void => { console.error(e); @@ -368,6 +435,7 @@ export class SliderComponent extends Component { this._navigator.stateService.currentState$ : new Subject(); }), + filter(frame => !!frame.state.currentImage && !!frame.state.currentTransform), distinctUntilChanged( undefined, (frame: AnimationFrame): string => { @@ -457,15 +525,37 @@ export class SliderComponent extends Component { withLatestFrom( this._container.renderService.size$, this._navigator.stateService.currentTransform$)); + const viewAlignedRoiTrigger$ = observableCombineLatest( + decision$, position$, this._container.renderService.renderCameraFrame$, this._container.renderService.size$).pipe( + map(([d, position, camera, size]) => ({ + viewAligned: d.configuration.viewAligned === true, mode: d.mode, + camera, size, transform: d.frame.state.currentTransform, + key: [d.configuration.viewAligned === true, d.mode, + d.configuration.viewAligned === true ? sliderPairKey(d.frame.state) : "legacy", + d.frame.state.currentImage && d.frame.state.currentImage.id, + d.frame.state.previousImage && d.frame.state.previousImage.id, position, + ...camera.perspective.projectionMatrix.elements, ...camera.perspective.matrixWorld.elements.slice(0, 12), + size.width, size.height].join(","), + })), + distinctUntilChanged((a, b) => a.key === b.key), + filter(d => d.viewAligned), + map(({ camera, size, transform }): [RenderCamera, ViewportSize, Transform] => [camera, size, transform])); subs.push(textureProvider$.pipe( switchMap( (provider: TextureProvider): Observable<[TileRegionOfInterest, TextureProvider]> => { - return roiTrigger$.pipe( + return observableMerge(roiTrigger$, viewAlignedRoiTrigger$).pipe( + withLatestFrom(decision$, position$), + filter(([_view, decision]) => !provider.disposed && + (decision.configuration.viewAligned !== true || + !!decision.frame.state.currentImage && !!decision.frame.state.previousImage && + !!decision.frame.state.currentTransform && !!decision.frame.state.previousTransform)), map( - ([camera, size, transform]: [RenderCamera, ViewportSize, Transform]): + ([[camera, size, transform], decision, position]): [TileRegionOfInterest, TextureProvider] => { return [ - this._roiCalculator.computeRegionOfInterest(camera, size, transform), + decision.configuration.viewAligned === true && decision.mode === SliderConfigurationMode.Motion ? + this._getMotionRegionOfInterest(camera, size, transform, decision.frame, position) : + this._roiCalculator.computeRegionOfInterest(camera, size, transform), provider, ]; })); @@ -499,7 +589,7 @@ export class SliderComponent extends Component { }), filter( (frame: AnimationFrame): boolean => { - return !!frame.state.previousImage; + return !!frame.state.previousImage && !!frame.state.previousTransform; }), distinctUntilChanged( undefined, @@ -591,12 +681,24 @@ export class SliderComponent extends Component { subs.push(textureProviderPrev$.pipe( switchMap( (provider: TextureProvider): Observable<[TileRegionOfInterest, TextureProvider]> => { - return roiTriggerPrev$.pipe( + return observableMerge(roiTriggerPrev$, viewAlignedRoiTrigger$).pipe( + withLatestFrom( + decision$, position$), + filter(([_view, decision]) => !provider.disposed && + (decision.configuration.viewAligned !== true || + !!decision.frame.state.currentImage && !!decision.frame.state.previousImage && + !!decision.frame.state.currentTransform && !!decision.frame.state.previousTransform)), map( - ([camera, size, transform]: [RenderCamera, ViewportSize, Transform]): + ([[camera, size, transform], decision, position]): [TileRegionOfInterest, TextureProvider] => { + const frame = decision.frame; + const viewAligned = decision.configuration.viewAligned === true; return [ - this._roiCalculator.computeRegionOfInterest(camera, size, transform), + decision.configuration.viewAligned === true && decision.mode === SliderConfigurationMode.Motion ? + this._getMotionRegionOfInterest(camera, size, frame.state.previousTransform, frame, position) : + viewAligned ? this._getViewAlignedRegionOfInterest( + camera, size, frame.state.previousTransform) : + this._roiCalculator.computeRegionOfInterest(camera, size, transform), provider, ]; })); @@ -605,9 +707,14 @@ export class SliderComponent extends Component { (args: [TileRegionOfInterest, TextureProvider]): boolean => { return !args[1].disposed; }), - withLatestFrom(this._navigator.stateService.currentState$)) + withLatestFrom(this._navigator.stateService.currentState$, viewAligned$)) .subscribe( - ([[roi, provider], frame]: [[TileRegionOfInterest, TextureProvider], AnimationFrame]): void => { + ([[roi, provider], frame, viewAligned]: + [[TileRegionOfInterest, TextureProvider], AnimationFrame, boolean]): void => { + if (viewAligned) { + provider.setRegionOfInterest(roi); + return; + } let shiftedRoi: TileRegionOfInterest = null; if (isSpherical(frame.state.previousImage.cameraType)) { if (isSpherical(frame.state.currentImage.cameraType)) { @@ -733,6 +840,8 @@ export class SliderComponent extends Component { } protected _getDefaultConfiguration(): SliderConfiguration { return { + viewAligned: false, + automaticMotion: false, initialPosition: 1, mode: SliderConfigurationMode.Motion, sliderVisible: true, @@ -746,6 +855,33 @@ export class SliderComponent extends Component { return observableEmpty(); })); } + private _getMotionRegionOfInterest( + camera: RenderCamera, + size: ViewportSize, + transform: Transform, + frame: AnimationFrame, + position: number): TileRegionOfInterest { + const origins = [frame.state.previousTransform, frame.state.currentTransform].map(sliderCameraOrigin); + const perspective = translatedSliderPerspective(camera.perspective, origins[0], origins[1], position); + const roi = this._roiCalculator.computeRegionOfInterest({ perspective } as RenderCamera, size, transform); + // The general ROI calculator projects at a fixed depth, not at actual + // mesh hits. Use the full photo as a safe superset in view-aligned Motion; + // the provider still selects its bounded tile level from pixel size. + return { ...roi, bbox: { minX: 0, maxX: 1, minY: 0, maxY: 1 } }; + } + private _getViewAlignedRegionOfInterest( + camera: RenderCamera, + size: ViewportSize, + transform: Transform): TileRegionOfInterest { + // Project the same live rays through the background's own pose/lens. + // Using its original origin is equivalent to a recentered panorama. + const perspective = camera.perspective.clone(); + perspective.position.copy(sliderCameraOrigin(transform)); + perspective.updateMatrix(); + perspective.updateMatrixWorld(false); + return this._roiCalculator.computeRegionOfInterest( + { perspective } as RenderCamera, size, transform); + } private _getBasicCorners(currentAspect: number, previousAspect: number): number[][] { let offsetX: number; let offsetY: number; diff --git a/src/component/slider/SliderGLRenderer.ts b/src/component/slider/SliderGLRenderer.ts index 77af39732..b29c0dabe 100644 --- a/src/component/slider/SliderGLRenderer.ts +++ b/src/component/slider/SliderGLRenderer.ts @@ -13,6 +13,11 @@ import { CameraType } from "../../geo/interfaces/CameraType"; import { Camera } from "../../geo/Camera"; import { SliderTransform } from "./SliderTransform"; import { SliderMeshScene, SliderMeshSceneItem } from "./SliderMeshScene"; +import { canLevelSliderImage } from "./SliderAlignment"; +import { sliderHeadingRotation } from "./SliderHeading"; +import { translatedSliderPerspective } from "./SliderAutomaticMotion"; +import { sliderViewAlignedRoll } from "./SliderRoll"; +import { sliderReconstructionImage, sliderPairKey, sliderCameraOrigin, sliderViewAlignedStationary } from "./SliderReconstruction"; export class SliderGLRenderer { private _factory: SliderMeshFactory; private _scene: SliderMeshScene; @@ -21,13 +26,26 @@ export class SliderGLRenderer { private _previousKey: string; private _disabled: boolean; private _curtain: number; + private _screenSpace: boolean = false; private _frameId: number; private _needsRender: boolean; private _mode: SliderConfigurationMode; + private _viewAligned: boolean = false; + private _automaticMotion: boolean = false; + private _motionOrigins: THREE.Vector3[] = []; + private _viewAlignedPair: string; private _gravityAligned: boolean; + private _currentGravityAligned: boolean; + private _previousGravityAligned: boolean; + private _previousSpherical: boolean; private _currentPerspective: THREE.PerspectiveCamera; + private _currentCamera: Camera; private _previousCamera: Camera; + private _currentNativeUp: THREE.Vector3; + private _previousNativeUp: THREE.Vector3; private _previousPerspective: THREE.PerspectiveCamera; + private _previousRotation: THREE.Quaternion; + private _headingRotation: THREE.Quaternion; private _currentProviderDisposers: { [key: string]: () => void }; private _previousProviderDisposers: { [key: string]: () => void }; constructor() { @@ -42,9 +60,15 @@ export class SliderGLRenderer { this._needsRender = false; this._mode = null; this._gravityAligned = false; + this._currentGravityAligned = false; + this._previousGravityAligned = false; + this._previousSpherical = false; this._currentPerspective = null; + this._currentCamera = null; this._previousCamera = null; this._previousPerspective = null; + this._previousRotation = null; + this._headingRotation = new THREE.Quaternion(); this._currentProviderDisposers = {}; this._previousProviderDisposers = {}; } @@ -76,15 +100,27 @@ export class SliderGLRenderer { public update( frame: AnimationFrame, mode: SliderConfigurationMode, - gravityAligned: boolean = false): void { + gravityAligned: boolean = false, + viewAligned: boolean = false, + automaticMotion: boolean = false): void { this._updateFrameId(frame.id); - this._updateImagePlanes(frame.state, mode, gravityAligned); + try { + this._updateImagePlanes(frame.state, mode, gravityAligned, viewAligned, automaticMotion); + } catch (error) { + if (!automaticMotion) { throw error; } + // Corrupt/partially hydrated opt-in data is not a renderer crash. + this._disabled = true; + this._mode = SliderConfigurationMode.Stationary; + this._scene.clear(); + this._needsRender = true; + } } - public updateCurtain(curtain: number): void { - if (this._curtain === curtain) { + public updateCurtain(curtain: number, screenSpace: boolean = false): void { + if (this._curtain === curtain && this._screenSpace === screenSpace) { return; } this._curtain = curtain; + this._screenSpace = screenSpace; this._updateCurtain(); this._needsRender = true; } @@ -170,7 +206,12 @@ export class SliderGLRenderer { private _getCurrentPerspective( perspectiveCamera: THREE.PerspectiveCamera) : THREE.PerspectiveCamera { - if (!this._gravityAligned) { + if (this._viewAligned && + this._mode === SliderConfigurationMode.Motion && this._motionOrigins.length === 2) { + return translatedSliderPerspective(perspectiveCamera, + this._motionOrigins[0], this._motionOrigins[1], this._curtain); + } + if (this._viewAligned || !this._currentGravityAligned) { return perspectiveCamera; } if (this._currentPerspective == null) { @@ -193,7 +234,47 @@ export class SliderGLRenderer { private _getPreviousPerspective( perspectiveCamera: THREE.PerspectiveCamera) : THREE.PerspectiveCamera { - if (this._mode === SliderConfigurationMode.Motion) { + if (this._viewAligned && this._previousCamera != null) { + // Common-view ray reprojection: copy the live foreground frame, + // never the native-relative rotation used by legacy comparisons. + const current = this._getCurrentPerspective(perspectiveCamera); + if (this._previousPerspective == null) { + this._previousPerspective = current.clone(); + } else { + this._previousPerspective.copy(current); + } + this._previousPerspective.position.copy(this._previousSpherical || + this._mode === SliderConfigurationMode.Motion ? + current.position : this._previousCamera.position); + // Keep the comparison's native frame upright like the original's, + // not tilted by the two reconstructions' differing roll estimates. + const roll = sliderViewAlignedRoll(current, + this._currentNativeUp, this._previousNativeUp, + this._previousGravityAligned); + if (this._mode === SliderConfigurationMode.Motion) { + this._previousPerspective.matrixAutoUpdate = false; + this._previousPerspective.matrix.copy(current.matrixWorld) + .setPosition(this._previousPerspective.position); + if (roll !== 0) { + this._previousPerspective.matrix.multiply( + new THREE.Matrix4().makeRotationZ(roll)); + this._previousPerspective.quaternion.setFromRotationMatrix( + new THREE.Matrix4().extractRotation(this._previousPerspective.matrix)); + } + } else { + if (roll !== 0) { + this._previousPerspective.quaternion.multiply( + new THREE.Quaternion().setFromAxisAngle( + new THREE.Vector3(0, 0, 1), roll)); + } + this._previousPerspective.updateMatrix(); + } + this._previousPerspective.updateMatrixWorld(false); + return this._previousPerspective; + } + if (this._mode === SliderConfigurationMode.Motion && + (!this._gravityAligned || + this._currentGravityAligned && this._previousGravityAligned)) { return this._getCurrentPerspective(perspectiveCamera); } if (this._previousCamera == null) { @@ -205,18 +286,51 @@ export class SliderGLRenderer { this._previousCamera.position); this._previousPerspective.up.copy(this._previousCamera.up); this._previousPerspective.matrixAutoUpdate = true; - this._previousPerspective.lookAt(this._previousCamera.lookat); + // Panorama meshes are recentered on the foreground camera. Keep + // the background's independent direction/up, not its GPS position. + const lookat = this._previousCamera.lookat.clone(); + if (this._gravityAligned && this._previousSpherical) { + const direction = lookat.clone().sub(this._previousCamera.position); + this._previousPerspective.position.copy(perspectiveCamera.position); + lookat.copy(perspectiveCamera.position).add(direction); + } + this._previousPerspective.lookAt(lookat); this._previousPerspective.matrixAutoUpdate = false; this._previousPerspective.updateMatrix(); this._previousPerspective.updateMatrixWorld(false); } + if (this._gravityAligned && this._currentCamera != null) { + // Map the live view's native-relative rotation into the comparison's + // upright frame. Do not freeze the fallback camera or share an + // untrusted reconstructed world-up axis between the two images. + if (this._previousRotation == null) { + const currentRotation = this._getCameraQuaternion(this._currentCamera); + this._previousRotation = this._getCameraQuaternion(this._previousCamera) + .multiply(this._headingRotation) + .multiply(currentRotation.invert()); + } + const current = this._getCurrentPerspective(perspectiveCamera); + this._previousPerspective.copy(current); + this._previousPerspective.position.copy(this._previousSpherical ? + current.position : this._previousCamera.position); + this._previousPerspective.up.copy(this._previousCamera.up); + this._previousPerspective.quaternion.copy(this._previousRotation) + .multiply(current.quaternion); + this._previousPerspective.updateMatrix(); + this._previousPerspective.updateMatrixWorld(false); + } return this._previousPerspective; } + private _getCameraQuaternion(camera: Camera): THREE.Quaternion { + const matrix = new THREE.Matrix4().lookAt( + camera.position, camera.lookat, camera.up); + return new THREE.Quaternion().setFromRotationMatrix(matrix); + } private _setDisabled(state: IAnimationState): void { this._disabled = state.currentImage == null || state.previousImage == null || - (isSpherical(state.currentImage.cameraType) && + (!this._viewAligned && isSpherical(state.currentImage.cameraType) && !isSpherical(state.previousImage.cameraType)); } private _setTextureProvider( @@ -254,6 +368,9 @@ export class SliderGLRenderer { if (!!shaderMaterial.uniforms.curtain) { shaderMaterial.uniforms.curtain.value = this._curtain; } + if (shaderMaterial.uniforms.screenSpace) { + shaderMaterial.uniforms.screenSpace.value = this._screenSpace; + } } } private _updateFrameId(frameId: number): void { @@ -262,32 +379,87 @@ export class SliderGLRenderer { private _updateImagePlanes( state: IAnimationState, mode: SliderConfigurationMode, - gravityAligned: boolean): void { - const currentChanged: boolean = state.currentImage != null && this._currentKey !== state.currentImage.id; - const previousChanged: boolean = state.previousImage != null && this._previousKey !== state.previousImage.id; + gravityAligned: boolean, + viewAligned: boolean = false, + automaticMotion: boolean = false): void { + if (viewAligned && (!state.currentImage || !state.currentTransform || + state.previousImage && !state.previousTransform || + !state.previousImage && this._previousKey != null)) { + // Partial hydration must not terminate the shared GL subscription. + // Retain textures/providers; invalidate the pair so the next + // complete same-ID frame rebuilds without losing high-res imagery. + this._disabled = true; + this._mode = SliderConfigurationMode.Stationary; + this._motionOrigins = []; + this._viewAlignedPair = null; + + this._needsRender = true; + return; + } + const pairKey = viewAligned ? sliderPairKey(state) : null; + const pairChanged = viewAligned && pairKey !== this._viewAlignedPair; + if (sliderViewAlignedStationary(state, viewAligned)) { + mode = SliderConfigurationMode.Stationary; + } + const currentIdChanged = state.currentImage != null && this._currentKey !== state.currentImage.id; + const previousIdChanged = state.previousImage != null && this._previousKey !== state.previousImage.id; + const currentChanged: boolean = state.currentImage != null && (currentIdChanged || pairChanged); + const previousChanged: boolean = state.previousImage != null && (previousIdChanged || pairChanged); const modeChanged: boolean = this._mode !== mode; const gravityAlignmentChanged: boolean = this._gravityAligned !== gravityAligned; + const viewAlignmentChanged: boolean = this._viewAligned !== viewAligned; + const automaticChanged = this._automaticMotion !== automaticMotion; if (!(currentChanged || previousChanged || modeChanged || - gravityAlignmentChanged)) { + gravityAlignmentChanged || viewAlignmentChanged || automaticChanged)) { return; } + this._viewAligned = viewAligned; + this._automaticMotion = automaticMotion; + this._viewAlignedPair = pairKey; + this._motionOrigins = viewAligned && !state.motionless && + !sliderViewAlignedStationary(state, viewAligned) && state.previousTransform && state.currentTransform ? + [state.previousTransform, state.currentTransform].map(sliderCameraOrigin) : []; this._setDisabled(state); this._needsRender = true; this._mode = mode; this._gravityAligned = gravityAligned; - this._previousCamera = state.previousCamera == null ? null : - gravityAligned ? new Camera(state.previousTransform) : - state.previousCamera.clone(); - if (gravityAligned && this._previousCamera != null) { + this._currentGravityAligned = gravityAligned && + state.currentImage != null && + canLevelSliderImage(state.currentImage, state.currentTransform); + this._previousSpherical = state.previousImage != null && + isSpherical(state.previousImage.cameraType); + this._previousGravityAligned = gravityAligned && + state.previousImage != null && + canLevelSliderImage(state.previousImage, state.previousTransform); + this._currentCamera = gravityAligned && state.currentTransform != null ? + new Camera(state.currentTransform) : null; + if (this._currentGravityAligned && this._currentCamera != null) { + this._currentCamera.up.set(0, 0, 1); + } + this._previousCamera = viewAligned && state.previousTransform != null ? + new Camera(state.previousTransform) : + state.previousCamera == null ? null : + gravityAligned ? new Camera(state.previousTransform) : + state.previousCamera.clone(); + if (viewAligned && state.previousTransform != null && this._previousCamera != null) { + this._previousCamera.position.copy(sliderCameraOrigin(state.previousTransform)); + } + if (this._previousGravityAligned && this._previousCamera != null) { this._previousCamera.up.set(0, 0, 1); } + this._currentNativeUp = state.currentTransform != null ? + state.currentTransform.upVector() : null; + this._previousNativeUp = state.previousTransform != null ? + state.previousTransform.upVector() : null; this._previousPerspective = null; + this._previousRotation = null; + this._headingRotation = sliderHeadingRotation(state.currentImage, state.previousImage); const motionless = state.motionless || mode === SliderConfigurationMode.Stationary || isSpherical(state.currentImage.cameraType); - if (this.disabled || previousChanged) { + if (this.disabled || (viewAligned ? previousIdChanged : previousChanged)) { if (this._previousKey in this._previousProviderDisposers) { this._previousProviderDisposers[this._previousKey](); delete this._previousProviderDisposers[this._previousKey]; @@ -296,8 +468,10 @@ export class SliderGLRenderer { if (this.disabled) { this._scene.setImagePlanesOld([]); } else { - if (previousChanged || modeChanged || gravityAlignmentChanged) { - const previousNode: Image = state.previousImage; + if (previousChanged || modeChanged || gravityAlignmentChanged || + viewAlignmentChanged || automaticChanged || viewAligned && currentChanged) { + const previousNode: Image = automaticMotion ? + sliderReconstructionImage(state.previousImage) : state.previousImage; this._previousKey = previousNode.id; const elements: number[] = state.currentTransform.rt.elements; let translation: number[] = [elements[12], elements[13], elements[14]]; @@ -338,6 +512,9 @@ export class SliderGLRenderer { const pElements: number[] = state.previousTransform.rt.elements; let pTranslation: number[] = [pElements[12], pElements[13], pElements[14]]; + if (viewAligned && isSpherical(previousNode.cameraType)) { + pTranslation = translation; + } const previousTransform = new SliderTransform( state.previousImage.exifOrientation, state.previousImage.width, @@ -365,7 +542,18 @@ export class SliderGLRenderer { state.currentImage.camera.type); let mesh: THREE.Mesh = undefined; - if (isSpherical(previousNode.cameraType)) { + if (viewAligned) { + // Fisheye and spherical factory paths do not honor the + // reconstruction flag. Hide only the old mesh, without + // mutating the cached image or its camera/lens metadata. + const meshImage: Image = motionless ? + Object.create(previousNode) : previousNode; + if (motionless) { + Object.defineProperty(meshImage, "mesh", { value: null }); + } + mesh = this._factory.createMesh( + meshImage, previousTransform, !motionless); + } else if (isSpherical(previousNode.cameraType)) { mesh = this._factory.createMesh( previousNode, motionless || @@ -382,11 +570,15 @@ export class SliderGLRenderer { } } const previousPlanes: SliderMeshSceneItem[] = [{imageId: previousNode.id, mesh, camera: state.previousImage.camera}]; + if (viewAligned && !previousIdChanged) { + this._preserveTextures(this._scene.planesOld, previousPlanes); + } this._scene.setImagePlanesOld(previousPlanes); } } - if (currentChanged || modeChanged || gravityAlignmentChanged) { - if (this._currentKey in this._currentProviderDisposers) { + if (currentChanged || modeChanged || gravityAlignmentChanged || + viewAlignmentChanged || automaticChanged) { + if ((!viewAligned || currentIdChanged) && this._currentKey in this._currentProviderDisposers) { this._currentProviderDisposers[this._currentKey](); delete this._currentProviderDisposers[this._currentKey]; } @@ -424,15 +616,37 @@ export class SliderGLRenderer { } else { planes.push( {imageId: state.currentImage.id, mesh: this._factory.createCurtainMesh( - state.currentImage, + automaticMotion ? sliderReconstructionImage(state.currentImage) : state.currentImage, currentTransform), camera: state.currentImage.camera} ); } } + if (viewAligned && !currentIdChanged) { + this._preserveTextures(this._scene.planes, planes); + } this._scene.setImagePlanes(planes); this._updateCurtain(); } } + /** Rebuild only geometry/materials for same-id view-aligned mode changes. + * Detach the old texture before scene disposal and retain the live provider + * (its render-target texture will not be recreated on an unchanged id). + */ + private _preserveTextures(oldPlanes: SliderMeshSceneItem[], newPlanes: SliderMeshSceneItem[]): void { + for (const next of newPlanes) { + const old = oldPlanes.filter(p => p.imageId === next.imageId)[0]; + if (!old) { continue; } + const oldMaterial = old.mesh.material as SliderBBoxProjectorShaderMaterial; + const nextMaterial = next.mesh.material as SliderBBoxProjectorShaderMaterial; + const texture = oldMaterial.uniforms.projectorTex.value as THREE.Texture; + const nextTexture = nextMaterial.uniforms.projectorTex.value as THREE.Texture; + if (!texture) { continue; } + // Never dispose the texture being handed over (a shared render target). + if (nextTexture && nextTexture !== texture) { nextTexture.dispose(); } + oldMaterial.uniforms.projectorTex.value = null; + nextMaterial.uniforms.projectorTex.value = texture; + } + } private _updateTexture(texture: THREE.Texture): void { this._needsRender = true; const planes: SliderMeshSceneItem[] = this._scene.planes; diff --git a/src/component/slider/SliderHeading.ts b/src/component/slider/SliderHeading.ts new file mode 100644 index 000000000..9d9a23c44 --- /dev/null +++ b/src/component/slider/SliderHeading.ts @@ -0,0 +1,17 @@ +import * as THREE from "three"; +import { isSpherical } from "../../geo/Geo"; +import { Image } from "../../graph/Image"; + +/** Align upright panorama frames by heading without trusting reconstructed roll/pitch. */ +export function sliderHeadingRotation(current: Image, previous: Image): THREE.Quaternion { + const rotation = new THREE.Quaternion(); + if (current == null || previous == null || + !isSpherical(current.cameraType) || !isSpherical(previous.cameraType) || + !Number.isFinite(current.compassAngle) || !Number.isFinite(previous.compassAngle)) { + return rotation; + } + // Compass bearings are clockwise; local camera yaw around +Y is the inverse. + // A comparison facing north must look south to match a south-facing original. + const heading = ((previous.compassAngle - current.compassAngle) % 360) * Math.PI / 180; + return rotation.setFromAxisAngle(new THREE.Vector3(0, 1, 0), heading); +} diff --git a/src/component/slider/SliderMeshFactory.ts b/src/component/slider/SliderMeshFactory.ts index c3506143f..554662e2d 100644 --- a/src/component/slider/SliderMeshFactory.ts +++ b/src/component/slider/SliderMeshFactory.ts @@ -16,6 +16,17 @@ export class SliderMeshFactory { this._imageSphereRadius = imageSphereRadius != null ? imageSphereRadius : 200; } + /** Geometry only: exactly the clamped perspective reconstruction used by + * both curtain and background meshes. The caller owns/disposes the result. + * Does not allocate a texture or material, and never substitutes a plane. + */ + public createReconstructionGeometry( + image: Image, + transform: SliderTransform): THREE.BufferGeometry { + return transform.cameraType === "perspective" && this._useMesh(transform, image) ? + this._getImagePlaneGeo(transform, image) : null; + } + public createMesh( image: Image, transform: SliderTransform, @@ -182,6 +193,7 @@ export class SliderMeshFactory { transparent: true, uniforms: { curtain: { value: 1.0 }, + screenSpace: { value: false }, opacity: { value: 1.0 }, projectorMat: { value: transform.rt }, projectorTex: { value: texture }, @@ -255,6 +267,7 @@ export class SliderMeshFactory { transparent: true, uniforms: { curtain: { value: 1.0 }, + screenSpace: { value: false }, focal: { value: transform.focal }, k1: { value: transform.ck1 }, k2: { value: transform.ck2 }, @@ -282,6 +295,7 @@ export class SliderMeshFactory { transparent: true, uniforms: { curtain: { value: 1.0 }, + screenSpace: { value: false }, focal: { value: transform.focal }, k1: { value: transform.ck1 }, k2: { value: transform.ck2 }, @@ -309,6 +323,7 @@ export class SliderMeshFactory { transparent: true, uniforms: { curtain: { value: 1.0 }, + screenSpace: { value: false }, opacity: { value: 1.0 }, projectorMat: { value: transform.projectorMatrix() }, projectorTex: { value: texture }, diff --git a/src/component/slider/SliderReconstruction.ts b/src/component/slider/SliderReconstruction.ts new file mode 100644 index 000000000..b9e9572fc --- /dev/null +++ b/src/component/slider/SliderReconstruction.ts @@ -0,0 +1,72 @@ +import { Image } from "../../graph/Image"; +import { MeshContract } from "../../api/contracts/MeshContract"; +import * as THREE from "three"; +import { IAnimationState } from "../../state/interfaces/IAnimationState"; +import { Transform } from "../../geo/Transform"; + +const meshVersions = new WeakMap(); +let nextMeshVersion = 0; + +/** Bounded primitive snapshot shared by safety, GL geometry, and tile ROI. + * Includes calibration/extrinsics even when an Image/Transform is mutated in + * place. Mesh versions follow immutable shared snapshots, never frame hashes. + */ +export function sliderPairKey(state: IAnimationState): string { + return JSON.stringify([state.motionless, ...[state.previousImage, state.currentImage].map((image, n) => { + const transform = n === 0 ? state.previousTransform : state.currentTransform; + if (!image) { return null; } + const mesh = image.mesh; + let version = 0; + if (mesh && typeof mesh === "object") { + if (!meshVersions.has(mesh)) { meshVersions.set(mesh, ++nextMeshVersion); } + version = meshVersions.get(mesh); + } + return [image.id, image.cameraType, image.merged, image.mergeId, image.clusterId, + image.width, image.height, image.scale, image.exifOrientation, + Array.isArray(image.rotation) ? image.rotation.slice(0, 3) : null, + image.rotation && image.rotation.length, + image.camera && image.camera.type, + image.camera && image.camera.parameters ? + [Object.keys(image.camera.parameters).length, ...Object.values(image.camera.parameters).slice(0, 3)] : null, + transform && transform.cameraType, transform && transform.scale, + transform && transform.rt && transform.rt.elements.slice(0, 16), version]; + })]); +} + +/** Read the actual current extrinsics, not a cached inverse which may predate + * an in-place RT update. Used by coverage, render translation and tile ROI. */ +export function sliderCameraOrigin(transform: Transform): THREE.Vector3 { + return new THREE.Vector3().setFromMatrixPosition(transform.rt.clone().invert()); +} + +export function sliderViewAlignedStationary(state: IAnimationState, viewAligned: boolean): boolean { + return viewAligned && (state.motionless || !state.currentImage || !state.previousImage || + state.currentImage.cameraType !== "perspective" || state.previousImage.cameraType !== "perspective"); +} + +// Coverage and automatic rendering share the SAME immutable reconstruction +// snapshot. Never freeze/mutate graph cache data or hash a large mesh per frame. +// An in-place provider mutation cannot change rendered geometry behind a cached +// coverage decision. Providers replace the mesh object to supply a new version. +const snapshots = new WeakMap(); + +export function sliderReconstructionImage(image: Image): Image { + const source = image.mesh; + if (!source) { return image; } + let snapshot = snapshots.get(source); + if (!snapshot) { + if (!Array.isArray(source.vertices) || !Array.isArray(source.faces) || + source.vertices.length > 3 * 65535 || source.faces.length > 30000) { + // Oversized/corrupt data is rejected by safety before any copying. + return image; + } + snapshot = Object.freeze({ + vertices: Object.freeze(source.vertices.slice()) as unknown as number[], + faces: Object.freeze(source.faces.slice()) as unknown as number[], + }); + snapshots.set(source, snapshot); + } + const copy = Object.create(image) as Image; + Object.defineProperty(copy, "mesh", { value: snapshot }); + return copy; +} diff --git a/src/component/slider/SliderRoll.ts b/src/component/slider/SliderRoll.ts new file mode 100644 index 000000000..52e93199b --- /dev/null +++ b/src/component/slider/SliderRoll.ts @@ -0,0 +1,46 @@ +import * as THREE from "three"; + +const MAX_ROLL_CORRECTION = Math.PI / 4; +const LEVEL_TOLERANCE = 0.5 * Math.PI / 180; +const MIN_PROJECTED_UP = 0.1; + +function cameraSpace(camera: THREE.Camera, vector: THREE.Vector3): THREE.Vector3 { + const rotation = new THREE.Quaternion().setFromRotationMatrix( + new THREE.Matrix4().extractRotation(camera.matrixWorld)).invert(); + return vector.clone().normalize().applyQuaternion(rotation); +} + +/** Whether the rendered view's screen-up is the world gravity axis. */ +export function sliderViewLeveled(camera: THREE.Camera): boolean { + const gravity = cameraSpace(camera, new THREE.Vector3(0, 0, 1)); + const length = Math.hypot(gravity.x, gravity.y); + return length >= MIN_PROJECTED_UP && + Math.abs(Math.atan2(gravity.x, gravity.y)) <= LEVEL_TOLERANCE; +} + +/** + * Roll, about the view axis, that keeps a view-aligned comparison upright the + * same way as the original. Separate reconstructions often disagree on small + * rolls; reprojecting through those poses tilts an upright photo. Show both + * native frames upright unless the live view is gravity-leveled and the + * comparison can be leveled too. + */ +export function sliderViewAlignedRoll( + camera: THREE.Camera, + currentUp: THREE.Vector3, + previousUp: THREE.Vector3, + previousLevel: boolean): number { + if (currentUp == null || previousUp == null || + previousLevel && sliderViewLeveled(camera)) { + return 0; + } + const current = cameraSpace(camera, currentUp); + const previous = cameraSpace(camera, previousUp); + if (Math.hypot(current.x, current.y) < MIN_PROJECTED_UP || + Math.hypot(previous.x, previous.y) < MIN_PROJECTED_UP) { + return 0; + } + let roll = Math.atan2(previous.y, previous.x) - Math.atan2(current.y, current.x); + roll = Math.atan2(Math.sin(roll), Math.cos(roll)); + return Number.isFinite(roll) && Math.abs(roll) <= MAX_ROLL_CORRECTION ? roll : 0; +} diff --git a/src/component/slider/SliderTransform.ts b/src/component/slider/SliderTransform.ts index 5019ef219..f407ae263 100644 --- a/src/component/slider/SliderTransform.ts +++ b/src/component/slider/SliderTransform.ts @@ -447,6 +447,9 @@ export class SliderTransform { if (bearing[2] > 0) { const [x, y, z]: number[] = bearing; const r: number = Math.sqrt(x * x + y * y); + if (r === 0) { + return [0, 0]; + } let theta: number = Math.atan2(r, z); if (theta > this._radialPeak) { diff --git a/src/component/slider/shaders/fisheye.fragment.glsl.ts b/src/component/slider/shaders/fisheye.fragment.glsl.ts index 956d5ad8a..1a4b49548 100644 --- a/src/component/slider/shaders/fisheye.fragment.glsl.ts +++ b/src/component/slider/shaders/fisheye.fragment.glsl.ts @@ -21,6 +21,10 @@ void main() float x = vRstq.x; float y = vRstq.y; float z = vRstq.z; + if (z <= 0.0) { + gl_FragColor = vec4(0.0); + return; + } float r = sqrt(x * x + y * y); float theta = atan(r, z); @@ -31,7 +35,7 @@ void main() float theta2 = theta * theta; float theta_d = theta * (1.0 + theta2 * (k1 + theta2 * k2)); - float s = focal * theta_d / r; + float s = r > 0.0 ? focal * theta_d / r : 0.0; float u = scale_x * s * x + 0.5; float v = -scale_y * s * y + 0.5; diff --git a/src/component/slider/shaders/fisheye_curtain.fragment.glsl.ts b/src/component/slider/shaders/fisheye_curtain.fragment.glsl.ts index 9c9dfe96d..f7b460357 100644 --- a/src/component/slider/shaders/fisheye_curtain.fragment.glsl.ts +++ b/src/component/slider/shaders/fisheye_curtain.fragment.glsl.ts @@ -13,12 +13,24 @@ uniform float scale_x; uniform float scale_y; uniform float radial_peak; uniform float curtain; +uniform bool screenSpace; +varying vec4 vClipPosition; varying vec4 vRstq; void main() { + // Homogeneous clip coordinates give the exact visible screen fraction, + // independent of lens projection, image pose, roll or panorama wrapping. + float screenX = (vClipPosition.x / vClipPosition.w + 1.0) * 0.5; + if (screenSpace && (curtain <= 0.0 || (curtain < 1.0 && screenX >= curtain))) { + discard; + } float x = vRstq.x; float y = vRstq.y; float z = vRstq.z; + if (z <= 0.0) { + gl_FragColor = vec4(0.0); + return; + } float r2 = sqrt(x * x + y * y); float theta = atan(r2, z); if (radial_peak > 0. && theta > radial_peak) { @@ -26,11 +38,11 @@ void main() } float theta2 = theta * theta; float theta_d = theta * (1.0 + theta2 * (k1 + theta2 * k2)); - float s = focal * theta_d / r2; + float s = r2 > 0.0 ? focal * theta_d / r2 : 0.0; float u = scale_x * s * x + 0.5; float v = -scale_y * s * y + 0.5; vec4 baseColor; - if ((u < curtain || curtain >= 1.0) && u >= 0. && u <= 1. && v >= 0. && v <= 1.) { + if ((screenSpace || u < curtain || curtain >= 1.0) && u >= 0. && u <= 1. && v >= 0. && v <= 1.) { baseColor = texture2D(projectorTex, vec2(u, v)); baseColor.a = opacity; } else { diff --git a/src/component/slider/shaders/fisheye_curtain.vertex.glsl.ts b/src/component/slider/shaders/fisheye_curtain.vertex.glsl.ts index 6570e85d7..25bfb25a6 100644 --- a/src/component/slider/shaders/fisheye_curtain.vertex.glsl.ts +++ b/src/component/slider/shaders/fisheye_curtain.vertex.glsl.ts @@ -4,9 +4,11 @@ precision highp float; #endif uniform mat4 projectorMat; varying vec4 vRstq; +varying vec4 vClipPosition; void main() { vRstq = projectorMat * vec4(position, 1.0); gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + vClipPosition = gl_Position; } ` diff --git a/src/component/slider/shaders/perspective_curtain.fragment.glsl.ts b/src/component/slider/shaders/perspective_curtain.fragment.glsl.ts index b688b81a1..f9548a735 100644 --- a/src/component/slider/shaders/perspective_curtain.fragment.glsl.ts +++ b/src/component/slider/shaders/perspective_curtain.fragment.glsl.ts @@ -13,9 +13,17 @@ uniform float scale_x; uniform float scale_y; uniform float radial_peak; uniform float curtain; +uniform bool screenSpace; +varying vec4 vClipPosition; varying vec4 vRstq; void main() { + // Homogeneous clip coordinates give the exact visible screen fraction, + // independent of lens projection, image pose, roll or panorama wrapping. + float screenX = (vClipPosition.x / vClipPosition.w + 1.0) * 0.5; + if (screenSpace && (curtain <= 0.0 || (curtain < 1.0 && screenX >= curtain))) { + discard; + } float x = vRstq.x / vRstq.z; float y = vRstq.y / vRstq.z; float r2 = x * x + y * y; @@ -26,7 +34,7 @@ void main() float u = scale_x * focal * d * x + 0.5; float v = - scale_y * focal * d * y + 0.5; vec4 baseColor; - if ((u < curtain || curtain >= 1.0) && u >= 0. && u <= 1. && v >= 0. && v <= 1.) { + if ((screenSpace || u < curtain || curtain >= 1.0) && u >= 0. && u <= 1. && v >= 0. && v <= 1.) { baseColor = texture2D(projectorTex, vec2(u, v)); baseColor.a = opacity; } else { diff --git a/src/component/slider/shaders/perspective_curtain.vertex.glsl.ts b/src/component/slider/shaders/perspective_curtain.vertex.glsl.ts index aa0fd703c..452187330 100644 --- a/src/component/slider/shaders/perspective_curtain.vertex.glsl.ts +++ b/src/component/slider/shaders/perspective_curtain.vertex.glsl.ts @@ -4,9 +4,11 @@ precision highp float; #endif uniform mat4 projectorMat; varying vec4 vRstq; +varying vec4 vClipPosition; void main() { vRstq = projectorMat * vec4(position, 1.0); gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + vClipPosition = gl_Position; } ` diff --git a/src/component/slider/shaders/perspective_distorted_curtain.fragment.glsl.ts b/src/component/slider/shaders/perspective_distorted_curtain.fragment.glsl.ts index 5d3d12805..d2ef6aee5 100644 --- a/src/component/slider/shaders/perspective_distorted_curtain.fragment.glsl.ts +++ b/src/component/slider/shaders/perspective_distorted_curtain.fragment.glsl.ts @@ -7,13 +7,21 @@ precision mediump float; uniform sampler2D projectorTex; uniform float opacity; uniform float curtain; +uniform bool screenSpace; +varying vec4 vClipPosition; varying vec4 vRstq; void main() { + // Homogeneous clip coordinates give the exact visible screen fraction, + // independent of lens projection, image pose, roll or panorama wrapping. + float screenX = (vClipPosition.x / vClipPosition.w + 1.0) * 0.5; + if (screenSpace && (curtain <= 0.0 || (curtain < 1.0 && screenX >= curtain))) { + discard; + } float u = vRstq.x / vRstq.w; float v = vRstq.y / vRstq.w; vec4 baseColor; - if ((u < curtain || curtain >= 1.0) && u >= 0. && u <= 1. && v >= 0. && v <= 1.) { + if ((screenSpace || u < curtain || curtain >= 1.0) && u >= 0. && u <= 1. && v >= 0. && v <= 1.) { baseColor = texture2D(projectorTex, vec2(u, v)); baseColor.a = opacity; } else { diff --git a/src/component/slider/shaders/perspective_distorted_curtain.vertex.glsl.ts b/src/component/slider/shaders/perspective_distorted_curtain.vertex.glsl.ts index 57a47c682..e7b929749 100644 --- a/src/component/slider/shaders/perspective_distorted_curtain.vertex.glsl.ts +++ b/src/component/slider/shaders/perspective_distorted_curtain.vertex.glsl.ts @@ -4,9 +4,11 @@ precision highp float; #endif uniform mat4 projectorMat; varying vec4 vRstq; +varying vec4 vClipPosition; void main() { vRstq = projectorMat * vec4(position, 1.0); gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + vClipPosition = gl_Position; } ` diff --git a/src/component/slider/shaders/spherical_curtain.fragment.glsl.ts b/src/component/slider/shaders/spherical_curtain.fragment.glsl.ts index 8486fe21b..4a5acd36a 100644 --- a/src/component/slider/shaders/spherical_curtain.fragment.glsl.ts +++ b/src/component/slider/shaders/spherical_curtain.fragment.glsl.ts @@ -7,10 +7,18 @@ precision mediump float; #define tau 6.28318530718 uniform sampler2D projectorTex; uniform float curtain; +uniform bool screenSpace; +varying vec4 vClipPosition; uniform float opacity; varying vec4 vRstq; void main() { + // Homogeneous clip coordinates give the exact visible screen fraction, + // independent of lens projection, image pose, roll or panorama wrapping. + float screenX = (vClipPosition.x / vClipPosition.w + 1.0) * 0.5; + if (screenSpace && (curtain <= 0.0 || (curtain < 1.0 && screenX >= curtain))) { + discard; + } vec3 b = normalize(vRstq.xyz); float lat = -asin(b.y); float lng = atan(b.x, b.z); @@ -19,9 +27,9 @@ void main() bool inverted = curtain < 0.5; float curtainMin = inverted ? curtain + 0.5 : curtain - 0.5; float curtainMax = curtain; - bool insideCurtain = inverted ? + bool insideCurtain = screenSpace || (inverted ? x > curtainMin || x < curtainMax : - x > curtainMin && x < curtainMax; + x > curtainMin && x < curtainMax); vec4 baseColor; if (insideCurtain) { baseColor = texture2D(projectorTex, vec2(x, y)); diff --git a/src/component/slider/shaders/spherical_curtain.vertex.glsl.ts b/src/component/slider/shaders/spherical_curtain.vertex.glsl.ts index e55aa3c59..1e01a29b3 100644 --- a/src/component/slider/shaders/spherical_curtain.vertex.glsl.ts +++ b/src/component/slider/shaders/spherical_curtain.vertex.glsl.ts @@ -4,9 +4,11 @@ precision highp float; #endif uniform mat4 projectorMat; varying vec4 vRstq; +varying vec4 vClipPosition; void main() { vRstq = projectorMat * vec4(position, 1.0); gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + vClipPosition = gl_Position; } ` diff --git a/src/component/zoom/ZoomComponent.ts b/src/component/zoom/ZoomComponent.ts index 5c9fe80ca..6b7dca918 100644 --- a/src/component/zoom/ZoomComponent.ts +++ b/src/component/zoom/ZoomComponent.ts @@ -27,6 +27,15 @@ import { Container } from "../../viewer/Container"; import { Navigator } from "../../viewer/Navigator"; import { ComponentName } from "../ComponentName"; +// SDK zoom is logarithmic: zero is the unzoomed 1.0x view. +export function formatZoomLevel(zoom: number): string { + if (!Number.isFinite(zoom)) { + return ""; + } + const level: string = Math.pow(2, zoom).toFixed(1); + return level === "1.0" ? "" : level + "×"; +} + /** * @class ZoomComponent * @@ -89,12 +98,18 @@ export class ZoomComponent extends Component { configuration.size === ComponentSize.Automatic && size.width < 640 ? ".mapillary-zoom-compact" : ""; + const label: string = state === State.Waiting ? "" : formatZoomLevel(zoom); + const children: vd.VNode[] = [zoomInButton, zoomOutButton]; + if (label) { + children.push(vd.h("div.mapillary-zoom-level", [label])); + } + return { name: this._name, vNode: vd.h( "div.mapillary-zoom-container" + compact, { oncontextmenu: (event: MouseEvent): void => { event.preventDefault(); } }, - [zoomInButton, zoomOutButton]), + children), }; })) .subscribe(this._container.domRenderer.render$)); diff --git a/src/geometry/camera/FisheyeCamera.ts b/src/geometry/camera/FisheyeCamera.ts index 20450063c..7cf225018 100644 --- a/src/geometry/camera/FisheyeCamera.ts +++ b/src/geometry/camera/FisheyeCamera.ts @@ -47,6 +47,12 @@ function project( uniforms: Uniforms): number[] { const [x, y, z] = point; + if (z <= 0) { + return [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY]; + } + if (x === 0 && y === 0) { + return [0, 0]; + } const { focal, k1, k2 } = parameters; const radialPeak = uniforms.radialPeak; @@ -90,7 +96,7 @@ vec2 projectToSfm(vec3 bearing, Parameters parameters, Uniforms uniforms) { float radialPeak = uniforms.radialPeak; // Projection - if (z < 0.) { + if (z <= 0.) { return vec2(POSITIVE_INFINITY, POSITIVE_INFINITY); } @@ -101,8 +107,9 @@ vec2 projectToSfm(vec3 bearing, Parameters parameters, Uniforms uniforms) { theta = radialPeak; } - float xp = theta / r * x; - float yp = theta / r * y; + float scale = r > 0.0 ? theta / r : 0.0; + float xp = scale * x; + float yp = scale * y; // Distortion float theta2 = theta * theta; diff --git a/styles/zoom.css b/styles/zoom.css index f4643a624..90097c0f3 100644 --- a/styles/zoom.css +++ b/styles/zoom.css @@ -8,10 +8,27 @@ z-index: 10; } +.mapillary-zoom-level { + position: absolute; + right: 0; + bottom: calc(100% + 6px); + padding: 3px 6px; + border-radius: 4px; + background: rgba(0, 0, 0, 0.65); + color: #fff; + font-size: 12px; + line-height: 18px; + font-variant-numeric: tabular-nums; + white-space: nowrap; + pointer-events: none; + user-select: none; +} + .mapillary-zoom-in-button, .mapillary-zoom-in-button-inactive, .mapillary-zoom-out-button, .mapillary-zoom-out-button-inactive { + width: 100%; height: 32px; pointer-events: auto; diff --git a/test/component/bearing/BearingComponent.test.ts b/test/component/bearing/BearingComponent.test.ts new file mode 100644 index 000000000..2c5d23cbe --- /dev/null +++ b/test/component/bearing/BearingComponent.test.ts @@ -0,0 +1,34 @@ +import * as vd from "virtual-dom"; + +import { createBearingContainer } from "../../../src/component/bearing/BearingComponent"; + +const COMPACT = "mapillary-bearing-compact"; + +describe("bearing container", () => { + it("adds the compact class when the viewer shrinks", () => { + const large = createBearingContainer(false, []); + const element = vd.create(large); + expect(element.classList.contains(COMPACT)).toBe(false); + + const compact = createBearingContainer(true, []); + vd.patch(element, vd.diff(large, compact)); + expect(element.classList.contains(COMPACT)).toBe(true); + expect(element.classList.contains("mapillary-bearing-indicator-container")).toBe(true); + }); + + it("removes the compact class when the viewer grows", () => { + const compact = createBearingContainer(true, []); + const element = vd.create(compact); + expect(element.classList.contains(COMPACT)).toBe(true); + + const large = createBearingContainer(false, []); + vd.patch(element, vd.diff(compact, large)); + expect(element.classList.contains(COMPACT)).toBe(false); + }); + + it("keeps the left bearing tooltip", () => { + const element = vd.create(createBearingContainer(false, [])); + expect(element.getAttribute("data-mapillary-tooltip")).toBe("Bearing"); + expect(element.getAttribute("data-mapillary-tooltip-position")).toBe("left"); + }); +}); diff --git a/test/component/reorientation/ReorientationAdoptedView.test.ts b/test/component/reorientation/ReorientationAdoptedView.test.ts new file mode 100644 index 000000000..82a260bf2 --- /dev/null +++ b/test/component/reorientation/ReorientationAdoptedView.test.ts @@ -0,0 +1,70 @@ +import { of, Subject } from "rxjs"; +import { ReorientationComponent } from "../../../src/component/reorientation/ReorientationComponent"; + +function fixture() { + const component: any = Object.create(ReorientationComponent.prototype); + const image: any = { id: "image", sequenceId: "sequence", cameraType: "spherical" }; + const result: any = { valid: true, moving: true, seq: "sequence", basicX: 0.4, cca: 180, viewCompassAngle: 180 }; + const state = { + getCenter: (): any => of([0.5, 0.5]), + setCenter: jasmine.createSpy("setCenter"), + setZoom: jasmine.createSpy("setZoom"), + traverse: jasmine.createSpy("traverse"), + gravityTraverse: jasmine.createSpy("gravityTraverse"), + clearReorientations: jasmine.createSpy("clearReorientations"), + rotateToBasic: jasmine.createSpy("rotateToBasic"), + rotateToBasicSmooth: jasmine.createSpy("rotateToBasicSmooth"), + }; + component._navigator = { stateService: state }; + component._engine = { precompute: (): any => Promise.resolve(), get: (): any => result }; + component._seed = (): any => ({}); + component._activeId = image.id; + component._imageSequence = image.sequenceId; + component._lastSeq = null; + component._userViewRevision = 0; + component._userOffsetX = 0; + component._userOffsetY = 0; + component._automaticHorizonLeveling = true; + component._reorientToFront = true; + component._settled$ = new Subject(); + const run = async (): Promise => { + component._reorient(image, null, null, false, false, 0); + await Promise.resolve(); + await Promise.resolve(); + }; + return { component, state, run }; +} + +describe("Reorientation adopted URL view", () => { + it("applies the exact adopted coordinates after the initial state transition", async () => { + const f = fixture(); + const center = [0.679852275866379, 0.4936419199209455]; + f.component.adoptView(center, 1.25); + await f.run(); + expect(f.state.traverse).toHaveBeenCalled(); + expect(f.state.setCenter).toHaveBeenCalledWith(center); + expect(f.state.setZoom).toHaveBeenCalledWith(1.25); + expect(f.state.rotateToBasic).not.toHaveBeenCalled(); + expect(f.state.rotateToBasicSmooth).not.toHaveBeenCalled(); + expect(f.component._userOffsetX).toBeCloseTo(center[0] - 0.4, 10); + expect(f.component._adoptedView).toBeNull(); + }); + + it("does not animate a large adopted pitch or an old sequence pitch reset", async () => { + const f = fixture(); + f.component._userOffsetY = 0.3; + f.component.adoptView([0.7, 0.2], 0); + await f.run(); + expect(f.state.setCenter).toHaveBeenCalledWith([0.7, 0.2]); + expect(f.state.setZoom).toHaveBeenCalledWith(0); + expect(f.state.rotateToBasic).not.toHaveBeenCalled(); + expect(f.state.rotateToBasicSmooth).not.toHaveBeenCalled(); + }); + + it("keeps ordinary automatic orientation when no view was adopted", async () => { + const f = fixture(); + await f.run(); + expect(f.state.setCenter).not.toHaveBeenCalled(); + expect(f.state.rotateToBasicSmooth).toHaveBeenCalled(); + }); +}); diff --git a/test/component/slider/SliderAlignment.test.ts b/test/component/slider/SliderAlignment.test.ts new file mode 100644 index 000000000..2b7c0892b --- /dev/null +++ b/test/component/slider/SliderAlignment.test.ts @@ -0,0 +1,96 @@ +import * as THREE from "three"; +import { canLevelSliderImage } from "../../../src/component/slider/SliderAlignment"; +import { SliderGLRenderer } from "../../../src/component/slider/SliderGLRenderer"; +import { SliderConfigurationMode } from "../../../src/component/interfaces/SliderConfiguration"; +import { Camera } from "../../../src/geo/Camera"; +import { Image } from "../../../src/graph/Image"; +import { Transform } from "../../../src/geo/Transform"; + +function image(vertices: number[] = [0, 0, 0, 1, 0, 0, 0, 1, 0], cameraType: string = "spherical"): Image { + return { cameraType, mesh: { vertices, faces: [] } } as Image; +} +function transform(roll: number = 0, pitch: number = 0): Transform { + const direction = new THREE.Vector3(1, 0, 0).applyAxisAngle(new THREE.Vector3(0, 1, 0), pitch * Math.PI / 180); + const up = new THREE.Vector3(0, 0, 1).applyAxisAngle(direction, roll * Math.PI / 180); + return { + upVector: () => up.clone(), + unprojectBasic: (_: number[], depth: number) => direction.clone().multiplyScalar(depth).toArray(), + } as unknown as Transform; +} + +describe("Slider horizon safety", () => { + it("does not level the reported image's empty reconstruction", () => { + expect(canLevelSliderImage(image([]), transform(11, 67))).toBe(false); + }); + it("does not trust a missing mesh", () => { + expect(canLevelSliderImage({ mesh: null } as Image, transform())).toBe(false); + }); + it("does not trust the default cube used by the comparison image", () => { + const cube = { cameraType: "spherical", mesh: { vertices: Array(24).fill(1), faces: Array(36).fill(0) } } as Image; + expect(canLevelSliderImage(cube, transform())).toBe(false); + }); + it("levels a panorama with a real and plausible reconstruction", () => { + expect(canLevelSliderImage(image(), transform(11, 5))).toBe(true); + }); + it("rejects an implausible roll or pitch", () => { + expect(canLevelSliderImage(image(), transform(46, 0))).toBe(false); + expect(canLevelSliderImage(image(), transform(0, 80))).toBe(false); + }); + it("retains the ordinary viewer's small perspective correction guard", () => { + expect(canLevelSliderImage(image(undefined, "perspective"), transform(6))).toBe(false); + expect(canLevelSliderImage(image(undefined, "perspective"), transform(8))).toBe(true); + }); + it("uses the normal camera unchanged when foreground gravity is untrusted", () => { + const renderer = new SliderGLRenderer() as any; + renderer._gravityAligned = true; + renderer._currentGravityAligned = false; + const camera = new THREE.PerspectiveCamera(); + expect(renderer._getCurrentPerspective(camera)).toBe(camera); + }); + it("levels the foreground camera without mutating the normal viewer camera", () => { + const renderer = new SliderGLRenderer() as any; + renderer._currentGravityAligned = true; + const camera = new THREE.PerspectiveCamera(); + camera.up.set(0, 1, 0); + camera.lookAt(1, 0, 0); + camera.updateMatrixWorld(true); + const leveled = renderer._getCurrentPerspective(camera); + expect(leveled.up.toArray()).toEqual([0, 0, 1]); + expect(camera.up.toArray()).toEqual([0, 1, 0]); + }); + it("renders uncalibrated comparisons in their own upright camera frame", () => { + const renderer = new SliderGLRenderer() as any; + renderer._gravityAligned = true; + renderer._mode = SliderConfigurationMode.Motion; + renderer._previousCamera = new Camera(); + renderer._previousCamera.up.set(0, 1, 0); + const camera = new THREE.PerspectiveCamera(); + const previous = renderer._getPreviousPerspective(camera); + expect(previous).not.toBe(camera); + expect(previous.up.toArray()).toEqual([0, 1, 0]); + }); + it("recenters an independent panorama camera on its recentered mesh", () => { + const renderer = new SliderGLRenderer() as any; + renderer._gravityAligned = true; + renderer._previousSpherical = true; + renderer._mode = SliderConfigurationMode.Motion; + renderer._previousCamera = new Camera(); + renderer._previousCamera.position.set(20, 10, 4); + renderer._previousCamera.lookat.set(21, 10, 4); + const camera = new THREE.PerspectiveCamera(); + camera.position.set(2, 3, 4); + const previous = renderer._getPreviousPerspective(camera); + expect(previous.position.toArray()).toEqual([2, 3, 4]); + const direction = previous.getWorldDirection(new THREE.Vector3()); + expect(direction.x).toBeCloseTo(1); + expect(direction.y).toBeCloseTo(0); + expect(direction.z).toBeCloseTo(0); + }); + + it("preserves the legacy shared camera when leveling is switched off", () => { + const renderer = new SliderGLRenderer() as any; + renderer._mode = SliderConfigurationMode.Motion; + const camera = new THREE.PerspectiveCamera(); + expect(renderer._getPreviousPerspective(camera)).toBe(camera); + }); +}); diff --git a/test/component/slider/SliderAutomaticMotion.test.ts b/test/component/slider/SliderAutomaticMotion.test.ts new file mode 100644 index 000000000..bc6ccce4e --- /dev/null +++ b/test/component/slider/SliderAutomaticMotion.test.ts @@ -0,0 +1,718 @@ +import * as THREE from "three"; +import { Subject } from "rxjs"; +import { SliderAutomaticMotion, translatedSliderPerspective } from "../../../src/component/slider/SliderAutomaticMotion"; +import { SliderConfigurationMode as Mode } from "../../../src/component/interfaces/SliderConfiguration"; +import { SliderTransform } from "../../../src/component/slider/SliderTransform"; +import { SliderMeshFactory } from "../../../src/component/slider/SliderMeshFactory"; +import { SliderGLRenderer } from "../../../src/component/slider/SliderGLRenderer"; +import { SliderComponent } from "../../../src/component/slider/SliderComponent"; +import { SliderDOMRenderer } from "../../../src/component/slider/SliderDOMRenderer"; +import { Camera } from "../../../src/geo/Camera"; +import { State } from "../../../src/state/State"; +import { TextureProvider } from "../../../src/tile/TextureProvider"; +import { ContainerMockCreator } from "../../helper/ContainerMockCreator"; +import { NavigatorMockCreator } from "../../helper/NavigatorMockCreator"; +import { MockCreator } from "../../helper/MockCreator"; + +function image(id: string): any { + return { id, merged: true, mergeId: "shared-merge", cameraType: "perspective", width: 1600, height: 1200, + scale: 1, exifOrientation: 1, rotation: [0, 0, 0], clusterId: "cluster", + camera: { type: "perspective", parameters: { focal: 0.8, k1: 0, k2: 0 } }, + image: document.createElement("img"), + mesh: { vertices: [-10, -10, 20, 10, -10, 20, 10, 10, 20, -10, 10, 20, 0, 0, 20], + faces: [0, 1, 4, 1, 2, 4, 2, 3, 4, 3, 0, 4] } }; +} +function transform(i: any, origin: number): SliderTransform { + return new SliderTransform(i.exifOrientation, i.width, i.height, i.scale, + i.rotation, [-origin, 0, 0], i.image, undefined, Object.values(i.camera.parameters), i.cameraType); +} +function fixture(): any { + const previousImage = image("previous"); + const currentImage = image("current"); + const previousTransform = transform(previousImage, 0); + const currentTransform = transform(currentImage, 2); + const state = { previousImage, currentImage, previousTransform, currentTransform, + previousCamera: new Camera(previousTransform as any), motionless: false, trajectory: [previousImage, currentImage] }; + const camera = new THREE.PerspectiveCamera(20, 4 / 3, 0.1, 1000); + camera.position.set(2, 0, 0); + camera.up.set(0, -1, 0); + camera.lookAt(2, 0, 20); + camera.updateMatrixWorld(true); + camera.matrixAutoUpdate = false; + return { state, camera, frame: { id: 3, state }, + configuration: { automaticMotion: true, viewAligned: true, mode: Mode.Motion, + ids: { foreground: "current", background: "previous" } }, + size: { width: 1200, height: 900 } }; +} +function resolve(s: SliderAutomaticMotion, f: any): Mode { + return s.resolve(f.configuration, f.state, f.camera, f.size, "current"); +} +function sameView(a: THREE.PerspectiveCamera, b: THREE.PerspectiveCamera): void { + expect(a.quaternion.toArray()).toEqual(b.quaternion.toArray()); + expect(a.up.toArray()).toEqual(b.up.toArray()); + expect(a.projectionMatrix.elements).toEqual(b.projectionMatrix.elements); + expect(a.projectionMatrixInverse.elements).toEqual(b.projectionMatrixInverse.elements); + expect(a.view).toEqual(b.view); + expect(a.zoom).toBe(b.zoom); + expect(a.getWorldDirection(new THREE.Vector3()).distanceTo(b.getWorldDirection(new THREE.Vector3()))).toBeCloseTo(0, 12); +} + +describe("Slider automatic motion safety", () => { + it("enables a hydrated synthetic perspective pair with real meshes", () => { + const s = new SliderAutomaticMotion(); + expect(resolve(s, fixture())).toBe(Mode.Motion); + s.dispose(); + }); + const invalid: { [name: string]: (f: any) => void } = { + unmerged: f => { f.state.previousImage.merged = false; }, + "different merged components despite same cluster/close pose": f => { f.state.currentImage.mergeId = "unrelated-merge"; }, + "missing previous merge ID": f => { delete f.state.previousImage.mergeId; }, + "missing current merge ID": f => { f.state.currentImage.mergeId = null; }, + "both merge IDs missing": f => { delete f.state.previousImage.mergeId; delete f.state.currentImage.mergeId; }, + "both merge IDs empty": f => { f.state.previousImage.mergeId = f.state.currentImage.mergeId = ""; }, + "both merge IDs blank": f => { f.state.previousImage.mergeId = f.state.currentImage.mergeId = " "; }, + "invalid numeric merge IDs": f => { f.state.previousImage.mergeId = f.state.currentImage.mergeId = 123; }, + "missing mesh": f => { f.state.currentImage.mesh = null; }, + "malformed mesh": f => { f.state.currentImage.mesh = {}; }, + "default cube": f => { f.state.currentImage.mesh = { vertices: Array(24).fill(1), faces: Array(36).fill(0) }; }, + "invalid scale": f => { f.state.currentImage.scale = NaN; }, + "infinite transform scale": f => { f.state.currentTransform = transform({ ...f.state.currentImage, scale: Infinity }, 2); }, + "zero scale": f => { f.state.currentImage.scale = 0; }, + "mixed pair": f => { f.state.currentImage.cameraType = "spherical"; }, + spherical: f => { f.state.currentImage.cameraType = f.state.previousImage.cameraType = "spherical"; }, + fisheye: f => { f.state.previousImage.cameraType = "fisheye"; }, + motionless: f => { f.state.motionless = true; }, + "bad up": f => { f.state.previousImage.rotation = [0, 0, Math.PI / 4]; }, + "invalid rotation": f => { f.state.previousImage.rotation = [NaN, 0, 0]; }, + "invalid lens": f => { f.state.previousImage.camera.parameters.k1 = Infinity; }, + "invalid dimensions": f => { f.state.previousImage.height = Infinity; }, + "missing image": f => { f.state.previousImage = null; }, + "missing transform": f => { f.state.previousTransform = null; }, + "stale selected pair": f => { f.configuration.ids.foreground = "new"; }, + "missing selected ids": f => { f.configuration.ids = null; }, + "invalid projection": f => { f.camera.projectionMatrix.elements[0] = NaN; }, + "singular projection": f => { f.camera.projectionMatrix.elements.fill(0); }, + "nonfinite near": f => { f.camera.near = NaN; }, + "nonfinite far": f => { f.camera.far = Infinity; }, + "invalid clip range": f => { f.camera.far = f.camera.near; }, + "invalid viewport": f => { f.size.width = 0; }, + "invalid faces": f => { f.state.previousImage.mesh.faces[0] = 65536; }, + "negative depth": f => { f.state.previousImage.mesh.vertices[2] = -1; }, + "nonfinite mesh": f => { f.state.previousImage.mesh.vertices[0] = Infinity; }, + "over vertex budget": f => { f.state.previousImage.mesh.vertices = Array(3 * 65536).fill(1); }, + "over triangle budget": f => { f.state.previousImage.mesh.faces = Array(30003).fill(0); }, + "baseline over 2m even in same cluster/component": f => { f.state.currentTransform = transform(f.state.currentImage, 2.01); }, + "far same-cluster pose": f => { f.state.currentTransform = transform(f.state.currentImage, 51); }, + "far unrelated pose": f => { f.state.currentTransform = transform(f.state.currentImage, 21); f.state.currentImage.clusterId = "other"; }, + "nonfinite camera origin": f => { f.state.currentTransform.rt.elements[12] = Infinity; }, + "no shared photo coverage": f => { f.camera.lookAt(20, 0, 2); f.camera.updateMatrix(); f.camera.updateMatrixWorld(true); }, + "no mesh coverage": f => { f.state.previousImage.mesh.faces = [0, 0, 0]; }, + }; + for (const name of Object.keys(invalid)) { + it(`fails closed without throwing for ${name}, including repeated requests`, () => { + const f = fixture(); const s = new SliderAutomaticMotion(); + invalid[name](f); + expect(resolve(s, f)).toBe(Mode.Stationary); + expect(resolve(s, f)).toBe(Mode.Stationary); + s.dispose(); + }); + } + it("permits covered small baselines but rejects 7m even with the same cluster and merge component", () => { + const f = fixture(); const s = new SliderAutomaticMotion(); + for (const baseline of [0, 0.5, 1, 2]) { + f.state.currentTransform = transform(f.state.currentImage, baseline); + expect(resolve(s, f)).toBe(Mode.Motion); + } + f.state.currentTransform = transform(f.state.currentImage, 7); + expect(resolve(s, f)).toBe(Mode.Stationary); + s.dispose(); + }); + it("rejects excessive reprojection parallax at a surface hit even when all photo bounds pass", () => { + const f = fixture(); const s = new SliderAutomaticMotion() as any; + f.camera.fov = 5; f.camera.aspect = 1; f.camera.updateProjectionMatrix(); + // A near foreground plane has ample shared photo/mesh coverage but + // >10-degree reprojection from its native origin at the other endpoint. + f.state.currentImage.mesh.vertices = f.state.currentImage.mesh.vertices.map((v: number, n: number) => n % 3 === 2 ? 5 : v); + const photo = spyOn(s, "_insidePhoto").and.callThrough(); + expect(resolve(s, f)).toBe(Mode.Stationary); + expect(photo.calls.count()).toBeGreaterThan(0); + photo.calls.allArgs().forEach(args => expect(s._insidePhoto(args[0], args[1])).toBe(true)); + s.dispose(); + }); + it("rejects unrelated merge frames before raycasting and revalidates when shared merge IDs change in place", () => { + const f = fixture(); const s = new SliderAutomaticMotion() as any; + const covered = spyOn(s, "_covered").and.callThrough(); + // Coverage can be ample for overlapping GPS frames while reconstruction + // components are unrelated. Same cluster/close pose is NOT an override. + f.state.previousImage.mergeId = "1278340129"; + f.state.currentImage.mergeId = "1085082133386200"; + expect(resolve(s, f)).toBe(Mode.Stationary); + expect(covered).not.toHaveBeenCalled(); + f.state.currentImage.mergeId = f.state.previousImage.mergeId; + expect(resolve(s, f)).toBe(Mode.Motion); + expect(covered.calls.count()).toBe(1); + f.state.previousImage.mergeId = f.state.currentImage.mergeId = "another-shared-frame"; + expect(resolve(s, f)).toBe(Mode.Motion); + expect(covered.calls.count()).toBe(2); + f.state.currentImage.mergeId = "unrelated-again"; + expect(resolve(s, f)).toBe(Mode.Stationary); + s.dispose(); + }); + it("rejects missing live camera and mismatched rendered image", () => { + const f = fixture(); const s = new SliderAutomaticMotion(); + expect(s.resolve(f.configuration, f.state, null, f.size, "current")).toBe(Mode.Stationary); + expect(s.resolve(f.configuration, f.state, f.camera, f.size, "old")).toBe(Mode.Stationary); + }); + it("checks endpoints AND seven intermediate stops using both meshes", () => { + const f = fixture(); const s = new SliderAutomaticMotion(); + const rays = spyOn(THREE.Raycaster.prototype, "setFromCamera").and.callThrough(); + const photo = spyOn(s as any, "_insidePhoto").and.callThrough(); + expect(resolve(s, f)).toBe(Mode.Motion); + const xs = rays.calls.allArgs().map(args => (args[1] as THREE.PerspectiveCamera).position.x); + expect(Array.from(new Set(xs))).toEqual([0, 0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]); + expect(rays.calls.count()).toBe(9 * 49); + expect(photo.calls.count()).toBe(9 * 49 * 2 * 2); + s.dispose(); + }); + it("rejects an interior-only hole even when endpoint sampled rays are covered", () => { + const f = fixture(); const s = new SliderAutomaticMotion() as any; + f.state.currentTransform = transform(f.state.currentImage, 2); + f.camera.fov = 5; f.camera.aspect = 1; f.camera.updateProjectionMatrix(); + const mesh = { vertices: Array(12).fill(1), faces: [] as number[] }; + // Four rectangles surround a narrow hole at world x=1,y=0,z=20. + for (const [x0, x1, y0, y1] of [[-10, 0.95, -10, 10], [1.05, 10, -10, 10], + [0.95, 1.05, -10, -0.05], [0.95, 1.05, 0.05, 10]]) { + const n = mesh.vertices.length / 3; + mesh.vertices.push(x0, y0, 20, x1, y0, 20, x1, y1, 20, x0, y1, 20); + mesh.faces.push(n, n + 1, n + 2, n, n + 2, n + 3); + } + f.state.previousImage.mesh = mesh; + expect(s._prepare(f.state)).toBe(true); + const original = THREE.Raycaster.prototype.setFromCamera; + const rays = spyOn(THREE.Raycaster.prototype, "setFromCamera").and.callFake(function(coords: any, camera: any): void { + // First run deliberately examines endpoints only. + const endpoint = camera.clone(); endpoint.position.x = camera.position.x < 1 ? 0 : 2; + endpoint.updateMatrix(); endpoint.updateMatrixWorld(true); + original.call(this, coords, endpoint); + }); + expect(s._covered(f.camera)).toBe(true); + rays.and.callThrough(); + expect(s._covered(f.camera)).toBe(false); + s.dispose(); + }); + it("shares EXACT renderer-clamped reconstruction geometry without textures", () => { + const f = fixture(); const factory = new SliderMeshFactory(); + f.state.previousImage.mesh.vertices[2] = 1; + const t = f.state.previousTransform; + const texture = spyOn(factory as any, "_createTexture").and.callThrough(); + const geometry = factory.createReconstructionGeometry(f.state.previousImage, t); + expect(texture).not.toHaveBeenCalled(); + const rendered = factory.createMesh(f.state.previousImage, t, true); + expect(Array.from((geometry.getAttribute("position") as THREE.BufferAttribute).array)).toEqual( + Array.from((rendered.geometry.getAttribute("position") as THREE.BufferAttribute).array)); + geometry.dispose(); rendered.geometry.dispose(); (rendered.material as THREE.Material).dispose(); + }); + it("reuses coverage for manual 2D/3D, bounds cache and invalidates pair/lens/viewport", () => { + const f = fixture(); const s = new SliderAutomaticMotion() as any; + const covered = spyOn(s, "_covered").and.callThrough(); + expect(resolve(s, f)).toBe(Mode.Motion); + f.configuration.mode = Mode.Stationary; + expect(resolve(s, f)).toBe(Mode.Stationary); + f.configuration.mode = Mode.Motion; + expect(resolve(s, f)).toBe(Mode.Motion); + expect(covered.calls.count()).toBe(1); + f.camera.zoom = 1.1; f.camera.updateProjectionMatrix(); resolve(s, f); + f.size.width++; resolve(s, f); + expect(covered.calls.count()).toBe(3); + f.state.currentImage.camera.parameters.focal = 0.85; resolve(s, f); + expect(covered.calls.count()).toBe(4); + for (let n = 0; n < 10; n++) { f.size.width++; resolve(s, f); } + expect(s._coverage.size).toBe(8); + f.state.currentImage.mesh = { vertices: [], faces: [] }; + expect(resolve(s, f)).toBe(Mode.Stationary); + s.dispose(); + }); + it("does not affect default or non-viewAligned modes", () => { + const f = fixture(); const s = new SliderAutomaticMotion(); + f.configuration.automaticMotion = false; f.state.previousImage = null; + expect(resolve(s, f)).toBe(Mode.Motion); + f.configuration.automaticMotion = true; f.configuration.viewAligned = false; + expect(resolve(s, f)).toBe(Mode.Motion); + }); +}); + +describe("Slider automatic rendering", () => { + it("translates actual frozen world matrices even when live position and matrixWorld disagree", () => { + const f = fixture(); + const worldBefore = f.camera.matrixWorld.elements.slice(); + f.camera.position.set(123, 45, 67); // Deliberately stale public pose. + f.camera.matrixAutoUpdate = false; + (f.camera as any).matrixWorldAutoUpdate = false; + f.camera.matrixWorldNeedsUpdate = false; + for (const position of [0, 0.5, 1]) { + const translated = translatedSliderPerspective(f.camera, + new THREE.Vector3(0, 0, 0), new THREE.Vector3(2, 0, 0), position); + const ray = new THREE.Raycaster(); ray.setFromCamera(new THREE.Vector2(), translated); + expect(translated.position.toArray()).toEqual([2 * position, 0, 0]); + expect(ray.ray.origin.toArray()).toEqual([2 * position, 0, 0]); + expect(translated.position.clone().applyMatrix4(translated.matrixWorldInverse).length()).toBeCloseTo(0, 12); + sameView(translated, f.camera); + } + expect(f.camera.position.toArray()).toEqual([123, 45, 67]); + expect(f.camera.matrixWorld.elements).toEqual(worldBefore); + }); + it("rebuilds same-object calibration/rotation/RT geometry and origins while safety still permits Motion", () => { + const f = fixture(); const safety = new SliderAutomaticMotion(); const r = new SliderGLRenderer() as any; + expect(resolve(safety, f)).toBe(Mode.Motion); + r.update(f.frame, Mode.Motion, false, true, true); + let mesh = r._scene.planes[0].mesh; + f.state.currentImage.camera.parameters.focal = 0.85; + expect(resolve(safety, f)).toBe(Mode.Motion); + r.update(f.frame, Mode.Motion, false, true, true); + expect(r._scene.planes[0].mesh).not.toBe(mesh); + expect(r._scene.planes[0].mesh.material.uniforms.focal.value).toBe(0.85); + mesh = r._scene.planes[0].mesh; + const points = Array.from(mesh.geometry.getAttribute("position").array); + f.state.currentImage.rotation[2] = 0.01; + expect(resolve(safety, f)).toBe(Mode.Motion); + r.update(f.frame, Mode.Motion, false, true, true); + expect(r._scene.planes[0].mesh).not.toBe(mesh); + expect(Array.from(r._scene.planes[0].mesh.geometry.getAttribute("position").array)).not.toEqual(points); + mesh = r._scene.planes[0].mesh; + // Mutate RT itself without updating Transform's cached inverse. + f.state.currentTransform.rt.elements[12] = -1.5; + expect(resolve(safety, f)).toBe(Mode.Motion); + r.update(f.frame, Mode.Motion, false, true, true); + expect(r._scene.planes[0].mesh).not.toBe(mesh); + expect(r._motionOrigins[1].toArray()).toEqual([1.5, 0, 0]); + r.updateCurtain(0.5, true); + expect(r._getCurrentPerspective(f.camera).position.toArray()).toEqual([0.75, 0, 0]); + expect(r._getPreviousPerspective(f.camera).position.toArray()).toEqual([0.75, 0, 0]); + r.dispose(); safety.dispose(); + }); + it("enforces stationary rendering for mixed viewAligned pairs even with direct requested Motion", () => { + const f = fixture(); const r = new SliderGLRenderer() as any; + f.state.previousImage.cameraType = f.state.previousImage.camera.type = "spherical"; + f.state.previousTransform = transform(f.state.previousImage, 0); + r.update(f.frame, Mode.Motion, false, true, false); + expect(r._mode).toBe(Mode.Stationary); + expect(r._getCurrentPerspective(f.camera)).toBe(f.camera); + expect(r._scene.planes[0].mesh.geometry.index.count).toBe(6); + r.dispose(); + }); + it("uses an immutable shared snapshot despite same-object, same-length provider mesh mutation", () => { + const f = fixture(); const safety = new SliderAutomaticMotion(); const r = new SliderGLRenderer() as any; + expect(resolve(safety, f)).toBe(Mode.Motion); + r.update(f.frame, Mode.Motion, false, true, true); + const before = Array.from(r._scene.planes[0].mesh.geometry.getAttribute("position").array); + f.state.currentImage.mesh.vertices.fill(NaN); + expect(resolve(safety, f)).toBe(Mode.Motion); + r.update(f.frame, Mode.Stationary, false, true, true); + r.update(f.frame, Mode.Motion, false, true, true); + expect(Array.from(r._scene.planes[0].mesh.geometry.getAttribute("position").array)).toEqual(before); + // A new object is a new version and must be rejected/revalidated. + f.state.currentImage.mesh = { vertices: f.state.currentImage.mesh.vertices.slice(), faces: f.state.currentImage.mesh.faces.slice() }; + expect(resolve(safety, f)).toBe(Mode.Stationary); + r.dispose(); safety.dispose(); + }); + for (const automatic of [false, true]) { + it(`retains same-id high-res providers/textures across repeated mode rebuilds (automatic=${automatic})`, () => { + const f = fixture(); const r = new SliderGLRenderer() as any; + r.update(f.frame, Mode.Stationary, false, true, automatic); + const providers = ["current", "previous"].map(id => ({ id, textureCreated$: new Subject(), + textureUpdated$: new Subject(), dispose: jasmine.createSpy("dispose") })); + const textures = [new THREE.Texture(), new THREE.Texture()]; + const disposers = textures.map(t => spyOn(t, "dispose").and.callThrough()); + r.setTextureProvider("current", providers[0]); r.setTextureProviderPrev("previous", providers[1]); + providers.forEach((p, n) => p.textureCreated$.next(textures[n])); + for (const mode of [Mode.Motion, Mode.Stationary, Mode.Motion, Mode.Stationary]) { + r.update(f.frame, mode, false, true, automatic); + expect(r._scene.planes[0].mesh.material.uniforms.projectorTex.value).toBe(textures[0]); + expect(r._scene.planesOld[0].mesh.material.uniforms.projectorTex.value).toBe(textures[1]); + providers.forEach(p => expect(p.dispose).not.toHaveBeenCalled()); + disposers.forEach(d => expect(d).not.toHaveBeenCalled()); + } + const previousImage = f.state.previousImage, previousTransform = f.state.previousTransform; + f.state.previousImage = null; f.state.previousTransform = null; + r.update(f.frame, Mode.Motion, false, true, automatic); + expect(r.disabled).toBe(true); + providers.forEach(p => expect(p.dispose).not.toHaveBeenCalled()); + disposers.forEach(d => expect(d).not.toHaveBeenCalled()); + f.state.previousImage = previousImage; f.state.previousTransform = previousTransform; + r.update(f.frame, Mode.Motion, false, true, automatic); + expect(r.disabled).toBe(false); + expect(r._scene.planes[0].mesh.material.uniforms.projectorTex.value).toBe(textures[0]); + expect(r._scene.planesOld[0].mesh.material.uniforms.projectorTex.value).toBe(textures[1]); + providers.forEach(p => p.textureUpdated$.next(true)); + expect(r.needsRender).toBe(true); + r.dispose(); + providers.forEach(p => expect(p.dispose).toHaveBeenCalledTimes(1)); + disposers.forEach(d => expect(d).toHaveBeenCalledTimes(1)); + }); + } + it("handles corrupt opt-in renderer data without throwing", () => { + const f = fixture(); const r = new SliderGLRenderer(); + f.state.currentImage.camera = null; + expect(() => r.update(f.frame, Mode.Stationary, false, true, true)).not.toThrow(); + expect(r.disabled).toBe(true); r.dispose(); + }); + it("renders both real meshes from the translated live clone, preserving exact direction/up/projection/crop", () => { + const f = fixture(); const r = new SliderGLRenderer() as any; + f.camera.up.set(0.1, -0.9, 0.2).normalize(); f.camera.lookAt(2.5, 0.3, 20); + f.camera.zoom = 1.4; f.camera.setViewOffset(1400, 1000, 30, 40, 1200, 900); + f.camera.updateMatrixWorld(true); + const before = f.camera.clone(); + r.update(f.frame, Mode.Motion, true, true, true); + expect(r._scene.planes[0].mesh.geometry.index.count).toBe(12); + expect(r._scene.planesOld[0].mesh.geometry.index.count).toBe(12); + 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 }); + const cameras = render.calls.allArgs().map(args => args[1]); + expect(cameras.length).toBe(2); + cameras.forEach(camera => { + expect(camera.position.toArray()).toEqual([2 * position, 0, 0]); + expect(new THREE.Vector3().setFromMatrixPosition(camera.matrixWorld).toArray()).toEqual([2 * position, 0, 0]); + expect(camera.position.clone().applyMatrix4(camera.matrixWorldInverse).length()).toBeCloseTo(0, 12); + const ray = new THREE.Raycaster(); ray.setFromCamera(new THREE.Vector2(), camera); + expect(ray.ray.origin.toArray()).toEqual([2 * position, 0, 0]); + sameView(camera, before); + }); + sameView(f.camera, before); + expect(f.camera.position.toArray()).toEqual(before.position.toArray()); + } + r.update(f.frame, Mode.Stationary, true, true, true); + expect(r._scene.planes[0].mesh.geometry.index.count).toBe(6); + expect(r._scene.planesOld[0].mesh.geometry.index.count).toBe(6); + for (const position of [0, 0.5, 1]) { + r.updateCurtain(position, true); + expect(r._getCurrentPerspective(f.camera)).toBe(f.camera); + expect(r._getPreviousPerspective(f.camera).position.toArray()).toEqual([0, 0, 0]); + } + r.dispose(); + }); +}); + +describe("Slider view-aligned native mode roundtrip", () => { + it("preserves projection and direction while translating only Motion render cameras, including repeated 2D returns", () => { + const f = fixture(); const r = new SliderGLRenderer() as any; + f.state.previousImage.scale = 1.07; + f.state.currentImage.scale = 1.025; + f.state.previousTransform = transform(f.state.previousImage, -1.3); + f.state.currentTransform = transform(f.state.currentImage, 2.1); + f.camera.position.set(2.1, 0, 0); + f.camera.up.set(0.1, -0.9, 0.2).normalize(); f.camera.lookAt(2.5, 0.3, 20); + f.camera.zoom = 1.4; f.camera.setViewOffset(1400, 1000, 30, 40, 1200, 900); + f.camera.updateMatrix(); f.camera.updateMatrixWorld(true); + const before = f.camera.clone(); + for (const mode of [Mode.Stationary, Mode.Motion, Mode.Stationary, Mode.Motion, Mode.Stationary]) { + r.update(f.frame, mode, true, true, false); + for (const position of [0, 0.25, 0.5, 0.75, 1]) { + r.updateCurtain(position, true); + const current = r._getCurrentPerspective(f.camera); + const previous = r._getPreviousPerspective(f.camera); + sameView(current, before); sameView(previous, before); sameView(f.camera, before); + expect(f.camera.position.toArray()).toEqual(before.position.toArray()); + const currentX = mode === Mode.Motion ? -1.3 + 3.4 * position : 2.1; + const previousX = mode === Mode.Motion ? currentX : -1.3; + [current, previous].forEach((camera, n) => { + const x = n === 0 ? currentX : previousX; + expect(camera.position.x).toBeCloseTo(x, 12); + expect(new THREE.Vector3().setFromMatrixPosition(camera.matrixWorld).x).toBeCloseTo(x, 12); + const ray = new THREE.Raycaster(); ray.setFromCamera(new THREE.Vector2(), camera); + expect(ray.ray.origin.x).toBeCloseTo(x, 12); + expect(camera.position.clone().applyMatrix4(camera.matrixWorldInverse).length()).toBeCloseTo(0, 12); + }); + } + } + r.dispose(); + }); + it("revalidates native same-ID pose and motionless changes instead of retaining stale camera origins", () => { + const f = fixture(); const r = new SliderGLRenderer() as any; + r.update(f.frame, Mode.Motion, false, true, false); r.updateCurtain(0.5, true); + expect(r._getCurrentPerspective(f.camera).position.x).toBeCloseTo(1, 12); + const first = r._scene.planes[0].mesh; + f.state.currentTransform.rt.elements[12] = -1.5; + r.update(f.frame, Mode.Motion, false, true, false); + expect(r._scene.planes[0].mesh).not.toBe(first); + expect(r._getCurrentPerspective(f.camera).position.x).toBeCloseTo(0.75, 12); + expect(r._getPreviousPerspective(f.camera).position.x).toBeCloseTo(0.75, 12); + f.state.motionless = true; + r.update(f.frame, Mode.Motion, false, true, false); + expect(r._mode).toBe(Mode.Stationary); + expect(r._motionOrigins).toEqual([]); + expect(r._getCurrentPerspective(f.camera)).toBe(f.camera); + expect(r._scene.planes[0].mesh.geometry.index.count).toBe(6); + f.state.motionless = false; + r.update(f.frame, Mode.Motion, false, true, false); + expect(r._mode).toBe(Mode.Motion); + expect(r._getCurrentPerspective(f.camera).position.x).toBeCloseTo(0.75, 12); + r.dispose(); + }); + for (const automatic of [false, true]) { + it(`uses fresh previous RT origins on Stationary return and motionless fallback (automatic=${automatic})`, () => { + const f = fixture(); const r = new SliderGLRenderer() as any; + r.update(f.frame, Mode.Motion, false, true, automatic); r.updateCurtain(0.5, true); + // Change RT in place while leaving Transform's cached inverse stale. + f.state.previousTransform.rt.elements[12] = 1.25; + r.update(f.frame, Mode.Motion, false, true, automatic); + expect(r._getCurrentPerspective(f.camera).position.x).toBeCloseTo(0.375, 12); + const assertStationary = () => { + const previous = r._getPreviousPerspective(f.camera); + expect(previous.position.x).toBeCloseTo(-1.25, 12); + expect(new THREE.Vector3().setFromMatrixPosition(previous.matrixWorld).x).toBeCloseTo(-1.25, 12); + const component = new SliderComponent("slider", new ContainerMockCreator().create(), new NavigatorMockCreator().create()) as any; + const compute = spyOn(component._roiCalculator, "computeRegionOfInterest").and.returnValue({}); + component._getViewAlignedRegionOfInterest({ perspective: f.camera }, f.size, f.state.previousTransform); + const roiCamera = compute.calls.mostRecent().args[0].perspective; + expect(roiCamera.matrixWorld.elements).toEqual(previous.matrixWorld.elements); + sameView(roiCamera, previous); + }; + r.update(f.frame, Mode.Stationary, false, true, automatic); assertStationary(); + r.update(f.frame, Mode.Motion, false, true, automatic); + f.state.motionless = true; + r.update(f.frame, Mode.Motion, false, true, automatic); expect(r._mode).toBe(Mode.Stationary); assertStationary(); + r.dispose(); + }); + } + it("does not kill native view-aligned rendering while the comparison transform is still hydrating", () => { + const f = fixture(); const r = new SliderGLRenderer() as any; + const previous = f.state.previousTransform; + f.state.previousTransform = null; + f.state.previousImage = null; + expect(() => r.update(f.frame, Mode.Motion, false, true, false)).not.toThrow(); + expect(r.disabled).toBe(true); + // The previousCamera can precede previousTransform in the same frame. + expect(r._previousCamera).not.toBeNull(); + f.state.previousImage = image("previous"); + f.state.previousTransform = previous; + expect(() => r.update(f.frame, Mode.Motion, false, true, false)).not.toThrow(); + expect(r.disabled).toBe(false); + expect(r._scene.planesOld.length).toBe(1); + r.dispose(); + }); + for (const missing of ["currentImage", "previousTransform", "currentTransform"]) { + it(`survives a missing ${missing} and recovers the same-ID view-aligned pair`, () => { + const f = fixture(); const r = new SliderGLRenderer() as any; + r.update(f.frame, Mode.Motion, false, true, false); + const saved = f.state[missing]; f.state[missing] = null; + expect(() => r.update(f.frame, Mode.Motion, false, true, false)).not.toThrow(); + expect(r.disabled).toBe(true); expect(r._motionOrigins.length).toBe(0); + f.state[missing] = saved; + expect(() => r.update(f.frame, Mode.Motion, false, true, false)).not.toThrow(); + expect(r.disabled).toBe(false); expect(r._scene.planesOld.length).toBe(1); + r.dispose(); + }); + } + it("does not translate non-reconstructed/motionless view-aligned fallback cameras", () => { + const f = fixture(); const r = new SliderGLRenderer() as any; + f.state.motionless = true; + r.update(f.frame, Mode.Motion, false, true, false); + r.updateCurtain(0.25, true); + expect(r._getCurrentPerspective(f.camera)).toBe(f.camera); + r.dispose(); + }); +}); + +describe("Slider automatic shared decision and interactive state", () => { + function componentFixture(): any { + const container = new ContainerMockCreator().create(); + const navigator = new NavigatorMockCreator().create(); + new MockCreator().mockProperty(container, "domContainer", document.createElement("div")); + const dom = spyOn(SliderDOMRenderer.prototype, "render").and.returnValue({} as any); + const update = spyOn(SliderGLRenderer.prototype, "update"); + const component = new SliderComponent("slider", container, navigator); + const f = fixture(); + // Avoid initiating mock graph loads; ids are installed with configure + // before activation and the graph cache returns an empty stream. + spyOn((component as any), "_catchCacheImage$").and.returnValue(new Subject()); + component.configure({ ...f.configuration, initialPosition: 0.5, screenSpace: true }); + component.activate(); + (container.renderService.size$ as Subject).next(f.size); + (navigator.stateService.state$ as Subject).next(State.Traversing); + const frame = () => (navigator.stateService.currentState$ as Subject).next(f.frame); + const view = () => (container.renderService.renderCameraFrame$ as Subject).next({ perspective: f.camera, currentImageId: "current" }); + frame(); view(); + return { ...f, component, container, navigator, dom, update, emitFrame: frame, emitView: view }; + } + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + for (const side of ["current", "previous"]) { + it(`keeps merged mixed ${side}-spherical comparisons in 2D despite manual Motion with automaticMotion=false`, () => { + const f = componentFixture(); + const i = f.state[`${side}Image`]; + i.cameraType = i.camera.type = "spherical"; + f.state[`${side}Transform`] = transform(i, side === "current" ? 2 : 0); + f.component.configure({ automaticMotion: false, viewAligned: true, mode: Mode.Motion }); + f.emitFrame(); + (f.component as any)._domRenderer._notifyModeChanged$.next(Mode.Motion); + expect(f.update.calls.mostRecent().args[1]).toBe(Mode.Stationary); + expect(f.dom.calls.mostRecent().args[1]).toBe(Mode.Stationary); + expect(f.dom.calls.mostRecent().args[2]).toBe(true); + f.component.setPosition(0.25); + expect(f.navigator.stateService.moveTo.calls.mostRecent().args).toEqual([1]); + expect(f.navigator.stateService.wait).not.toHaveBeenCalled(); + expect(f.navigator.stateService.waitInteractively).toHaveBeenCalled(); + f.component.deactivate(); + }); + } + it("keeps native view-aligned mode changes interactive and alpha1 without resetting the selected crop", () => { + const f = componentFixture(); + f.component.configure({ automaticMotion: false, viewAligned: true, mode: Mode.Stationary }); + f.navigator.stateService.wait.calls.reset(); + f.navigator.stateService.moveTo.calls.reset(); + for (const mode of [Mode.Motion, Mode.Stationary, Mode.Motion, Mode.Stationary]) { + (f.component as any)._domRenderer._notifyModeChanged$.next(mode); + f.component.setPosition(0.25); + expect(f.update.calls.mostRecent().args[1]).toBe(mode); + expect(f.dom.calls.mostRecent().args[1]).toBe(mode); + expect(f.navigator.stateService.moveTo.calls.mostRecent().args).toEqual([1]); + expect(f.navigator.stateService.wait).not.toHaveBeenCalled(); + } + expect(f.navigator.stateService.waitInteractively).toHaveBeenCalled(); + f.component.deactivate(); + }); + it("snapshots legacy interaction primitives before distinct when one frame/state object is repeatedly mutated", () => { + const f = componentFixture(); + f.component.configure({ automaticMotion: false, viewAligned: false, mode: Mode.Motion }); + f.navigator.stateService.wait.calls.reset(); + f.navigator.stateService.waitInteractively.calls.reset(); + f.state.motionless = true; + f.emitFrame(); + expect(f.navigator.stateService.waitInteractively).toHaveBeenCalledTimes(1); + expect(f.navigator.stateService.moveTo.calls.mostRecent().args).toEqual([1]); + f.state.motionless = false; + f.emitFrame(); + expect(f.navigator.stateService.wait).toHaveBeenCalledTimes(1); + expect(f.navigator.stateService.moveTo.calls.mostRecent().args).toEqual([0.5]); + f.state.currentImage.id = "new-current"; + f.state.previousImage.id = "new-previous"; + f.emitFrame(); + expect(f.navigator.stateService.wait).toHaveBeenCalledTimes(2); + f.component.deactivate(); + }); + for (const automatic of [false, true]) { + it(`updates both Motion tile ROIs on curtain/mode changes (automatic=${automatic})`, () => { + const f = componentFixture(); + f.component.configure({ automaticMotion: automatic, viewAligned: true, mode: Mode.Motion }); + const roi = { bbox: { minX: 0.2, maxX: 0.8, minY: 0.3, maxY: 0.7 }, pixelWidth: 0.001, pixelHeight: 0.002 }; + const compute = spyOn((f.component as any)._roiCalculator, "computeRegionOfInterest").and.returnValue(roi); + const setROI = spyOn(TextureProvider.prototype, "setRegionOfInterest"); + (f.container.configurationService.imageTiling$ as Subject).next(true); + (f.container.glRenderer.webGLRenderer$ as Subject).next({}); + (f.container.renderService.size$ as Subject).next(f.size); + f.emitFrame(); + (f.navigator.stateService.currentTransform$ as Subject).next(f.state.currentTransform); + f.emitView(); + (f.container.renderService.size$ as Subject).next(f.size); + jest.advanceTimersByTime(100); + setROI.calls.reset(); compute.calls.reset(); + f.component.setPosition(0.25); + expect(setROI.calls.count()).toBe(2); + expect(compute.calls.count()).toBe(2); + compute.calls.allArgs().forEach(args => { + expect(args[0].perspective.position.toArray()).toEqual([0.5, 0, 0]); + expect(new THREE.Vector3().setFromMatrixPosition(args[0].perspective.matrixWorld).toArray()).toEqual([0.5, 0, 0]); + const ray = new THREE.Raycaster(); ray.setFromCamera(new THREE.Vector2(), args[0].perspective); + expect(ray.ray.origin.toArray()).toEqual([0.5, 0, 0]); + sameView(args[0].perspective, f.camera); + }); + setROI.calls.allArgs().forEach(args => expect(args[0].bbox).toEqual({ minX: 0, maxX: 1, minY: 0, maxY: 1 })); + expect(new Set(compute.calls.allArgs().map(args => args[2]))).toEqual(new Set([f.state.currentTransform, f.state.previousTransform])); + f.component.configure({ mode: Mode.Stationary }); + f.component.configure({ mode: Mode.Motion }); + expect(setROI.calls.count()).toBeGreaterThan(2); + const previousImage = f.state.previousImage, previousTransform = f.state.previousTransform; + setROI.calls.reset(); compute.calls.reset(); + f.state.previousImage = null; f.state.previousTransform = null; + f.emitFrame(); f.component.setPosition(0.75); f.emitView(); + expect(compute.calls.count()).toBe(0); expect(setROI.calls.count()).toBe(0); + f.state.previousImage = previousImage; f.state.previousTransform = previousTransform; + f.emitFrame(); jest.advanceTimersByTime(100); + setROI.calls.reset(); compute.calls.reset(); f.component.setPosition(0.25); + expect(compute.calls.count()).toBe(2); expect(setROI.calls.count()).toBe(2); + f.component.deactivate(); + }); + } + it("uses stationary render cameras for native motionless fallback tile footprints", () => { + const f = componentFixture(); + f.component.configure({ automaticMotion: false, viewAligned: true, mode: Mode.Motion }); + f.state.motionless = true; f.emitFrame(); + const roi = { bbox: { minX: 0.2, maxX: 0.8, minY: 0.3, maxY: 0.7 }, pixelWidth: 0.001, pixelHeight: 0.002 }; + const compute = spyOn((f.component as any)._roiCalculator, "computeRegionOfInterest").and.returnValue(roi); + const setROI = spyOn(TextureProvider.prototype, "setRegionOfInterest"); + (f.container.configurationService.imageTiling$ as Subject).next(true); + (f.container.glRenderer.webGLRenderer$ as Subject).next({}); + (f.container.renderService.size$ as Subject).next(f.size); f.emitFrame(); + (f.navigator.stateService.currentTransform$ as Subject).next(f.state.currentTransform); + f.emitView(); (f.container.renderService.size$ as Subject).next(f.size); + setROI.calls.reset(); compute.calls.reset(); f.component.setPosition(0.25); + expect(f.update.calls.mostRecent().args[1]).toBe(Mode.Stationary); + expect(compute.calls.count()).toBe(2); expect(setROI.calls.count()).toBe(2); + f.update.and.callThrough(); + const r = new SliderGLRenderer() as any; r.update(f.frame, Mode.Motion, false, true, false); r.updateCurtain(0.25, true); + for (const args of compute.calls.allArgs()) { + const expected = args[2] === f.state.currentTransform ? r._getCurrentPerspective(f.camera) : r._getPreviousPerspective(f.camera); + expect(args[0].perspective.matrixWorld.elements).toEqual(expected.matrixWorld.elements); + sameView(args[0].perspective, expected); + } + setROI.calls.allArgs().forEach(args => expect(args[0].bbox).toEqual(roi.bbox)); + r.dispose(); f.component.deactivate(); + }); + it("waits interactively and holds native alpha at 1 even when effective Motion", () => { + const f = componentFixture(); + expect(f.component.defaultConfiguration.automaticMotion).toBe(false); + expect(f.update.calls.mostRecent().args[1]).toBe(Mode.Stationary); + jest.advanceTimersByTime(100); + expect(f.update.calls.mostRecent().args.slice(1)).toEqual([Mode.Motion, false, true, true]); + expect(f.dom.calls.mostRecent().args[1]).toBe(Mode.Motion); + f.component.setPosition(0.25); + expect(f.navigator.stateService.moveTo.calls.mostRecent().args).toEqual([1]); + expect(f.navigator.stateService.wait).not.toHaveBeenCalled(); + expect(f.navigator.stateService.waitInteractively).toHaveBeenCalled(); + f.component.deactivate(); + }); + it("immediately revokes Motion for a same-object merge-ID mismatch and cannot be bypassed by the mode control", () => { + const f = componentFixture(); jest.advanceTimersByTime(100); + expect(f.update.calls.mostRecent().args[1]).toBe(Mode.Motion); + 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); + expect(f.navigator.stateService.moveTo.calls.mostRecent().args).toEqual([1]); + expect(f.navigator.stateService.wait).not.toHaveBeenCalled(); + f.component.deactivate(); + }); + it("never enables a stale pair and mode button cannot override an unsafe rejection", () => { + const f = componentFixture(); jest.advanceTimersByTime(100); + f.component.configure({ ids: { background: "replacement", foreground: "current" } }); + 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(); + }); + it("revokes on view/resize changes and only checks a settled view, reusing manual2D coverage", () => { + const f = componentFixture(); const covered = spyOn(SliderAutomaticMotion.prototype as any, "_covered").and.callThrough(); + jest.advanceTimersByTime(100); expect(covered.calls.count()).toBe(1); + f.component.configure({ mode: Mode.Stationary }); + expect(f.update.calls.mostRecent().args[1]).toBe(Mode.Stationary); + expect(f.dom.calls.mostRecent().args[2]).toBe(false); + f.component.configure({ mode: Mode.Motion }); expect(covered.calls.count()).toBe(1); + for (let n = 0; n < 20; n++) { f.emitFrame(); f.emitView(); } + expect(covered.calls.count()).toBe(1); + f.camera.zoom = 1.2; f.camera.updateProjectionMatrix(); f.emitView(); + expect(f.update.calls.mostRecent().args[1]).toBe(Mode.Stationary); + expect(covered.calls.count()).toBe(1); + jest.advanceTimersByTime(100); expect(covered.calls.count()).toBe(2); + (f.container.renderService.size$ as Subject).next({ width: 1000, height: 900 }); + expect(covered.calls.count()).toBe(2); + expect(f.update.calls.mostRecent().args[1]).toBe(Mode.Stationary); + jest.advanceTimersByTime(100); + expect(covered.calls.count()).toBe(3); + f.component.deactivate(); + }); +}); diff --git a/test/component/slider/SliderEntry.test.ts b/test/component/slider/SliderEntry.test.ts new file mode 100644 index 000000000..e5d65ecd2 --- /dev/null +++ b/test/component/slider/SliderEntry.test.ts @@ -0,0 +1,77 @@ +import { Subject } from "rxjs"; +import { SliderComponent } from "../../../src/component/slider/SliderComponent"; +import { SliderDOMRenderer } from "../../../src/component/slider/SliderDOMRenderer"; +import { SliderGLRenderer } from "../../../src/component/slider/SliderGLRenderer"; +import { SliderConfigurationMode } from "../../../src/component/interfaces/SliderConfiguration"; +import { State } from "../../../src/state/State"; +import { ContainerMockCreator } from "../../helper/ContainerMockCreator"; +import { NavigatorMockCreator } from "../../helper/NavigatorMockCreator"; +import { MockCreator } from "../../helper/MockCreator"; + +describe("Slider entry camera state", () => { + function fixture(): any { + const container = new ContainerMockCreator().create(); + const navigator = new NavigatorMockCreator().create(); + new MockCreator().mockProperty(container, "domContainer", document.createElement("div")); + spyOn(SliderDOMRenderer.prototype, "render").and.returnValue({}); + spyOn(SliderGLRenderer.prototype, "update"); + const component = new SliderComponent("slider", container, navigator); + component.configure({ initialPosition: 0.5 }); + component.activate(); + (>container.renderService.size$).next({width: 1440, height: 900}); + (>navigator.stateService.state$).next(State.Traversing); + const frame = (previous: boolean, cameraType = "spherical", motionless = true) => { + (>navigator.stateService.currentState$).next({state: { + currentImage: {cameraType}, previousImage: previous ? {cameraType} : null, + motionless, + }}); + }; + return {component, navigator, frame}; + } + + it("does not reset the lone original's view or zoom while comparison loads", () => { + const f = fixture(); + f.frame(false); + f.frame(false); + expect(f.navigator.stateService.wait).not.toHaveBeenCalled(); + expect(f.navigator.stateService.waitInteractively).not.toHaveBeenCalled(); + f.component.deactivate(); + }); + + it("enters interactive comparison only once both panoramas are available", () => { + const f = fixture(); + f.frame(false); + f.frame(true); + expect(f.navigator.stateService.wait).not.toHaveBeenCalled(); + expect(f.navigator.stateService.waitInteractively).toHaveBeenCalledTimes(1); + f.component.deactivate(); + }); + + it("preserves legacy waiting for an available noninteractive motion pair", () => { + const f = fixture(); + f.frame(false, "perspective", false); + expect(f.navigator.stateService.wait).not.toHaveBeenCalled(); + f.frame(true, "perspective", false); + expect(f.navigator.stateService.wait).toHaveBeenCalledTimes(1); + expect(f.navigator.stateService.waitInteractively).not.toHaveBeenCalled(); + f.component.deactivate(); + }); + + it("does not change camera state when the curtain is deliberately hidden", () => { + const f = fixture(); + f.component.configure({sliderVisible: false}); + f.frame(true); + expect(f.navigator.stateService.wait).not.toHaveBeenCalled(); + expect(f.navigator.stateService.waitInteractively).not.toHaveBeenCalled(); + f.component.deactivate(); + }); + + it("retains interactive stationary perspective comparisons", () => { + const f = fixture(); + f.component.configure({mode: SliderConfigurationMode.Stationary}); + f.frame(true, "perspective", false); + expect(f.navigator.stateService.wait).not.toHaveBeenCalled(); + expect(f.navigator.stateService.waitInteractively).toHaveBeenCalledTimes(1); + f.component.deactivate(); + }); +}); diff --git a/test/component/slider/SliderFisheyeAxis.test.ts b/test/component/slider/SliderFisheyeAxis.test.ts new file mode 100644 index 000000000..0706eb828 --- /dev/null +++ b/test/component/slider/SliderFisheyeAxis.test.ts @@ -0,0 +1,37 @@ +import { FisheyeCamera } from "../../../src/geometry/camera/FisheyeCamera"; +import { SliderTransform } from "../../../src/component/slider/SliderTransform"; + +function slider(): SliderTransform { + return new SliderTransform(1, 4000, 3000, 1, [0, 0, 0], [0, 0, 0], + undefined, undefined, [0.7, 0.01, 0.02], "fisheye"); +} + +describe("Fisheye exact optical-axis projection", () => { + it("projects the positive optical axis to finite texture center at any distance", () => { + const camera = new FisheyeCamera([0.7, 0.01, 0.02]); + for (const z of [1, 50, 200]) { + expect(camera.projectToSfm([0, 0, z])).toEqual([0, 0]); + } + }); + it("rejects non-forward optical-axis rays instead of painting the image center", () => { + const camera = new FisheyeCamera([0.7, 0.01, 0.02]); + for (const z of [0, -1]) { + expect(camera.projectToSfm([0, 0, z])).toEqual([Infinity, Infinity]); + } + }); + it("preserves continuous near-axis projection", () => { + const camera = new FisheyeCamera([0.7, 0.01, 0.02]); + const projected = camera.projectToSfm([1e-10, -1e-10, 1]); + expect(projected.every(Number.isFinite)).toBe(true); + expect(Math.abs(projected[0])).toBeLessThan(1e-8); + expect(Math.abs(projected[1])).toBeLessThan(1e-8); + }); + it("projects the stationary slider optical axis without division by zero", () => { + for (const z of [1, 50, 200]) { + expect(slider().projectSfM([0, 0, z])).toEqual([0, 0]); + } + }); + it("keeps the stationary slider rear-ray guard", () => { + expect(slider().projectSfM([0, 0, -1])).toEqual([Infinity, Infinity]); + }); +}); diff --git a/test/component/slider/SliderHeading.test.ts b/test/component/slider/SliderHeading.test.ts new file mode 100644 index 000000000..a3d610b46 --- /dev/null +++ b/test/component/slider/SliderHeading.test.ts @@ -0,0 +1,67 @@ +import * as THREE from "three"; +import { sliderHeadingRotation } from "../../../src/component/slider/SliderHeading"; +import { Image } from "../../../src/graph/Image"; +import { SliderGLRenderer } from "../../../src/component/slider/SliderGLRenderer"; +import { SliderConfigurationMode } from "../../../src/component/interfaces/SliderConfiguration"; +import { Camera } from "../../../src/geo/Camera"; + +function image(compassAngle: number, cameraType: string = "spherical"): Image { + return { compassAngle, cameraType } as Image; +} +function camera(): Camera { + const result = new Camera(); + result.lookat.set(0, 0, -1); + result.up.set(0, 1, 0); + return result; +} + +describe("Slider panorama heading", () => { + it("aligns opposite-facing captures to the same physical viewing direction", () => { + const rotation = sliderHeadingRotation(image(180), image(0)); + const direction = new THREE.Vector3(0, 0, -1).applyQuaternion(rotation); + expect(direction.z).toBeCloseTo(1); + expect(direction.y).toBeCloseTo(0); + }); + it("wraps north crossings without changing pitch or roll", () => { + const rotation = sliderHeadingRotation(image(350), image(10)); + const expected = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), 20 * Math.PI / 180); + expect(Math.abs(rotation.dot(expected))).toBeCloseTo(1, 10); + const up = new THREE.Vector3(0, 1, 0).applyQuaternion(rotation); + expect(up.x).toBeCloseTo(0); + expect(up.y).toBeCloseTo(1); + expect(up.z).toBeCloseTo(0); + }); + it("falls back to native-relative rotation when heading is unavailable", () => { + for (const heading of [undefined, null, NaN, Infinity]) { + expect(sliderHeadingRotation(image(180), image(heading)).toArray()).toEqual([0, 0, 0, 1]); + expect(sliderHeadingRotation(image(heading), image(0)).toArray()).toEqual([0, 0, 0, 1]); + } + }); + it("does not apply panorama heading offsets to perspective or missing images", () => { + expect(sliderHeadingRotation(image(180, "perspective"), image(0)).toArray()).toEqual([0, 0, 0, 1]); + expect(sliderHeadingRotation(image(180), image(0, "perspective")).toArray()).toEqual([0, 0, 0, 1]); + expect(sliderHeadingRotation(null, image(0)).toArray()).toEqual([0, 0, 0, 1]); + }); + it("applies current rotation and zoom when selecting an opposite-facing comparison", () => { + const r = new SliderGLRenderer() as any; + r._gravityAligned = true; + r._previousSpherical = true; + r._mode = SliderConfigurationMode.Motion; + r._currentCamera = camera(); + r._previousCamera = camera(); + const live = new THREE.PerspectiveCamera(45, 1.5, 0.1, 100); + const delta = new THREE.Quaternion().setFromEuler(new THREE.Euler(0.2, 0.7, 0)); + live.quaternion.copy(delta); + live.updateMatrixWorld(true); + r._getPreviousPerspective(live); + r._headingRotation = sliderHeadingRotation(image(180), image(0)); + r._previousRotation = null; + r._previousPerspective = null; + const comparison = r._getPreviousPerspective(live); + const expected = r._headingRotation.clone().multiply(delta); + expect(Math.abs(comparison.quaternion.dot(expected))).toBeCloseTo(1, 10); + expect(comparison.fov).toBe(45); + expect(comparison.projectionMatrix.elements).toEqual(live.projectionMatrix.elements); + expect(live.quaternion.toArray()).toEqual(delta.toArray()); + }); +}); diff --git a/test/component/slider/SliderPosition.test.ts b/test/component/slider/SliderPosition.test.ts new file mode 100644 index 000000000..da2c59a6f --- /dev/null +++ b/test/component/slider/SliderPosition.test.ts @@ -0,0 +1,67 @@ +import { Subject } from "rxjs"; +import { SliderComponent } 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.setPosition", () => { + function fixture() { + const container = new ContainerMockCreator().create(); + const navigator = new NavigatorMockCreator().create(); + new MockCreator().mockProperty(container, "domContainer", document.createElement("div")); + const render = spyOn(SliderDOMRenderer.prototype, "render").and.returnValue({}); + spyOn(SliderGLRenderer.prototype, "update"); + const component = new SliderComponent("slider", container, navigator); + component.configure({ initialPosition: 0.5 }); + component.activate(); + (>container.renderService.size$).next({width: 1440, height: 900}); + (>navigator.stateService.currentState$).next({state: { + currentImage: {cameraType: "perspective"}, + previousImage: {cameraType: "perspective"}, motionless: true, + }}); + return { component, render }; + } + + it("updates the curtain without deactivating or disposing its renderer", () => { + const f = fixture(); + const dispose = spyOn(SliderGLRenderer.prototype, "dispose"); + const deactivate = spyOn(f.component, "deactivate"); + f.component.setPosition(1); + expect(f.render.calls.mostRecent().args[0]).toBe(1); + f.component.setPosition(0.5); + expect(f.render.calls.mostRecent().args[0]).toBe(0.5); + expect(f.component.activated).toBe(true); + expect(deactivate).not.toHaveBeenCalled(); + expect(dispose).not.toHaveBeenCalled(); + }); + + it("can reveal a fully hidden comparison repeatedly", () => { + const f = fixture(); + for (const hidden of [0, 1, 1, 0]) { + f.component.setPosition(hidden); + f.component.setPosition(0.5); + expect(f.render.calls.mostRecent().args[0]).toBe(0.5); + } + }); + + it("rejects non-finite and out-of-range positions without changing the curtain", () => { + const f = fixture(); + const count = f.render.calls.count(); + for (const position of [-1, 2, NaN, Infinity, -Infinity]) { + expect(() => f.component.setPosition(position)).toThrowError(RangeError); + } + expect(f.render.calls.count()).toBe(count); + }); + + it("retains the configured initial position on a new activation", () => { + const f = fixture(); + f.component.setPosition(0); + f.component.deactivate(); + f.component.activate(); + // Render streams receive fresh frames after activation. + expect(f.component.activated).toBe(true); + expect(f.component.defaultConfiguration.initialPosition).toBe(1); + }); +}); diff --git a/test/component/slider/SliderRoll.test.ts b/test/component/slider/SliderRoll.test.ts new file mode 100644 index 000000000..e37b16ec2 --- /dev/null +++ b/test/component/slider/SliderRoll.test.ts @@ -0,0 +1,58 @@ +import * as THREE from "three"; +import { sliderViewAlignedRoll, sliderViewLeveled } from "../../../src/component/slider/SliderRoll"; + +const deg = Math.PI / 180; +function camera(up: THREE.Vector3 = new THREE.Vector3(0, 0, 1)): THREE.PerspectiveCamera { + const result = new THREE.PerspectiveCamera(60, 1.5, 0.1, 100); + result.up.copy(up); + result.lookAt(new THREE.Vector3(0, 1, 0)); + result.updateMatrixWorld(true); + return result; +} +/** Image up for a forward (+y) photo rolled clockwise by `roll` radians. */ +function rolledUp(roll: number): THREE.Vector3 { + return new THREE.Vector3(0, 0, 1).applyAxisAngle(new THREE.Vector3(0, 1, 0), roll); +} +function screenAngle(c: THREE.Camera, up: THREE.Vector3): number { + const q = new THREE.Quaternion().setFromRotationMatrix( + new THREE.Matrix4().extractRotation(c.matrixWorld)).invert(); + const v = up.clone().applyQuaternion(q); + return Math.atan2(v.x, v.y); +} + +describe("Slider view-aligned roll", () => { + it("shows an upright comparison upright despite opposite small reconstruction rolls", () => { + const currentUp = rolledUp(3.3 * deg), previousUp = rolledUp(-3.4 * deg); + const live = camera(currentUp); + const roll = sliderViewAlignedRoll(live, currentUp, previousUp, false); + expect(Math.abs(roll)).toBeCloseTo(6.7 * deg, 6); + const comparison = live.clone(); + comparison.quaternion.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), roll)); + comparison.updateMatrixWorld(true); + expect(screenAngle(live, currentUp)).toBeCloseTo(0, 6); + expect(screenAngle(comparison, previousUp)).toBeCloseTo(0, 6); + }); + it("keeps native alignment when the original is panned or gravity-levelled", () => { + const currentUp = rolledUp(3 * deg), previousUp = rolledUp(-4 * deg); + const live = camera(); + const roll = sliderViewAlignedRoll(live, currentUp, previousUp, false); + const comparison = live.clone(); + comparison.quaternion.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), roll)); + comparison.updateMatrixWorld(true); + expect(screenAngle(comparison, previousUp)).toBeCloseTo(screenAngle(live, currentUp), 6); + }); + it("keeps the shared gravity frame when both views are levelled", () => { + const live = camera(); + expect(sliderViewLeveled(live)).toBe(true); + expect(sliderViewAlignedRoll(live, rolledUp(9 * deg), rolledUp(-8 * deg), true)).toBe(0); + expect(sliderViewLeveled(camera(rolledUp(3 * deg)))).toBe(false); + }); + it("ignores missing, degenerate and implausible frames", () => { + const live = camera(); + expect(sliderViewAlignedRoll(live, null, rolledUp(0), false)).toBe(0); + expect(sliderViewAlignedRoll(live, rolledUp(0), null, false)).toBe(0); + expect(sliderViewAlignedRoll(live, rolledUp(0), rolledUp(60 * deg), false)).toBe(0); + expect(sliderViewAlignedRoll(live, rolledUp(0), new THREE.Vector3(0, 1, 0), false)).toBe(0); + expect(sliderViewAlignedRoll(live, rolledUp(2 * deg), rolledUp(2 * deg), false)).toBeCloseTo(0, 10); + }); +}); diff --git a/test/component/slider/SliderRotation.test.ts b/test/component/slider/SliderRotation.test.ts new file mode 100644 index 000000000..674d800ed --- /dev/null +++ b/test/component/slider/SliderRotation.test.ts @@ -0,0 +1,113 @@ +import * as THREE from "three"; +import { SliderGLRenderer } from "../../../src/component/slider/SliderGLRenderer"; +import { SliderConfigurationMode } from "../../../src/component/interfaces/SliderConfiguration"; +import { Camera } from "../../../src/geo/Camera"; + +function nativeCamera(direction: THREE.Vector3, up: THREE.Vector3): Camera { + const camera = new Camera(); + camera.position.set(20, 10, 4); + camera.lookat.copy(camera.position).add(direction); + camera.up.copy(up); + return camera; +} +function perspective(camera: Camera): THREE.PerspectiveCamera { + const result = new THREE.PerspectiveCamera(90, 1.5, 0.1, 100); + result.position.copy(camera.position); + result.up.copy(camera.up); + result.lookAt(camera.lookat); + result.updateMatrixWorld(true); + return result; +} +function expectSameRotation(actual: THREE.Quaternion, expected: THREE.Quaternion): void { + expect(Math.abs(actual.dot(expected))).toBeCloseTo(1, 10); +} +function renderer(): any { + const result = new SliderGLRenderer() as any; + result._gravityAligned = true; + result._previousSpherical = true; + result._mode = SliderConfigurationMode.Motion; + // Distinct, non-world-up image frames reproduce the reported unsafe poses. + result._currentCamera = nativeCamera(new THREE.Vector3(1, 0, 0), new THREE.Vector3(0, 1, 0)); + result._previousCamera = nativeCamera(new THREE.Vector3(0, 0, -1), new THREE.Vector3(1, 0, 0)); + return result; +} + +describe("Slider linked fallback rotation", () => { + it("preserves each image's native upright pose at the default center", () => { + const r = renderer(); + const live = perspective(r._currentCamera); + const before = live.quaternion.clone(); + const comparison = r._getPreviousPerspective(live); + expectSameRotation(comparison.quaternion, perspective(r._previousCamera).quaternion); + expectSameRotation(live.quaternion, before); + expect(comparison.up.toArray()).toEqual(r._previousCamera.up.toArray()); + }); + it("applies a view rotated before entry on the first comparison frame", () => { + const r = renderer(); + const live = perspective(r._currentCamera); + const delta = new THREE.Quaternion().setFromEuler(new THREE.Euler(0.12, 0.6, 0)); + live.quaternion.multiply(delta); + live.updateMatrixWorld(true); + const expected = perspective(r._previousCamera).quaternion.multiply(delta); + expectSameRotation(r._getPreviousPerspective(live).quaternion, expected); + }); + it("follows horizontal and vertical drags on every render, without resetting the offset", () => { + const r = renderer(); + const live = perspective(r._currentCamera); + const original = r._getPreviousPerspective(live); + for (const [pitch, yaw] of [[0.1, 0.4], [-0.2, -0.3], [0, 0]]) { + const delta = new THREE.Quaternion().setFromEuler(new THREE.Euler(pitch, yaw, 0)); + live.quaternion.copy(perspective(r._currentCamera).quaternion).multiply(delta); + live.updateMatrixWorld(true); + const comparison = r._getPreviousPerspective(live); + expect(comparison).toBe(original); + expectSameRotation(comparison.quaternion, perspective(r._previousCamera).quaternion.multiply(delta)); + } + }); + it("copies live zoom, aspect and projection after the first render", () => { + const r = renderer(); + const live = perspective(r._currentCamera); + r._getPreviousPerspective(live); + live.fov = 42; + live.aspect = 2; + live.zoom = 1.4; + live.updateProjectionMatrix(); + const comparison = r._getPreviousPerspective(live); + expect(comparison.fov).toBe(42); + expect(comparison.aspect).toBe(2); + expect(comparison.zoom).toBe(1.4); + expect(comparison.projectionMatrix.elements).toEqual(live.projectionMatrix.elements); + }); + it("keeps spherical fallback cameras on the recentered mesh as the live position changes", () => { + const r = renderer(); + const live = perspective(r._currentCamera); + r._getPreviousPerspective(live); + live.position.set(2, 3, 4); + expect(r._getPreviousPerspective(live).position.toArray()).toEqual([2, 3, 4]); + }); + it("preserves the independent position for perspective fallback images", () => { + const r = renderer(); + const live = perspective(r._currentCamera); + r._previousSpherical = false; + live.position.set(2, 3, 4); + expect(r._getPreviousPerspective(live).position.toArray()).toEqual(r._previousCamera.position.toArray()); + }); + it("retains the shared live camera for trusted gravity-aligned Motion pairs", () => { + const r = renderer(); + const live = perspective(r._currentCamera); + r._currentGravityAligned = true; + r._previousGravityAligned = true; + expect(r._getPreviousPerspective(live)).toBe(r._getCurrentPerspective(live)); + }); + it("recomputes the native mapping after the comparison camera is replaced", () => { + const r = renderer(); + const live = perspective(r._currentCamera); + r._getPreviousPerspective(live); + const delta = new THREE.Quaternion().setFromEuler(new THREE.Euler(0.1, 0.4, 0)); + live.quaternion.multiply(delta); + r._previousCamera = nativeCamera(new THREE.Vector3(0, 1, 0), new THREE.Vector3(0, 0, 1)); + r._previousPerspective = null; + r._previousRotation = null; + expectSameRotation(r._getPreviousPerspective(live).quaternion, perspective(r._previousCamera).quaternion.multiply(delta)); + }); +}); diff --git a/test/component/slider/SliderScreenSpace.test.ts b/test/component/slider/SliderScreenSpace.test.ts new file mode 100644 index 000000000..7174153d3 --- /dev/null +++ b/test/component/slider/SliderScreenSpace.test.ts @@ -0,0 +1,102 @@ +import { Subject } from "rxjs"; +import { SliderComponent } from "../../../src/component/slider/SliderComponent"; +import { SliderDOMRenderer } from "../../../src/component/slider/SliderDOMRenderer"; +import { SliderGLRenderer } from "../../../src/component/slider/SliderGLRenderer"; +import { SliderMeshFactory } from "../../../src/component/slider/SliderMeshFactory"; +import { ViewportCoords } from "../../../src/geo/ViewportCoords"; +import { ContainerMockCreator } from "../../helper/ContainerMockCreator"; +import { NavigatorMockCreator } from "../../helper/NavigatorMockCreator"; +import { MockCreator } from "../../helper/MockCreator"; + +describe("Slider opt-in screen-space curtain", () => { + function renderer(): any { + const r = new SliderGLRenderer() as any; + r._scene.planes.push({mesh: {material: {uniforms: { + curtain: {value: 1}, screenSpace: {value: false}, + }}}}); + return r; + } + it("keeps image-coordinate clipping as the default", () => { + const r = renderer(); + r.updateCurtain(0.5); + expect(r._scene.planes[0].mesh.material.uniforms.screenSpace.value).toBe(false); + expect(r._scene.planes[0].mesh.material.uniforms.curtain.value).toBe(0.5); + }); + it("retains exact screen fractions including fully hidden and visible endpoints", () => { + const r = renderer(); + for (const p of [0, 0.25, 0.5, 0.75, 1]) { + r.updateCurtain(p, true); + const uniforms = r._scene.planes[0].mesh.material.uniforms; + expect(uniforms.curtain.value).toBe(p); + expect(uniforms.screenSpace.value).toBe(true); + } + }); + it("invalidates when clipping mode changes at the same position", () => { + const r = renderer(); + r.updateCurtain(0.5, true); + r._needsRender = false; + r.updateCurtain(0.5, false); + expect(r.needsRender).toBe(true); + expect(r._scene.planes[0].mesh.material.uniforms.screenSpace.value).toBe(false); + }); + it("does not invalidate unchanged screen-space position", () => { + const r = renderer(); + r.updateCurtain(0.5, true); + r._needsRender = false; + r.updateCurtain(0.5, true); + expect(r.needsRender).toBe(false); + }); + for (const method of ["_createCurtainSphereMaterialParameters", + "_createCurtainPlaneMaterialParameters", "_createCurtainPlaneMaterialParametersFisheye", + "_createDistortedCurtainPlaneMaterialParameters"]) { + it(`uses homogeneous screen clipping without changing the ${method} lens projection`, () => { + const f = new SliderMeshFactory() as any; + const transform = {focal: 0.8, ck1: 0.02, ck2: 0.01, basicWidth: 4, + basicHeight: 3, projectorMatrix: () => ({})}; + const p = f[method](transform, {}); + expect(p.uniforms.screenSpace.value).toBe(false); + expect(p.vertexShader).toContain("vClipPosition = gl_Position;"); + expect(p.fragmentShader).toContain("vClipPosition.x / vClipPosition.w"); + expect(p.fragmentShader).toContain("curtain <= 0.0"); + expect(p.fragmentShader).toContain("curtain < 1.0 && screenX >= curtain"); + }); + } + function component(screenSpace: boolean): any { + const container = new ContainerMockCreator().create(); + const navigator = new NavigatorMockCreator().create(); + new MockCreator().mockProperty(container, "domContainer", document.createElement("div")); + spyOn(SliderDOMRenderer.prototype, "render").and.returnValue({} as any); + spyOn(SliderGLRenderer.prototype, "update"); + const update = spyOn(SliderGLRenderer.prototype, "updateCurtain"); + const project = spyOn(ViewportCoords.prototype, "viewportToBasic") + .and.callFake(x => x < 0 ? [0.8, 0.5] : [0.1, 0.5]); + const c = new SliderComponent("slider", container, navigator); + c.configure({initialPosition: 0.5, screenSpace}); + c.activate(); + (container.renderService.size$ as Subject).next({width: 1440, height: 900}); + (navigator.stateService.currentState$ as Subject).next({state: { + currentImage: {cameraType: "spherical"}, + previousImage: {cameraType: "spherical"}, motionless: true, + }}); + (container.renderService.renderCamera$ as Subject).next({perspective: {}}); + (navigator.stateService.currentTransform$ as Subject).next({}); + return {c, container, update, project}; + } + it("passes the visible fraction directly, bypassing panorama wrap and camera projection", () => { + const f = component(true); + expect(f.update.calls.mostRecent().args).toEqual([0.5, true]); + for (const position of [0, 0.25, 0.5, 0.75, 1]) { + f.c.setPosition(position); + expect(f.update.calls.mostRecent().args).toEqual([position, true]); + } + expect(f.project).not.toHaveBeenCalled(); + f.c.deactivate(); + }); + it("preserves the existing panorama coordinate mapping when not opted in", () => { + const f = component(false); + expect(f.update.calls.mostRecent().args[0]).toBeCloseTo(0.95); + expect(f.update.calls.mostRecent().args[1]).toBe(false); + expect(f.project).toHaveBeenCalled(); + f.c.deactivate(); + }); +}); diff --git a/test/component/slider/SliderSelectionFraming.test.ts b/test/component/slider/SliderSelectionFraming.test.ts new file mode 100644 index 000000000..840cca002 --- /dev/null +++ b/test/component/slider/SliderSelectionFraming.test.ts @@ -0,0 +1,72 @@ +import * as THREE from "three"; +import { of, Subject } from "rxjs"; +import { SliderComponent } 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"; + +// Selected-pair hydration must retain the original's live wheel-zoom anchor. +describe("Slider comparison selection framing", () => { + function fixture(viewAligned = true, single = false): any { + const container = new ContainerMockCreator().create(); + const navigator = new NavigatorMockCreator().create(); + new MockCreator().mockProperty(container, "domContainer", document.createElement("div")); + spyOn(SliderDOMRenderer.prototype, "render").and.returnValue({}); + spyOn(SliderGLRenderer.prototype, "update"); + const component = new SliderComponent("slider", container, navigator); + const background$ = new Subject(), foreground$ = new Subject(); + spyOn(component, "_catchCacheImage$").and.callFake(id => id === "new" ? background$ : foreground$); + component.activate(); + (>container.renderService.size$).next({ width: 1440, height: 900 }); + const original = { id: "original", cameraType: "perspective" }; + const old = { id: "old", cameraType: "perspective" }; + const project = jest.fn().mockReturnValue([0.51, 0.49]); + const frame = () => (>navigator.stateService.currentState$).next({ state: { + currentImage: original, previousImage: single ? null : old, + motionless: true, trajectory: single ? [original] : [old, original], + currentTransform: { projectBasic: project }, camera: { lookat: new THREE.Vector3(1, 2, 3) }, + zoom: 1.25, + }}); + frame(); + component.configure({ ids: { foreground: "original", background: "new" }, viewAligned }); + frame(); + return { component, navigator, background$, foreground$, original, project, frame }; + } + it("keeps the latest source center after background hydration without writing zoom", () => { + const f = fixture(); + f.project.mockReturnValue([0.57, 0.46]); f.frame(); + f.background$.next({ id: "new" }); f.foreground$.next(f.original); f.frame(); + expect(f.navigator.stateService.setImages.calls.allArgs()).toEqual([[ [{ id: "new" }] ], [ [f.original] ]]); + expect(f.navigator.stateService.setCenter).toHaveBeenCalledWith([0.57, 0.46]); + expect(f.navigator.stateService.setZoom).not.toHaveBeenCalled(); + f.component.deactivate(); + }); + it("retains the entry source center when the first background is prepended", () => { + const f = fixture(true, true); + f.background$.next({ id: "new" }); f.foreground$.next(f.original); f.frame(); + expect(f.navigator.stateService.prependImages).toHaveBeenCalledWith([{ id: "new" }]); + expect(f.navigator.stateService.setCenter).toHaveBeenCalledWith([0.51, 0.49]); + expect(f.navigator.stateService.setZoom).not.toHaveBeenCalled(); + f.component.deactivate(); + }); + it("does not drop synchronously cached pairs before a new state frame arrives", () => { + const f = fixture(); + const cache = 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("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(); + expect(f.navigator.stateService.setCenter).not.toHaveBeenCalled(); + expect(f.navigator.stateService.setZoom).not.toHaveBeenCalled(); + f.component.deactivate(); + }); +}); diff --git a/test/component/slider/SliderTextureHandoff.test.ts b/test/component/slider/SliderTextureHandoff.test.ts new file mode 100644 index 000000000..f1f7ffd48 --- /dev/null +++ b/test/component/slider/SliderTextureHandoff.test.ts @@ -0,0 +1,36 @@ +import { SliderGLRenderer } from "../../../src/component/slider/SliderGLRenderer"; + +function plane(imageId: string, texture: any): any { + return { imageId, mesh: { material: { uniforms: { projectorTex: { value: texture } } } } }; +} +function texture(): any { + return { dispose: jasmine.createSpy("dispose") }; +} + +describe("SliderGLRenderer texture handoff", () => { + test("moves the live texture to the rebuilt plane and disposes only the replaced one", () => { + const live = texture(), replaced = texture(); + const old = plane("a", live), next = plane("a", replaced); + (new SliderGLRenderer() as any)._preserveTextures([old], [next]); + expect(next.mesh.material.uniforms.projectorTex.value).toBe(live); + expect(old.mesh.material.uniforms.projectorTex.value).toBeNull(); + expect(replaced.dispose).toHaveBeenCalledTimes(1); + expect(live.dispose).not.toHaveBeenCalled(); + }); + + test("does not dispose a texture shared by the old and rebuilt planes", () => { + const shared = texture(); + const old = plane("a", shared), next = plane("a", shared); + (new SliderGLRenderer() as any)._preserveTextures([old], [next]); + expect(next.mesh.material.uniforms.projectorTex.value).toBe(shared); + expect(shared.dispose).not.toHaveBeenCalled(); + }); + + test("leaves planes for other images untouched", () => { + const live = texture(), other = texture(); + const next = plane("b", other); + (new SliderGLRenderer() as any)._preserveTextures([plane("a", live)], [next]); + expect(next.mesh.material.uniforms.projectorTex.value).toBe(other); + expect(other.dispose).not.toHaveBeenCalled(); + }); +}); diff --git a/test/component/slider/SliderViewAligned.test.ts b/test/component/slider/SliderViewAligned.test.ts new file mode 100644 index 000000000..96ebd52ef --- /dev/null +++ b/test/component/slider/SliderViewAligned.test.ts @@ -0,0 +1,310 @@ +import * as THREE from "three"; +import { Subject } from "rxjs"; +import { SliderConfigurationMode } from "../../../src/component/interfaces/SliderConfiguration"; +import { SliderComponent } from "../../../src/component/slider/SliderComponent"; +import { SliderDOMRenderer } from "../../../src/component/slider/SliderDOMRenderer"; +import { SliderGLRenderer } from "../../../src/component/slider/SliderGLRenderer"; +import { SliderTransform } from "../../../src/component/slider/SliderTransform"; +import { Camera } from "../../../src/geo/Camera"; +import { Image } from "../../../src/graph/Image"; +import { State } from "../../../src/state/State"; +import { ContainerMockCreator } from "../../helper/ContainerMockCreator"; +import { NavigatorMockCreator } from "../../helper/NavigatorMockCreator"; +import { MockCreator } from "../../helper/MockCreator"; + +function image(id: string, cameraType: string, width: number, height: number): any { + return { + id, cameraType, width, height, scale: 1, exifOrientation: 1, + rotation: id === "current" ? [Math.PI / 2, 0, 0] : [0.1, 0.2, 0.3], + camera: { type: cameraType, parameters: { focal: 0.62, k1: 0.02, k2: -0.005 } }, + mesh: { vertices: [], faces: [] }, image: document.createElement("img"), + }; +} +function transform(i: any, translation: number[]): any { + return new SliderTransform(i.exifOrientation, i.width, i.height, i.scale, + i.rotation, translation, i.image, undefined, + Object.values(i.camera.parameters) as number[], i.cameraType); +} +function frame(currentType = "spherical", previousType = "perspective"): any { + const currentImage = image("current", currentType, 4000, 2000); + const previousImage = image("previous", previousType, 1200, 1600); + previousImage.exifOrientation = 6; + const currentTransform = transform(currentImage, [-2, -3, -4]); + const previousTransform = transform(previousImage, [-20, -10, -4]); + return { id: 7, state: { + currentImage, previousImage, currentTransform, previousTransform, + previousCamera: new Camera(previousTransform), motionless: false, + } }; +} +function liveCamera(): THREE.PerspectiveCamera { + const camera = new THREE.PerspectiveCamera(43, 1.7, 0.2, 2000); + camera.position.set(2, 3, 4); + camera.up.set(0.2, 0.4, 0.8).normalize(); + camera.lookAt(3, 5, 4.5); + camera.zoom = 1.4; + camera.setViewOffset(2000, 1400, 100, 80, 1800, 1200); + camera.updateMatrixWorld(true); + camera.matrixAutoUpdate = false; + return camera; +} +function expectSameView(actual: THREE.PerspectiveCamera, expected: THREE.PerspectiveCamera): void { + expect(actual.quaternion.toArray()).toEqual(expected.quaternion.toArray()); + expect(actual.up.toArray()).toEqual(expected.up.toArray()); + expect(actual.getWorldDirection(new THREE.Vector3()).distanceTo( + expected.getWorldDirection(new THREE.Vector3()))).toBeCloseTo(0, 10); + expect(actual.projectionMatrix.elements).toEqual(expected.projectionMatrix.elements); + expect(actual.projectionMatrixInverse.elements).toEqual(expected.projectionMatrixInverse.elements); + expect(actual.fov).toBe(expected.fov); + expect(actual.aspect).toBe(expected.aspect); + expect(actual.zoom).toBe(expected.zoom); + expect(actual.view).toEqual(expected.view); +} +function componentFixture(): any { + const container = new ContainerMockCreator().create(); + const navigator = new NavigatorMockCreator().create(); + new MockCreator().mockProperty(container, "domContainer", document.createElement("div")); + const dom = spyOn(SliderDOMRenderer.prototype, "render").and.returnValue({} as any); + const update = spyOn(SliderGLRenderer.prototype, "update"); + const component = new SliderComponent("slider", container, navigator); + component.configure({ mode: SliderConfigurationMode.Stationary }); + component.activate(); + (container.renderService.size$ as Subject).next({ width: 1440, height: 900 }); + (navigator.stateService.state$ as Subject).next(State.Traversing); + const emit = (f: any) => (navigator.stateService.currentState$ as Subject).next(f); + return { component, container, navigator, dom, update, emit }; +} + +describe("Slider opt-in common-world view", () => { + it("retains the mixed spherical-to-perspective guard by default and with three update arguments", () => { + const r = new SliderGLRenderer() as any; + const f = frame(); + r.update(f, SliderConfigurationMode.Stationary); + expect(r.disabled).toBe(true); + expect(r._viewAligned).toBe(false); + r.update(f, SliderConfigurationMode.Stationary, true); + expect(r.disabled).toBe(true); + expect(r._viewAligned).toBe(false); + r.dispose(); + }); + it("enables only the opt-in mixed pair and invalidates when the option changes", () => { + const r = new SliderGLRenderer() as any; + const f = frame(); + r.update(f, SliderConfigurationMode.Stationary, false); + r._needsRender = false; + r.update(f, SliderConfigurationMode.Stationary, false, true); + expect(r.disabled).toBe(false); + expect(r.needsRender).toBe(true); + expect(r._scene.planesOld.length).toBe(1); + r._needsRender = false; + r.update(f, SliderConfigurationMode.Stationary, false, true); + expect(r.needsRender).toBe(false); + r.update(f, SliderConfigurationMode.Stationary, false, false); + expect(r.disabled).toBe(true); + expect(r._scene.planesOld.length).toBe(0); + r.dispose(); + }); + it("never bypasses missing-image guards", () => { + const r = new SliderGLRenderer() as any; + r._viewAligned = true; + for (const state of [ + { currentImage: null, previousImage: image("p", "perspective", 4, 3) }, + { currentImage: image("c", "spherical", 4, 2), previousImage: null }, + { currentImage: null, previousImage: null }, + ]) { + r._setDisabled(state); + expect(r.disabled).toBe(true); + } + }); + it("preserves legacy same-type and perspective-to-spherical enabled pairs", () => { + const r = new SliderGLRenderer() as any; + for (const [current, previous] of [ + ["spherical", "spherical"], ["perspective", "perspective"], ["perspective", "spherical"], + ]) { + r._setDisabled(frame(current, previous).state); + expect(r.disabled).toBe(false); + } + }); + it("copies exact live world direction/up/projection and uses the ordinary background origin", () => { + const r = new SliderGLRenderer() as any; + const f = frame(); + r.update(f, SliderConfigurationMode.Stationary, true, true); + const live = liveCamera(); + const before = live.clone(); + const previous = r._getPreviousPerspective(live); + expectSameView(previous, live); + expect(previous.position.toArray()).toEqual(new Camera(f.state.previousTransform).position.toArray()); + expectSameView(live, before); + expect(live.position.toArray()).toEqual(before.position.toArray()); + expect(r._previousRotation).toBeNull(); + r.dispose(); + }); + it("follows subsequent live rotation, up, off-center projection, FOV and zoom", () => { + const r = new SliderGLRenderer() as any; + r.update(frame(), SliderConfigurationMode.Stationary, true, true); + const live = liveCamera(); + const original = r._getPreviousPerspective(live); + live.quaternion.multiply(new THREE.Quaternion().setFromEuler(new THREE.Euler(0.2, 0.4, 0.1))); + live.up.set(0, 0, 1); + live.position.set(50, 30, 20); + live.fov = 28; + live.aspect = 2.2; + live.zoom = 2; + live.clearViewOffset(); + live.updateProjectionMatrix(); + live.updateMatrix(); + live.updateMatrixWorld(true); + const previous = r._getPreviousPerspective(live); + expect(previous).toBe(original); + expectSameView(previous, live); + expect(previous.position.toArray()).toEqual(r._previousCamera.position.toArray()); + r.dispose(); + }); + it("does not level or replace the original foreground camera for opt-in rendering", () => { + const r = new SliderGLRenderer() as any; + r._viewAligned = true; + r._currentGravityAligned = true; + const live = liveCamera(); + expect(r._getCurrentPerspective(live)).toBe(live); + }); + it("renders sceneOld with common rays, then the original foreground source camera", () => { + const r = new SliderGLRenderer() as any; + r.update(frame(), SliderConfigurationMode.Stationary, false, true); + const live = liveCamera(); + const render = jasmine.createSpy("render"); + r.render(live, { render }); + const calls = render.calls.allArgs(); + expect(calls[0][0]).toBe(r._scene.sceneOld); + expectSameView(calls[0][1], live); + expect(calls[1]).toEqual([r._scene.scene, live]); + r.dispose(); + }); + it("keeps an allowed panorama's background camera on the recentered current origin", () => { + const r = new SliderGLRenderer() as any; + r.update(frame("spherical", "spherical"), SliderConfigurationMode.Stationary, false, true); + const live = liveCamera(); + expect(r._getPreviousPerspective(live).position.toArray()).toEqual(live.position.toArray()); + live.position.set(7, 8, 9); + expect(r._getPreviousPerspective(live).position.toArray()).toEqual([7, 8, 9]); + expectSameView(r._getPreviousPerspective(live), live); + r.dispose(); + }); + it("uses the previous transform origin rather than a reconstructed previous camera position", () => { + const r = new SliderGLRenderer() as any; + const f = frame(); + f.state.previousCamera.position.set(999, 888, 777); + r.update(f, SliderConfigurationMode.Stationary, false, true); + expect(r._getPreviousPerspective(liveCamera()).position.toArray()).toEqual( + new Camera(f.state.previousTransform).position.toArray()); + r.dispose(); + }); + for (const cameraType of ["perspective", "fisheye", "spherical"]) { + it(`preserves the ${cameraType} lens and background geometry without stationary reconstruction or stretching`, () => { + const r = new SliderGLRenderer() as any; + const f = frame("spherical", cameraType); + const old = f.state.previousImage; + old.mesh = { vertices: Array(30).fill(1), faces: [0, 1, 2] }; + const meshBefore = old.mesh; + const create = spyOn(r._factory, "createMesh").and.callThrough(); + r.update(f, SliderConfigurationMode.Stationary, false, true); + const [meshImage, t, reconstruction] = create.calls.mostRecent().args as any[]; + expect(meshImage.id).toBe(old.id); + expect(meshImage.camera).toBe(old.camera); + expect(meshImage.mesh).toBeNull(); + expect(old.mesh).toBe(meshBefore); + expect(reconstruction).toBe(false); + expect(t.cameraType).toBe(cameraType); + expect(t.width).toBe(old.width); + expect(t.height).toBe(old.height); + expect(t.orientation).toBe(old.exifOrientation); + expect(t.basicAspect).toBe(1600 / 1200); + expect(t._textureScale).toEqual([1, 1]); + expect(t.focal).toBe(cameraType === "spherical" ? 1 : old.camera.parameters.focal); + if (cameraType !== "spherical") { + expect(t.ck1).toBe(old.camera.parameters.k1); + expect(t.ck2).toBe(old.camera.parameters.k2); + expect(t.rt.elements).toEqual(f.state.previousTransform.rt.elements); + const uniforms = r._scene.planesOld[0].mesh.material.uniforms; + expect(uniforms.focal.value).toBe(old.camera.parameters.focal); + expect(uniforms.k1.value).toBe(old.camera.parameters.k1); + expect(uniforms.k2.value).toBe(old.camera.parameters.k2); + } else { + const actual = new THREE.Vector3().fromArray(t.unprojectSfM([0, 0], 0)); + const expected = new THREE.Vector3().fromArray(f.state.currentTransform.unprojectSfM([0, 0], 0)); + expect(actual.distanceTo(expected)).toBeCloseTo(0, 10); + } + r.dispose(); + }); + } + it("rebuilds a recentered background when only the foreground image changes", () => { + const r = new SliderGLRenderer() as any; + const f = frame("spherical", "spherical"); + const create = spyOn(r._factory, "createMesh").and.callThrough(); + r.update(f, SliderConfigurationMode.Stationary, false, true); + f.state.currentImage = { ...f.state.currentImage, id: "replacement" }; + f.state.currentTransform = transform(f.state.currentImage, [-30, -40, -50]); + r.update(f, SliderConfigurationMode.Stationary, false, true); + expect(create).toHaveBeenCalledTimes(2); + const t = create.calls.mostRecent().args[1] as any; + const actual = new THREE.Vector3().fromArray(t.unprojectSfM([0, 0], 0)); + const expected = new THREE.Vector3().fromArray(f.state.currentTransform.unprojectSfM([0, 0], 0)); + expect(actual.distanceTo(expected)).toBeCloseTo(0, 10); + r.dispose(); + }); +}); + +describe("Slider viewAligned configuration", () => { + it("defaults to false and propagates explicit true/false alongside gravityAligned", () => { + const f = componentFixture(); + f.emit(frame()); + expect(f.update.calls.mostRecent().args.slice(1)).toEqual([ + SliderConfigurationMode.Stationary, false, false, + ]); + f.component.configure({ gravityAligned: true, viewAligned: true }); + expect(f.update.calls.mostRecent().args.slice(1)).toEqual([ + SliderConfigurationMode.Stationary, true, true, + ]); + f.component.configure({ viewAligned: false }); + expect(f.update.calls.mostRecent().args[3]).toBe(false); + f.component.deactivate(); + }); + it("shows mixed comparisons only when opted in, and responds without a new frame", () => { + const f = componentFixture(); + f.emit(frame()); + expect(f.dom.calls.mostRecent().args[4]).toBe(false); + f.component.configure({ viewAligned: true }); + expect(f.dom.calls.mostRecent().args[4]).toBe(true); + f.component.configure({ viewAligned: false }); + expect(f.dom.calls.mostRecent().args[4]).toBe(false); + f.component.deactivate(); + }); + it("retains deliberate slider visibility and missing-background guards when opted in", () => { + const f = componentFixture(); + f.component.configure({ viewAligned: true }); + const missing = frame(); + missing.state.previousImage = null; + f.emit(missing); + expect(f.dom.calls.mostRecent().args[4]).toBe(false); + f.component.configure({ sliderVisible: false }); + f.emit(frame()); + expect(f.dom.calls.mostRecent().args[4]).toBe(false); + f.component.deactivate(); + }); + it("projects the previous tile ROI with common live rays and its own lens, without mutating the live view", () => { + const f = componentFixture(); + const live = liveCamera(); + const t = frame().state.previousTransform; + const roi = { bbox: { minX: 0.1, maxX: 0.7, minY: 0.2, maxY: 0.8 }, pixelWidth: 0.001, pixelHeight: 0.002 }; + const compute = spyOn((f.component as any)._roiCalculator, "computeRegionOfInterest").and.returnValue(roi); + const before = live.clone(); + const size = { width: 1440, height: 900 }; + expect((f.component as any)._getViewAlignedRegionOfInterest({ perspective: live }, size, t)).toBe(roi); + const [background, usedSize, usedTransform] = compute.calls.mostRecent().args as any[]; + expect(usedSize).toBe(size); + expect(usedTransform).toBe(t); + expectSameView(background.perspective, live); + expect(background.perspective.position.toArray()).toEqual(t.unprojectSfM([0, 0], 0)); + expectSameView(live, before); + expect(live.position.toArray()).toEqual(before.position.toArray()); + f.component.deactivate(); + }); +}); diff --git a/test/component/zoom/ZoomComponent.test.ts b/test/component/zoom/ZoomComponent.test.ts new file mode 100644 index 000000000..fe97b9e04 --- /dev/null +++ b/test/component/zoom/ZoomComponent.test.ts @@ -0,0 +1,21 @@ +import { formatZoomLevel } from "../../../src/component/zoom/ZoomComponent"; + +describe("zoom level label", () => { + it("hides the unzoomed 1.0x view", () => { + expect(formatZoomLevel(0)).toBe(""); + expect(formatZoomLevel(1e-8)).toBe(""); + }); + it("converts logarithmic SDK zoom to magnification", () => { + expect(formatZoomLevel(1)).toBe("2.0×"); + expect(formatZoomLevel(2)).toBe("4.0×"); + expect(formatZoomLevel(3)).toBe("8.0×"); + }); + it("shows fractional zoom with one decimal", () => { + expect(formatZoomLevel(Math.log2(1.5))).toBe("1.5×"); + expect(formatZoomLevel(Math.log2(2.75))).toBe("2.8×"); + }); + it("does not display invalid camera state", () => { + expect(formatZoomLevel(NaN)).toBe(""); + expect(formatZoomLevel(Infinity)).toBe(""); + }); +}); From 095c9be0142ce9ef1c1ae595b5710827ba94d699 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Mon, 2 Nov 2026 08:39:38 +0100 Subject: [PATCH 10/16] Share perspective horizon leveling between reorientation and slider Extract the perspective leveling rules into `PerspectiveLevel` and use them for both reorientation and slider alignment so both level images the same way. --- .../reorientation/PerspectiveLevel.ts | 195 ++++++++++++++++++ .../reorientation/ReorientationComponent.ts | 141 ++----------- src/component/slider/SliderAlignment.ts | 12 ++ src/component/slider/SliderGLRenderer.ts | 26 ++- .../reorientation/PerspectiveLevel.test.ts | 67 ++++++ test/component/slider/SliderAlignment.test.ts | 17 +- 6 files changed, 331 insertions(+), 127 deletions(-) create mode 100644 src/component/reorientation/PerspectiveLevel.ts create mode 100644 test/component/reorientation/PerspectiveLevel.test.ts diff --git a/src/component/reorientation/PerspectiveLevel.ts b/src/component/reorientation/PerspectiveLevel.ts new file mode 100644 index 000000000..5a1e6ee68 --- /dev/null +++ b/src/component/reorientation/PerspectiveLevel.ts @@ -0,0 +1,195 @@ +import * as THREE from "three"; +import { computeBearings } from "../../geo/Geo"; +import { Spatial } from "../../geo/Spatial"; +import { Transform } from "../../geo/Transform"; +import { ViewportCoords } from "../../geo/ViewportCoords"; + +/** + * Automatic horizon levelling decision for perspective images, shared by the + * ordinary viewer (ReorientationComponent) and Time Travel comparisons + * (SliderGLRenderer), so both level exactly the same photos. + */ + +export const MAX_HORIZON_CORRECTION_DEG = 75; +export const MAX_REORIENTATION_ROLL_DEG = 45; +export const MIN_PERSPECTIVE_LEVEL_ROLL_DEG = 7; +export const MAX_PERSPECTIVE_AUTO_ZOOM = 0.75; +export const MAX_PERSPECTIVE_FOV = 125; +export const PERSPECTIVE_EDGE_MARGIN = 1e-3; + +/** + * Small perspective corrections are indistinguishable from a tilted SfM frame + * without an independent gravity signal, so the raw camera frame is safer. + */ +export function shouldLevelPerspective(rollDeg: number): boolean { + return Number.isFinite(rollDeg) && + rollDeg >= MIN_PERSPECTIVE_LEVEL_ROLL_DEG && + rollDeg <= MAX_REORIENTATION_ROLL_DEG; +} + +/** Basic row of the reconstructed horizon in column `x`, or null. */ +export function perspectiveHorizonRow(transform: Transform, x: number): number | null { + if (transform == null) { + return null; + } + + const cameraZ = transform.unprojectSfM([0, 0], 0)[2]; + let low = 0; + let high = 1; + let lowZ = transform.unprojectBasic([x, low], 10)[2] - cameraZ; + const highZ = transform.unprojectBasic([x, high], 10)[2] - cameraZ; + if (lowZ * highZ > 0) { + return null; + } + + for (let i = 0; i < 32; i++) { + const middle = (low + high) / 2; + const middleZ = + transform.unprojectBasic([x, middle], 10)[2] - cameraZ; + if (lowZ * middleZ > 0) { + low = middle; + lowZ = middleZ; + } else { + high = middle; + } + } + + const horizon = (low + high) / 2; + return Math.abs(horizon - 0.5) * 180 <= + MAX_HORIZON_CORRECTION_DEG ? horizon : null; +} + +/** Unsigned roll, in degrees, between the image up and gravity at `center`. */ +export function perspectiveRollDeg(transform: Transform, center: number[]): number | null { + if (transform == null) { + return null; + } + const origin = new THREE.Vector3().fromArray( + transform.unprojectSfM([0, 0], 0)); + const direction = new THREE.Vector3().fromArray( + transform.unprojectBasic(center, 10)) + .sub(origin) + .normalize(); + const imageUp = transform.upVector() + .addScaledVector(direction, -transform.upVector().dot(direction)) + .normalize(); + const gravityUp = new THREE.Vector3(0, 0, 1) + .addScaledVector(direction, -direction.z) + .normalize(); + const rollDeg = imageUp.angleTo(gravityUp) * 180 / Math.PI; + return Number.isFinite(rollDeg) ? rollDeg : null; +} + +/** + * Zoom (log2 of the field of view reduction from `baseFov`) needed so a + * gravity-levelled view centered on `center` does not expose the photo's + * boundary, or null when that exceeds the automatic zoom limit. + */ +export function perspectiveAutoZoom( + transform: Transform, + aspect: number, + center: number[], + baseFov: number, + viewportCoords: ViewportCoords = new ViewportCoords()): number | null { + if (transform == null || !(aspect > 0) || !Number.isFinite(aspect)) { + return null; + } + + const origin = new THREE.Vector3().fromArray( + transform.unprojectSfM([0, 0], 0)); + const rollDeg = perspectiveRollDeg(transform, center); + if (rollDeg == null || + rollDeg > MAX_REORIENTATION_ROLL_DEG) { + return null; + } + + const camera = new THREE.PerspectiveCamera(60, aspect, 1e-1, 1e4); + camera.position.copy(origin); + + const maxFov = (basic: number[], up: THREE.Vector3): number => { + camera.up.copy(up); + camera.lookAt(new THREE.Vector3().fromArray( + transform.unprojectBasic(basic, 10))); + camera.updateMatrixWorld(true); + + const fits = (fov: number): boolean => { + camera.fov = fov; + camera.updateProjectionMatrix(); + const corners = [ + viewportCoords.viewportToBasic(-1, 1, transform, camera), + viewportCoords.viewportToBasic(1, 1, transform, camera), + viewportCoords.viewportToBasic(1, -1, transform, camera), + viewportCoords.viewportToBasic(-1, -1, transform, camera), + ]; + return corners.every((point: number[]): boolean => + point != null && + point[0] >= PERSPECTIVE_EDGE_MARGIN && + point[0] <= 1 - PERSPECTIVE_EDGE_MARGIN && + point[1] >= PERSPECTIVE_EDGE_MARGIN && + point[1] <= 1 - PERSPECTIVE_EDGE_MARGIN); + }; + + let low = 0; + let high = MAX_PERSPECTIVE_FOV; + for (let i = 0; i < 16; i++) { + const middle = (low + high) / 2; + if (fits(middle)) { + low = middle; + } else { + high = middle; + } + } + return low; + }; + + const levelFov = maxFov(center, new THREE.Vector3(0, 0, 1)); + const zoom = Math.max(0, Math.log(baseFov / levelFov) / Math.log(2)); + return Number.isFinite(zoom) && zoom <= MAX_PERSPECTIVE_AUTO_ZOOM ? + zoom : null; +} + +/** + * Unzoomed vertical field of view the viewer uses for a perspective image in + * fill render mode (RenderCamera's fill computation). + */ +export function perspectiveFillFov( + transform: Transform, + aspect: number, + viewportCoords: ViewportCoords = new ViewportCoords()): number { + const spatial = new Spatial(); + const y = computeBearings(transform, [[0, 0]], [[1, 0]], 25, viewportCoords) + .map(b => spatial.projectToPlane(b, [1, 0, 0])) + .map(p => 2 * spatial.radToDeg(Math.abs(Math.atan2(p[1], -p[2])))); + const x = computeBearings(transform, [[0, 0]], [[0, 1]], 25, viewportCoords) + .map(b => spatial.projectToPlane(b, [0, 1, 0])) + .map(p => 2 * spatial.radToDeg(Math.abs(Math.atan2(p[0] / aspect, -p[2])))); + const fovs = [...y, ...x]; + const vFovMin = fovs.length > 0 ? 0.995 * Math.min(...fovs) : MAX_PERSPECTIVE_FOV; + return Math.min(vFovMin, MAX_PERSPECTIVE_FOV); +} + +/** + * Whether the ordinary viewer's automatic horizon levelling would level this + * perspective image when it is opened fresh (center column, unzoomed) in a + * viewport of `aspect`. Sequence continuity checks are not included: they + * need the neighbouring captures the viewer walked through. + */ +export function wouldAutoLevelPerspective( + transform: Transform, + aspect: number, + viewportCoords: ViewportCoords = new ViewportCoords()): boolean { + try { + const horizon = perspectiveHorizonRow(transform, 0.5); + if (horizon == null) { + return false; + } + const center = [0.5, horizon]; + if (!shouldLevelPerspective(perspectiveRollDeg(transform, center))) { + return false; + } + const baseFov = perspectiveFillFov(transform, aspect, viewportCoords); + return perspectiveAutoZoom(transform, aspect, center, baseFov, viewportCoords) != null; + } catch (_) { + return false; + } +} diff --git a/src/component/reorientation/ReorientationComponent.ts b/src/component/reorientation/ReorientationComponent.ts index 2acf9fe4d..591f3a595 100644 --- a/src/component/reorientation/ReorientationComponent.ts +++ b/src/component/reorientation/ReorientationComponent.ts @@ -1,6 +1,5 @@ import { Observable, Subject } from "rxjs"; import { filter, first } from "rxjs/operators"; -import * as THREE from "three"; import { Component } from "../Component"; import { ComponentName } from "../ComponentName"; @@ -22,6 +21,13 @@ import { Sequence } from "../../graph/Sequence"; import { isSpherical } from "../../geo/Geo"; import { Transform } from "../../geo/Transform"; import { ViewportCoords } from "../../geo/ViewportCoords"; +import { + MAX_REORIENTATION_ROLL_DEG, + perspectiveAutoZoom, + perspectiveHorizonRow, + perspectiveRollDeg, + shouldLevelPerspective, +} from "./PerspectiveLevel"; import { RenderCamera } from "../../render/RenderCamera"; import { hasReconstructionMesh } from "../../util/Mesh"; import { Container } from "../../viewer/Container"; @@ -30,16 +36,10 @@ import { Navigator } from "../../viewer/Navigator"; // Skip a reorientation when it would move the current view less than this many // degrees (on either axis) — small moves are just jitter. const MIN_REORIENT_DEG = 15; -const MAX_HORIZON_CORRECTION_DEG = 75; -const MAX_REORIENTATION_ROLL_DEG = 45; -const MIN_PERSPECTIVE_LEVEL_ROLL_DEG = 7; // Abrupt roll or pitch changes between neighboring captures are reconstruction // errors, not plausible camera motion, so their horizon is not worth using. const MAX_LEVEL_ROLL_DELTA_DEG = 15; const MAX_LEVEL_PITCH_DELTA_DEG = 15; -const MAX_PERSPECTIVE_AUTO_ZOOM = 0.75; -const MAX_PERSPECTIVE_FOV = 125; -const PERSPECTIVE_EDGE_MARGIN = 1e-3; const USER_ZOOM_EPSILON = 1e-2; /** Pitch correction, in degrees, that levelling to a horizon row implies. */ @@ -47,15 +47,8 @@ export function horizonPitchDeg(horizonY: number): number { return (0.5 - horizonY) * 180; } -/** - * Small perspective corrections are indistinguishable from a tilted SfM frame - * without an independent gravity signal, so the raw camera frame is safer. - */ -export function shouldLevelPerspective(rollDeg: number): boolean { - return Number.isFinite(rollDeg) && - rollDeg >= MIN_PERSPECTIVE_LEVEL_ROLL_DEG && - rollDeg <= MAX_REORIENTATION_ROLL_DEG; -} +// The perspective levelling decision is shared with Time Travel comparisons. +export { shouldLevelPerspective }; /** * Whether a reconstructed pose is plausible enough to level to. Judged on its @@ -1079,93 +1072,21 @@ export class ReorientationComponent } private _rollDeg(center: number[]): number | null { - const transform = this._currentTransform; - if (transform == null) { - return null; - } - const origin = new THREE.Vector3().fromArray( - transform.unprojectSfM([0, 0], 0)); - const direction = new THREE.Vector3().fromArray( - transform.unprojectBasic(center, 10)) - .sub(origin) - .normalize(); - const imageUp = transform.upVector() - .addScaledVector(direction, -transform.upVector().dot(direction)) - .normalize(); - const gravityUp = new THREE.Vector3(0, 0, 1) - .addScaledVector(direction, -direction.z) - .normalize(); - const rollDeg = imageUp.angleTo(gravityUp) * 180 / Math.PI; - return Number.isFinite(rollDeg) ? rollDeg : null; + return perspectiveRollDeg(this._currentTransform, center); } private _perspectiveAutoZoom( center: number[], baseFov: number): number | null { - const transform = this._currentTransform; const element = this._container.container; - if (transform == null || element.offsetHeight === 0) { + if (this._currentTransform == null || element.offsetHeight === 0) { return null; } - - const origin = new THREE.Vector3().fromArray( - transform.unprojectSfM([0, 0], 0)); - const rollDeg = this._rollDeg(center); - if (rollDeg == null || - rollDeg > MAX_REORIENTATION_ROLL_DEG) { - return null; - } - - const camera = new THREE.PerspectiveCamera( - 60, + return perspectiveAutoZoom( + this._currentTransform, element.offsetWidth / element.offsetHeight, - 1e-1, - 1e4); - camera.position.copy(origin); - - const maxFov = (basic: number[], up: THREE.Vector3): number => { - camera.up.copy(up); - camera.lookAt(new THREE.Vector3().fromArray( - transform.unprojectBasic(basic, 10))); - camera.updateMatrixWorld(true); - - const fits = (fov: number): boolean => { - camera.fov = fov; - camera.updateProjectionMatrix(); - const corners = [ - this._viewportCoords.viewportToBasic( - -1, 1, transform, camera), - this._viewportCoords.viewportToBasic( - 1, 1, transform, camera), - this._viewportCoords.viewportToBasic( - 1, -1, transform, camera), - this._viewportCoords.viewportToBasic( - -1, -1, transform, camera), - ]; - return corners.every((point: number[]): boolean => - point != null && - point[0] >= PERSPECTIVE_EDGE_MARGIN && - point[0] <= 1 - PERSPECTIVE_EDGE_MARGIN && - point[1] >= PERSPECTIVE_EDGE_MARGIN && - point[1] <= 1 - PERSPECTIVE_EDGE_MARGIN); - }; - - let low = 0; - let high = MAX_PERSPECTIVE_FOV; - for (let i = 0; i < 16; i++) { - const middle = (low + high) / 2; - if (fits(middle)) { - low = middle; - } else { - high = middle; - } - } - return low; - }; - - const levelFov = maxFov(center, new THREE.Vector3(0, 0, 1)); - const zoom = Math.max(0, Math.log(baseFov / levelFov) / Math.log(2)); - return Number.isFinite(zoom) && zoom <= MAX_PERSPECTIVE_AUTO_ZOOM ? - zoom : null; + center, + baseFov, + this._viewportCoords); } private _captureOffset(): void { @@ -1198,35 +1119,7 @@ export class ReorientationComponent } private _horizonRow(x: number): number | null { - const transform = this._currentTransform; - if (transform == null) { - return null; - } - - const cameraZ = transform.unprojectSfM([0, 0], 0)[2]; - let low = 0; - let high = 1; - let lowZ = transform.unprojectBasic([x, low], 10)[2] - cameraZ; - const highZ = transform.unprojectBasic([x, high], 10)[2] - cameraZ; - if (lowZ * highZ > 0) { - return null; - } - - for (let i = 0; i < 32; i++) { - const middle = (low + high) / 2; - const middleZ = - transform.unprojectBasic([x, middle], 10)[2] - cameraZ; - if (lowZ * middleZ > 0) { - low = middle; - lowZ = middleZ; - } else { - high = middle; - } - } - - const horizon = (low + high) / 2; - return Math.abs(horizon - 0.5) * 180 <= - MAX_HORIZON_CORRECTION_DEG ? horizon : null; + return perspectiveHorizonRow(this._currentTransform, x); } private _resetOffset(): void { diff --git a/src/component/slider/SliderAlignment.ts b/src/component/slider/SliderAlignment.ts index 5cd1b475a..bf3764a44 100644 --- a/src/component/slider/SliderAlignment.ts +++ b/src/component/slider/SliderAlignment.ts @@ -3,6 +3,7 @@ import { Image } from "../../graph/Image"; import { Transform } from "../../geo/Transform"; import { isSpherical } from "../../geo/Geo"; import { hasReconstructionMesh } from "../../util/Mesh"; +import { wouldAutoLevelPerspective } from "../reorientation/PerspectiveLevel"; /** Match the ordinary viewer's refusal to level an uncalibrated panorama. */ export function canLevelSliderImage(image: Image, transform: Transform): boolean { @@ -21,3 +22,14 @@ export function canLevelSliderImage(image: Image, transform: Transform): boolean Math.abs(pitch) <= 75 && roll <= 45 && (isSpherical(image.cameraType) || roll >= 7); } + +/** + * Whether a view-aligned comparison may be shown in the gravity frame: a + * perspective comparison is levelled only when the ordinary viewer would level + * it too (same horizon, roll and auto-fit rules, in a viewport of `aspect`). + * Otherwise it keeps its own upright frame, as the ordinary viewer shows it. + */ +export function canLevelSliderComparison(image: Image, transform: Transform, aspect: number): boolean { + return canLevelSliderImage(image, transform) && + (isSpherical(image.cameraType) || wouldAutoLevelPerspective(transform, aspect)); +} diff --git a/src/component/slider/SliderGLRenderer.ts b/src/component/slider/SliderGLRenderer.ts index b29c0dabe..9d3c54eff 100644 --- a/src/component/slider/SliderGLRenderer.ts +++ b/src/component/slider/SliderGLRenderer.ts @@ -11,9 +11,10 @@ import { SliderMeshFactory } from "./SliderMeshFactory"; import { isSpherical } from "../../geo/Geo"; import { CameraType } from "../../geo/interfaces/CameraType"; import { Camera } from "../../geo/Camera"; +import { Transform } from "../../geo/Transform"; import { SliderTransform } from "./SliderTransform"; import { SliderMeshScene, SliderMeshSceneItem } from "./SliderMeshScene"; -import { canLevelSliderImage } from "./SliderAlignment"; +import { canLevelSliderComparison, canLevelSliderImage } from "./SliderAlignment"; import { sliderHeadingRotation } from "./SliderHeading"; import { translatedSliderPerspective } from "./SliderAutomaticMotion"; import { sliderViewAlignedRoll } from "./SliderRoll"; @@ -43,6 +44,9 @@ export class SliderGLRenderer { private _previousCamera: Camera; private _currentNativeUp: THREE.Vector3; private _previousNativeUp: THREE.Vector3; + private _previousLevelImage: { image: Image; transform: Transform } = null; + private _previousLevelAspect: number = null; + private _previousLevelResult: boolean = false; private _previousPerspective: THREE.PerspectiveCamera; private _previousRotation: THREE.Quaternion; private _headingRotation: THREE.Quaternion; @@ -250,7 +254,7 @@ export class SliderGLRenderer { // not tilted by the two reconstructions' differing roll estimates. const roll = sliderViewAlignedRoll(current, this._currentNativeUp, this._previousNativeUp, - this._previousGravityAligned); + this._previousViewAlignedLevel(current.aspect)); if (this._mode === SliderConfigurationMode.Motion) { this._previousPerspective.matrixAutoUpdate = false; this._previousPerspective.matrix.copy(current.matrixWorld) @@ -322,6 +326,19 @@ export class SliderGLRenderer { return this._previousPerspective; } + /** Comparison levelling, as the ordinary viewer decides it for this viewport. */ + private _previousViewAlignedLevel(aspect: number): boolean { + const level = this._previousLevelImage; + if (level == null) { + return false; + } + if (this._previousLevelAspect == null || + Math.abs(this._previousLevelAspect - aspect) > 1e-3) { + this._previousLevelAspect = aspect; + this._previousLevelResult = canLevelSliderComparison(level.image, level.transform, aspect); + } + return this._previousLevelResult; + } private _getCameraQuaternion(camera: Camera): THREE.Quaternion { const matrix = new THREE.Matrix4().lookAt( camera.position, camera.lookat, camera.up); @@ -432,6 +449,11 @@ export class SliderGLRenderer { this._previousGravityAligned = gravityAligned && state.previousImage != null && canLevelSliderImage(state.previousImage, state.previousTransform); + // View-aligned comparisons follow the ordinary viewer's perspective + // levelling decision, evaluated for the rendered viewport. + this._previousLevelImage = this._previousGravityAligned && viewAligned ? + { image: state.previousImage, transform: state.previousTransform } : null; + this._previousLevelAspect = null; this._currentCamera = gravityAligned && state.currentTransform != null ? new Camera(state.currentTransform) : null; if (this._currentGravityAligned && this._currentCamera != null) { diff --git a/test/component/reorientation/PerspectiveLevel.test.ts b/test/component/reorientation/PerspectiveLevel.test.ts new file mode 100644 index 000000000..45604c7da --- /dev/null +++ b/test/component/reorientation/PerspectiveLevel.test.ts @@ -0,0 +1,67 @@ +import * as THREE from "three"; +import { + perspectiveFillFov, + perspectiveHorizonRow, + perspectiveRollDeg, + wouldAutoLevelPerspective, +} from "../../../src/component/reorientation/PerspectiveLevel"; +import { shouldLevelPerspective } from "../../../src/component/reorientation/ReorientationComponent"; +import { Transform } from "../../../src/geo/Transform"; +import { ProjectionService } from "../../../src/viewer/ProjectionService"; + +function makeTransform(rotation: number[], parameters: number[] = [0.85, 0, 0], + orientation: number = 1, width: number = 4000, height: number = 3000): Transform { + const camera = new ProjectionService().makeCamera("perspective", parameters); + return new Transform(orientation, width, height, 1, rotation, [0, 0, 0], null, camera); +} +/** Angle-axis world-to-camera rotation for a north-facing camera rolled by `rollDeg`. */ +function rolled(rollDeg: number, pitchDeg: number = 0): number[] { + // Camera axes (x right, y down, z forward) as rows, in world ENU. + const level = new THREE.Matrix4().set( + 1, 0, 0, 0, + 0, 0, -1, 0, + 0, 1, 0, 0, + 0, 0, 0, 1); + const pitch = new THREE.Matrix4().makeRotationX(pitchDeg * Math.PI / 180); + const roll = new THREE.Matrix4().makeRotationZ(rollDeg * Math.PI / 180); + const q = new THREE.Quaternion().setFromRotationMatrix(roll.multiply(pitch).multiply(level)); + const angle = 2 * Math.acos(Math.min(1, Math.abs(q.w))); + const s = Math.sqrt(Math.max(1e-12, 1 - q.w * q.w)) * Math.sign(q.w || 1); + return angle < 1e-9 ? [0, 0, 0] : [q.x / s * angle, q.y / s * angle, q.z / s * angle]; +} + +describe("Shared perspective levelling decision", () => { + it("keeps the ordinary viewer's roll range", () => { + expect(shouldLevelPerspective(6)).toBe(false); + expect(shouldLevelPerspective(8)).toBe(true); + expect(shouldLevelPerspective(46)).toBe(false); + }); + it("measures roll and horizon on a synthetic camera", () => { + const t = makeTransform(rolled(10)); + const horizon = perspectiveHorizonRow(t, 0.5); + expect(horizon).toBeCloseTo(0.5, 3); + expect(perspectiveRollDeg(t, [0.5, horizon])).toBeCloseTo(10, 1); + }); + it("levels a moderately tilted photo the viewer can fit", () => { + expect(wouldAutoLevelPerspective(makeTransform(rolled(10)), 1.6)).toBe(true); + }); + it("does not level a nearly upright photo", () => { + expect(wouldAutoLevelPerspective(makeTransform(rolled(3)), 1.6)).toBe(false); + }); + it("does not level the reported Time Travel comparison (implausible 38 deg roll)", () => { + // Image 1213889739636709: upright phone photo, separate reconstruction. + const t = makeTransform( + [-1.2223267744317, 1.2079613891934, 0.1647357955055], + [0.73551162611046, -0.0015303224124321, -0.018730651694032], 3, 4032, 3024); + const horizon = perspectiveHorizonRow(t, 0.5); + expect(horizon).not.toBeNull(); + expect(perspectiveRollDeg(t, [0.5, horizon])).toBeGreaterThan(30); + expect(wouldAutoLevelPerspective(t, 1440 / 900)).toBe(false); + }); + it("computes the fill field of view of an undistorted photo", () => { + const t = makeTransform(rolled(0)); + // Width-limited at a wider viewport: horizontal half-tangent 0.5 / 0.85. + const expected = 0.995 * 2 * Math.atan(0.5 / 0.85 / 1.6) * 180 / Math.PI; + expect(perspectiveFillFov(t, 1.6)).toBeCloseTo(expected, 1); + }); +}); diff --git a/test/component/slider/SliderAlignment.test.ts b/test/component/slider/SliderAlignment.test.ts index 2b7c0892b..449730dbc 100644 --- a/test/component/slider/SliderAlignment.test.ts +++ b/test/component/slider/SliderAlignment.test.ts @@ -1,5 +1,6 @@ import * as THREE from "three"; -import { canLevelSliderImage } from "../../../src/component/slider/SliderAlignment"; +import { canLevelSliderComparison, canLevelSliderImage } from "../../../src/component/slider/SliderAlignment"; +import { ProjectionService } from "../../../src/viewer/ProjectionService"; import { SliderGLRenderer } from "../../../src/component/slider/SliderGLRenderer"; import { SliderConfigurationMode } from "../../../src/component/interfaces/SliderConfiguration"; import { Camera } from "../../../src/geo/Camera"; @@ -40,6 +41,20 @@ describe("Slider horizon safety", () => { expect(canLevelSliderImage(image(undefined, "perspective"), transform(6))).toBe(false); expect(canLevelSliderImage(image(undefined, "perspective"), transform(8))).toBe(true); }); + it("levels comparison panoramas like other slider images", () => { + expect(canLevelSliderComparison(image(), transform(38, 5), 1.6)).toBe(true); + expect(canLevelSliderComparison(image(), transform(46, 0), 1.6)).toBe(false); + }); + it("keeps a perspective comparison upright when the ordinary viewer would not level it", () => { + // Reported pair: a separate reconstruction claims a 38 deg roll for an + // upright phone photo; the viewer's auto-fit refuses to level it. + const camera = new ProjectionService().makeCamera("perspective", + [0.73551162611046, -0.0015303224124321, -0.018730651694032]); + const reported = new Transform(3, 4032, 3024, 1, + [-1.2223267744317, 1.2079613891934, 0.1647357955055], [0, 0, 0], null, camera); + expect(canLevelSliderImage(image(undefined, "perspective"), reported)).toBe(true); + expect(canLevelSliderComparison(image(undefined, "perspective"), reported, 1.6)).toBe(false); + }); it("uses the normal camera unchanged when foreground gravity is untrusted", () => { const renderer = new SliderGLRenderer() as any; renderer._gravityAligned = true; From e257d3fed6be5f77d05d13b726630e5fb3db81b5 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Mon, 2 Nov 2026 08:39:39 +0100 Subject: [PATCH 11/16] Add a configurable soft edge to the slider curtain New `SliderConfiguration` option feathers the split between the two slider images in all curtain shaders. --- .../interfaces/SliderConfiguration.ts | 7 ++ src/component/slider/SliderComponent.ts | 1 + src/component/slider/SliderCurtain.ts | 29 ++++++ src/component/slider/SliderGLRenderer.ts | 13 +++ src/component/slider/SliderMeshFactory.ts | 4 + .../shaders/fisheye_curtain.fragment.glsl.ts | 8 +- .../perspective_curtain.fragment.glsl.ts | 8 +- ...pective_distorted_curtain.fragment.glsl.ts | 8 +- .../spherical_curtain.fragment.glsl.ts | 8 +- .../slider/SliderScreenSpace.test.ts | 5 +- test/component/slider/SliderSoftEdge.test.ts | 96 +++++++++++++++++++ 11 files changed, 178 insertions(+), 9 deletions(-) create mode 100644 src/component/slider/SliderCurtain.ts create mode 100644 test/component/slider/SliderSoftEdge.test.ts diff --git a/src/component/interfaces/SliderConfiguration.ts b/src/component/interfaces/SliderConfiguration.ts index b72e710f1..8e32c7f43 100644 --- a/src/component/interfaces/SliderConfiguration.ts +++ b/src/component/interfaces/SliderConfiguration.ts @@ -109,6 +109,13 @@ export interface SliderConfiguration extends ComponentConfiguration { * @default false */ screenSpace?: boolean; + /** + * Soft edge for the screenSpace curtain: half width of the blend around + * the split, as a fraction of the viewport width (0 to 0.1). 0 keeps + * the hard split. + * @default 0 + */ + curtainFeather?: number; /** * Initial position of the slider on the interval [0, 1]. * diff --git a/src/component/slider/SliderComponent.ts b/src/component/slider/SliderComponent.ts index fc1f37050..5ba1a7347 100644 --- a/src/component/slider/SliderComponent.ts +++ b/src/component/slider/SliderComponent.ts @@ -366,6 +366,7 @@ export class SliderComponent extends Component { subs.push(decision$.pipe( map((decision): GLRendererOperation => (glRenderer): SliderGLRenderer => { const c = decision.configuration; + glRenderer.updateCurtainFeather(c.curtainFeather); if (decision.automatic) { glRenderer.update(decision.frame, decision.mode, c.gravityAligned === true, c.viewAligned === true, true); diff --git a/src/component/slider/SliderCurtain.ts b/src/component/slider/SliderCurtain.ts new file mode 100644 index 000000000..cf8478a3b --- /dev/null +++ b/src/component/slider/SliderCurtain.ts @@ -0,0 +1,29 @@ +/** + * Screen-space curtain with an optional soft edge. + * + * Shared GLSL for the curtain shaders; must follow the `curtain` uniform. + * With `curtainFeather` 0 it is exactly the original hard rule (hidden at + * <=0, full at >=1, otherwise visible left of the curtain). A positive + * feather blends the foreground out over +/- feather (fraction of viewport + * width) around the curtain, so small misalignments read as a soft seam. + */ +export const SLIDER_CURTAIN_GLSL = ` +uniform float curtainFeather; +float sliderCurtainAlpha(float screenX) { + if (curtain <= 0.0) { + return 0.0; + } + if (curtain >= 1.0) { + return 1.0; + } + if (curtainFeather > 0.0) { + return 1.0 - smoothstep(curtain - curtainFeather, curtain + curtainFeather, screenX); + } + return screenX < curtain ? 1.0 : 0.0; +} +`; + +/** Valid soft-edge half width: 0 (hard) up to 0.1 of the viewport width. */ +export function sliderCurtainFeather(feather: unknown): number { + return typeof feather === "number" && Number.isFinite(feather) ? Math.max(0, Math.min(0.1, feather)) : 0; +} diff --git a/src/component/slider/SliderGLRenderer.ts b/src/component/slider/SliderGLRenderer.ts index 9d3c54eff..57994bf6f 100644 --- a/src/component/slider/SliderGLRenderer.ts +++ b/src/component/slider/SliderGLRenderer.ts @@ -19,6 +19,7 @@ import { sliderHeadingRotation } from "./SliderHeading"; import { translatedSliderPerspective } from "./SliderAutomaticMotion"; import { sliderViewAlignedRoll } from "./SliderRoll"; import { sliderReconstructionImage, sliderPairKey, sliderCameraOrigin, sliderViewAlignedStationary } from "./SliderReconstruction"; +import { sliderCurtainFeather } from "./SliderCurtain"; export class SliderGLRenderer { private _factory: SliderMeshFactory; private _scene: SliderMeshScene; @@ -33,6 +34,7 @@ export class SliderGLRenderer { private _mode: SliderConfigurationMode; private _viewAligned: boolean = false; private _automaticMotion: boolean = false; + private _curtainFeather: number = 0; private _motionOrigins: THREE.Vector3[] = []; private _viewAlignedPair: string; private _gravityAligned: boolean; @@ -119,6 +121,14 @@ export class SliderGLRenderer { this._needsRender = true; } } + /** Soft-edge half width at the curtain (fraction of viewport width); 0 is a hard split. */ + public updateCurtainFeather(feather: number): void { + const next = sliderCurtainFeather(feather); + if (next === this._curtainFeather) { return; } + this._curtainFeather = next; + this._updateCurtain(); + this._needsRender = true; + } public updateCurtain(curtain: number, screenSpace: boolean = false): void { if (this._curtain === curtain && this._screenSpace === screenSpace) { return; @@ -385,6 +395,9 @@ export class SliderGLRenderer { if (!!shaderMaterial.uniforms.curtain) { shaderMaterial.uniforms.curtain.value = this._curtain; } + if (shaderMaterial.uniforms.curtainFeather) { + shaderMaterial.uniforms.curtainFeather.value = this._curtainFeather; + } if (shaderMaterial.uniforms.screenSpace) { shaderMaterial.uniforms.screenSpace.value = this._screenSpace; } diff --git a/src/component/slider/SliderMeshFactory.ts b/src/component/slider/SliderMeshFactory.ts index 554662e2d..64416f9cf 100644 --- a/src/component/slider/SliderMeshFactory.ts +++ b/src/component/slider/SliderMeshFactory.ts @@ -194,6 +194,7 @@ export class SliderMeshFactory { uniforms: { curtain: { value: 1.0 }, screenSpace: { value: false }, + curtainFeather: { value: 0 }, opacity: { value: 1.0 }, projectorMat: { value: transform.rt }, projectorTex: { value: texture }, @@ -268,6 +269,7 @@ export class SliderMeshFactory { uniforms: { curtain: { value: 1.0 }, screenSpace: { value: false }, + curtainFeather: { value: 0 }, focal: { value: transform.focal }, k1: { value: transform.ck1 }, k2: { value: transform.ck2 }, @@ -296,6 +298,7 @@ export class SliderMeshFactory { uniforms: { curtain: { value: 1.0 }, screenSpace: { value: false }, + curtainFeather: { value: 0 }, focal: { value: transform.focal }, k1: { value: transform.ck1 }, k2: { value: transform.ck2 }, @@ -324,6 +327,7 @@ export class SliderMeshFactory { uniforms: { curtain: { value: 1.0 }, screenSpace: { value: false }, + curtainFeather: { value: 0 }, opacity: { value: 1.0 }, projectorMat: { value: transform.projectorMatrix() }, projectorTex: { value: texture }, diff --git a/src/component/slider/shaders/fisheye_curtain.fragment.glsl.ts b/src/component/slider/shaders/fisheye_curtain.fragment.glsl.ts index f7b460357..db7eb2e79 100644 --- a/src/component/slider/shaders/fisheye_curtain.fragment.glsl.ts +++ b/src/component/slider/shaders/fisheye_curtain.fragment.glsl.ts @@ -1,3 +1,5 @@ +import { SLIDER_CURTAIN_GLSL } from "../SliderCurtain"; + export const fisheyeCurtainFrag = ` #ifdef GL_FRAGMENT_PRECISION_HIGH precision highp float; @@ -14,6 +16,7 @@ uniform float scale_y; uniform float radial_peak; uniform float curtain; uniform bool screenSpace; +${SLIDER_CURTAIN_GLSL} varying vec4 vClipPosition; varying vec4 vRstq; void main() @@ -21,7 +24,8 @@ void main() // Homogeneous clip coordinates give the exact visible screen fraction, // independent of lens projection, image pose, roll or panorama wrapping. float screenX = (vClipPosition.x / vClipPosition.w + 1.0) * 0.5; - if (screenSpace && (curtain <= 0.0 || (curtain < 1.0 && screenX >= curtain))) { + float curtainAlpha = screenSpace ? sliderCurtainAlpha(screenX) : 1.0; + if (curtainAlpha <= 0.0) { discard; } float x = vRstq.x; @@ -44,7 +48,7 @@ void main() vec4 baseColor; if ((screenSpace || u < curtain || curtain >= 1.0) && u >= 0. && u <= 1. && v >= 0. && v <= 1.) { baseColor = texture2D(projectorTex, vec2(u, v)); - baseColor.a = opacity; + baseColor.a = opacity * curtainAlpha; } else { baseColor = vec4(0.0, 0.0, 0.0, 0.0); } diff --git a/src/component/slider/shaders/perspective_curtain.fragment.glsl.ts b/src/component/slider/shaders/perspective_curtain.fragment.glsl.ts index f9548a735..13884b3ad 100644 --- a/src/component/slider/shaders/perspective_curtain.fragment.glsl.ts +++ b/src/component/slider/shaders/perspective_curtain.fragment.glsl.ts @@ -1,3 +1,5 @@ +import { SLIDER_CURTAIN_GLSL } from "../SliderCurtain"; + export const perspectiveCurtainFrag = ` #ifdef GL_FRAGMENT_PRECISION_HIGH precision highp float; @@ -14,6 +16,7 @@ uniform float scale_y; uniform float radial_peak; uniform float curtain; uniform bool screenSpace; +${SLIDER_CURTAIN_GLSL} varying vec4 vClipPosition; varying vec4 vRstq; void main() @@ -21,7 +24,8 @@ void main() // Homogeneous clip coordinates give the exact visible screen fraction, // independent of lens projection, image pose, roll or panorama wrapping. float screenX = (vClipPosition.x / vClipPosition.w + 1.0) * 0.5; - if (screenSpace && (curtain <= 0.0 || (curtain < 1.0 && screenX >= curtain))) { + float curtainAlpha = screenSpace ? sliderCurtainAlpha(screenX) : 1.0; + if (curtainAlpha <= 0.0) { discard; } float x = vRstq.x / vRstq.z; @@ -36,7 +40,7 @@ void main() vec4 baseColor; if ((screenSpace || u < curtain || curtain >= 1.0) && u >= 0. && u <= 1. && v >= 0. && v <= 1.) { baseColor = texture2D(projectorTex, vec2(u, v)); - baseColor.a = opacity; + baseColor.a = opacity * curtainAlpha; } else { baseColor = vec4(0.0, 0.0, 0.0, 0.0); } diff --git a/src/component/slider/shaders/perspective_distorted_curtain.fragment.glsl.ts b/src/component/slider/shaders/perspective_distorted_curtain.fragment.glsl.ts index d2ef6aee5..c4a6f965c 100644 --- a/src/component/slider/shaders/perspective_distorted_curtain.fragment.glsl.ts +++ b/src/component/slider/shaders/perspective_distorted_curtain.fragment.glsl.ts @@ -1,3 +1,5 @@ +import { SLIDER_CURTAIN_GLSL } from "../SliderCurtain"; + export const perspectiveDistortedCurtainFrag = ` #ifdef GL_FRAGMENT_PRECISION_HIGH precision highp float; @@ -8,6 +10,7 @@ uniform sampler2D projectorTex; uniform float opacity; uniform float curtain; uniform bool screenSpace; +${SLIDER_CURTAIN_GLSL} varying vec4 vClipPosition; varying vec4 vRstq; void main() @@ -15,7 +18,8 @@ void main() // Homogeneous clip coordinates give the exact visible screen fraction, // independent of lens projection, image pose, roll or panorama wrapping. float screenX = (vClipPosition.x / vClipPosition.w + 1.0) * 0.5; - if (screenSpace && (curtain <= 0.0 || (curtain < 1.0 && screenX >= curtain))) { + float curtainAlpha = screenSpace ? sliderCurtainAlpha(screenX) : 1.0; + if (curtainAlpha <= 0.0) { discard; } float u = vRstq.x / vRstq.w; @@ -23,7 +27,7 @@ void main() vec4 baseColor; if ((screenSpace || u < curtain || curtain >= 1.0) && u >= 0. && u <= 1. && v >= 0. && v <= 1.) { baseColor = texture2D(projectorTex, vec2(u, v)); - baseColor.a = opacity; + baseColor.a = opacity * curtainAlpha; } else { baseColor = vec4(0.0, 0.0, 0.0, 0.0); } diff --git a/src/component/slider/shaders/spherical_curtain.fragment.glsl.ts b/src/component/slider/shaders/spherical_curtain.fragment.glsl.ts index 4a5acd36a..cf1603202 100644 --- a/src/component/slider/shaders/spherical_curtain.fragment.glsl.ts +++ b/src/component/slider/shaders/spherical_curtain.fragment.glsl.ts @@ -1,3 +1,5 @@ +import { SLIDER_CURTAIN_GLSL } from "../SliderCurtain"; + export const sphericalCurtainFrag = ` #ifdef GL_FRAGMENT_PRECISION_HIGH precision highp float; @@ -8,6 +10,7 @@ precision mediump float; uniform sampler2D projectorTex; uniform float curtain; uniform bool screenSpace; +${SLIDER_CURTAIN_GLSL} varying vec4 vClipPosition; uniform float opacity; varying vec4 vRstq; @@ -16,7 +19,8 @@ void main() // Homogeneous clip coordinates give the exact visible screen fraction, // independent of lens projection, image pose, roll or panorama wrapping. float screenX = (vClipPosition.x / vClipPosition.w + 1.0) * 0.5; - if (screenSpace && (curtain <= 0.0 || (curtain < 1.0 && screenX >= curtain))) { + float curtainAlpha = screenSpace ? sliderCurtainAlpha(screenX) : 1.0; + if (curtainAlpha <= 0.0) { discard; } vec3 b = normalize(vRstq.xyz); @@ -33,7 +37,7 @@ void main() vec4 baseColor; if (insideCurtain) { baseColor = texture2D(projectorTex, vec2(x, y)); - baseColor.a = opacity; + baseColor.a = opacity * curtainAlpha; } else { baseColor = vec4(0.0, 0.0, 0.0, 0.0); } diff --git a/test/component/slider/SliderScreenSpace.test.ts b/test/component/slider/SliderScreenSpace.test.ts index 7174153d3..2911fcddf 100644 --- a/test/component/slider/SliderScreenSpace.test.ts +++ b/test/component/slider/SliderScreenSpace.test.ts @@ -58,7 +58,10 @@ describe("Slider opt-in screen-space curtain", () => { expect(p.vertexShader).toContain("vClipPosition = gl_Position;"); expect(p.fragmentShader).toContain("vClipPosition.x / vClipPosition.w"); expect(p.fragmentShader).toContain("curtain <= 0.0"); - expect(p.fragmentShader).toContain("curtain < 1.0 && screenX >= curtain"); + // Hard split (curtainFeather 0) keeps the original rule: hidden at + // <=0, full at >=1, otherwise visible only left of the curtain. + expect(p.fragmentShader).toContain("curtain >= 1.0"); + expect(p.fragmentShader).toContain("return screenX < curtain ? 1.0 : 0.0;"); }); } function component(screenSpace: boolean): any { diff --git a/test/component/slider/SliderSoftEdge.test.ts b/test/component/slider/SliderSoftEdge.test.ts new file mode 100644 index 000000000..cc155dafd --- /dev/null +++ b/test/component/slider/SliderSoftEdge.test.ts @@ -0,0 +1,96 @@ +import * as THREE from "three"; +import { Subject } from "rxjs"; +import { SliderConfigurationMode as Mode } from "../../../src/component/interfaces/SliderConfiguration"; +import { SliderTransform } from "../../../src/component/slider/SliderTransform"; +import { SliderGLRenderer } from "../../../src/component/slider/SliderGLRenderer"; +import { SliderComponent } from "../../../src/component/slider/SliderComponent"; +import { SliderDOMRenderer } from "../../../src/component/slider/SliderDOMRenderer"; +import { SliderShaders } from "../../../src/component/slider/SliderShaders"; +import { sliderCurtainFeather } from "../../../src/component/slider/SliderCurtain"; +import { Camera } from "../../../src/geo/Camera"; +import { State } from "../../../src/state/State"; +import { ContainerMockCreator } from "../../helper/ContainerMockCreator"; +import { NavigatorMockCreator } from "../../helper/NavigatorMockCreator"; +import { MockCreator } from "../../helper/MockCreator"; + +const LEVEL = [Math.PI / 2, 0, 0]; +function image(id: string): any { + return { id, merged: true, mergeId: id, cameraType: "perspective", width: 1600, height: 1200, + scale: 1, exifOrientation: 1, rotation: LEVEL, clusterId: id, + camera: { type: "perspective", parameters: { focal: 0.8, k1: 0, k2: 0 } }, + image: document.createElement("img"), mesh: { vertices: [], faces: [] } }; +} +function transform(i: any, east: number): any { + const R = new THREE.Matrix4().makeRotationAxis(new THREE.Vector3(1, 0, 0), i.rotation[0]); + const t = new THREE.Vector3(-east, 0, 0).applyMatrix4(R); + return new SliderTransform(i.exifOrientation, i.width, i.height, i.scale, + i.rotation, t.toArray(), i.image, undefined, Object.values(i.camera.parameters), i.cameraType); +} +function state(): any { + const previousImage = image("previous"); const currentImage = image("current"); + const previousTransform = transform(previousImage, 0); const currentTransform = transform(currentImage, 3); + return { previousImage, currentImage, previousTransform, currentTransform, + previousCamera: new Camera(previousTransform), motionless: false, trajectory: [previousImage, currentImage] }; +} +/** JS mirror of sliderCurtainAlpha, to pin the GLSL semantics. */ +function alpha(curtain: number, feather: number, x: number): number { + if (curtain <= 0) { return 0; } + if (curtain >= 1) { return 1; } + if (feather > 0) { + const t = Math.max(0, Math.min(1, (x - (curtain - feather)) / (2 * feather))); + return 1 - t * t * (3 - 2 * t); + } + return x < curtain ? 1 : 0; +} + +describe("Slider soft edge", () => { + it("clamps the feather and defaults to the hard split", () => { + expect([undefined, null, NaN, -1, 0, 0.025, 1, "0.02"].map(sliderCurtainFeather)) + .toEqual([0, 0, 0, 0, 0, 0.025, 0.1, 0]); + expect(alpha(0.5, 0, 0.49)).toBe(1); expect(alpha(0.5, 0, 0.5)).toBe(0); + expect(alpha(0.5, 0.025, 0.5)).toBeCloseTo(0.5, 12); + expect(alpha(0.5, 0.025, 0.47)).toBe(1); expect(alpha(0.5, 0.025, 0.53)).toBe(0); + expect(alpha(0, 0.025, 0)).toBe(0); expect(alpha(1, 0.025, 0.999)).toBe(1); + }); + it("is the only curtain variation in every curtain shader, alpha-blended", () => { + for (const name of ["perspectiveCurtain", "fisheyeCurtain", "sphericalCurtain", "perspectiveDistortedCurtain"]) { + const frag = (SliderShaders as any)[name].fragment as string; + expect(frag).toContain("uniform float curtainFeather;"); + expect(frag).toContain("smoothstep(curtain - curtainFeather, curtain + curtainFeather, screenX)"); + expect(frag).toContain("baseColor.a = opacity * curtainAlpha;"); + expect(frag).not.toContain("${"); + } + }); + it("sets the feather uniform on the curtain, including after a rebuild", () => { + const r = new SliderGLRenderer() as any; + r.update({ id: 1, state: state() }, Mode.Stationary, true, true, true); + const u = () => r._scene.planes[0].mesh.material.uniforms; + expect(u().curtainFeather.value).toBe(0); + r.updateCurtainFeather(0.025); + expect(u().curtainFeather.value).toBe(0.025); + expect(r.needsRender).toBe(true); + r.update({ id: 2, state: state() }, Mode.Motion, true, true, true); + expect(u().curtainFeather.value).toBe(0.025); + r.updateCurtainFeather(undefined); + expect(u().curtainFeather.value).toBe(0); + r.dispose(); + }); + it("passes the configured feather to the renderer", () => { + const container = new ContainerMockCreator().create(); + const navigator = new NavigatorMockCreator().create(); + new MockCreator().mockProperty(container, "domContainer", document.createElement("div")); + spyOn(SliderDOMRenderer.prototype, "render").and.returnValue({} as any); + spyOn(SliderGLRenderer.prototype, "update"); + const feather = spyOn(SliderGLRenderer.prototype, "updateCurtainFeather").and.callThrough(); + const component = new SliderComponent("slider", container, navigator); + spyOn((component as any), "_catchCacheImage$").and.returnValue(new Subject()); + component.configure({ viewAligned: true, screenSpace: true, curtainFeather: 0.025, + ids: { foreground: "current", background: "previous" } }); + component.activate(); + (container.renderService.size$ as Subject).next({ width: 1200, height: 800 }); + (navigator.stateService.state$ as Subject).next(State.Traversing); + (navigator.stateService.currentState$ as Subject).next({ id: 1, state: state() }); + expect(feather.calls.mostRecent().args).toEqual([0.025]); + component.deactivate(); + }); +}); From d82d31e9c89036e8150475739002fa6bd608e789 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Mon, 2 Nov 2026 08:39:39 +0100 Subject: [PATCH 12/16] Add an image-based view correction to the slider Accept an externally computed view correction (e.g. from image matching) in `SliderConfiguration` and apply it when rendering the comparison image. --- .../interfaces/SliderConfiguration.ts | 12 ++ src/component/slider/SliderComponent.ts | 8 + src/component/slider/SliderGLRenderer.ts | 21 ++- .../slider/SliderViewCorrection.test.ts | 152 ++++++++++++++++++ 4 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 test/component/slider/SliderViewCorrection.test.ts diff --git a/src/component/interfaces/SliderConfiguration.ts b/src/component/interfaces/SliderConfiguration.ts index 8e32c7f43..7e276fb2b 100644 --- a/src/component/interfaces/SliderConfiguration.ts +++ b/src/component/interfaces/SliderConfiguration.ts @@ -101,6 +101,18 @@ export interface SliderConfiguration extends ComponentConfiguration { * @default false */ automaticMotion?: boolean; + /** + * Image-based refinement of the background's reconstructed orientation. + * + * @description World-frame rotation quaternion [x, y, z, w] that maps the + * background's reconstructed viewing rays onto the foreground's, e.g. + * estimated by matching both photos. Applies to viewAligned Stationary + * rendering only and replaces the roll safeguard while set. Invalid + * values are ignored. + * + * @default undefined + */ + viewCorrection?: number[]; /** * Clip the foreground at a fraction of the visible viewport width. * diff --git a/src/component/slider/SliderComponent.ts b/src/component/slider/SliderComponent.ts index 5ba1a7347..01fa80473 100644 --- a/src/component/slider/SliderComponent.ts +++ b/src/component/slider/SliderComponent.ts @@ -363,6 +363,14 @@ export class SliderComponent extends Component { }; })) .subscribe(this._glRendererOperation$)); + subs.push(this._configuration$.pipe( + map((configuration: SliderConfiguration): number[] => configuration.viewCorrection ?? null), + distinctUntilChanged((a: number[], b: number[]): boolean => JSON.stringify(a) === JSON.stringify(b)), + map((correction: number[]): GLRendererOperation => (glRenderer): SliderGLRenderer => { + glRenderer.updateViewCorrection(correction); + return glRenderer; + })) + .subscribe(this._glRendererOperation$)); subs.push(decision$.pipe( map((decision): GLRendererOperation => (glRenderer): SliderGLRenderer => { const c = decision.configuration; diff --git a/src/component/slider/SliderGLRenderer.ts b/src/component/slider/SliderGLRenderer.ts index 57994bf6f..503e82417 100644 --- a/src/component/slider/SliderGLRenderer.ts +++ b/src/component/slider/SliderGLRenderer.ts @@ -51,6 +51,7 @@ export class SliderGLRenderer { private _previousLevelResult: boolean = false; private _previousPerspective: THREE.PerspectiveCamera; private _previousRotation: THREE.Quaternion; + private _viewCorrection: THREE.Quaternion = null; private _headingRotation: THREE.Quaternion; private _currentProviderDisposers: { [key: string]: () => void }; private _previousProviderDisposers: { [key: string]: () => void }; @@ -129,6 +130,19 @@ export class SliderGLRenderer { this._updateCurtain(); this._needsRender = true; } + /** Image-based background orientation refinement; null clears it. */ + public updateViewCorrection(correction: number[]): void { + const valid = Array.isArray(correction) && correction.length === 4 && + correction.every(Number.isFinite) && Math.hypot(...correction) > 0.5; + const next = valid ? new THREE.Quaternion( + correction[0], correction[1], correction[2], correction[3]).normalize() : null; + if (next === this._viewCorrection || + next != null && this._viewCorrection != null && next.equals(this._viewCorrection)) { + return; + } + this._viewCorrection = next; + this._needsRender = true; + } public updateCurtain(curtain: number, screenSpace: boolean = false): void { if (this._curtain === curtain && this._screenSpace === screenSpace) { return; @@ -276,7 +290,12 @@ export class SliderGLRenderer { new THREE.Matrix4().extractRotation(this._previousPerspective.matrix)); } } else { - if (roll !== 0) { + if (this._viewCorrection != null) { + // Show the background content whose corrected ray C * r + // is this pixel's live ray r: camera rotation C^-1 * Q. + this._previousPerspective.quaternion.premultiply( + this._viewCorrection.clone().invert()); + } else if (roll !== 0) { this._previousPerspective.quaternion.multiply( new THREE.Quaternion().setFromAxisAngle( new THREE.Vector3(0, 0, 1), roll)); diff --git a/test/component/slider/SliderViewCorrection.test.ts b/test/component/slider/SliderViewCorrection.test.ts new file mode 100644 index 000000000..043c6646c --- /dev/null +++ b/test/component/slider/SliderViewCorrection.test.ts @@ -0,0 +1,152 @@ +import * as THREE from "three"; +import { Subject } from "rxjs"; +import { SliderConfigurationMode } from "../../../src/component/interfaces/SliderConfiguration"; +import { SliderComponent } from "../../../src/component/slider/SliderComponent"; +import { SliderDOMRenderer } from "../../../src/component/slider/SliderDOMRenderer"; +import { SliderGLRenderer } from "../../../src/component/slider/SliderGLRenderer"; +import { SliderTransform } from "../../../src/component/slider/SliderTransform"; +import { Camera } from "../../../src/geo/Camera"; +import { Image } from "../../../src/graph/Image"; +import { State } from "../../../src/state/State"; +import { ContainerMockCreator } from "../../helper/ContainerMockCreator"; +import { NavigatorMockCreator } from "../../helper/NavigatorMockCreator"; +import { MockCreator } from "../../helper/MockCreator"; + +function image(id: string, cameraType: string, width: number, height: number): any { + return { + id, cameraType, width, height, scale: 1, exifOrientation: 1, + rotation: id === "current" ? [Math.PI / 2, 0, 0] : [0.1, 0.2, 0.3], + camera: { type: cameraType, parameters: { focal: 0.62, k1: 0.02, k2: -0.005 } }, + mesh: { vertices: [], faces: [] }, image: document.createElement("img"), + }; +} +function transform(i: any, translation: number[]): any { + return new SliderTransform(i.exifOrientation, i.width, i.height, i.scale, + i.rotation, translation, i.image, undefined, + Object.values(i.camera.parameters) as number[], i.cameraType); +} +function frame(currentType = "spherical", previousType = "perspective"): any { + const currentImage = image("current", currentType, 4000, 2000); + const previousImage = image("previous", previousType, 1200, 1600); + previousImage.exifOrientation = 6; + const currentTransform = transform(currentImage, [-2, -3, -4]); + const previousTransform = transform(previousImage, [-20, -10, -4]); + return { id: 7, state: { + currentImage, previousImage, currentTransform, previousTransform, + previousCamera: new Camera(previousTransform), motionless: false, + } }; +} +function liveCamera(): THREE.PerspectiveCamera { + const camera = new THREE.PerspectiveCamera(43, 1.7, 0.2, 2000); + camera.position.set(2, 3, 4); + camera.up.set(0.2, 0.4, 0.8).normalize(); + camera.lookAt(3, 5, 4.5); + camera.zoom = 1.4; + camera.setViewOffset(2000, 1400, 100, 80, 1800, 1200); + camera.updateMatrixWorld(true); + camera.matrixAutoUpdate = false; + return camera; +} +function expectSameView(actual: THREE.PerspectiveCamera, expected: THREE.PerspectiveCamera): void { + expect(actual.quaternion.toArray()).toEqual(expected.quaternion.toArray()); + expect(actual.up.toArray()).toEqual(expected.up.toArray()); + expect(actual.getWorldDirection(new THREE.Vector3()).distanceTo( + expected.getWorldDirection(new THREE.Vector3()))).toBeCloseTo(0, 10); + expect(actual.projectionMatrix.elements).toEqual(expected.projectionMatrix.elements); + expect(actual.projectionMatrixInverse.elements).toEqual(expected.projectionMatrixInverse.elements); + expect(actual.fov).toBe(expected.fov); + expect(actual.aspect).toBe(expected.aspect); + expect(actual.zoom).toBe(expected.zoom); + expect(actual.view).toEqual(expected.view); +} +function componentFixture(): any { + const container = new ContainerMockCreator().create(); + const navigator = new NavigatorMockCreator().create(); + new MockCreator().mockProperty(container, "domContainer", document.createElement("div")); + const dom = spyOn(SliderDOMRenderer.prototype, "render").and.returnValue({} as any); + const update = spyOn(SliderGLRenderer.prototype, "update"); + const component = new SliderComponent("slider", container, navigator); + component.configure({ mode: SliderConfigurationMode.Stationary }); + component.activate(); + (container.renderService.size$ as Subject).next({ width: 1440, height: 900 }); + (navigator.stateService.state$ as Subject).next(State.Traversing); + const emit = (f: any) => (navigator.stateService.currentState$ as Subject).next(f); + return { component, container, navigator, dom, update, emit }; +} + +describe("Slider view-aligned image-based correction", () => { + const yaw = (degrees: number) => new THREE.Quaternion().setFromAxisAngle( + new THREE.Vector3(0, 0, 1), degrees * Math.PI / 180); + it("rotates the stationary background camera by the inverse world correction", () => { + const r = new SliderGLRenderer() as any; + r.update(frame(), SliderConfigurationMode.Stationary, false, true); + const live = liveCamera(); + const uncorrected = r._getPreviousPerspective(live).quaternion.clone(); + r._needsRender = false; + const correction = yaw(3); + r.updateViewCorrection(correction.toArray()); + expect(r.needsRender).toBe(true); + const corrected = r._getPreviousPerspective(live); + const expected = correction.clone().invert().multiply(live.quaternion); + expect(corrected.quaternion.angleTo(expected)).toBeCloseTo(0, 10); + // The live foreground camera and the background origin are untouched. + expect(corrected.position.toArray()).toEqual(r._previousCamera.position.toArray()); + expect(corrected.projectionMatrix.elements).toEqual(live.projectionMatrix.elements); + expect(corrected.quaternion.angleTo(uncorrected)).toBeGreaterThan(0.01); + r.dispose(); + }); + it("maps a corrected background ray onto the live ray", () => { + const r = new SliderGLRenderer() as any; + r.update(frame(), SliderConfigurationMode.Stationary, false, true); + const live = liveCamera(); + const correction = new THREE.Quaternion().setFromEuler(new THREE.Euler(0.03, -0.02, 0.05)); + r.updateViewCorrection(correction.toArray()); + const previous = r._getPreviousPerspective(live); + const liveRay = new THREE.Vector3(0.1, -0.2, -1).normalize().applyQuaternion(live.quaternion); + const backgroundRay = new THREE.Vector3(0.1, -0.2, -1).normalize().applyQuaternion(previous.quaternion); + expect(backgroundRay.clone().applyQuaternion(correction).distanceTo(liveRay)).toBeCloseTo(0, 10); + r.dispose(); + }); + it("ignores invalid values, deduplicates equal ones and restores the default when cleared", () => { + const r = new SliderGLRenderer() as any; + r.update(frame(), SliderConfigurationMode.Stationary, false, true); + const live = liveCamera(); + const original = r._getPreviousPerspective(live).quaternion.clone(); + for (const invalid of [undefined, null, [], [0, 0, 0], [NaN, 0, 0, 1], [0, 0, 0, 0]]) { + r.updateViewCorrection(invalid); + expect(r._viewCorrection).toBeNull(); + } + r.updateViewCorrection(yaw(2).toArray()); + r._needsRender = false; + r.updateViewCorrection(yaw(2).toArray()); + expect(r.needsRender).toBe(false); + r.updateViewCorrection(null); + expect(r.needsRender).toBe(true); + expect(r._getPreviousPerspective(live).quaternion.angleTo(original)).toBeCloseTo(0, 10); + r.dispose(); + }); + it("does not alter motion rendering", () => { + const r = new SliderGLRenderer() as any; + r.update(frame("perspective", "perspective"), SliderConfigurationMode.Motion, false, true); + const live = liveCamera(); + const before = r._getPreviousPerspective(live).matrix.clone(); + r.updateViewCorrection(yaw(4).toArray()); + expect(r._getPreviousPerspective(live).matrix.elements).toEqual(before.elements); + r.dispose(); + }); + it("propagates configuration changes to the renderer once per distinct value", () => { + const spy = spyOn(SliderGLRenderer.prototype, "updateViewCorrection").and.callThrough(); + const f = componentFixture(); + f.emit(frame()); + const initial = spy.calls.count(); + expect(spy.calls.mostRecent().args[0]).toBeNull(); + f.component.configure({ viewAligned: true, viewCorrection: [0, 0, 0.02, 0.9998] }); + expect(spy.calls.count()).toBe(initial + 1); + expect(spy.calls.mostRecent().args[0]).toEqual([0, 0, 0.02, 0.9998]); + f.component.configure({ viewCorrection: [0, 0, 0.02, 0.9998] }); + expect(spy.calls.count()).toBe(initial + 1); + f.component.configure({ viewCorrection: null }); + expect(spy.calls.mostRecent().args[0]).toBeNull(); + f.component.deactivate(); + }); +}); From 6d0b1fab4e0441a24f56256dd14c6d2772b1c7ac Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Mon, 2 Nov 2026 08:39:39 +0100 Subject: [PATCH 13/16] Add a pointer view window for drag panning New `PointerConfiguration` view bounds let drag panning and bounce limits use a sub-window of the viewer, and keep silent cluster loads from emitting errors. --- .../interfaces/PointerConfiguration.ts | 25 ++++ src/component/pointer/BounceHandler.ts | 17 ++- src/component/pointer/DragPanHandler.ts | 12 +- src/component/pointer/ImageBoundary.ts | 102 ++++++++++++-- src/external/component.ts | 6 +- src/graph/Graph.ts | 2 +- .../pointer/ImageBoundaryViewBounds.test.ts | 127 ++++++++++++++++++ test/graph/GraphClusterSilent.test.ts | 27 ++++ 8 files changed, 296 insertions(+), 22 deletions(-) create mode 100644 test/component/pointer/ImageBoundaryViewBounds.test.ts create mode 100644 test/graph/GraphClusterSilent.test.ts diff --git a/src/component/interfaces/PointerConfiguration.ts b/src/component/interfaces/PointerConfiguration.ts index 2af3d3ab3..ac48faff1 100644 --- a/src/component/interfaces/PointerConfiguration.ts +++ b/src/component/interfaces/PointerConfiguration.ts @@ -47,4 +47,29 @@ export interface PointerConfiguration extends ComponentConfiguration { * @default true */ touchZoom?: boolean; + + /** + * Keep drag panning, and the bounce back after panning or zooming, + * within a view window of the current image instead of its edges. + * + * @description The window is a perspective frustum from the render + * camera's position and up vector, looking at `center` with the + * vertical `fieldOfView`, at the viewport's aspect. Regular images + * are additionally limited to their edges. `null` restores the image + * edges. + * + * @default null + */ + viewBounds?: PointerViewBounds | null; +} + +/** + * A view window of the current image for the pointer component. + */ +export interface PointerViewBounds { + /** Window center in basic coordinates of the current image. */ + center: number[]; + + /** Vertical field of view of the window, in degrees. */ + fieldOfView: number; } diff --git a/src/component/pointer/BounceHandler.ts b/src/component/pointer/BounceHandler.ts index 7bd6a0014..c8ddd00d3 100644 --- a/src/component/pointer/BounceHandler.ts +++ b/src/component/pointer/BounceHandler.ts @@ -69,16 +69,19 @@ export class BounceHandler extends HandlerBase { }), distinctUntilChanged(), switchMap( - (noForce: boolean): Observable<[RenderCamera, Transform]> => { + (noForce: boolean): Observable<[RenderCamera, Transform, PointerConfiguration]> => { return noForce ? observableEmpty() : observableCombineLatest( this._container.renderService.renderCamera$, - this._navigator.stateService.currentTransform$.pipe(first())); + this._navigator.stateService.currentTransform$.pipe(first()), + // A new window must take effect without waiting for a camera move. + this._component.configuration$); }), withLatestFrom(this._navigator.panService.panImages$)) .subscribe( - ([[render, transform], nts]: [[RenderCamera, Transform], [Image, Transform, number][]]): void => { + ([[render, transform, configuration], nts]: + [[RenderCamera, Transform, PointerConfiguration], [Image, Transform, number][]]): void => { if (!transform.hasValidScale && render.camera.focal < 0.1) { return; } @@ -87,11 +90,15 @@ export class BounceHandler extends HandlerBase { return; } - const distances: number[] = ImageBoundary.viewportDistances(transform, render.perspective, this._viewportCoords); + const bounds: number[] | null = + ImageBoundary.viewBoundsBasic(transform, render.perspective, configuration.viewBounds); + const distances: number[] = bounds ? + ImageBoundary.viewportDistances(transform, render.perspective, this._viewportCoords, bounds) : + ImageBoundary.viewportDistances(transform, render.perspective, this._viewportCoords); const basic: number[] = this._viewportCoords.viewportToBasic(0, 0, transform, render.perspective); - if ((basic[0] < 0 || basic[0] > 1) && nts.length > 0) { + if (!bounds && (basic[0] < 0 || basic[0] > 1) && nts.length > 0) { distances[0] = distances[2] = 0; } diff --git a/src/component/pointer/DragPanHandler.ts b/src/component/pointer/DragPanHandler.ts index 250962be5..c58f36624 100644 --- a/src/component/pointer/DragPanHandler.ts +++ b/src/component/pointer/DragPanHandler.ts @@ -172,14 +172,16 @@ export class DragPanHandler extends HandlerBase { withLatestFrom( this._container.renderService.renderCamera$, this._navigator.stateService.currentTransform$, - this._navigator.panService.panImages$), + this._navigator.panService.panImages$, + this._component.configuration$), map( - ([events, render, transform, nts]: + ([events, render, transform, nts, configuration]: [ MouseTouchPair, RenderCamera, Transform, [Image, Transform, number][], + PointerConfiguration, ]): EulerRotation => { let previousEvent: MouseEvent | Touch = events[0]; let event: MouseEvent | Touch = events[1]; @@ -218,7 +220,11 @@ export class DragPanHandler extends HandlerBase { let phi: number = (movementX > 0 ? 1 : -1) * directionX.angleTo(currentDirection); let theta: number = (movementY > 0 ? -1 : 1) * directionY.angleTo(currentDirection); - const distances: number[] = ImageBoundary.viewportDistances(transform, render.perspective, this._viewportCoords); + const bounds: number[] | null = + ImageBoundary.viewBoundsBasic(transform, render.perspective, configuration.viewBounds); + const distances: number[] = bounds ? + ImageBoundary.viewportDistances(transform, render.perspective, this._viewportCoords, bounds) : + ImageBoundary.viewportDistances(transform, render.perspective, this._viewportCoords); for (const [, t] of nts) { const d: number[] = ImageBoundary.viewportDistances(t, render.perspective, this._viewportCoords); diff --git a/src/component/pointer/ImageBoundary.ts b/src/component/pointer/ImageBoundary.ts index 342bc6874..d208985b3 100644 --- a/src/component/pointer/ImageBoundary.ts +++ b/src/component/pointer/ImageBoundary.ts @@ -3,11 +3,17 @@ import * as THREE from "three"; import * as Lines from "../../geo/Lines"; import { Transform } from "../../geo/Transform"; import { ViewportCoords } from "../../geo/ViewportCoords"; +import { isSpherical } from "../../geo/Geo"; +import { PointerViewBounds } from "../interfaces/PointerConfiguration"; -function basicBoundaryPoints(pointsPerSide: number): number[][] { +/** Basic rectangle [minX, minY, maxX, maxY]; the default is the whole image. */ +const IMAGE_BOUNDS: number[] = [0, 0, 1, 1]; + +function basicBoundaryPoints(pointsPerSide: number, bounds: number[] = IMAGE_BOUNDS): number[][] { let points: number[][] = []; - let os: number[][] = [[0, 0], [1, 0], [1, 1], [0, 1]]; - let ds: number[][] = [[1, 0], [0, 1], [-1, 0], [0, -1]]; + const [x0, y0, x1, y1] = bounds; + let os: number[][] = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]]; + let ds: number[][] = [[x1 - x0, 0], [0, y1 - y0], [x0 - x1, 0], [0, y0 - y1]]; for (let side: number = 0; side < 4; ++side) { let o: number[] = os[side]; @@ -26,12 +32,86 @@ function insideViewport(x: number, y: number): boolean { return x >= -1 && x <= 1 && y >= -1 && y <= 1; } -function insideBasic(x: number, y: number): boolean { - return x >= 0 && x <= 1 && y >= 0 && y <= 1; +function insideBasic(x: number, y: number, bounds: number[]): boolean { + return x >= bounds[0] && x <= bounds[2] && y >= bounds[1] && y <= bounds[3]; +} + +/** Unwrap a panorama's basic X to the copy nearest `reference` (bounds may cross the seam). */ +function unwrapX(x: number, reference: number, spherical: boolean): number { + return spherical ? x + Math.round(reference - x) : x; +} + +/** + * Basic rectangle of the current image inscribed in a view window: a + * perspective frustum from the render camera's position and up vector, + * looking at `bounds.center` (basic) with vertical `bounds.fieldOfView` + * (degrees) at the viewport's aspect. Regular images are also limited to + * their edges. Returns null when the window cannot be resolved, so callers + * fall back to the image edges. + */ +export function viewBoundsBasic( + transform: Transform, + perspective: THREE.PerspectiveCamera, + bounds: PointerViewBounds | null | undefined): number[] | null { + if (!bounds || !bounds.center || bounds.center.length !== 2 || + !bounds.center.every(Number.isFinite) || !Number.isFinite(bounds.fieldOfView) || + bounds.fieldOfView <= 0 || bounds.fieldOfView >= 180 || + !Number.isFinite(perspective.aspect) || perspective.aspect <= 0) { + return null; + } + const spherical = isSpherical(transform.cameraType); + const frustum = new THREE.PerspectiveCamera(bounds.fieldOfView, perspective.aspect, 0.1, 1000); + frustum.position.copy(perspective.position); + frustum.up.copy(perspective.up); + frustum.lookAt(new THREE.Vector3().fromArray(transform.unprojectBasic(bounds.center, 10))); + frustum.updateMatrixWorld(true); + frustum.updateProjectionMatrix(); + const samples = 16; + // Inscribed rectangle: the most restrictive sample on each side. + // Basic Y grows downwards, so the window's top edge limits minY. + const limits: number[] = [-Infinity, -Infinity, Infinity, Infinity]; + const point = new THREE.Vector3(); + for (let i = 0; i <= samples; i++) { + const t = -1 + 2 * i / samples; + const sides: number[][] = [[-1, t], [t, 1], [1, t], [t, -1]]; + for (let side = 0; side < 4; side++) { + point.set(sides[side][0], sides[side][1], 0.5).unproject(frustum); + const direction = point.sub(frustum.position).normalize().multiplyScalar(10); + const basic = transform.projectBasic(frustum.position.clone().add(direction).toArray()); + if (!basic || !basic.every(Number.isFinite)) { + return null; + } + const x = unwrapX(basic[0], bounds.center[0], spherical); + const y = basic[1]; + if (side === 0) { limits[0] = Math.max(limits[0], x); } + if (side === 1) { limits[1] = Math.max(limits[1], y); } + if (side === 2) { limits[2] = Math.min(limits[2], x); } + if (side === 3) { limits[3] = Math.min(limits[3], y); } + } + } + const rect = spherical ? + [limits[0], Math.max(0, limits[1]), limits[2], Math.min(1, limits[3])] : + [Math.max(0, limits[0]), Math.max(0, limits[1]), Math.min(1, limits[2]), Math.min(1, limits[3])]; + if (!rect.every(Number.isFinite) || rect[0] >= rect[2] || rect[1] >= rect[3] || + rect[2] - rect[0] > 1) { + return null; + } + return rect; } -export function viewportDistances(transform: Transform, perspective: THREE.PerspectiveCamera, viewportCoords: ViewportCoords): number[] { - const boundaryPointsBasic: number[][] = basicBoundaryPoints(100); +/** + * How far the viewport extends past the image edges, or past `bounds` + * (a basic rectangle from `viewBoundsBasic`), in viewport units: + * [top, right, bottom, left]. + */ +export function viewportDistances( + transform: Transform, + perspective: THREE.PerspectiveCamera, + viewportCoords: ViewportCoords, + bounds: number[] = IMAGE_BOUNDS): number[] { + const spherical = bounds !== IMAGE_BOUNDS && isSpherical(transform.cameraType); + const centerX = (bounds[0] + bounds[2]) / 2; + const boundaryPointsBasic: number[][] = basicBoundaryPoints(100, bounds); const boundaryPointsViewport: number[][] = boundaryPointsBasic .map( (basic: number[]) => { @@ -99,19 +179,19 @@ export function viewportDistances(transform: Transform, perspective: THREE.Persp const [bottomLeftBasicX, bottomLeftBasicY]: number[] = viewportCoords.viewportToBasic(-1, -1, transform, perspective); - if (insideBasic(topLeftBasicX, topLeftBasicY)) { + if (insideBasic(unwrapX(topLeftBasicX, centerX, spherical), topLeftBasicY, bounds)) { intersections[3] = intersections[0] = true; } - if (insideBasic(topRightBasicX, topRightBasicY)) { + if (insideBasic(unwrapX(topRightBasicX, centerX, spherical), topRightBasicY, bounds)) { intersections[0] = intersections[1] = true; } - if (insideBasic(bottomRightBasicX, bottomRightBasicY)) { + if (insideBasic(unwrapX(bottomRightBasicX, centerX, spherical), bottomRightBasicY, bounds)) { intersections[1] = intersections[2] = true; } - if (insideBasic(bottomLeftBasicX, bottomLeftBasicY)) { + if (insideBasic(unwrapX(bottomLeftBasicX, centerX, spherical), bottomLeftBasicY, bounds)) { intersections[2] = intersections[3] = true; } diff --git a/src/external/component.ts b/src/external/component.ts index 2f67b7b43..83393eabc 100644 --- a/src/external/component.ts +++ b/src/external/component.ts @@ -65,8 +65,10 @@ export { SimpleMarkerOptions } // Pointer export { DragPanHandler } from "../component/pointer/DragPanHandler"; export { PointerComponent } from "../component/pointer/PointerComponent"; -export { PointerConfiguration } - from "../component/interfaces/PointerConfiguration"; +export { + PointerConfiguration, + PointerViewBounds, +} from "../component/interfaces/PointerConfiguration"; export { ScrollZoomHandler } from "../component/pointer/ScrollZoomHandler"; export { TouchZoomHandler } from "../component/pointer/TouchZoomHandler"; diff --git a/src/graph/Graph.ts b/src/graph/Graph.ts index 652277b44..425952cb8 100644 --- a/src/graph/Graph.ts +++ b/src/graph/Graph.ts @@ -2059,7 +2059,7 @@ export class Graph { private _addClusterNode(node: Image): void { const clusterId = node.clusterId; if (clusterId == null) { - console.warn(`Cannot set cluster node, cluster ID is undefined for node ${node.id}.`); + // Images without a reconstruction belong to no cluster. return; } diff --git a/test/component/pointer/ImageBoundaryViewBounds.test.ts b/test/component/pointer/ImageBoundaryViewBounds.test.ts new file mode 100644 index 000000000..dab0add45 --- /dev/null +++ b/test/component/pointer/ImageBoundaryViewBounds.test.ts @@ -0,0 +1,127 @@ +import * as THREE from "three"; + +import * as ImageBoundary from "../../../src/component/pointer/ImageBoundary"; +import { Camera } from "../../../src/geo/Camera"; +import { Transform } from "../../../src/geo/Transform"; +import { ViewportCoords } from "../../../src/geo/ViewportCoords"; +import { CameraType } from "../../../src/geo/interfaces/CameraType"; +import { ProjectionService } from "../../../src/viewer/ProjectionService"; + +const coords = new ViewportCoords(); + +function transform(cameraType: CameraType): Transform { + const spherical = cameraType === "spherical"; + const camera = new ProjectionService() + .makeCamera(cameraType, spherical ? [] : [0.85, 0, 0]); + return new Transform(1, spherical ? 4096 : 4000, spherical ? 2048 : 3000, 1, + [0, 0, 0], [0, 0, 0], null, camera); +} + +/** A render camera at the image position looking at a basic point. */ +function render(t: Transform, center: number[], fov: number, aspect: number = 1.5): THREE.PerspectiveCamera { + const native = new Camera(t); + const result = new THREE.PerspectiveCamera(fov, aspect, 0.1, 1000); + result.position.copy(native.position); + result.up.copy(native.up); + result.lookAt(new THREE.Vector3().fromArray(t.unprojectBasic(center, 10))); + result.updateMatrixWorld(true); + result.updateProjectionMatrix(); + return result; +} + +describe("ImageBoundary.viewBoundsBasic", () => { + it("returns null without a valid window, keeping image edges", () => { + const t = transform("perspective"); + const p = render(t, [0.5, 0.5], 40); + expect(ImageBoundary.viewBoundsBasic(t, p, null)).toBeNull(); + expect(ImageBoundary.viewBoundsBasic(t, p, undefined)).toBeNull(); + expect(ImageBoundary.viewBoundsBasic(t, p, { center: [0.5], fieldOfView: 30 })).toBeNull(); + expect(ImageBoundary.viewBoundsBasic(t, p, { center: [0.5, 0.5], fieldOfView: 0 })).toBeNull(); + expect(ImageBoundary.viewBoundsBasic(t, p, { center: [0.5, NaN], fieldOfView: 30 })).toBeNull(); + }); + + it("is a centered rectangle inside a regular image", () => { + const t = transform("perspective"); + const rect = ImageBoundary.viewBoundsBasic(t, render(t, [0.5, 0.5], 20), { center: [0.5, 0.5], fieldOfView: 20 }); + expect(rect).not.toBeNull(); + expect(rect[0]).toBeGreaterThan(0); + expect(rect[1]).toBeGreaterThan(0); + expect(rect[2]).toBeLessThan(1); + expect(rect[3]).toBeLessThan(1); + expect((rect[0] + rect[2]) / 2).toBeCloseTo(0.5, 6); + expect((rect[1] + rect[3]) / 2).toBeCloseTo(0.5, 6); + }); + + it("is also limited to the edges of a regular image", () => { + const t = transform("perspective"); + const rect = ImageBoundary.viewBoundsBasic(t, render(t, [0.5, 0.5], 40), { center: [0.5, 0.5], fieldOfView: 120 }); + expect(rect).toEqual([0, 0, 1, 1]); + }); + + it("unwraps a panorama window across the seam", () => { + const t = transform("spherical"); + const rect = ImageBoundary.viewBoundsBasic(t, render(t, [0.01, 0.5], 40), { center: [0.01, 0.5], fieldOfView: 40 }); + expect(rect).not.toBeNull(); + expect(rect[0]).toBeLessThan(0); + expect(rect[2]).toBeGreaterThan(0.01); + expect((rect[0] + rect[2]) / 2).toBeCloseTo(0.01, 6); + }); +}); + +describe("ImageBoundary.viewportDistances with view bounds", () => { + for (const cameraType of ["perspective", "spherical"] as CameraType[]) { + const center = cameraType === "spherical" ? [0.01, 0.5] : [0.5, 0.5]; + + it(`keeps the default image edges for ${cameraType} images`, () => { + const t = transform(cameraType); + const p = render(t, center, 30); + expect(ImageBoundary.viewportDistances(t, p, coords)) + .toEqual(ImageBoundary.viewportDistances(t, p, coords, undefined)); + }); + + it(`allows panning a zoomed ${cameraType} view within the window`, () => { + const t = transform(cameraType); + const bounds = { center, fieldOfView: 30 }; + // Zoomed in twice: the window still contains the view near each edge. + const rect = ImageBoundary.viewBoundsBasic(t, render(t, center, 15), bounds); + for (const offset of [[0, 0], [-0.35, 0], [0.35, 0], [0, -0.35], [0, 0.35], [0.3, 0.3], [-0.3, -0.3]]) { + const target = [ + center[0] + offset[0] * (rect[2] - rect[0]) / 2, + center[1] + offset[1] * (rect[3] - rect[1]) / 2, + ]; + const p = render(t, target, 15); + const distances = ImageBoundary.viewportDistances(t, p, coords, ImageBoundary.viewBoundsBasic(t, p, bounds)); + expect(Math.max(...distances)).toBeLessThan(0.01); + } + }); + + it(`pushes a ${cameraType} view panned past the window back towards it`, () => { + const t = transform(cameraType); + const bounds = { center, fieldOfView: 30 }; + const rect = ImageBoundary.viewBoundsBasic(t, render(t, center, 15), bounds); + const width = rect[2] - rect[0], height = rect[3] - rect[1]; + const cases: [number[], number][] = [ + // Straddling each window edge: that viewport side shows outside. + [[center[0] + width / 2, center[1]], 1], + [[center[0] - width / 2, center[1]], 3], + [[center[0], center[1] + height / 2], 2], // basic Y grows downwards + [[center[0], center[1] - height / 2], 0], + ]; + for (const [target, side] of cases) { + const p = render(t, target, 15); + const distances = ImageBoundary.viewportDistances(t, p, coords, ImageBoundary.viewBoundsBasic(t, p, bounds)); + expect(distances[side]).toBeGreaterThan(0.01); + expect(distances[(side + 2) % 4]).toBeLessThan(0.01); + } + }); + + it(`centers a ${cameraType} view wider than its window instead of drifting`, () => { + const t = transform(cameraType); + const p = render(t, center, 40); + const distances = ImageBoundary.viewportDistances(t, p, coords, + ImageBoundary.viewBoundsBasic(t, p, { center, fieldOfView: 20 })); + expect(distances[1] - distances[3]).toBeCloseTo(0, 2); + expect(distances[0] - distances[2]).toBeCloseTo(0, 2); + }); + } +}); diff --git a/test/graph/GraphClusterSilent.test.ts b/test/graph/GraphClusterSilent.test.ts new file mode 100644 index 000000000..bef634388 --- /dev/null +++ b/test/graph/GraphClusterSilent.test.ts @@ -0,0 +1,27 @@ +import { bootstrap } from "../Bootstrap"; +bootstrap(); + +import { APIWrapper } from "../../src/api/APIWrapper"; +import { Graph } from "../../src/graph/Graph"; +import { DataProvider } from "../helper/ProviderHelper"; + +describe("Graph cluster membership", () => { + it("silently skips images without a reconstruction cluster", () => { + const graph = new Graph(new APIWrapper(new DataProvider())) as any; + const warn = spyOn(console, "warn"); + + graph._addClusterNode({ id: "unclustered", clusterId: undefined, isComplete: true }); + graph._removeClusterNode({ id: "unclustered", clusterId: undefined, isComplete: true }); + + expect(warn).not.toHaveBeenCalled(); + expect(graph._clusterNodes.size).toBe(0); + }); + + it("still tracks clustered images", () => { + const graph = new Graph(new APIWrapper(new DataProvider())) as any; + graph._addClusterNode({ id: "a", clusterId: "c", isComplete: true }); + expect(graph._clusterNodes.get("c").has("a")).toBe(true); + graph._removeClusterNode({ id: "a", clusterId: "c", isComplete: true }); + expect(graph._clusterNodes.has("c")).toBe(false); + }); +}); From 3499c6b8597390c1408b9f11fa46c7a1956a8dc5 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Mon, 2 Nov 2026 08:39:40 +0100 Subject: [PATCH 14/16] Add panorama look-around correction, view scale and relaxed automatic motion to the slider Apply view correction to panorama look-around, add `viewScale` to size 360 comparisons like the original, and add a relaxed `automaticMotionPolicy` that lets pairs from different reconstructions try guarded 3D motion. --- .../interfaces/SliderConfiguration.ts | 32 +++- src/component/slider/SliderAutomaticMotion.ts | 111 ++++++++++---- src/component/slider/SliderComponent.ts | 9 ++ src/component/slider/SliderGLRenderer.ts | 61 +++++++- .../slider/SliderAutomaticMotion.test.ts | 64 ++++++++ .../slider/SliderViewCorrection.test.ts | 47 ++++++ test/component/slider/SliderViewScale.test.ts | 142 ++++++++++++++++++ 7 files changed, 426 insertions(+), 40 deletions(-) create mode 100644 test/component/slider/SliderViewScale.test.ts diff --git a/src/component/interfaces/SliderConfiguration.ts b/src/component/interfaces/SliderConfiguration.ts index 7e276fb2b..04cf67b0b 100644 --- a/src/component/interfaces/SliderConfiguration.ts +++ b/src/component/interfaces/SliderConfiguration.ts @@ -107,12 +107,40 @@ export interface SliderConfiguration extends ComponentConfiguration { * @description World-frame rotation quaternion [x, y, z, w] that maps the * background's reconstructed viewing rays onto the foreground's, e.g. * estimated by matching both photos. Applies to viewAligned Stationary - * rendering only and replaces the roll safeguard while set. Invalid - * values are ignored. + * rendering, where it replaces the roll safeguard while set, and to + * panorama-to-panorama look-around, where it replaces the compass + * heading alignment while set. Invalid values are ignored. * * @default undefined */ viewCorrection?: number[]; + /** + * Widening of a panorama background in viewAligned rendering. + * + * @description Ratio applied to the background's tan(fov / 2) relative + * to the live view, about the view centre. A comparison panorama + * captured ahead of the original along the view shows the subject + * larger; widening it (>1) shows the subject at the original's size, + * and narrowing (<1) compensates one captured behind. Applies only to + * viewAligned pairs whose background is a panorama; values outside + * [0.25, 4] are ignored (1). + * + * @default 1 + */ + viewScale?: number; + /** + * Gate policy for automaticMotion. + * + * @description "strict" (default) requires one shared merge component, + * <=2m baseline, <=10-degree parallax and full sampled coverage. + * "relaxed" also accepts pairs from different merged + * reconstructions placed by their computed geo-poses, with <=12m + * baseline, <=35-degree parallax, and per stop >=50% of sampled rays + * inside both photos with both meshes agreeing on depth within 2x. + * Expect registration seams and some warping in relaxed mode. + * @default "strict" + */ + automaticMotionPolicy?: "strict" | "relaxed"; /** * Clip the foreground at a fraction of the visible viewport width. * diff --git a/src/component/slider/SliderAutomaticMotion.ts b/src/component/slider/SliderAutomaticMotion.ts index b2bb1daae..b0f9f37e5 100644 --- a/src/component/slider/SliderAutomaticMotion.ts +++ b/src/component/slider/SliderAutomaticMotion.ts @@ -41,7 +41,35 @@ export function translatedSliderPerspective( * settled view (not animation frames). One pair's geometry and at most eight * projection/viewport decisions are retained; manual 2D/3D reuses them. */ +interface SliderMotionLimits { + relaxed: boolean; + sameMerge: boolean; + maxBaseline: number; + minParallaxCosine: number; + minCoverage: number; + maxDepthRatio: number; +} + +const STRICT_LIMITS: SliderMotionLimits = { + relaxed: false, sameMerge: true, maxBaseline: 2, minParallaxCosine: Math.cos(Math.PI / 18), minCoverage: 1, + maxDepthRatio: Infinity, +}; +// Time Travel candidates usually come from different merged +// reconstructions (one per capture), so only computed geo-poses relate them. +// Accept those up to the Time Travel matching radius, tolerating seams. +// Independent reconstructions of the SAME place must agree on where the +// surfaces are; unrelated frames (e.g. a garage under a street) do not. +const RELAXED_LIMITS: SliderMotionLimits = { + relaxed: true, sameMerge: false, maxBaseline: 12, minParallaxCosine: Math.cos(35 * Math.PI / 180), minCoverage: 0.5, + maxDepthRatio: 2, +}; + +export function sliderMotionLimits(configuration: SliderConfiguration): SliderMotionLimits { + return configuration.automaticMotionPolicy === "relaxed" ? RELAXED_LIMITS : STRICT_LIMITS; +} + export class SliderAutomaticMotion { + private _limits: SliderMotionLimits = STRICT_LIMITS; private _factory = new SliderMeshFactory(); private _pair: string; private _meshes: object[] = []; @@ -69,6 +97,8 @@ export class SliderAutomaticMotion { !perspective || !size || ![size.width, size.height].every(n => Number.isFinite(n) && n > 0)) { return SliderConfigurationMode.Stationary; } + const limits = sliderMotionLimits(configuration); + if (limits !== this._limits) { this.dispose(); this._limits = limits; } if (!this._prepare(state)) { return SliderConfigurationMode.Stationary; } @@ -113,8 +143,9 @@ export class SliderAutomaticMotion { // unrelated reconstruction frames together (e.g. parking under a street). // Ray/photo hits only test local coverage, not global pose coherence. // Require the same nonempty aligned component BEFORE using any meshes. - if (typeof mergeId !== "string" || mergeId.trim().length === 0 || - mergeId !== pairImages[1].mergeId) { return false; } + const limits = this._limits; + if (limits.sameMerge && (typeof mergeId !== "string" || mergeId.trim().length === 0 || + mergeId !== pairImages[1].mergeId)) { return false; } const images = pairImages.map(sliderReconstructionImage); const transforms = [state.previousTransform, state.currentTransform]; if (images.some(i => i.cameraType !== "perspective" || i.merged !== true) || @@ -145,7 +176,7 @@ export class SliderAutomaticMotion { // Automatic opt-in only: sparse clamped meshes can have ample photo // coverage yet stretch badly across a large baseline. No cluster // exception to this conservative quality cap; legacy motion is unchanged. - if (!Number.isFinite(distance) || distance > 2 || + if (!Number.isFinite(distance) || distance > limits.maxBaseline || ups.some(u => !u.toArray().every(Number.isFinite) || u.lengthSq() < 0.99) || ups[0].angleTo(ups[1]) > Math.PI / 6) { return false; } for (let n = 0; n < 2; n++) { @@ -186,40 +217,60 @@ export class SliderAutomaticMotion { const stops = Math.max(8, Math.ceil(this._origins[0].distanceTo(this._origins[1]) / 2)); const ray = new THREE.Raycaster(); const intersection = new THREE.Vector3(); + const limits = this._limits; + const required = Math.ceil(49 * limits.minCoverage); for (let stop = 0; stop <= stops; stop++) { const camera = translatedSliderPerspective(live, this._origins[0], this._origins[1], stop / stops); + let covered = 0; for (let y = 0; y < 7; y++) { for (let x = 0; x < 7; x++) { - ray.setFromCamera(new THREE.Vector2(1.01 * (x / 3 - 1), 1.01 * (y / 3 - 1)), camera); - if (!ray.ray.direction.toArray().every(Number.isFinite)) { return false; } - for (let meshIndex = 0; meshIndex < this._triangles.length; meshIndex++) { - const triangles = this._triangles[meshIndex]; - let nearest = Infinity; - let hit: THREE.Vector3 = null; - for (const triangle of triangles) { - if (ray.ray.intersectTriangle(triangle.a, triangle.b, triangle.c, false, intersection)) { - const distance = intersection.distanceTo(camera.position); - if (distance < nearest) { nearest = distance; hit = intersection.clone(); } - } - } - // Also respect clipping; never count a clipped surface. - if (!hit || this._transforms.some(t => !this._insidePhoto(hit, t))) { return false; } - const nativeRay = hit.clone().sub(this._origins[meshIndex]); - const translatedRay = hit.clone().sub(camera.position); - const denominator = nativeRay.length() * translatedRay.length(); - const parallaxCosine = nativeRay.dot(translatedRay) / denominator; - // Coverage alone does not rule out severe stretching of - // sparse reconstruction triangles. Bound reprojection - // parallax relative to this mesh's own photo origin. - // This conservative heuristic is NOT a quality proof. - if (!(denominator > 1e-8) || !Number.isFinite(parallaxCosine) || - parallaxCosine < Math.cos(Math.PI / 18)) { return false; } - const depth = hit.clone().applyMatrix4(camera.matrixWorldInverse).z; - if (-depth < camera.near || -depth > camera.far) { return false; } - } + if (this._rayCovered(ray, intersection, camera, x, y, limits)) { covered++; } + else if (limits.minCoverage >= 1) { return false; } + } + } + if (covered < required) { return false; } + } + return true; + } + + private _rayCovered( + ray: THREE.Raycaster, + intersection: THREE.Vector3, + camera: THREE.PerspectiveCamera, + x: number, + y: number, + limits: SliderMotionLimits): boolean { + ray.setFromCamera(new THREE.Vector2(1.01 * (x / 3 - 1), 1.01 * (y / 3 - 1)), camera); + if (!ray.ray.direction.toArray().every(Number.isFinite)) { throw new Error("Invalid slider ray"); } + const depths: number[] = []; + for (let meshIndex = 0; meshIndex < this._triangles.length; meshIndex++) { + const triangles = this._triangles[meshIndex]; + let nearest = Infinity; + let hit: THREE.Vector3 = null; + for (const triangle of triangles) { + if (ray.ray.intersectTriangle(triangle.a, triangle.b, triangle.c, false, intersection)) { + const distance = intersection.distanceTo(camera.position); + if (distance < nearest) { nearest = distance; hit = intersection.clone(); } } } + // Also respect clipping; never count a clipped surface. + if (!hit || this._transforms.some(t => !this._insidePhoto(hit, t))) { return false; } + const nativeRay = hit.clone().sub(this._origins[meshIndex]); + const translatedRay = hit.clone().sub(camera.position); + const denominator = nativeRay.length() * translatedRay.length(); + const parallaxCosine = nativeRay.dot(translatedRay) / denominator; + // Coverage alone does not rule out severe stretching of + // sparse reconstruction triangles. Bound reprojection + // parallax relative to this mesh's own photo origin. + // This conservative heuristic is NOT a quality proof. + if (!(denominator > 1e-8) || !Number.isFinite(parallaxCosine) || + parallaxCosine < limits.minParallaxCosine) { return false; } + const depth = hit.clone().applyMatrix4(camera.matrixWorldInverse).z; + if (-depth < camera.near || -depth > camera.far) { return false; } + depths.push(nearest); } + if (Number.isFinite(limits.maxDepthRatio) && + Math.max(...depths) > limits.maxDepthRatio * Math.min(...depths)) { return false; } return true; } diff --git a/src/component/slider/SliderComponent.ts b/src/component/slider/SliderComponent.ts index 01fa80473..92eba84b0 100644 --- a/src/component/slider/SliderComponent.ts +++ b/src/component/slider/SliderComponent.ts @@ -371,6 +371,14 @@ export class SliderComponent extends Component { return glRenderer; })) .subscribe(this._glRendererOperation$)); + subs.push(this._configuration$.pipe( + map((configuration: SliderConfiguration): number => configuration.viewScale ?? 1), + distinctUntilChanged(), + map((scale: number): GLRendererOperation => (glRenderer): SliderGLRenderer => { + glRenderer.updateViewScale(scale); + return glRenderer; + })) + .subscribe(this._glRendererOperation$)); subs.push(decision$.pipe( map((decision): GLRendererOperation => (glRenderer): SliderGLRenderer => { const c = decision.configuration; @@ -851,6 +859,7 @@ export class SliderComponent extends Component { return { viewAligned: false, automaticMotion: false, + automaticMotionPolicy: "strict", initialPosition: 1, mode: SliderConfigurationMode.Motion, sliderVisible: true, diff --git a/src/component/slider/SliderGLRenderer.ts b/src/component/slider/SliderGLRenderer.ts index 503e82417..d6555ab6f 100644 --- a/src/component/slider/SliderGLRenderer.ts +++ b/src/component/slider/SliderGLRenderer.ts @@ -41,6 +41,7 @@ export class SliderGLRenderer { private _currentGravityAligned: boolean; private _previousGravityAligned: boolean; private _previousSpherical: boolean; + private _currentSpherical: boolean = false; private _currentPerspective: THREE.PerspectiveCamera; private _currentCamera: Camera; private _previousCamera: Camera; @@ -52,6 +53,7 @@ export class SliderGLRenderer { private _previousPerspective: THREE.PerspectiveCamera; private _previousRotation: THREE.Quaternion; private _viewCorrection: THREE.Quaternion = null; + private _viewScale: number = 1; private _headingRotation: THREE.Quaternion; private _currentProviderDisposers: { [key: string]: () => void }; private _previousProviderDisposers: { [key: string]: () => void }; @@ -122,14 +124,6 @@ export class SliderGLRenderer { this._needsRender = true; } } - /** Soft-edge half width at the curtain (fraction of viewport width); 0 is a hard split. */ - public updateCurtainFeather(feather: number): void { - const next = sliderCurtainFeather(feather); - if (next === this._curtainFeather) { return; } - this._curtainFeather = next; - this._updateCurtain(); - this._needsRender = true; - } /** Image-based background orientation refinement; null clears it. */ public updateViewCorrection(correction: number[]): void { const valid = Array.isArray(correction) && correction.length === 4 && @@ -141,6 +135,25 @@ export class SliderGLRenderer { return; } this._viewCorrection = next; + if (!this._viewAligned) { + // The panorama look-around camera is cached; rebuild it. + this._previousPerspective = null; + } + this._needsRender = true; + } + /** View-aligned panorama background widening (tan ratio); 1 clears it. */ + public updateViewScale(scale: number): void { + const next = Number.isFinite(scale) && scale >= 0.25 && scale <= 4 ? scale : 1; + if (next === this._viewScale) { return; } + this._viewScale = next; + this._needsRender = true; + } + /** Soft-edge half width at the curtain (fraction of viewport width); 0 is a hard split. */ + public updateCurtainFeather(feather: number): void { + const next = sliderCurtainFeather(feather); + if (next === this._curtainFeather) { return; } + this._curtainFeather = next; + this._updateCurtain(); this._needsRender = true; } public updateCurtain(curtain: number, screenSpace: boolean = false): void { @@ -302,6 +315,36 @@ export class SliderGLRenderer { } this._previousPerspective.updateMatrix(); } + if (this._previousSpherical && this._viewScale !== 1) { + // Same view centre, wider (narrower) panorama view: scale the + // live projection exactly, keeping any view offset. + const s = this._viewScale; + const e = this._previousPerspective.projectionMatrix.elements; + e[0] /= s; + e[5] /= s; + this._previousPerspective.projectionMatrixInverse + .copy(this._previousPerspective.projectionMatrix).invert(); + this._previousPerspective.zoom = current.zoom / s; + } + this._previousPerspective.updateMatrixWorld(false); + return this._previousPerspective; + } + if (this._viewCorrection != null && this._currentSpherical && this._previousSpherical) { + // Panorama look-around with an image-based correction: both + // spheres are centred on the live camera, and the correction + // already relates their pixels, so neither the compass heading + // nor the reconstructed up axes are needed. Show the background + // content whose corrected world ray C * r is this pixel's live ray r. + const current = this._getCurrentPerspective(perspectiveCamera); + if (this._previousPerspective == null) { + this._previousPerspective = current.clone(); + } else { + this._previousPerspective.copy(current); + } + this._previousPerspective.position.copy(current.position); + this._previousPerspective.quaternion.premultiply( + this._viewCorrection.clone().invert()); + this._previousPerspective.updateMatrix(); this._previousPerspective.updateMatrixWorld(false); return this._previousPerspective; } @@ -478,6 +521,8 @@ export class SliderGLRenderer { canLevelSliderImage(state.currentImage, state.currentTransform); this._previousSpherical = state.previousImage != null && isSpherical(state.previousImage.cameraType); + this._currentSpherical = state.currentImage != null && + isSpherical(state.currentImage.cameraType); this._previousGravityAligned = gravityAligned && state.previousImage != null && canLevelSliderImage(state.previousImage, state.previousTransform); diff --git a/test/component/slider/SliderAutomaticMotion.test.ts b/test/component/slider/SliderAutomaticMotion.test.ts index bc6ccce4e..ccad65547 100644 --- a/test/component/slider/SliderAutomaticMotion.test.ts +++ b/test/component/slider/SliderAutomaticMotion.test.ts @@ -117,6 +117,70 @@ describe("Slider automatic motion safety", () => { s.dispose(); }); } + describe("relaxed policy", () => { + function relaxed(): any { + const f = fixture(); + f.configuration.automaticMotionPolicy = "relaxed"; + const wide = (i: any) => { i.mesh.vertices = i.mesh.vertices.map((v: number, n: number) => n % 3 === 2 ? v : 3 * v); }; + wide(f.state.previousImage); wide(f.state.currentImage); + return f; + } + it("accepts different merged reconstructions that strict rejects", () => { + const f = relaxed(); const s = new SliderAutomaticMotion(); + f.state.previousImage.mergeId = "1278340129"; + f.state.currentImage.mergeId = "1085082133386200"; + expect(resolve(s, f)).toBe(Mode.Motion); + f.configuration.automaticMotionPolicy = "strict"; + expect(resolve(s, f)).toBe(Mode.Stationary); + f.configuration.automaticMotionPolicy = "relaxed"; + expect(resolve(s, f)).toBe(Mode.Motion); + s.dispose(); + }); + it("accepts baselines up to the 12m Time Travel radius and rejects beyond", () => { + const f = relaxed(); const s = new SliderAutomaticMotion(); + f.state.currentImage.mergeId = "other-merge"; + // A distant facade keeps both photos overlapping across 12m. + const far = (i: any) => { i.mesh.vertices = i.mesh.vertices.map((v: number) => 3 * v); }; + far(f.state.previousImage); far(f.state.currentImage); + for (const baseline of [2.01, 5, 7, 12]) { + f.state.currentTransform = transform(f.state.currentImage, baseline); + expect(resolve(s, f)).toBe(Mode.Motion); + } + f.state.currentTransform = transform(f.state.currentImage, 12.01); + expect(resolve(s, f)).toBe(Mode.Stationary); + s.dispose(); + }); + it("still fails closed without meshes, merged poses, perspective cameras or coverage", () => { + const cases: Array<(f: any) => void> = [ + f => { f.state.previousImage.merged = false; }, + f => { f.state.currentImage.mesh = null; }, + f => { f.state.currentImage.cameraType = "spherical"; }, + f => { f.state.previousImage.mesh.faces = [0, 0, 0]; }, + f => { f.camera.lookAt(20, 0, 2); f.camera.updateMatrix(); f.camera.updateMatrixWorld(true); }, + ]; + for (const apply of cases) { + const f = relaxed(); const s = new SliderAutomaticMotion(); + apply(f); + expect(resolve(s, f)).toBe(Mode.Stationary); + s.dispose(); + } + }); + it("rejects reconstructions that disagree on surface depth (e.g. a garage under a street)", () => { + const f = relaxed(); const s = new SliderAutomaticMotion(); + f.state.currentImage.mergeId = "other-merge"; + expect(resolve(s, f)).toBe(Mode.Motion); + f.state.currentImage.mesh = { ...f.state.currentImage.mesh, + vertices: f.state.currentImage.mesh.vertices.map((v: number, n: number) => n % 3 === 2 ? 50 : 2.5 * v) }; + expect(resolve(s, f)).toBe(Mode.Stationary); + s.dispose(); + }); + it("keeps explicit Stationary as manual 2D", () => { + const f = relaxed(); const s = new SliderAutomaticMotion(); + f.configuration.mode = Mode.Stationary; + expect(resolve(s, f)).toBe(Mode.Stationary); + s.dispose(); + }); + }); it("permits covered small baselines but rejects 7m even with the same cluster and merge component", () => { const f = fixture(); const s = new SliderAutomaticMotion(); for (const baseline of [0, 0.5, 1, 2]) { diff --git a/test/component/slider/SliderViewCorrection.test.ts b/test/component/slider/SliderViewCorrection.test.ts index 043c6646c..a920e97bc 100644 --- a/test/component/slider/SliderViewCorrection.test.ts +++ b/test/component/slider/SliderViewCorrection.test.ts @@ -134,6 +134,53 @@ describe("Slider view-aligned image-based correction", () => { expect(r._getPreviousPerspective(live).matrix.elements).toEqual(before.elements); r.dispose(); }); + it("aligns panorama look-around by the correction instead of the compass heading", () => { + for (const gravityAligned of [false, true]) { + for (const mode of [SliderConfigurationMode.Motion, SliderConfigurationMode.Stationary]) { + const r = new SliderGLRenderer() as any; + const f = frame("spherical", "spherical"); + f.state.currentImage.compassAngle = 10; + f.state.previousImage.compassAngle = 70; + r.update(f, mode, gravityAligned, false); + expect(r.disabled).toBe(false); + const live = liveCamera(); + const uncorrected = r._getPreviousPerspective(live).quaternion.clone(); + const correction = new THREE.Quaternion().setFromEuler(new THREE.Euler(0.02, -0.03, 0.04)); + r._needsRender = false; + r.updateViewCorrection(correction.toArray()); + expect(r.needsRender).toBe(true); + const current = r._getCurrentPerspective(live); + const previous = r._getPreviousPerspective(live); + // Shares the (possibly levelled) live view; only rotated by C^-1. + expect(previous.position.toArray()).toEqual(current.position.toArray()); + expect(previous.projectionMatrix.elements).toEqual(live.projectionMatrix.elements); + const ray = new THREE.Vector3(-0.3, 0.1, -1).normalize(); + const liveRay = ray.clone().applyQuaternion(current.quaternion); + const backgroundRay = ray.clone().applyQuaternion(previous.quaternion); + expect(backgroundRay.applyQuaternion(correction).distanceTo(liveRay)).toBeCloseTo(0, 10); + // Follows the live view as it moves. + live.lookAt(-3, 1, 4); + live.updateMatrixWorld(true); + const moved = r._getCurrentPerspective(live).quaternion.clone(); + expect(r._getPreviousPerspective(live).quaternion.angleTo( + correction.clone().invert().multiply(moved))).toBeCloseTo(0, 10); + // Clearing restores the existing behaviour exactly. + live.copy(liveCamera()); + r.updateViewCorrection(null); + expect(r._getPreviousPerspective(live).quaternion.angleTo(uncorrected)).toBeCloseTo(0, 10); + r.dispose(); + } + } + }); + it("leaves mixed panorama/photo look-around untouched", () => { + const r = new SliderGLRenderer() as any; + r.update(frame("perspective", "spherical"), SliderConfigurationMode.Stationary, false, false); + const live = liveCamera(); + const before = r._getPreviousPerspective(live).quaternion.clone(); + r.updateViewCorrection(new THREE.Quaternion().setFromEuler(new THREE.Euler(0, 0, 0.05)).toArray()); + expect(r._getPreviousPerspective(live).quaternion.angleTo(before)).toBeCloseTo(0, 10); + r.dispose(); + }); it("propagates configuration changes to the renderer once per distinct value", () => { const spy = spyOn(SliderGLRenderer.prototype, "updateViewCorrection").and.callThrough(); const f = componentFixture(); diff --git a/test/component/slider/SliderViewScale.test.ts b/test/component/slider/SliderViewScale.test.ts new file mode 100644 index 000000000..50213808e --- /dev/null +++ b/test/component/slider/SliderViewScale.test.ts @@ -0,0 +1,142 @@ +import * as THREE from "three"; +import { Subject } from "rxjs"; +import { SliderConfigurationMode } from "../../../src/component/interfaces/SliderConfiguration"; +import { SliderComponent } from "../../../src/component/slider/SliderComponent"; +import { SliderDOMRenderer } from "../../../src/component/slider/SliderDOMRenderer"; +import { SliderGLRenderer } from "../../../src/component/slider/SliderGLRenderer"; +import { SliderTransform } from "../../../src/component/slider/SliderTransform"; +import { Camera } from "../../../src/geo/Camera"; +import { State } from "../../../src/state/State"; +import { ContainerMockCreator } from "../../helper/ContainerMockCreator"; +import { NavigatorMockCreator } from "../../helper/NavigatorMockCreator"; +import { MockCreator } from "../../helper/MockCreator"; + +function image(id: string, cameraType: string, width: number, height: number): any { + return { + id, cameraType, width, height, scale: 1, exifOrientation: 1, + rotation: id === "current" ? [Math.PI / 2, 0, 0] : [Math.PI / 2, 0.05, 0], + camera: { type: cameraType, parameters: { focal: 0.62, k1: 0.02, k2: -0.005 } }, + mesh: { vertices: [], faces: [] }, image: document.createElement("img"), + }; +} +function transform(i: any, translation: number[]): any { + return new SliderTransform(i.exifOrientation, i.width, i.height, i.scale, + i.rotation, translation, i.image, undefined, + Object.values(i.camera.parameters) as number[], i.cameraType); +} +function frame(currentType = "perspective", previousType = "spherical"): any { + const currentImage = image("current", currentType, 4000, 3000); + const previousImage = image("previous", previousType, 4000, 2000); + const currentTransform = transform(currentImage, [-2, -3, -4]); + const previousTransform = transform(previousImage, [-2, -13, -4]); + return { id: 7, state: { + currentImage, previousImage, currentTransform, previousTransform, + previousCamera: new Camera(previousTransform), motionless: false, + } }; +} +function liveCamera(): THREE.PerspectiveCamera { + const camera = new THREE.PerspectiveCamera(43, 1.7, 0.2, 2000); + camera.position.set(2, 3, 4); + camera.up.set(0, 0, 1); + camera.lookAt(3, 5, 4.5); + camera.zoom = 1.4; + camera.setViewOffset(2000, 1400, 100, 80, 1800, 1200); + camera.updateMatrixWorld(true); + camera.matrixAutoUpdate = false; + return camera; +} +/** Screen position (NDC) of a camera-space direction. */ +function project(camera: THREE.PerspectiveCamera, direction: THREE.Vector3): THREE.Vector3 { + return direction.clone().applyQuaternion(camera.quaternion).add(camera.position).project(camera); +} + +describe("Slider view-aligned panorama widening", () => { + it("widens the panorama background about the view centre", () => { + const r = new SliderGLRenderer() as any; + r.update(frame(), SliderConfigurationMode.Stationary, false, true); + const live = liveCamera(); + const plain = r._getPreviousPerspective(live).clone(); + r._needsRender = false; + r.updateViewScale(2); + expect(r.needsRender).toBe(true); + const wide = r._getPreviousPerspective(live); + // Same direction and origin; only the projection changes. + expect(wide.quaternion.angleTo(plain.quaternion)).toBeCloseTo(0, 10); + expect(wide.position.toArray()).toEqual(plain.position.toArray()); + const ray = new THREE.Vector3(0.1, -0.05, -1); + const a = project(plain, ray), b = project(wide, ray); + const centre = project(plain, new THREE.Vector3(0, 0, -1)); + expect(project(wide, new THREE.Vector3(0, 0, -1)).distanceTo(centre)).toBeCloseTo(0, 10); + // A direction lands half as far from the view centre. + expect(b.x - centre.x).toBeCloseTo((a.x - centre.x) / 2, 8); + expect(b.y - centre.y).toBeCloseTo((a.y - centre.y) / 2, 8); + const identity = wide.projectionMatrix.clone().multiply(wide.projectionMatrixInverse); + expect(identity.elements.map((v: number) => (Math.round(v * 1e9) / 1e9) || 0)) + .toEqual(new THREE.Matrix4().elements); + // The live camera is untouched. + expect(live.projectionMatrix.elements).toEqual(liveCamera().projectionMatrix.elements); + r.dispose(); + }); + it("follows the live zoom and restores the plain view when cleared", () => { + const r = new SliderGLRenderer() as any; + r.update(frame(), SliderConfigurationMode.Stationary, false, true); + const live = liveCamera(); + const plain = r._getPreviousPerspective(live).projectionMatrix.clone(); + r.updateViewScale(3); + expect(r._getPreviousPerspective(live).zoom).toBeCloseTo(1.4 / 3, 10); + live.zoom = 2.8; + live.updateProjectionMatrix(); + expect(r._getPreviousPerspective(live).zoom).toBeCloseTo(2.8 / 3, 10); + live.copy(liveCamera()); + r.updateViewScale(1); + expect(r._getPreviousPerspective(live).projectionMatrix.elements).toEqual(plain.elements); + r.dispose(); + }); + it("ignores invalid values and deduplicates equal ones", () => { + const r = new SliderGLRenderer() as any; + for (const invalid of [undefined, null, NaN, 0, -1, 0.2, 5, Infinity]) { + r.updateViewScale(invalid); + expect(r._viewScale).toBe(1); + } + r.updateViewScale(1.5); + r._needsRender = false; + r.updateViewScale(1.5); + expect(r.needsRender).toBe(false); + r.dispose(); + }); + it("leaves perspective backgrounds and look-around untouched", () => { + for (const [previousType, viewAligned] of [["perspective", true], ["spherical", false]] as [string, boolean][]) { + const r = new SliderGLRenderer() as any; + r.update(frame("perspective", previousType), SliderConfigurationMode.Stationary, false, viewAligned); + const live = liveCamera(); + const before = r._getPreviousPerspective(live).projectionMatrix.clone(); + r.updateViewScale(2); + expect(r._getPreviousPerspective(live).projectionMatrix.elements).toEqual(before.elements); + r.dispose(); + } + }); + it("propagates configuration changes to the renderer once per distinct value", () => { + const container = new ContainerMockCreator().create(); + const navigator = new NavigatorMockCreator().create(); + new MockCreator().mockProperty(container, "domContainer", document.createElement("div")); + spyOn(SliderDOMRenderer.prototype, "render").and.returnValue({} as any); + spyOn(SliderGLRenderer.prototype, "update"); + const spy = spyOn(SliderGLRenderer.prototype, "updateViewScale").and.callThrough(); + const component = new SliderComponent("slider", container, navigator); + component.configure({ mode: SliderConfigurationMode.Stationary }); + component.activate(); + (container.renderService.size$ as Subject).next({ width: 1440, height: 900 }); + (navigator.stateService.state$ as Subject).next(State.Traversing); + (navigator.stateService.currentState$ as Subject).next(frame()); + const initial = spy.calls.count(); + expect(spy.calls.mostRecent().args[0]).toBe(1); + component.configure({ viewAligned: true, viewScale: 2.5 }); + expect(spy.calls.count()).toBe(initial + 1); + expect(spy.calls.mostRecent().args[0]).toBe(2.5); + component.configure({ viewScale: 2.5 }); + expect(spy.calls.count()).toBe(initial + 1); + component.configure({ viewScale: 1 }); + expect(spy.calls.mostRecent().args[0]).toBe(1); + component.deactivate(); + }); +}); From e2443c8819d5644aa6a111f96ce2f3733abc3464 Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Mon, 2 Nov 2026 08:39:40 +0100 Subject: [PATCH 15/16] Zoom in half steps from the zoom buttons and keyboard Button and keyboard zoom now step 1x -> 1.5x -> 2x ... instead of doubling each time (`ZoomSteps`, `stepZoom`). --- src/component/keyboard/KeyZoomHandler.ts | 2 +- src/component/zoom/ZoomComponent.ts | 2 +- src/state/StateContext.ts | 4 ++ src/state/StateService.ts | 12 +++++ src/state/ZoomSteps.ts | 54 +++++++++++++++++++ src/state/interfaces/IStateContext.ts | 1 + src/state/state/InteractiveStateBase.ts | 11 ++++ src/state/state/StateBase.ts | 2 + .../component/keyboard/KeyZoomHandler.test.ts | 8 +-- test/state/ZoomSteps.test.ts | 41 ++++++++++++++ 10 files changed, 131 insertions(+), 6 deletions(-) create mode 100755 src/state/ZoomSteps.ts create mode 100755 test/state/ZoomSteps.test.ts diff --git a/src/component/keyboard/KeyZoomHandler.ts b/src/component/keyboard/KeyZoomHandler.ts index 12522a28c..e9899b205 100644 --- a/src/component/keyboard/KeyZoomHandler.ts +++ b/src/component/keyboard/KeyZoomHandler.ts @@ -73,7 +73,7 @@ export class KeyZoomHandler extends HandlerBase { const unprojected: THREE.Vector3 = this._viewportCoords.unprojectFromViewport(0, 0, render.perspective); const reference: number[] = transform.projectBasic(unprojected.toArray()); - this._navigator.stateService.zoomIn(delta, reference); + this._navigator.stateService.stepZoom(delta, reference); }); } diff --git a/src/component/zoom/ZoomComponent.ts b/src/component/zoom/ZoomComponent.ts index 6b7dca918..02fb98557 100644 --- a/src/component/zoom/ZoomComponent.ts +++ b/src/component/zoom/ZoomComponent.ts @@ -123,7 +123,7 @@ export class ZoomComponent extends Component { const unprojected: THREE.Vector3 = this._viewportCoords.unprojectFromViewport(0, 0, render.perspective); const reference: number[] = transform.projectBasic(unprojected.toArray()); - this._navigator.stateService.zoomIn(zoomDelta, reference); + this._navigator.stateService.stepZoom(zoomDelta, reference); })); } diff --git a/src/state/StateContext.ts b/src/state/StateContext.ts index ad0c2e403..c2203e388 100644 --- a/src/state/StateContext.ts +++ b/src/state/StateContext.ts @@ -236,6 +236,10 @@ export class StateContext implements IStateContext { this._state.zoomIn(delta, reference); } + public stepZoom(steps: number, reference: number[]): void { + this._state.stepZoom(steps, reference); + } + public setSpeed(speed: number): void { this._state.setSpeed(speed); } diff --git a/src/state/StateService.ts b/src/state/StateService.ts index 9b43e68f0..005947557 100644 --- a/src/state/StateService.ts +++ b/src/state/StateService.ts @@ -521,6 +521,18 @@ export class StateService { this._invokeContextOperation((context: IStateContext) => { context.zoomIn(delta, reference); }); } + /** + * Move between discrete zoom stops (1x, 1.5x, 2x, 3x, 4x, 6x, 8x) while + * keeping the reference point position approximately static. + * + * @parameter {number} steps - Number of stops; negative zooms out. + * @parameter {Array} reference - Reference point in basic coordinates. + */ + public stepZoom(steps: number, reference: number[]): void { + this._inMotionOperation$.next(true); + this._invokeContextOperation((context: IStateContext) => { context.stepZoom(steps, reference); }); + } + public getCenter(): Observable { return this._lastState$.pipe( first(), diff --git a/src/state/ZoomSteps.ts b/src/state/ZoomSteps.ts new file mode 100755 index 000000000..b3219a1fa --- /dev/null +++ b/src/state/ZoomSteps.ts @@ -0,0 +1,54 @@ +/** + * Discrete zoom stops used by the zoom buttons and keyboard zoom. + * + * Each former power-of-two step (1x, 2x, 4x, 8x) is split in half, adding + * the linear midpoint between neighbouring powers of two: 1x, 1.5x, 2x, 3x, + * 4x, 6x, 8x. Zoom is logarithmic, so a magnification m is zoom log2(m). + */ +const HALF_STEP: number = Math.log2(1.5); + +/** + * Minimum logarithmic distance to the next stop. Continuous zoom (scroll, + * pinch) can leave the camera just short of a stop; a step always moves by + * a visible amount instead of snapping a few percent. + */ +const MIN_STEP: number = 0.2; + +export function zoomStops(minZoom: number, maxZoom: number): number[] { + const stops: number[] = []; + for (let level = Math.ceil(minZoom); level <= maxZoom; level++) { + stops.push(level); + if (level + HALF_STEP < maxZoom) { + stops.push(level + HALF_STEP); + } + } + if (stops.length === 0 || stops[0] > minZoom) { + stops.unshift(minZoom); + } + if (stops[stops.length - 1] < maxZoom) { + stops.push(maxZoom); + } + return stops; +} + +/** + * Returns the zoom reached after moving the given number of stops from zoom. + * Positive steps zoom in, negative steps zoom out; the result is clamped. + */ +export function stepZoom( + zoom: number, + steps: number, + minZoom: number, + maxZoom: number): number { + const stops: number[] = zoomStops(minZoom, maxZoom); + let target: number = Math.max(minZoom, Math.min(maxZoom, zoom)); + const direction: number = Math.sign(steps); + for (let i = 0; i < Math.abs(Math.round(steps)); i++) { + const next: number = direction > 0 ? + stops.find((stop: number): boolean => stop > target + MIN_STEP) : + [...stops].reverse().find((stop: number): boolean => stop < target - MIN_STEP); + target = next !== undefined ? + next : direction > 0 ? maxZoom : minZoom; + } + return target; +} diff --git a/src/state/interfaces/IStateContext.ts b/src/state/interfaces/IStateContext.ts index d4323b6d6..7bc9bad73 100644 --- a/src/state/interfaces/IStateContext.ts +++ b/src/state/interfaces/IStateContext.ts @@ -38,6 +38,7 @@ export interface IStateContext extends IAnimationState { move(delta: number): void; moveTo(position: number): void; zoomIn(delta: number, reference: number[]): void; + stepZoom(steps: number, reference: number[]): void; getCenter(): number[]; setCenter(center: number[]): void; diff --git a/src/state/state/InteractiveStateBase.ts b/src/state/state/InteractiveStateBase.ts index 052e4eaa5..f94319a28 100644 --- a/src/state/state/InteractiveStateBase.ts +++ b/src/state/state/InteractiveStateBase.ts @@ -1,4 +1,5 @@ import * as THREE from "three"; +import { stepZoom } from "../ZoomSteps"; import { StateBase } from "./StateBase"; @@ -241,6 +242,16 @@ export abstract class InteractiveStateBase extends StateBase { .fromArray(this.currentTransform.unprojectBasic(basic, this._lookatDepth)); } + public stepZoom(steps: number, reference: number[]): void { + if (this._currentImage == null) { + return; + } + + const target: number = + stepZoom(this._desiredZoom, steps, this._minZoom, this._maxZoom); + this.zoomIn(target - this._desiredZoom, reference); + } + public zoomIn(delta: number, reference: number[]): void { if (this._currentImage == null) { return; diff --git a/src/state/state/StateBase.ts b/src/state/state/StateBase.ts index 62c4bad64..863fde0d6 100644 --- a/src/state/state/StateBase.ts +++ b/src/state/state/StateBase.ts @@ -216,6 +216,8 @@ export abstract class StateBase implements IStateBase { public zoomIn(delta: number, reference: number[]): void { /*noop*/ } + public stepZoom(steps: number, reference: number[]): void { /*noop*/ } + public update(delta: number): void { /*noop*/ } public setCenter(center: number[]): void { /*noop*/ } diff --git a/test/component/keyboard/KeyZoomHandler.test.ts b/test/component/keyboard/KeyZoomHandler.test.ts index 0ede3f37f..75645887d 100644 --- a/test/component/keyboard/KeyZoomHandler.test.ts +++ b/test/component/keyboard/KeyZoomHandler.test.ts @@ -115,7 +115,7 @@ describe("KeyZoomHandler.enable", () => { (>containerMock.keyboardService.keyDown$).next(keyboardEvent); expect(preventDefaultSpyMeta.calls.count()).toBe(0); - const zoomInSpy = navigatorMock.stateService.zoomIn; + const zoomInSpy = navigatorMock.stateService.stepZoom; expect(zoomInSpy.calls.count()).toBe(0); }); @@ -153,7 +153,7 @@ describe("KeyZoomHandler.enable", () => { .next(keyboardEvent); expect(preventDefaultSpyShift.calls.count()).toBe(1); - const zoomInSpy = navigatorMock.stateService.zoomIn; + const zoomInSpy = navigatorMock.stateService.stepZoom; expect(zoomInSpy.calls.count()).toBe(1); }); @@ -218,7 +218,7 @@ describe("KeyZoomHandler.enable", () => { const keyboardEvent = EventHelper.createKeyboardEvent("keydown", { key: "+" }); (>containerMock.keyboardService.keyDown$).next(keyboardEvent); - const zoomInSpy = navigatorMock.stateService.zoomIn; + const zoomInSpy = navigatorMock.stateService.stepZoom; expect(zoomInSpy.calls.count()).toBe(1); expect(zoomInSpy.calls.first().args[0]).toBe(1); }); @@ -252,7 +252,7 @@ describe("KeyZoomHandler.enable", () => { const keyboardEvent = EventHelper.createKeyboardEvent("keydown", { key: "-" }); (>containerMock.keyboardService.keyDown$).next(keyboardEvent); - const zoomInSpy = navigatorMock.stateService.zoomIn; + const zoomInSpy = navigatorMock.stateService.stepZoom; expect(zoomInSpy.calls.count()).toBe(1); expect(zoomInSpy.calls.first().args[0]).toBe(-1); }); diff --git a/test/state/ZoomSteps.test.ts b/test/state/ZoomSteps.test.ts new file mode 100755 index 000000000..eaa2b6f56 --- /dev/null +++ b/test/state/ZoomSteps.test.ts @@ -0,0 +1,41 @@ +import { stepZoom, zoomStops } from "../../src/state/ZoomSteps"; + +const scale = (zoom: number): number => Math.round(Math.pow(2, zoom) * 1000) / 1000; + +describe("zoom stops", () => { + it("splits each power-of-two step in half", () => { + expect(zoomStops(0, 3).map(scale)).toEqual([1, 1.5, 2, 3, 4, 6, 8]); + }); + + it("zooms in one half step at a time", () => { + let zoom = 0; + const visited: number[] = []; + for (let i = 0; i < 7; i++) { + zoom = stepZoom(zoom, 1, 0, 3); + visited.push(scale(zoom)); + } + expect(visited).toEqual([1.5, 2, 3, 4, 6, 8, 8]); + }); + + it("zooms out through the same stops", () => { + let zoom = 3; + const visited: number[] = []; + for (let i = 0; i < 7; i++) { + zoom = stepZoom(zoom, -1, 0, 3); + visited.push(scale(zoom)); + } + expect(visited).toEqual([6, 4, 3, 2, 1.5, 1, 1]); + }); + + it("always takes a visible step after continuous zoom", () => { + expect(scale(stepZoom(Math.log2(1.45), 1, 0, 3))).toBe(2); + expect(scale(stepZoom(Math.log2(2.1), -1, 0, 3))).toBe(1.5); + expect(scale(stepZoom(Math.log2(1.8), 1, 0, 3))).toBe(3); + }); + + it("supports multiple steps and clamps", () => { + expect(scale(stepZoom(0, 2, 0, 3))).toBe(2); + expect(scale(stepZoom(5, 1, 0, 3))).toBe(8); + expect(scale(stepZoom(-1, -1, 0, 3))).toBe(1); + }); +}); From ddebada086b2a790a82d0c4ffa4830c2c63a5cef Mon Sep 17 00:00:00 2001 From: Boris Masis Date: Mon, 2 Nov 2026 12:22:55 +0100 Subject: [PATCH 16/16] Fix lint and strict typing in new slider, pointer and reorientation code Align wrapped arguments, split multi-variable declarations and long lines, add missing typedefs, fix JSDoc formatting and a shadowed parameter, and type the missing-image guard cases so the build passes noImplicitAny. No behavior change. --- src/component/slider/SliderAutomaticMotion.ts | 14 +++++---- src/component/slider/SliderComponent.ts | 13 +++++---- src/component/slider/SliderGLRenderer.ts | 6 ++-- src/component/slider/SliderReconstruction.ts | 6 ++-- .../pointer/ImageBoundaryViewBounds.test.ts | 7 +++-- .../reorientation/PerspectiveLevel.test.ts | 2 +- .../ReorientationAdoptedView.test.ts | 2 +- test/component/slider/SliderAlignment.test.ts | 4 +-- .../slider/SliderAutomaticMotion.test.ts | 29 +++++++++++++------ .../slider/SliderFisheyeAxis.test.ts | 2 +- test/component/slider/SliderPosition.test.ts | 2 +- test/component/slider/SliderRoll.test.ts | 6 ++-- .../slider/SliderSelectionFraming.test.ts | 5 ++-- test/component/slider/SliderSoftEdge.test.ts | 2 +- .../slider/SliderTextureHandoff.test.ts | 16 ++++++---- .../slider/SliderViewAligned.test.ts | 8 ++--- .../slider/SliderViewCorrection.test.ts | 6 ++-- test/component/slider/SliderViewScale.test.ts | 9 +++--- 18 files changed, 83 insertions(+), 56 deletions(-) diff --git a/src/component/slider/SliderAutomaticMotion.ts b/src/component/slider/SliderAutomaticMotion.ts index b0f9f37e5..99ad2ce95 100644 --- a/src/component/slider/SliderAutomaticMotion.ts +++ b/src/component/slider/SliderAutomaticMotion.ts @@ -8,8 +8,10 @@ import { SliderMeshFactory } from "./SliderMeshFactory"; import { SliderTransform } from "./SliderTransform"; import { sliderReconstructionImage, sliderPairKey, sliderCameraOrigin } from "./SliderReconstruction"; -/** A camera-only translation: never interpolate direction/up or regenerate the - * live projection (including its off-center crop). Curtain 0 is background. */ +/** + * A camera-only translation: never interpolate direction/up or regenerate the + * live projection (including its off-center crop). Curtain 0 is background. + */ export function translatedSliderPerspective( live: THREE.PerspectiveCamera, previousOrigin: THREE.Vector3, @@ -70,13 +72,13 @@ export function sliderMotionLimits(configuration: SliderConfiguration): SliderMo export class SliderAutomaticMotion { private _limits: SliderMotionLimits = STRICT_LIMITS; - private _factory = new SliderMeshFactory(); + private _factory: SliderMeshFactory = new SliderMeshFactory(); private _pair: string; private _meshes: object[] = []; private _geometries: THREE.BufferGeometry[] = []; private _transforms: SliderTransform[] = []; private _triangles: THREE.Triangle[][] = []; - private _coverage = new Map(); + private _coverage: Map = new Map(); private _origins: THREE.Vector3[] = []; private _prepared: boolean = false; @@ -167,8 +169,8 @@ export class SliderAutomaticMotion { !t.rt.elements.every(Number.isFinite) || Math.abs(t.rt.determinant()) < 1e-8) { return false; } const e = t.rt.elements; this._transforms.push(new SliderTransform(i.exifOrientation, i.width, i.height, i.scale, - i.rotation, [e[12], e[13], e[14]], i.image, undefined, - Object.values(i.camera.parameters), "perspective")); + i.rotation, [e[12], e[13], e[14]], i.image, undefined, + Object.values(i.camera.parameters), "perspective")); } this._origins = transforms.map(sliderCameraOrigin); const distance = this._origins[0].distanceTo(this._origins[1]); diff --git a/src/component/slider/SliderComponent.ts b/src/component/slider/SliderComponent.ts index 92eba84b0..7b73a3b6f 100644 --- a/src/component/slider/SliderComponent.ts +++ b/src/component/slider/SliderComponent.ts @@ -207,7 +207,7 @@ export class SliderComponent extends Component { distinctUntilChanged(), switchMap(automatic => automatic ? observableCombineLatest( observableMerge(this._container.renderService.renderCamera$, - this._container.renderService.renderCameraFrame$), + this._container.renderService.renderCameraFrame$), this._container.renderService.size$).pipe( map(([render, size]: [RenderCamera, ViewportSize]) => ({ render, size, @@ -260,7 +260,8 @@ export class SliderComponent extends Component { // AnimationFrame.state can be a shared mutable StateContext. Copy // primitives BEFORE distinct, never compare aliases to old frames. map(d => ({ - mode: d.mode, automatic: d.automatic, viewAligned: d.configuration.viewAligned === true, motionless: d.frame.state.motionless, + mode: d.mode, automatic: d.automatic, + viewAligned: d.configuration.viewAligned === true, motionless: d.frame.state.motionless, paired: !!d.frame.state.currentImage && !!d.frame.state.previousImage, spherical: !!d.frame.state.currentImage && isSpherical(d.frame.state.currentImage.cameraType), key: JSON.stringify([d.mode, d.automatic, d.configuration.viewAligned === true, d.frame.state.motionless, @@ -326,7 +327,9 @@ export class SliderComponent extends Component { position$, decision$, sliderVisible$, this._container.renderService.size$).pipe( map(([position, decision, sliderVisible]): VirtualNodeHash => ({ name: this._name, - vNode: this._domRenderer.render(position, decision.mode, + vNode: this._domRenderer.render( + position, + decision.mode, decision.frame.state.motionless || decision.stationaryOnly || decision.automatic && !decision.eligible, !!decision.frame.state.currentImage && isSpherical(decision.frame.state.currentImage.cameraType), sliderVisible), @@ -385,10 +388,10 @@ export class SliderComponent extends Component { glRenderer.updateCurtainFeather(c.curtainFeather); if (decision.automatic) { glRenderer.update(decision.frame, decision.mode, - c.gravityAligned === true, c.viewAligned === true, true); + c.gravityAligned === true, c.viewAligned === true, true); } else { glRenderer.update(decision.frame, decision.mode, - c.gravityAligned === true, c.viewAligned === true); + c.gravityAligned === true, c.viewAligned === true); } return glRenderer; })) diff --git a/src/component/slider/SliderGLRenderer.ts b/src/component/slider/SliderGLRenderer.ts index d6555ab6f..b49ca5705 100644 --- a/src/component/slider/SliderGLRenderer.ts +++ b/src/component/slider/SliderGLRenderer.ts @@ -250,7 +250,7 @@ export class SliderGLRenderer { if (this._viewAligned && this._mode === SliderConfigurationMode.Motion && this._motionOrigins.length === 2) { return translatedSliderPerspective(perspectiveCamera, - this._motionOrigins[0], this._motionOrigins[1], this._curtain); + this._motionOrigins[0], this._motionOrigins[1], this._curtain); } if (this._viewAligned || !this._currentGravityAligned) { return perspectiveCamera; @@ -290,8 +290,8 @@ export class SliderGLRenderer { // Keep the comparison's native frame upright like the original's, // not tilted by the two reconstructions' differing roll estimates. const roll = sliderViewAlignedRoll(current, - this._currentNativeUp, this._previousNativeUp, - this._previousViewAlignedLevel(current.aspect)); + this._currentNativeUp, this._previousNativeUp, + this._previousViewAlignedLevel(current.aspect)); if (this._mode === SliderConfigurationMode.Motion) { this._previousPerspective.matrixAutoUpdate = false; this._previousPerspective.matrix.copy(current.matrixWorld) diff --git a/src/component/slider/SliderReconstruction.ts b/src/component/slider/SliderReconstruction.ts index b9e9572fc..dbd934509 100644 --- a/src/component/slider/SliderReconstruction.ts +++ b/src/component/slider/SliderReconstruction.ts @@ -33,8 +33,10 @@ export function sliderPairKey(state: IAnimationState): string { })]); } -/** Read the actual current extrinsics, not a cached inverse which may predate - * an in-place RT update. Used by coverage, render translation and tile ROI. */ +/** + * Read the actual current extrinsics, not a cached inverse which may predate + * an in-place RT update. Used by coverage, render translation and tile ROI. + */ export function sliderCameraOrigin(transform: Transform): THREE.Vector3 { return new THREE.Vector3().setFromMatrixPosition(transform.rt.clone().invert()); } diff --git a/test/component/pointer/ImageBoundaryViewBounds.test.ts b/test/component/pointer/ImageBoundaryViewBounds.test.ts index dab0add45..323e6a6e5 100644 --- a/test/component/pointer/ImageBoundaryViewBounds.test.ts +++ b/test/component/pointer/ImageBoundaryViewBounds.test.ts @@ -14,7 +14,7 @@ function transform(cameraType: CameraType): Transform { const camera = new ProjectionService() .makeCamera(cameraType, spherical ? [] : [0.85, 0, 0]); return new Transform(1, spherical ? 4096 : 4000, spherical ? 2048 : 3000, 1, - [0, 0, 0], [0, 0, 0], null, camera); + [0, 0, 0], [0, 0, 0], null, camera); } /** A render camera at the image position looking at a basic point. */ @@ -99,7 +99,8 @@ describe("ImageBoundary.viewportDistances with view bounds", () => { const t = transform(cameraType); const bounds = { center, fieldOfView: 30 }; const rect = ImageBoundary.viewBoundsBasic(t, render(t, center, 15), bounds); - const width = rect[2] - rect[0], height = rect[3] - rect[1]; + const width = rect[2] - rect[0]; + const height = rect[3] - rect[1]; const cases: [number[], number][] = [ // Straddling each window edge: that viewport side shows outside. [[center[0] + width / 2, center[1]], 1], @@ -119,7 +120,7 @@ describe("ImageBoundary.viewportDistances with view bounds", () => { const t = transform(cameraType); const p = render(t, center, 40); const distances = ImageBoundary.viewportDistances(t, p, coords, - ImageBoundary.viewBoundsBasic(t, p, { center, fieldOfView: 20 })); + ImageBoundary.viewBoundsBasic(t, p, { center, fieldOfView: 20 })); expect(distances[1] - distances[3]).toBeCloseTo(0, 2); expect(distances[0] - distances[2]).toBeCloseTo(0, 2); }); diff --git a/test/component/reorientation/PerspectiveLevel.test.ts b/test/component/reorientation/PerspectiveLevel.test.ts index 45604c7da..e3b838c40 100644 --- a/test/component/reorientation/PerspectiveLevel.test.ts +++ b/test/component/reorientation/PerspectiveLevel.test.ts @@ -10,7 +10,7 @@ import { Transform } from "../../../src/geo/Transform"; import { ProjectionService } from "../../../src/viewer/ProjectionService"; function makeTransform(rotation: number[], parameters: number[] = [0.85, 0, 0], - orientation: number = 1, width: number = 4000, height: number = 3000): Transform { + orientation: number = 1, width: number = 4000, height: number = 3000): Transform { const camera = new ProjectionService().makeCamera("perspective", parameters); return new Transform(orientation, width, height, 1, rotation, [0, 0, 0], null, camera); } diff --git a/test/component/reorientation/ReorientationAdoptedView.test.ts b/test/component/reorientation/ReorientationAdoptedView.test.ts index 82a260bf2..73e4ce0fb 100644 --- a/test/component/reorientation/ReorientationAdoptedView.test.ts +++ b/test/component/reorientation/ReorientationAdoptedView.test.ts @@ -1,7 +1,7 @@ import { of, Subject } from "rxjs"; import { ReorientationComponent } from "../../../src/component/reorientation/ReorientationComponent"; -function fixture() { +function fixture(): any { const component: any = Object.create(ReorientationComponent.prototype); const image: any = { id: "image", sequenceId: "sequence", cameraType: "spherical" }; const result: any = { valid: true, moving: true, seq: "sequence", basicX: 0.4, cca: 180, viewCompassAngle: 180 }; diff --git a/test/component/slider/SliderAlignment.test.ts b/test/component/slider/SliderAlignment.test.ts index 449730dbc..0d5b8b03c 100644 --- a/test/component/slider/SliderAlignment.test.ts +++ b/test/component/slider/SliderAlignment.test.ts @@ -49,9 +49,9 @@ describe("Slider horizon safety", () => { // Reported pair: a separate reconstruction claims a 38 deg roll for an // upright phone photo; the viewer's auto-fit refuses to level it. const camera = new ProjectionService().makeCamera("perspective", - [0.73551162611046, -0.0015303224124321, -0.018730651694032]); + [0.73551162611046, -0.0015303224124321, -0.018730651694032]); const reported = new Transform(3, 4032, 3024, 1, - [-1.2223267744317, 1.2079613891934, 0.1647357955055], [0, 0, 0], null, camera); + [-1.2223267744317, 1.2079613891934, 0.1647357955055], [0, 0, 0], null, camera); expect(canLevelSliderImage(image(undefined, "perspective"), reported)).toBe(true); expect(canLevelSliderComparison(image(undefined, "perspective"), reported, 1.6)).toBe(false); }); diff --git a/test/component/slider/SliderAutomaticMotion.test.ts b/test/component/slider/SliderAutomaticMotion.test.ts index ccad65547..9f605a786 100644 --- a/test/component/slider/SliderAutomaticMotion.test.ts +++ b/test/component/slider/SliderAutomaticMotion.test.ts @@ -24,7 +24,7 @@ function image(id: string): any { } function transform(i: any, origin: number): SliderTransform { return new SliderTransform(i.exifOrientation, i.width, i.height, i.scale, - i.rotation, [-origin, 0, 0], i.image, undefined, Object.values(i.camera.parameters), i.cameraType); + i.rotation, [-origin, 0, 0], i.image, undefined, Object.values(i.camera.parameters), i.cameraType); } function fixture(): any { const previousImage = image("previous"); @@ -151,7 +151,7 @@ describe("Slider automatic motion safety", () => { s.dispose(); }); it("still fails closed without meshes, merged poses, perspective cameras or coverage", () => { - const cases: Array<(f: any) => void> = [ + const cases: ((f: any) => void)[] = [ f => { f.state.previousImage.merged = false; }, f => { f.state.currentImage.mesh = null; }, f => { f.state.currentImage.cameraType = "spherical"; }, @@ -315,7 +315,7 @@ describe("Slider automatic rendering", () => { f.camera.matrixWorldNeedsUpdate = false; for (const position of [0, 0.5, 1]) { const translated = translatedSliderPerspective(f.camera, - new THREE.Vector3(0, 0, 0), new THREE.Vector3(2, 0, 0), position); + new THREE.Vector3(0, 0, 0), new THREE.Vector3(2, 0, 0), position); const ray = new THREE.Raycaster(); ray.setFromCamera(new THREE.Vector2(), translated); expect(translated.position.toArray()).toEqual([2 * position, 0, 0]); expect(ray.ray.origin.toArray()).toEqual([2 * position, 0, 0]); @@ -375,7 +375,10 @@ describe("Slider automatic rendering", () => { r.update(f.frame, Mode.Motion, false, true, true); expect(Array.from(r._scene.planes[0].mesh.geometry.getAttribute("position").array)).toEqual(before); // A new object is a new version and must be rejected/revalidated. - f.state.currentImage.mesh = { vertices: f.state.currentImage.mesh.vertices.slice(), faces: f.state.currentImage.mesh.faces.slice() }; + f.state.currentImage.mesh = { + vertices: f.state.currentImage.mesh.vertices.slice(), + faces: f.state.currentImage.mesh.faces.slice(), + }; expect(resolve(safety, f)).toBe(Mode.Stationary); r.dispose(); safety.dispose(); }); @@ -396,7 +399,8 @@ describe("Slider automatic rendering", () => { providers.forEach(p => expect(p.dispose).not.toHaveBeenCalled()); disposers.forEach(d => expect(d).not.toHaveBeenCalled()); } - const previousImage = f.state.previousImage, previousTransform = f.state.previousTransform; + const previousImage = f.state.previousImage; + const previousTransform = f.state.previousTransform; f.state.previousImage = null; f.state.previousTransform = null; r.update(f.frame, Mode.Motion, false, true, automatic); expect(r.disabled).toBe(true); @@ -526,7 +530,10 @@ describe("Slider view-aligned native mode roundtrip", () => { const previous = r._getPreviousPerspective(f.camera); expect(previous.position.x).toBeCloseTo(-1.25, 12); expect(new THREE.Vector3().setFromMatrixPosition(previous.matrixWorld).x).toBeCloseTo(-1.25, 12); - const component = new SliderComponent("slider", new ContainerMockCreator().create(), new NavigatorMockCreator().create()) as any; + const component = new SliderComponent( + "slider", + new ContainerMockCreator().create(), + new NavigatorMockCreator().create()) as any; const compute = spyOn(component._roiCalculator, "computeRegionOfInterest").and.returnValue({}); component._getViewAlignedRegionOfInterest({ perspective: f.camera }, f.size, f.state.previousTransform); const roiCamera = compute.calls.mostRecent().args[0].perspective; @@ -683,11 +690,13 @@ describe("Slider automatic shared decision and interactive state", () => { sameView(args[0].perspective, f.camera); }); setROI.calls.allArgs().forEach(args => expect(args[0].bbox).toEqual({ minX: 0, maxX: 1, minY: 0, maxY: 1 })); - expect(new Set(compute.calls.allArgs().map(args => args[2]))).toEqual(new Set([f.state.currentTransform, f.state.previousTransform])); + expect(new Set(compute.calls.allArgs().map(args => args[2]))) + .toEqual(new Set([f.state.currentTransform, f.state.previousTransform])); f.component.configure({ mode: Mode.Stationary }); f.component.configure({ mode: Mode.Motion }); expect(setROI.calls.count()).toBeGreaterThan(2); - const previousImage = f.state.previousImage, previousTransform = f.state.previousTransform; + const previousImage = f.state.previousImage; + const previousTransform = f.state.previousTransform; setROI.calls.reset(); compute.calls.reset(); f.state.previousImage = null; f.state.previousTransform = null; f.emitFrame(); f.component.setPosition(0.75); f.emitView(); @@ -717,7 +726,9 @@ describe("Slider automatic shared decision and interactive state", () => { f.update.and.callThrough(); const r = new SliderGLRenderer() as any; r.update(f.frame, Mode.Motion, false, true, false); r.updateCurtain(0.25, true); for (const args of compute.calls.allArgs()) { - const expected = args[2] === f.state.currentTransform ? r._getCurrentPerspective(f.camera) : r._getPreviousPerspective(f.camera); + const expected = args[2] === f.state.currentTransform ? + r._getCurrentPerspective(f.camera) : + r._getPreviousPerspective(f.camera); expect(args[0].perspective.matrixWorld.elements).toEqual(expected.matrixWorld.elements); sameView(args[0].perspective, expected); } diff --git a/test/component/slider/SliderFisheyeAxis.test.ts b/test/component/slider/SliderFisheyeAxis.test.ts index 0706eb828..1d3799d64 100644 --- a/test/component/slider/SliderFisheyeAxis.test.ts +++ b/test/component/slider/SliderFisheyeAxis.test.ts @@ -3,7 +3,7 @@ import { SliderTransform } from "../../../src/component/slider/SliderTransform"; function slider(): SliderTransform { return new SliderTransform(1, 4000, 3000, 1, [0, 0, 0], [0, 0, 0], - undefined, undefined, [0.7, 0.01, 0.02], "fisheye"); + undefined, undefined, [0.7, 0.01, 0.02], "fisheye"); } describe("Fisheye exact optical-axis projection", () => { diff --git a/test/component/slider/SliderPosition.test.ts b/test/component/slider/SliderPosition.test.ts index da2c59a6f..e024a5cb3 100644 --- a/test/component/slider/SliderPosition.test.ts +++ b/test/component/slider/SliderPosition.test.ts @@ -7,7 +7,7 @@ import { NavigatorMockCreator } from "../../helper/NavigatorMockCreator"; import { MockCreator } from "../../helper/MockCreator"; describe("SliderComponent.setPosition", () => { - function fixture() { + function fixture(): any { const container = new ContainerMockCreator().create(); const navigator = new NavigatorMockCreator().create(); new MockCreator().mockProperty(container, "domContainer", document.createElement("div")); diff --git a/test/component/slider/SliderRoll.test.ts b/test/component/slider/SliderRoll.test.ts index e37b16ec2..fb4848a79 100644 --- a/test/component/slider/SliderRoll.test.ts +++ b/test/component/slider/SliderRoll.test.ts @@ -22,7 +22,8 @@ function screenAngle(c: THREE.Camera, up: THREE.Vector3): number { describe("Slider view-aligned roll", () => { it("shows an upright comparison upright despite opposite small reconstruction rolls", () => { - const currentUp = rolledUp(3.3 * deg), previousUp = rolledUp(-3.4 * deg); + const currentUp = rolledUp(3.3 * deg); + const previousUp = rolledUp(-3.4 * deg); const live = camera(currentUp); const roll = sliderViewAlignedRoll(live, currentUp, previousUp, false); expect(Math.abs(roll)).toBeCloseTo(6.7 * deg, 6); @@ -33,7 +34,8 @@ describe("Slider view-aligned roll", () => { expect(screenAngle(comparison, previousUp)).toBeCloseTo(0, 6); }); it("keeps native alignment when the original is panned or gravity-levelled", () => { - const currentUp = rolledUp(3 * deg), previousUp = rolledUp(-4 * deg); + const currentUp = rolledUp(3 * deg); + const previousUp = rolledUp(-4 * deg); const live = camera(); const roll = sliderViewAlignedRoll(live, currentUp, previousUp, false); const comparison = live.clone(); diff --git a/test/component/slider/SliderSelectionFraming.test.ts b/test/component/slider/SliderSelectionFraming.test.ts index 840cca002..1a3d612f8 100644 --- a/test/component/slider/SliderSelectionFraming.test.ts +++ b/test/component/slider/SliderSelectionFraming.test.ts @@ -9,14 +9,15 @@ import { MockCreator } from "../../helper/MockCreator"; // Selected-pair hydration must retain the original's live wheel-zoom anchor. describe("Slider comparison selection framing", () => { - function fixture(viewAligned = true, single = false): any { + function fixture(viewAligned: boolean = true, single: boolean = false): any { const container = new ContainerMockCreator().create(); const navigator = new NavigatorMockCreator().create(); new MockCreator().mockProperty(container, "domContainer", document.createElement("div")); spyOn(SliderDOMRenderer.prototype, "render").and.returnValue({}); spyOn(SliderGLRenderer.prototype, "update"); const component = new SliderComponent("slider", container, navigator); - const background$ = new Subject(), foreground$ = new Subject(); + const background$ = new Subject(); + const foreground$ = new Subject(); spyOn(component, "_catchCacheImage$").and.callFake(id => id === "new" ? background$ : foreground$); component.activate(); (>container.renderService.size$).next({ width: 1440, height: 900 }); diff --git a/test/component/slider/SliderSoftEdge.test.ts b/test/component/slider/SliderSoftEdge.test.ts index cc155dafd..ed7ba7896 100644 --- a/test/component/slider/SliderSoftEdge.test.ts +++ b/test/component/slider/SliderSoftEdge.test.ts @@ -24,7 +24,7 @@ function transform(i: any, east: number): any { const R = new THREE.Matrix4().makeRotationAxis(new THREE.Vector3(1, 0, 0), i.rotation[0]); const t = new THREE.Vector3(-east, 0, 0).applyMatrix4(R); return new SliderTransform(i.exifOrientation, i.width, i.height, i.scale, - i.rotation, t.toArray(), i.image, undefined, Object.values(i.camera.parameters), i.cameraType); + i.rotation, t.toArray(), i.image, undefined, Object.values(i.camera.parameters), i.cameraType); } function state(): any { const previousImage = image("previous"); const currentImage = image("current"); diff --git a/test/component/slider/SliderTextureHandoff.test.ts b/test/component/slider/SliderTextureHandoff.test.ts index f1f7ffd48..0214b6a96 100644 --- a/test/component/slider/SliderTextureHandoff.test.ts +++ b/test/component/slider/SliderTextureHandoff.test.ts @@ -1,7 +1,7 @@ import { SliderGLRenderer } from "../../../src/component/slider/SliderGLRenderer"; -function plane(imageId: string, texture: any): any { - return { imageId, mesh: { material: { uniforms: { projectorTex: { value: texture } } } } }; +function plane(imageId: string, projectorTexture: any): any { + return { imageId, mesh: { material: { uniforms: { projectorTex: { value: projectorTexture } } } } }; } function texture(): any { return { dispose: jasmine.createSpy("dispose") }; @@ -9,8 +9,10 @@ function texture(): any { describe("SliderGLRenderer texture handoff", () => { test("moves the live texture to the rebuilt plane and disposes only the replaced one", () => { - const live = texture(), replaced = texture(); - const old = plane("a", live), next = plane("a", replaced); + const live = texture(); + const replaced = texture(); + const old = plane("a", live); + const next = plane("a", replaced); (new SliderGLRenderer() as any)._preserveTextures([old], [next]); expect(next.mesh.material.uniforms.projectorTex.value).toBe(live); expect(old.mesh.material.uniforms.projectorTex.value).toBeNull(); @@ -20,14 +22,16 @@ describe("SliderGLRenderer texture handoff", () => { test("does not dispose a texture shared by the old and rebuilt planes", () => { const shared = texture(); - const old = plane("a", shared), next = plane("a", shared); + const old = plane("a", shared); + const next = plane("a", shared); (new SliderGLRenderer() as any)._preserveTextures([old], [next]); expect(next.mesh.material.uniforms.projectorTex.value).toBe(shared); expect(shared.dispose).not.toHaveBeenCalled(); }); test("leaves planes for other images untouched", () => { - const live = texture(), other = texture(); + const live = texture(); + const other = texture(); const next = plane("b", other); (new SliderGLRenderer() as any)._preserveTextures([plane("a", live)], [next]); expect(next.mesh.material.uniforms.projectorTex.value).toBe(other); diff --git a/test/component/slider/SliderViewAligned.test.ts b/test/component/slider/SliderViewAligned.test.ts index 96ebd52ef..27bba01a6 100644 --- a/test/component/slider/SliderViewAligned.test.ts +++ b/test/component/slider/SliderViewAligned.test.ts @@ -22,10 +22,10 @@ function image(id: string, cameraType: string, width: number, height: number): a } function transform(i: any, translation: number[]): any { return new SliderTransform(i.exifOrientation, i.width, i.height, i.scale, - i.rotation, translation, i.image, undefined, - Object.values(i.camera.parameters) as number[], i.cameraType); + i.rotation, translation, i.image, undefined, + Object.values(i.camera.parameters) as number[], i.cameraType); } -function frame(currentType = "spherical", previousType = "perspective"): any { +function frame(currentType: string = "spherical", previousType: string = "perspective"): any { const currentImage = image("current", currentType, 4000, 2000); const previousImage = image("previous", previousType, 1200, 1600); previousImage.exifOrientation = 6; @@ -106,7 +106,7 @@ describe("Slider opt-in common-world view", () => { it("never bypasses missing-image guards", () => { const r = new SliderGLRenderer() as any; r._viewAligned = true; - for (const state of [ + for (const state of [ { currentImage: null, previousImage: image("p", "perspective", 4, 3) }, { currentImage: image("c", "spherical", 4, 2), previousImage: null }, { currentImage: null, previousImage: null }, diff --git a/test/component/slider/SliderViewCorrection.test.ts b/test/component/slider/SliderViewCorrection.test.ts index a920e97bc..c41e3500e 100644 --- a/test/component/slider/SliderViewCorrection.test.ts +++ b/test/component/slider/SliderViewCorrection.test.ts @@ -22,10 +22,10 @@ function image(id: string, cameraType: string, width: number, height: number): a } function transform(i: any, translation: number[]): any { return new SliderTransform(i.exifOrientation, i.width, i.height, i.scale, - i.rotation, translation, i.image, undefined, - Object.values(i.camera.parameters) as number[], i.cameraType); + i.rotation, translation, i.image, undefined, + Object.values(i.camera.parameters) as number[], i.cameraType); } -function frame(currentType = "spherical", previousType = "perspective"): any { +function frame(currentType: string = "spherical", previousType: string = "perspective"): any { const currentImage = image("current", currentType, 4000, 2000); const previousImage = image("previous", previousType, 1200, 1600); previousImage.exifOrientation = 6; diff --git a/test/component/slider/SliderViewScale.test.ts b/test/component/slider/SliderViewScale.test.ts index 50213808e..16d6b5f66 100644 --- a/test/component/slider/SliderViewScale.test.ts +++ b/test/component/slider/SliderViewScale.test.ts @@ -21,10 +21,10 @@ function image(id: string, cameraType: string, width: number, height: number): a } function transform(i: any, translation: number[]): any { return new SliderTransform(i.exifOrientation, i.width, i.height, i.scale, - i.rotation, translation, i.image, undefined, - Object.values(i.camera.parameters) as number[], i.cameraType); + i.rotation, translation, i.image, undefined, + Object.values(i.camera.parameters) as number[], i.cameraType); } -function frame(currentType = "perspective", previousType = "spherical"): any { +function frame(currentType: string = "perspective", previousType: string = "spherical"): any { const currentImage = image("current", currentType, 4000, 3000); const previousImage = image("previous", previousType, 4000, 2000); const currentTransform = transform(currentImage, [-2, -3, -4]); @@ -64,7 +64,8 @@ describe("Slider view-aligned panorama widening", () => { expect(wide.quaternion.angleTo(plain.quaternion)).toBeCloseTo(0, 10); expect(wide.position.toArray()).toEqual(plain.position.toArray()); const ray = new THREE.Vector3(0.1, -0.05, -1); - const a = project(plain, ray), b = project(wide, ray); + const a = project(plain, ray); + const b = project(wide, ray); const centre = project(plain, new THREE.Vector3(0, 0, -1)); expect(project(wide, new THREE.Vector3(0, 0, -1)).distanceTo(centre)).toBeCloseTo(0, 10); // A direction lands half as far from the view centre.