Warning, /firebird/packages/firebird-ng/display/src/lib/services/camera-framing.spec.ts is written in an unsupported language. File is not indexed.
0001 /**
0002 * Camera framing: what no pack pins derives from the loaded geometry's
0003 * bounding sphere (orbit limits; the home view while the camera has not
0004 * moved), and what a pack pins (a home pose and limits) stays exactly as
0005 * pinned. Presets come from the registry; face views keep the orbit
0006 * target and distance.
0007 */
0008 import { TestBed } from '@angular/core/testing';
0009 import { BoxGeometry, Group, Mesh } from 'three';
0010 import { ThreeService } from './three.service';
0011 import { PerfService } from './perf.service';
0012 import { RenderViewImpl } from './render-view';
0013 import { withCameraLimits, withCameraPreset, type CameraPreset } from '@dexvis/firebird-ng/api';
0014 import { withStandardCameraPresets } from '@dexvis/firebird-ng';
0015
0016 /** The private members these specs set up instead of a WebGPU init. */
0017 type ThreeInternals = {
0018 views: RenderViewImpl[];
0019 sceneGeometry: Group;
0020 initialized: boolean;
0021 pinnedHomePose: () => RenderViewImpl['startPose'] | undefined;
0022 cameraLimits: { minDistance: number; maxDistance: number };
0023 scheduleGeometryBvh: () => void;
0024 };
0025
0026 /** A pack's pinned home view: a top view from 7 m with x up. */
0027 const PINNED_HOME: CameraPreset = { name: 'home', position: [0, 7000, 0], target: [0, 0, 0], up: [1, 0, 0] };
0028
0029 /** A ThreeService with a real main view over a fake renderer, and a 2 m cube as geometry. */
0030 function setup(providers: unknown[]) {
0031 TestBed.configureTestingModule({
0032 providers: [ThreeService, { provide: PerfService, useValue: { updateStats: () => undefined } }, ...providers as never[]],
0033 });
0034 const service = TestBed.inject(ThreeService);
0035 const internals = service as unknown as ThreeInternals;
0036 const renderer = { domElement: document.createElement('canvas') } as unknown as ConstructorParameters<typeof RenderViewImpl>[0];
0037 const main = new RenderViewImpl(renderer, {
0038 name: 'main',
0039 container: document.createElement('div'),
0040 startPose: internals.pinnedHomePose(),
0041 distanceLimits: internals.cameraLimits,
0042 });
0043 internals.views = [main];
0044 internals.sceneGeometry = new Group();
0045 internals.initialized = true;
0046 internals.scheduleGeometryBvh = () => undefined;
0047 const loadCube = () => {
0048 internals.sceneGeometry.add(new Mesh(new BoxGeometry(2000, 2000, 2000)));
0049 internals.sceneGeometry.updateMatrixWorld(true);
0050 service.geometryChanged();
0051 };
0052 return { service, main, loadCube };
0053 }
0054
0055 describe('camera framing', () => {
0056 const cubeRadius = Math.sqrt(3) * 1000;
0057
0058 it('without pins: derives the limits from the geometry and frames it from the home direction', () => {
0059 const { main, loadCube } = setup([...withStandardCameraPresets().providers]);
0060 expect(main.isAtStartPose()).toBe(true);
0061 loadCube();
0062
0063 expect(main.controls.minDistance).toBeCloseTo(0.05 * cubeRadius);
0064 expect(main.controls.maxDistance).toBeCloseTo(5 * cubeRadius);
0065 expect(main.camera.far).toBeCloseTo(5.5 * cubeRadius);
0066 // Top view, the sphere fitting the 60 degree field of view: r / sin(30)
0067 expect(main.camera.position.y).toBeCloseTo(2 * cubeRadius);
0068 expect(main.controls.target.length()).toBeCloseTo(0);
0069 expect(main.camera.up.toArray()).toEqual([1, 0, 0]);
0070 });
0071
0072 it('without pins: leaves a camera the user already moved where it is', () => {
0073 const { main, loadCube } = setup([...withStandardCameraPresets().providers]);
0074 main.camera.position.set(100, 200, 300);
0075 loadCube();
0076 expect(main.camera.position.toArray()).toEqual([100, 200, 300]);
0077 expect(main.controls.maxDistance).toBeCloseTo(5 * cubeRadius);
0078 });
0079
0080 it('with a pinned home and limits: starts at the pinned home and keeps pose and limits after a load', () => {
0081 const { main, loadCube } = setup([
0082 ...withStandardCameraPresets().providers,
0083 ...withCameraPreset(PINNED_HOME).providers,
0084 ...withCameraLimits({ minDistance: 750, maxDistance: 75000 }).providers,
0085 ]);
0086 // Rounded: OrbitControls leaves sub-nanometer noise on the exact pose
0087 const roundedPosition = () => main.camera.position.toArray().map(value => Math.round(value) + 0);
0088 expect(roundedPosition()).toEqual([0, 7000, 0]);
0089 expect(main.camera.far).toBe(82500);
0090 loadCube();
0091 expect(roundedPosition()).toEqual([0, 7000, 0]);
0092 expect([main.controls.minDistance, main.controls.maxDistance, main.camera.far]).toEqual([750, 75000, 82500]);
0093 });
0094
0095 it('a face preset keeps the orbit target and distance; a fixed pose sets all three', () => {
0096 const { service, main } = setup([...withStandardCameraPresets().providers]);
0097 main.controls.target.set(0, 0, 1000);
0098 main.camera.position.set(0, 3000, 1000);
0099 service.applyCameraPreset({ name: 'front', direction: [-1, 0, 0], up: [0, 1, 0] });
0100 expect(main.camera.position.toArray().map(value => Math.round(value) + 0)).toEqual([-3000, 0, 1000]);
0101 expect(main.camera.up.toArray()).toEqual([0, 1, 0]);
0102
0103 service.applyCameraPreset({ name: 'downstream', position: [8000, 7500, 40000], target: [0, 0, 30000], up: [0, 1, 0] });
0104 expect(main.controls.target.toArray()).toEqual([0, 0, 30000]);
0105 expect(main.camera.position.toArray().map(value => Math.round(value) + 0)).toEqual([8000, 7500, 40000]);
0106 });
0107 });