import { describe, expect, it } from 'vitest' import * as THREE from 'three' import { createGuideObliqueReturnPose, createGuideTopViewPose, getGuideTopViewScreenBasis, getPerspectiveDistanceForHorizontalSpan, getPerspectiveHorizontalVisibleSpan } from '@/domain/guideTopView' const createObliqueCamera = () => { const yaw = THREE.MathUtils.degToRad(54) const elevation = THREE.MathUtils.degToRad(42) const horizontal = Math.cos(elevation) const target = new THREE.Vector3(29.0598, -61.8768, 23.9424) const direction = new THREE.Vector3( Math.sin(yaw) * horizontal, Math.sin(elevation), Math.cos(yaw) * horizontal ).normalize() const camera = new THREE.PerspectiveCamera(42, 390 / 844, 0.1, 20_000) camera.position.copy(target).addScaledVector(direction, 720) camera.up.set(0, 1, 0) camera.lookAt(target) camera.updateProjectionMatrix() camera.updateMatrixWorld() return { camera, target } } describe('GuideTopView', () => { it('derives the top-view basis from the current camera screen-right direction', () => { const { camera } = createObliqueCamera() const basis = getGuideTopViewScreenBasis(camera.quaternion) const yaw = THREE.MathUtils.degToRad(54) expect(basis.screenRight.x).toBeCloseTo(Math.cos(yaw), 8) expect(basis.screenRight.y).toBeCloseTo(0, 8) expect(basis.screenRight.z).toBeCloseTo(-Math.sin(yaw), 8) expect(basis.screenDown.x).toBeCloseTo(Math.sin(yaw), 8) expect(basis.screenDown.z).toBeCloseTo(Math.cos(yaw), 8) expect(basis.screenRight.dot(basis.screenDown)).toBeCloseTo(0, 8) }) it('keeps target, horizontal scale, and screen direction when entering top view', () => { const { camera, target } = createObliqueCamera() const originalDistance = camera.position.distanceTo(target) const originalSpan = getPerspectiveHorizontalVisibleSpan( originalDistance, camera.fov, camera.aspect ) const pose = createGuideTopViewPose({ position: camera.position, target, quaternion: camera.quaternion, fov: camera.fov, aspect: camera.aspect }) expect(pose.target.distanceTo(target)).toBeLessThan(1e-9) expect(pose.position.x).toBeCloseTo(target.x, 9) expect(pose.position.z).toBeCloseTo(target.z, 9) expect(pose.position.y - target.y).toBeCloseTo(originalDistance, 9) expect(pose.visibleWorldSpan).toBeCloseTo(originalSpan, 9) expect(getPerspectiveDistanceForHorizontalSpan( pose.visibleWorldSpan, camera.fov, camera.aspect )).toBeCloseTo(originalDistance, 9) const topCamera = camera.clone() topCamera.position.copy(pose.position) topCamera.up.copy(pose.up) topCamera.lookAt(pose.target) topCamera.updateMatrixWorld() const left = target.clone().addScaledVector(pose.screenRight, -20) const right = target.clone().addScaledVector(pose.screenRight, 20) const obliqueWidth = right.clone().project(camera).x - left.clone().project(camera).x const topWidth = right.clone().project(topCamera).x - left.clone().project(topCamera).x expect(topWidth).toBeCloseTo(obliqueWidth, 8) }) it('restores the exact oblique pose when the top viewport did not move', () => { const { camera, target } = createObliqueCamera() const result = createGuideObliqueReturnPose({ originalPosition: camera.position, originalTarget: target, currentTarget: target.clone(), currentDistance: camera.position.distanceTo(target) }) expect(result.viewportChanged).toBe(false) expect(result.position.distanceTo(camera.position)).toBeLessThan(1e-9) expect(result.target.distanceTo(target)).toBeLessThan(1e-9) }) it('preserves a changed world center and scale when returning to oblique view', () => { const { camera, target } = createObliqueCamera() const currentTarget = target.clone().add(new THREE.Vector3(18, 0, -11)) const result = createGuideObliqueReturnPose({ originalPosition: camera.position, originalTarget: target, currentTarget, currentDistance: 540 }) const originalDirection = camera.position.clone().sub(target).normalize() const restoredDirection = result.position.clone().sub(result.target).normalize() expect(result.viewportChanged).toBe(true) expect(result.target.distanceTo(currentTarget)).toBeLessThan(1e-9) expect(result.position.distanceTo(result.target)).toBeCloseTo(540, 9) expect(restoredDirection.distanceTo(originalDirection)).toBeLessThan(1e-9) }) })