import { createCamera3D, setCamera3DViewMatrix4FromMatrix4, createPerspectiveProjection, setCamera3DViewMatrix4FromLookAt, } from '@flighthq/camera/contract'; import { copyQuaternion, createMatrix4, createQuaternion, setQuaternionFromAxisAngle, setVector3, } from '@flighthq/geometry/contract'; import { createPlaneMeshGeometry } from '@flighthq/mesh/contract'; import { addNodeChild, getNodeWorldMatrix4, invalidateNodeLocalTransform } from '@flighthq/node/contract'; import type { Camera3D } from '@flighthq/types/contract'; import { describe, expect, it } from 'vitest'; import { createBillboard } from './billboard'; import { orientBillboardToCamera, orientScene3DBillboardsToCamera, setBillboardCameraBasisGuard, } from './billboardCamera'; import { createMesh } from './mesh'; import { createNode3D } from './sceneNode'; // A camera at (ex,ey,ez) looking at the world origin with world +Y up. function cameraLookingFrom(ex: number, ey: number, ez: number): Camera3D { const camera = createCamera3D({ far: 100, near: 0.1, projection: createPerspectiveProjection({ aspect: 1, fovY: 1 }), }); setCamera3DViewMatrix4FromLookAt(camera, { x: ex, y: ey, z: ez }, { x: 0, y: 0, z: 0 }, { x: 0, y: 1, z: 0 }); return camera; } describe('orientBillboardToCamera', () => { // The setter split matters here and is worth keeping visible: a lookAt camera CANNOT reach this branch // (setMatrix4LookAt reseeds both degenerate bases), so the only way in is an arbitrary view matrix // copied through setCamera3DViewMatrix4FromMatrix4, which validates nothing. it('declines and leaves the billboard untouched when the view matrix has no inverse', () => { const billboard = createBillboard(createPlaneMeshGeometry(), [null], 'full'); setVector3(billboard.position, 1, 2, 3); invalidateNodeLocalTransform(billboard); const before = Array.from(getNodeWorldMatrix4(billboard).m); const camera = createCamera3D({ far: 100, near: 0.1, projection: createPerspectiveProjection({ aspect: 1, fovY: 1 }), }); setCamera3DViewMatrix4FromMatrix4(camera, createMatrix4(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)); expect(orientBillboardToCamera(billboard, camera)).toBe(false); expect(Array.from(getNodeWorldMatrix4(billboard).m)).toEqual(before); for (const value of getNodeWorldMatrix4(billboard).m) expect(Number.isFinite(value)).toBe(true); }); it('reports true for an invertible view', () => { const billboard = createBillboard(createPlaneMeshGeometry(), [null], 'full'); expect(orientBillboardToCamera(billboard, cameraLookingFrom(0, 0, 10))).toBe(true); }); it('full mode at the origin faces a +Z camera with an identity basis', () => { const billboard = createBillboard(createPlaneMeshGeometry(), [null], 'full'); orientBillboardToCamera(billboard, cameraLookingFrom(0, 0, 10)); const m = getNodeWorldMatrix4(billboard).m; // right = +X, up = +Y, normal(+Z) toward the camera. expect(m[0]).toBeCloseTo(1, 5); expect(m[5]).toBeCloseTo(1, 5); expect(m[10]).toBeCloseTo(1, 5); expect(m[1]).toBeCloseTo(0, 5); expect(m[4]).toBeCloseTo(0, 5); expect(m[8]).toBeCloseTo(0, 5); }); it('full mode points the normal at the camera eye and preserves world position', () => { const billboard = createBillboard(createPlaneMeshGeometry(), [null], 'full'); setVector3(billboard.position, 3, 0, 0); invalidateNodeLocalTransform(billboard); orientBillboardToCamera(billboard, cameraLookingFrom(0, 0, 10)); const m = getNodeWorldMatrix4(billboard).m; // Position preserved. expect(m[12]).toBeCloseTo(3, 5); expect(m[13]).toBeCloseTo(0, 5); expect(m[14]).toBeCloseTo(0, 5); // Normal = normalize(eye - position) = normalize((-3, 0, 10)). const length = Math.hypot(-3, 0, 10); expect(m[8]).toBeCloseTo(-3 / length, 5); expect(m[9]).toBeCloseTo(0, 5); expect(m[10]).toBeCloseTo(10 / length, 5); }); it('full mode preserves world scale in the facing basis', () => { const billboard = createBillboard(createPlaneMeshGeometry(), [null], 'full'); setVector3(billboard.scale, 2, 2, 2); invalidateNodeLocalTransform(billboard); orientBillboardToCamera(billboard, cameraLookingFrom(0, 0, 10)); const m = getNodeWorldMatrix4(billboard).m; expect(Math.hypot(m[0], m[1], m[2])).toBeCloseTo(2, 5); expect(Math.hypot(m[4], m[5], m[6])).toBeCloseTo(2, 5); expect(Math.hypot(m[8], m[9], m[10])).toBeCloseTo(2, 5); }); it('axisY mode yaws about world +Y and stays upright', () => { const billboard = createBillboard(createPlaneMeshGeometry(), [null], 'axisY'); orientBillboardToCamera(billboard, cameraLookingFrom(10, 5, 0)); const m = getNodeWorldMatrix4(billboard).m; // Normal projected onto XZ toward the camera = +X; up stays world +Y; right = up x normal = -Z. expect(m[8]).toBeCloseTo(1, 5); expect(m[9]).toBeCloseTo(0, 5); expect(m[10]).toBeCloseTo(0, 5); expect(m[4]).toBeCloseTo(0, 5); expect(m[5]).toBeCloseTo(1, 5); expect(m[6]).toBeCloseTo(0, 5); expect(m[0]).toBeCloseTo(0, 5); expect(m[2]).toBeCloseTo(-1, 5); }); it('screenAligned mode adopts the camera basis regardless of position', () => { const billboard = createBillboard(createPlaneMeshGeometry(), [null], 'screenAligned'); setVector3(billboard.position, 5, 3, 0); invalidateNodeLocalTransform(billboard); orientBillboardToCamera(billboard, cameraLookingFrom(0, 0, 10)); const m = getNodeWorldMatrix4(billboard).m; expect(m[0]).toBeCloseTo(1, 5); expect(m[5]).toBeCloseTo(1, 5); expect(m[10]).toBeCloseTo(1, 5); expect(m[12]).toBeCloseTo(5, 5); expect(m[13]).toBeCloseTo(3, 5); expect(m[14]).toBeCloseTo(0, 5); }); it('is stable across repeated orientation (no scale or position drift)', () => { const billboard = createBillboard(createPlaneMeshGeometry(), [null], 'full'); setVector3(billboard.position, 3, 0, 0); invalidateNodeLocalTransform(billboard); setVector3(billboard.scale, 2, 2, 2); invalidateNodeLocalTransform(billboard); const camera = cameraLookingFrom(0, 0, 10); orientBillboardToCamera(billboard, camera); orientBillboardToCamera(billboard, camera); orientBillboardToCamera(billboard, camera); const m = getNodeWorldMatrix4(billboard).m; expect(m[12]).toBeCloseTo(3, 5); expect(Math.hypot(m[0], m[1], m[2])).toBeCloseTo(2, 5); expect(Math.hypot(m[8], m[9], m[10])).toBeCloseTo(2, 5); }); }); describe('orientScene3DBillboardsToCamera', () => { it('orients billboards in a subtree and leaves non-billboards untouched', () => { const root = createNode3D(); const mesh = createMesh(createPlaneMeshGeometry(), [null]); setVector3(mesh.position, 7, 0, 0); invalidateNodeLocalTransform(mesh); const billboard = createBillboard(createPlaneMeshGeometry(), [null], 'full'); addNodeChild(root, mesh); addNodeChild(root, billboard); orientScene3DBillboardsToCamera(root, cameraLookingFrom(0, 0, 10)); // Billboard faces the camera. const b = getNodeWorldMatrix4(billboard).m; expect(b[0]).toBeCloseTo(1, 5); expect(b[10]).toBeCloseTo(1, 5); // Mesh is untouched: its authored position is intact. const meshMatrix = getNodeWorldMatrix4(mesh).m; expect(meshMatrix[12]).toBeCloseTo(7, 5); }); it('faces the camera in world space regardless of parent rotation', () => { const parent = createNode3D(); const rotation = createQuaternion(0, 0, 0, 1); setQuaternionFromAxisAngle(rotation, { x: 0, y: 1, z: 0 }, Math.PI / 2); copyQuaternion(parent.rotation, rotation); invalidateNodeLocalTransform(parent); const billboard = createBillboard(createPlaneMeshGeometry(), [null], 'full'); addNodeChild(parent, billboard); orientScene3DBillboardsToCamera(parent, cameraLookingFrom(0, 0, 10)); // Parent is yawed 90°, but the billboard's world basis still faces the camera (identity). const m = getNodeWorldMatrix4(billboard).m; expect(m[0]).toBeCloseTo(1, 5); expect(m[5]).toBeCloseTo(1, 5); expect(m[10]).toBeCloseTo(1, 5); expect(m[2]).toBeCloseTo(0, 5); expect(m[8]).toBeCloseTo(0, 5); }); }); describe('setBillboardCameraBasisGuard', () => { it('is notified on the decline and never on an invertible view', () => { const seen: number[] = []; setBillboardCameraBasisGuard((camera) => seen.push(camera.view.m[0])); try { const camera = createCamera3D({ far: 100, near: 0.1, projection: createPerspectiveProjection({ aspect: 1, fovY: 1 }), }); setCamera3DViewMatrix4FromMatrix4(camera, createMatrix4(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)); orientBillboardToCamera(createBillboard(createPlaneMeshGeometry(), [null], 'full'), camera); orientBillboardToCamera(createBillboard(createPlaneMeshGeometry(), [null], 'full'), cameraLookingFrom(0, 0, 10)); expect(seen).toEqual([0]); } finally { setBillboardCameraBasisGuard(null); } }); });