Warning, /firebird/firebird-ng/src/app/services/three.service.ts is written in an unsupported language. File is not indexed.
0001 import { Injectable, NgZone, OnDestroy} 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 Frustum,
0011 Matrix4,
0012 Camera,
0013 Scene, Mesh
0014 } from 'three';
0015 import {PerfService} from "./perf.service";
0016 import {BehaviorSubject, Subject} from "rxjs";
0017
0018
0019 import {
0020 acceleratedRaycast,
0021 computeBoundsTree,
0022 disposeBoundsTree, MeshBVH, MeshBVHHelper
0023 } from 'three-mesh-bvh';
0024
0025
0026
0027 @Injectable({
0028 providedIn: 'root',
0029 })
0030 export class ThreeService implements OnDestroy {
0031
0032
0033 /** Three.js core components */
0034 public scene!: THREE.Scene;
0035 public sceneGeometry!: THREE.Group;
0036 public sceneEvent!: THREE.Group;
0037 public sceneHelpers!: THREE.Group;
0038 public renderer!: THREE.WebGLRenderer;
0039 public controls!: OrbitControls;
0040
0041
0042 /** Cameras */
0043 public perspectiveCamera!: THREE.PerspectiveCamera;
0044 public orthographicCamera!: THREE.OrthographicCamera;
0045
0046 /** Camera that is actually used */
0047 public camera!: THREE.PerspectiveCamera | THREE.OrthographicCamera;
0048 public cameraMode$ = new BehaviorSubject<boolean>(true);
0049
0050 /** Optional clipping planes and logic. */
0051 public clipPlanes = [
0052 new THREE.Plane(new THREE.Vector3(0, -1, 0), 0),
0053 new THREE.Plane(new THREE.Vector3(0, 1, 0), 0),
0054 ];
0055
0056 /** Functions callbacks that help organize performance */
0057 public profileBeginFunc: (() => void) | null = null;
0058 public profileEndFunc: (() => void) | null = null;
0059
0060
0061
0062
0063 /** Animation loop control */
0064 private animationFrameId: number | null = null;
0065 private shouldRender = false;
0066
0067 /** Callbacks to run each frame before rendering. */
0068 private frameCallbacks: Array<() => void> = [];
0069
0070 private clipIntersection: boolean = false;
0071
0072 /** Initialization flag */
0073 private initialized: boolean = false;
0074
0075 /** Reference to the container element used for rendering */
0076 private containerElement!: HTMLElement;
0077
0078 /** Lights */
0079 private ambientLight!: AmbientLight;
0080 private hemisphereLight!: HemisphereLight;
0081 private directionalLight!: DirectionalLight;
0082 private pointLight!: PointLight; // Optional
0083 private spotLight!: SpotLight; // Optional
0084
0085 /** BVH wizard */
0086 private boundsViz!: MeshBVHHelper;
0087
0088 // Raycasting properties
0089 private raycaster = new THREE.Raycaster();
0090 private pointer = new THREE.Vector2();
0091 private isRaycastEnabled = false;
0092
0093
0094 // Track hover indicator
0095 // private hoverPoint: THREE.Mesh | null = null;
0096
0097 // Track highlighted object for raycast feedback
0098 private highlightedObject: THREE.Object3D | null = null;
0099 private originalMaterials = new Map<THREE.Object3D, THREE.Material | THREE.Material[]>();
0100
0101 // Events
0102 public trackHovered = new Subject<{track: THREE.Object3D, point: THREE.Vector3}>();
0103 public trackClicked = new Subject<{track: THREE.Object3D, point: THREE.Vector3}>();
0104
0105 // Raw hit point every frame (hover)
0106 public pointHovered = new Subject<THREE.Vector3>();
0107
0108 // Distance ready after second point
0109 public distanceReady = new Subject<{ p1: THREE.Vector3; p2: THREE.Vector3; dist: number }>();
0110
0111 // Toggle by UI when “3‑D Distance” checkbox is on
0112 public measureMode = false;
0113
0114 // temp storage for first measure point
0115 private firstMeasurePoint: THREE.Vector3 | null = null;
0116
0117 // Add properties for event handlers and measurement state
0118 private pointerMoveHandler?: (event: PointerEvent) => void;
0119 private pointerDownHandler?: (event: PointerEvent) => void;
0120 private doubleClickHandler?: (event: MouseEvent) => void;
0121 private hoverTimeout: number | null = null;
0122 private measurementPoints: THREE.Mesh[] = [];
0123
0124 private frustumCuller = {
0125 frustum: new Frustum(),
0126 projScreenMatrix: new Matrix4(),
0127
0128 updateFrustum(camera: Camera): void {
0129 this.projScreenMatrix.multiplyMatrices(
0130 camera.projectionMatrix,
0131 camera.matrixWorldInverse
0132 );
0133 this.frustum.setFromProjectionMatrix(this.projScreenMatrix);
0134 },
0135
0136 cullMeshes(scene: Scene, camera: Camera): void {
0137 this.updateFrustum(camera);
0138
0139 scene.traverse((object:any) => {
0140 if (object!=null && (object as any).isMesh) {
0141 // First check if object has bounds
0142 if (!object.geometry.boundingBox) {
0143 object.geometry.computeBoundingBox();
0144 }
0145
0146 // For objects with BVH
0147 if (object.geometry.boundsTree) {
0148 // Use BVH for efficient culling
0149 const visible = object.geometry.boundsTree.shapecast({
0150 intersectsBounds: (box: THREE.Box3) => {
0151 return this.frustum.intersectsBox(box);
0152 }
0153 });
0154 console.log("Shapecast!");
0155 object.visible = visible ?? true;
0156 } else {
0157 // Fallback to standard bounding box check
0158 const box = new THREE.Box3().setFromObject(object);
0159 object.visible = this.frustum.intersectsBox(box);
0160 }
0161 }
0162 });
0163 }
0164 };
0165
0166
0167 constructor(
0168 private ngZone: NgZone,
0169 private perfService: PerfService) {
0170 // Empty constructor – initialization happens in init()
0171
0172 // Apply mesh-bvh acceleration to improve raycasting performance
0173 THREE.Mesh.prototype.raycast = acceleratedRaycast;
0174 THREE.BufferGeometry.prototype.computeBoundsTree = computeBoundsTree;
0175 THREE.BufferGeometry.prototype.disposeBoundsTree = disposeBoundsTree;
0176 }
0177
0178 /**
0179 * Initializes the Three.js scene, camera, renderer, controls, and lights.
0180 * Must be called before any other method.
0181 * @param container A string representing the ID of the HTML element,
0182 * or the actual HTMLElement where the renderer will attach.
0183 */
0184 init(container: string | HTMLElement): void {
0185
0186 let containerElement: HTMLElement;
0187
0188 // Figure out the container
0189 if (typeof container === 'string') {
0190 const el = document.getElementById(container);
0191 if (!el) {
0192 throw new Error(`ThreeService Initialization Error: Container element #${container} not found.`);
0193 }
0194 containerElement = el;
0195 } else {
0196 containerElement = container;
0197 }
0198
0199 // If already initialized once, warn but still re-attach the canvas.
0200 if (this.initialized) {
0201 console.warn('ThreeService has already been initialized. Re-attaching renderer...');
0202 this.attachRenderer(containerElement);
0203 return;
0204 }
0205
0206 this.containerElement = containerElement;
0207
0208 // 1) Create scene
0209 this.scene = new THREE.Scene();
0210 this.scene.background = new THREE.Color(0x3f3f3f); // Dark grey background
0211
0212 // Geometry scene tree
0213 this.sceneGeometry = new THREE.Group();
0214 this.sceneGeometry.name = 'Geometry';
0215 this.scene.add(this.sceneGeometry);
0216
0217 // Event scene tree
0218 this.sceneEvent = new THREE.Group();
0219 this.sceneEvent.name = 'Event';
0220 this.scene.add(this.sceneEvent);
0221
0222 // Lights scene tree
0223 this.sceneHelpers = new THREE.Group();
0224 this.sceneHelpers.name = 'Helpers';
0225 this.scene.add(this.sceneHelpers);
0226
0227 // BVH helper
0228 // this.boundsViz = new MeshBVHHelper( knots[ 0 ] );
0229 // containerObj.add( boundsViz );
0230
0231 // Create cameras
0232 this.perspectiveCamera = new THREE.PerspectiveCamera(60, 1, 10, 40000);
0233 this.perspectiveCamera.position.set(-7000, 0 , 0);
0234
0235
0236
0237 // Better orthographic camera initialization
0238 const orthoSize = 1000; // Start with a large enough size to see the detector
0239 this.orthographicCamera = new THREE.OrthographicCamera(
0240 -orthoSize, orthoSize,
0241 orthoSize, -orthoSize,
0242 -10000, 40000 // Critical change: Allow negative near plane to see objects behind camera position
0243 );
0244 this.orthographicCamera.position.copy(this.perspectiveCamera.position);
0245 this.orthographicCamera.lookAt(this.scene.position);
0246
0247 // Default camera is perspective
0248 this.camera = this.perspectiveCamera;
0249
0250 // Create renderer
0251 this.renderer = new THREE.WebGLRenderer({ antialias: true });
0252 this.renderer.setPixelRatio(window.devicePixelRatio);
0253 this.renderer.localClippingEnabled = false;
0254 this.renderer.shadowMap.enabled = true;
0255 this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
0256
0257 // Append renderer to the container
0258 this.containerElement.appendChild(this.renderer.domElement);
0259
0260 // Create OrbitControls
0261 this.controls = new OrbitControls(this.camera, this.renderer.domElement);
0262 this.controls.target.set(0, 0, 0);
0263 this.controls.enableDamping = false;
0264 this.controls.dampingFactor = 0.05;
0265
0266 // Perspective camera distance limits
0267 const sceneRadius = 15000;
0268 this.controls.minDistance = sceneRadius * 0.05;
0269 this.controls.maxDistance = sceneRadius * 5;
0270 this.camera.far = this.controls.maxDistance * 1.1;
0271 this.camera.updateProjectionMatrix();
0272
0273 this.controls.update();
0274
0275 // Setup lights
0276 this.setupLights();
0277
0278 // Add default objects
0279 this.addDefaultObjects();
0280
0281 // (!) We set initialized here, as at this point all main objects are created and configured
0282 // It is important not to set this flag at the function end as functions, such as setSize will check the flag
0283 this.initialized = true;
0284
0285 // ----------- POST INIT ------------------
0286
0287 // Set initial size
0288 const width = this.containerElement.clientWidth;
0289 const height = this.containerElement.clientHeight;
0290 this.setSize(width, height);
0291
0292
0293 // Initialize the hover point
0294 // this.initHoverPoint();
0295
0296 // Set up new raycasting handlers
0297 this.setupRaycasting();
0298
0299 // Compute BVH for all existing meshes for fast raycasting
0300 this.setupBVH();
0301
0302 // Start rendering
0303 this.startRendering();
0304 }
0305
0306 /**
0307 * If the service is already initialized (scene, camera, renderer exist),
0308 * you can re-attach the <canvas> to a container if it was removed or changed.
0309 */
0310 private attachRenderer(elem: HTMLElement): void {
0311 this.containerElement = elem;
0312
0313 // If the canvas is not already in the DOM, re-append it.
0314 if (this.renderer?.domElement) {
0315 this.containerElement.appendChild(this.renderer.domElement);
0316 }
0317 }
0318
0319 /**
0320 * When You Do Want to Recreate the Entire Scene
0321 * If sometimes you genuinely need to start fresh (e.g. user changed geometry drastically)
0322 */
0323 public reset(): void {
0324 this.stopRendering();
0325 // remove old scene from memory
0326 // e.g. dispose geometries, empty the scene, etc.
0327 this.renderer.domElement.parentNode?.removeChild(this.renderer.domElement);
0328
0329 this.initialized = false;
0330 // next time `init` is called, it will do full creation again.
0331 }
0332
0333
0334 /**
0335 * Sets up the lighting for the scene.
0336 */
0337 private setupLights(): void {
0338 this.ambientLight = new AmbientLight(0xffffff, 0.4);
0339 this.ambientLight.name = "Light-Ambient";
0340 this.sceneHelpers.add(this.ambientLight);
0341
0342 this.hemisphereLight = new HemisphereLight(0xffffff, 0x444444, 0.6);
0343 this.hemisphereLight.position.set(0, 200, 0);
0344 this.hemisphereLight.name = "Light-Hemisphere";
0345 this.sceneHelpers.add(this.hemisphereLight);
0346
0347 this.directionalLight = new DirectionalLight(0xffffff, 0.8);
0348 this.directionalLight.position.set(100, 200, 100);
0349 this.directionalLight.name = "Light-Directional";
0350 this.directionalLight.castShadow = true;
0351 this.directionalLight.shadow.mapSize.width = 512;
0352 this.directionalLight.shadow.mapSize.height = 512;
0353 this.directionalLight.shadow.camera.near = 0.5;
0354 this.directionalLight.shadow.camera.far = 1000;
0355 this.sceneHelpers.add(this.directionalLight);
0356
0357 this.pointLight = new PointLight(0xffffff, 0.5, 500);
0358 this.pointLight.position.set(-100, 100, -100);
0359 this.pointLight.castShadow = true;
0360 this.pointLight.name = "Light-Point";
0361 this.sceneHelpers.add(this.pointLight);
0362
0363 this.spotLight = new SpotLight(0xffffff, 0.5);
0364 this.spotLight.position.set(0, 300, 0);
0365 this.spotLight.angle = Math.PI / 6;
0366 this.spotLight.penumbra = 0.2;
0367 this.spotLight.decay = 2;
0368 this.spotLight.distance = 1000;
0369 this.spotLight.castShadow = true;
0370 this.spotLight.name = "Light-Spot";
0371 this.sceneHelpers.add(this.spotLight);
0372 }
0373
0374 /**
0375 * Adds default objects to the scene.
0376 */
0377 private addDefaultObjects(): void {
0378 // const gridHelper = new THREE.GridHelper(1000, 100);
0379 // gridHelper.name = "Grid";
0380 // this.sceneHelpers.add(gridHelper);
0381
0382 const axesHelper = new THREE.AxesHelper(1500);
0383 axesHelper.name = "Axes";
0384 this.sceneHelpers.add(axesHelper);
0385
0386 // const geometry = new THREE.BoxGeometry(100, 100, 100);
0387 // const material = new THREE.MeshStandardMaterial({ color: 0x00ff00 });
0388 // const cube = new THREE.Mesh(geometry, material);
0389 // cube.name = "TestCube"
0390 // cube.castShadow = true;
0391 // cube.receiveShadow = true;
0392 // this.sceneGeometry.add(cube);
0393 }
0394
0395 /**
0396 * Starts the rendering loop.
0397 */
0398 startRendering(): void {
0399 this.ensureInitialized('startRendering');
0400
0401 if (this.animationFrameId !== null) {
0402 console.warn('[ThreeService]: Rendering loop is already running.');
0403 return;
0404 }
0405
0406 this.shouldRender = true;
0407 this.ngZone.runOutsideAngular(() => {
0408 this.renderLoop();
0409 });
0410 }
0411
0412 /**
0413 * Stops the rendering loop.
0414 */
0415 stopRendering(): void {
0416 this.shouldRender = false;
0417 if (this.animationFrameId !== null) {
0418 cancelAnimationFrame(this.animationFrameId);
0419 this.animationFrameId = null;
0420 }
0421 }
0422
0423 /**
0424 * The render loop: updates controls, executes frame callbacks, renders the scene, and schedules the next frame.
0425 */
0426 private renderLoop(): void {
0427 if (!this.shouldRender) {
0428 return;
0429 }
0430
0431 this.animationFrameId = requestAnimationFrame(() => this.renderLoop());
0432
0433 try {
0434 const frameStartTime = performance.now(); // Add this
0435 // Profiling start
0436 this.profileBeginFunc?.();
0437
0438
0439 // Add frustum culling before rendering
0440 // this.frustumCuller.cullMeshes(this.scene, this.camera);
0441
0442 // Update three components
0443 this.controls.update();
0444 this.renderer.render(this.scene, this.camera);
0445 // Profiling end
0446 this.perfService.updateStats(this.renderer, frameStartTime);
0447
0448
0449 // Run all custom/users callbacks
0450 for (const cb of this.frameCallbacks) {
0451 cb();
0452 }
0453
0454 this.profileEndFunc?.();
0455 } catch (error) {
0456 console.error('(!!!) ThreeService Render Loop Error:', error);
0457 this.stopRendering();
0458 }
0459 }
0460
0461 /**
0462 * Adds a callback to be executed each frame before rendering.
0463 * Prevents duplicate callbacks.
0464 * @param callback Function to execute each frame.
0465 */
0466 addFrameCallback(callback: () => void): void {
0467 if (!this.frameCallbacks.includes(callback)) {
0468 this.frameCallbacks.push(callback);
0469 } else {
0470 console.warn('ThreeService: Attempted to add a duplicate frame callback.');
0471 }
0472 }
0473
0474 /**
0475 * Removes a previously added frame callback.
0476 * @param callback The callback function to remove.
0477 */
0478 removeFrameCallback(callback: () => void): void {
0479 const index = this.frameCallbacks.indexOf(callback);
0480 if (index !== -1) {
0481 this.frameCallbacks.splice(index, 1);
0482 } else {
0483 console.warn('ThreeService: Attempted to remove a non-existent frame callback.');
0484 }
0485 }
0486
0487 /**
0488 * Sets the size of the renderer and updates the camera projections.
0489 * @param width The new width in pixels.
0490 * @param height The new height in pixels.
0491 */
0492 setSize(width: number, height: number): void {
0493 if (!this.initialized) {
0494 console.error('ThreeService: setSize called before initialization.');
0495 return;
0496 }
0497 this.renderer.setSize(width, height);
0498
0499 this.perspectiveCamera.aspect = width / height;
0500 this.perspectiveCamera.updateProjectionMatrix();
0501
0502 this.orthographicCamera.left = width / -2;
0503 this.orthographicCamera.right = width / 2;
0504 this.orthographicCamera.top = height / 2;
0505 this.orthographicCamera.bottom = height / -2;
0506 this.orthographicCamera.updateProjectionMatrix();
0507
0508 this.controls.update();
0509 }
0510
0511 /**
0512 * Enables or disables local clipping.
0513 * @param enable Whether clipping should be enabled.
0514 */
0515 enableClipping(enable: boolean): void {
0516 this.renderer.localClippingEnabled = enable;
0517
0518 // Update all materials to use clipping planes when enabled
0519 if (enable) {
0520 this.updateMaterialClipping();
0521 }
0522 }
0523
0524 /**
0525 * Sets two-plane clipping by rotating the clipping planes.
0526 * @param startAngleDeg The starting angle in degrees.
0527 * @param openingAngleDeg The opening angle in degrees.
0528 */
0529 setClippingAngle(startAngleDeg: number, openingAngleDeg: number): void {
0530 const planeA = this.clipPlanes[0];
0531 const planeB = this.clipPlanes[1];
0532
0533 this.clipIntersection = openingAngleDeg < 180;
0534 const startAngle = (startAngleDeg * Math.PI) / 180;
0535 const openingAngle = (openingAngleDeg * Math.PI) / 180;
0536
0537 const quatA = new THREE.Quaternion();
0538 quatA.setFromAxisAngle(new THREE.Vector3(0, 0, 1), startAngle);
0539 planeA.normal.set(0, -1, 0).applyQuaternion(quatA);
0540
0541 const quatB = new THREE.Quaternion();
0542 quatB.setFromAxisAngle(new THREE.Vector3(0, 0, 1), startAngle + openingAngle);
0543 planeB.normal.set(0, 1, 0).applyQuaternion(quatB);
0544
0545 // Enable clipping and update materials
0546 this.renderer.localClippingEnabled = true;
0547 this.updateMaterialClipping();
0548 }
0549
0550 /**
0551 * Update all materials to use current clipping planes
0552 */
0553 private updateMaterialClipping(): void {
0554 const updateObjectClipping = (object: THREE.Object3D) => {
0555 if (object instanceof THREE.Mesh && object.material) {
0556 const materials = Array.isArray(object.material) ? object.material : [object.material];
0557
0558 materials.forEach((material: THREE.Material) => {
0559 if (this.renderer.localClippingEnabled) {
0560 material.clippingPlanes = this.clipPlanes;
0561 material.clipIntersection = this.clipIntersection;
0562 } else {
0563 material.clippingPlanes = null;
0564 material.clipIntersection = false;
0565 }
0566
0567 // Prevent z-fighting
0568 if (material instanceof THREE.MeshBasicMaterial ||
0569 material instanceof THREE.MeshLambertMaterial ||
0570 material instanceof THREE.MeshPhongMaterial ||
0571 material instanceof THREE.MeshStandardMaterial) {
0572 material.polygonOffset = true;
0573 material.polygonOffsetFactor = 1;
0574 material.polygonOffsetUnits = 1;
0575 }
0576
0577 material.needsUpdate = true;
0578 });
0579 }
0580 };
0581
0582 this.sceneGeometry.traverse(updateObjectClipping);
0583 this.sceneEvent.traverse(updateObjectClipping);
0584 }
0585
0586 /**
0587 * Toggles between perspective and orthographic cameras.
0588 * @param useOrtho Whether to use the orthographic camera.
0589 */
0590 toggleOrthographicView(useOrtho: boolean): void {
0591
0592 if (useOrtho) {
0593 // When switching to orthographic, sync position and target from perspective
0594 this.orthographicCamera.position.copy(this.perspectiveCamera.position);
0595
0596 // Get the current target from OrbitControls
0597 const target = this.controls.target.clone();
0598
0599
0600 // Update orthographic camera to look in the same direction
0601 this.orthographicCamera.lookAt(target);
0602
0603 // Calculate suitable frustum size based on distance to target
0604 const distance = this.orthographicCamera.position.distanceTo(target);
0605 const orthoSize = distance * Math.tan(THREE.MathUtils.degToRad(this.perspectiveCamera.fov / 2));
0606
0607 // Update orthographic frustum based on aspect ratio
0608 const aspect = this.renderer.domElement.width / this.renderer.domElement.height;
0609 this.orthographicCamera.left = -orthoSize * aspect;
0610 this.orthographicCamera.right = orthoSize * aspect;
0611 this.orthographicCamera.top = orthoSize;
0612 this.orthographicCamera.bottom = -orthoSize;
0613
0614 // Set a generous near/far plane range to ensure all geometry is visible
0615 this.orthographicCamera.near = -10000;
0616 this.orthographicCamera.far = 40000;
0617
0618 this.orthographicCamera.updateProjectionMatrix();
0619 this.camera = this.orthographicCamera;
0620 } else {
0621 // Switch back to perspective camera
0622 this.camera = this.perspectiveCamera;
0623 }
0624
0625 // Update the controls to use the current camera
0626 this.controls.object = this.camera;
0627 this.controls.update();
0628
0629 this.cameraMode$.next(!useOrtho);
0630 }
0631
0632 /**
0633 * Ensures the service has been initialized before performing operations.
0634 * @param methodName The name of the method performing the check.
0635 */
0636 private ensureInitialized(methodName: string): void {
0637 if (!this.initialized) {
0638 const errorMsg = `ThreeService Error: Method '${methodName}' called before initialization. Call 'init(container)' first.`;
0639 console.error(errorMsg);
0640 throw new Error(errorMsg);
0641 }
0642 }
0643
0644 /**
0645 * Cleans up resources when the service is destroyed.
0646 */
0647 ngOnDestroy(): void {
0648 this.clearHighlight();
0649 this.stopRendering();
0650 this.cleanupEventListeners();
0651 if(this.sceneGeometry) this.cleanupBVH(this.sceneGeometry);
0652 if(this.sceneEvent) this.cleanupBVH(this.sceneEvent);
0653 }
0654
0655
0656 logRendererInfo() {
0657 // Access the THREE.WebGLRenderer from threeService
0658 const renderer = this.renderer;
0659 const info = renderer.info;
0660 console.log('Draw calls:', info.render.calls);
0661 console.log('Triangles:', info.render.triangles);
0662 console.log('Points:', info.render.points);
0663 console.log('Lines:', info.render.lines);
0664 console.log('Geometries in memory:', info.memory.geometries);
0665 console.log('Textures in memory:', info.memory.textures);
0666 console.log('Programs:', info.programs?.length);
0667 console.log(info.programs);
0668 }
0669 // /**
0670 // * Initialize the hover point indicator
0671 // */
0672 // private initHoverPoint(): void {
0673 // const sphereGeom = new THREE.SphereGeometry(6, 16, 16);
0674 // const sphereMat = new THREE.MeshBasicMaterial({
0675 // color: 0xff0000,
0676 // transparent: true,
0677 // opacity: 0.8,
0678 // depthTest: false,
0679 // depthWrite: false
0680 // });
0681 //
0682 // this.hoverPoint = new THREE.Mesh(sphereGeom, sphereMat);
0683 // this.hoverPoint.visible = false;
0684 // this.hoverPoint.name = "HoverPoint";
0685 // this.hoverPoint.renderOrder = 999;
0686 // this.sceneHelpers.add(this.hoverPoint);
0687 // }
0688
0689 /**
0690 * Highlight an object by making its material brighter
0691 */
0692 private highlightObject(object: THREE.Object3D): void {
0693 if (this.highlightedObject === object) return;
0694
0695 // Clear previous highlight
0696 this.clearHighlight();
0697
0698 if (object instanceof THREE.Mesh && object.material) {
0699 this.highlightedObject = object;
0700
0701 // Store original material(s)
0702 this.originalMaterials.set(object, object.material);
0703
0704 // Create highlighted version
0705 const materials = Array.isArray(object.material) ? object.material : [object.material];
0706 const highlightedMaterials = materials.map(mat => {
0707 const highlightMat = mat.clone();
0708
0709 // Make material brighter by increasing emissive
0710 if ('emissive' in highlightMat) {
0711 highlightMat.emissive.setHex(0x444444); // Add subtle glow
0712 }
0713
0714 // Increase overall brightness for materials that support it
0715 if ('color' in highlightMat && highlightMat.color) {
0716 highlightMat.color.multiplyScalar(1.5); // Make 50% brighter
0717 }
0718
0719 highlightMat.needsUpdate = true;
0720 return highlightMat;
0721 });
0722
0723 object.material = Array.isArray(object.material) ? highlightedMaterials : highlightedMaterials[0];
0724 }
0725 }
0726
0727 /**
0728 * Clear the current highlight
0729 */
0730 private clearHighlight(): void {
0731 if (this.highlightedObject && this.originalMaterials.has(this.highlightedObject)) {
0732 const original = this.originalMaterials.get(this.highlightedObject);
0733 if (this.highlightedObject instanceof THREE.Mesh && original) {
0734 this.highlightedObject.material = original;
0735 }
0736 this.originalMaterials.delete(this.highlightedObject);
0737 this.highlightedObject = null;
0738 }
0739 }
0740
0741 /**
0742 * Helper to check if a point is clipped by active clipping planes
0743 */
0744 private isPointClipped(point: THREE.Vector3): boolean {
0745 if (!this.renderer.localClippingEnabled || this.clipPlanes.length === 0) {
0746 return false;
0747 }
0748
0749 for (const plane of this.clipPlanes) {
0750 const distance = plane.distanceToPoint(point);
0751 if (distance < 0) {
0752 return true;
0753 }
0754 }
0755 return false;
0756 }
0757
0758 /**
0759 * Filter intersections based on clipping planes
0760 */
0761 private filterClippedIntersections(intersections: THREE.Intersection[]): THREE.Intersection[] {
0762 if (!this.renderer.localClippingEnabled || this.clipPlanes.length === 0) {
0763 return intersections;
0764 }
0765
0766 return intersections.filter(intersection => !this.isPointClipped(intersection.point));
0767 }
0768
0769
0770 /**
0771 * Sets up the raycasting functionality with proper clipping support
0772 */
0773 private setupRaycasting(): void {
0774 const buildBVHIfNeeded = (obj: THREE.Object3D) => {
0775 if (obj instanceof THREE.Mesh && obj.geometry && !obj.geometry.boundsTree) {
0776 // @ts-ignore
0777 obj.geometry.computeBoundsTree?.();
0778 }
0779 };
0780
0781 // Throttled hover handling to improve performance
0782 const onPointerMove = (event: PointerEvent) => {
0783 if (!this.isRaycastEnabled || this.measureMode) {
0784 this.clearHighlight();
0785 return;
0786 }
0787
0788 // Throttle hover events
0789 if (this.hoverTimeout) return;
0790
0791 this.hoverTimeout = window.setTimeout(() => {
0792 this.hoverTimeout = null;
0793
0794 // Use proper canvas dimensions for coordinate calculation
0795 const canvas = this.renderer.domElement;
0796 const rect = canvas.getBoundingClientRect();
0797
0798 this.pointer.x = ((event.clientX - rect.left) / canvas.clientWidth) * 2 - 1;
0799 this.pointer.y = -((event.clientY - rect.top) / canvas.clientHeight) * 2 + 1;
0800
0801 this.raycaster.setFromCamera(this.pointer, this.camera);
0802 this.raycaster.firstHitOnly = false;
0803 this.raycaster.near = this.camera.near;
0804 this.raycaster.far = this.camera.far;
0805
0806 this.sceneEvent.traverse(buildBVHIfNeeded);
0807 this.sceneGeometry.traverse(buildBVHIfNeeded);
0808
0809 let intersection: THREE.Intersection | null = null;
0810
0811 // Try sceneEvent first
0812 const hitsEvt = this.raycaster.intersectObjects(this.sceneEvent.children, true);
0813 const filteredEvtHits = this.filterClippedIntersections(hitsEvt);
0814
0815 if (filteredEvtHits.length > 0) {
0816 intersection = filteredEvtHits[0];
0817 } else {
0818 // Fall back to geometry
0819 const hitsGeo = this.raycaster.intersectObjects(this.sceneGeometry.children, true);
0820 const filteredGeoHits = this.filterClippedIntersections(hitsGeo);
0821
0822 if (filteredGeoHits.length > 0) {
0823 intersection = filteredGeoHits[0];
0824 }
0825 }
0826
0827 if (intersection && intersection.object.name &&
0828 !intersection.object.name.includes('Helper') &&
0829 !intersection.object.name.startsWith('MeasurePoint_') &&
0830 intersection.object.visible) {
0831
0832 this.highlightObject(intersection.object);
0833 this.trackHovered.next({ track: intersection.object, point: intersection.point.clone() });
0834 console.log('[raycast] HOVER', intersection.object.name, intersection.point);
0835
0836 this.ngZone.run(() => {
0837 this.pointHovered.next(intersection.point.clone());
0838 });
0839 } else {
0840 this.clearHighlight();
0841 }
0842 }, 16); // ~60fps throttling
0843 };
0844
0845 // Single click for selection only (no measurement, no preventDefault in selection mode)
0846 const onPointerDown = (event: PointerEvent) => {
0847 if (!this.isRaycastEnabled || this.measureMode) return;
0848
0849 // Only handle left mouse button
0850 if (event.button !== 0) return;
0851
0852 // Don't prevent default for selection to allow OrbitControls
0853 // event.preventDefault(); // REMOVED
0854
0855 const canvas = this.renderer.domElement;
0856 const rect = canvas.getBoundingClientRect();
0857
0858 this.pointer.x = ((event.clientX - rect.left) / canvas.clientWidth) * 2 - 1;
0859 this.pointer.y = -((event.clientY - rect.top) / canvas.clientHeight) * 2 + 1;
0860
0861 this.raycaster.setFromCamera(this.pointer, this.camera);
0862 this.raycaster.firstHitOnly = false;
0863
0864 // Selection mode
0865 const hitsEvt = this.raycaster.intersectObjects(this.sceneEvent.children, true);
0866 const hitsGeo = this.raycaster.intersectObjects(this.sceneGeometry.children, true);
0867
0868 const filteredEvtHits = this.filterClippedIntersections(hitsEvt);
0869 const filteredGeoHits = this.filterClippedIntersections(hitsGeo);
0870
0871 const selected = filteredEvtHits[0] ?? filteredGeoHits[0];
0872
0873 if (selected && selected.object.visible && selected.object.name &&
0874 selected.object.name !== 'HoverPoint' &&
0875 !selected.object.name.startsWith('MeasurePoint_')) {
0876 this.trackClicked.next({ track: selected.object, point: selected.point.clone() });
0877 console.log('[raycast] SELECTED', selected.object.name, selected.point);
0878 }
0879 };
0880
0881 // Double-click handler for distance measurement
0882 const onDoubleClick = (event: MouseEvent) => {
0883 if (!this.isRaycastEnabled || !this.measureMode) return;
0884
0885 event.preventDefault();
0886 event.stopPropagation();
0887
0888 const canvas = this.renderer.domElement;
0889 const rect = canvas.getBoundingClientRect();
0890
0891 this.pointer.x = ((event.clientX - rect.left) / canvas.clientWidth) * 2 - 1;
0892 this.pointer.y = -((event.clientY - rect.top) / canvas.clientHeight) * 2 + 1;
0893
0894 this.raycaster.setFromCamera(this.pointer, this.camera);
0895 this.raycaster.firstHitOnly = false;
0896
0897 // Measure mode - double click
0898 const hitsEvt = this.raycaster.intersectObjects(this.sceneEvent.children, true);
0899 const hitsGeo = this.raycaster.intersectObjects(this.sceneGeometry.children, true);
0900
0901 const filteredEvtHits = this.filterClippedIntersections(hitsEvt);
0902 const filteredGeoHits = this.filterClippedIntersections(hitsGeo);
0903
0904 const picked = filteredEvtHits[0] ?? filteredGeoHits[0];
0905
0906 if (picked && picked.object.visible &&
0907 picked.object.name !== 'HoverPoint' &&
0908 !picked.object.name.startsWith('MeasurePoint_')) {
0909 const pt = picked.point.clone();
0910
0911 if (!this.firstMeasurePoint) {
0912 this.firstMeasurePoint = pt;
0913 this.showMeasurePoint(pt, 'first');
0914 console.log('[raycast] DIST: first point from', picked.object.name, pt);
0915 } else {
0916 const p1 = this.firstMeasurePoint.clone();
0917 const p2 = pt;
0918 const dist = p1.distanceTo(p2);
0919
0920 this.showMeasurePoint(pt, 'second');
0921
0922 this.ngZone.run(() => {
0923 this.distanceReady.next({ p1, p2, dist });
0924 });
0925
0926 console.log('[raycast] DIST: second point from', picked.object.name, '→', dist.toFixed(2));
0927
0928 // Reset after measurement
0929 setTimeout(() => this.resetMeasurement(), 2000); // Clear after 2 seconds
0930 }
0931 }
0932 };
0933
0934 // Clean up existing listeners before adding new ones
0935 this.cleanupEventListeners();
0936
0937 // Store references and attach listeners
0938 this.pointerMoveHandler = onPointerMove;
0939 this.pointerDownHandler = onPointerDown;
0940 this.doubleClickHandler = onDoubleClick;
0941
0942 const canvas = this.renderer.domElement;
0943 canvas.addEventListener('pointermove', this.pointerMoveHandler, false);
0944 canvas.addEventListener('pointerdown', this.pointerDownHandler, false);
0945 canvas.addEventListener('dblclick', this.doubleClickHandler, false);
0946 }
0947
0948 // Visual feedback for measurement points
0949 private showMeasurePoint(point: THREE.Vector3, type: 'first' | 'second'): void {
0950 const geometry = new THREE.SphereGeometry(8, 16, 16);
0951 const material = new THREE.MeshBasicMaterial({
0952 color: type === 'first' ? 0x00ff00 : 0x0000ff, // Green for first, blue for second
0953 transparent: true,
0954 opacity: 0.9,
0955 depthTest: false,
0956 depthWrite: false
0957 });
0958
0959 const sphere = new THREE.Mesh(geometry, material);
0960 sphere.position.copy(point);
0961 sphere.name = `MeasurePoint_${type}`;
0962 sphere.renderOrder = 1000;
0963
0964 // Add to helpers scene
0965 this.sceneHelpers.add(sphere);
0966 this.measurementPoints.push(sphere);
0967 }
0968
0969 // Reset measurement state and clear visual indicators
0970 private resetMeasurement(): void {
0971 this.firstMeasurePoint = null;
0972 this.clearMeasurePoints();
0973 }
0974
0975 private clearMeasurePoints(): void {
0976 this.measurementPoints.forEach(point => {
0977 this.sceneHelpers.remove(point);
0978 point.geometry.dispose();
0979 if (point.material instanceof THREE.Material) {
0980 point.material.dispose();
0981 }
0982 });
0983 this.measurementPoints = [];
0984 }
0985
0986 /**
0987 * Clean up event listeners
0988 */
0989 private cleanupEventListeners(): void {
0990
0991 if(!this.renderer?.domElement) {
0992 return;
0993 }
0994 const canvas = this.renderer.domElement;
0995
0996 if (this.pointerMoveHandler) {
0997 canvas.removeEventListener('pointermove', this.pointerMoveHandler);
0998 this.pointerMoveHandler = undefined;
0999 }
1000 if (this.pointerDownHandler) {
1001 canvas.removeEventListener('pointerdown', this.pointerDownHandler);
1002 this.pointerDownHandler = undefined;
1003 }
1004 if (this.doubleClickHandler) {
1005 canvas.removeEventListener('dblclick', this.doubleClickHandler);
1006 this.doubleClickHandler = undefined;
1007 }
1008
1009 // Clear timeout if active
1010 if (this.hoverTimeout) {
1011 clearTimeout(this.hoverTimeout);
1012 this.hoverTimeout = null;
1013 }
1014 }
1015
1016 setupBVH(): void {
1017 const processMesh = (mesh: THREE.Mesh) => {
1018 if (mesh.geometry && !mesh.geometry.boundsTree) {
1019 // @ts-ignore
1020 mesh.geometry.computeBoundsTree({
1021 maxLeafTris: 10,
1022 strategy: 0
1023 });
1024 }
1025 };
1026
1027 this.sceneGeometry.traverse((object) => {
1028 if ((object as any).isMesh) {
1029 processMesh(object as Mesh);
1030 }
1031 });
1032
1033 this.sceneEvent.traverse((object) => {
1034 if ((object as any).isMesh) {
1035 processMesh(object as Mesh);
1036 }
1037 });
1038 }
1039
1040 cleanupBVH(object: THREE.Object3D): void {
1041
1042 if (object instanceof THREE.Mesh && object.geometry && object.geometry.boundsTree) {
1043 // @ts-ignore
1044 object.geometry.disposeBoundsTree();
1045 }
1046 if(object.children != null) {
1047 object.children.forEach(child => this.cleanupBVH(child));
1048 }
1049 }
1050
1051 // Enhanced toggle methods
1052 toggleRaycast(): void {
1053 this.isRaycastEnabled = !this.isRaycastEnabled;
1054 console.log(`Raycast is now ${this.isRaycastEnabled ? 'ENABLED' : 'DISABLED'}`);
1055
1056 if (!this.isRaycastEnabled) {
1057 this.clearHighlight();
1058 // Reset measurement when disabling raycast
1059 this.resetMeasurement();
1060 }
1061 }
1062
1063 isRaycastEnabledState(): boolean {
1064 return this.isRaycastEnabled;
1065 }
1066
1067 }