import { TilesRenderer } from "3d-tiles-renderer"; import { find, get, includes, merge } from "lodash"; import * as THREE from "three"; import { VertexNormalsHelper } from "three/examples/jsm/helpers/VertexNormalsHelper.js"; import Stats from "three/examples/jsm/libs/stats.module.js"; import { Font, FontLoader } from "three/examples/jsm/loaders/FontLoader.js"; import { BloomPass } from "three/examples/jsm/postprocessing/BloomPass.js"; import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js"; import { OutlinePass } from "three/examples/jsm/postprocessing/OutlinePass.js"; import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js"; import { SAOPass } from "three/examples/jsm/postprocessing/SAOPass.js"; import { SSAARenderPass } from "three/examples/jsm/postprocessing/SSAARenderPass.js"; import { SSAOPass } from "three/examples/jsm/postprocessing/SSAOPass.js"; import { ShaderPass } from "three/examples/jsm/postprocessing/ShaderPass.js"; import { UnrealBloomPass } from "three/examples/jsm/postprocessing/UnrealBloomPass.js"; import { CSS2DRenderer } from "three/examples/jsm/renderers/CSS2DRenderer.js"; import { FXAAShader } from "three/examples/jsm/shaders/FXAAShader.js"; import { ShxFontLoader } from "../shx-parser"; import { ViewerEvent } from "./ViewerEvent"; import { BottomBar } from "src/components/bottom-bar"; import { ContextMenu, contextMenuItems } from "src/components/context-menu"; import { BimViewerDatGui } from "src/components/dat-gui"; import { CameraSettings, Settings as SettingsType, defaultSettings } from "src/components/settings"; import { DEFAULT_BIMVIEWER_TOOLBAR_CONFIG, Toolbar } from "src/components/toolbar"; import { BimViewerConfig, CameraConfig, DEFAULT_BIM_VIEWER_CONFIG, Hotpoint, ModelConfig } from "src/core/Configs"; import { matrixAutoUpdate, SectionType, layerForSelectableObjects, layerForHitableObjects } from "src/core/Constants"; import { CoordinateAxes, CoordinateAxesViewport } from "src/core/axes"; import { CanvasRender, Drawable } from "src/core/canvas"; import { CameraControlsEx } from "src/core/controls"; import { InstantiateHelper, LoadingHelper, MeshBvhHelper, RafHelper, ZoomToRectHelper } from "src/core/helpers"; import { EventInfo, InputManager, MouseButton } from "src/core/input/InputManager"; import { MeasurementData, MeasurementManager, MeasurementType } from "src/core/measure"; import { NavCubeViewport } from "src/core/navcube/NavCubeViewport"; import { AxisPlaneSection, ObjectsBoxSection, PickPlaneSection, SectionManager } from "src/core/section"; import { CoordinateConversionUtils, SceneUtils, CommonUtils, CSS2DObjectUtils, DeviceUtils, GroundUtils, log, MaterialUtils, Batch, MergeUtils, MaterialInfo, ObjectUtils, SkyboxUtils, TextureUtils, Viewer3DUtils, } from "src/core/utils"; import { BaseViewer, ViewerName } from "src/core/viewers/BaseViewer"; import { WebCam } from "src/core/webcam"; import { ViewCube } from "src/plugins/view-cube/ViewCube"; const tempVec3 = /*@__PURE__*/ new THREE.Vector3(); const tempSphere = /*@__PURE__*/ new THREE.Sphere(); export class BimViewer extends BaseViewer { /** * @internal */ name = ViewerName.BimViewer; private timer = Date.now(); // used to log time for debugging, create it at the very begining protected font?: Font; /** * @internal */ ambientLight?: THREE.AmbientLight; /** * @internal */ directionalLight?: THREE.DirectionalLight; /** * @internal */ hemisphereLight?: THREE.HemisphereLight; /** * @internal */ selectedObject: any | Drawable | undefined = undefined; // eslint-disable-line /** * @internal */ groundGrid?: THREE.Line; /** * @internal */ grassGround?: THREE.Mesh; /** * @internal */ sceneBackgroundColor: THREE.Color = new THREE.Color(0xebf2f7); // TODO: add it to settings /** * @internal */ skyOfGradientRamp?: THREE.Mesh; private stats?: Stats; /** * @internal */ loadedModels: { [src: string]: { id: number; bbox?: THREE.Box3 } } = {}; // a map to store model file and id /** * @internal */ loaded3dTiles: { [src: string]: { id: number; bbox: THREE.Box3; renderer: TilesRenderer } } = {}; // a map to store 3dtiles and id /** * @internal */ pmremGenerator?: THREE.PMREMGenerator; private perspectiveCamera?: THREE.PerspectiveCamera; private orthoCamera?: THREE.OrthographicCamera; private perspectiveCameraControls?: CameraControlsEx; private orthoCameraConrols?: CameraControlsEx; private css2dRenderer?: CSS2DRenderer; // used to render html labels in the scene private composerRenderEnabled = true; // if we should call composer.render() in render() private composerEnabled = false; // if composer and passes are enabled private composer?: EffectComposer; private renderPass?: RenderPass; private effectFxaaPass?: ShaderPass; private ssaoPass?: SSAOPass | ShaderPass; private saoPass?: SAOPass; private outlinePass?: OutlinePass; private ssaaRenderPass?: SSAARenderPass; private bloomPass?: BloomPass; private unrealBloomPass?: UnrealBloomPass; private raycaster?: THREE.Raycaster; private cameraUpdateInterval?: NodeJS.Timer; private savedMaterialsForOpacity?: MaterialInfo[] = []; private section?: ObjectsBoxSection | PickPlaneSection | AxisPlaneSection; /** * @internal */ public sectionType?: string; private sectionManager?: SectionManager; private measurementManager?: MeasurementManager; private zoomToRectHelper?: ZoomToRectHelper; // private datGui?: BimViewerDatGui; // react native not support css inline // eslint-disable-next-line @typescript-eslint/no-explicit-any private datGui?: any; private shadowCameraHelper?: THREE.CameraHelper; private directionalLightHelper?: THREE.DirectionalLightHelper; private webcam?: WebCam; private webcamPlane?: THREE.Mesh; // RafHelper (requestAnimationFrame Helper) is used to improve render performance, // With this feature, it only renders when necessary, e.g. camera position changed, model loaded, etc. // We can disable this feature by assigning raf to undefined private raf?: RafHelper = new RafHelper(); private clock: THREE.Clock = new THREE.Clock(); private renderEnabled = true; // used together with RafHelper private timeoutSymbol?: symbol; // used together with RafHelper private isFrustumInsectChecking = false; private lastFrameExecuteTime = Date.now(); // used to limit max fps private maxFps = 60; // used to limit max fps. < 0 means no limitation private settings: SettingsType = defaultSettings; private contextMenu?: ContextMenu; private navCube?: NavCubeViewport; private viewCube?: ViewCube; private axes?: CoordinateAxesViewport; private axesInScene?: CoordinateAxes; private twoDModelCount = 0; // number of 2d models private vertexNormalsHelpers?: THREE.Group; /** * @internal */ toolbar?: Toolbar; private bottomBar?: BottomBar; /** * @internal */ private bbox = new THREE.Box3(); private anchor?: HTMLDivElement; private hotpointRoot?: THREE.Group; // eslint-disable-next-line constructor(viewerCfg: BimViewerConfig, cameraCfg?: CameraConfig) { super(viewerCfg); this.viewerCfg = { ...DEFAULT_BIM_VIEWER_CONFIG, ...viewerCfg } as BimViewerConfig; log.info("[BimViewer]", "viewerCfg:", this.viewerCfg); this.settings = defaultSettings; this.cameraCfg = cameraCfg; if (this.cameraCfg && this.cameraCfg.near) { this.settings.camera.near = this.cameraCfg.near; } if (this.cameraCfg && this.cameraCfg.far) { this.settings.camera.far = this.cameraCfg.far; } this.increaseJobCount(); // it is busy since initializing this.init(); this.animate(); if (this.renderer) { this.viewerContainer?.append(this.renderer.domElement); } this.decreaseJobCount(); // initialization done log.info(`[BimViewer] Initialized in ${(Date.now() - this.timer) / 1000}s`); } /** * Initialize everything it needs * @internal */ init() { const viewerCfg = this.viewerCfg as BimViewerConfig; this.initThree(); if (DeviceUtils.isBrowser && !viewerCfg.context) { this.initDom(); } this.initEvents(); this.initControls(); } private initThree() { this.initScene(); this.initRenderer(); this.initCamera(); this.initLights(); } private initDom() { const viewerCfg = this.viewerCfg as BimViewerConfig; this.initSpinner(); this.initCSS2DRenderer(); if (viewerCfg.enableDatGui === true) { this.initDatGui(); // should be initialized later than sky, ground grid, etc. } this.initOthers(); if (viewerCfg.enableAxisGizmo === true) { this.axes = this.initAxes(); } if (viewerCfg.enableStats === true) { this.stats = this.initStats(); } if (viewerCfg.enableToolbar) { this.toolbar = this.initToolbar(); } if (viewerCfg.enableBottomBar) { this.bottomBar = this.initBottomBar(); } if (viewerCfg.enableNavCube) { this.navCube = this.initNavCube(); } if (viewerCfg.enableViewCube) { this.viewCube = this.initViewCube(); } if (viewerCfg.enableContextMenu) { this.contextMenu = this.initContextMenu(); } } private initScene() { this.scene = new THREE.Scene(); // this.scene.background = new THREE.Color(0xebf2f7) // Find more performance tips at: https://discoverthreejs.com/tips-and-tricks/ // For performance. call .updateMatrix() manually when needed. this.scene.matrixAutoUpdate = matrixAutoUpdate; // When testing the performance of your apps, one of the first things you’ll need to do is check whether it is CPU bound, or GPU bound. // If performance increases, then your app is GPU bound. If performance doesn’t increase, your app is CPU bound. // this.scene.overrideMaterial = new MeshBasicMaterial({ color: 'green' }) } private initRenderer() { this.renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true, }); this.renderer.setPixelRatio(window.devicePixelRatio); this.renderer.setSize(this.width, this.height); // this.renderer.outputEncoding = THREE.sRGBEncoding; this.renderer.outputColorSpace = THREE.SRGBColorSpace; this.renderer.toneMappingExposure = 1; // this.renderer.physicallyCorrectLights = true; this.renderer.useLegacyLights = false; this.renderer.setClearColor(0xa9a9a9, 1); this.renderer.shadowMap.enabled = true; this.renderer.shadowMap.type = THREE.PCFSoftShadowMap; CommonUtils.printGpuInfo(this.renderer.getContext()); this.pmremGenerator = new THREE.PMREMGenerator(this.renderer); this.pmremGenerator.compileEquirectangularShader(); if (this.enableOverlayRenderer) { this.overlayRender = new CanvasRender(this); } this.setEnvironmentFromDataArray(); } private initCSS2DRenderer() { const r = new CSS2DRenderer(); r.setSize(this.width, this.height); r.domElement.style.height = "0"; r.domElement.style.width = "0"; r.domElement.style.position = "absolute"; r.domElement.style.top = "0"; r.domElement.style.left = "0"; r.domElement.style.overflow = "visible"; r.domElement.classList.add("css2d-renderer"); // add a class so it is easier to be found this.viewerContainer?.appendChild(r.domElement); this.css2dRenderer = r; } private initCamera() { if (!this.scene) { return; // have to init scene first } // to avoid z-fighting issue, do not set near value too small! // https://www.cnblogs.com/lst619247/p/9098845.html this.perspectiveCamera = new THREE.PerspectiveCamera( 45, this.width / this.height, this.settings.camera.near, this.settings.camera.far ); this.perspectiveCamera.position.set(0, 100, 0); this.scene.add(this.perspectiveCamera); // need to init scene before camera this.camera = this.perspectiveCamera; } private initControls(isOrthCamera = false) { if (!this.renderer) { return; } // TODO: support touch device later, see https://github.com/tdushinwa/threejs_touchtest/blob/master/js/TrackballControls.js DeviceUtils.printDeviceInfo(); const camera = isOrthCamera ? this.orthoCamera : this.perspectiveCamera; if (!camera || !this.inputManager) { return; } let controls: CameraControlsEx | undefined; if (!this.perspectiveCameraControls || !this.orthoCameraConrols) { // controls = new CameraControlsEx(camera, this.renderer.domElement); controls = new CameraControlsEx(camera, this.inputManager as unknown as HTMLElement); controls.dollySpeed = 0.1; controls.dollyToCursor = true; controls.enabled = true; controls.keyTruckSpeed = 10; controls.restThreshold = 0; // controls.keys = { LEFT: '65', UP: '87', RIGHT: '68', BOTTOM: '83' } // a, w, d, s controls.keys = { left: "KeyD", // model goes left, camera goes right up: "KeyE", // model goes up, camera goes down right: "KeyA", // model goes right, camera goes left bottom: "KeyQ", // model goes down, camera goes up }; controls.listenToKeyEvents(); controls.update(0); } if (!controls) { return; } if (isOrthCamera) { this.orthoCameraConrols = controls; controls.minZoom = 3; // controls.maxZoom = 5; } else { this.perspectiveCameraControls = controls; if (this.bbox) { controls.minDistance = 0.1; controls.maxDistance = this.bbox.getSize(new THREE.Vector3()).length() * 3; } } this.controls = controls; } private initRotateToCursor() { this.anchor = this.createAnchor(); } private onResize = () => { if (this.viewerContainer && this.viewerContainer.parentElement) { this.resize(this.viewerContainer.parentElement.clientWidth, this.viewerContainer.parentElement.clientHeight); } }; private onKeyDown = (e: EventInfo) => { const camera = this.camera as THREE.PerspectiveCamera | THREE.OrthographicCamera; const controls = this.controls as CameraControlsEx; if (!camera || !controls) { return; } const sensitivity = this.settings.keyboard.sensitivity || 3; const p = controls.getPosition(new THREE.Vector3()); // camera's position const t = controls.getTarget(new THREE.Vector3()); // target point const newTarget = t.clone(); if (e.code === "ArrowLeft" || e.code === "ArrowRight") { // keep camera's position uchanged, rotate to left (q) or right (e) around y-axis // thus it changes target(lookAt) point const ANGLE = sensitivity; // angle in degree let theta = (Math.PI * ANGLE) / 180; // angle in radians if (e.code === "ArrowLeft") { theta = -theta; // rotate to left } newTarget.x = (t.x - p.x) * Math.cos(theta) - (t.z - p.z) * Math.sin(theta) + p.x; newTarget.z = (t.z - p.z) * Math.cos(theta) + (t.x - p.x) * Math.sin(theta) + p.z; controls.setTarget(newTarget.x, newTarget.y, newTarget.z); } else if (e.code === "ArrowUp" || e.code === "ArrowDown") { // keep camera's position uchanged, rotate to left (q) or right (e) around y-axis // thus it changes target(lookAt) point's y value const ANGLE2 = sensitivity; // angle in degree let theta2 = (Math.PI * ANGLE2) / 180; // angle in radians const distVec = new THREE.Vector3(t.x - p.x, t.y - p.y, t.z - p.z); // distance vector from p to t const dist = distVec.length(); const deltaY = t.y - p.y; if (e.code === "ArrowDown") { // z: rotate down theta2 = -theta2; } const angle = Math.asin(deltaY / dist) + theta2; if (angle < -Math.PI / 2 || angle > Math.PI / 2) { return; // cannot rotate that much } const newDeltaY = Math.sin(angle) * dist; newTarget.y = t.y + (newDeltaY - deltaY); controls.setTarget(newTarget.x, newTarget.y, newTarget.z); } else if (e.code === "KeyW") { // go forward const ALPHA = sensitivity * 0.01; const dist = p.distanceTo(t); if (dist < camera.near * 10) { // If distance is too close, better to move target position forward too, so camera can keep moving forward, // rather than stop at the lookAt position. Let's move it to be 'camera.near' away from camera. const tempTarget = controls.getTarget(new THREE.Vector3()).lerp(p, -camera.near / dist); controls.setTarget(tempTarget.x, tempTarget.y, tempTarget.z); } p.lerp(t, ALPHA); controls.setLookAt(p.x, p.y, p.z, t.x, t.y, t.z); } else if (e.code === "KeyS") { // go backward const ALPHA = sensitivity * 0.01; p.lerp(t, -ALPHA); controls.setLookAt(p.x, p.y, p.z, t.x, t.y, t.z, false); } else if (e.code === "KeyF") { // if there is object selected, fly to it. (This is a design by Unreal Engine) this.flyToSelectedObject(); } else if (e.code === "KeyT") { // reset target // When a user does rotate(or pan) operation, where is the rotate center(camera's target)? // It depends on where is the camera's target as well as where is the camera itself. // Assume camera is at p1, user clicked at p2, target is p3, distance between p1 and p2 is d1, distance between p1 and p3 is d2. // In order to have a better user experience, we'll move target to p4 without changing camera's direction, // where distance between p1 and p4 equals d1. this.raycaster && this.raycaster.layers.set(layerForHitableObjects); const intersections = this.getIntersections(); if (intersections.length > 0) { const firstIntersect = intersections.find((intersect) => { const object = intersect.object; // exclude invisible objects // exclude non-mesh objects, ground, outline, etc. return object.visible && object instanceof THREE.Mesh; }); if (firstIntersect && firstIntersect.point && this.camera && this.controls) { const p1 = this.camera.position; const p2 = firstIntersect.point; const p3 = controls.getTarget(new THREE.Vector3()); const d1 = p1.distanceTo(p2); if (d1 > camera.near && d1 < camera.far) { // only take it as valid scenario when d1 is between near and far const d2 = p1.distanceTo(p3); const p4 = p1.clone().lerp(p3, d1 / d2); controls.setTarget(p4.x, p4.y, p4.z); } } } } else if (e.code === "KeyY") { // Make camera direction vertical with y-axis. // It is useful when a user want to roaming horizontally with key(w/s/a/d) controls this.flyTo(p, t.clone().setY(p.y)); } this.enableRender(); }; private initLights() { if (!this.scene) { return; } // TODO: move light settings into project settings panel // Maybe we can automatically calculate light direction, intensity, etc. according to models' materials const color = 0xffffff; const highIntensity = 1.5; const dl = new THREE.DirectionalLight(color, highIntensity); dl.name = "sun"; dl.castShadow = true; dl.position.set(-2, 2, 4); dl.shadow.autoUpdate = false; dl.shadow.mapSize.width = 1024; dl.shadow.mapSize.height = 1024; this.directionalLight = dl; this.scene.add(dl); this.scene.add(dl.target); // The object is only displayed, it is not necessary to call enableAll this.directionalLightHelper = new THREE.DirectionalLightHelper(this.directionalLight); this.directionalLightHelper.visible = false; this.scene.add(this.directionalLightHelper); this.shadowCameraHelper = new THREE.CameraHelper(this.directionalLight.shadow.camera); this.shadowCameraHelper.visible = false; this.scene.add(this.shadowCameraHelper); this.ambientLight = new THREE.AmbientLight(0x303030); this.hemisphereLight = new THREE.HemisphereLight(color, 0xdddddd, 0.2); this.hemisphereLight.position.set(0, 300, 0); this.scene.add(this.ambientLight); this.scene.add(this.hemisphereLight); } /** * Initialize mouse/pointer events */ private initEvents() { if (!this.renderer) { return; } const input = new InputManager(this.renderer.domElement); this.inputManager = input; let mousedowned = false, mousemoved = false; input.addEventListener("pointerdown", (e) => { mousemoved = false; mousedowned = true; e.button === MouseButton.Left && this.onAnchorPointerDown(e); }); input.addEventListener("pointermove", () => { if (mousedowned) { mousemoved = true; } }); input.addEventListener("pointerup", () => { mousedowned = false; this.onAnchorPointerUp(); }); let timeId: NodeJS.Timeout; input.addEventListener("click", (e) => { if (mousemoved) { mousemoved = false; return; } timeId && clearTimeout(timeId); // do not run immediately, because it can be a double click timeId = setTimeout(() => { switch (e.button) { case MouseButton.Left: this.handleMouseClick(e); break; default: break; } }, 300); if (!this.selectedObject || (this.selectedObject && this.selectedObject instanceof THREE.Object3D)) { this.contextMenu?.hide(); } }); input.addEventListener("dblclick", (e) => { if (!mousemoved && this.sectionManager?.isSectionActive() && this.measurementManager?.isMeasurementActive()) { return; } timeId && clearTimeout(timeId); this.handleMouseClick(e); this.flyToSelectedObject(); this.enableRender(); }); input.addEventListener("keydown", (e) => { this.onKeyDown(e); // if (e.altKey && e.code === "KeyR") { // // alt + R to fly to random object // this.flyToRandomObject(); // } }); input.addEventListener("contextmenu", (e) => { if (mousemoved || this.sectionManager?.isSectionActive() || this.measurementManager?.isMeasurementActive()) { return; } this.handleRightClick(e); }); // auto resize now // input.addEventListener("resize", this.onResize); this.initRotateToCursor(); this.raycaster = new THREE.Raycaster(); } private initDatGui() { this.datGui = new BimViewerDatGui(this); this.datGui.close(); // collapse it by default } private initOthers() { if (!this.scene || !this.renderer || !this.camera) { return; } // Read some settings from Dat.Gui. While in future, // these default settings should be defined by project settings. const ctrl = this.datGui && this.datGui.controls; if (ctrl) { // some settings are read from datGui, so it requires to initialize datGui first if (ctrl.showGroundGrid) { this.groundGrid = GroundUtils.createGroundGrid(); this.scene.add(this.groundGrid); } if (ctrl.showGrassGround) { (async () => { this.grassGround = await GroundUtils.createGrassGround(); this.scene && this.scene.add(this.grassGround); this.enableRender(); })(); } ctrl.webcam && this.enableWebCam(); // the order of passes matters, outline pass should be the last one this.composerEnabled = ctrl.composerEnabled; if (this.composerEnabled) { this.enableComposer(true); this.enableRenderPass(ctrl.renderPassEnabled); this.enableFxaaPass(ctrl.fxaaEnabled); this.enableSaoPass(ctrl.saoEnabled); this.enableSsaoPass(ctrl.ssaoEnabled); this.enableOutlinePass(ctrl.outlineEnabled); this.enableSsaaPass(ctrl.ssaaEnabled); this.enableBloomPass(ctrl.bloomEnabled); this.enableUnrealBloomPass(ctrl.unrealBloomEnabled); } } // erase the black outline when viewer is focused this.renderer.domElement.style.outlineWidth = "0"; } private initNavCube() { const navCubeElement = document.createElement("div"); navCubeElement.id = "navCube"; const navCube = new NavCubeViewport(); if (navCube.renderer) { navCubeElement.appendChild(navCube.renderer.domElement); navCube.setHostViewer(this); } this.widgetContainer?.appendChild(navCubeElement); return navCube; } private initViewCube() { const viewCubeElement = document.createElement("div"); viewCubeElement.id = "viewCube"; this.widgetContainer?.appendChild(viewCubeElement); const viewCube = new ViewCube({ containerId: viewCubeElement.id, }); viewCube.setHostViewer(this); return viewCube; } private initAxes() { const axesDiv = document.createElement("div"); axesDiv.classList.add("axesRenderer"); const cav = new CoordinateAxesViewport(axesDiv, this.camera as THREE.Camera); this.widgetContainer?.append(axesDiv); // Some users want a axes in scene, so they know how a model is defined. // We don't want to add one more option for BimViewer, so it will be controlled together by "enableAxisGizmo" this.axesInScene = new CoordinateAxes(false); this.scene?.add(this.axesInScene); return cav; } private initStats() { const stats = new Stats() as any; // eslint-disable-line stats.setMode(0); // 0: fps, 1: ms const div = document.createElement("div"); div.classList.add("statsOutput"); div.appendChild(stats.domElement); this.widgetContainer?.append(div); return stats; } private initContextMenu() { const contextMenu = new ContextMenu({ items: contextMenuItems, context: { bimViewer: this, toolbar: this.toolbar, section: this.section }, container: this.widgetContainer, }); window.oncontextmenu = (event: MouseEvent) => event.preventDefault(); return contextMenu; } private initToolbar() { const viewerCfg = this.viewerCfg as BimViewerConfig; return new Toolbar(this, merge({}, DEFAULT_BIMVIEWER_TOOLBAR_CONFIG, viewerCfg.toolbarMenuConfig)); } private initBottomBar() { return new BottomBar(this); } /** * If there is any 2d model loaded * @internal */ get has2dModel() { return this.twoDModelCount > 0; } private showContextMenu(event: EventInfo) { if (!this.contextMenu || !this.widgetContainer) { return; } this.raycaster && this.raycaster.layers.set(layerForHitableObjects); const intersections = this.getAllIntersections(event); log.debug("[BimViewer] showContextMenu intersections = ", intersections); const firstIntersect = find(intersections, (intersect) => { const object = intersect.object; // exclude invisible objects // exclude non-mesh objects, ground, outline, etc. return object instanceof THREE.Mesh && object.visible; }); const context = this.contextMenu.context; const instanceId = (firstIntersect as THREE.Intersection)?.instanceId; const faceIndex = firstIntersect?.faceIndex; let batchId = undefined; if (faceIndex) { batchId = MergeUtils.getBatchIdByFaceIndex(firstIntersect?.object as THREE.Mesh, faceIndex); } this.contextMenu.context = { ...context, hit: firstIntersect?.object, instanceId, batchId }; const { x, y } = event; this.contextMenu.show(x, y); } private handleRightClick(event: EventInfo) { this.showContextMenu(event); } private sycnCameraPosition( src: THREE.PerspectiveCamera | THREE.OrthographicCamera, dest: THREE.PerspectiveCamera | THREE.OrthographicCamera ) { const p = src.position; dest.position.set(p.x, p.y, p.z); if (this.scene) { const t = this.scene.position; // src.target dest.lookAt(t); } } private sycnControls(src: CameraControlsEx, dest: CameraControlsEx) { const t = src.getTarget(new THREE.Vector3()); const p = src.getPosition(new THREE.Vector3()); dest.setPosition(p.x, p.y, p.z); dest.setTarget(t.x, t.y, t.z); } setToOrthographicCamera(isOrthCamera = false) { if (!this.scene || !this.controls) { return; } const pc = this.perspectiveCamera; const pcc = this.perspectiveCameraControls; let oc = this.orthoCamera; let occ = this.orthoCameraConrols; if (isOrthCamera) { if (!oc) { oc = new THREE.OrthographicCamera( -this.width / 2, this.width / 2, this.height / 2, -this.height / 2, this.settings.camera.near, this.settings.camera.far ); oc.position.set(0, 100, 0); oc.zoom = 10; // it seems 10 works better, but don't know how to set a better one! oc.updateProjectionMatrix(); this.scene && this.scene.add(oc); // need to init scene before camera this.orthoCamera = oc; this.frustumSize = this.width; } if (!occ) { this.initControls(true); occ = this.orthoCameraConrols; } if (pc) { this.sycnCameraPosition(pc, oc); oc.zoom = 10; oc.updateProjectionMatrix(); } if (pcc && occ && pc) { this.sycnControls(pcc, occ); } this.camera = oc; this.controls = occ; } else { if (pc && oc) { this.sycnCameraPosition(oc, pc); } if (pcc && occ && oc) { this.sycnControls(occ, pcc); } this.camera = pc; this.controls = pcc; } this.axes?.setHostCamera(this.camera as THREE.Camera); this.resize(this.width, this.height); // trigger camera to update properly this.dispatchEvent(ViewerEvent.CameraChanged); } protected animate() { this.requestAnimationFrameHandle = requestAnimationFrame(this.animate.bind(this)); const delta = this.clock.getDelta(); const updated = this.controls && this.controls.update(delta); if (this.maxFps > 0) { const delta = Date.now() - this.lastFrameExecuteTime; if (delta < 1000 / this.maxFps) { return; } this.lastFrameExecuteTime = Date.now(); } this.webcam && this.webcam.animate(); if (this.scene && this.camera) { // If parent container change ,auto resize if (this.viewerContainer && this.viewerContainer.parentElement) { const { width, height } = this.viewerContainer.parentElement.getBoundingClientRect(); const needResize = this.width !== width || this.height !== height; if (needResize) { this.resize(width, height); } } if (this.renderEnabled || updated) { //this.updateRaycasterThreshold(); this.update3dTiles(); this.renderer?.render(this.scene, this.camera); // Improves rendering framerate if (this.hotpointRoot && this.hotpointRoot.children.length > 0) { this.css2dRenderer?.render(this.scene, this.camera); } this.dispatchEvent(ViewerEvent.AfterRender); } } if (this.composerRenderEnabled && this.composer && this.composerEnabled) { this.composer.render(); this.composerRenderEnabled = false; } this.frustrumCullingByModelBBox(); this.stats?.update(); this.bottomBar?.update(); } private update3dTiles() { if (!this.camera) { return; } const tileSets = Object.values(this.loaded3dTiles); if (tileSets.length === 0) { return; } // The camera matrix is expected to be up to date before calling tilesRenderer.update this.camera.updateMatrixWorld(); tileSets.forEach((obj: { id: number; bbox: THREE.Box3; renderer: TilesRenderer }) => { obj.renderer.update(); }); } /** * This is a method called in animate() in order to optimize rendering speed. * The idea is to hide any model out of view frustrum. */ private frustrumCullingByModelBBox() { const frustum = new THREE.Frustum(); const projScreenMatrix = new THREE.Matrix4(); this.isFrustumInsectChecking = true; if (this.camera) { projScreenMatrix.multiplyMatrices(this.camera.projectionMatrix, this.camera.matrixWorldInverse); frustum.setFromProjectionMatrix(projScreenMatrix); Object.values(this.loadedModels).forEach((obj: { id: number; bbox?: THREE.Box3 }) => { const model = this.scene && this.scene.getObjectById(obj.id); const bbox = obj.bbox; if (model && bbox && this.scene) { // adds userData to model // userData: { // _visible: boolean, // userConfigVisibility: boolean // } // userConfigVisibility is a flag to indicate if model's visibility ever changed by user // from BimTree, LayerManager, etc. If ever changed, then frustrumCullingByModelBBox shouldn't // work for thus model any more. if (typeof model.userData._visible === "undefined") { model.userData._visible = true; Object.defineProperties(model, { visible: { set: (val) => { // eslint-disable-line model.userData._visible = val; if (!this.isFrustumInsectChecking) { model.userData.userConfigVisibility = true; } }, get: () => model.userData._visible, }, }); } if (typeof model.userData.userConfigVisibility === "undefined") { model.visible = frustum.intersectsBox(bbox); } } }); // 3dTiles Object.values(this.loaded3dTiles).forEach((obj: { id: number; bbox: THREE.Box3; renderer: TilesRenderer }) => { const model = obj.renderer.group; if (model && !obj.bbox.isEmpty() && this.scene) { // adds userData to model // userData: { // _visible: boolean, // userConfigVisibility: boolean // } // userConfigVisibility is a flag to indicate if model's visibility ever changed by user // from BimTree, LayerManager, etc. If ever changed, then frustrumCullingByModelBBox shouldn't // work for thus model any more. if (typeof model.userData._visible === "undefined") { model.userData._visible = true; Object.defineProperties(model, { visible: { set: (val) => { // eslint-disable-line model.userData._visible = val; if (!this.isFrustumInsectChecking) { model.userData.userConfigVisibility = true; } }, get: () => model.userData._visible, }, }); } if (typeof model.userData.userConfigVisibility === "undefined") { model.visible = frustum.intersectsBox(obj.bbox); } } }); } this.isFrustumInsectChecking = false; } /** * In order to have a better performance, it should only render when necessary. * Usually, we should enable render for these cases: * - Anything added to, removed from scene, or objects' position, scale, rotation, opacity, material, etc. changed * - Anything selected/unselected * - Camera changed * - Render area resized * @internal */ enableRender = (time = 1000) => { this.renderEnabled = true; if (!this.raf) { return; } this.timeoutSymbol && this.raf.clearTimeout(this.timeoutSymbol); this.timeoutSymbol = this.raf.setTimeout(() => { this.renderEnabled = false; // when main render process is done, enable composer render this.composerRenderEnabled = true; }, time); }; destroy() { this.inputManager?.removeEventListener(); if (this.datGui && this.datGui.gui) { this.datGui.beforeDestroy(); this.datGui = undefined; } const wc = this.webcamPlane; if (this.scene && wc) { this.scene.remove(wc); wc.geometry.dispose(); (wc.material as THREE.Material).dispose(); this.webcamPlane = undefined; } this.webcam = undefined; this.composer = undefined; this.renderPass = undefined; this.effectFxaaPass = undefined; this.saoPass = undefined; this.ssaoPass = undefined; this.outlinePass = undefined; this.ambientLight = undefined; this.directionalLight = undefined; this.hemisphereLight = undefined; if (this.directionalLightHelper) { this.directionalLightHelper.dispose(); this.directionalLightHelper = undefined; } if (this.controls) { this.disposeRotateToCursor(); this.controls.dispose(); this.controls = undefined; } if (this.perspectiveCameraControls) { this.perspectiveCameraControls.dispose(); this.perspectiveCameraControls = undefined; } if (this.shadowCameraHelper) { this.shadowCameraHelper.dispose(); this.shadowCameraHelper = undefined; } if (this.css2dRenderer) { this.viewerContainer?.removeChild(this.css2dRenderer.domElement); this.css2dRenderer = undefined; } this.stats = undefined; this.raycaster = undefined; this.selectedObject = undefined; if (this.groundGrid) { this.groundGrid.geometry.dispose(); (this.groundGrid.material as THREE.Material).dispose(); this.groundGrid.clear(); this.groundGrid = undefined; } if (this.grassGround) { this.grassGround.geometry.dispose(); (this.grassGround.material as THREE.Material).dispose(); this.grassGround.clear(); this.grassGround = undefined; } if (this.skyOfGradientRamp) { this.skyOfGradientRamp.geometry.dispose(); // TODO: don't know why ShaderMaterial still cannot be released from memory (this.skyOfGradientRamp.material as THREE.Material).dispose(); this.skyOfGradientRamp.clear(); this.skyOfGradientRamp = undefined; } this.savedMaterialsForOpacity = undefined; this.section = undefined; this.sectionType = undefined; Object.keys(this.loadedModels).forEach((key) => { delete this.loadedModels[key]; }); Object.values(this.loaded3dTiles).forEach((model: { id: number; bbox: THREE.Box3; renderer: TilesRenderer }) => { model.renderer.dispose(); }); this.loaded3dTiles = {}; this.perspectiveCamera = undefined; this.perspectiveCameraControls = undefined; if (this.raf) { this.timeoutSymbol && this.raf.clearTimeout(this.timeoutSymbol); this.raf = undefined; } if (this.contextMenu) { this.contextMenu.destroy(); this.contextMenu = undefined; } if (this.navCube) { this.navCube.dispose(); this.navCube = undefined; } if (this.viewCube) { this.viewCube.destroy(); this.viewCube = undefined; } if (this.axes) { this.axes.dispose(); this.axes = undefined; } if (this.axesInScene) { this.axesInScene.clear(); this.axesInScene = undefined; } if (this.toolbar) { this.toolbar.destroy(); this.toolbar = undefined; } this.font = undefined; if (this.pmremGenerator) { this.pmremGenerator.dispose(); this.pmremGenerator = undefined; } this.measurementManager?.destroy(); this.measurementManager = undefined; this.zoomToRectHelper?.destroy(); this.zoomToRectHelper = undefined; this.sectionManager?.destroy(); this.sectionManager = undefined; super.destroy(); } /** * Loads a 3d model from local * @internal */ async loadLocalModel( url: string, modelCfg: ModelConfig, manager?: THREE.LoadingManager, onProgress?: (event: ProgressEvent) => void ): Promise { this.timer = Date.now(); // it's better to increase jobCount in individule loadXxx method, // but for less modification, let's do it here. this.increaseJobCount(); try { const loadingHelper = new LoadingHelper(manager); // font is used for dxf for now if (this.font) { loadingHelper.setFont(this.font as Font); } const object = await loadingHelper.loadLocalModel(url, modelCfg.src, onProgress); if (object) { this.applyOptionsAndAddToScene(url, object, modelCfg); return Promise.resolve(); } } catch (error) { const msg = `Error loading ${modelCfg.src}`; log.error(msg, error); return Promise.reject(msg); } finally { this.decreaseJobCount(); } return Promise.reject(); } /** * Loads a 3d model */ async loadModel(modelCfg: ModelConfig, onProgress?: (event: ProgressEvent) => void): Promise { this.timer = Date.now(); // it's better to increase jobCount in individule loadXxx method, // but for less modification, let's do it here. this.increaseJobCount(); try { const loadingHelper = new LoadingHelper(); // font is used for dxf for now if (this.font) { loadingHelper.setFont(this.font as Font); } const object = await loadingHelper.loadModel(modelCfg.src, modelCfg.fileFormat, onProgress); if (object) { this.applyOptionsAndAddToScene(modelCfg.src, object, modelCfg); return Promise.resolve(); } } catch (error) { const msg = `Error loading ${modelCfg.src}`; log.error(msg, error); return Promise.reject(msg); } finally { this.decreaseJobCount(); } return Promise.reject(); } /** * Loads 3dtiles * TODO: Temporarily does not support 3dtiles version 1.0 above * The coordinate system is not processed yet * @internal */ async load3dTiles(modelCfg: ModelConfig): Promise { this.timer = Date.now(); // it's better to increase jobCount in individule loadXxx method, // but for less modification, let's do it here. this.increaseJobCount(); const tilesRenderer = new TilesRenderer(modelCfg.src); const bbox = new THREE.Box3(); const object = await new Promise((resolve, reject) => { tilesRenderer.onLoadTileSet = () => { const success = tilesRenderer.getBounds(bbox); if (!success) { if (!tilesRenderer.getBoundingSphere(tempSphere)) { log.warn(`[BimViewer] Can't get the correct bounding box of 3dTiles '${modelCfg.src}'!`); reject(); } else { tempSphere.getBoundingBox(bbox); } } log.debug(bbox); // avoid multiple calls tilesRenderer.onLoadTileSet = null; const object = tilesRenderer.group; log.debug(object); resolve(object); }; tilesRenderer.onLoadModel = (model: THREE.Object3D) => { model.traverse((obj) => { if (!matrixAutoUpdate && obj.matrixAutoUpdate) { obj.matrixAutoUpdate = matrixAutoUpdate; obj.updateMatrix(); } // eslint-disable-next-line if ((obj as any).isMesh) { obj.castShadow = true; obj.receiveShadow = true; } // eslint-disable-next-line if ((obj as any).geometry) { obj.layers.enableAll(); } }); model.updateWorldMatrix(false, true); this.updateDirectionalLightShadow(); this.enableRender(); }; tilesRenderer.setCamera(this.camera as THREE.Camera); tilesRenderer.setResolutionFromRenderer(this.camera as THREE.Camera, this.renderer as THREE.WebGLRenderer); tilesRenderer.update(); }); if (object) { const t = Date.now(); let modelId = modelCfg.modelId || modelCfg.src; if (this.loaded3dTiles[modelId]) { let i = 1; while (this.loaded3dTiles[`${modelId}_${i}`]) { i++; } modelId = `${modelId}_${i}`; log.warn(`[BimViewer] 3dTiles '${modelId}' is loaded more than once!`); } this.loaded3dTiles[modelId] = { id: object.id, bbox, renderer: tilesRenderer }; if (modelCfg.matrix && modelCfg.matrix.length === 16) { const mat = new THREE.Matrix4(); mat.elements = modelCfg.matrix; object.applyMatrix4(mat); } else { const pos = modelCfg.position || [0, 0, 0]; const rot = modelCfg.rotation || [0, 0, 0]; const scale = modelCfg.scale || [1, 1, 1]; object.position.set(pos[0], pos[1], pos[2]); object.rotation.set((rot[0] * Math.PI) / 180.0, (rot[1] * Math.PI) / 180.0, (rot[2] * Math.PI) / 180.0); object.scale.set(scale[0], scale[1], scale[2]); } object.matrixAutoUpdate = matrixAutoUpdate; object.updateMatrix(); object.updateMatrixWorld(true); this.scene?.add(object); bbox.applyMatrix4(object.matrix); this.computeBoundingBox(); this.tryAdjustDirectionalLight(); const loadedModelsCount = Object.keys(this.loadedModels).length; const isFirstModel = loadedModelsCount === 0 && Object.keys(this.loaded3dTiles).length === 1; if (isFirstModel) { const ctrl = this.datGui && this.datGui.controls; this.regenSkyOfGradientRamp(); if (ctrl && ctrl.showGroundGrid) { this.regenGroundGrid(); } this.tryAdjustCameraNearAndFar(); this.goToHomeView(); // only go to home view once, when the first model loaded } log.info(`[BimViewer] Added 3dTiles '${modelCfg.src}' to scene in ${(Date.now() - t) / 1000}s`); this.enableRender(); this.decreaseJobCount(); return Promise.resolve(); } this.decreaseJobCount(); return Promise.reject(); } /** * Sets font. * This needs to be called before loading a dxf, it won't affect any loaded text. * It accepts shx or typeface formats. For typeface, it only support passing in 1 font file in the array for now. * @param urls font file urls */ async setFont(urls: string[]) { const t = Date.now(); if (ShxFontLoader.isShxFile(urls[0])) { this.font = await new ShxFontLoader().loadAsync(urls); } else { if (urls.length > 1) { log.warn(`[BimViewer] Only support 1 typeface font file for now, others will be ignored!`); } this.font = await new FontLoader().loadAsync(urls[0]); } log.info(`[BimViewer] Font file(s) load time in ${(Date.now() - t) / 1000}s`); } /** * Sets decoder path for draco loader. * Draco decoder will be used if a model is draco encoded. * @param decoderPath e.g., "three/js/libs/draco/gltf/" * @internal */ setDracoDecoderPath(path: string) { LoadingHelper.setDracoDecoderPath(path); } /** * Applies options and add object to scene. */ private applyOptionsAndAddToScene = (url: string, object: THREE.Object3D, modelCfg: ModelConfig) => { log.info(`[BimViewer] '${url}' is loaded in ${(Date.now() - this.timer) / 1000}s, adding to scene...`); this.timer = Date.now(); const fileName = modelCfg.src && modelCfg.src.toLowerCase(); if (fileName && fileName.endsWith("dxf")) { this.twoDModelCount++; } if (modelCfg.matrix && modelCfg.matrix.length === 16) { const mat = new THREE.Matrix4(); mat.elements = modelCfg.matrix; object.applyMatrix4(mat); } else { const pos = modelCfg.position || [0, 0, 0]; const rot = modelCfg.rotation || [0, 0, 0]; const scale = modelCfg.scale || [1, 1, 1]; object.position.set(pos[0], pos[1], pos[2]); object.rotation.set((rot[0] * Math.PI) / 180.0, (rot[1] * Math.PI) / 180.0, (rot[2] * Math.PI) / 180.0); object.scale.set(scale[0], scale[1], scale[2]); } // Set object.matrixAutoUpdate = matrixAutoUpdate for static or rarely moving objects and manually call // object.updateMatrix() whenever their position/rotation/quaternion/scale are updated. object.matrixAutoUpdate = matrixAutoUpdate; object.updateMatrix(); object.traverse((obj) => { if (!matrixAutoUpdate && obj.matrixAutoUpdate) { obj.matrixAutoUpdate = matrixAutoUpdate; obj.updateMatrix(); } }); const instantiate = modelCfg.instantiate; const merge = modelCfg.merge; if (instantiate) { // load and display first, then do instantiation setTimeout(() => { this.instantiate(object); // If we do instantiate, better to goToHomeView() and regenSky() after instantiate is done, // otherwise, the bounding box could be wrong. // This makes loading take longer time, since we add to scene after instantiate! setTimeout(() => { if (merge) { this.merge(object); } this.addLoadedModelToScene(object, modelCfg); }, 0); }, 0); } else if (merge) { setTimeout(() => { this.merge(object); // If we do instantiate, better to goToHomeView() and regenSky() after instantiate is done, // otherwise, the bounding box could be wrong. // This makes loading take longer time, since we add to scene after instantiate! setTimeout(() => this.addLoadedModelToScene(object, modelCfg), 0); }, 0); } else { this.addLoadedModelToScene(object, modelCfg); } }; /** * Add newly added object to scene. * Also, usually(but not always) we should regenerate sky and go to home view * @param object */ private addLoadedModelToScene(object: THREE.Object3D, modelCfg: ModelConfig) { if (!this.scene) { return; } if (modelCfg.merge) { const t = Date.now(); // If highlighting is not required, the original index data may not be preserved MeshBvhHelper.createMeshBvhAsync([object], { saveOriginalIndex: true }); log.info(`[BimViewer] Creates mesh bvh cost ${(Date.now() - t) / 1000}s`); } // enable shadow object.traverse((obj) => { // eslint-disable-next-line if ((obj as any).isMesh) { obj.castShadow = true; obj.receiveShadow = true; } // eslint-disable-next-line if ((obj as any).geometry) { obj.layers.enableAll(); } }); this.scene.add(object); const bbox = new THREE.Box3().setFromObject(object); // It is a valid case when a model is loaded more than once, // but we need to handle this.loadedModels' 'key' properly. let modelId = modelCfg.modelId || modelCfg.src; if (this.loadedModels[modelId]) { let i = 1; while (this.loadedModels[`${modelId}_${i}`]) { i++; } modelId = `${modelId}_${i}`; log.warn(`[BimViewer] Model '${modelId}' is loaded more than once!`); } this.loadedModels[modelId] = { id: object.id, bbox }; this.computeBoundingBox(); this.tryAdjustDirectionalLight(); const modelIds = Object.values(this.loadedModels).map((obj) => obj.id); Object.values(this.loaded3dTiles).forEach((obj) => modelIds.push(obj.id)); const isFirstModel = !modelIds || modelIds.length <= 1; if (isFirstModel) { if (this.has2dModel) { // this.scene.background = new THREE.Color(0x000000); this.setToOrthographicCamera(true); if (this.skyOfGradientRamp) { this.scene.remove(this.skyOfGradientRamp); } } else { const ctrl = this.datGui && this.datGui.controls; this.regenSkyOfGradientRamp(); if (ctrl && ctrl.showGroundGrid) { this.regenGroundGrid(); } } this.tryAdjustCameraNearAndFar(); this.goToHomeView(); // only go to home view once, when the first model loaded } // this.scene.add(bbox); if (modelCfg.edges) { ObjectUtils.addOutlines(object); } log.info(`[BimViewer] Added '${modelCfg.src}' to scene in ${(Date.now() - this.timer) / 1000}s`); this.enableRender(); // this.decreaseJobCount(); this.dispatchEvent(ViewerEvent.ModelLoaded); } /** * We won't set a opacity directly, because that way will lose model's original opacity value * @param isAdd is add or remove the opacity we added * @param opacity * @internal */ public addOrRemoveObjectOpacity(isAdd = true, opacity = 0.3, includeObjectIds?: number[], excludeObjectIds?: number[]) { // store informations into materials, so we can revert them if (!this.savedMaterialsForOpacity) { this.savedMaterialsForOpacity = []; } if (!this.scene) { return; } const scene = this.scene; const materialInfoList: MaterialInfo[] = []; const modelIds = Object.values(this.loadedModels).map((obj) => obj.id); Object.values(this.loaded3dTiles).forEach((obj) => modelIds.push(obj.id)); modelIds.forEach((id) => { if (isAdd) { if (this.savedMaterialsForOpacity && this.savedMaterialsForOpacity.length > 0) { ObjectUtils.revertObjectOpacityById(scene, id, this.savedMaterialsForOpacity); } const list = ObjectUtils.setObjectOpacityById(scene, id, opacity, includeObjectIds, excludeObjectIds); materialInfoList.push(...list); } else if (this.savedMaterialsForOpacity) { ObjectUtils.revertObjectOpacityById(scene, id, this.savedMaterialsForOpacity); } }); if (isAdd) { this.savedMaterialsForOpacity = materialInfoList; } else { this.savedMaterialsForOpacity = []; } this.enableRender(); } /** * @internal */ public hasTransparentObject(): boolean { return !!(this.savedMaterialsForOpacity && this.savedMaterialsForOpacity.length > 0); } /** * @internal */ public showVertexNormals(show: boolean, size = 0.1) { if (show) { if (!this.vertexNormalsHelpers) { this.vertexNormalsHelpers = new THREE.Group(); } this.scene?.traverseVisible((obj: THREE.Object3D) => { const objectNamesToExclude = ["SKYBOX", "GROUND_GRID", "GRASS_GROUND", "BIM_VIEWER_BOX_HELPER"]; if (obj instanceof THREE.Mesh && !objectNamesToExclude.includes(obj.name)) { const normal = obj.geometry.attributes.normal; if (!normal) { return; } const helper = new VertexNormalsHelper(obj, size, 0xff0000); this.vertexNormalsHelpers?.add(helper); } }); this.scene?.add(this.vertexNormalsHelpers); } else if (this.vertexNormalsHelpers) { this.scene?.remove(this.vertexNormalsHelpers); this.vertexNormalsHelpers = undefined; } } // resize render area // if no width or height passed in, use window.innerWidth/window.innerHeight instead protected resize(width: number, height: number) { // handle window resize event super.resize(width, height); if (height > 0) { if (this.composer) { this.composer.setSize(width, height); } if (this.effectFxaaPass) { // eslint-disable-next-line this.effectFxaaPass.uniforms["resolution"].value.set(1 / width, 1 / height); } } this.enableRender(); } /** * @internal */ public getRaycaster(): THREE.Raycaster | undefined { return this.raycaster; } /** * @internal */ public getRaycastableObjectsByMouse(event?: EventInfo): THREE.Object3D[] { let objects: THREE.Object3D[] = []; if (!this.raycaster || !this.camera || !this.scene || !event || !this.viewerContainer) { return objects; } const screenPoint = new THREE.Vector2(event.x, event.y); const coords = CoordinateConversionUtils.screenPoint2NdcPoint(screenPoint, this.camera, this.viewerContainer); this.camera.updateMatrixWorld(); this.raycaster.setFromCamera(coords, this.camera); objects = this.getRaycastableObjects(); return objects; } private getRaycastableObjects(): THREE.Object3D[] { const objects: THREE.Object3D[] = []; Object.values(this.loadedModels).forEach((obj) => { const object = this.scene && this.scene.getObjectById(obj.id); if (object && object.visible) { objects.push(object); } }); Object.values(this.loaded3dTiles).forEach((obj) => { const object = this.scene && this.scene.getObjectById(obj.id); if (object && object.visible) { objects.push(object); } }); return objects; } /** * Gets intersections by given mouse location. * If no MouseEvent is passed in, use (0, 0) as the raycaster's origin. */ private getIntersections(event?: EventInfo): THREE.Intersection[] { const objects = this.getRaycastableObjectsByMouse(event); let intersects = (this.raycaster && this.raycaster.intersectObjects(objects, true)) || []; //filter clipp object if (this.renderer && this.renderer.clippingPlanes.length > 0) { intersects = intersects.filter((intsect) => { return this.renderer?.clippingPlanes.every(function (plane) { return plane.distanceToPoint(intsect.point) > 0; }); }); } return intersects; } private getAllIntersections(event?: EventInfo): THREE.Intersection[] { if (!this.raycaster || !this.camera || !this.scene || !this.viewerContainer) { return []; } const screenPoint = new THREE.Vector2(event?.x, event?.y); const coords = CoordinateConversionUtils.screenPoint2NdcPoint(screenPoint, this.camera, this.viewerContainer); this.raycaster.setFromCamera(coords, this.camera); const objects: THREE.Object3D[] = this.scene.children; let intersects = this.raycaster.intersectObjects(objects, true) || []; //filter clipp object if (this.renderer && this.renderer.clippingPlanes.length > 0) { intersects = intersects.filter((intsect) => { return this.renderer?.clippingPlanes.every(function (plane) { return plane.distanceToPoint(intsect.point) > 0; }); }); } return intersects; } /** * Handles mouse click event */ private handleMouseClick(event: EventInfo) { // when measure is enabled, disable highlight/select feature if (this.measurementManager?.isMeasurementActive() || this.sectionManager?.isSectionActive()) { return; } const t = Date.now(); this.raycaster && this.raycaster.layers.set(layerForSelectableObjects); const intersections = this.getIntersections(event); log.debug(`[BimViewer] getIntersections costs ${(Date.now() - t) / 1000}s`); const firstIntersect = intersections.find((intersect) => { const object = intersect.object; // exclude invisible objects // exclude non-selectable non-mesh objects, ground, outline, etc. It's kind of complex, but gonna be wired if user can select another object behand a mesh. return object.visible && (object.userData.selectable !== false || object instanceof THREE.Mesh); }); let object: THREE.Object3D | Drawable | undefined = (firstIntersect && firstIntersect.object) || undefined; let instanceId; // used for InstancedMesh let batchId; // used for merged mesh if (object) { if (object instanceof THREE.InstancedMesh) { instanceId = (firstIntersect as THREE.Intersection).instanceId; if ( this.selectedObject && this.selectedObject.id === object.id && this.selectedObject.userData.instanceId === instanceId ) { // if the same InstancedMesh is selected and with the same instanceId, then deselect it object = undefined; } } else if (MergeUtils.isMergedMesh(object as THREE.Mesh)) { const faceIndex = (firstIntersect && firstIntersect.faceIndex) || -1; if (faceIndex >= 0) { batchId = MergeUtils.getBatchIdByFaceIndex(object as THREE.Mesh, faceIndex); if ( this.selectedObject && this.selectedObject.id === object.id && this.selectedObject.userData.batchId === batchId ) { object = undefined; } } else { object = undefined; } } else if (this.selectedObject && this.selectedObject.id === object.id) { // if one object is selected twice, deselect it object = undefined; } } if (intersections.length > 0 && intersections[0].point) { const drawables = this.overlayRender?.getDrawablesByPosition(intersections[0].point, this.raycaster); if (drawables && drawables.length > 0) { // this.clearSelection(); // drawables[0].selected = true; // this.selectedObject = drawables[0]; object = drawables[0]; } } if (this.selectedObject) { this.clearSelection(); } object ? this.selectObject(object, instanceId, batchId) : this.clearSelection(); } /** * Select or unselect an object. * It doesn't support selecting more than one objects. * It doesn't support selecting a parent object which doesn't have material itself. * In order to support de-select, we'll need to store some information, we do this via userData: * For InstancedMesh, there are two cases: * 1) One Mesh in InstancedMesh is selected * it adds following to selected object: userData \{ * instanceId: number, * originalMatrix: THREE.Matrix4, * clonedMesh: THREE.Mesh * \} * 2) The whole InstancedMesh is selected. This case is no different from a normal Mesh is selected, so: * For Mesh, it adds: userData \{ * originalMaterial: THREE.Material * \} * @param object * @param instanceId pass in instanceId if an InstancedMesh is selected * @param depthTest set to false if caller want to make sure user can see it. When an object is * selected by user manually, we don't need to make sure user can see it. While if selection is * made by program, we parbably need to make sure user can see it, in other words, the selected * object won't be blocked by other objects. * @internal */ public selectObject( object?: THREE.Object3D | Drawable, instanceId?: number, batchId?: number, depthTest: boolean | undefined = undefined ) { if (object instanceof Drawable) { this.selectedObject = object; this.selectedObject.selected = true; this.enableRender(); return; } // revert last selected object's material if any if (this.selectedObject) { const userData = this.selectedObject.userData; if (userData.instanceId != null && userData.originalMatrix && userData.clonedMesh) { this.scene && this.scene.remove(userData.clonedMesh); // clear the cloned mesh const im = this.selectedObject as THREE.InstancedMesh; im.setMatrixAt(userData.instanceId, userData.originalMatrix); // revert the matrix im.instanceMatrix.needsUpdate = true; im.updateMatrix(); // need to call it since object.matrixAutoUpdate is false delete userData.instanceId; delete userData.originalMatrix; // if the cloned object is selected, then just de-select it and return if (object === userData.clonedMesh) { userData.clonedMesh.geometry.dispose(); delete userData.clonedMesh; this.selectedObject = undefined; if (this.outlinePass) { this.outlinePass.selectedObjects = []; } return; } userData.clonedMesh.geometry.dispose(); delete userData.clonedMesh; } else if (userData.batchId != null && userData.clonedMesh) { this.scene && this.scene.remove(userData.clonedMesh); // clear the cloned mesh delete userData.batchId; // if the cloned object is selected, then just de-select it and return if (object === userData.clonedMesh) { userData.clonedMesh.geometry.dispose(); delete userData.clonedMesh; this.selectedObject = undefined; if (this.outlinePass) { this.outlinePass.selectedObjects = []; } return; } userData.clonedMesh.geometry.dispose(); delete userData.clonedMesh; } else if (userData.originalMaterial) { if (this.selectedObject.material) { // manually dispose it according to https://threejs.org/docs/#manual/en/introduction/How-to-dispose-of-objects const material = this.selectedObject.material; if (Array.isArray(material)) { material.forEach((m) => m.dispose()); } else if (material instanceof THREE.Material) { material.dispose(); } } this.selectedObject.material = userData.originalMaterial; delete userData.originalMaterial; // clean up } this.selectedObject = undefined; if (this.outlinePass) { this.outlinePass.selectedObjects = []; } } if (!this.scene || !object) { this.enableRender(); return; } if (object instanceof THREE.InstancedMesh && instanceId != null) { const im = object as THREE.InstancedMesh; const originalMatrix = new THREE.Matrix4(); const hideMatrix = new THREE.Matrix4(); hideMatrix.set(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); // this matrix hides an object im.getMatrixAt(instanceId, originalMatrix); this.selectedObject = object; if (this.outlinePass) { this.outlinePass.selectedObjects = [object]; } // Here is the example to select InstancedMesh, which is to call setColorAt() // https://threejs.org/examples/?q=instanc#webgl_instancing_raycast // While, it sounds like only support MeshPhongMaterial. So here, we'll clone // a mesh with highlighted color to replace the original instance in InstancedMesh const clonedMaterial = MaterialUtils.clonedHighlightMaterials(object, { depthTest, }); if (clonedMaterial) { // clone a new mesh for the selected instance const clonedMesh = new THREE.Mesh(im.geometry.clone(), clonedMaterial); clonedMesh.applyMatrix4(object.matrixWorld.multiply(originalMatrix)); clonedMesh.matrixWorldNeedsUpdate = true; clonedMesh.name = "Cloned mesh for highlighting"; clonedMesh.layers.enableAll(); // hide the original mesh by its matrix const matrix = originalMatrix.clone(); matrix.multiplyMatrices(originalMatrix, hideMatrix); im.setMatrixAt(instanceId, matrix); im.instanceMatrix.needsUpdate = true; im.updateMatrix(); // need to call it since object.matrixAutoUpdate is false this.selectedObject.userData.instanceId = instanceId; // store some instanceId so highlight can be reverted this.selectedObject.userData.originalMatrix = originalMatrix; this.selectedObject.userData.clonedMesh = clonedMesh; this.scene.add(clonedMesh); // add it to scene temporaly } } else if (MergeUtils.isMergedMesh(object as THREE.Mesh) && batchId != null) { const batch = MergeUtils.getBatchByBatchId(object as THREE.Mesh, batchId); let logStr = `[BimViewer] Clicked on merged mesh(id: ${object.id}).`; logStr += ` Original mesh batchId: ${batchId}, name: ${batch?.name}`; log.info(logStr); const clonedMaterial = MaterialUtils.clonedHighlightMaterials(object as THREE.Mesh, { depthTest }); const clonedGeom = MergeUtils.cloneGeometryForBatch(object as THREE.Mesh, batch as Batch); if (clonedMaterial && clonedGeom) { const clonedMesh = new THREE.Mesh(clonedGeom, clonedMaterial); clonedMesh.applyMatrix4(object.matrixWorld); clonedMesh.matrixWorldNeedsUpdate = true; clonedMesh.name = "Cloned mesh for highlighting"; clonedMesh.layers.enableAll(); this.selectedObject = object; this.selectedObject.userData.batchId = batchId; this.selectedObject.userData.clonedMesh = clonedMesh; this.scene.add(clonedMesh); } } else { // save the original material, so we can reverit it back when deselect const clonedMaterial = MaterialUtils.clonedHighlightMaterials(object as THREE.Mesh, { depthTest }); if (clonedMaterial) { this.selectedObject = object; this.selectedObject.userData.originalMaterial = this.selectedObject.material; this.selectedObject.material = clonedMaterial; if (this.outlinePass) { this.outlinePass.selectedObjects = [object]; } } } this.enableRender(); } /** * Clears the current selection */ public clearSelection() { // remove select fot drawable if (this.selectedObject && this.selectedObject instanceof Drawable) { this.selectedObject.selected = false; } else { this.selectObject(); // simply select nothing } this.selectedObject = undefined; } /** * Gets bounding box * @internal */ getBBox(): THREE.Box3 | undefined { return this.bbox; } /** * Make camera fly to objects */ protected flyToObjects(objects: THREE.Object3D[]) { if (!objects || objects.length === 0 || !this.camera) { return; } const eye = new THREE.Vector3(); const look = new THREE.Vector3(); const bbox = new THREE.Box3(); objects.forEach((object) => { const box = SceneUtils.getBoundingBox(object); bbox.union(box); }); const cameraDir = new THREE.Vector3(); this.camera.getWorldDirection(cameraDir); Viewer3DUtils.getCameraPositionByBboxAndDirection(bbox, eye, look, this.camera.projectionMatrix, cameraDir); // this.flyTo(eye, look); const distance = new THREE.Vector3().subVectors(eye, look).length(); (this.controls as CameraControlsEx).moveTo(eye.x, eye.y, eye.z, true); if (this.camera instanceof THREE.OrthographicCamera) { const width = this.camera.right - this.camera.left; const height = this.camera.top - this.camera.bottom; const bbSize = new THREE.Vector3(); bbox.getSize(bbSize); const zoom = Math.min(width / bbSize.x, height / bbSize.y); (this.controls as CameraControlsEx).zoomTo(zoom, true); } else if (this.camera instanceof THREE.PerspectiveCamera) { (this.controls as CameraControlsEx).dollyTo(distance, true); } (this.controls as CameraControlsEx).setFocalOffset(0, 0, 0, true); // maybe use fitToBox better // (this.controls as CameraControlsEx).fitToBox(bbox, true, { // paddingTop: bbSize.length() / 5, // paddingBottom: bbSize.length() / 5, // paddingLeft: bbSize.length() / 5, // paddingRight: bbSize.length() / 5, // }); } /** * Make camera fly to an object * @internal */ public flyToObject(object: THREE.Object3D) { this.flyToObjects([object]); } /** * Flies to current selected object if any */ protected flyToSelectedObject() { if (!this.selectedObject) { return; } let obj = this.selectedObject; // if part of InstancedMesh is selected, fly to that part rather than fly to the whole InstancedMesh if (obj instanceof THREE.InstancedMesh && obj.userData.clonedMesh) { obj = obj.userData.clonedMesh; } else if (MergeUtils.isMergedMesh(obj) && obj.userData.clonedMesh) { obj = obj.userData.clonedMesh; } this.flyToObject(obj); } /** * Flies to a random object (by alt + r). * It is useful when either the data is wrong or there is bug in program, * then we cannot see anything in the scene! */ protected flyToRandomObject() { const modelIds = Object.values(this.loadedModels).map((obj) => obj.id); Object.values(this.loaded3dTiles).forEach((obj) => modelIds.push(obj.id)); const modelCount = modelIds.length; if (modelCount <= 0) { return; } const index = Math.floor(Math.random() * modelCount); const id = modelIds[index]; const model = this.scene?.getObjectById(id); if (!model) { return; } const objectIds: number[] = []; // store id, rather than id or object instances this.scene?.traverseVisible((obj: THREE.Object3D) => { const objectNamesToExclude = ["SKYBOX", "GROUND_GRID", "GRASS_GROUND", "BIM_VIEWER_BOX_HELPER"]; if ((obj instanceof THREE.Mesh || obj instanceof THREE.Line) && !objectNamesToExclude.includes(obj.name)) { objectIds.push(obj.id); } }); if (objectIds.length < 1) { return; } const rand = Math.floor(Math.random() * objectIds.length); const obj = this.scene?.getObjectById(objectIds[rand]); if (!obj) { return; } log.info(`[BimViewer] Flying to random object: ${obj.name}, type: ${obj.type}`); this.selectObject(obj, undefined, undefined, true); this.flyToObject(obj); } /** * Make camera fly to target position with given lookAt position * @param position camera's target position * @param lookAt camera's new lookAt position */ public flyTo(position: THREE.Vector3, lookAt: THREE.Vector3) { // TODO: redesign const camera = this.camera as THREE.PerspectiveCamera | THREE.OrthographicCamera; const controls = this.controls as CameraControlsEx; if (!camera || !controls) { return; } if (position.equals(lookAt)) { log.error("[BimViewer] camera position and lookAt cannot be the same!"); return; } else if (!CommonUtils.isVectorValid(position) || !CommonUtils.isVectorValid(lookAt)) { log.error("[BimViewer] invalid position or lookAt!"); return; } // If distance between position and lookAt is too near or far (according to camera's near/far settings). // need to adjust 'position' to fit it. const distance = position.distanceTo(lookAt); if (distance < camera.near) { // the new position is just farer than original position position = position .clone() .sub(lookAt) .normalize() .multiplyScalar(camera.near * 1.1); log.warn("[BimViewer] camera could be too close to see the object!"); } else if (distance > camera.far) { // the new position is just closer than original position position = position .clone() .sub(lookAt) .normalize() .multiplyScalar(camera.far * 0.9); log.warn("[BimViewer] camera could be too far to see the object!"); } // It seem that setOrbitPoint and setLookAt can not use in the same controls.setLookAt(position.x, position.y, position.z, lookAt.x, lookAt.y, lookAt.z, true); // const update = (p: THREE.Vector3, t: THREE.Vector3) => { // controls.setLookAt(p.x, p.y, p.z, t.x, t.y, t.z); // }; // // since we've implemented a new controls, whose target may not be at the center // // of screen, so, we'd better use camera's position. And, let's assume the origin target // // is in the center of screen. // const originPosition = camera.position.clone(); // const cameraDir = new THREE.Vector3(); // camera.getWorldDirection(cameraDir); // const dist = controls.getTarget(new THREE.Vector3()).distanceTo(originPosition); // // calculate a target in the center of screen // const originLookAt = originPosition.clone().addScaledVector(cameraDir, dist); // const totalTime = 500; // total duration in ms // const startTime = Date.now(); // this.cameraUpdateInterval && clearInterval(this.cameraUpdateInterval); // this.cameraUpdateInterval = setInterval(() => { // let elipsedTime = Date.now() - startTime; // if (elipsedTime > totalTime) { // elipsedTime = totalTime; // } // // calculate current position by alpha // const calcCurrPos = (v1: THREE.Vector3, v2: THREE.Vector3, alpha: number): THREE.Vector3 => { // const x = v1.x + (v2.x - v1.x) * alpha; // const y = v1.y + (v2.y - v1.y) * alpha; // const z = v1.z + (v2.z - v1.z) * alpha; // return new THREE.Vector3(x, y, z); // }; // const p = calcCurrPos(originPosition, position, elipsedTime / totalTime); // const t = calcCurrPos(originLookAt, lookAt, elipsedTime / totalTime); // update(p, t); // if (elipsedTime >= totalTime) { // clearInterval(this.cameraUpdateInterval); // this.cameraUpdateInterval = undefined; // } // }, 10); } /** * Fits the camera to view all objects in scene */ public viewFitAll() { if (!this.scene || !this.camera) { return; } const eye = new THREE.Vector3(); const look = new THREE.Vector3(); const bbox = SceneUtils.getVisibleObjectBoundingBox(this.scene); const cameraDir = new THREE.Vector3(); this.camera?.getWorldDirection(cameraDir); Viewer3DUtils.getCameraPositionByBboxAndDirection(bbox, eye, look, this.camera.projectionMatrix, cameraDir); this.flyTo(eye, look); } /** * Goes to home view */ public goToHomeView() { const camera = this.camera; const home = this.cameraCfg; const position = home && CommonUtils.arrayToVector3(home.eye); const target = home && CommonUtils.arrayToVector3(home.look); if (position && target) { // TODO: flyTo() doesn't handle OrthographicCamera properly, we need another process! // this.flyTo(position, target); (this.controls as CameraControlsEx).setLookAt( position.x, position.y, position.z, target.x, target.y, target.z, true ); (this.controls as CameraControlsEx).setFocalOffset(0, 0, 0, true); } else if (this.scene) { // if home.eye not defined in project, then go to 'Front' const eye = new THREE.Vector3(); const look = new THREE.Vector3(); const cameraDir = new THREE.Vector3(-1, -0.5, -1); if (this.has2dModel) { // get a proper direction by bbox const sizeX = this.bbox.max.x - this.bbox.min.x; const sizeY = this.bbox.max.y - this.bbox.min.y; const sizeZ = this.bbox.max.z - this.bbox.min.z; const minSize = Math.min(sizeX, sizeY, sizeZ); if (sizeX - minSize <= 0) { cameraDir.set(1, 0, 0); // left view } else if (sizeY - minSize <= 0) { cameraDir.set(0, -1, 0); // top view } else if (sizeZ - minSize <= 0) { cameraDir.set(0, 0, -1); // front view } } Viewer3DUtils.getCameraPositionByBboxAndDirection(this.bbox, eye, look, camera?.projectionMatrix, cameraDir); if (!this.cameraCfg || (this.cameraCfg && (!this.cameraCfg.eye || !this.cameraCfg.look))) { this.cameraCfg = { eye: eye.toArray(), look: look.toArray(), }; } if (camera instanceof THREE.OrthographicCamera) { const bbox = this.bbox; const sizeX = bbox.max.x - bbox.min.x; const sizeZ = bbox.max.z - bbox.min.z; // adjust zoom value according to object size and camera's top/bottom/left/right const leftRightSize = camera.right - camera.left; const bottomTopSize = camera.top - camera.bottom; camera.zoom = Math.max(leftRightSize, bottomTopSize) / Math.max(sizeX, sizeZ); camera.zoom /= 2; // make it smaller seems better camera.updateProjectionMatrix(); } // do not allow camera's target and position is the same point!! if (!eye.equals(look)) { this.flyTo(eye, look); } } } public zoomToBBox(bbox: THREE.Box3) { const eye = new THREE.Vector3(); const look = new THREE.Vector3(); const cameraDir = new THREE.Vector3(); this.camera?.getWorldDirection(cameraDir); Viewer3DUtils.getCameraPositionByBboxAndDirection(bbox, eye, look, this.camera?.projectionMatrix, cameraDir); if (!eye.equals(look)) { // this.flyTo(eye, look); const distance = new THREE.Vector3().subVectors(eye, look).length(); (this.controls as CameraControlsEx).moveTo(eye.x, eye.y, eye.z, true); if (this.camera instanceof THREE.OrthographicCamera) { const width = this.camera.right - this.camera.left; const height = this.camera.top - this.camera.bottom; const bbSize = new THREE.Vector3(); bbox.getSize(bbSize); const zoom = Math.min(width / bbSize.x, height / bbSize.y); (this.controls as CameraControlsEx).zoomTo(zoom, true); } else if (this.camera instanceof THREE.PerspectiveCamera) { (this.controls as CameraControlsEx).dollyTo(distance, true); } (this.controls as CameraControlsEx).setFocalOffset(0, 0, 0, true); } } /** * Tries to adjust camera near/far clip plane according to objects size in scene. * Do this to avoid the case when objects are too small or big thus clipped! */ private tryAdjustCameraNearAndFar() { const camera = this.camera as THREE.PerspectiveCamera | THREE.OrthographicCamera; if (!this.scene || !camera) { return; } const bbox = this.bbox; const near = camera.near; const far = camera.far; const sizeX = bbox.max.x - bbox.min.x; const sizeY = bbox.max.y - bbox.min.y; const sizeZ = bbox.max.z - bbox.min.z; const maxSize = Math.max(sizeX, sizeY, sizeZ); const factor = 5; // a value according to experience const maxNear = maxSize / factor; // camera.near shouldn't bigger than this const minFar = maxSize * factor; // camera.far shouldn't smaller than this if (near > maxNear || far < minFar) { const n2s = (n: number): string => CommonUtils.numberToString(n); log.info(`[BimViewer] BBox's longest side is: ${n2s(maxSize)}`); if (near > maxNear) { log.warn(`[BimViewer] camera.near(${n2s(near)}) shouldn't bigger than ${n2s(maxNear)}, will change it!`); camera.near = maxNear; } if (far < minFar) { log.warn(`[BimViewer] camera.far(${n2s(far)}) shouldn't smaller than ${n2s(minFar)}, will change it!`); camera.far = minFar; } } // adjust zoom value according to object size and camera's top/bottom/left/right if (camera instanceof THREE.OrthographicCamera) { const leftRightSize = camera.right - camera.left; const bottomTopSize = camera.top - camera.bottom; camera.zoom = Math.max(leftRightSize, bottomTopSize) / Math.max(sizeX, sizeZ); camera.zoom /= 2; // make it smaller seems better } camera.updateProjectionMatrix(); } // TODO: Set the fixed direction according to the time private tryAdjustDirectionalLight() { if (!this.directionalLight) { return; } const bbox = this.bbox; const sphere = new THREE.Sphere(); bbox.getBoundingSphere(sphere); const center = sphere.center; const radius = sphere.radius; const camera = this.directionalLight.shadow.camera; camera.zoom = 1.0; camera.top = radius; camera.bottom = -radius; camera.right = radius; camera.left = -radius; camera.near = 0.1; camera.far = 2 * radius; camera.updateProjectionMatrix(); const lightDirection = new THREE.Vector3(-1, -0.5, 1); // Keep the default direction consistent with UE //const lightDirection = new THREE.Vector3(0.641, -0.423, -0.641); lightDirection.normalize().multiplyScalar(radius); this.directionalLight.position.copy(center).addScaledVector(lightDirection, -1); this.directionalLight.target.position.copy(center); log.debug("[BimViewer] this.directionalLight.position", this.directionalLight.position); log.debug("[BimViewer] this.directionalLight.target.position", this.directionalLight.target.position); this.updateDirectionalLight(); this.enableRender(); } /** * @internal */ updateDirectionalLight() { if (!this.directionalLight) { return; } this.directionalLight.updateMatrixWorld(); this.directionalLight.target.updateMatrixWorld(); if (this.directionalLightHelper) { // matrix = light.matrixWorld this.directionalLightHelper.update(); !this.scene?.matrixAutoUpdate && this.directionalLightHelper.updateWorldMatrix(false, true); } this.directionalLight.shadow.needsUpdate = true; // update the light's shadow camera's projection matrix this.directionalLight.shadow.camera.updateProjectionMatrix(); if (this.shadowCameraHelper) { // and now update the camera helper we're using to show the light's shadow camera this.shadowCameraHelper.update(); if (!this.scene?.matrixAutoUpdate) { const shadowCamera = this.directionalLight.shadow.camera; shadowCamera.position.setFromMatrixPosition(this.directionalLight.matrixWorld); tempVec3.setFromMatrixPosition(this.directionalLight.target.matrixWorld); shadowCamera.lookAt(tempVec3); shadowCamera.updateMatrixWorld(); // matrix = camera.worldMatrix this.shadowCameraHelper.updateWorldMatrix(false, true); } } } private updateDirectionalLightShadow() { if (!this.directionalLight) { return; } this.directionalLight.shadow.needsUpdate = true; } /** * @internal */ showDirectionalLightHelper(visible: boolean) { if (this.directionalLightHelper) { this.directionalLightHelper.visible = visible; } if (this.shadowCameraHelper) { this.shadowCameraHelper.visible = visible; } } /** * Regenerates skybox according to models' location and size */ private regenSkyOfGradientRamp() { if (!this.scene) { return; } if (this.skyOfGradientRamp) { this.skyOfGradientRamp.geometry.dispose(); (this.skyOfGradientRamp.material as THREE.Material).dispose(); this.scene.remove(this.skyOfGradientRamp); this.skyOfGradientRamp.clear(); this.skyOfGradientRamp = undefined; } const modelIds = Object.values(this.loadedModels).map((obj) => obj.id); Object.values(this.loaded3dTiles).forEach((obj) => modelIds.push(obj.id)); if (modelIds) { const home = this.cameraCfg; const position = home && CommonUtils.arrayToVector3(home.eye); const target = home && CommonUtils.arrayToVector3(home.look); let bbox = new THREE.Box3(); if (position && target) { const p1 = position; const p2 = target; bbox.expandByPoint(new THREE.Vector3(p1.x, p1.y, p1.z)); bbox.expandByPoint(new THREE.Vector3(p2.x, p2.y, p2.z)); } else { bbox = SceneUtils.getObjectsBoundingBox(this.scene, modelIds); } this.skyOfGradientRamp = SkyboxUtils.createSkyOfGradientRampByBoundingBox(bbox); this.scene.add(this.skyOfGradientRamp); } } /** * Regenerates ground grid according to models' location and size */ private regenGroundGrid() { if (!this.scene) { return; } if (this.groundGrid) { this.groundGrid.geometry.dispose(); (this.groundGrid.material as THREE.Material).dispose(); this.scene.remove(this.groundGrid); } const modelIds = Object.values(this.loadedModels).map((obj) => obj.id); Object.values(this.loaded3dTiles).forEach((obj) => modelIds.push(obj.id)); if (modelIds) { const home = this.cameraCfg; const center = home && CommonUtils.arrayToVector3(home.look); center && (center.y = 0); // TODO: will need to consider ground size according to models' size this.groundGrid = GroundUtils.createGroundGrid(undefined, undefined, center); this.scene.add(this.groundGrid); } } /**** Anchor rotation related interface start ****/ private setOrbitPoint(event: EventInfo) { const controls = this.controls as CameraControlsEx; if (!this.camera || !this.renderer || !this.controls || !this.scene || !this.raycaster || !this.viewerContainer) { return; } if (this.selectedObject && this.selectedObject instanceof THREE.Object3D) { const box = new THREE.Box3().setFromObject(this.selectedObject); const center = new THREE.Vector3(); box.getCenter(center); controls?.setOrbitPoint(center.x, center.y, center.z); const point = CoordinateConversionUtils.worldPosition2ScreenPoint(center, this.camera, this.viewerContainer); this.setAnchorPosition(point); } else { this.raycaster && this.raycaster.layers.set(layerForHitableObjects); const intersections = this.getIntersections(event); const vector = new THREE.Vector2(event.x, event.y); if (intersections && intersections.length !== 0) { const point = intersections[0].point; controls?.setOrbitPoint(point.x, point.y, point.z); this.setAnchorPosition(vector); } else { const center = this.bbox.getCenter(new THREE.Vector3()); controls.setOrbitPoint(center.x, center.y, center.z); const screenPosition = CoordinateConversionUtils.worldPosition2ScreenPoint( center, this.camera, this.renderer.domElement ); this.setAnchorPosition(screenPosition); } } } private onAnchorPointerDown = (event: EventInfo) => { if (!this.controls?.enableRotate || !this.renderer || this.sectionManager?.isSectionActive()) { return; } this.setOrbitPoint(event); }; private setAnchorPosition(position: THREE.Vector2) { if (this.anchor) { this.anchor.className = `anchor active`; this.anchor.style.left = `${position.x}px`; this.anchor.style.top = `${position.y}px`; } } private createAnchor() { const anchor = document.createElement("div"); anchor.className = "anchor"; this.viewerContainer?.appendChild(anchor); return anchor; } private disposeAnchor() { if (this.anchor) { this.viewerContainer?.removeChild(this.anchor); this.anchor = undefined; } } private onAnchorPointerUp = () => { if (this.anchor) { this.anchor.className = `anchor`; } if (!this.viewerContainer || !this.camera || !this.controls) { return; } }; private disposeRotateToCursor() { this.disposeAnchor(); } /******* Anchor rotation related interface end *********/ /** * Adds a hotpoint. * Caller should set a hotpointId that is unique in the session of current DxfViewer. */ addHotpoint(hotpoint: Hotpoint) { const exists = this.hasHotpoint(hotpoint.hotpointId); if (exists) { log.warn(`[BimViewer] Hotpoint with id '${hotpoint.hotpointId}' already exist!`); return; } const p = hotpoint.anchorPosition; const object = CSS2DObjectUtils.createHotpoint(hotpoint.html); object.position.set(p[0] || 0, p[1] || 0, p[2] || 0); object.visible = hotpoint.visible !== false; object.userData.hotpoint = hotpoint; if (!this.hotpointRoot) { this.hotpointRoot = new THREE.Group(); this.hotpointRoot.matrixAutoUpdate = matrixAutoUpdate; this.hotpointRoot.matrixWorldAutoUpdate = false; this.hotpointRoot.name = "HotpointRoot"; this.scene?.add(this.hotpointRoot); } this.hotpointRoot.add(object); object.updateWorldMatrix(false, false); this.enableRender(); } /** * Removes a hotpoint by given hotpointId. * Caller should set a hotpointId that is unique in the session of current DxfViewer. */ removeHotpoint(hotpointId: string) { const objects = this.hotpointRoot?.children || []; for (let i = 0; i < objects.length; ++i) { const obj = objects[i]; if (obj.userData.hotpoint?.hotpointId === hotpointId) { obj.removeFromParent(); } } } /** * Clears all hotpoints. */ clearHotpoints() { this.hotpointRoot?.clear(); } /** * Checks if hotpoint with specific id already exist * Caller should set a hotpointId that is unique in the session of current DxfViewer. * @internal */ hasHotpoint(hotpointId: string): boolean { const objects = this.hotpointRoot?.children || []; return objects.findIndex((obj) => obj.userData.hotpoint?.hotpointId === hotpointId) !== -1; } /** * Enables or disable Composer * @internal */ public enableComposer(enable: boolean) { if (!this.scene || !this.camera || !this.renderer) { return; } this.composerEnabled = enable; if (enable && !this.composer) { this.composer = new EffectComposer(this.renderer); } this.enableRender(); } /** * Enables or disable RenderPass * @internal */ public enableRenderPass(enable: boolean) { if (!this.scene || !this.camera || !this.renderer || !this.composer) { return; } if (enable && !this.renderPass) { const pass = new RenderPass(this.scene, this.camera); pass.setSize(this.width, this.height); this.composer.addPass(pass); this.renderPass = pass; } if (this.renderPass) { this.renderPass.enabled = enable; } this.enableRender(); } /** * Enables or disable FxaaPass * @internal */ public enableFxaaPass(enable: boolean) { if (!this.scene || !this.camera || !this.renderer || !this.composer) { return; } if (enable && !this.effectFxaaPass) { const pass = new ShaderPass(FXAAShader); // eslint-disable-next-line pass.uniforms["resolution"].value.set(1 / this.width, 1 / this.height); pass.setSize(this.width, this.height); pass.renderToScreen = true; this.composer.addPass(pass); this.effectFxaaPass = pass; } if (this.effectFxaaPass) { this.effectFxaaPass.enabled = enable; } this.enableRender(); } /** * Enables or disable SAOPass * @internal */ public enableSaoPass(enable: boolean) { if (!this.scene || !this.camera || !this.renderer || !this.composer) { return; } if (enable && !this.saoPass) { const pass = new SAOPass(this.scene, this.camera, false, true, new THREE.Vector2(1 / this.width, 1 / this.height)); pass.setSize(this.width, this.height); // pass.renderToScreen = true // pass.resolution.set(1024, 1024) pass.params.output = 0; pass.params.saoBias = 0.5; // -1 - 1 pass.params.saoIntensity = 0.00005; // 0 - 1 pass.params.saoScale = 5; // 0 - 10 pass.params.saoKernelRadius = 40; // 1 - 100 pass.params.saoMinResolution = 0; // 0 - 1 // pass.params.saoBlur = true // pass.params.saoBlurRadius = 8 // 0 - 200 // pass.params.saoBlurStdDev = 4 // 0.5 - 150 // pass.params.saoBlurDepthCutoff = 0.01 // 0 - 0.1 this.composer.addPass(pass); this.saoPass = pass; } if (this.saoPass) { this.saoPass.enabled = enable; } this.enableRender(); } /** * Enables or disable SSAOPass * @internal */ public enableSsaoPass(enable: boolean) { if (!this.scene || !this.camera || !this.renderer || !this.composer) { return; } if (enable && !this.ssaoPass) { const pass = new SSAOPass(this.scene, this.camera, this.width, this.height); pass.kernelRadius = 16; pass.minDistance = 0.005; // 0.001 - 0.02 pass.maxDistance = 0.1; // 0.01 - 0.3 // pass.output = 0 // 'Default': 0, 'SSAO': 1, 'Blur': 2, 'Beauty': 3, 'Depth': 4, 'Normal': 5 this.composer.addPass(pass); this.ssaoPass = pass; } if (this.ssaoPass) { this.ssaoPass.enabled = enable; } this.enableRender(); } /** * Enables or disable OutlinePass * @internal */ public enableOutlinePass(enable: boolean) { if (!this.scene || !this.camera || !this.renderer || !this.composer) { return; } if (enable && !this.outlinePass) { const pass = new OutlinePass(new THREE.Vector2(this.width, this.height), this.scene, this.camera); pass.edgeStrength = 3; pass.edgeGlow = 0; pass.edgeThickness = 2; pass.pulsePeriod = 0; // 0: don't pulse // outlinePass.usePatternTexture = pass.visibleEdgeColor.set(0xff0000); pass.hiddenEdgeColor.set(0xffa080); this.composer.addPass(pass); this.outlinePass = pass; } if (this.outlinePass) { this.outlinePass.enabled = enable; } this.enableRender(); } /** * Enables or disable SSAARenderPass * @internal */ public enableSsaaPass(enable: boolean) { if (!this.scene || !this.camera || !this.renderer || !this.composer) { return; } if (enable && !this.ssaaRenderPass) { const pass = new SSAARenderPass(this.scene, this.camera, 0xffffff, 0); this.composer.addPass(pass); this.ssaaRenderPass = pass; } if (this.ssaaRenderPass) { this.ssaaRenderPass.enabled = enable; } this.enableRender(); } /** * Enables or disable BloomPass * @internal */ public enableBloomPass(enable: boolean) { if (!this.scene || !this.camera || !this.renderer || !this.composer) { return; } if (enable && !this.bloomPass) { const pass = new BloomPass( 1, // strength 25, // kernel size 4 // sigma ? ); pass.renderToScreen = true; // usually set it to true if it is the last pass this.composer.addPass(pass); this.bloomPass = pass; } if (this.bloomPass) { this.bloomPass.enabled = enable; } this.enableRender(); } /** * Enables or disable UnrealBloomPass * @internal */ public enableUnrealBloomPass(enable: boolean) { if (!this.scene || !this.camera || !this.renderer || !this.composer) { return; } if (enable && !this.unrealBloomPass) { const pass = new UnrealBloomPass(new THREE.Vector2(this.width, this.height), 1, 0, 0); pass.threshold = 0; pass.strength = 0.5; pass.radius = 0; this.composer.addPass(pass); this.unrealBloomPass = pass; } if (this.unrealBloomPass) { this.unrealBloomPass.enabled = enable; } this.enableRender(); } /** * Enable section. * Currently, it only implemented local(object) box section. */ public activateSection(type = SectionType.ObjectsBoxSection, clippingObjectIds?: number[]) { if (!this.inputManager || this.sectionManager?.isSectionActive()) { return; } this.sectionType = type; this.clearSelection(); if (!this.sectionManager) { this.sectionManager = new SectionManager(this, this.inputManager); } this.sectionManager.activateSection(type, clippingObjectIds); this.enableRender(); } /** * Deactivates section */ public deactivateSection() { this.sectionManager?.deactivateSection(); this.enableRender(); } /** * @internal */ setSectionClippingObjectIds(clippingObjectIds?: number[]) { this.sectionManager?.setSectionClippingObjectIds(clippingObjectIds); this.enableRender(); } /** * @internal */ getActiveSection() { return this.sectionManager?.getActiveSection(); } /** * @internal */ getMeasurementManager() { return this.measurementManager; } /** * Gets measurement data * @internal */ getMeasurements(): MeasurementData[] { if (!this.inputManager) { return []; } if (!this.measurementManager) { this.measurementManager = new MeasurementManager(this, this.inputManager); } const mm = this.measurementManager; return mm.getMeasurementsData(); } /** * Activates one of "Distance", "Area" or "Angle" measurement * @param type "Distance", "Area" or "Angle" */ activateMeasurement(type: MeasurementType) { if (!this.inputManager) { return; } if (!this.measurementManager) { this.measurementManager = new MeasurementManager(this, this.inputManager); } const mm = this.measurementManager; mm.activateMeasurement(type); this.clearSelection(); } /** * Deactivates measurement */ deactivateMeasurement() { this.measurementManager?.deactivateMeasurement(); } /** * @internal */ setMeasurementVisibility(id: string, visible: boolean): boolean { if (!this.inputManager || !this.measurementManager || !id) { return false; } const mm = this.measurementManager; return mm.setMeasurementVisibility(id, visible); } /** * Clears all measurement results */ clearMeasurements() { this.measurementManager?.clearMeasurements(); } /** * Zooms to selected box area. */ zoomToRect() { if (!this.zoomToRectHelper) { this.zoomToRectHelper = new ZoomToRectHelper(this); } this.zoomToRectHelper.activate(); } /** * @internal */ deactivateZoomRect() { this.zoomToRectHelper?.deactivate(); } /** * @internal */ public enableWebCam() { if (!this.scene) { return; } // for now, this is a demo. Just create a 5x4 plane and put it somewhere if (!this.webcam) { this.webcam = new WebCam(); } if (!this.webcamPlane) { this.webcamPlane = this.webcam.createWebCamPlane(); this.webcamPlane.position.set(10, 2, 0); } this.scene.add(this.webcamPlane); } /** * @internal */ public disableWebCam() { if (!this.scene) { return; } if (this.webcamPlane) { this.webcamPlane.geometry.dispose(); (this.webcamPlane.material as THREE.Material).dispose(); this.scene.remove(this.webcamPlane); } } /** * Sets environment for the scene. * @param hdrUrl Full path of picture url in hdr format */ public setEnvironment(hdrUrl: string) { TextureUtils.createEnvTexture(this.pmremGenerator, hdrUrl).then((texture) => { if (this.scene) { this.scene.environment = texture; } }); } /** * Sets environment for the scene. * @param data Uint16Array of the hdr content * @internal */ public setEnvironmentFromDataArray(data?: Uint16Array) { TextureUtils.createEnvTextureFromDataArray(this.pmremGenerator, data).then((texture) => { if (this.scene) { this.scene.environment = texture; } }); } async takeObjectsScreenshot(uniqueIds: string[]) { return new Promise((resolve, reject) => { if (!this.renderer) { reject("renderer is undefined"); } // const excludeObjectIds: string[] = []; this.scene?.traverse((object: THREE.Object3D) => { if (object instanceof THREE.Mesh) { if (includes(uniqueIds, get(object.userData, "UniqueId"))) { // excludeObjectIds.push(object.id); object.visible = true; } else { object.visible = false; } } }); // ObjectUtils.setObjectOpacity(this.scene, 0.1, undefined, excludeObjectIds); this.enableRender(); setTimeout(() => { resolve(this.renderer?.domElement.toDataURL("image/png")); }, 1000); }); } /** * Sets object to a specific color. Note that: * - The change is permanent, and cannot be recovered to the original color or material. * - If a material is shared, it may affect other objects. * @param color A color number in format of "0x000000" * @internal */ setObjectColor(object: THREE.Object3D, color: number) { // unselect any selected/highlighted object, otherwise, there maybe bug this.clearSelection(); // Find out all materials for the object. The key is material id. const materials: Record = {}; object.traverse((obj) => { const material = (obj as any).material; // eslint-disable-line if (!material) { return; } if (Array.isArray(material)) { material.forEach((mat: THREE.Material) => { materials[mat.id] = mat; }); } else { materials[material.id] = material; } }); // change color one by one for (const id in materials) { MaterialUtils.setMaterialColor(materials[id], new THREE.Color(color)); } this.enableRender(); } /** * Updates raycaster threshold to a proper value, so user can easily pick points and lines */ private updateRaycasterThreshold() { const camera = this.camera; if (!camera || !this.raycaster) { return; } // TODO: There are problems about line raycaster in OrthographicCamera. // Zoom need to be considered. const threshold = 12 / camera.zoom; const params = this.raycaster.params; if (!params.Line) { params.Line = { threshold: threshold }; } else { params.Line.threshold = threshold; } if (!params.Points) { params.Points = { threshold: threshold }; } else { params.Points.threshold = threshold; } // log.info(`[BimViewer] Raycaster threshold: ${threshold}`); } /** * Instatiates leaf nodes of given object. * If objects' geometry and material are the same, they can be instanced. * @param object */ private instantiate(object: THREE.Object3D) { new InstantiateHelper(object).instantiate(); } /** * Merges leaf nodes of given object. * If objects' materials are the same, they can be merged. * @param object */ private merge(object: THREE.Object3D) { this.increaseJobCount(); try { const objects: THREE.Object3D[] = []; object.traverse((obj) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any if ((obj as any).geometry && (obj as any).material) { // eslint-disable-line objects.push(obj); } }); MergeUtils.deepMerge(objects, object); } finally { this.decreaseJobCount(); } } /** * Updates project settings * @internal */ public updateProjectSettings(settings: SettingsType) { this.settings = settings; const updateCameraSettings = ( c: THREE.PerspectiveCamera | THREE.OrthographicCamera | undefined, cs: CameraSettings ) => { if (c && cs) { c.near = cs.near; c.far = cs.far; c.updateProjectionMatrix(); } }; updateCameraSettings(this.perspectiveCamera, this.settings.camera); updateCameraSettings(this.orthoCamera, this.settings.camera); this.enableRender(10); } /** * Compute bounding box of loaded models * @internal */ public computeBoundingBox(): THREE.Box3 { const bbox = new THREE.Box3(); Object.values(this.loadedModels).forEach((model: { id: number; bbox?: THREE.Box3 }) => { if (model.bbox && !model.bbox.isEmpty()) { bbox.union(model.bbox); } }); Object.values(this.loaded3dTiles).forEach((model: { id: number; bbox: THREE.Box3; renderer: TilesRenderer }) => { if (!model.bbox.isEmpty()) { bbox.union(model.bbox); } }); this.bbox = bbox; if (this.controls) { this.controls.minDistance = 0.1; this.controls.maxDistance = this.bbox.getSize(new THREE.Vector3()).length() * 3; } return bbox; } }