Add Raycaster for mouse picking
This commit is contained in:
parent
a6a7f5ba17
commit
08cdf0d41a
5 changed files with 85 additions and 11 deletions
2
TODO.md
2
TODO.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
9
pnpm-lock.yaml
generated
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue