Warning, /firebird/packages/firebird-ng/display/src/lib/services/render-view.ts is written in an unsupported language. File is not indexed.
0001 /**
0002 * RenderViewImpl - one view of the shared three.js scene (the `RenderView`
0003 * contract of @dexvis/firebird-ng/api).
0004 *
0005 * ThreeService owns the single Scene, the renderer, and the one render loop.
0006 * A RenderView owns everything that is per-view: the DOM container it draws
0007 * into, its cameras (perspective + orthographic pair), the OrbitControls
0008 * bound to that container, the viewport/scissor rectangle inside the shared
0009 * canvas, and any overlays drawn on top of the view (e.g. the navigation
0010 * cube).
0011 *
0012 * The main display is view #0; a multi-view page (like the quad projection
0013 * view) adds more views over the same scene. Views are plain TS objects
0014 * created by `ThreeService.addView()` — no DI here.
0015 */
0016
0017 import {
0018 MathUtils,
0019 OrthographicCamera,
0020 PerspectiveCamera,
0021 Plane,
0022 Quaternion,
0023 Raycaster,
0024 Vector2,
0025 Vector3,
0026 } from 'three';
0027 import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
0028 import type { WebGPURenderer } from 'three/webgpu';
0029 import { BehaviorSubject } from 'rxjs';
0030 import {
0031 EVENT_DATA_LAYER,
0032 GEOMETRY_SLICE_LAYER,
0033 type CameraLimits,
0034 type CameraPose,
0035 type GeometrySlice,
0036 type RenderView,
0037 type RenderViewOptions,
0038 type ViewOverlay,
0039 type ViewportRect,
0040 } from '@dexvis/firebird-ng/api';
0041 import { runIsolated } from './render-loop';
0042
0043 /**
0044 * The start pose without a pinned home view: the HENP top view, camera 7 m
0045 * above the detector looking down, beam (Z) pointing right on screen, X
0046 * toward the top of the screen - hence up = +X while looking along -Y.
0047 */
0048 export const DEFAULT_START_POSE: CameraPose = { position: [0, 7000, 0], target: [0, 0, 0], up: [1, 0, 0] };
0049
0050 /** Orbit limits before a geometry load derives or a pack pins others: a 15 m scene radius, 0.05 r to 5 r. */
0051 export const DEFAULT_CAMERA_LIMITS: CameraLimits = { minDistance: 750, maxDistance: 75000 };
0052
0053 const POLE_LIMIT = Math.cos(15 * Math.PI / 180);
0054
0055 /**
0056 * The display's implementation of the `RenderView` contract, with the
0057 * methods only the render loop and frame capture call.
0058 */
0059 export class RenderViewImpl implements RenderView {
0060 readonly name: string;
0061 container: HTMLElement;
0062
0063 perspectiveCamera: PerspectiveCamera;
0064 orthographicCamera: OrthographicCamera;
0065 /** The active camera (perspective or orthographic). */
0066 camera: PerspectiveCamera | OrthographicCamera;
0067 /** Emits true when the active camera is the perspective one. */
0068 cameraMode$ = new BehaviorSubject<boolean>(true);
0069
0070 controls: OrbitControls;
0071
0072 /** Fixed-projection views (top/front/right) disable rotation. */
0073 readonly isRotationLocked: boolean;
0074
0075 /** The pose the camera had at construction (see isAtStartPose). */
0076 readonly startPose: CameraPose;
0077
0078 /**
0079 * This view's world-space geometry cut, applied through `geometrySlice`
0080 * right before the view renders. Null when the view has no per-view cut.
0081 */
0082 clipPlane: Plane | null = null;
0083 /** The shared slice the clip plane is written into (see ClippedGeometrySlice). */
0084 geometrySlice: GeometrySlice | null = null;
0085
0086 /**
0087 * When true, event data draws over geometry regardless of depth: the scene
0088 * renders in two passes (geometry+helpers first, then the event layer over
0089 * a cleared depth buffer). Projection views default this ON — the point of
0090 * a cross-section view is watching tracks curve over the detector outline.
0091 * Toggling is a per-render-call camera-layer/clear-flag change, never a
0092 * material change (per-view material flips would rebuild shader pipelines
0093 * every frame).
0094 */
0095 tracksOnTop = false;
0096
0097 /**
0098 * Render-on-demand flag: true when this view needs a new frame. SET by
0099 * events (controls 'change', per-view knob changes) or by
0100 * ThreeService.invalidate(); the render loop reads and clears it. Any
0101 * dirty view triggers a frame that repaints ALL views — the shared
0102 * canvas's drawing buffer does not survive compositing, so partial
0103 * repaints would leave the other rectangles as cleared background.
0104 */
0105 dirty = true;
0106
0107 private renderer: WebGPURenderer;
0108 private overlays: ViewOverlay[] = [];
0109
0110 /** Cached viewport rect; recomputed by updateViewport() on layout changes. */
0111 private viewport: ViewportRect = { x: 0, y: 0, width: 1, height: 1 };
0112 /** CSS-pixel size of the container, cached with the viewport. */
0113 private viewSize = { width: 1, height: 1 };
0114
0115 private raycaster = new Raycaster();
0116
0117 // Scratch vectors for pole re-anchoring (no per-frame allocation)
0118 private readonly poleOffset = new Vector3();
0119 private readonly poleScreenUp = new Vector3();
0120
0121 constructor(renderer: WebGPURenderer, options: RenderViewOptions) {
0122 this.name = options.name;
0123 this.renderer = renderer;
0124 this.container = options.container;
0125 this.isRotationLocked = !!options.fixedDirection;
0126 if (options.clipPlane) {
0127 this.clipPlane = new Plane(new Vector3(...options.clipPlane.normal), options.clipPlane.constant);
0128 }
0129 this.geometrySlice = options.geometrySlice ?? null;
0130 this.tracksOnTop = options.tracksOnTop ?? false;
0131 // Picking is scoped by subtree (event data, original geometry), not by
0132 // layer — and original geometry moves off layer 0 when a slice exists,
0133 // so the raycaster must not filter by layers at all.
0134 this.raycaster.layers.enableAll();
0135
0136 // The up vector must be set BEFORE OrbitControls is constructed: the
0137 // controls capture the up axis once, in their constructor.
0138 this.startPose = options.startPose ?? DEFAULT_START_POSE;
0139 this.perspectiveCamera = new PerspectiveCamera(60, 1, 10, 40000);
0140 this.perspectiveCamera.position.set(...this.startPose.position);
0141 this.perspectiveCamera.up.set(...this.startPose.up);
0142
0143 const orthoSize = (options.orthoWorldHeight ?? 2000) / 2;
0144 this.orthographicCamera = new OrthographicCamera(
0145 -orthoSize, orthoSize,
0146 orthoSize, -orthoSize,
0147 // Negative near plane: orthographic views must see objects behind the
0148 // camera position (the camera sits inside the world bounds).
0149 -10000, 40000
0150 );
0151 this.orthographicCamera.position.copy(this.perspectiveCamera.position);
0152 this.orthographicCamera.up.copy(this.perspectiveCamera.up);
0153 this.orthographicCamera.lookAt(...this.startPose.target);
0154
0155 this.camera = options.orthographic ? this.orthographicCamera : this.perspectiveCamera;
0156 this.cameraMode$.next(!options.orthographic);
0157
0158 // A view of the geometry slice renders the slice copy (the original
0159 // geometry moves to GEOMETRY_MAIN_LAYER while a slice exists)
0160 const layers = options.geometrySlice ? [GEOMETRY_SLICE_LAYER, ...(options.extraLayers ?? [])] : options.extraLayers ?? [];
0161 for (const layer of layers) {
0162 this.perspectiveCamera.layers.enable(layer);
0163 this.orthographicCamera.layers.enable(layer);
0164 }
0165 // Event data lives on its own layer (so tracks-on-top can isolate it);
0166 // every view shows it.
0167 this.perspectiveCamera.layers.enable(EVENT_DATA_LAYER);
0168 this.orthographicCamera.layers.enable(EVENT_DATA_LAYER);
0169
0170 // Controls listen on the container, not the canvas: in multi-view pages
0171 // the shared canvas sits behind the view containers and never receives
0172 // pointer events (in the single-view page canvas events bubble up).
0173 this.controls = new OrbitControls(this.camera, this.container);
0174 this.controls.target.set(...this.startPose.target);
0175 this.controls.enableDamping = false;
0176 this.controls.dampingFactor = 0.05;
0177 this.setDistanceLimits(options.distanceLimits ?? DEFAULT_CAMERA_LIMITS);
0178
0179 // Any camera interaction (orbit, pan, zoom, damping ticks) marks the
0180 // view for re-render. This is the event side of render-on-demand — the
0181 // loop never polls camera state.
0182 this.controls.addEventListener('change', () => { this.dirty = true; });
0183
0184 if (options.fixedDirection) {
0185 const { direction, up } = options.fixedDirection;
0186 this.setCameraUp(new Vector3(...up));
0187 const distance = 7000;
0188 this.camera.position.set(
0189 direction[0] * distance, direction[1] * distance, direction[2] * distance);
0190 this.camera.lookAt(0, 0, 0);
0191 this.controls.enableRotate = false;
0192 // Pan in screen space so axis-locked projections pan intuitively
0193 this.controls.screenSpacePanning = true;
0194 } else {
0195 // OrbitControls cannot rotate across the poles of its up axis (the
0196 // polar angle is hard-clamped). With the top view as the primary
0197 // orientation that clamp is a wall in the middle of normal navigation,
0198 // so the orbit frame is re-anchored whenever the camera gets near a
0199 // pole: the up axis is replaced by the current screen-up, which keeps
0200 // the visible roll identical while moving the poles 90 degrees away.
0201 // The world may end up rolled after long free tumbles — the navigation
0202 // cube and view presets restore canonical orientations.
0203 this.controls.addEventListener('change', () => this.keepOrbitAwayFromPoles());
0204 }
0205
0206 this.controls.update();
0207 }
0208
0209 /**
0210 * Sets the orbit distance limits [mm] and moves the active camera's far
0211 * plane to 10% beyond the farthest allowed distance.
0212 */
0213 setDistanceLimits(limits: CameraLimits): void {
0214 this.controls.minDistance = limits.minDistance;
0215 this.controls.maxDistance = limits.maxDistance;
0216 this.camera.far = limits.maxDistance * 1.1;
0217 this.camera.updateProjectionMatrix();
0218 }
0219
0220 /**
0221 * True while the camera keeps the pose it had at construction: nothing
0222 * (user, preset, command) has moved it yet. Compared with a tolerance of
0223 * 1e-6 of the start distance, since orbit updates round.
0224 */
0225 isAtStartPose(): boolean {
0226 const [px, py, pz] = this.startPose.position;
0227 const [tx, ty, tz] = this.startPose.target;
0228 const [ux, uy, uz] = this.startPose.up;
0229 const tolerance = 1e-6 * Math.max(1, Math.hypot(px - tx, py - ty, pz - tz));
0230 return this.camera.position.distanceTo(new Vector3(px, py, pz)) <= tolerance
0231 && this.controls.target.distanceTo(new Vector3(tx, ty, tz)) <= tolerance
0232 && this.camera.up.distanceTo(new Vector3(ux, uy, uz)) <= 1e-6;
0233 }
0234
0235 /** Re-parents the view to another DOM element (page/route switches). */
0236 setContainer(container: HTMLElement): void {
0237 if (this.container === container) return;
0238 this.container = container;
0239 this.controls.connect(container);
0240 for (const overlay of this.overlays) {
0241 overlay.onViewContainerChange?.(this);
0242 }
0243 }
0244
0245 // -------------------------------------------------------------------------
0246 // Size and viewport
0247 // -------------------------------------------------------------------------
0248
0249 /**
0250 * Recomputes the viewport rectangle of this view inside the shared canvas
0251 * and updates the camera projections for the new aspect ratio. Call after
0252 * any layout change (resize, pane toggle, view add/remove) — not per frame.
0253 */
0254 updateViewport(): void {
0255 const canvas = this.renderer.domElement;
0256 const width = this.container.clientWidth || 1;
0257 const height = this.container.clientHeight || 1;
0258
0259 if (this.container === canvas.parentElement) {
0260 // Single-view fast path: the canvas fills this container directly.
0261 this.viewport = { x: 0, y: 0, width, height };
0262 } else {
0263 const rect = this.container.getBoundingClientRect();
0264 const canvasRect = canvas.getBoundingClientRect();
0265 // WebGPURenderer takes a top-left viewport origin on both backends:
0266 // its WebGL2 fallback flips y itself, so flipping here would flip twice.
0267 this.viewport = {
0268 x: rect.left - canvasRect.left,
0269 y: rect.top - canvasRect.top,
0270 width: rect.width,
0271 height: rect.height,
0272 };
0273 }
0274
0275 this.viewSize = { width, height };
0276
0277 this.perspectiveCamera.aspect = width / Math.max(1, height);
0278 this.perspectiveCamera.updateProjectionMatrix();
0279
0280 if (this.camera === this.orthographicCamera) {
0281 const ortho = this.orthographicCamera;
0282 // Preserve the visible world height (frustum height / zoom) across
0283 // resizes so resizing does not change the scale, only the crop.
0284 const worldScreenHeight = (ortho.top - ortho.bottom) / Math.max(1e-9, ortho.zoom);
0285 const unitsPerPixel = worldScreenHeight / Math.max(1, height);
0286 const halfW = (width * unitsPerPixel) / 2;
0287 const halfH = (height * unitsPerPixel) / 2;
0288 ortho.left = -halfW; ortho.right = halfW;
0289 ortho.top = halfH; ortho.bottom = -halfH;
0290 ortho.updateProjectionMatrix();
0291 }
0292
0293 this.controls.update();
0294
0295 for (const overlay of this.overlays) {
0296 overlay.onViewResize?.(this);
0297 }
0298 }
0299
0300 /** The view's rectangle inside the canvas, y ready for `renderer.setViewport`. */
0301 get viewportRect(): Readonly<ViewportRect> {
0302 return this.viewport;
0303 }
0304
0305 /** CSS-pixel size of the view container. */
0306 get size(): Readonly<{ width: number; height: number }> {
0307 return this.viewSize;
0308 }
0309
0310 // -------------------------------------------------------------------------
0311 // Rendering
0312 // -------------------------------------------------------------------------
0313
0314 /**
0315 * Renders this view's rectangle: viewport + scissor to the view rect, scene
0316 * render with the view camera, then overlays. `ThreeService`'s loop calls
0317 * this in multi-view mode; the single-view fast path renders directly
0318 * without viewport state changes (identical to a plain full-canvas render).
0319 */
0320 renderTo(renderer: WebGPURenderer, scene: import('three').Scene, rect?: ViewportRect): void {
0321 // Per-view geometry cut: write this view's plane into the shared slice.
0322 // Plane values are re-projected on every render call, so sequential
0323 // views each render with their own cut from the one shared group.
0324 if (this.geometrySlice && this.clipPlane) {
0325 this.geometrySlice.plane.copy(this.clipPlane);
0326 }
0327 // rect overrides the on-screen viewport for off-screen composition
0328 // (frame capture renders views into arbitrary rectangles).
0329 const { x, y, width, height } = rect ?? this.viewport;
0330 renderer.setViewport(x, y, width, height);
0331 renderer.setScissor(x, y, width, height);
0332 this.renderScenePasses(renderer, scene);
0333 if (!rect) {
0334 this.renderOverlays();
0335 }
0336 }
0337
0338 /**
0339 * Renders this view over the WHOLE canvas (its camera, cut and
0340 * tracks-on-top applied), independent of the on-screen layout. Used by
0341 * the single-view render loop path and by frame capture at target
0342 * resolution. No overlays — the loop renders them separately; captures
0343 * record the scene, not navigation chrome. For capture, the caller is
0344 * responsible for camera aspect (see setCaptureAspect) and canvas size.
0345 */
0346 renderFullFrame(renderer: WebGPURenderer, scene: import('three').Scene): void {
0347 if (this.geometrySlice && this.clipPlane) {
0348 this.geometrySlice.plane.copy(this.clipPlane);
0349 }
0350 this.renderScenePasses(renderer, scene);
0351 }
0352
0353 /**
0354 * Points the cameras at an off-screen target of the given aspect (frame
0355 * capture). The orthographic frustum keeps its visible world height and
0356 * adjusts width. Call `updateViewport()` afterwards to restore the
0357 * on-screen aspect from the container.
0358 */
0359 setCaptureAspect(width: number, height: number): void {
0360 const aspect = width / Math.max(1, height);
0361 this.perspectiveCamera.aspect = aspect;
0362 this.perspectiveCamera.updateProjectionMatrix();
0363
0364 const ortho = this.orthographicCamera;
0365 const worldHeight = (ortho.top - ortho.bottom);
0366 const halfW = (worldHeight * aspect) / 2;
0367 ortho.left = -halfW;
0368 ortho.right = halfW;
0369 ortho.updateProjectionMatrix();
0370 }
0371
0372 /**
0373 * The view's scene render: one pass normally, two when `tracksOnTop` —
0374 * geometry+helpers first, then the event layer over a cleared depth
0375 * buffer. Both selections are camera-layer masks flipped per render call:
0376 * no material or object mutation, so cached render objects stay valid.
0377 * The lights carry the event layer too, keeping the collected light set
0378 * identical in both passes (see EVENT_DATA_LAYER).
0379 */
0380 private renderScenePasses(renderer: WebGPURenderer, scene: import('three').Scene): void {
0381 if (!this.tracksOnTop) {
0382 renderer.render(scene, this.camera);
0383 return;
0384 }
0385
0386 const savedMask = this.camera.layers.mask;
0387 this.camera.layers.disable(EVENT_DATA_LAYER);
0388 renderer.render(scene, this.camera);
0389
0390 this.camera.layers.mask = 0;
0391 this.camera.layers.enable(EVENT_DATA_LAYER);
0392 // Clear depth only (keep the geometry pass's colors). Clear flags, not
0393 // an explicit renderer.clear(): the clear must happen inside the render
0394 // pass with the scissor applied, exactly like the first pass's clear.
0395 const autoClearColor = renderer.autoClearColor;
0396 const autoClearDepth = renderer.autoClearDepth;
0397 const autoClearStencil = renderer.autoClearStencil;
0398 renderer.autoClearColor = false;
0399 renderer.autoClearStencil = false;
0400 renderer.autoClearDepth = true;
0401 renderer.render(scene, this.camera);
0402 renderer.autoClearColor = autoClearColor;
0403 renderer.autoClearDepth = autoClearDepth;
0404 renderer.autoClearStencil = autoClearStencil;
0405 this.camera.layers.mask = savedMask;
0406 }
0407
0408 /**
0409 * Runs the overlay renders (the loop calls this after the scene render).
0410 * An overlay whose render throws is logged and removed, so one broken
0411 * overlay cannot stop the render loop.
0412 */
0413 renderOverlays(): void {
0414 const failed = runIsolated(this.overlays, overlay => overlay.render(this),
0415 (overlay, error) => console.error(`[RenderView ${this.name}] Overlay render failed; removing it:`, overlay, error));
0416 for (const overlay of failed) {
0417 this.removeOverlay(overlay);
0418 }
0419 }
0420
0421 /** Adds an overlay drawn after this view's scene render. */
0422 addOverlay(overlay: ViewOverlay): void {
0423 if (!this.overlays.includes(overlay)) {
0424 this.overlays.push(overlay);
0425 }
0426 }
0427
0428 removeOverlay(overlay: ViewOverlay): void {
0429 const index = this.overlays.indexOf(overlay);
0430 if (index !== -1) {
0431 this.overlays.splice(index, 1);
0432 }
0433 }
0434
0435 // -------------------------------------------------------------------------
0436 // Picking
0437 // -------------------------------------------------------------------------
0438
0439 /**
0440 * Normalized device coordinates of a pointer event inside this view,
0441 * or null when the pointer is outside the view rectangle.
0442 */
0443 ndcFromEvent(event: { clientX: number; clientY: number }): Vector2 | null {
0444 const rect = this.container.getBoundingClientRect();
0445 const x = (event.clientX - rect.left) / Math.max(1, rect.width);
0446 const y = (event.clientY - rect.top) / Math.max(1, rect.height);
0447 if (x < 0 || x > 1 || y < 0 || y > 1) return null;
0448 return new Vector2(x * 2 - 1, -(y * 2 - 1));
0449 }
0450
0451 /**
0452 * A raycaster aimed from this view's camera through the given pointer
0453 * event. Returns null when the pointer is outside the view.
0454 */
0455 raycasterFromEvent(event: { clientX: number; clientY: number }): Raycaster | null {
0456 const ndc = this.ndcFromEvent(event);
0457 if (!ndc) return null;
0458 this.raycaster.setFromCamera(ndc, this.camera);
0459 this.raycaster.near = this.camera.near;
0460 this.raycaster.far = this.camera.far;
0461 return this.raycaster;
0462 }
0463
0464 // -------------------------------------------------------------------------
0465 // Camera handling
0466 // -------------------------------------------------------------------------
0467
0468 /**
0469 * Sets the camera up vector (kept in sync on both cameras) and refreshes
0470 * the up axis OrbitControls captured in its constructor — without the
0471 * refresh, orbiting keeps rotating around the old up. Used by camera view
0472 * presets and the viewport gizmo for top/bottom views and rolls.
0473 */
0474 setCameraUp(up: Vector3): void {
0475 this.perspectiveCamera.up.copy(up);
0476 this.orthographicCamera.up.copy(up);
0477 const controls = this.controls as unknown as {
0478 _quat?: Quaternion;
0479 _quatInverse?: Quaternion;
0480 };
0481 if (controls._quat) {
0482 controls._quat.setFromUnitVectors(up, new Vector3(0, 1, 0));
0483 controls._quatInverse?.copy(controls._quat).invert();
0484 }
0485 }
0486
0487 /**
0488 * Re-anchors the orbit frame when the camera direction closes in on the
0489 * up-axis poles (see the controls 'change' listener in the constructor).
0490 * The new up is the exact current screen-up, so nothing visibly changes at
0491 * the moment of the switch.
0492 */
0493 private keepOrbitAwayFromPoles(): void {
0494 const camera = this.camera;
0495 const offset = this.poleOffset.subVectors(camera.position, this.controls.target);
0496 const distance = offset.length();
0497 if (!distance) return;
0498
0499 offset.divideScalar(distance);
0500 // cos of the angle between the view direction and the up axis;
0501 // |cos| > cos(15°) means the camera is within 15° of a pole.
0502 if (Math.abs(offset.dot(camera.up)) < POLE_LIMIT) return;
0503
0504 const screenUp = this.poleScreenUp
0505 .copy(camera.up)
0506 .addScaledVector(offset, -offset.dot(camera.up));
0507 if (screenUp.lengthSq() < 1e-12) return;
0508
0509 this.setCameraUp(screenUp.normalize());
0510 }
0511
0512 /**
0513 * Toggles between the perspective and orthographic cameras, preserving the
0514 * viewing direction and the visible world height (so nothing jumps).
0515 */
0516 toggleOrthographicView(useOrtho: boolean): void {
0517 const target = this.controls.target.clone();
0518 const vw = this.viewSize.width || 1;
0519 const vh = this.viewSize.height || 1;
0520
0521 // Current visible world height at the target, from whichever camera is active
0522 let worldScreenHeight: number;
0523 if ((this.camera as PerspectiveCamera).isPerspectiveCamera) {
0524 const persp = this.camera as PerspectiveCamera;
0525 const dist = persp.position.distanceTo(target);
0526 const fovRad = MathUtils.degToRad(persp.fov);
0527 worldScreenHeight = 2 * dist * Math.tan(fovRad / 2);
0528 } else {
0529 const ortho = this.camera as OrthographicCamera;
0530 worldScreenHeight = (ortho.top - ortho.bottom) / Math.max(1e-9, ortho.zoom);
0531 }
0532
0533 if (useOrtho) {
0534 const persp = this.perspectiveCamera;
0535 this.orthographicCamera.position.copy(persp.position);
0536 this.orthographicCamera.up.copy(persp.up);
0537 this.orthographicCamera.lookAt(target);
0538
0539 const unitsPerPixel = worldScreenHeight / vh;
0540 const halfW = (vw * unitsPerPixel) / 2;
0541 const halfH = (vh * unitsPerPixel) / 2;
0542 this.orthographicCamera.left = -halfW;
0543 this.orthographicCamera.right = halfW;
0544 this.orthographicCamera.top = halfH;
0545 this.orthographicCamera.bottom = -halfH;
0546 this.orthographicCamera.zoom = 1; // scale is encoded in the frustum
0547
0548 // Generous near/far span: ortho views must not clip world-sized geometry
0549 const dist = persp.position.distanceTo(target);
0550 const clipSpan = Math.max(1e6, dist * 10);
0551 this.orthographicCamera.near = -clipSpan;
0552 this.orthographicCamera.far = clipSpan;
0553
0554 this.orthographicCamera.updateProjectionMatrix();
0555 this.orthographicCamera.up.copy(this.camera.up);
0556 this.camera = this.orthographicCamera;
0557 } else {
0558 // Back to perspective: choose the distance that keeps the same visible height
0559 const persp = this.perspectiveCamera;
0560 const fovRad = MathUtils.degToRad(persp.fov);
0561 const dist = (worldScreenHeight / 2) / Math.tan(fovRad / 2);
0562
0563 const dir = new Vector3().subVectors(this.camera.position, target).normalize();
0564 const newPos = new Vector3().copy(target).addScaledVector(dir, dist);
0565
0566 persp.position.copy(newPos);
0567 persp.up.copy(this.camera.up);
0568 persp.lookAt(target);
0569
0570 persp.near = Math.max(0.1, dist * 0.001);
0571 persp.far = Math.max(1000, dist * 1000);
0572
0573 persp.updateProjectionMatrix();
0574 this.camera = persp;
0575 }
0576
0577 this.controls.object = this.camera;
0578 this.controls.update();
0579 this.cameraMode$.next(!useOrtho);
0580 }
0581
0582 dispose(): void {
0583 for (const overlay of this.overlays) {
0584 overlay.dispose?.();
0585 }
0586 this.overlays = [];
0587 this.controls.dispose();
0588 }
0589 }