Back to home page

EIC code displayed by LXR

 
 

    


Warning, /firebird/packages/firebird-ng/display/src/lib/services/three.service.ts is written in an unsupported language. File is not indexed.

0001 import { Injectable, Injector, NgZone, OnDestroy, inject } from '@angular/core';
0002 import * as THREE from 'three';
0003 import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
0004 import {
0005   HemisphereLight,
0006   DirectionalLight,
0007   AmbientLight,
0008   PointLight,
0009   SpotLight,
0010 } from 'three';
0011 import { WebGPURenderer, ClippingGroup } from 'three/webgpu';
0012 import {PerfService} from "./perf.service";
0013 import {BehaviorSubject, Subject, combineLatest} from "rxjs";
0014 import { MeshBVHHelper } from 'three-mesh-bvh';
0015 
0016 import {
0017   CAMERA_LIMITS,
0018   CLIPPING_ENABLED_CONFIG,
0019   CLIPPING_OPENING_ANGLE_CONFIG,
0020   CLIPPING_START_ANGLE_CONFIG,
0021   EVENT_DATA_LAYER,
0022   GEOMETRY_MAIN_LAYER,
0023   LAZY_THREE_EXTENSIONS,
0024   THREE_EXTENSIONS,
0025   Z_CLIPPING_ENABLED_CONFIG,
0026   Z_CLIPPING_FORWARD_CONFIG,
0027   Z_CLIPPING_POSITION_CONFIG,
0028   injectCameraPresets,
0029   type CameraLimits,
0030   type CameraPose,
0031   type CameraPreset,
0032   type FrameContext,
0033   type RenderView,
0034   type RenderViewOptions,
0035   type SceneContext,
0036   type ThreeExtension,
0037 } from '@dexvis/firebird-ng/api';
0038 import type {Event as FbEvent} from '@dexvis/firebird-core';
0039 import {DEFAULT_CAMERA_LIMITS, RenderViewImpl} from './render-view';
0040 import {ClippedGeometrySlice} from './geometry-slice';
0041 import { ConfigService } from '@dexvis/app-features';
0042 import { useFragmentShaderClipping } from './three-clipping-patch';
0043 import { runIsolated, shouldRenderFrame } from './render-loop';
0044 import {
0045   ClipPlaneSets,
0046   buildPickingBvh,
0047   clipPlaneSetsOf,
0048   collectBvhCandidates,
0049   intersectVisible,
0050   isClickGesture,
0051   isPointClipped,
0052   setWedgePlanes,
0053   useAcceleratedRaycast,
0054 } from './picking';
0055 
0056 /** A scene object under the pointer, with the raycast hit that found it. */
0057 export interface PickedObject {
0058   track: THREE.Object3D;
0059   point: THREE.Vector3;
0060   intersection: THREE.Intersection;
0061 }
0062 
0063 /**
0064  * Runs `callback` when the browser is idle. Where requestIdleCallback is
0065  * missing, runs it soon with a deadline that expires one frame (16 ms) after
0066  * the callback starts.
0067  */
0068 function whenIdle(callback: (deadline: { timeRemaining(): number }) => void): void {
0069   if (typeof requestIdleCallback === 'function') {
0070     requestIdleCallback(callback, { timeout: 1000 });
0071   } else {
0072     setTimeout(() => {
0073       const start = performance.now();
0074       callback({ timeRemaining: () => Math.max(0, 16 - (performance.now() - start)) });
0075     }, 1);
0076   }
0077 }
0078 
0079 
0080 
0081 @Injectable({
0082   providedIn: 'root',
0083 })
0084 export class ThreeService implements OnDestroy {
0085 
0086 
0087   /** Three.js core components */
0088   public scene!: THREE.Scene;
0089   /** Z clipping wrapper — parent of sceneGeometry, never accessed externally */
0090   private zClippingGroup!: ClippingGroup;
0091   /** Angular (wedge) clipping — geometry is added here */
0092   public sceneGeometry!: ClippingGroup;
0093   public sceneEvent!: THREE.Group;
0094   public sceneHelpers!: THREE.Group;
0095   public renderer!: WebGPURenderer;
0096 
0097   /**
0098    * Views of the scene. views[0] is the MAIN view — the one the app-level
0099    * camera API below delegates to. Additional views (projection panels of the
0100    * quad view, extension-added views) share the one scene and render loop.
0101    */
0102   public views: RenderViewImpl[] = [];
0103 
0104   /** The primary view: the display page's camera and controls live here. */
0105   public get mainView(): RenderViewImpl {
0106     return this.views[0];
0107   }
0108 
0109   // Camera/controls delegation to the main view. Most of the app works with
0110   // "the" camera; multi-view pages address other views through `views`.
0111   public get controls(): OrbitControls {
0112     return this.mainView?.controls as OrbitControls;
0113   }
0114 
0115   public get perspectiveCamera(): THREE.PerspectiveCamera {
0116     return this.mainView?.perspectiveCamera as THREE.PerspectiveCamera;
0117   }
0118 
0119   public get orthographicCamera(): THREE.OrthographicCamera {
0120     return this.mainView?.orthographicCamera as THREE.OrthographicCamera;
0121   }
0122 
0123   /** The main view's active camera (perspective or orthographic). */
0124   public get camera(): THREE.PerspectiveCamera | THREE.OrthographicCamera {
0125     return this.mainView?.camera as THREE.PerspectiveCamera;
0126   }
0127 
0128   /** Emits true when the main view uses the perspective camera. */
0129   public get cameraMode$(): BehaviorSubject<boolean> {
0130     return this.mainView?.cameraMode$ as BehaviorSubject<boolean>;
0131   }
0132 
0133   /** The default axes helper, controllable via scene-helpers */
0134   public axesHelper!: THREE.AxesHelper;
0135 
0136   /** Optional clipping planes and logic (angular wedge clipping). */
0137   public clipPlanes = [
0138     new THREE.Plane(new THREE.Vector3(0, -1, 0), 0),
0139     new THREE.Plane(new THREE.Vector3(0, 1, 0), 0),
0140   ];
0141 
0142   /** Z-axis clipping plane (perpendicular to Z). */
0143   public zClipPlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
0144   private zClippingEnabled = false;
0145   private angularClippingEnabled = false;
0146   /** Last clipping structure applied to the renderer; see updateClippingGroups. */
0147   private lastClippingStructure = '';
0148 
0149   /** The projection views' independently clipped geometry copy (see ClippedGeometrySlice). */
0150   public geometrySlice: ClippedGeometrySlice | null = null;
0151 
0152   /** Functions callbacks that help organize performance */
0153   public profileBeginFunc: (() => void) | null = null;
0154   public profileEndFunc: (() => void) | null = null;
0155 
0156   /** Animation loop control */
0157   private animationFrameId: number | null = null;
0158   private shouldRender = false;
0159 
0160   /** The first init()'s scene creation; later and concurrent calls await it. */
0161   private initPromise: Promise<void> | null = null;
0162   /**
0163    * Bumped by every init() and detach(). An init() whose number is no
0164    * longer current when its awaits resolve skips attaching the canvas and
0165    * starting the loop: a later init() or a detach() owns the display state.
0166    */
0167   private attachGeneration = 0;
0168 
0169   /**
0170    * Render scheduling (config key `rendering.mode`). On-demand is the
0171    * default: the RAF loop keeps ticking, but scene renders happen only when
0172    * something flagged a change. Dirty flags are SET by events, signals and
0173    * `invalidate()` calls — the loop only reads and clears them; it never
0174    * polls application state.
0175    */
0176   private continuousMode = false;
0177   /** "Render every view on the next frame" — set by invalidate(). */
0178   private renderRequested = true;
0179   /** Frames that actually rendered (idle gates and the perf box read this). */
0180   public renderedFrameCount = 0;
0181 
0182   /** Callbacks run after every rendered frame (see addFrameCallback). */
0183   private frameCallbacks: Array<() => void> = [];
0184 
0185   /** Extensions whose onFrame threw: their onFrame is not called again. */
0186   private disabledFrameHooks = new WeakSet<ThreeExtension>();
0187 
0188   /** Scene background; ThemeService sets it, init() applies it. */
0189   private readonly background = new THREE.Color(0x3f3f3f);
0190 
0191   private clipIntersection: boolean = false;
0192 
0193   /** Initialization flag */
0194   private initialized: boolean = false;
0195 
0196   /** Extension system: contributions collected from DI.
0197    * Eager extensions are DI-instantiated here; lazy ones join after init. */
0198   private extensions: ThreeExtension[] = (inject(THREE_EXTENSIONS, {optional: true}) ?? []).slice();
0199   private lazyExtensionLoaders = inject(LAZY_THREE_EXTENSIONS, {optional: true}) ?? [];
0200   private injector = inject(Injector);
0201   /** Optional so plain `new ThreeService(...)` in tests works without DI. */
0202   private configService = inject(ConfigService, {optional: true});
0203 
0204   /** Camera presets (withCameraPreset): 'home' sets the start pose and the navigation cube's home. */
0205   private cameraPresets = injectCameraPresets();
0206   /** Orbit limits a pack pinned (withCameraLimits); null derives them from each loaded geometry. */
0207   private pinnedCameraLimits = inject(CAMERA_LIMITS, {optional: true});
0208   /** The limits in effect; views added later get them too. */
0209   private cameraLimits: CameraLimits = this.pinnedCameraLimits ?? DEFAULT_CAMERA_LIMITS;
0210   /** Bounding sphere of the geometry, computed on demand; undefined until then, null when empty. */
0211   private geometryBounds: THREE.Sphere | null | undefined = undefined;
0212   private sceneContext: SceneContext | null = null;
0213   private frameContext: FrameContext | null = null;
0214   private lastFrameStartTime = 0;
0215 
0216   /** Reference to the container element used for rendering */
0217   private containerElement!: HTMLElement;
0218 
0219   /** Lights */
0220   private ambientLight!: AmbientLight;
0221   private hemisphereLight!: HemisphereLight;
0222   private directionalLight!: DirectionalLight;
0223   private pointLight!: PointLight; // Optional
0224   private spotLight!: SpotLight; // Optional
0225 
0226   /** Adds a MeshBVHHelper to each mesh whose picking BVH gets built (debug GUI toggle). */
0227   public showBVHDebug: boolean = false;
0228 
0229   /** Geometry meshes waiting for their picking BVH (built in idle time after a load). */
0230   private bvhQueue: THREE.Mesh[] = [];
0231   private bvhBuildScheduled = false;
0232 
0233   /** Hover picking (opt-in through toggleRaycast). Click selection is always on. */
0234   private isRaycastEnabled = false;
0235 
0236   /** Pointer handlers installed per view, kept so views can be removed cleanly. */
0237   private viewPointerHandlers = new Map<RenderViewImpl, {
0238     move: (event: PointerEvent) => void;
0239     leave: (event: PointerEvent) => void;
0240     down: (event: PointerEvent) => void;
0241     up: (event: PointerEvent) => void;
0242     cancel: (event: PointerEvent) => void;
0243     dblclick: (event: MouseEvent) => void;
0244   }>();
0245 
0246   /**
0247    * Hover highlight of detector geometry: the hovered mesh shows this one
0248    * shared material while its own is parked in hoveredMeshMaterial. Event
0249    * data is never highlighted here — its hover goes out through
0250    * `trackHovered`, and SelectionService routes it to the owning painter.
0251    */
0252   private readonly hoverMaterial = new THREE.MeshLambertMaterial({
0253     color: 0xffe082,
0254     emissive: 0x403010,
0255     side: THREE.DoubleSide,
0256     transparent: true,
0257     opacity: 0.85,
0258   });
0259   private hoveredMesh: THREE.Mesh | null = null;
0260   private hoveredMeshMaterial: THREE.Material | THREE.Material[] | null = null;
0261   /** True while the last hover pick hit event data (a null still has to go out when it ends). */
0262   private hoveringEventData = false;
0263 
0264   /**
0265    * Event data under the pointer (hover picking only), or null when the
0266    * pointer left it. SelectionService turns this into the painter's entity
0267    * highlight.
0268    */
0269   public trackHovered = new Subject<PickedObject | null>();
0270   /** A click (press and release without dragging) picked this object. */
0271   public trackClicked = new Subject<PickedObject>();
0272 
0273   // Raw hit point every frame (hover)
0274   public pointHovered = new Subject<THREE.Vector3>();
0275 
0276   // Distance ready after second point
0277   public distanceReady = new Subject<{ p1: THREE.Vector3; p2: THREE.Vector3; dist: number }>();
0278 
0279   // Toggle by UI when “3‑D Distance” checkbox is on
0280   public measureMode = false;
0281 
0282   // temp storage for first measure point
0283   private firstMeasurePoint: THREE.Vector3 | null = null;
0284 
0285   //  Measurement / hover state
0286   private hoverTimeout: number | null = null;
0287   private measurementPoints: THREE.Mesh[] = [];
0288 
0289 
0290   constructor(
0291     private ngZone: NgZone,
0292     private perfService: PerfService) {
0293     // Initialization happens in init()
0294   }
0295 
0296   /**
0297    * Attaches the display to a container: the first call creates the scene,
0298    * renderer, main view and lights; later calls move the existing canvas and
0299    * main view into the new container (page revisits). Either way the render
0300    * loop runs afterwards. Concurrent calls are safe: they share the one
0301    * scene creation, and only the most recent call attaches.
0302    *
0303    * @param container The ID of the HTML element, or the element itself.
0304    * @returns True when this call attached the display; false when a later
0305    *          init() or a detach() superseded it while it waited.
0306    */
0307   async init(container: string | HTMLElement): Promise<boolean> {
0308 
0309     let containerElement: HTMLElement;
0310 
0311     // Figure out the container
0312     if (typeof container === 'string') {
0313       const el = document.getElementById(container);
0314       if (!el) {
0315         throw new Error(`ThreeService Initialization Error: Container element #${container} not found.`);
0316       }
0317       containerElement = el;
0318     } else {
0319       containerElement = container;
0320     }
0321 
0322     const generation = ++this.attachGeneration;
0323     const creates = this.initPromise === null;
0324     if (creates) {
0325       this.initPromise = this.createScene(containerElement);
0326     }
0327     try {
0328       await this.initPromise;
0329     } catch (error) {
0330       // A failed creation must not poison later attempts.
0331       if (creates) this.initPromise = null;
0332       throw error;
0333     }
0334 
0335     if (generation !== this.attachGeneration) {
0336       return false;
0337     }
0338     // The creating call already placed the canvas and main view.
0339     if (!creates) {
0340       this.attachRenderer(containerElement);
0341     }
0342     this.startRendering();
0343     return true;
0344   }
0345 
0346   /**
0347    * Detaches the display: the page that hosted the canvas went away, so the
0348    * render loop stops (nothing would show its frames). The scene, views and
0349    * extensions stay; the next init() re-attaches the canvas and restarts
0350    * the loop. Display pages call this from ngOnDestroy.
0351    */
0352   detach(): void {
0353     this.attachGeneration++;
0354     this.stopRendering();
0355     this.endHover();
0356   }
0357 
0358   /**
0359    * Sets the scene background color (the theme's canvas color). Applies at
0360    * once when the scene exists, otherwise when init() creates it.
0361    */
0362   setBackground(color: THREE.ColorRepresentation): void {
0363     this.background.set(color);
0364     if (this.scene) {
0365       this.scene.background = this.background;
0366       this.invalidate();
0367     }
0368   }
0369 
0370   /** The one-time creation behind init(): scene, renderer, main view, lights, extensions. */
0371   private async createScene(containerElement: HTMLElement): Promise<void> {
0372     this.containerElement = containerElement;
0373 
0374     // 1) Create scene
0375     this.scene = new THREE.Scene();
0376     this.scene.background = this.background;
0377 
0378     // Z clipping group (union mode) wraps the geometry group
0379     this.zClippingGroup = new ClippingGroup();
0380     this.zClippingGroup.name = 'ZClipping';
0381     this.zClippingGroup.enabled = false;
0382     this.zClippingGroup.clipIntersection = false; // union — always
0383     this.scene.add(this.zClippingGroup);
0384 
0385     // Angular (wedge) clipping group — geometry is added here
0386     this.sceneGeometry = new ClippingGroup();
0387     this.sceneGeometry.name = 'Geometry';
0388     this.sceneGeometry.enabled = false;
0389     this.zClippingGroup.add(this.sceneGeometry);
0390 
0391     // Event scene tree (regular Group — clipping does NOT apply to event data)
0392     this.sceneEvent = new THREE.Group();
0393     this.sceneEvent.name = 'Event';
0394     this.scene.add(this.sceneEvent);
0395 
0396     // Lights scene tree
0397     this.sceneHelpers = new THREE.Group();
0398     this.sceneHelpers.name = 'Helpers';
0399     this.scene.add(this.sceneHelpers);
0400 
0401     // Create renderer (WebGPU with automatic WebGL2 fallback)
0402     useFragmentShaderClipping();
0403     this.renderer = new WebGPURenderer({ antialias: true , logarithmicDepthBuffer: true, stencil:true});
0404     this.renderer.setPixelRatio(window.devicePixelRatio);
0405     this.renderer.shadowMap.enabled = true;
0406     // WebGPURenderer dropped PCFSoftShadowMap in three r186 and falls back to
0407     // PCFShadowMap with a warning on the first render; request it directly.
0408     this.renderer.shadowMap.type = THREE.PCFShadowMap;
0409     await this.renderer.init();
0410 
0411     // Append renderer to the container
0412     this.containerElement.appendChild(this.renderer.domElement);
0413 
0414     // The main view owns the cameras, OrbitControls, and viewport handling.
0415     // It starts at a pinned home view, or at the HENP top view (see
0416     // RenderViewImpl); a startup command (?cmd=camera-preset:...) moves it after init.
0417     const mainView = new RenderViewImpl(this.renderer, {
0418       name: 'main',
0419       container: this.containerElement,
0420       startPose: this.pinnedHomePose(),
0421       distanceLimits: this.cameraLimits,
0422     });
0423     this.views = [mainView];
0424 
0425     // Setup lights
0426     this.setupLights();
0427 
0428     // Add default objects
0429     this.addDefaultObjects();
0430 
0431     // (!) We set initialized here, as at this point all main objects are created and configured
0432     // It is important not to set this flag at the function end as functions, such as setSize will check the flag
0433     this.initialized = true;
0434 
0435     // Render scheduling mode. Normal config precedence applies, so a URL
0436     // (?config.rendering.mode=continuous), server config or the user can
0437     // override the on-demand default at any time.
0438     if (this.configService) {
0439       const modeProperty = this.configService.getConfigOrCreate<string>('rendering.mode', 'on-demand');
0440       const applyMode = (mode: string) => {
0441         this.continuousMode = mode === 'continuous';
0442         this.invalidate();
0443       };
0444       applyMode(modeProperty.value);
0445       modeProperty.subject.subscribe(applyMode); // root-singleton lifetime
0446     }
0447 
0448     // Geometry clipping of the main chain follows its config keys, whatever
0449     // sets them: the toolbar's clipping panel, a deep link
0450     // (?config.clippingEnabled=...), the server config or a saved value.
0451     // Bound here, not in the toolbar, so pages without the toolbar clip too.
0452     if (this.configService) {
0453       this.bindClippingConfig(this.configService);
0454     }
0455     this.updateClippingGroups();
0456 
0457     // ----------- POST INIT ------------------
0458 
0459     // Set initial size
0460     const width = this.containerElement.clientWidth;
0461     const height = this.containerElement.clientHeight;
0462     this.setSize(width, height);
0463 
0464     // Pointer picking per view (hover, click selection, measurement)
0465     this.setupRaycasting();
0466 
0467     // Extension lifecycle: onSceneInit fires strictly AFTER the async renderer
0468     // init resolved — extensions never see a half-initialized scene. Lazy
0469     // extensions load after that, off the critical path. init() starts the
0470     // render loop once this creation resolved.
0471     this.initExtensions();
0472     void this.activateLazyExtensions();
0473   }
0474 
0475   /**
0476    * Applies the clipping config keys to the wedge and Z clipping groups now
0477    * and on every change. The subscriptions live as long as this root service.
0478    */
0479   private bindClippingConfig(config: ConfigService): void {
0480     const wedge = [
0481       config.declare(CLIPPING_ENABLED_CONFIG),
0482       config.declare(CLIPPING_START_ANGLE_CONFIG),
0483       config.declare(CLIPPING_OPENING_ANGLE_CONFIG),
0484     ] as const;
0485     combineLatest([wedge[0].changes$, wedge[1].changes$, wedge[2].changes$])
0486       .subscribe(([enabled, startAngle, openingAngle]) => {
0487         this.setClippingAngle(startAngle, openingAngle);
0488         this.enableClipping(enabled);
0489       });
0490 
0491     const z = [
0492       config.declare(Z_CLIPPING_ENABLED_CONFIG),
0493       config.declare(Z_CLIPPING_POSITION_CONFIG),
0494       config.declare(Z_CLIPPING_FORWARD_CONFIG),
0495     ] as const;
0496     combineLatest([z[0].changes$, z[1].changes$, z[2].changes$])
0497       .subscribe(([enabled, position, forward]) => {
0498         this.updateZClipping(position, forward);
0499         this.enableZClipping(enabled);
0500       });
0501   }
0502 
0503   /** Builds the extension contexts and runs onSceneInit for eager extensions. */
0504   private initExtensions(): void {
0505     // The render-on-demand contract: extensions call invalidate() after
0506     // mutating renderable state, and the next frame renders. Extensions
0507     // that already followed the documented contract work unmodified.
0508     const invalidate = () => this.invalidate();
0509     const service = this;
0510     this.sceneContext = {
0511       scene: this.scene,
0512       sceneGeometry: this.sceneGeometry,
0513       sceneEvent: this.sceneEvent,
0514       sceneHelpers: this.sceneHelpers,
0515       // Getter: tracks perspective/orthographic camera toggling
0516       get camera() { return service.camera; },
0517       renderer: this.renderer,
0518       canvas: this.renderer.domElement,
0519       // Views: the list is live (multi-view pages add/remove views);
0520       // mainView is where per-view overlays like the navigation cube attach.
0521       get views() { return service.views as readonly RenderView[]; },
0522       get mainView() { return service.mainView; },
0523       addView: (options: RenderViewOptions) => this.addView(options),
0524       removeView: (view: RenderView) => this.removeView(view),
0525       get geometrySlice() { return service.geometrySlice; },
0526       createGeometrySlice: () => this.createGeometrySlice(),
0527       rebuildGeometrySlice: () => this.rebuildGeometrySlice(),
0528       removeGeometrySlice: () => this.removeGeometrySlice(),
0529       addEventObject: (object: THREE.Object3D) => this.addEventObject(object),
0530       invalidate,
0531     };
0532     this.frameContext = {
0533       deltaTime: 0,
0534       renderer: this.renderer,
0535       get camera() { return service.camera; },
0536       invalidate,
0537     };
0538     const context = this.sceneContext;
0539     runIsolated(this.extensions, extension => extension.onSceneInit?.(context),
0540       (extension, error) => console.error('[ThreeService] Extension onSceneInit failed:', extension, error));
0541   }
0542 
0543   /**
0544    * Resolves lazily-registered extensions (withLazyThreeExtension): loads the
0545    * chunk, instantiates the class through a child injector so `inject()` works
0546    * in its constructor, and gives late joiners the onSceneInit call they missed.
0547    * (v22 `injectAsync` targets already-provided tokens; lazy extension classes
0548    * arrive unprovided, hence the explicit child injector.)
0549    */
0550   private async activateLazyExtensions(): Promise<void> {
0551     for (const load of this.lazyExtensionLoaders) {
0552       try {
0553         const extensionClass = await load();
0554         const child = Injector.create({providers: [extensionClass], parent: this.injector});
0555         const extension = child.get(extensionClass);
0556         this.extensions.push(extension);
0557         if (this.sceneContext) {
0558           extension.onSceneInit?.(this.sceneContext);
0559         }
0560       } catch (error) {
0561         console.error('[ThreeService] Lazy extension activation failed:', error);
0562       }
0563     }
0564   }
0565 
0566   /** Forwards a newly loaded event to extensions (called by EventDisplayService). */
0567   notifyEventLoaded(event: FbEvent): void {
0568     runIsolated(this.extensions, extension => extension.onEventLoaded?.(event),
0569       (extension, error) => console.error('[ThreeService] Extension onEventLoaded failed:', extension, error));
0570     this.invalidate();
0571   }
0572 
0573   /**
0574    * If the service is already initialized (scene, camera, renderer exist),
0575    * you can re-attach the <canvas> to a container if it was removed or changed.
0576    * The main view follows the canvas host; camera state is preserved.
0577    */
0578   private attachRenderer(elem: HTMLElement): void {
0579     this.containerElement = elem;
0580 
0581     // If the canvas is not already in the DOM, re-append it.
0582     if (this.renderer?.domElement) {
0583       this.containerElement.appendChild(this.renderer.domElement);
0584     }
0585     if (this.mainView) {
0586       this.setMainViewContainer(elem);
0587     }
0588   }
0589 
0590   /**
0591    * Moves the main view (camera, controls, picking, overlays like the
0592    * navigation cube) to another DOM element. Multi-view pages call this to
0593    * place the main view into their layout cell; the display page's container
0594    * is restored through the normal init/re-attach path.
0595    */
0596   setMainViewContainer(container: HTMLElement): void {
0597     const view = this.mainView;
0598     if (!view) return;
0599     // Pointer handlers follow the container (remove BEFORE the switch —
0600     // removal targets the old element).
0601     this.removeRaycastHandlers(view);
0602     view.setContainer(container);
0603     this.installRaycastHandlers(view);
0604     view.updateViewport();
0605     this.invalidate();
0606   }
0607 
0608   /**
0609    * Re-parents the shared canvas into `host` without touching the main view's
0610    * container. Multi-view pages call this with a wrapper element that the
0611    * canvas fills, then place view containers (CSS grid cells) above it and
0612    * point views at them via `addView` / `mainView.setContainer`.
0613    */
0614   attachCanvasHost(host: HTMLElement): void {
0615     this.ensureInitialized('attachCanvasHost');
0616     this.containerElement = host;
0617     host.appendChild(this.renderer.domElement);
0618   }
0619 
0620   /**
0621    * Adds a view of the shared scene. The view renders inside `options.container`,
0622    * which must be positioned above the shared canvas (see attachCanvasHost).
0623    * Views share the scene and the one render loop; cameras, controls, picking
0624    * and overlays are per-view.
0625    */
0626   addView(options: RenderViewOptions): RenderViewImpl {
0627     this.ensureInitialized('addView');
0628     const view = new RenderViewImpl(this.renderer, { distanceLimits: this.cameraLimits, ...options });
0629     this.views.push(view);
0630     this.installRaycastHandlers(view);
0631     view.updateViewport();
0632     this.invalidate();
0633     return view;
0634   }
0635 
0636   /** Removes and disposes a view added with addView. The main view stays. */
0637   removeView(view: RenderView): void {
0638     if (view === this.mainView) {
0639       console.error('[ThreeService] The main view cannot be removed.');
0640       return;
0641     }
0642     const index = this.views.findIndex(candidate => candidate === view);
0643     if (index === -1) return;
0644     const [removed] = this.views.splice(index, 1);
0645     this.removeRaycastHandlers(removed);
0646     removed.dispose();
0647     this.invalidate();
0648   }
0649 
0650   /**
0651    * Creates (or returns) the geometry slice: a second copy of the detector
0652    * geometry that projection views clip independently of the main view (see
0653    * ClippedGeometrySlice for the mechanism and the layer routing). The main
0654    * view keeps rendering the original geometry; pass the returned slice plus
0655    * a `clipPlane` in `addView` options to give an added view its own cut.
0656    *
0657    * Call `rebuildGeometrySlice()` after a geometry load while a slice exists.
0658    */
0659   createGeometrySlice(): ClippedGeometrySlice {
0660     this.ensureInitialized('createGeometrySlice');
0661     if (this.geometrySlice) return this.geometrySlice;
0662     const slice = new ClippedGeometrySlice();
0663     slice.rebuild(this.sceneGeometry);
0664     this.scene.add(slice.group);
0665     // Originals moved off layer 0 — the main view must opt into their layer.
0666     this.mainView.perspectiveCamera.layers.enable(GEOMETRY_MAIN_LAYER);
0667     this.mainView.orthographicCamera.layers.enable(GEOMETRY_MAIN_LAYER);
0668     this.geometrySlice = slice;
0669     // Copies do not inherit the per-mesh accelerated raycast of the originals.
0670     useAcceleratedRaycast(slice.group);
0671     // A previous slice's shader states (same plane-count shape) would be
0672     // bound to its orphaned plane array — force rebuilds against this one.
0673     this.dropClippingShaderState();
0674     this.invalidate();
0675     return slice;
0676   }
0677 
0678   /**
0679    * Rebuilds the slice spine from the current geometry content (after
0680    * loads). Always schedules a render, slice or not: callers use it as the
0681    * "geometry content changed" notification.
0682    */
0683   rebuildGeometrySlice(): void {
0684     if (this.geometrySlice) {
0685       this.geometrySlice.rebuild(this.sceneGeometry);
0686       useAcceleratedRaycast(this.geometrySlice.group);
0687       this.dropClippingShaderState();
0688     }
0689     this.invalidate();
0690   }
0691 
0692   /**
0693    * Call after the content under `sceneGeometry` changed (a geometry load
0694    * added or replaced it). Rebuilds the projection views' geometry copy when
0695    * one exists, queues the picking BVH builds, and schedules a render — the
0696    * on-demand loop draws nothing new without that.
0697    */
0698   geometryChanged(): void {
0699     this.clearHoverHighlight();
0700     useAcceleratedRaycast(this.sceneGeometry);
0701     this.rebuildGeometrySlice();
0702     this.scheduleGeometryBvh();
0703     this.geometryBounds = undefined;
0704     this.frameGeometry();
0705   }
0706 
0707   // -------------------------------------------------------------------------
0708   // Camera framing and presets
0709   // -------------------------------------------------------------------------
0710 
0711   /**
0712    * The bounding sphere of everything under `sceneGeometry`, in world
0713    * coordinates (mm); null when there is no geometry. Computed on first use
0714    * after each geometry change.
0715    */
0716   geometryBoundingSphere(): THREE.Sphere | null {
0717     if (this.geometryBounds === undefined) {
0718       const box = new THREE.Box3().setFromObject(this.sceneGeometry);
0719       this.geometryBounds = box.isEmpty() ? null : box.getBoundingSphere(new THREE.Sphere());
0720     }
0721     return this.geometryBounds;
0722   }
0723 
0724   /** The 'home' preset as a start pose, when a pack pinned it to a fixed pose. */
0725   private pinnedHomePose(): CameraPose | undefined {
0726     const home = this.cameraPresets.find(preset => preset.name === 'home');
0727     if (!home || !('position' in home)) return undefined;
0728     return { position: home.position, target: home.target, up: home.up ?? [1, 0, 0] };
0729   }
0730 
0731   /**
0732    * Camera framing after a geometry load, for what no pack pinned: orbit
0733    * limits from the geometry's bounding sphere (radius r: 0.05 r to 5 r),
0734    * and the 'home' view when it frames the geometry and the camera has not
0735    * moved since startup. The bounding sphere is computed only when needed.
0736    */
0737   private frameGeometry(): void {
0738     if (!this.pinnedCameraLimits) {
0739       const bounds = this.geometryBoundingSphere();
0740       if (bounds && bounds.radius > 0) {
0741         this.cameraLimits = { minDistance: bounds.radius * 0.05, maxDistance: bounds.radius * 5 };
0742         for (const view of this.views) {
0743           view.setDistanceLimits(this.cameraLimits);
0744         }
0745       }
0746     }
0747     const home = this.cameraPresets.find(preset => preset.name === 'home');
0748     if (home && 'fitGeometry' in home && home.fitGeometry && this.mainView?.isAtStartPose()) {
0749       this.applyCameraPreset(home);
0750     }
0751     this.invalidate();
0752   }
0753 
0754   /**
0755    * Moves the main camera to a preset (see `CameraPreset`). A view-direction
0756    * preset keeps the orbit target and distance, or with `fitGeometry` frames
0757    * the loaded geometry's bounding sphere; before any geometry it keeps them
0758    * too.
0759    */
0760   applyCameraPreset(preset: CameraPreset): void {
0761     const camera = this.camera;
0762     const controls = this.controls;
0763     if ('position' in preset) {
0764       camera.position.set(...preset.position);
0765       controls.target.set(...preset.target);
0766     } else {
0767       const bounds = preset.fitGeometry ? this.geometryBoundingSphere() : null;
0768       if (bounds) {
0769         controls.target.copy(bounds.center);
0770       }
0771       // The sphere fits the vertical field of view at radius / sin(fov / 2)
0772       const fitDistance = bounds && 'fov' in camera
0773         ? bounds.radius / Math.sin(THREE.MathUtils.degToRad(camera.fov) / 2)
0774         : 0;
0775       const distance = fitDistance || camera.position.distanceTo(controls.target) || 7000;
0776       camera.position
0777         .copy(controls.target)
0778         .addScaledVector(new THREE.Vector3(...preset.direction), distance);
0779     }
0780     if (preset.up) {
0781       this.setCameraUp(new THREE.Vector3(...preset.up));
0782     }
0783     controls.update();
0784   }
0785 
0786   /**
0787    * Adds an object to the event data: under sceneEvent, on EVENT_DATA_LAYER
0788    * with its whole subtree (never clipped, drawn in every view, in the second
0789    * pass of tracks-on-top views), and schedules a frame.
0790    */
0791   addEventObject(object: THREE.Object3D): void {
0792     this.sceneEvent.add(object);
0793     object.traverse(node => node.layers.set(EVENT_DATA_LAYER));
0794     this.invalidate();
0795   }
0796 
0797   /** Removes the geometry slice and restores single-copy layer routing. */
0798   removeGeometrySlice(): void {
0799     if (!this.geometrySlice) return;
0800     this.scene.remove(this.geometrySlice.group);
0801     this.geometrySlice.dispose();
0802     this.geometrySlice = null;
0803     this.dropClippingShaderState();
0804     this.invalidate();
0805   }
0806 
0807 
0808   /**
0809    * Sets up the lighting for the scene.
0810    */
0811   private setupLights(): void {
0812     this.ambientLight = new AmbientLight(0xffffff, 0.4);
0813     this.ambientLight.name = "Light-Ambient";
0814     this.sceneHelpers.add(this.ambientLight);
0815 
0816     this.hemisphereLight = new HemisphereLight(0xffffff, 0x444444, 0.6);
0817     this.hemisphereLight.position.set(0, 200, 0);
0818     this.hemisphereLight.name = "Light-Hemisphere";
0819     this.sceneHelpers.add(this.hemisphereLight);
0820 
0821     this.directionalLight = new DirectionalLight(0xffffff, 0.8);
0822     this.directionalLight.position.set(100, 200, 100);
0823     this.directionalLight.name = "Light-Directional";
0824     this.directionalLight.castShadow = true;
0825     this.directionalLight.shadow.mapSize.width = 512;
0826     this.directionalLight.shadow.mapSize.height = 512;
0827     this.directionalLight.shadow.camera.near = 0.5;
0828     this.directionalLight.shadow.camera.far = 1000;
0829     this.sceneHelpers.add(this.directionalLight);
0830 
0831     this.pointLight = new PointLight(0xffffff, 0.5, 500);
0832     this.pointLight.position.set(-100, 100, -100);
0833     this.pointLight.castShadow = true;
0834     this.pointLight.name = "Light-Point";
0835     this.sceneHelpers.add(this.pointLight);
0836 
0837     this.spotLight = new SpotLight(0xffffff, 0.5);
0838     this.spotLight.position.set(0, 300, 0);
0839     this.spotLight.angle = Math.PI / 6;
0840     this.spotLight.penumbra = 0.2;
0841     this.spotLight.decay = 2;
0842     this.spotLight.distance = 1000;
0843     this.spotLight.castShadow = true;
0844     this.spotLight.name = "Light-Spot";
0845     this.sceneHelpers.add(this.spotLight);
0846 
0847     // Lights are collected per render pass by camera-layer test. A
0848     // tracks-on-top pass renders with ONLY the event layer enabled, and the
0849     // light set it collects must be IDENTICAL to every other pass — the set
0850     // is part of the render-object cache key, so a differing set would
0851     // rebuild every shared render object on every frame.
0852     for (const light of [this.ambientLight, this.hemisphereLight, this.directionalLight, this.pointLight, this.spotLight]) {
0853       light.layers.enable(EVENT_DATA_LAYER);
0854     }
0855   }
0856 
0857   /**
0858    * Adds default objects to the scene.
0859    */
0860   private addDefaultObjects(): void {
0861     // const gridHelper = new THREE.GridHelper(1000, 100);
0862     // gridHelper.name = "Grid";
0863     // this.sceneHelpers.add(gridHelper);
0864 
0865     this.axesHelper = new THREE.AxesHelper(1500);
0866     this.axesHelper.name = "Axes";
0867     this.sceneHelpers.add(this.axesHelper);
0868 
0869     // const geometry = new THREE.BoxGeometry(100, 100, 100);
0870     // const material = new THREE.MeshStandardMaterial({ color: 0x00ff00 });
0871     // const cube = new THREE.Mesh(geometry, material);
0872     // cube.name = "TestCube"
0873     // cube.castShadow = true;
0874     // cube.receiveShadow = true;
0875     // this.sceneGeometry.add(cube);
0876   }
0877 
0878   /**
0879    * Starts the rendering loop. Does nothing when it already runs. init()
0880    * starts it; call this only to resume after an explicit stopRendering().
0881    */
0882   startRendering(): void {
0883     this.ensureInitialized('startRendering');
0884 
0885     if (this.animationFrameId !== null) {
0886       return;
0887     }
0888 
0889     this.shouldRender = true;
0890     this.renderRequested = true;
0891     this.ngZone.runOutsideAngular(() => {
0892       this.renderLoop();
0893     });
0894   }
0895 
0896   /**
0897    * Stops the rendering loop.
0898    */
0899   stopRendering(): void {
0900     this.shouldRender = false;
0901     if (this.animationFrameId !== null) {
0902       cancelAnimationFrame(this.animationFrameId);
0903       this.animationFrameId = null;
0904     }
0905   }
0906 
0907   /**
0908    * Schedules a render of every view on the next animation frame. THE
0909    * render-on-demand primitive: anything that changes renderable state calls
0910    * this (directly, or through the SceneContext/FrameContext contract).
0911    * Cheap and idempotent — flags are consumed once per frame.
0912    */
0913   invalidate(): void {
0914     this.renderRequested = true;
0915     for (const view of this.views) {
0916       view.dirty = true;
0917     }
0918   }
0919 
0920   /**
0921    * The render loop. Runs every animation frame; whether it RENDERS depends
0922    * on the scheduling mode: continuous renders always, on-demand only when a
0923    * dirty flag was set since the last frame (invalidate(), controls 'change',
0924    * per-view knobs). Flags are read-and-cleared here — never polled state.
0925    *
0926    * Animations sustain their own chains: a rendered frame runs the frame
0927    * callbacks and extension onFrame hooks, and an active animator (tween
0928    * group, gizmo transition, damping) re-invalidates until it settles.
0929    */
0930   private renderLoop(): void {
0931     if (!this.shouldRender) {
0932       return;
0933     }
0934 
0935     this.animationFrameId = requestAnimationFrame(() => this.renderLoop());
0936 
0937     try {
0938       const frameStartTime = performance.now();
0939 
0940       // Controls progression (damping, active interaction) — the one
0941       // permitted per-frame animator. It fires 'change' events that set the
0942       // per-view dirty flags, and goes quiet when motion settles.
0943       for (const view of this.views) {
0944         view.controls.update();
0945       }
0946 
0947       const rendering = shouldRenderFrame(this.continuousMode, this.renderRequested, this.views);
0948 
0949       if (rendering) {
0950         // Profiling start
0951         this.profileBeginFunc?.();
0952 
0953         // Extension onFrame hooks run before rendering. Keep them cheap:
0954         // animation only, no state polling (state changes travel through
0955         // signals). deltaTime = ms since the previous RENDERED frame. A hook
0956         // that throws is logged and not called again; the loop goes on.
0957         const frameContext = this.frameContext;
0958         if (frameContext && this.extensions.length > 0) {
0959           frameContext.deltaTime = this.lastFrameStartTime ? frameStartTime - this.lastFrameStartTime : 0;
0960           const failed = runIsolated(this.extensions,
0961             extension => {
0962               if (extension.onFrame && !this.disabledFrameHooks.has(extension)) {
0963                 extension.onFrame(frameContext);
0964               }
0965             },
0966             (extension, error) => console.error('[ThreeService] Extension onFrame failed; disabling its onFrame:', extension, error));
0967           for (const extension of failed) {
0968             this.disabledFrameHooks.add(extension);
0969           }
0970         }
0971         this.lastFrameStartTime = frameStartTime;
0972 
0973         this.renderRequested = false;
0974 
0975         if (this.views.length === 1) {
0976           // Single view: full-canvas render, no viewport/scissor state.
0977           // Goes through the view so per-view modes (tracks-on-top) apply
0978           // on the display page too; with them off this is exactly a plain
0979           // renderer.render.
0980           const view = this.mainView;
0981           view.dirty = false;
0982           view.renderFullFrame(this.renderer, this.scene);
0983           view.renderOverlays();
0984         } else {
0985           // Multiple views: each renders its own scissored rectangle of the
0986           // shared canvas. autoClear stays on — with the scissor test enabled
0987           // the clear applies per-rectangle, so views do not erase each other
0988           // WITHIN the frame. Every view must repaint EVERY frame: the
0989           // drawing buffer does not survive compositing
0990           // (preserveDrawingBuffer is false; WebGPU swap-chain textures
0991           // likewise start undefined), so a skipped view's rectangle would
0992           // show cleared background once the previous frame was presented.
0993           // Dirty flags decide whether a frame happens at all — never which
0994           // views paint within it.
0995           for (const view of this.views) {
0996             view.dirty = false;
0997           }
0998           this.renderer.setScissorTest(true);
0999           for (const view of this.views) {
1000             view.renderTo(this.renderer, this.scene);
1001           }
1002           this.renderer.setScissorTest(false);
1003           const canvas = this.renderer.domElement;
1004           this.renderer.setViewport(0, 0, canvas.clientWidth, canvas.clientHeight);
1005         }
1006 
1007         this.renderedFrameCount++;
1008 
1009         // Frame callbacks (tween advancement etc.) run on rendered frames;
1010         // an active animation re-invalidates, sustaining its own chain. A
1011         // callback that throws is logged and removed; the loop goes on.
1012         const failedCallbacks = runIsolated(this.frameCallbacks, callback => callback(),
1013           (callback, error) => console.error('[ThreeService] Frame callback failed; removing it:', callback, error));
1014         for (const callback of failedCallbacks) {
1015           this.removeFrameCallback(callback);
1016         }
1017 
1018         this.profileEndFunc?.();
1019       }
1020 
1021       // Stats update every RAF tick: FPS counts rendered frames only, so an
1022       // idle on-demand display correctly reads 0 (shown as "idle").
1023       this.perfService.updateStats(this.renderer, frameStartTime, rendering, this.continuousMode);
1024     } catch (error) {
1025       // Hooks and callbacks are isolated above; what lands here is a
1026       // renderer failure, which would repeat on every frame.
1027       console.error('(!!!) ThreeService Render Loop Error:', error);
1028       this.stopRendering();
1029     }
1030   }
1031 
1032   /**
1033    * Adds a callback that runs after every RENDERED frame (under on-demand
1034    * scheduling, idle frames skip it; an animation re-invalidates from its
1035    * callback to keep frames coming). Prevents duplicate callbacks. A
1036    * callback that throws is logged and removed.
1037    * @param callback Function to execute each rendered frame.
1038    */
1039   addFrameCallback(callback: () => void): void {
1040     if (!this.frameCallbacks.includes(callback)) {
1041       this.frameCallbacks.push(callback);
1042     } else {
1043       console.warn('ThreeService: Attempted to add a duplicate frame callback.');
1044     }
1045   }
1046 
1047   /**
1048    * Removes a previously added frame callback.
1049    * @param callback The callback function to remove.
1050    */
1051   removeFrameCallback(callback: () => void): void {
1052     const index = this.frameCallbacks.indexOf(callback);
1053     if (index !== -1) {
1054       this.frameCallbacks.splice(index, 1);
1055     } else {
1056       console.warn('ThreeService: Attempted to remove a non-existent frame callback.');
1057     }
1058   }
1059 
1060   /**
1061    * Sets the size of the renderer and updates the camera projections.
1062    * @param width The new width in pixels.
1063    * @param height The new height in pixels.
1064    */
1065   setSize(width: number, height: number): void {
1066     if (!this.initialized) {
1067       console.error('ThreeService: setSize called before initialization.');
1068       return;
1069     }
1070 
1071     this.renderer.setSize(width, height);
1072 
1073     // Views recompute their viewport rectangles and camera aspects from
1074     // their containers (for the single main view: the whole canvas).
1075     for (const view of this.views) {
1076       view.updateViewport();
1077     }
1078     this.invalidate();
1079   }
1080 
1081   /**
1082    * Enables or disables local clipping.
1083    * @param enable Whether clipping should be enabled.
1084    */
1085   enableClipping(enable: boolean): void {
1086     this.angularClippingEnabled = enable;
1087     this.updateClippingGroups();
1088   }
1089 
1090   /**
1091    * Sets two-plane clipping by rotating the clipping planes.
1092    * @param startAngleDeg The starting angle in degrees.
1093    * @param openingAngleDeg The opening angle in degrees.
1094    */
1095   setClippingAngle(startAngleDeg: number, openingAngleDeg: number): void {
1096     this.clipIntersection = setWedgePlanes(this.clipPlanes[0], this.clipPlanes[1], startAngleDeg, openingAngleDeg);
1097     this.updateClippingGroups();
1098   }
1099 
1100   /**
1101    * Enables or disables Z-axis clipping.
1102    */
1103   enableZClipping(enable: boolean): void {
1104     this.zClippingEnabled = enable;
1105     this.updateClippingGroups();
1106   }
1107 
1108   /**
1109    * Updates the Z clipping plane from an absolute Z coordinate and direction.
1110    * @param zPosition The Z coordinate where the plane sits.
1111    * @param forward   If true, keeps z >= zPosition. If false, keeps z <= zPosition.
1112    *
1113    * THREE.Plane visible side: normal · point + constant >= 0
1114    *   Forward:  normal=(0,0,1),  constant=-pos  →  z - pos >= 0  →  z >= pos
1115    *   Backward: normal=(0,0,-1), constant=+pos  → -z + pos >= 0  →  z <= pos
1116    */
1117   updateZClipping(zPosition: number, forward: boolean): void {
1118     this.zClipPlane.normal.set(0, 0, forward ? 1 : -1);
1119     this.zClipPlane.constant = forward ? -zPosition : zPosition;
1120     this.invalidate();
1121   }
1122 
1123   /**
1124    * Synchronise the two nested ClippingGroups with the current clipping state.
1125    *
1126    * Scene structure:
1127    *   zClippingGroup (union mode, Z plane)
1128    *     └── sceneGeometry (intersection/union, angular wedge planes)
1129    *
1130    * Separating them into two groups lets each have its own clipIntersection mode.
1131    */
1132   private updateClippingGroups(): void {
1133     if (!this.initialized) return;
1134 
1135     // Angular (wedge) clipping on sceneGeometry
1136     this.sceneGeometry.clippingPlanes = this.angularClippingEnabled ? [...this.clipPlanes] : [];
1137     this.sceneGeometry.enabled = this.angularClippingEnabled;
1138     this.sceneGeometry.clipIntersection = this.angularClippingEnabled ? this.clipIntersection : false;
1139 
1140     // Z clipping on the parent wrapper
1141     this.zClippingGroup.clippingPlanes = this.zClippingEnabled ? [this.zClipPlane] : [];
1142     this.zClippingGroup.enabled = this.zClippingEnabled;
1143 
1144     // three.js does not reliably rebuild shaders when the SET of clipping
1145     // planes changes (plane positions are fine — they are uniforms); see
1146     // dropClippingShaderState for the two defects. The eviction runs only
1147     // when the structure — enabled flags, plane count, intersection mode —
1148     // changes, never while a slider drags plane positions around.
1149     const structure = `${this.sceneGeometry.enabled}:${this.sceneGeometry.clipIntersection}:${this.sceneGeometry.clippingPlanes.length}:${this.zClippingEnabled}`;
1150     if (structure !== this.lastClippingStructure) {
1151       this.lastClippingStructure = structure;
1152       this.dropClippingShaderState();
1153     }
1154     this.invalidate();
1155   }
1156 
1157   /**
1158    * Makes the next frame rebuild every clipped object's render state against
1159    * the current clipping structure. Called whenever the SET of active
1160    * clipping planes changes (never for plane position updates — those are
1161    * uniforms).
1162    *
1163    * Two three.js defects make the rebuild necessary:
1164    *
1165    * - Render objects: RenderObjects.get short-circuits on material.version
1166    *   before consuming the one-shot clippingNeedsUpdate getter, so a pipeline
1167    *   compiled without planes keeps rendering unclipped after planes appear.
1168    *
1169    * - Node states: built shaders bind clipping planes to the ARRAY INSTANCE
1170    *   their ClippingContext held at build time, but ClippingContext.update()
1171    *   REPLACES its arrays whenever the parent group chain changes (e.g. an
1172    *   outer clipping group toggled). A toggle that returns to a
1173    *   previously-seen plane-count shape would reuse a shader bound to the
1174    *   ORPHANED array — whose view-space plane values nobody re-projects,
1175    *   leaving the cut frozen to the camera (orbit moves the cut, zoom clips
1176    *   deeper).
1177    *
1178    * The eviction disposes the materials of the clipped subtrees (the
1179    * original geometry and the slice copy share them). Every render object
1180    * built for a material listens to its 'dispose' event and releases itself
1181    * through the renderer's reference counting: pipeline, bindings and node
1182    * state usage drop, and a node state whose last user went away leaves the
1183    * cache. Disposing a material keeps it usable; the next frame builds fresh
1184    * render objects and node states that capture the live plane arrays.
1185    * Objects outside the clipping chain (event data, helpers) keep theirs.
1186    *
1187    * three-clipping-internals.spec.ts pins the array-replacement behavior.
1188    * Without this eviction the cut stays frozen to the camera after a toggle;
1189    * with it, repeated toggles keep the render-object count flat.
1190    */
1191   private dropClippingShaderState(): void {
1192     // A hovered mesh shows the shared hover material; restore its own first
1193     // so the eviction reaches the material it renders with afterwards.
1194     this.clearHoverHighlight();
1195     const materials = new Set<THREE.Material>();
1196     const collect = (object: THREE.Object3D) => {
1197       const material = (object as THREE.Mesh).material as THREE.Material | THREE.Material[] | undefined;
1198       if (Array.isArray(material)) {
1199         for (const entry of material) materials.add(entry);
1200       } else if (material) {
1201         materials.add(material);
1202       }
1203     };
1204     this.zClippingGroup?.traverse(collect);
1205     this.geometrySlice?.group.traverse(collect);
1206     for (const material of materials) {
1207       material.dispose();
1208     }
1209   }
1210 
1211   /**
1212    * Toggles the MAIN view between perspective and orthographic cameras.
1213    * @param useOrtho Whether to use the orthographic camera.
1214    */
1215   toggleOrthographicView(useOrtho: boolean): void {
1216     this.mainView.toggleOrthographicView(useOrtho);
1217     this.invalidate();
1218   }
1219 
1220   /**
1221    * Sets the main view's camera up vector, keeping its OrbitControls frame in
1222    * sync. Used by camera view presets and the viewport gizmo.
1223    */
1224   setCameraUp(up: THREE.Vector3): void {
1225     this.mainView.setCameraUp(up);
1226     this.invalidate();
1227   }
1228 
1229 
1230 
1231   /**
1232    * Ensures the service has been initialized before performing operations.
1233    * @param methodName The name of the method performing the check.
1234    */
1235   private ensureInitialized(methodName: string): void {
1236     if (!this.initialized) {
1237       const errorMsg = `ThreeService Error: Method '${methodName}' called before initialization. Call 'init(container)' first.`;
1238       console.error(errorMsg);
1239       throw new Error(errorMsg);
1240     }
1241   }
1242 
1243   /**
1244    * Application teardown (the root injector is destroyed): extensions get
1245    * onDispose, then views, listeners, BVHs and the renderer go away. Display
1246    * pages unmounting do not reach this; they call detach().
1247    */
1248   ngOnDestroy(): void {
1249     runIsolated(this.extensions, extension => extension.onDispose?.(),
1250       (extension, error) => console.error('[ThreeService] Extension onDispose failed:', extension, error));
1251     this.clearHoverHighlight();
1252     this.stopRendering();
1253     this.cleanupEventListeners();
1254     for (const view of this.views) {
1255       view.dispose();
1256     }
1257     this.views = [];
1258     this.bvhQueue = [];
1259     if(this.sceneGeometry) this.cleanupBVH(this.sceneGeometry);
1260     if(this.sceneEvent) this.cleanupBVH(this.sceneEvent);
1261     this.hoverMaterial.dispose();
1262     this.renderer?.dispose();
1263   }
1264 
1265 
1266   logRendererInfo() {
1267     // Access the renderer from threeService
1268     const renderer = this.renderer;
1269     const info = renderer.info;
1270     console.log('Draw calls:', info.render.calls);
1271     console.log('Triangles:', info.render.triangles);
1272     console.log('Points:', info.render.points);
1273     console.log('Lines:', info.render.lines);
1274     console.log('Geometries in memory:', info.memory.geometries);
1275     console.log('Textures in memory:', info.memory.textures);
1276     console.log('Pipelines:', (info as any).pipelines?.length);
1277   }
1278 
1279   // -------------------------------------------------------------------------
1280   // Hover highlight
1281   // -------------------------------------------------------------------------
1282 
1283   /**
1284    * Shows the shared hover material on a geometry mesh. The mesh's own
1285    * material is parked and comes back in clearHoverHighlight; nothing is
1286    * cloned, so knob restyles and selection highlights keep acting on the
1287    * real material.
1288    */
1289   private highlightGeometryMesh(mesh: THREE.Mesh): void {
1290     if (this.hoveredMesh === mesh) return;
1291     this.clearHoverHighlight();
1292     this.hoveredMesh = mesh;
1293     this.hoveredMeshMaterial = mesh.material;
1294     mesh.material = this.hoverMaterial;
1295     this.invalidate();
1296   }
1297 
1298   /**
1299    * Restores the hovered geometry mesh's own material. Call before
1300    * disposing or replacing geometry that may be under the pointer.
1301    */
1302   clearHoverHighlight(): void {
1303     const mesh = this.hoveredMesh;
1304     if (!mesh) return;
1305     if (this.hoveredMeshMaterial) {
1306       mesh.material = this.hoveredMeshMaterial;
1307     }
1308     this.hoveredMesh = null;
1309     this.hoveredMeshMaterial = null;
1310     this.invalidate();
1311   }
1312 
1313   /** Ends any hover: geometry highlight off, and event-data hover reported as left. */
1314   private endHover(): void {
1315     if (this.hoverTimeout) {
1316       clearTimeout(this.hoverTimeout);
1317       this.hoverTimeout = null;
1318     }
1319     this.clearHoverHighlight();
1320     if (this.hoveringEventData) {
1321       this.hoveringEventData = false;
1322       this.trackHovered.next(null);
1323     }
1324   }
1325 
1326   // -------------------------------------------------------------------------
1327   // Picking
1328   // -------------------------------------------------------------------------
1329 
1330   /**
1331    * The clipping planes that hide geometry in `view`. A view with its own
1332    * cut renders the slice copy, clipped by its plane alone; every other view
1333    * renders the original geometry under the main chain (Z group around the
1334    * wedge group).
1335    */
1336   private clipPlanesFor(view: RenderViewImpl): ClipPlaneSets {
1337     if (view.geometrySlice && view.clipPlane) {
1338       return clipPlaneSetsOf([{
1339         enabled: view.geometrySlice.group.enabled,
1340         clipIntersection: view.geometrySlice.group.clipIntersection,
1341         clippingPlanes: [view.clipPlane],
1342       }]);
1343     }
1344     return clipPlaneSetsOf([this.zClippingGroup, this.sceneGeometry]);
1345   }
1346 
1347   /** The geometry subtree `view` renders: the slice copy for views with their own cut. */
1348   private geometryRootFor(view: RenderViewImpl): THREE.Object3D {
1349     return view.geometrySlice && view.clipPlane ? view.geometrySlice.group : this.sceneGeometry;
1350   }
1351 
1352   /**
1353    * Casts from the view camera through the pointer: event data first, then
1354    * geometry. Invisible subtrees (hidden pieces, hidden detector parts) are
1355    * skipped. The clip test applies to GEOMETRY hits only — event data is
1356    * never clipped visually (sceneEvent sits outside the clipping groups),
1357    * so it stays pickable everywhere it is drawn.
1358    */
1359   private pick(view: RenderViewImpl, event: { clientX: number; clientY: number }): { hit: THREE.Intersection; isEventData: boolean } | null {
1360     const raycaster = view.raycasterFromEvent(event);
1361     if (!raycaster) return null;
1362     raycaster.firstHitOnly = false;
1363 
1364     const isPickable = (hit: THREE.Intersection) => {
1365       const name = hit.object.name;
1366       return !name.includes('Helper') && !name.startsWith('MeasurePoint_');
1367     };
1368 
1369     const eventHit = intersectVisible(raycaster, [this.sceneEvent]).find(isPickable);
1370     if (eventHit) return { hit: eventHit, isEventData: true };
1371 
1372     const planes = this.clipPlanesFor(view);
1373     const geometryHit = intersectVisible(raycaster, [this.geometryRootFor(view)])
1374       .find(hit => isPickable(hit) && !isPointClipped(hit.point, planes));
1375     return geometryHit ? { hit: geometryHit, isEventData: false } : null;
1376   }
1377 
1378   /**
1379    * Sets up the raycasting functionality with proper clipping support.
1380    * Handlers are installed per view: each view raycasts with its own camera
1381    * from pointer positions inside its own container.
1382    */
1383   private setupRaycasting(): void {
1384     for (const view of this.views) {
1385       this.installRaycastHandlers(view);
1386     }
1387   }
1388 
1389   private installRaycastHandlers(view: RenderViewImpl): void {
1390     if (this.viewPointerHandlers.has(view)) return;
1391 
1392     // Hover: throttled, opt-in (toggleRaycast). The latest pointer position
1393     // wins when the throttle window closes.
1394     let lastMove: PointerEvent | null = null;
1395     const onPointerMove = (event: PointerEvent) => {
1396       if (!this.isRaycastEnabled || this.measureMode) {
1397         this.endHover();
1398         return;
1399       }
1400       // A pressed button means an orbit or pan is in progress.
1401       if (event.buttons !== 0) return;
1402 
1403       lastMove = event;
1404       if (this.hoverTimeout) return;
1405 
1406       this.hoverTimeout = window.setTimeout(() => {
1407         this.hoverTimeout = null;
1408         if (!lastMove) return;
1409         const picked = this.pick(view, lastMove);
1410 
1411         if (picked?.isEventData) {
1412           this.clearHoverHighlight();
1413           this.hoveringEventData = true;
1414           const { hit } = picked;
1415           this.trackHovered.next({ track: hit.object, point: hit.point.clone(), intersection: hit });
1416         } else {
1417           if (this.hoveringEventData) {
1418             this.hoveringEventData = false;
1419             this.trackHovered.next(null);
1420           }
1421           const mesh = picked?.hit.object as THREE.Mesh | undefined;
1422           if (mesh?.isMesh) {
1423             this.highlightGeometryMesh(mesh);
1424           } else {
1425             this.clearHoverHighlight();
1426           }
1427         }
1428 
1429         if (picked) {
1430           const point = picked.hit.point.clone();
1431           this.ngZone.run(() => this.pointHovered.next(point));
1432         }
1433       }, 16); // ~60fps throttling
1434     };
1435 
1436     const onPointerLeave = () => {
1437       lastMove = null;
1438       this.endHover();
1439     };
1440 
1441     // Selection: a click (press and release without dragging) picks.
1442     // Selecting on the press would select whatever lies under the pointer
1443     // when an orbit starts. No preventDefault: OrbitControls keep working.
1444     let press: { pointerId: number; clientX: number; clientY: number } | null = null;
1445     const onPointerDown = (event: PointerEvent) => {
1446       press = event.button === 0 && !this.measureMode
1447         ? { pointerId: event.pointerId, clientX: event.clientX, clientY: event.clientY }
1448         : null;
1449     };
1450     const onPointerUp = (event: PointerEvent) => {
1451       const start = press;
1452       press = null;
1453       if (!start || event.pointerId !== start.pointerId || event.button !== 0 || this.measureMode) return;
1454       if (!isClickGesture(start, event)) return;
1455 
1456       const picked = this.pick(view, event);
1457       if (picked) {
1458         const { hit } = picked;
1459         this.trackClicked.next({ track: hit.object, point: hit.point.clone(), intersection: hit });
1460       }
1461     };
1462     const onPointerCancel = () => {
1463       press = null;
1464     };
1465 
1466     //  Double-click handler for distance measurement
1467     const onDoubleClick = (event: MouseEvent) => {
1468       if (!this.isRaycastEnabled || !this.measureMode) return;
1469 
1470       event.preventDefault();
1471       event.stopPropagation();
1472 
1473       const picked = this.pick(view, event)?.hit;
1474 
1475       if (picked) {
1476         const pt = picked.point.clone();
1477 
1478         if (!this.firstMeasurePoint) {
1479           this.firstMeasurePoint = pt;
1480           this.showMeasurePoint(pt, 'first');
1481           console.log('[raycast] DIST: first point from', picked.object.name, pt);
1482         } else {
1483           const p1 = this.firstMeasurePoint.clone();
1484           const p2 = pt;
1485           const dist = p1.distanceTo(p2);
1486 
1487           this.showMeasurePoint(pt, 'second');
1488 
1489           this.ngZone.run(() => {
1490             this.distanceReady.next({ p1, p2, dist });
1491           });
1492 
1493           console.log('[raycast] DIST: second point from', picked.object.name, '→', dist.toFixed(2));
1494 
1495           //  Reset after measurement
1496           setTimeout(() => this.resetMeasurement(), 2000); // Clear after 2 seconds
1497         }
1498       }
1499     };
1500 
1501     // Listeners live on the view container: canvas events bubble to it in the
1502     // single-view page, and in multi-view pages the containers sit above the
1503     // canvas and receive the events directly.
1504     const handlers = {
1505       move: onPointerMove,
1506       leave: onPointerLeave,
1507       down: onPointerDown,
1508       up: onPointerUp,
1509       cancel: onPointerCancel,
1510       dblclick: onDoubleClick,
1511     };
1512     this.viewPointerHandlers.set(view, handlers);
1513     view.container.addEventListener('pointermove', handlers.move, false);
1514     view.container.addEventListener('pointerleave', handlers.leave, false);
1515     view.container.addEventListener('pointerdown', handlers.down, false);
1516     view.container.addEventListener('pointerup', handlers.up, false);
1517     view.container.addEventListener('pointercancel', handlers.cancel, false);
1518     view.container.addEventListener('dblclick', handlers.dblclick, false);
1519   }
1520 
1521   /** Detaches the pointer handlers installed for a view. */
1522   private removeRaycastHandlers(view: RenderViewImpl): void {
1523     const handlers = this.viewPointerHandlers.get(view);
1524     if (!handlers) return;
1525     this.viewPointerHandlers.delete(view);
1526     view.container.removeEventListener('pointermove', handlers.move, false);
1527     view.container.removeEventListener('pointerleave', handlers.leave, false);
1528     view.container.removeEventListener('pointerdown', handlers.down, false);
1529     view.container.removeEventListener('pointerup', handlers.up, false);
1530     view.container.removeEventListener('pointercancel', handlers.cancel, false);
1531     view.container.removeEventListener('dblclick', handlers.dblclick, false);
1532   }
1533 
1534 //  Visual feedback for measurement points
1535   private showMeasurePoint(point: THREE.Vector3, type: 'first' | 'second'): void {
1536     const geometry = new THREE.SphereGeometry(8, 16, 16);
1537     const material = new THREE.MeshBasicMaterial({
1538       color: type === 'first' ? 0x00ff00 : 0x0000ff, // Green for first, blue for second
1539       transparent: true,
1540       opacity: 0.9,
1541       depthTest: false,
1542       depthWrite: false
1543     });
1544 
1545     const sphere = new THREE.Mesh(geometry, material);
1546     sphere.position.copy(point);
1547     sphere.name = `MeasurePoint_${type}`;
1548     sphere.renderOrder = 1000;
1549 
1550     // Add to helpers scene
1551     this.sceneHelpers.add(sphere);
1552     this.measurementPoints.push(sphere);
1553     this.invalidate();
1554   }
1555 
1556 //  Reset measurement state and clear visual indicators
1557   private resetMeasurement(): void {
1558     this.firstMeasurePoint = null;
1559     this.clearMeasurePoints();
1560   }
1561 
1562   private clearMeasurePoints(): void {
1563     this.measurementPoints.forEach(point => {
1564       this.sceneHelpers.remove(point);
1565       point.geometry.dispose();
1566       if (point.material instanceof THREE.Material) {
1567         point.material.dispose();
1568       }
1569     });
1570     this.measurementPoints = [];
1571     this.invalidate();
1572   }
1573 
1574   /**
1575    * Clean up event listeners
1576    */
1577   private cleanupEventListeners(): void {
1578     for (const view of [...this.viewPointerHandlers.keys()]) {
1579       this.removeRaycastHandlers(view);
1580     }
1581 
1582     //  Clear timeout if active
1583     if (this.hoverTimeout) {
1584       clearTimeout(this.hoverTimeout);
1585       this.hoverTimeout = null;
1586     }
1587   }
1588 
1589   // -------------------------------------------------------------------------
1590   // Picking BVHs
1591   // -------------------------------------------------------------------------
1592 
1593   /**
1594    * Queues a picking BVH for every geometry mesh and builds them in idle
1595    * time, a few meshes per idle period, so a load never blocks the page for
1596    * the whole detector at once. Until a mesh's BVH exists, picking falls
1597    * back to three's raycast for it. A new load replaces the queue.
1598    */
1599   private scheduleGeometryBvh(): void {
1600     this.bvhQueue = collectBvhCandidates(this.sceneGeometry);
1601     if (this.bvhBuildScheduled || this.bvhQueue.length === 0) return;
1602     this.bvhBuildScheduled = true;
1603     whenIdle(deadline => this.buildQueuedBvh(deadline));
1604   }
1605 
1606   private buildQueuedBvh(deadline: { timeRemaining(): number }): void {
1607     const start = performance.now();
1608     let built = 0;
1609     // At least one mesh per idle period, then as many as the period allows.
1610     do {
1611       const mesh = this.bvhQueue.shift();
1612       if (!mesh) break;
1613       // A newer load may have removed it while it waited.
1614       if (!this.isUnderGeometry(mesh)) continue;
1615       if (buildPickingBvh(mesh)) built++;
1616       if (this.showBVHDebug && mesh.geometry.boundsTree) {
1617         mesh.add(new MeshBVHHelper(mesh));
1618         this.invalidate();
1619       }
1620     } while (this.bvhQueue.length > 0 && deadline.timeRemaining() > 2);
1621 
1622     const elapsed = performance.now() - start;
1623     if (elapsed > 50) {
1624       console.log(`[ThreeService] Picking BVH: ${built} built in ${elapsed.toFixed(0)} ms, ${this.bvhQueue.length} queued`);
1625     }
1626     if (this.bvhQueue.length > 0) {
1627       whenIdle(next => this.buildQueuedBvh(next));
1628     } else {
1629       this.bvhBuildScheduled = false;
1630     }
1631   }
1632 
1633   private isUnderGeometry(object: THREE.Object3D): boolean {
1634     for (let node: THREE.Object3D | null = object; node; node = node.parent) {
1635       if (node === this.sceneGeometry) return true;
1636     }
1637     return false;
1638   }
1639 
1640   /** Drops the picking BVHs of a subtree (they are rebuilt by the next geometryChanged()). */
1641   cleanupBVH(object: THREE.Object3D): void {
1642     object.traverse(child => {
1643       const geometry = (child as THREE.Mesh).geometry as THREE.BufferGeometry | undefined;
1644       if (geometry?.boundsTree) {
1645         geometry.boundsTree = undefined;
1646       }
1647     });
1648   }
1649 
1650   //  Enhanced toggle methods
1651   toggleRaycast(): void {
1652     this.isRaycastEnabled = !this.isRaycastEnabled;
1653     console.log(`Raycast is now ${this.isRaycastEnabled ? 'ENABLED' : 'DISABLED'}`);
1654 
1655     if (!this.isRaycastEnabled) {
1656       this.endHover();
1657       //  Reset measurement when disabling raycast
1658       this.resetMeasurement();
1659     }
1660   }
1661 
1662   isRaycastEnabledState(): boolean {
1663     return this.isRaycastEnabled;
1664   }
1665 
1666 }