diff --git a/TODO.md b/TODO.md index f474a6481..8da586a0a 100644 --- a/TODO.md +++ b/TODO.md @@ -46,7 +46,7 @@ A step-by-step checklist for porting MatterControl's design features to a Vue + - [x] Add "fit to selection" camera function - [x] Add "reset view" function (home position) - [x] Add view presets (front, back, top, bottom, left, right) -- [ ] Add orthographic/perspective toggle +- [x] Add orthographic/perspective toggle ### Scene State Management - [ ] Create `useScene` composable for scene state diff --git a/src/components/Viewport.vue b/src/components/Viewport.vue index 568ca0ce8..3d62c00eb 100644 --- a/src/components/Viewport.vue +++ b/src/components/Viewport.vue @@ -17,7 +17,10 @@ const props = withDefaults( const containerRef = ref(null) let scene: THREE.Scene -let camera: THREE.PerspectiveCamera +let perspectiveCamera: THREE.PerspectiveCamera +let orthographicCamera: THREE.OrthographicCamera +let activeCamera: THREE.PerspectiveCamera | THREE.OrthographicCamera +let isOrthographic = false let renderer: THREE.WebGLRenderer let animationFrameId: number let resizeObserver: ResizeObserver @@ -47,11 +50,27 @@ function initScene() { scene = new THREE.Scene() scene.background = new THREE.Color(0x1a1a2e) - // Create camera + // Create cameras const { clientWidth: width, clientHeight: height } = containerRef.value - camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000) - camera.position.copy(HOME_POSITION) - camera.lookAt(HOME_TARGET) + const aspect = width / height + + perspectiveCamera = new THREE.PerspectiveCamera(45, aspect, 0.1, 1000) + perspectiveCamera.position.copy(HOME_POSITION) + perspectiveCamera.lookAt(HOME_TARGET) + + const frustumSize = 10 + orthographicCamera = new THREE.OrthographicCamera( + (-frustumSize * aspect) / 2, + (frustumSize * aspect) / 2, + frustumSize / 2, + -frustumSize / 2, + 0.1, + 1000 + ) + orthographicCamera.position.copy(HOME_POSITION) + orthographicCamera.lookAt(HOME_TARGET) + + activeCamera = perspectiveCamera // Create renderer renderer = new THREE.WebGLRenderer({ antialias: true }) @@ -60,7 +79,7 @@ function initScene() { containerRef.value.appendChild(renderer.domElement) // Setup orbit controls - controls = new OrbitControls(camera, renderer.domElement) + controls = new OrbitControls(activeCamera, renderer.domElement) controls.enableDamping = true controls.dampingFactor = 0.05 @@ -139,8 +158,18 @@ function setupResizeObserver() { for (const entry of entries) { const { width, height } = entry.contentRect if (width > 0 && height > 0) { - camera.aspect = width / height - camera.updateProjectionMatrix() + const aspect = width / height + + perspectiveCamera.aspect = aspect + perspectiveCamera.updateProjectionMatrix() + + const frustumSize = 10 + orthographicCamera.left = (-frustumSize * aspect) / 2 + orthographicCamera.right = (frustumSize * aspect) / 2 + orthographicCamera.top = frustumSize / 2 + orthographicCamera.bottom = -frustumSize / 2 + orthographicCamera.updateProjectionMatrix() + renderer.setSize(width, height) } } @@ -152,7 +181,7 @@ function setupResizeObserver() { function animate() { animationFrameId = requestAnimationFrame(animate) controls.update() - renderer.render(scene, camera) + renderer.render(scene, activeCamera) } function fitToSelection(objects: THREE.Object3D[]) { @@ -166,31 +195,54 @@ function fitToSelection(objects: THREE.Object3D[]) { const center = box.getCenter(new THREE.Vector3()) const size = box.getSize(new THREE.Vector3()) const maxDim = Math.max(size.x, size.y, size.z) - const fov = camera.fov * (Math.PI / 180) - const distance = maxDim / (2 * Math.tan(fov / 2)) * 1.5 - const direction = camera.position.clone().sub(controls.target).normalize() - camera.position.copy(center).add(direction.multiplyScalar(distance)) + const fov = perspectiveCamera.fov * (Math.PI / 180) + const distance = (maxDim / (2 * Math.tan(fov / 2))) * 1.5 + + const direction = activeCamera.position.clone().sub(controls.target).normalize() + activeCamera.position.copy(center).add(direction.multiplyScalar(distance)) controls.target.copy(center) controls.update() } function resetView() { - camera.position.copy(HOME_POSITION) + activeCamera.position.copy(HOME_POSITION) controls.target.copy(HOME_TARGET) controls.update() } function setViewPreset(preset: ViewPreset) { const position = VIEW_POSITIONS[preset] - camera.position.copy(position) + activeCamera.position.copy(position) controls.target.set(0, 0, 0) - camera.up.set(0, 1, 0) - if (preset === 'top') camera.up.set(0, 0, -1) - if (preset === 'bottom') camera.up.set(0, 0, 1) + activeCamera.up.set(0, 1, 0) + if (preset === 'top') activeCamera.up.set(0, 0, -1) + if (preset === 'bottom') activeCamera.up.set(0, 0, 1) controls.update() } +function setOrthographic(ortho: boolean) { + if (isOrthographic === ortho) return + + const newCamera = ortho ? orthographicCamera : perspectiveCamera + + // Copy position and rotation from current camera + newCamera.position.copy(activeCamera.position) + newCamera.up.copy(activeCamera.up) + newCamera.lookAt(controls.target) + + activeCamera = newCamera + isOrthographic = ortho + + // Update controls to use new camera + controls.object = activeCamera + controls.update() +} + +function toggleProjection() { + setOrthographic(!isOrthographic) +} + function cleanup() { if (animationFrameId) { cancelAnimationFrame(animationFrameId) @@ -222,6 +274,8 @@ defineExpose({ fitToSelection, resetView, setViewPreset, + setOrthographic, + toggleProjection, })