ModelViewerControls.test.tsx 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. /**
  2. * Zoom and resize behaviour of the 3D model viewer (#2976).
  3. *
  4. * Only what jsdom cannot provide is replaced: the WebGL renderer, the PMREM
  5. * environment it would bake, and OrbitControls. Scene, camera and the STL
  6. * loader are real, so the tests exercise the component's own framing maths.
  7. */
  8. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
  9. import { act, fireEvent, render, waitFor } from '@testing-library/react';
  10. import * as THREE from 'three';
  11. const mocks = vi.hoisted(() => ({
  12. setSize: vi.fn(),
  13. wheelSeen: vi.fn(),
  14. controlsInstances: [] as Array<{ object: THREE.Camera; domElement: HTMLElement; target: THREE.Vector3; minDistance: number; maxDistance: number }>,
  15. }));
  16. vi.mock('three', async (importOriginal) => {
  17. const actual = await importOriginal<typeof import('three')>();
  18. class FakeWebGLRenderer {
  19. domElement = document.createElement('canvas');
  20. shadowMap = { enabled: false, type: 0 };
  21. toneMapping = 0;
  22. toneMappingExposure = 1;
  23. setSize(width: number, height: number) {
  24. mocks.setSize(width, height);
  25. this.domElement.style.width = `${width}px`;
  26. this.domElement.style.height = `${height}px`;
  27. }
  28. setPixelRatio() {}
  29. render() {}
  30. dispose() {}
  31. }
  32. class FakePMREMGenerator {
  33. fromScene() {
  34. return { texture: { dispose() {} } };
  35. }
  36. dispose() {}
  37. }
  38. return { ...actual, WebGLRenderer: FakeWebGLRenderer, PMREMGenerator: FakePMREMGenerator };
  39. });
  40. vi.mock('three/examples/jsm/controls/OrbitControls.js', () => ({
  41. OrbitControls: class {
  42. object: THREE.Camera;
  43. domElement: HTMLElement;
  44. target = new THREE.Vector3();
  45. enableDamping = false;
  46. dampingFactor = 0;
  47. minDistance = 0;
  48. maxDistance = Infinity;
  49. constructor(object: THREE.Camera, domElement: HTMLElement) {
  50. this.object = object;
  51. this.domElement = domElement;
  52. // The real controls listen for wheel on the element they are handed;
  53. // the fake does the same so the test can tell where events end up.
  54. domElement.addEventListener('wheel', mocks.wheelSeen);
  55. mocks.controlsInstances.push(this);
  56. }
  57. update() {}
  58. dispose() {
  59. this.domElement.removeEventListener('wheel', mocks.wheelSeen);
  60. }
  61. },
  62. }));
  63. vi.mock('../../api/client', () => ({
  64. getAuthToken: () => null,
  65. }));
  66. import { ModelViewer } from '../../components/ModelViewer';
  67. // Binary STL of one triangle, enough for a bounding box.
  68. function tinyStl(): ArrayBuffer {
  69. const buffer = new ArrayBuffer(84 + 50);
  70. const view = new DataView(buffer);
  71. view.setUint32(80, 1, true);
  72. const floats = [0, 0, 1, 0, 0, 0, 20, 0, 0, 0, 20, 10];
  73. floats.forEach((value, i) => view.setFloat32(84 + i * 4, value, true));
  74. return buffer;
  75. }
  76. function setClientSize(element: HTMLElement, width: number, height: number) {
  77. Object.defineProperty(element, 'clientWidth', { configurable: true, value: width });
  78. Object.defineProperty(element, 'clientHeight', { configurable: true, value: height });
  79. }
  80. describe('ModelViewer controls', () => {
  81. beforeEach(() => {
  82. mocks.setSize.mockClear();
  83. mocks.wheelSeen.mockClear();
  84. mocks.controlsInstances.length = 0;
  85. // jsdom lays nothing out; give every element a panel-sized box so the
  86. // camera gets a real aspect ratio to frame against.
  87. Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get: () => 800 });
  88. Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, get: () => 600 });
  89. vi.stubGlobal(
  90. 'fetch',
  91. vi.fn(async () => new Response(tinyStl(), { status: 200 })),
  92. );
  93. vi.stubGlobal(
  94. 'ResizeObserver',
  95. class {
  96. observe() {}
  97. unobserve() {}
  98. disconnect() {}
  99. },
  100. );
  101. });
  102. afterEach(() => {
  103. vi.unstubAllGlobals();
  104. delete (HTMLElement.prototype as { clientWidth?: number }).clientWidth;
  105. delete (HTMLElement.prototype as { clientHeight?: number }).clientHeight;
  106. });
  107. async function renderLoaded() {
  108. const utils = render(<ModelViewer url="/api/v1/library/files/1/download" fileType="stl" />);
  109. const canvas = document.querySelector('canvas') as HTMLCanvasElement;
  110. // Loaded once the zoom / reset buttons replace the spinner.
  111. await waitFor(() => expect(document.querySelector('.lucide-zoom-in')).not.toBeNull());
  112. return { ...utils, canvas, controls: mocks.controlsInstances[0] };
  113. }
  114. it('hands the canvas that is in the DOM to OrbitControls, so wheel events reach it', async () => {
  115. const { canvas, controls } = await renderLoaded();
  116. expect(controls.domElement).toBe(canvas);
  117. expect(document.body.contains(canvas)).toBe(true);
  118. fireEvent.wheel(canvas, { deltaY: -100 });
  119. expect(mocks.wheelSeen).toHaveBeenCalledTimes(1);
  120. });
  121. it('bounds the dolly range and keeps the far plane beyond it', async () => {
  122. const { controls } = await renderLoaded();
  123. const camera = controls.object as THREE.PerspectiveCamera;
  124. const framed = camera.position.distanceTo(controls.target);
  125. expect(controls.maxDistance).toBeGreaterThan(framed);
  126. expect(Number.isFinite(controls.maxDistance)).toBe(true);
  127. expect(controls.minDistance).toBeGreaterThan(0);
  128. // Zooming all the way out must never push the model past the far plane.
  129. expect(camera.far).toBeGreaterThan(controls.maxDistance);
  130. });
  131. it('zoom buttons dolly along the view axis toward the orbit target', async () => {
  132. const { controls } = await renderLoaded();
  133. const camera = controls.object as THREE.PerspectiveCamera;
  134. const before = camera.position.clone();
  135. const distanceBefore = before.distanceTo(controls.target);
  136. const directionBefore = before.clone().sub(controls.target).normalize();
  137. fireEvent.click(document.querySelector('.lucide-zoom-in')!.closest('button')!);
  138. const distanceAfter = camera.position.distanceTo(controls.target);
  139. expect(distanceAfter).toBeCloseTo(distanceBefore * 0.8, 5);
  140. const directionAfter = camera.position.clone().sub(controls.target).normalize();
  141. expect(directionAfter.distanceTo(directionBefore)).toBeLessThan(1e-6);
  142. fireEvent.click(document.querySelector('.lucide-zoom-out')!.closest('button')!);
  143. expect(camera.position.distanceTo(controls.target)).toBeCloseTo(distanceBefore, 5);
  144. });
  145. it('resets to the framed view, not a fixed pose', async () => {
  146. const { controls } = await renderLoaded();
  147. const camera = controls.object as THREE.PerspectiveCamera;
  148. const framedPosition = camera.position.clone();
  149. const framedTarget = controls.target.clone();
  150. fireEvent.click(document.querySelector('.lucide-zoom-in')!.closest('button')!);
  151. fireEvent.click(document.querySelector('.lucide-rotate-ccw')!.closest('button')!);
  152. expect(camera.position.distanceTo(framedPosition)).toBeLessThan(1e-6);
  153. expect(controls.target.distanceTo(framedTarget)).toBeLessThan(1e-6);
  154. });
  155. it('resizes the canvas and camera when fullscreen changes', async () => {
  156. const { canvas, controls } = await renderLoaded();
  157. const camera = controls.object as THREE.PerspectiveCamera;
  158. const container = canvas.parentElement as HTMLElement;
  159. mocks.setSize.mockClear();
  160. setClientSize(container, 1920, 1080);
  161. act(() => {
  162. document.dispatchEvent(new Event('fullscreenchange'));
  163. });
  164. expect(mocks.setSize).toHaveBeenCalledWith(1920, 1080);
  165. expect(camera.aspect).toBeCloseTo(1920 / 1080, 5);
  166. expect(canvas.style.width).toBe('1920px');
  167. });
  168. it('ignores a resize to zero (hidden container) instead of collapsing the camera', async () => {
  169. const { canvas, controls } = await renderLoaded();
  170. const camera = controls.object as THREE.PerspectiveCamera;
  171. const container = canvas.parentElement as HTMLElement;
  172. const aspect = camera.aspect;
  173. mocks.setSize.mockClear();
  174. setClientSize(container, 0, 0);
  175. act(() => {
  176. document.dispatchEvent(new Event('fullscreenchange'));
  177. });
  178. expect(mocks.setSize).not.toHaveBeenCalled();
  179. expect(camera.aspect).toBe(aspect);
  180. });
  181. });