@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
text/typescript
///////////////////////////////////////////////////////////////////////////////
// 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);
}
}