diff --git a/TODO.md b/TODO.md index b26c925a5..8794b5da6 100644 --- a/TODO.md +++ b/TODO.md @@ -95,9 +95,9 @@ A step-by-step checklist for porting MatterControl's design features to a Vue + - [x] Add "Select All" (Ctrl+A) shortcut ### Selection Visualization -- [ ] Add selection outline effect (OutlinePass or custom) -- [ ] Add hover highlight on mouseover -- [ ] Distinguish single vs multi-selection visually +- [x] Add selection outline effect (OutlinePass or custom) +- [x] Add hover highlight on mouseover +- [x] Distinguish single vs multi-selection visually ### Context Menu - [ ] Create right-click context menu component diff --git a/src/components/Viewport.vue b/src/components/Viewport.vue index 105b09a4b..0e85f612a 100644 --- a/src/components/Viewport.vue +++ b/src/components/Viewport.vue @@ -2,6 +2,10 @@ import { ref, watch, onMounted, onBeforeUnmount } from 'vue' import * as THREE from 'three' import { OrbitControls } from 'three/addons/controls/OrbitControls.js' +import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js' +import { RenderPass } from 'three/addons/postprocessing/RenderPass.js' +import { OutlinePass } from 'three/addons/postprocessing/OutlinePass.js' +import { OutputPass } from 'three/addons/postprocessing/OutputPass.js' import { HOME_POSITION, HOME_TARGET, @@ -43,6 +47,10 @@ let axisHelper: THREE.AxesHelper let controls: OrbitControls let raycaster: THREE.Raycaster const mouse = new THREE.Vector2() +let composer: EffectComposer +let selectionOutlinePass: OutlinePass +let hoverOutlinePass: OutlinePass +let hoveredObject: SceneObject | null = null function initScene() { if (!containerRef.value) return @@ -91,16 +99,58 @@ function initScene() { addLighting() addGridHelper() addAxisHelper() - addTestCube() raycaster = new THREE.Raycaster() sceneStore.setScene(scene) + addTestCube() + + setupPostProcessing() setupMouseHandlers() setupResizeObserver() animate() } +function setupPostProcessing() { + if (!containerRef.value) return + + const { clientWidth: width, clientHeight: height } = containerRef.value + + composer = new EffectComposer(renderer) + + const renderPass = new RenderPass(scene, activeCamera) + composer.addPass(renderPass) + + // Selection outline - cyan color + selectionOutlinePass = new OutlinePass( + new THREE.Vector2(width, height), + scene, + activeCamera + ) + selectionOutlinePass.visibleEdgeColor.set(0x00ffff) + selectionOutlinePass.hiddenEdgeColor.set(0x004444) + selectionOutlinePass.edgeStrength = 3 + selectionOutlinePass.edgeThickness = 1 + selectionOutlinePass.edgeGlow = 0.5 + composer.addPass(selectionOutlinePass) + + // Hover outline - yellow color, thinner + hoverOutlinePass = new OutlinePass( + new THREE.Vector2(width, height), + scene, + activeCamera + ) + hoverOutlinePass.visibleEdgeColor.set(0xffff00) + hoverOutlinePass.hiddenEdgeColor.set(0x444400) + hoverOutlinePass.edgeStrength = 2 + hoverOutlinePass.edgeThickness = 1 + hoverOutlinePass.edgeGlow = 0 + composer.addPass(hoverOutlinePass) + + const outputPass = new OutputPass() + composer.addPass(outputPass) +} + function addGridHelper() { gridHelper = new THREE.GridHelper(10, 10, 0x444444, 0x333333) gridHelper.visible = props.showGrid @@ -147,19 +197,21 @@ function addTestCube() { const geometry = new THREE.BoxGeometry(1, 1, 1) const material = new THREE.MeshStandardMaterial({ color: 0x4a90d9 }) const cube = new THREE.Mesh(geometry, material) - scene.add(cube) + sceneStore.addObject(cube, 'Cube') } function setupMouseHandlers() { if (!renderer) return renderer.domElement.addEventListener('click', onCanvasClick) + renderer.domElement.addEventListener('mousemove', onCanvasMouseMove) } function cleanupMouseHandlers() { if (!renderer) return renderer.domElement.removeEventListener('click', onCanvasClick) + renderer.domElement.removeEventListener('mousemove', onCanvasMouseMove) } function updateMouseCoordinates(event: MouseEvent) { @@ -198,6 +250,14 @@ function onCanvasClick(event: MouseEvent) { emit('pick', pickedObject, event) } +function onCanvasMouseMove(event: MouseEvent) { + const newHovered = pickObject(event) + if (newHovered !== hoveredObject) { + hoveredObject = newHovered + updateHoverOutline() + } +} + function setupResizeObserver() { if (!containerRef.value) return @@ -218,6 +278,11 @@ function setupResizeObserver() { orthographicCamera.updateProjectionMatrix() renderer.setSize(width, height) + composer.setSize(width, height) + + const resolution = new THREE.Vector2(width, height) + selectionOutlinePass.resolution = resolution + hoverOutlinePass.resolution = resolution } } }) @@ -228,9 +293,34 @@ function setupResizeObserver() { function animate() { animationFrameId = requestAnimationFrame(animate) controls.update() - renderer.render(scene, activeCamera) + composer.render() } +function updateSelectionOutline() { + if (!selectionOutlinePass) return + selectionOutlinePass.selectedObjects = sceneStore.selectedObjects.map( + (obj) => obj.mesh + ) +} + +function updateHoverOutline() { + if (!hoverOutlinePass) return + if (hoveredObject && !sceneStore.selectedIds.has(hoveredObject.id)) { + hoverOutlinePass.selectedObjects = [hoveredObject.mesh] + } else { + hoverOutlinePass.selectedObjects = [] + } +} + +watch( + () => sceneStore.selectedObjects, + () => { + updateSelectionOutline() + updateHoverOutline() + }, + { deep: true } +) + function fitToSelection(objects: THREE.Object3D[]) { if (objects.length === 0) return @@ -281,6 +371,10 @@ function setOrthographic(ortho: boolean) { // Update controls to use new camera controls.object = activeCamera controls.update() + + // Update outline passes camera + if (selectionOutlinePass) selectionOutlinePass.renderCamera = activeCamera + if (hoverOutlinePass) hoverOutlinePass.renderCamera = activeCamera } function toggleProjection() {