Add Raycaster for mouse picking

This commit is contained in:
Nettika 2026-01-29 01:23:34 -08:00
parent a6a7f5ba17
commit 08cdf0d41a
No known key found for this signature in database
5 changed files with 85 additions and 11 deletions

View file

@ -86,7 +86,7 @@ A step-by-step checklist for porting MatterControl's design features to a Vue +
## Phase 4: Selection System ## Phase 4: Selection System
### Object Picking ### Object Picking
- [ ] Add Raycaster for mouse picking - [x] Add Raycaster for mouse picking
- [ ] Implement click-to-select single object - [ ] Implement click-to-select single object
- [ ] Implement click-on-empty to deselect - [ ] Implement click-on-empty to deselect
- [ ] Add `selectedObjects` array to Pinia store - [ ] Add `selectedObjects` array to Pinia store

View file

@ -2,12 +2,20 @@ import js from '@eslint/js'
import tseslint from 'typescript-eslint' import tseslint from 'typescript-eslint'
import pluginVue from 'eslint-plugin-vue' import pluginVue from 'eslint-plugin-vue'
import eslintConfigPrettier from 'eslint-config-prettier' import eslintConfigPrettier from 'eslint-config-prettier'
import globals from 'globals'
export default tseslint.config( export default tseslint.config(
{ ignores: ['dist', 'node_modules'] }, { ignores: ['dist', 'node_modules'] },
js.configs.recommended, js.configs.recommended,
...tseslint.configs.recommended, ...tseslint.configs.recommended,
...pluginVue.configs['flat/recommended'], ...pluginVue.configs['flat/recommended'],
{
languageOptions: {
globals: {
...globals.browser,
},
},
},
{ {
files: ['**/*.vue'], files: ['**/*.vue'],
languageOptions: { languageOptions: {
@ -15,6 +23,9 @@ export default tseslint.config(
parser: tseslint.parser, parser: tseslint.parser,
}, },
}, },
rules: {
'vue/multi-word-component-names': 'off',
},
}, },
eslintConfigPrettier eslintConfigPrettier
) )

View file

@ -27,6 +27,7 @@
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-vue": "^10.7.0", "eslint-plugin-vue": "^10.7.0",
"globals": "^17.2.0",
"happy-dom": "^20.4.0", "happy-dom": "^20.4.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"typescript": "~5.9.3", "typescript": "~5.9.3",

9
pnpm-lock.yaml generated
View file

@ -45,6 +45,9 @@ importers:
eslint-plugin-vue: eslint-plugin-vue:
specifier: ^10.7.0 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)) 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: happy-dom:
specifier: ^20.4.0 specifier: ^20.4.0
version: 20.4.0 version: 20.4.0
@ -917,6 +920,10 @@ packages:
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
globals@17.2.0:
resolution: {integrity: sha512-tovnCz/fEq+Ripoq+p/gN1u7l6A7wwkoBT9pRCzTHzsD/LvADIzXZdjmRymh5Ztf0DYC3Rwg5cZRYjxzBmzbWg==}
engines: {node: '>=18'}
happy-dom@20.4.0: happy-dom@20.4.0:
resolution: {integrity: sha512-RDeQm3dT9n0A5f/TszjUmNCLEuPnMGv3Tv4BmNINebz/h17PA6LMBcxJ5FrcqltNBMh9jA/8ufgDdBYUdBt+eg==} resolution: {integrity: sha512-RDeQm3dT9n0A5f/TszjUmNCLEuPnMGv3Tv4BmNINebz/h17PA6LMBcxJ5FrcqltNBMh9jA/8ufgDdBYUdBt+eg==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@ -2214,6 +2221,8 @@ snapshots:
globals@14.0.0: {} globals@14.0.0: {}
globals@17.2.0: {}
happy-dom@20.4.0: happy-dom@20.4.0:
dependencies: dependencies:
'@types/node': 24.10.9 '@types/node': 24.10.9

View file

@ -10,6 +10,7 @@ import {
getViewUpVector, getViewUpVector,
type ViewPreset, type ViewPreset,
} from '../utils/camera' } from '../utils/camera'
import { useSceneStore, type SceneObject } from '../stores/scene'
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@ -22,6 +23,11 @@ const props = withDefaults(
} }
) )
const emit = defineEmits<{
pick: [object: SceneObject | null, event: MouseEvent]
}>()
const sceneStore = useSceneStore()
const containerRef = ref<HTMLDivElement | null>(null) const containerRef = ref<HTMLDivElement | null>(null)
let scene: THREE.Scene let scene: THREE.Scene
@ -35,6 +41,8 @@ let resizeObserver: ResizeObserver
let gridHelper: THREE.GridHelper let gridHelper: THREE.GridHelper
let axisHelper: THREE.AxesHelper let axisHelper: THREE.AxesHelper
let controls: OrbitControls let controls: OrbitControls
let raycaster: THREE.Raycaster
const mouse = new THREE.Vector2()
function initScene() { function initScene() {
if (!containerRef.value) return if (!containerRef.value) return
@ -80,22 +88,16 @@ function initScene() {
controls.enableDamping = true controls.enableDamping = true
controls.dampingFactor = 0.05 controls.dampingFactor = 0.05
// Add lighting
addLighting() addLighting()
// Add grid helper
addGridHelper() addGridHelper()
// Add axis helper
addAxisHelper() addAxisHelper()
// Add a test cube to verify rendering
addTestCube() addTestCube()
// Setup resize observer raycaster = new THREE.Raycaster()
setupResizeObserver() sceneStore.setScene(scene)
// Start render loop setupMouseHandlers()
setupResizeObserver()
animate() animate()
} }
@ -148,6 +150,54 @@ function addTestCube() {
scene.add(cube) 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() { function setupResizeObserver() {
if (!containerRef.value) return if (!containerRef.value) return
@ -246,6 +296,8 @@ function cleanup() {
resizeObserver.disconnect() resizeObserver.disconnect()
} }
cleanupMouseHandlers()
if (controls) { if (controls) {
controls.dispose() controls.dispose()
} }
@ -270,6 +322,7 @@ defineExpose({
setViewPreset, setViewPreset,
setOrthographic, setOrthographic,
toggleProjection, toggleProjection,
pickObject,
}) })
</script> </script>