From 4c9b3bd3d60f821bf213d4d46dc5460fe5cfff82 Mon Sep 17 00:00:00 2001 From: Matthew MacFarquhar Date: Tue, 14 Apr 2026 14:55:47 -0400 Subject: [PATCH 1/9] feat: add vite-browser mesh canvas position helper --- .vscode/settings.json | 3 +- package.json | 1 + pnpm-lock.yaml | 43 +++++++++++++++-------- src/lib/browser.js | 28 +++++++++++++++ src/routes/Scene.svelte | 2 +- src/routes/__tests__/SceneBrowser.spec.ts | 29 +++++++++++++++ 6 files changed, 90 insertions(+), 16 deletions(-) create mode 100644 src/lib/browser.js create mode 100644 src/routes/__tests__/SceneBrowser.spec.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 5a719e1..e5d7c81 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,5 +4,6 @@ "typescript.preferences.importModuleSpecifier": "relative", "[svelte]": { "editor.defaultFormatter": "esbenp.prettier-vscode" - } + }, + "deno.enable": false } 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/browser.js b/src/lib/browser.js new file mode 100644 index 0000000..1b603c7 --- /dev/null +++ b/src/lib/browser.js @@ -0,0 +1,28 @@ +import * as THREE from 'three' + +/** + * Get the canvas position of a mesh by name. + * + * @param {string} name + * @param {THREE.Scene} scene + * @param {HTMLCanvasElement} canvas + * @returns {{ x: number; y: number } | undefined} + */ +function getMeshCanvasPositionByName(name, scene, canvas) { + const mesh = scene.children.find((child) => child.name === name) + if (!mesh) return undefined + + const vector = new THREE.Vector3() + mesh.getWorldPosition(vector) + + const rect = canvas.getBoundingClientRect() + const viewportX = rect.left + ((vector.x + 1) / 2) * rect.width + const viewportY = rect.top + ((-vector.y + 1) / 2) * rect.height + + return { + x: viewportX - rect.left, + y: viewportY - rect.top, + } +} + +export { getMeshCanvasPositionByName } \ No newline at end of file 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..519d33e --- /dev/null +++ b/src/routes/__tests__/SceneBrowser.spec.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from 'vitest' +import { render } from '../../lib' +import { getMeshCanvasPositionByName } from '../../lib/browser' +import Subject from '../Scene.svelte' +import { userEvent } from 'vitest/browser' + +describe('SceneBrowser', () => { + it('renders a scene browser', async () => { + const onclick = vi.fn() + const { scene, container } = render(Subject, { props: { onclick } }) + + const canvas = container.querySelector('canvas') + expect(canvas).not.toBeNull() + if (!canvas) return + + + const position = getMeshCanvasPositionByName('box-1', scene, canvas) + expect(position).not.toBeNull() + if (!position) return + expect(position.x).toBeDefined() + expect(position.y).toBeDefined() + + await userEvent.click(canvas, { + position: position + }) + + expect(onclick).toHaveBeenCalledOnce() + }) +}) \ No newline at end of file From 60a6d2300a51c4806df0731503e7dacf3841950b Mon Sep 17 00:00:00 2001 From: Matthew MacFarquhar Date: Tue, 14 Apr 2026 14:59:34 -0400 Subject: [PATCH 2/9] remove deno --- .changeset/cold-socks-kick.md | 5 +++++ .vscode/settings.json | 3 +-- 2 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 .changeset/cold-socks-kick.md 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/.vscode/settings.json b/.vscode/settings.json index e5d7c81..5a719e1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,6 +4,5 @@ "typescript.preferences.importModuleSpecifier": "relative", "[svelte]": { "editor.defaultFormatter": "esbenp.prettier-vscode" - }, - "deno.enable": false + } } From 4d69b63092c0892b37938bb33f75e650e4f0a336 Mon Sep 17 00:00:00 2001 From: Matthew MacFarquhar Date: Wed, 15 Apr 2026 14:53:12 -0400 Subject: [PATCH 3/9] Add function to render module and update test cases accordingly --- src/lib/browser.js | 28 ------------------ src/lib/index.js | 2 +- src/lib/pure.js | 30 ++++++++++++++++++- src/routes/__tests__/SceneBrowser.spec.ts | 36 +++++++++++++++-------- 4 files changed, 54 insertions(+), 42 deletions(-) delete mode 100644 src/lib/browser.js diff --git a/src/lib/browser.js b/src/lib/browser.js deleted file mode 100644 index 1b603c7..0000000 --- a/src/lib/browser.js +++ /dev/null @@ -1,28 +0,0 @@ -import * as THREE from 'three' - -/** - * Get the canvas position of a mesh by name. - * - * @param {string} name - * @param {THREE.Scene} scene - * @param {HTMLCanvasElement} canvas - * @returns {{ x: number; y: number } | undefined} - */ -function getMeshCanvasPositionByName(name, scene, canvas) { - const mesh = scene.children.find((child) => child.name === name) - if (!mesh) return undefined - - const vector = new THREE.Vector3() - mesh.getWorldPosition(vector) - - const rect = canvas.getBoundingClientRect() - const viewportX = rect.left + ((vector.x + 1) / 2) * rect.width - const viewportY = rect.top + ((-vector.y + 1) / 2) * rect.height - - return { - x: viewportX - rect.left, - y: viewportY - rect.top, - } -} - -export { getMeshCanvasPositionByName } \ No newline at end of file diff --git a/src/lib/index.js b/src/lib/index.js index 714d0f3..79c58c5 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -16,4 +16,4 @@ if (typeof window !== 'undefined') { console.warn = warn } -export { act, cleanup, render } from './pure' +export { act, cleanup, getMeshCanvasPositionByName, render } from './pure' diff --git a/src/lib/pure.js b/src/lib/pure.js index d0072d4..27e7080 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' @@ -107,4 +108,31 @@ const act = async (fn) => { return Svelte.tick() } -export { act, cleanup, render } +/** + * Get the canvas position of a mesh by name. + * + * @param {string} name + * @param {THREE.Scene} scene + * @param {HTMLCanvasElement} canvas + * @param {THREE.Camera} camera + * @returns {{ x: number; y: number } | undefined} + */ +function getMeshCanvasPositionByName(name, scene, canvas, camera) { + const mesh = scene.children.find((child) => child.name === name) + if (!mesh) return undefined + + const vector = new THREE.Vector3() + mesh.getWorldPosition(vector) + vector.project(camera); + + const rect = canvas.getBoundingClientRect() + const viewportX = rect.left + ((vector.x + 1) / 2) * rect.width + const viewportY = rect.top + ((-vector.y + 1) / 2) * rect.height + + return { + x: viewportX - rect.left, + y: viewportY - rect.top, + } +} + +export { act, cleanup, getMeshCanvasPositionByName, render } diff --git a/src/routes/__tests__/SceneBrowser.spec.ts b/src/routes/__tests__/SceneBrowser.spec.ts index 519d33e..2f0c1ab 100644 --- a/src/routes/__tests__/SceneBrowser.spec.ts +++ b/src/routes/__tests__/SceneBrowser.spec.ts @@ -1,29 +1,41 @@ import { describe, expect, it, vi } from 'vitest' -import { render } from '../../lib' -import { getMeshCanvasPositionByName } from '../../lib/browser' +import { render, getMeshCanvasPositionByName } from '../../lib' import Subject from '../Scene.svelte' import { userEvent } from 'vitest/browser' describe('SceneBrowser', () => { - it('renders a scene browser', async () => { + it('calls the onclick callback when the box mesh is clicked', async () => { const onclick = vi.fn() - const { scene, container } = render(Subject, { props: { onclick } }) + const { scene, container, camera, advance, rerender } = render(Subject, { props: { onclick } }) + advance() const canvas = container.querySelector('canvas') expect(canvas).not.toBeNull() if (!canvas) return - const position = getMeshCanvasPositionByName('box-1', scene, canvas) - expect(position).not.toBeNull() - if (!position) return - expect(position.x).toBeDefined() - expect(position.y).toBeDefined() + const rerenderAndClickBox = async (x: number) => { + await rerender({ x }) + const nextPosition = getMeshCanvasPositionByName('box-1', scene, canvas, camera.current) + expect(nextPosition).not.toBeNull() + if (!nextPosition) return + expect(nextPosition.x).toBeDefined() + expect(nextPosition.y).toBeDefined() - await userEvent.click(canvas, { - position: position - }) + await userEvent.click(canvas, { + position: nextPosition + }) + } + await rerenderAndClickBox(0.0) expect(onclick).toHaveBeenCalledOnce() + + onclick.mockClear() + await rerenderAndClickBox(0.6) + expect(onclick).toHaveBeenCalledOnce() + + onclick.mockClear() + await rerenderAndClickBox(3) + expect(onclick).not.toHaveBeenCalled() }) }) \ No newline at end of file From 4d5470fc3bfca205fc5918dce3000377bbe16e89 Mon Sep 17 00:00:00 2001 From: Matthew MacFarquhar Date: Wed, 15 Apr 2026 14:55:17 -0400 Subject: [PATCH 4/9] fix import --- src/routes/__tests__/SceneBrowser.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/__tests__/SceneBrowser.spec.ts b/src/routes/__tests__/SceneBrowser.spec.ts index 2f0c1ab..f172d18 100644 --- a/src/routes/__tests__/SceneBrowser.spec.ts +++ b/src/routes/__tests__/SceneBrowser.spec.ts @@ -35,7 +35,7 @@ describe('SceneBrowser', () => { expect(onclick).toHaveBeenCalledOnce() onclick.mockClear() - await rerenderAndClickBox(3) + await rerenderAndClickBox(3) // this one is out of the camera frustrum so expect(onclick).not.toHaveBeenCalled() }) }) \ No newline at end of file From 02a868cf084d74af047eead0fa55a0869ec32587 Mon Sep 17 00:00:00 2001 From: Matthew MacFarquhar Date: Wed, 15 Apr 2026 14:56:47 -0400 Subject: [PATCH 5/9] update comment --- src/routes/__tests__/SceneBrowser.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/__tests__/SceneBrowser.spec.ts b/src/routes/__tests__/SceneBrowser.spec.ts index f172d18..fd51e9e 100644 --- a/src/routes/__tests__/SceneBrowser.spec.ts +++ b/src/routes/__tests__/SceneBrowser.spec.ts @@ -35,7 +35,7 @@ describe('SceneBrowser', () => { expect(onclick).toHaveBeenCalledOnce() onclick.mockClear() - await rerenderAndClickBox(3) // this one is out of the camera frustrum so + await rerenderAndClickBox(3) // this one is out of the camera frustrum so click should not be called expect(onclick).not.toHaveBeenCalled() }) }) \ No newline at end of file From 2239c3deca886f21d31fde3952ff0ecb7fb59da9 Mon Sep 17 00:00:00 2001 From: Micheal Parks Date: Thu, 16 Apr 2026 17:15:39 -0400 Subject: [PATCH 6/9] refactor --- src/lib/index.js | 2 +- src/lib/pure.js | 72 ++++++++++++++--------- src/routes/__tests__/SceneBrowser.spec.ts | 25 +++----- 3 files changed, 53 insertions(+), 46 deletions(-) diff --git a/src/lib/index.js b/src/lib/index.js index 79c58c5..714d0f3 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -16,4 +16,4 @@ if (typeof window !== 'undefined') { console.warn = warn } -export { act, cleanup, getMeshCanvasPositionByName, render } from './pure' +export { act, cleanup, render } from './pure' diff --git a/src/lib/pure.js b/src/lib/pure.js index 27e7080..6a913fe 100644 --- a/src/lib/pure.js +++ b/src/lib/pure.js @@ -28,9 +28,45 @@ import { setup } from './setup.js' * advance: (options?: { count?: number; delta?: number }) => ({ frameInvalidated: boolean }) * rerender: (props?: Partial>) => Promise * unmount: () => void + * position: (input: string | THREE.Object3D) => { x: number, y: number } * }} RenderResult */ +/** + * Get the canvas position of a mesh by name. + * + * @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, + } + + console.log(position) + return position +} + /** * Render a component into the document. * @@ -70,6 +106,13 @@ const render = (Component, options = {}, renderOptions = {}) => { camera: component.context.camera, component: component.ref, container, + position: (input) => + getObject3dCanvasPosition( + input, + component.context.scene, + component.context.dom, + component.context.camera.current + ), context: component.context, scene: component.context.scene, advance: component.advance, @@ -108,31 +151,4 @@ const act = async (fn) => { return Svelte.tick() } -/** - * Get the canvas position of a mesh by name. - * - * @param {string} name - * @param {THREE.Scene} scene - * @param {HTMLCanvasElement} canvas - * @param {THREE.Camera} camera - * @returns {{ x: number; y: number } | undefined} - */ -function getMeshCanvasPositionByName(name, scene, canvas, camera) { - const mesh = scene.children.find((child) => child.name === name) - if (!mesh) return undefined - - const vector = new THREE.Vector3() - mesh.getWorldPosition(vector) - vector.project(camera); - - const rect = canvas.getBoundingClientRect() - const viewportX = rect.left + ((vector.x + 1) / 2) * rect.width - const viewportY = rect.top + ((-vector.y + 1) / 2) * rect.height - - return { - x: viewportX - rect.left, - y: viewportY - rect.top, - } -} - -export { act, cleanup, getMeshCanvasPositionByName, render } +export { act, cleanup, render } diff --git a/src/routes/__tests__/SceneBrowser.spec.ts b/src/routes/__tests__/SceneBrowser.spec.ts index fd51e9e..e1a15b5 100644 --- a/src/routes/__tests__/SceneBrowser.spec.ts +++ b/src/routes/__tests__/SceneBrowser.spec.ts @@ -1,29 +1,20 @@ import { describe, expect, it, vi } from 'vitest' -import { render, getMeshCanvasPositionByName } from '../../lib' +import { render } from '../../lib' import Subject from '../Scene.svelte' import { userEvent } from 'vitest/browser' describe('SceneBrowser', () => { it('calls the onclick callback when the box mesh is clicked', async () => { const onclick = vi.fn() - const { scene, container, camera, advance, rerender } = render(Subject, { props: { onclick } }) - advance() - - const canvas = container.querySelector('canvas') - expect(canvas).not.toBeNull() - if (!canvas) return - + const { container, rerender, position } = render(Subject, { + props: { onclick }, + }) const rerenderAndClickBox = async (x: number) => { await rerender({ x }) - const nextPosition = getMeshCanvasPositionByName('box-1', scene, canvas, camera.current) - expect(nextPosition).not.toBeNull() - if (!nextPosition) return - expect(nextPosition.x).toBeDefined() - expect(nextPosition.y).toBeDefined() - await userEvent.click(canvas, { - position: nextPosition + await userEvent.click(container, { + position: position('box-1'), }) } @@ -33,9 +24,9 @@ describe('SceneBrowser', () => { 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() }) -}) \ No newline at end of file +}) From ed26b46b865e175e470cf5815b4beb0bb6674ead Mon Sep 17 00:00:00 2001 From: Micheal Parks Date: Thu, 16 Apr 2026 17:16:49 -0400 Subject: [PATCH 7/9] log --- src/lib/pure.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/pure.js b/src/lib/pure.js index 6a913fe..2c26faf 100644 --- a/src/lib/pure.js +++ b/src/lib/pure.js @@ -63,7 +63,6 @@ function getObject3dCanvasPosition(input, scene, el, camera) { y: viewportY - rect.top, } - console.log(position) return position } From 73257a41553e58ae690bc77242d1dfafb5cf2511 Mon Sep 17 00:00:00 2001 From: Micheal Parks Date: Thu, 16 Apr 2026 17:23:37 -0400 Subject: [PATCH 8/9] docs --- README.md | 17 +++++++++++++++++ src/lib/pure.js | 6 +++--- src/routes/__tests__/SceneBrowser.spec.ts | 4 ++-- 3 files changed, 22 insertions(+), 5 deletions(-) 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/src/lib/pure.js b/src/lib/pure.js index 443d211..4e63505 100644 --- a/src/lib/pure.js +++ b/src/lib/pure.js @@ -29,12 +29,12 @@ import { setup } from './setup.js' * advance: (options?: { count?: number; delta?: number }) => ({ frameInvalidated: boolean }) * rerender: (props?: Partial>) => Promise * unmount: () => void - * position: (input: string | THREE.Object3D) => { x: number, y: number } + * toCanvasPosition: (input: string | THREE.Object3D) => { x: number, y: number } * }} RenderResult */ /** - * Get the canvas position of a mesh by name. + * Get the canvas position of an Object3D. * * @param {THREE.Object3D | string} input * @param {THREE.Scene} scene @@ -110,7 +110,7 @@ const render = (Component, options = {}, renderOptions = {}) => { camera: component.context.camera, component: component.ref, container, - position: (input) => + toCanvasPosition: (input) => getObject3dCanvasPosition( input, component.context.scene, diff --git a/src/routes/__tests__/SceneBrowser.spec.ts b/src/routes/__tests__/SceneBrowser.spec.ts index e1a15b5..9e9eb62 100644 --- a/src/routes/__tests__/SceneBrowser.spec.ts +++ b/src/routes/__tests__/SceneBrowser.spec.ts @@ -6,7 +6,7 @@ import { userEvent } from 'vitest/browser' describe('SceneBrowser', () => { it('calls the onclick callback when the box mesh is clicked', async () => { const onclick = vi.fn() - const { container, rerender, position } = render(Subject, { + const { container, rerender, toCanvasPosition } = render(Subject, { props: { onclick }, }) @@ -14,7 +14,7 @@ describe('SceneBrowser', () => { await rerender({ x }) await userEvent.click(container, { - position: position('box-1'), + position: toCanvasPosition('box-1'), }) } From f3d2abb5d82ebf951e5c9b131f855dad231713b8 Mon Sep 17 00:00:00 2001 From: Micheal Parks Date: Thu, 16 Apr 2026 17:25:48 -0400 Subject: [PATCH 9/9] lint --- src/routes/__tests__/SceneBrowser.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/routes/__tests__/SceneBrowser.spec.ts b/src/routes/__tests__/SceneBrowser.spec.ts index 9e9eb62..5602038 100644 --- a/src/routes/__tests__/SceneBrowser.spec.ts +++ b/src/routes/__tests__/SceneBrowser.spec.ts @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from 'vitest' +import { userEvent } from 'vitest/browser' + import { render } from '../../lib' import Subject from '../Scene.svelte' -import { userEvent } from 'vitest/browser' describe('SceneBrowser', () => { it('calls the onclick callback when the box mesh is clicked', async () => {