diff --git a/TODO.md b/TODO.md index ee253b0d4..2444a1caa 100644 --- a/TODO.md +++ b/TODO.md @@ -86,7 +86,7 @@ A step-by-step checklist for porting MatterControl's design features to a Vue + ## Phase 4: Selection System ### Object Picking -- [ ] Add Raycaster for mouse picking +- [x] Add Raycaster for mouse picking - [ ] Implement click-to-select single object - [ ] Implement click-on-empty to deselect - [ ] Add `selectedObjects` array to Pinia store diff --git a/eslint.config.js b/eslint.config.js index fb2552567..f107c6e4e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2,12 +2,20 @@ import js from '@eslint/js' import tseslint from 'typescript-eslint' import pluginVue from 'eslint-plugin-vue' import eslintConfigPrettier from 'eslint-config-prettier' +import globals from 'globals' export default tseslint.config( { ignores: ['dist', 'node_modules'] }, js.configs.recommended, ...tseslint.configs.recommended, ...pluginVue.configs['flat/recommended'], + { + languageOptions: { + globals: { + ...globals.browser, + }, + }, + }, { files: ['**/*.vue'], languageOptions: { @@ -15,6 +23,9 @@ export default tseslint.config( parser: tseslint.parser, }, }, + rules: { + 'vue/multi-word-component-names': 'off', + }, }, eslintConfigPrettier ) diff --git a/package.json b/package.json index 09d0c32a6..9d0b3a1cd 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", "eslint-plugin-vue": "^10.7.0", + "globals": "^17.2.0", "happy-dom": "^20.4.0", "prettier": "^3.8.1", "typescript": "~5.9.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 352e58c31..1be00e359 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,9 @@ importers: eslint-plugin-vue: specifier: ^10.7.0 version: 10.7.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(vue-eslint-parser@10.2.0(eslint@9.39.2)) + globals: + specifier: ^17.2.0 + version: 17.2.0 happy-dom: specifier: ^20.4.0 version: 20.4.0 @@ -917,6 +920,10 @@ packages: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} + globals@17.2.0: + resolution: {integrity: sha512-tovnCz/fEq+Ripoq+p/gN1u7l6A7wwkoBT9pRCzTHzsD/LvADIzXZdjmRymh5Ztf0DYC3Rwg5cZRYjxzBmzbWg==} + engines: {node: '>=18'} + happy-dom@20.4.0: resolution: {integrity: sha512-RDeQm3dT9n0A5f/TszjUmNCLEuPnMGv3Tv4BmNINebz/h17PA6LMBcxJ5FrcqltNBMh9jA/8ufgDdBYUdBt+eg==} engines: {node: '>=20.0.0'} @@ -2214,6 +2221,8 @@ snapshots: globals@14.0.0: {} + globals@17.2.0: {} + happy-dom@20.4.0: dependencies: '@types/node': 24.10.9 diff --git a/src/components/Viewport.vue b/src/components/Viewport.vue index 1d5728ec7..105b09a4b 100644 --- a/src/components/Viewport.vue +++ b/src/components/Viewport.vue @@ -10,6 +10,7 @@ import { getViewUpVector, type ViewPreset, } from '../utils/camera' +import { useSceneStore, type SceneObject } from '../stores/scene' const props = withDefaults( defineProps<{ @@ -22,6 +23,11 @@ const props = withDefaults( } ) +const emit = defineEmits<{ + pick: [object: SceneObject | null, event: MouseEvent] +}>() + +const sceneStore = useSceneStore() const containerRef = ref(null) let scene: THREE.Scene @@ -35,6 +41,8 @@ let resizeObserver: ResizeObserver let gridHelper: THREE.GridHelper let axisHelper: THREE.AxesHelper let controls: OrbitControls +let raycaster: THREE.Raycaster +const mouse = new THREE.Vector2() function initScene() { if (!containerRef.value) return @@ -80,22 +88,16 @@ function initScene() { controls.enableDamping = true controls.dampingFactor = 0.05 - // Add lighting addLighting() - - // Add grid helper addGridHelper() - - // Add axis helper addAxisHelper() - - // Add a test cube to verify rendering addTestCube() - // Setup resize observer - setupResizeObserver() + raycaster = new THREE.Raycaster() + sceneStore.setScene(scene) - // Start render loop + setupMouseHandlers() + setupResizeObserver() animate() } @@ -148,6 +150,54 @@ function addTestCube() { scene.add(cube) } +function setupMouseHandlers() { + if (!renderer) return + + renderer.domElement.addEventListener('click', onCanvasClick) +} + +function cleanupMouseHandlers() { + if (!renderer) return + + renderer.domElement.removeEventListener('click', onCanvasClick) +} + +function updateMouseCoordinates(event: MouseEvent) { + if (!containerRef.value) return + + const rect = renderer.domElement.getBoundingClientRect() + // Convert to normalized device coordinates (-1 to +1) + mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1 + mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1 +} + +function pickObject(event: MouseEvent): SceneObject | null { + updateMouseCoordinates(event) + + raycaster.setFromCamera(mouse, activeCamera) + + // Get all meshes from scene objects in the store + const meshes = sceneStore.objects.map((obj) => obj.mesh) + + if (meshes.length === 0) return null + + const intersects = raycaster.intersectObjects(meshes, false) + + if (intersects.length > 0) { + const hitMesh = intersects[0]!.object as THREE.Mesh + // Find the SceneObject that owns this mesh + const sceneObject = sceneStore.objects.find((obj) => obj.mesh === hitMesh) + return sceneObject ?? null + } + + return null +} + +function onCanvasClick(event: MouseEvent) { + const pickedObject = pickObject(event) + emit('pick', pickedObject, event) +} + function setupResizeObserver() { if (!containerRef.value) return @@ -246,6 +296,8 @@ function cleanup() { resizeObserver.disconnect() } + cleanupMouseHandlers() + if (controls) { controls.dispose() } @@ -270,6 +322,7 @@ defineExpose({ setViewPreset, setOrthographic, toggleProjection, + pickObject, })