diff --git a/src/components/HelloWorld.vue b/src/components/HelloWorld.vue deleted file mode 100644 index e402c273f..000000000 --- a/src/components/HelloWorld.vue +++ /dev/null @@ -1,38 +0,0 @@ - - - - - diff --git a/src/components/Viewport.vue b/src/components/Viewport.vue index 3d62c00eb..63e4dee22 100644 --- a/src/components/Viewport.vue +++ b/src/components/Viewport.vue @@ -2,6 +2,14 @@ import { ref, watch, onMounted, onBeforeUnmount } from 'vue' import * as THREE from 'three' import { OrbitControls } from 'three/addons/controls/OrbitControls.js' +import { + HOME_POSITION, + HOME_TARGET, + VIEW_POSITIONS, + calculateFitDistance, + getViewUpVector, + type ViewPreset, +} from '../utils/camera' const props = withDefaults( defineProps<{ @@ -28,21 +36,6 @@ let gridHelper: THREE.GridHelper let axisHelper: THREE.AxesHelper let controls: OrbitControls -const HOME_POSITION = new THREE.Vector3(5, 5, 5) -const HOME_TARGET = new THREE.Vector3(0, 0, 0) -const VIEW_DISTANCE = 10 - -type ViewPreset = 'front' | 'back' | 'top' | 'bottom' | 'left' | 'right' - -const VIEW_POSITIONS: Record = { - front: new THREE.Vector3(0, 0, VIEW_DISTANCE), - back: new THREE.Vector3(0, 0, -VIEW_DISTANCE), - top: new THREE.Vector3(0, VIEW_DISTANCE, 0), - bottom: new THREE.Vector3(0, -VIEW_DISTANCE, 0), - left: new THREE.Vector3(-VIEW_DISTANCE, 0, 0), - right: new THREE.Vector3(VIEW_DISTANCE, 0, 0), -} - function initScene() { if (!containerRef.value) return @@ -196,8 +189,7 @@ function fitToSelection(objects: THREE.Object3D[]) { const size = box.getSize(new THREE.Vector3()) const maxDim = Math.max(size.x, size.y, size.z) - const fov = perspectiveCamera.fov * (Math.PI / 180) - const distance = (maxDim / (2 * Math.tan(fov / 2))) * 1.5 + const distance = calculateFitDistance(perspectiveCamera.fov, maxDim) const direction = activeCamera.position.clone().sub(controls.target).normalize() activeCamera.position.copy(center).add(direction.multiplyScalar(distance)) @@ -215,9 +207,7 @@ function setViewPreset(preset: ViewPreset) { const position = VIEW_POSITIONS[preset] activeCamera.position.copy(position) controls.target.set(0, 0, 0) - activeCamera.up.set(0, 1, 0) - if (preset === 'top') activeCamera.up.set(0, 0, -1) - if (preset === 'bottom') activeCamera.up.set(0, 0, 1) + activeCamera.up.copy(getViewUpVector(preset)) controls.update() } diff --git a/src/components/__tests__/HelloWorld.spec.ts b/src/components/__tests__/HelloWorld.spec.ts deleted file mode 100644 index 58166e944..000000000 --- a/src/components/__tests__/HelloWorld.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { mount } from '@vue/test-utils' -import HelloWorld from '../HelloWorld.vue' - -describe('HelloWorld', () => { - it('renders msg prop', () => { - const wrapper = mount(HelloWorld, { - props: { - msg: 'Hello Vitest', - }, - }) - expect(wrapper.text()).toContain('Hello Vitest') - }) - - it('increments count on button click', async () => { - const wrapper = mount(HelloWorld, { - props: { - msg: 'Test', - }, - }) - const button = wrapper.find('button') - expect(button.text()).toContain('count is 0') - await button.trigger('click') - expect(button.text()).toContain('count is 1') - }) -}) diff --git a/src/utils/camera.spec.ts b/src/utils/camera.spec.ts new file mode 100644 index 000000000..8aee83732 --- /dev/null +++ b/src/utils/camera.spec.ts @@ -0,0 +1,142 @@ +import { describe, it, expect } from 'vitest' +import { + HOME_POSITION, + HOME_TARGET, + VIEW_DISTANCE, + VIEW_POSITIONS, + calculateFitDistance, + getViewUpVector, +} from './camera' + +describe('camera utilities', () => { + describe('constants', () => { + it('HOME_POSITION is at (5, 5, 5)', () => { + expect(HOME_POSITION.x).toBe(5) + expect(HOME_POSITION.y).toBe(5) + expect(HOME_POSITION.z).toBe(5) + }) + + it('HOME_TARGET is at origin', () => { + expect(HOME_TARGET.x).toBe(0) + expect(HOME_TARGET.y).toBe(0) + expect(HOME_TARGET.z).toBe(0) + }) + + it('VIEW_DISTANCE is 10', () => { + expect(VIEW_DISTANCE).toBe(10) + }) + }) + + describe('VIEW_POSITIONS', () => { + it('front view is on positive Z axis', () => { + expect(VIEW_POSITIONS.front.x).toBe(0) + expect(VIEW_POSITIONS.front.y).toBe(0) + expect(VIEW_POSITIONS.front.z).toBe(VIEW_DISTANCE) + }) + + it('back view is on negative Z axis', () => { + expect(VIEW_POSITIONS.back.x).toBe(0) + expect(VIEW_POSITIONS.back.y).toBe(0) + expect(VIEW_POSITIONS.back.z).toBe(-VIEW_DISTANCE) + }) + + it('top view is on positive Y axis', () => { + expect(VIEW_POSITIONS.top.x).toBe(0) + expect(VIEW_POSITIONS.top.y).toBe(VIEW_DISTANCE) + expect(VIEW_POSITIONS.top.z).toBe(0) + }) + + it('bottom view is on negative Y axis', () => { + expect(VIEW_POSITIONS.bottom.x).toBe(0) + expect(VIEW_POSITIONS.bottom.y).toBe(-VIEW_DISTANCE) + expect(VIEW_POSITIONS.bottom.z).toBe(0) + }) + + it('left view is on negative X axis', () => { + expect(VIEW_POSITIONS.left.x).toBe(-VIEW_DISTANCE) + expect(VIEW_POSITIONS.left.y).toBe(0) + expect(VIEW_POSITIONS.left.z).toBe(0) + }) + + it('right view is on positive X axis', () => { + expect(VIEW_POSITIONS.right.x).toBe(VIEW_DISTANCE) + expect(VIEW_POSITIONS.right.y).toBe(0) + expect(VIEW_POSITIONS.right.z).toBe(0) + }) + }) + + describe('calculateFitDistance', () => { + it('returns positive distance for valid inputs', () => { + const distance = calculateFitDistance(45, 1) + expect(distance).toBeGreaterThan(0) + }) + + it('increases distance for larger objects', () => { + const smallDistance = calculateFitDistance(45, 1) + const largeDistance = calculateFitDistance(45, 10) + expect(largeDistance).toBeGreaterThan(smallDistance) + }) + + it('increases distance for narrower FOV', () => { + const wideFovDistance = calculateFitDistance(90, 1) + const narrowFovDistance = calculateFitDistance(30, 1) + expect(narrowFovDistance).toBeGreaterThan(wideFovDistance) + }) + + it('applies padding multiplier', () => { + const noPadding = calculateFitDistance(45, 1, 1.0) + const withPadding = calculateFitDistance(45, 1, 2.0) + expect(withPadding).toBeCloseTo(noPadding * 2) + }) + + it('uses default padding of 1.5', () => { + const defaultPadding = calculateFitDistance(45, 1) + const explicitPadding = calculateFitDistance(45, 1, 1.5) + expect(defaultPadding).toBeCloseTo(explicitPadding) + }) + }) + + describe('getViewUpVector', () => { + it('returns Y-up for front view', () => { + const up = getViewUpVector('front') + expect(up.x).toBe(0) + expect(up.y).toBe(1) + expect(up.z).toBe(0) + }) + + it('returns Y-up for back view', () => { + const up = getViewUpVector('back') + expect(up.x).toBe(0) + expect(up.y).toBe(1) + expect(up.z).toBe(0) + }) + + it('returns Y-up for left view', () => { + const up = getViewUpVector('left') + expect(up.x).toBe(0) + expect(up.y).toBe(1) + expect(up.z).toBe(0) + }) + + it('returns Y-up for right view', () => { + const up = getViewUpVector('right') + expect(up.x).toBe(0) + expect(up.y).toBe(1) + expect(up.z).toBe(0) + }) + + it('returns negative Z-up for top view', () => { + const up = getViewUpVector('top') + expect(up.x).toBe(0) + expect(up.y).toBe(0) + expect(up.z).toBe(-1) + }) + + it('returns positive Z-up for bottom view', () => { + const up = getViewUpVector('bottom') + expect(up.x).toBe(0) + expect(up.y).toBe(0) + expect(up.z).toBe(1) + }) + }) +}) diff --git a/src/utils/camera.ts b/src/utils/camera.ts new file mode 100644 index 000000000..5b1547e56 --- /dev/null +++ b/src/utils/camera.ts @@ -0,0 +1,46 @@ +import * as THREE from 'three' + +export const HOME_POSITION = new THREE.Vector3(5, 5, 5) +export const HOME_TARGET = new THREE.Vector3(0, 0, 0) +export const VIEW_DISTANCE = 10 + +export type ViewPreset = 'front' | 'back' | 'top' | 'bottom' | 'left' | 'right' + +export const VIEW_POSITIONS: Record = { + front: new THREE.Vector3(0, 0, VIEW_DISTANCE), + back: new THREE.Vector3(0, 0, -VIEW_DISTANCE), + top: new THREE.Vector3(0, VIEW_DISTANCE, 0), + bottom: new THREE.Vector3(0, -VIEW_DISTANCE, 0), + left: new THREE.Vector3(-VIEW_DISTANCE, 0, 0), + right: new THREE.Vector3(VIEW_DISTANCE, 0, 0), +} + +/** + * Calculate the camera distance needed to fit an object of given size in view + * @param fovDegrees - Camera field of view in degrees + * @param boundingSize - Maximum dimension of the bounding box + * @param padding - Multiplier for extra space around the object (default 1.5) + */ +export function calculateFitDistance( + fovDegrees: number, + boundingSize: number, + padding = 1.5 +): number { + const fovRadians = fovDegrees * (Math.PI / 180) + return (boundingSize / (2 * Math.tan(fovRadians / 2))) * padding +} + +/** + * Get the up vector for a given view preset + * Most views use Y-up, but top/bottom need special handling + */ +export function getViewUpVector(preset: ViewPreset): THREE.Vector3 { + switch (preset) { + case 'top': + return new THREE.Vector3(0, 0, -1) + case 'bottom': + return new THREE.Vector3(0, 0, 1) + default: + return new THREE.Vector3(0, 1, 0) + } +}