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 }