Back to home page

EIC code displayed by LXR

 
 

    


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 }