Extract camera utils
This commit is contained in:
parent
128bfde8c0
commit
33ade7f289
5 changed files with 198 additions and 84 deletions
|
|
@ -1,38 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{ msg: string }>()
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
|
||||
<div class="card">
|
||||
<button type="button" @click="count++">count is {{ count }}</button>
|
||||
<p>
|
||||
Edit
|
||||
<code>components/HelloWorld.vue</code> to test HMR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Check out
|
||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank">create-vue</a>, the
|
||||
official Vue + Vite starter
|
||||
</p>
|
||||
<p>
|
||||
Learn more about IDE Support for Vue in the
|
||||
<a href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support" target="_blank"
|
||||
>Vue Docs Scaling up Guide</a
|
||||
>.
|
||||
</p>
|
||||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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<ViewPreset, THREE.Vector3> = {
|
||||
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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
142
src/utils/camera.spec.ts
Normal file
142
src/utils/camera.spec.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
46
src/utils/camera.ts
Normal file
46
src/utils/camera.ts
Normal file
|
|
@ -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<ViewPreset, THREE.Vector3> = {
|
||||
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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue