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 { ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
|
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(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
|
@ -28,21 +36,6 @@ let gridHelper: THREE.GridHelper
|
||||||
let axisHelper: THREE.AxesHelper
|
let axisHelper: THREE.AxesHelper
|
||||||
let controls: OrbitControls
|
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() {
|
function initScene() {
|
||||||
if (!containerRef.value) return
|
if (!containerRef.value) return
|
||||||
|
|
||||||
|
|
@ -196,8 +189,7 @@ function fitToSelection(objects: THREE.Object3D[]) {
|
||||||
const size = box.getSize(new THREE.Vector3())
|
const size = box.getSize(new THREE.Vector3())
|
||||||
const maxDim = Math.max(size.x, size.y, size.z)
|
const maxDim = Math.max(size.x, size.y, size.z)
|
||||||
|
|
||||||
const fov = perspectiveCamera.fov * (Math.PI / 180)
|
const distance = calculateFitDistance(perspectiveCamera.fov, maxDim)
|
||||||
const distance = (maxDim / (2 * Math.tan(fov / 2))) * 1.5
|
|
||||||
|
|
||||||
const direction = activeCamera.position.clone().sub(controls.target).normalize()
|
const direction = activeCamera.position.clone().sub(controls.target).normalize()
|
||||||
activeCamera.position.copy(center).add(direction.multiplyScalar(distance))
|
activeCamera.position.copy(center).add(direction.multiplyScalar(distance))
|
||||||
|
|
@ -215,9 +207,7 @@ function setViewPreset(preset: ViewPreset) {
|
||||||
const position = VIEW_POSITIONS[preset]
|
const position = VIEW_POSITIONS[preset]
|
||||||
activeCamera.position.copy(position)
|
activeCamera.position.copy(position)
|
||||||
controls.target.set(0, 0, 0)
|
controls.target.set(0, 0, 0)
|
||||||
activeCamera.up.set(0, 1, 0)
|
activeCamera.up.copy(getViewUpVector(preset))
|
||||||
if (preset === 'top') activeCamera.up.set(0, 0, -1)
|
|
||||||
if (preset === 'bottom') activeCamera.up.set(0, 0, 1)
|
|
||||||
controls.update()
|
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