UNPKG

@inweb/viewer-three

Version:

JavaScript library for rendering CAD and BIM files in a browser using Three.js

914 lines (746 loc) 30.1 kB
/////////////////////////////////////////////////////////////////////////////// // Copyright (C) 2002-2025, Open Design Alliance (the "Alliance"). // All rights reserved. // // This software and its documentation and related materials are owned by // the Alliance. The software may only be incorporated into application // programs owned by members of the Alliance, subject to a signed // Membership Agreement and Supplemental Software License Agreement with the // Alliance. The structure and organization of this software are the valuable // trade secrets of the Alliance and its suppliers. The software is also // protected by copyright law and international treaty provisions. Application // programs incorporating this software must include the following statement // with their copyright notices: // // This application incorporates Open Design Alliance software pursuant to a // license agreement with Open Design Alliance. // Open Design Alliance Copyright (C) 2002-2025 by Open Design Alliance. // All rights reserved. // // By use of this software, its documentation or related materials, you // acknowledge and accept the above terms. /////////////////////////////////////////////////////////////////////////////// import { Box3, LinearSRGBColorSpace, // LinearToneMapping, Object3D, OrthographicCamera, PerspectiveCamera, Plane, Raycaster, Scene, Sphere, Vector2, Vector3, WebGLRenderer, } from "three"; import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js"; import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js"; import { FXAAPass } from "three/examples/jsm/postprocessing/FXAAPass.js"; import { SMAAPass } from "three/examples/jsm/postprocessing/SMAAPass.js"; import { SSAARenderPass } from "./postprocessing/SSAARenderPass.js"; import { OutputPass } from "three/examples/jsm/postprocessing/OutputPass.js"; import { EventEmitter2 } from "@inweb/eventemitter2"; import { Assembly, Client, Model, File } from "@inweb/client"; import { CANVAS_EVENTS, CanvasEventMap, FileSource, IClippingPlane, IComponent, IDragger, IEntity, IInfo, ILoader, Info, IOptions, IOrthogonalCamera, IPerspectiveCamera, IPoint, IViewer, IViewpoint, Options, OptionsEventMap, ViewerEventMap, } from "@inweb/viewer-core"; import { IMarkup, IWorldTransform, Markup } from "@inweb/markup"; import { draggers } from "./draggers"; import { commands } from "./commands"; import { components } from "./components"; import { loaders } from "./loaders"; import { IModelImpl } from "./models/IModelImpl"; import { Helpers } from "./scenes/Helpers"; /** * 3D viewer powered by {@link https://threejs.org/ | Three.js}. */ export class Viewer extends EventEmitter2<ViewerEventMap & CanvasEventMap & OptionsEventMap> implements IViewer, IWorldTransform { public client: Client | undefined; public options: IOptions; public canvas: HTMLCanvasElement | undefined; public canvasEvents: string[]; public loaders: ILoader[]; public models: IModelImpl[]; public info: IInfo; private canvaseventlistener: (event: any) => void; public scene: Scene | undefined; public helpers: Helpers | undefined; public camera: PerspectiveCamera | OrthographicCamera | undefined; public renderer: WebGLRenderer | undefined; public renderPass: RenderPass | undefined; public helpersPass: RenderPass | undefined; public fxaaPass: FXAAPass | undefined; public smaaPass: SMAAPass | undefined; public ssaaRenderPass: SSAARenderPass | undefined; public outputPass: OutputPass | undefined; public composer: EffectComposer | undefined; public selected: Object3D[]; public extents: Box3; public target: Vector3; private _activeDragger: IDragger | null; private _components: IComponent[]; private _updateDelay: number; private _renderNeeded: boolean; private _renderTime: DOMHighResTimeStamp; private _markup: IMarkup; /** * @param client - The `Client` instance that is used to load model reference files from the Open Cloud * Server. Do not specify `Client` if you need a standalone viewer instance to view `glTF` files from * the web or from local computer. */ constructor(client?: Client) { super(); this.client = client; this.options = new Options(this); this.loaders = []; this.models = []; this.info = new Info(); this.canvasEvents = CANVAS_EVENTS.slice(); this.canvaseventlistener = (event: Event) => this.emit(event); this.selected = []; this.extents = new Box3(); this.target = new Vector3(0, 0, 0); this._activeDragger = null; this._components = []; this._updateDelay = 1000; this._renderNeeded = false; this._renderTime = 0; this.render = this.render.bind(this); this.update = this.update.bind(this); this._markup = new Markup(); } /** * 2D markup core instance used to create markups. * * @readonly */ get markup(): IMarkup { return this._markup; } // IViewer get draggers(): string[] { return [...draggers.getDraggers().keys()]; } get components(): string[] { return [...components.getComponents().keys()]; } initialize(canvas: HTMLCanvasElement, onProgress?: (event: ProgressEvent<EventTarget>) => void): Promise<this> { this.addEventListener("optionschange", (event) => this.syncOptions(event.data)); this.scene = new Scene(); this.helpers = new Helpers(); const pixelRatio = window.devicePixelRatio; const rect = canvas.parentElement.getBoundingClientRect(); const width = rect.width || 1; const height = rect.height || 1; const aspectRatio = width / height; this.camera = new PerspectiveCamera(45, aspectRatio, 0.001, 1000); this.camera.up.set(0, 1, 0); this.camera.position.set(0, 0, 1); this.camera.lookAt(this.target); this.camera.updateProjectionMatrix(); this.renderer = new WebGLRenderer({ canvas, antialias: true, alpha: true, preserveDrawingBuffer: true, powerPreference: "high-performance", logarithmicDepthBuffer: true, }); this.renderer.setPixelRatio(pixelRatio); this.renderer.setSize(width, height); this.renderer.outputColorSpace = LinearSRGBColorSpace; // this.renderer.toneMapping = LinearToneMapping; this.renderPass = new RenderPass(this.scene, this.camera); this.helpersPass = new RenderPass(this.helpers, this.camera); this.helpersPass.clear = false; this.fxaaPass = new FXAAPass(); this.smaaPass = new SMAAPass(); this.ssaaRenderPass = new SSAARenderPass([this.scene, this.helpers], this.camera); this.ssaaRenderPass.unbiased = true; this.outputPass = new OutputPass(); this.composer = new EffectComposer(this.renderer); this.composer.addPass(this.renderPass); this.composer.addPass(this.helpersPass); this.composer.addPass(this.smaaPass); this.composer.addPass(this.fxaaPass); this.composer.addPass(this.ssaaRenderPass); this.composer.addPass(this.outputPass); this.composer.setSize(width, height); this.canvas = canvas; this.canvasEvents.forEach((x) => canvas.addEventListener(x, this.canvaseventlistener)); this._markup.initialize(this.canvas, this.canvasEvents, this, this); for (const name of components.getComponents().keys()) { this._components.push(components.createComponent(name, this)); } this.syncOptions(); this.syncOverlay(); this._renderTime = performance.now(); if (typeof onProgress === "function") { const event = new ProgressEvent("progress", { lengthComputable: true, loaded: 1, total: 1 }); onProgress(event); } this.emitEvent({ type: "initializeprogress", data: 1, loaded: 1, total: 1 }); this.emitEvent({ type: "initialize" }); this.update(true); return Promise.resolve(this); } dispose(): this { this.cancel(); this.clear(); this.emitEvent({ type: "dispose" }); this.removeAllListeners(); this.setActiveDragger(); this._components.forEach((component: IComponent) => component.dispose()); this._components = []; this._markup.dispose(); if (this.canvas) { this.canvasEvents.forEach((x) => this.canvas.removeEventListener(x, this.canvaseventlistener)); this.canvas = undefined; } if (this.composer) this.composer.dispose(); if (this.renderPass) this.renderPass.dispose(); if (this.helpersPass) this.helpersPass.dispose(); if (this.fxaaPass) this.fxaaPass.dispose(); if (this.smaaPass) this.smaaPass.dispose(); if (this.ssaaRenderPass) this.ssaaRenderPass.dispose(); if (this.outputPass) this.outputPass.dispose(); if (this.renderer) this.renderer.dispose(); this.scene = undefined; this.helpers = undefined; this.camera = undefined; this.renderer = undefined; this.renderPass = undefined; this.helpersPass = undefined; this.fxaaPass = undefined; this.smaaPass = undefined; this.ssaaRenderPass = undefined; this.outputPass = undefined; this.composer = undefined; return this; } isInitialized(): boolean { return !!this.renderer; } setSize(width: number, height: number, updateStyle = true) { if (!this.renderer) return; const camera = this.camera as any; const aspectRatio = width / height; if (camera.isPerspectiveCamera) { camera.aspect = aspectRatio; camera.updateProjectionMatrix(); } if (camera.isOrthographicCamera) { camera.left = camera.bottom * aspectRatio; camera.right = camera.top * aspectRatio; camera.updateProjectionMatrix(); } this.renderer.setSize(width, height, updateStyle); this.composer.setSize(width, height); this.emitEvent({ type: "resize", width, height }); this.update(true); } update(force = false): void { const time = performance.now(); force = force || time - this._renderTime >= this._updateDelay; this._renderNeeded = true; if (force) this.render(time); this.emitEvent({ type: "update", force }); } // Internal render routines render(time?: DOMHighResTimeStamp, force = false): void { if (!this.renderer) return; if (!this._renderNeeded && !force) return; if (!time) time = performance.now(); const deltaTime = (time - this._renderTime) / 1000; this._renderTime = time; this._renderNeeded = false; this.renderer.info.autoReset = false; this.renderer.info.reset(); if (this.options.antialiasing === true || this.options.antialiasing === "msaa") { this.renderer.render(this.scene, this.camera); this.renderer.render(this.helpers, this.camera); } else { this.composer.render(deltaTime); } this._activeDragger?.updatePreview?.(); this.emitEvent({ type: "render", time, deltaTime }); } // Internal loading routines loadReferences(model: Model | File | Assembly): Promise<this> { // todo: load reference as text fonts return Promise.resolve(this); } /** * Loads a file into the viewer. * * The viewer must be {@link initialize | initialized} before opening the file. Otherwise, `open()` does * nothing. * * This method requires a `Client` instance to be specified to load file from the Open Cloud Server. * The file geometry data on the Open Cloud Server must be converted into a `gltf` format, otherwise an * exception will be thrown. * * For files from Open Cloud Server, the default model will be loaded. If there is no default model, * the first available model will be loaded. If no models are found in the file, an exception will be * thrown. * * For URLs, the file extension is used to determine the file format. For a `ArrayBuffer` and `Data * URL`, a file format must be specified using `params.format` parameter. If no appropriate loader is * found for the specified format, an exception will be thrown. * * If there was an active dragger before opening the file, it will be deactivated. After opening the * file, you must manually activate the required dragger. * * Fires: * * - {@link CancelEvent | cancel} * - {@link ClearEvent | clear} * - {@link OpenEvent | open} * - {@link GeometryStartEvent | geometrystart} * - {@link GeometryProgressEvent | geometryprogress} * - {@link DatabaseChunkEvent | databasechunk} * - {@link GeometryChunkEvent | geometrychunk} * - {@link GeometryEndEvent | geometryend} * - {@link GeometryErrorEvent | geometryerror} * * @param file - File to load. Can be: * * - `File`, `Assembly` or `Model` instance from the Open Cloud Server * - `URL` string * - {@link https://developer.mozilla.org/docs/Web/HTTP/Basics_of_HTTP/Data_URIs | Data URL} string * - {@link https://developer.mozilla.org/docs/Web/API/File | Web API File} object * - {@link https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer | ArrayBuffer} * object * * @param params - Loading parameters. * @param params.format - File format. Can be `gltf`, `glb` or format from an extension. Required when * loading a file as `ArrayBuffer` or `Data URL`. * @param params.mode - File opening mode. Can be one of: * * - `file` - Single file mode. Unloads an open file and opens a new one. This is default mode. * - `assembly` - Assembly mode. Appends a file to an already open file. * * @param params.modelId - Unique model ID in the assembly (multi-model scene). Used as a model prefix * when selecting objects (see {@link getSelected2}). Must not contain the ":" (colon). Required when * loading a file as `ArrayBuffer` or `Data URL`. * @param params.requestHeader - The * {@link https://developer.mozilla.org/docs/Glossary/Request_header | request header} used in HTTP * request. * @param params.withCredentials - Whether the HTTP request uses credentials such as cookies, * authorization headers or TLS client certificates. See * {@link https://developer.mozilla.org/docs/Web/API/XMLHttpRequest/withCredentials | XMLHttpRequest.withCredentials}. * @param params.path - The base path from which external resources such as binary data buffers, images * or textures will be loaded. If not defined, the base path of the file URL will be used. * @param params.externalFiles - External resource map. Each resource should be represented by a `uri` * and a corresponding resource URL, or * {@link https://developer.mozilla.org/docs/Web/HTTP/Basics_of_HTTP/Data_URIs | Data URL} string, or * {@link https://developer.mozilla.org/docs/Web/API/File | Web API File} object, or * {@link https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer | ArrayBuffer}. * @param params.crossOrigin - The crossOrigin string to implement CORS for loading the external * resources from a different domain that allows CORS. Default is `anonymous`. */ async open( file: FileSource, params: { format?: string; mode?: string; modelId?: string; requestHeader?: HeadersInit; withCredentials?: boolean; path?: string; externalFiles?: Map<string, string | globalThis.File | ArrayBuffer>; crossOrigin?: string; } = {} ): Promise<this> { if (!this.renderer) return this; const mode = params.mode || "file"; if (mode !== "assembly" && mode !== "a" && mode !== "append") { this.cancel(); this.clear(); } this.emitEvent({ type: "open", mode, file }); let model: any = file; if (model && typeof model.getModels === "function") { const models = await model.getModels(); model = models.find((model: Model) => model.default) || models[0] || file; } if (model && typeof model.database === "string") { file = model.file; } if (!model) throw new Error(`Format not supported`); let format = params.format; if (!format && typeof file["type"] === "string") format = file["type"].split(".").pop(); if (!format && typeof file === "string") format = file.split(".").pop(); if (!format && file instanceof globalThis.File) format = file.name.split(".").pop(); const loader: ILoader = loaders.createLoader(this, model, format); if (!loader) throw new Error(`Format not supported`); this.loaders.push(loader); this.emitEvent({ type: "geometrystart", file, model }); try { await this.loadReferences(model); await loader.load(model, format, params); } catch (error: any) { this.emitEvent({ type: "geometryerror", data: error, file, model }); throw error; } this.emitEvent({ type: "geometryend", file, model }); this.update(true); return this; } /** * Deprecated since `26.4`. Use {@link open | open()} instead. * * @deprecated */ openGltfFile(file, externalFiles, params: any = {}): Promise<this> { console.warn( "Viewer.openGltfFile() has been deprecated since 26.4 and will be removed in a future release, use Viewer.open() instead." ); return this.open(file, { ...params, format: "gltf", externalFiles }); } /** * Deprecated since `26.4`. Use {@link open | open()} instead. * * @deprecated */ loadGltfFile(file, externalFiles, params: any = {}): Promise<this> { console.warn( "Viewer.loadGltfFile() has been deprecated since 26.4 and will be removed in a future release, use Viewer.open() instead." ); return this.open(file, { ...params, format: "gltf", externalFiles, mode: "assembly" }); } cancel(): this { this.loaders.forEach((loader) => loader.cancel()); this.emitEvent({ type: "cancel" }); return this; } clear(): this { if (!this.renderer) return this; this.setActiveDragger(); this.clearSlices(); this.clearOverlay(); this.clearSelected(); this.loaders.forEach((loader) => loader.dispose()); this.loaders = []; this.models.forEach((model) => model.dispose()); this.models = []; this.scene.clear(); this.helpers.clear(); this.extents.makeEmpty(); this.target.set(0, 0, 0); this.syncOptions(); this.syncOverlay(); this.emitEvent({ type: "clear" }); this.update(true); return this; } is3D(): boolean { return true; } syncOptions(options: IOptions = this.options): void { if (!this.renderer) return; this.fxaaPass.enabled = options.antialiasing === "fxaa"; this.smaaPass.enabled = options.antialiasing === "smaa"; this.ssaaRenderPass.enabled = options.antialiasing === "ssaa"; this.renderPass.enabled = !this.ssaaRenderPass.enabled; this.helpersPass.enabled = !this.ssaaRenderPass.enabled; this.update(); } syncOverlay(): void { if (!this.renderer) return; this._markup.syncOverlay(); this.update(); } clearOverlay(): void { if (!this.renderer) return; this._markup.clearOverlay(); this.update(); } clearSlices(): void { if (!this.renderer) return; this.renderer.clippingPlanes = []; this.update(); } getSelected(): string[] { return this.executeCommand("getSelected"); } getSelected2(): string[] { return this.executeCommand("getSelected2"); } setSelected(handles?: string[]): void { this.executeCommand("setSelected", handles); } setSelected2(handles?: string[]): void { this.executeCommand("setSelected2", handles); } clearSelected(): void { this.executeCommand("clearSelected"); } hideSelected(): void { this.executeCommand("hideSelected"); } isolateSelected(): void { this.executeCommand("isolateSelected"); } showAll(): void { this.executeCommand("showAll"); } explode(index = 0): void { this.executeCommand("explode", index); } collect(): void { this.executeCommand("collect"); } activeDragger(): IDragger | null { return this._activeDragger; } setActiveDragger(name = ""): IDragger | null { if (!this._activeDragger || this._activeDragger.name !== name) { const oldDragger = this._activeDragger; let newDragger = null; if (this._activeDragger) { this._activeDragger.dispose(); this._activeDragger = null; } if (this.isInitialized()) { newDragger = draggers.createDragger(name, this); if (newDragger) { this._activeDragger = newDragger; this._activeDragger.initialize?.(); } } const canvas = this.canvas; if (canvas) { if (oldDragger) canvas.classList.remove(`oda-cursor-${oldDragger.name.toLowerCase()}`); if (newDragger) canvas.classList.add(`oda-cursor-${newDragger.name.toLowerCase()}`); } this.emitEvent({ type: "changeactivedragger", data: name }); this.update(); } return this._activeDragger; } resetActiveDragger(): void { const dragger = this._activeDragger; if (dragger) { this.setActiveDragger(); this.setActiveDragger(dragger.name); } } getComponent(name: string): IComponent { return this._components.find((component) => component.name === name); } drawViewpoint(viewpoint: IViewpoint): void { if (!this.renderer) return; const getVector3FromPoint3d = ({ x, y, z }): Vector3 => new Vector3(x, y, z); const setOrthogonalCamera = (orthogonal_camera: IOrthogonalCamera) => { if (orthogonal_camera) { const extentsSize = this.extents.getBoundingSphere(new Sphere()).radius * 2; const rendererSize = this.renderer.getSize(new Vector2()); const aspectRatio = rendererSize.x / rendererSize.y; const camera = new OrthographicCamera(); camera.top = orthogonal_camera.field_height / 2; camera.bottom = -orthogonal_camera.field_height / 2; camera.left = camera.bottom * aspectRatio; camera.right = camera.top * aspectRatio; camera.near = 0; camera.far = extentsSize * 1000; camera.zoom = orthogonal_camera.view_to_world_scale; camera.updateProjectionMatrix(); camera.up.copy(getVector3FromPoint3d(orthogonal_camera.up_vector)); camera.position.copy(getVector3FromPoint3d(orthogonal_camera.view_point)); camera.lookAt(getVector3FromPoint3d(orthogonal_camera.direction).add(camera.position)); camera.updateMatrixWorld(); this.camera = camera; this.renderPass.camera = camera; this.helpersPass.camera = camera; this.ssaaRenderPass.camera = camera; this.options.cameraMode = "orthographic"; this.emitEvent({ type: "changecameramode", mode: "orthographic" }); } }; const setPerspectiveCamera = (perspective_camera: IPerspectiveCamera) => { if (perspective_camera) { const extentsSize = this.extents.getBoundingSphere(new Sphere()).radius * 2; const rendererSize = this.renderer.getSize(new Vector2()); const aspectRatio = rendererSize.x / rendererSize.y; const camera = new PerspectiveCamera(); camera.fov = perspective_camera.field_of_view; camera.aspect = aspectRatio; camera.near = extentsSize / 1000; camera.far = extentsSize * 1000; camera.updateProjectionMatrix(); camera.up.copy(getVector3FromPoint3d(perspective_camera.up_vector)); camera.position.copy(getVector3FromPoint3d(perspective_camera.view_point)); camera.lookAt(getVector3FromPoint3d(perspective_camera.direction).add(camera.position)); camera.updateMatrixWorld(); this.camera = camera; this.renderPass.camera = camera; this.helpersPass.camera = camera; this.ssaaRenderPass.camera = camera; this.options.cameraMode = "perspective"; this.emitEvent({ type: "changecameramode", mode: "perspective" }); } }; const setClippingPlanes = (clipping_planes: IClippingPlane[]) => { if (clipping_planes) { clipping_planes.forEach((clipping_plane) => { const plane = new Plane(); plane.setFromNormalAndCoplanarPoint( getVector3FromPoint3d(clipping_plane.direction), getVector3FromPoint3d(clipping_plane.location) ); this.renderer.clippingPlanes.push(plane); }); } }; const setSelection = (selection: IEntity[]) => { if (selection) this.setSelected(selection.map((component) => component.handle)); }; const draggerName = this._activeDragger?.name; this.setActiveDragger(); this.clearSlices(); this.clearOverlay(); this.clearSelected(); this.showAll(); this.explode(); setOrthogonalCamera(viewpoint.orthogonal_camera); setPerspectiveCamera(viewpoint.perspective_camera); setClippingPlanes(viewpoint.clipping_planes); setSelection(viewpoint.custom_fields?.selection2 || viewpoint.selection); this._markup.setViewpoint(viewpoint); this.target.copy(getVector3FromPoint3d(viewpoint.custom_fields?.camera_target ?? this.target)); this.syncOverlay(); this.setActiveDragger(draggerName); this.emitEvent({ type: "drawviewpoint", data: viewpoint }); this.update(true); } createViewpoint(): IViewpoint { if (!this.renderer) return {}; const getPoint3dFromVector3 = ({ x, y, z }): IPoint => ({ x, y, z }); const getOrthogonalCamera = (): IOrthogonalCamera => { if (this.camera["isOrthographicCamera"]) return { view_point: getPoint3dFromVector3(this.camera.position), direction: getPoint3dFromVector3(this.camera.getWorldDirection(new Vector3())), up_vector: getPoint3dFromVector3(this.camera.up), field_width: this.camera["right"] - this.camera["left"], field_height: this.camera["top"] - this.camera["bottom"], view_to_world_scale: this.camera.zoom, }; else return undefined; }; const getPerspectiveCamera = (): IPerspectiveCamera => { if (this.camera["isPerspectiveCamera"]) return { view_point: getPoint3dFromVector3(this.camera.position), direction: getPoint3dFromVector3(this.camera.getWorldDirection(new Vector3())), up_vector: getPoint3dFromVector3(this.camera.up), field_of_view: this.camera["fov"], }; else return undefined; }; const getClippingPlanes = (): IClippingPlane[] => { const clipping_planes = []; this.renderer.clippingPlanes.forEach((plane: Plane) => { const clipping_plane = { location: getPoint3dFromVector3(plane.coplanarPoint(new Vector3())), direction: getPoint3dFromVector3(plane.normal), }; clipping_planes.push(clipping_plane); }); return clipping_planes; }; const getSelection = (): IEntity[] => { return this.getSelected().map((handle) => ({ handle })); }; const getSelection2 = (): IEntity[] => { return this.getSelected2().map((handle) => ({ handle })); }; const viewpoint: IViewpoint = { custom_fields: {} }; viewpoint.orthogonal_camera = getOrthogonalCamera(); viewpoint.perspective_camera = getPerspectiveCamera(); viewpoint.clipping_planes = getClippingPlanes(); viewpoint.selection = getSelection(); viewpoint.description = new Date().toDateString(); this._markup.getViewpoint(viewpoint); viewpoint.custom_fields.camera_target = getPoint3dFromVector3(this.target); viewpoint.custom_fields.selection2 = getSelection2(); this.emitEvent({ type: "createviewpoint", data: viewpoint }); return viewpoint; } // IWorldTransform screenToWorld(position: { x: number; y: number }): { x: number; y: number; z: number } { if (!this.renderer) return { x: position.x, y: position.y, z: 0 }; const rect = this.canvas.getBoundingClientRect(); const x = position.x / (rect.width / 2) - 1; const y = -position.y / (rect.height / 2) + 1; // ===================== AI-CODE-START ====================== // Source: Claude Sonnet 4.5 // Date: 2025-11-25 // Reviewer: vitaly.ivanov@opendesign.com // Issue: CLOUD-5990 if (this.camera["isPerspectiveCamera"]) { // Create a raycaster from the screen position const raycaster = new Raycaster(); const mouse = new Vector2(x, y); raycaster.setFromCamera(mouse, this.camera); // Create a plane perpendicular to the camera direction at the target point const cameraDirection = new Vector3(); this.camera.getWorldDirection(cameraDirection); const targetPlane = new Plane().setFromNormalAndCoplanarPoint(cameraDirection, this.target); // Intersect the ray with the target plane const intersectionPoint = new Vector3(); raycaster.ray.intersectPlane(targetPlane, intersectionPoint); // If intersection fails (ray parallel to plane), fallback to near plane unprojection if (!intersectionPoint) { const point = new Vector3(x, y, -1); point.unproject(this.camera); return { x: point.x, y: point.y, z: point.z }; } return { x: intersectionPoint.x, y: intersectionPoint.y, z: intersectionPoint.z }; } // ===================== AI-CODE-END ====================== const point = new Vector3(x, y, -1); point.unproject(this.camera); return { x: point.x, y: point.y, z: point.z }; } worldToScreen(position: { x: number; y: number; z: number }): { x: number; y: number } { if (!this.renderer) return { x: position.x, y: position.y }; const point = new Vector3(position.x, position.y, position.z); point.project(this.camera); const rect = this.canvas.getBoundingClientRect(); const x = (point.x + 1) * (rect.width / 2); const y = (-point.y + 1) * (rect.height / 2); return { x, y }; } getScale(): { x: number; y: number; z: number } { return { x: 1, y: 1, z: 1 }; } // ICommandService executeCommand(id: string, ...args: any[]): any { return commands.executeCommand(id, this, ...args); } }