diff --git a/.changeset/cold-socks-kick.md b/.changeset/cold-socks-kick.md new file mode 100644 index 0000000..d0ac80e --- /dev/null +++ b/.changeset/cold-socks-kick.md @@ -0,0 +1,5 @@ +--- +'@threlte/test': minor +--- + +feat: add mesh to canvas pos helper diff --git a/README.md b/README.md index 64c5351..6583bc3 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ const { camera, // CurrentWritable advance, // ({ count?: number; delta?: number }) => { frameInvalidated: boolean } fireEvent, // (object3D: THREE.Object3D, event, payload) => Promise + toCanvasPosition, // (input: string | THREE.Object3D) => { x: number, y: number } rerender, // (props) => Promise unmount, // () => void } = render(Component) @@ -108,6 +109,22 @@ expect(onclick).toHaveBeenCalledOnce() Note that if you use the event object, you will have to design a mock payload. +### toCanvasPosition + +`toCanvasPosition` projects a 3D object's world position into canvas pixel coordinates. It accepts either an object name string or a `THREE.Object3D` directly, and returns `{ x, y }` in pixels relative to the canvas. + +This is primarily useful when you want to simulate a real pointer event at the location of a 3D object: + +```ts +import { userEvent } from 'vitest/browser' + +const { container, toCanvasPosition } = render(Scene) + +await userEvent.click(container, { + position: toCanvasPosition('box-1' /* a Mesh with name='box-1' */), +}) +``` + ### Setup `@threlte/test` currently only supports vitest as your test runner. To get started, add the `threlteTesting` plugin to your Vite or Vitest config. diff --git a/package.json b/package.json index 7bf8969..9ef6bc8 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "@typescript-eslint/parser": "^8.58.0", "@vitest/browser": "^4.1.2", "@vitest/browser-playwright": "^4.1.2", + "@vitest/ui": "4.1.2", "eslint": "^10.1.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-perfectionist": "^5.8.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9634f93..916ef21 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: '@vitest/browser-playwright': specifier: ^4.1.2 version: 4.1.2(playwright@1.59.1)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@20.19.2))(vitest@4.1.2) + '@vitest/ui': + specifier: 4.1.2 + version: 4.1.2(vitest@4.1.2) eslint: specifier: ^10.1.0 version: 10.1.0 @@ -112,7 +115,7 @@ importers: version: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@20.19.2) vitest: specifier: ^4.1.2 - version: 4.1.2(@types/node@20.19.2)(@vitest/browser-playwright@4.1.2)(happy-dom@20.8.9)(jsdom@26.1.0)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@20.19.2)) + version: 4.1.2(@types/node@20.19.2)(@vitest/browser-playwright@4.1.2)(@vitest/ui@4.1.2)(happy-dom@20.8.9)(jsdom@26.1.0)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@20.19.2)) packages: @@ -400,42 +403,36 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': resolution: {integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': resolution: {integrity: sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==} @@ -686,6 +683,11 @@ packages: '@vitest/spy@4.1.2': resolution: {integrity: sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==} + '@vitest/ui@4.1.2': + resolution: {integrity: sha512-/irhyeAcKS2u6Zokagf9tqZJ0t8S6kMZq4ZG9BHZv7I+fkRrYfQX4w7geYeC2r6obThz39PDxvXQzZX+qXqGeg==} + peerDependencies: + vitest: 4.1.2 + '@vitest/utils@4.1.2': resolution: {integrity: sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==} @@ -1101,6 +1103,9 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + fs-extra@7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} engines: {node: '>=6 <7 || >=8'} @@ -1305,28 +1310,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -2682,7 +2683,7 @@ snapshots: '@vitest/mocker': 4.1.2(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@20.19.2)) playwright: 1.59.1 tinyrainbow: 3.1.0 - vitest: 4.1.2(@types/node@20.19.2)(@vitest/browser-playwright@4.1.2)(happy-dom@20.8.9)(jsdom@26.1.0)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@20.19.2)) + vitest: 4.1.2(@types/node@20.19.2)(@vitest/browser-playwright@4.1.2)(@vitest/ui@4.1.2)(happy-dom@20.8.9)(jsdom@26.1.0)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@20.19.2)) transitivePeerDependencies: - bufferutil - msw @@ -2698,7 +2699,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.2(@types/node@20.19.2)(@vitest/browser-playwright@4.1.2)(happy-dom@20.8.9)(jsdom@26.1.0)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@20.19.2)) + vitest: 4.1.2(@types/node@20.19.2)(@vitest/browser-playwright@4.1.2)(@vitest/ui@4.1.2)(happy-dom@20.8.9)(jsdom@26.1.0)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@20.19.2)) ws: 8.20.0 transitivePeerDependencies: - bufferutil @@ -2741,6 +2742,17 @@ snapshots: '@vitest/spy@4.1.2': {} + '@vitest/ui@4.1.2(vitest@4.1.2)': + dependencies: + '@vitest/utils': 4.1.2 + fflate: 0.8.2 + flatted: 3.4.2 + pathe: 2.0.3 + sirv: 3.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vitest: 4.1.2(@types/node@20.19.2)(@vitest/browser-playwright@4.1.2)(@vitest/ui@4.1.2)(happy-dom@20.8.9)(jsdom@26.1.0)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@20.19.2)) + '@vitest/utils@4.1.2': dependencies: '@vitest/pretty-format': 4.1.2 @@ -3134,6 +3146,8 @@ snapshots: flatted@3.3.3: {} + flatted@3.4.2: {} + fs-extra@7.0.1: dependencies: graceful-fs: 4.2.11 @@ -3885,7 +3899,7 @@ snapshots: optionalDependencies: vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@20.19.2) - vitest@4.1.2(@types/node@20.19.2)(@vitest/browser-playwright@4.1.2)(happy-dom@20.8.9)(jsdom@26.1.0)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@20.19.2)): + vitest@4.1.2(@types/node@20.19.2)(@vitest/browser-playwright@4.1.2)(@vitest/ui@4.1.2)(happy-dom@20.8.9)(jsdom@26.1.0)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@20.19.2)): dependencies: '@vitest/expect': 4.1.2 '@vitest/mocker': 4.1.2(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@20.19.2)) @@ -3910,6 +3924,7 @@ snapshots: optionalDependencies: '@types/node': 20.19.2 '@vitest/browser-playwright': 4.1.2(playwright@1.59.1)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@20.19.2))(vitest@4.1.2) + '@vitest/ui': 4.1.2(vitest@4.1.2) happy-dom: 20.8.9 jsdom: 26.1.0 transitivePeerDependencies: diff --git a/src/lib/pure.js b/src/lib/pure.js index 4ac4f34..4e63505 100644 --- a/src/lib/pure.js +++ b/src/lib/pure.js @@ -1,4 +1,5 @@ import * as Svelte from 'svelte' +import * as THREE from 'three' import { cleanup } from './cleanup.js' import Container from './Container.svelte' @@ -28,9 +29,44 @@ import { setup } from './setup.js' * advance: (options?: { count?: number; delta?: number }) => ({ frameInvalidated: boolean }) * rerender: (props?: Partial>) => Promise * unmount: () => void + * toCanvasPosition: (input: string | THREE.Object3D) => { x: number, y: number } * }} RenderResult */ +/** + * Get the canvas position of an Object3D. + * + * @param {THREE.Object3D | string} input + * @param {THREE.Scene} scene + * @param {HTMLElement} el + * @param {THREE.Camera} camera + * @returns {{ x: number; y: number }} + */ +function getObject3dCanvasPosition(input, scene, el, camera) { + const object3D = + typeof input === 'string' ? scene.getObjectByName(input) : input + + if (object3D === undefined) { + throw new Error(`${input} not found in scene.`) + } + + const vector = new THREE.Vector3() + + object3D.getWorldPosition(vector) + vector.project(camera) + + const rect = el.getBoundingClientRect() + const viewportX = rect.left + ((vector.x + 1) / 2) * rect.width + const viewportY = rect.top + ((-vector.y + 1) / 2) * rect.height + + const position = { + x: viewportX - rect.left, + y: viewportY - rect.top, + } + + return position +} + /** * Render a component into the document. * @@ -74,6 +110,13 @@ const render = (Component, options = {}, renderOptions = {}) => { camera: component.context.camera, component: component.ref, container, + toCanvasPosition: (input) => + getObject3dCanvasPosition( + input, + component.context.scene, + component.context.dom, + component.context.camera.current + ), context: component.context, scene: component.context.scene, advance: component.advance, diff --git a/src/routes/Scene.svelte b/src/routes/Scene.svelte index 16c8d06..cf04c7a 100644 --- a/src/routes/Scene.svelte +++ b/src/routes/Scene.svelte @@ -28,7 +28,7 @@ - + diff --git a/src/routes/__tests__/SceneBrowser.spec.ts b/src/routes/__tests__/SceneBrowser.spec.ts new file mode 100644 index 0000000..5602038 --- /dev/null +++ b/src/routes/__tests__/SceneBrowser.spec.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from 'vitest' +import { userEvent } from 'vitest/browser' + +import { render } from '../../lib' +import Subject from '../Scene.svelte' + +describe('SceneBrowser', () => { + it('calls the onclick callback when the box mesh is clicked', async () => { + const onclick = vi.fn() + const { container, rerender, toCanvasPosition } = render(Subject, { + props: { onclick }, + }) + + const rerenderAndClickBox = async (x: number) => { + await rerender({ x }) + + await userEvent.click(container, { + position: toCanvasPosition('box-1'), + }) + } + + await rerenderAndClickBox(0.0) + expect(onclick).toHaveBeenCalledOnce() + + onclick.mockClear() + await rerenderAndClickBox(0.6) + expect(onclick).toHaveBeenCalledOnce() + + onclick.mockClear() + await rerenderAndClickBox(3) // this one is out of the camera frustrum so click should not be called + expect(onclick).not.toHaveBeenCalled() + }) +})