Extract camera utils

This commit is contained in:
Nettika 2026-01-29 00:13:54 -08:00
parent 128bfde8c0
commit 33ade7f289
No known key found for this signature in database
5 changed files with 198 additions and 84 deletions

View file

@ -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>

View file

@ -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()
}

View file

@ -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
View 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
View 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)
}
}