@inweb/viewer-visualize
Version:
JavaScript library for rendering CAD and BIM files in a browser using VisualizeJS
1,423 lines (1,147 loc) • 46 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 { EventEmitter2 } from "@inweb/eventemitter2";
import { Assembly, Client, File, Model } from "@inweb/client";
import {
CANVAS_EVENTS,
CanvasEventMap,
Dragger,
FileSource,
IClippingPlane,
IComponent,
IDragger,
IEntity,
IInfo,
ILoader,
Info,
IOrthogonalCamera,
IOptions,
IPerspectiveCamera,
IPoint,
IViewer,
IViewpoint,
Options,
OptionsEventMap,
ViewerEventMap,
} from "@inweb/viewer-core";
import { IMarkup, IWorldTransform } 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 { loadVisualizeJs } from "./utils";
import { MarkupFactory, MarkupType } from "./Markup/MarkupFactory";
const OVERLAY_VIEW_NAME = "$OVERLAY_VIEW_NAME";
const isExist = (value) => value !== undefined && value !== null;
/**
* 3D viewer powered by {@link https://cloud.opendesign.com/docs/index.html#/visualizejs | VisualizeJS}.
*/
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: Event) => void;
private _visualizeJsUrl = "";
private _visualizeJs: any;
private _visualizeTimestamp: number;
private _viewer: any;
private _crossOrigin;
private _activeDragger: IDragger | null;
private _components: IComponent[];
private _updateDelay: number;
private _renderNeeded: boolean;
private _renderTime: DOMHighResTimeStamp;
private _enableAutoUpdate: boolean;
private _isRunAsyncUpdate: boolean;
public _abortControllerForReferences: AbortController | undefined;
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 `VSFX` files from
* the web or from local computer.
* @param params - An object containing viewer configuration parameters.
* @param params.visualizeJsUrl - `VisualizeJS` library URL. Set this URL to use your own library
* instance, or specify `undefined` or blank to use the default URL defined by `Viewer.visualize`
* library you are using.
* @param params.crossOrigin - The
* {@link https://developer.mozilla.org/docs/Web/HTML/Attributes/crossorigin | crossorigin} content
* attribute on `Visalize.js` script element. One of the following values: `""`, `anonymous` or
* `use-credentials`.
* @param params.enableAutoUpdate - Enable auto-update of the viewer after any changes. If the
* auto-update is disabled, you need to register an `update` event handler and update the
* `VisualizeJS` viewer and active dragger manually. Default is `true`.
* @param params.markupType - The type of the markup core: `Visualize` (deprecated) or `Konva`. Default
* is `Konva`.
*/
constructor(
client?: Client,
params: { visualizeJsUrl?: string; crossOrigin?: string; enableAutoUpdate?: boolean; markupType?: MarkupType } = {}
) {
super();
this.configure(params);
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._activeDragger = null;
this._components = [];
this._updateDelay = 1000;
this._renderNeeded = false;
this._renderTime = 0;
this._enableAutoUpdate = params.enableAutoUpdate ?? true;
this._isRunAsyncUpdate = false;
this.render = this.render.bind(this);
this.update = this.update.bind(this);
this._markup = MarkupFactory.createMarkup(params.markupType);
}
/**
* `VisualizeJS` library URL. Use {@link configure | configure()} to change library URL.
*
* @readonly
*/
get visualizeJsUrl(): string {
return this._visualizeJsUrl;
}
/**
* Returns `VisualizeJS` {@link https://cloud.opendesign.com/docs/index.html#/visualizejs_api | module}
* instance.
*/
get visualizeJs(): any {
return this._visualizeJs;
}
/**
* Returns `VisualizeJS` {@link https://cloud.opendesign.com/docs/index.html#/visualizejs_api | module}
* instance.
*/
visLib(): any {
return this._visualizeJs;
}
/**
* Returns `VisualizeJS` {@link https://cloud.opendesign.com/docs/index.html#/vis/Viewer | Viewer}
* instance.
*/
visViewer(): any {
return this._viewer;
}
/**
* 2D markup core instance used to create markups.
*
* @readonly
*/
get markup(): IMarkup {
return this._markup;
}
/**
* Changes the viewer parameters.
*
* @param params - An object containing new parameters.
* @param params.visualizeJsUrl - `VisualizeJS` library URL. Set this URL to use your own library
* instance or specify `undefined` or blank to use the default URL defined by `Viewer.visualize`
* library you are using.
* @param params.crossOrigin - The
* {@link https://developer.mozilla.org/docs/Web/HTML/Attributes/crossorigin | crossorigin} content
* attribute on `Visalize.js` script element. One of the following values: `""`, `anonymous` or
* `use-credentials`.
*/
configure(params: { visualizeJsUrl?: string; crossOrigin?: string }): this {
this._visualizeJsUrl = params.visualizeJsUrl || "VISUALIZE_JS_URL";
this._crossOrigin = params.crossOrigin;
return this;
}
// IViewer
get draggers(): string[] {
return [...draggers.getDraggers().keys()];
}
get components(): string[] {
return [...components.getComponents().keys()];
}
/**
* Loads the `VisualizeJS` module and initializes it with the specified canvas. Call
* {@link dispose | dispose()} to release allocated resources.
*
* Fires:
*
* - {@link InitializeEvent | initialize}
* - {@link InitializeProgressEvent | initializeprogress}
*
* @param canvas -
* {@link https://developer.mozilla.org/docs/Web/API/HTMLCanvasElement | HTMLCanvasElement} for
* `VisualizeJS`.
* @param onProgress - A callback function that handles events measuring progress of loading of the
* `VisualizeJS` library.
*/
async initialize(canvas: HTMLCanvasElement, onProgress?: (event: ProgressEvent) => void): Promise<this> {
this.addEventListener("optionschange", (event) => this.syncOptions(event.data));
const pixelRatio = window.devicePixelRatio;
const rect = canvas.parentElement.getBoundingClientRect();
const width = rect.width || 1;
const height = rect.height || 1;
canvas.width = Math.round(width * pixelRatio);
canvas.height = Math.round(height * pixelRatio);
canvas.style.width = width + "px";
canvas.style.height = height + "px";
canvas.parentElement.style.touchAction = "none";
canvas.style.touchAction = "none";
this._visualizeTimestamp = Date.now();
const visualizeTimestamp = this._visualizeTimestamp;
const visualizeJs: any = await loadVisualizeJs(
this.visualizeJsUrl,
(event: ProgressEvent) => {
const { loaded, total } = event;
if (onProgress) onProgress(new ProgressEvent("progress", { lengthComputable: true, loaded, total }));
this.emitEvent({ type: "initializeprogress", data: loaded / total, loaded, total });
},
{ crossOrigin: this._crossOrigin }
);
if (visualizeTimestamp !== this._visualizeTimestamp)
throw new Error(
"Viewer error: dispose() was called before initialize() completed. Are you using React strict mode?"
);
this._visualizeJs = visualizeJs;
this._visualizeJs.canvas = canvas;
this._viewer = visualizeJs.Viewer.create();
this._viewer.resize(0, canvas.width, canvas.height, 0);
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();
this.emitEvent({ type: "initialize" });
this.update(true);
return 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._viewer) this._viewer.clear();
this._visualizeJs = undefined;
this._visualizeTimestamp = undefined;
this._viewer = undefined;
return this;
}
/**
* Returns `true` if `VisualizeJS` module has been loaded and initialized.
*/
isInitialized(): boolean {
return !!this.visualizeJs;
}
setSize(width: number, height: number, updateStyle = true): void {
if (!this.visualizeJs) return;
this.canvas.width = Math.round(width * window.devicePixelRatio);
this.canvas.height = Math.round(height * window.devicePixelRatio);
if (updateStyle) {
this.canvas.style.width = width + "px";
this.canvas.style.height = height + "px";
}
this._viewer.resize(0, this.canvas.width, this.canvas.height, 0);
this.emitEvent({ type: "resize", width, height });
this.update(true);
}
resize(): this {
console.warn(
"Viewer.resize() has been deprecated since 26.9 and will be removed in a future release, use Viewer.setSize() instead."
);
if (!this.visualizeJs) return this;
if (!this.canvas.parentElement) return this;
const { width, height } = this.canvas.parentElement.getBoundingClientRect();
if (!width || !height) return this; // <- invisible viewer, or viewer with parent removed
this.setSize(width, height);
return this;
}
/**
* Updates the viewer.
*
* Do nothing if the auto-update mode is disabled in the constructor. In this case, register an
* `update` event handler and update the `Visualize` viewer and active dragger manually.
*
* Fires:
*
* - {@link UpdateEvent | update}
*
* @param force - If `true` updates the viewer immidietly. Otherwise updates on next animation frame.
* Default is `false`.
*/
update(force = false) {
const time = performance.now();
force = force || time - this._renderTime >= this._updateDelay;
if (this._enableAutoUpdate) {
this._renderNeeded = true;
if (force) this.render(time);
}
this.emitEvent({ type: "update", force });
}
// Internal render routines
render(time?: DOMHighResTimeStamp) {
if (!this.visualizeJs) return;
if (this._isRunAsyncUpdate) return;
const renderNeeded = this.visViewer().isRunningAnimation() || this._renderNeeded;
if (!renderNeeded) return;
if (!time) time = performance.now();
const deltaTime = (time - this._renderTime) / 1000;
this._renderTime = time;
this._renderNeeded = !this.visViewer().getActiveDevice().isValid();
this.visViewer().update();
this._activeDragger?.updatePreview?.();
this.emitEvent({ type: "render", time, deltaTime });
}
// Internal loading routines
async loadReferences(model: Model | File | Assembly): Promise<this> {
if (!this.visualizeJs) return this;
if (!this.client) return this;
if (!model.getReferences) return this;
const abortController = new AbortController();
this._abortControllerForReferences?.abort();
this._abortControllerForReferences = abortController;
let references: any[] = [];
await model
.getReferences(abortController.signal)
.then((data) => (references = data.references))
.catch((e) => console.error("Cannot load model references.", e));
for (const file of references) {
await this.client
.downloadFile(file.id, undefined, abortController.signal)
.then((arrayBuffer) => this.visualizeJs?.getViewer().addEmbeddedFile(file.name, new Uint8Array(arrayBuffer)))
.catch((e) => console.error(`Cannot load reference file ${file.name}.`, e));
}
return this;
}
applyModelTransformMatrix(model: Model | Assembly) {
if (!this.visualizeJs) return;
this.executeCommand("applyModelTransform", model);
}
applySceneGraphSettings(options = this.options) {
if (!this.visualizeJs) return;
const visLib = this.visLib();
const visViewer = this.visViewer();
const device = visViewer.getActiveDevice();
if (isExist(options.sceneGraph)) {
device.setOptionBool(visLib.DeviceOptions.kDelaySceneGraphProc, !options.sceneGraph);
}
// if (options.enablePartialMode && visLib.HpTrc.Usd >= visViewer.memoryLimit) {
// device.setOptionBool(visLib.DeviceOptions.kDelaySceneGraphProc, true);
// }
device.delete();
this.update();
}
/**
* 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 `vsfx` 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,
* first availiable 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.
*
* To open a large files, enable {@link IOptions.enablePartialMode | partial streaming} mode before
* opening. Partial streaming is only supported when opening files from an Open Cloud Server, but not
* local files and URLs. Example:
*
* ```javascript
* viewer.options.enableStreamingMode = true;
* viewer.options.enablePartialMode = true;
* await viewer.open(file);
* ```
*
* 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 dFile} 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 one of `vsf` or `vsfx`. Required when loading a file as
* `ArrayBuffer` or `Data URL`.
* @param params.mode - Reserved for future use.
* @param params.modelId - Reserved for future use.
* @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}
* for more details.
*/
async open(
file: FileSource,
params: {
format?: string;
mode?: string;
modelId?: string;
requestHeader?: HeadersInit;
withCredentials?: boolean;
} = {}
): Promise<this> {
if (!this.visualizeJs) return this;
this.cancel();
this.clear();
this.emitEvent({ type: "open", mode: "file", 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 = 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);
this.applyModelTransformMatrix(model);
this.applySceneGraphSettings();
} 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
*/
openVsfFile(buffer: Uint8Array | ArrayBuffer): this {
console.warn(
"Viewer.openVsfFile() has been deprecated since 26.4 and will be removed in a future release, use Viewer.open() instead."
);
if (!this.visualizeJs) return this;
this.cancel();
this.clear();
this.emitEvent({ type: "open", mode: "file", file: "", buffer });
const visViewer = this.visViewer();
this.emitEvent({ type: "geometrystart", file: "", buffer });
try {
const data = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
visViewer.parseFile(data);
this.syncOptions();
this.syncOverlay();
this.emitEvent({ type: "geometryprogress", data: 1, file: "", buffer });
this.emitEvent({ type: "databasechunk", data, file: "", buffer });
} catch (error: any) {
this.emitEvent({ type: "geometryerror", data: error, file: "", buffer });
throw error;
}
this.emitEvent({ type: "geometryend", file: "", buffer });
this.update(true);
return this;
}
/**
* Deprecated since `26.4`. Use {@link open | open()} instead.
*
* @deprecated
*/
openVsfxFile(buffer: Uint8Array | ArrayBuffer): this {
console.warn(
"Viewer.openVsfxFile() has been deprecated since 26.4 and will be removed in a future release, use Viewer.open() instead."
);
if (!this.visualizeJs) return this;
this.cancel();
this.clear();
this.emitEvent({ type: "open", mode: "file", file: "", buffer });
const visViewer = this.visViewer();
this.emitEvent({ type: "geometrystart", file: "", buffer });
try {
const data = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
visViewer.parseVsfx(data);
this.syncOptions();
this.syncOverlay();
this.emitEvent({ type: "geometryprogress", data: 1, file: "", buffer });
this.emitEvent({ type: "databasechunk", data, file: "", buffer });
} catch (error: any) {
this.emitEvent({ type: "geometryerror", data: error, file: "", buffer });
throw error;
}
this.emitEvent({ type: "geometryend", file: "", buffer });
this.update(true);
return this;
}
cancel(): this {
this._abortControllerForReferences?.abort();
this._abortControllerForReferences = undefined;
this.loaders.forEach((loader) => loader.cancel());
this.emitEvent({ type: "cancel" });
return this;
}
clear(): this {
if (!this.visualizeJs) return this;
const visViewer = this.visViewer();
this.setActiveDragger();
this.clearSlices();
this.clearOverlay();
this.clearSelected();
this.loaders.forEach((loader) => loader.dispose());
this.loaders = [];
this.models.forEach((model) => model.dispose());
this.models = [];
visViewer.clear();
visViewer.createLocalDatabase();
this.syncOptions();
this.syncOverlay();
this.emitEvent({ type: "clear" });
this.update(true);
return this;
}
is3D(): boolean {
if (!this.visualizeJs) return false;
const visViewer = this.visViewer();
const ext = visViewer.getActiveExtents();
const min = ext.min();
const max = ext.max();
const extHeight = max[2] - min[2];
return extHeight !== 0;
//return visViewer.activeView.upVector[1] >= 0.95;
}
syncOptions(options: IOptions = this.options): this {
if (!this.visualizeJs) return this;
const visLib = this.visLib();
const visViewer = this.visViewer();
const device = visViewer.getActiveDevice();
if (device.isNull()) return this;
// sync Open Cloud visual style
const view = device.getActiveView();
view.enableDefaultLighting(true, visLib.DefaultLightingType.kTwoLights);
view.setDefaultLightingIntensity(1.25);
let visualStyleId: any;
try {
visualStyleId = visViewer.findVisualStyle("OpenCloud");
} catch {
// Visualize.js 25.11 and earlier threw an exception if the style did not exist.
visualStyleId = undefined;
}
if (!visualStyleId || visualStyleId.isNull()) {
visualStyleId = visViewer.createVisualStyle("OpenCloud");
const colorDef = new visLib.OdTvColorDef(66, 66, 66);
const shadedVsId = visViewer.findVisualStyle("Realistic");
const visualStylePtr = visualStyleId.openObject();
visualStylePtr.copyFrom(shadedVsId);
visualStylePtr.setOptionInt32(visLib.VisualStyleOptions.kFaceModifiers, 0, visLib.VisualStyleOperations.kSet);
visualStylePtr.setOptionInt32(visLib.VisualStyleOptions.kEdgeModel, 2, visLib.VisualStyleOperations.kSet);
visualStylePtr.setOptionDouble(visLib.VisualStyleOptions.kEdgeCreaseAngle, 60, visLib.VisualStyleOperations.kSet);
visualStylePtr.setOptionInt32(visLib.VisualStyleOptions.kEdgeStyles, 0, visLib.VisualStyleOperations.kSet);
visualStylePtr.setOptionInt32(visLib.VisualStyleOptions.kEdgeModifiers, 8, visLib.VisualStyleOperations.kSet);
visualStylePtr.setOptionColor(
visLib.VisualStyleOptions.kEdgeColorValue,
colorDef,
visLib.VisualStyleOperations.kSet
);
visualStylePtr.delete();
}
view.visualStyle = visualStyleId;
// sync Visualize options
if (options.showWCS !== visViewer.getEnableWCS()) {
visViewer.setEnableWCS(options.showWCS);
}
if (options.cameraAnimation !== visViewer.getEnableAnimation()) {
visViewer.setEnableAnimation(options.cameraAnimation);
}
const antialiasing = options.antialiasing === true || options.antialiasing === "fxaa";
if (antialiasing !== visViewer.fxaaAntiAliasing3d) {
visViewer.fxaaAntiAliasing3d = antialiasing;
visViewer.fxaaQuality = 5;
}
if (options.shadows !== visViewer.shadows) {
visViewer.shadows = options.shadows;
// const canvas = visLib.canvas;
// device.invalidate([0, canvas.width, canvas.height, 0]);
}
if (options.groundShadow !== visViewer.groundShadow) {
visViewer.groundShadow = options.groundShadow;
}
if (options.ambientOcclusion !== device.getOptionBool(visLib.DeviceOptions.kSSAOEnable)) {
device.setOptionBool(visLib.DeviceOptions.kSSAOEnable, options.ambientOcclusion);
device.setOptionBool(visLib.DeviceOptions.kSSAODynamicRadius, true);
device.setOptionDouble(visLib.DeviceOptions.kSSAORadius, 1);
device.setOptionInt32(visLib.DeviceOptions.kSSAOLoops, 32);
device.setOptionDouble(visLib.DeviceOptions.kSSAOPower, 2);
device.setOptionInt32(visLib.DeviceOptions.kSSAOBlurRadius, 2);
const activeView = visViewer.activeView;
activeView.setSSAOEnabled(options.ambientOcclusion);
activeView.delete();
}
if (isExist(options.edgeModel)) {
const activeView = device.getActiveView();
const visualStyleId = visViewer.findVisualStyle("OpenCloud");
const visualStylePtr = visualStyleId.openObject();
visualStylePtr.setOptionInt32(
visLib.VisualStyleOptions.kEdgeModel,
options.edgeModel ? 2 : 0,
visLib.VisualStyleOperations.kSet
);
activeView.visualStyle = visualStyleId;
visualStylePtr.delete();
visualStyleId.delete();
activeView.delete();
}
// sync highlighting options
const params = options.enableCustomHighlight ? options : Options.defaults();
const { Entry, OdTvRGBColorDef } = visLib;
const highlightStyleId = visViewer.findHighlightStyle("Web_Default");
const highlightStylePtr = highlightStyleId.openObject();
if (isExist(params.facesColor)) {
const color = new OdTvRGBColorDef(params.facesColor.r, params.facesColor.g, params.facesColor.b);
highlightStylePtr.setFacesColor(Entry.k3D.value | Entry.k3DTop.value, color);
color.delete();
}
if (isExist(params.facesOverlap)) {
highlightStylePtr.setFacesVisibility(Entry.k3DTop.value, params.facesOverlap);
}
if (isExist(params.facesTransparancy)) {
highlightStylePtr.setFacesTransparency(Entry.k3D.value | Entry.k3DTop.value, params.facesTransparancy);
}
if (isExist(params.edgesColor)) {
const color = new OdTvRGBColorDef(params.edgesColor.r, params.edgesColor.g, params.edgesColor.b);
highlightStylePtr.setEdgesColor(
Entry.k3DTop.value | Entry.k3D.value | Entry.k2D.value | Entry.k2DTop.value,
color
);
color.delete();
}
if (isExist(params.edgesVisibility)) {
highlightStylePtr.setEdgesVisibility(
Entry.k2D.value | Entry.k2DTop.value | Entry.k3DTop.value | Entry.k3D.value,
params.edgesVisibility
);
}
if (isExist(params.edgesOverlap)) {
const visibility = !isExist(params.edgesVisibility) ? true : params.edgesVisibility;
highlightStylePtr.setEdgesVisibility(Entry.k2DTop.value | Entry.k3DTop.value, params.edgesOverlap && visibility);
}
// const canvas = visLib.canvas;
// device.invalidate([0, canvas.width, canvas.height, 0]);
view.delete();
device.delete();
this.update();
return this;
}
syncOverlay(): void {
if (!this.visualizeJs) return;
const visViewer = this.visViewer();
const activeView = visViewer.activeView;
let overlayView = visViewer.getViewByName(OVERLAY_VIEW_NAME);
if (!overlayView) {
const markupModel = visViewer.getMarkupModel();
const pDevice = visViewer.getActiveDevice();
overlayView = pDevice.createView(OVERLAY_VIEW_NAME, false);
overlayView.addModel(markupModel);
activeView.addSibling(overlayView);
pDevice.addView(overlayView);
}
overlayView.viewPosition = activeView.viewPosition;
overlayView.viewTarget = activeView.viewTarget;
overlayView.upVector = activeView.upVector;
overlayView.viewFieldWidth = activeView.viewFieldWidth;
overlayView.viewFieldHeight = activeView.viewFieldHeight;
const viewPort = overlayView.getViewport();
overlayView.setViewport(viewPort.lowerLeft, viewPort.upperRight);
overlayView.vportRect = activeView.vportRect;
this._markup.syncOverlay();
this.update();
}
clearOverlay(): void {
if (!this.visualizeJs) return;
this._markup.clearOverlay();
this.update();
}
clearSlices(): void {
if (!this.visualizeJs) return;
const visViewer = this.visViewer();
const activeView = visViewer.activeView;
activeView.removeCuttingPlanes();
activeView.delete();
this.update();
}
getSelected(): string[] {
return this.executeCommand("getSelected");
}
setSelected(handles?: string[]): void {
this.executeCommand("setSelected", handles);
}
getSelected2(): string[] {
return this.executeCommand("getSelected2");
}
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");
}
/**
* Deprecated since `25.12`. Use {@link draggers.registerDragger} instead.
*/
public registerDragger(name: string, dragger: typeof Dragger): void {
console.warn(
"Viewer.registerDragger() has been deprecated since 25.12 and will be removed in a future release, use draggers('visualizejs').registerDragger() instead."
);
draggers.registerDragger(name, (viewer: IViewer) => new dragger(viewer));
}
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.visualizeJs) {
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.visualizeJs) return;
const visViewer = this.visViewer();
const activeView = visViewer.activeView;
const getPoint3dAsArray = (point3d: IPoint): number[] => {
return [point3d.x, point3d.y, point3d.z];
};
const setOrthogonalCamera = (orthogonal_camera: IOrthogonalCamera) => {
if (orthogonal_camera) {
activeView.setView(
getPoint3dAsArray(orthogonal_camera.view_point),
getPoint3dAsArray(orthogonal_camera.direction),
getPoint3dAsArray(orthogonal_camera.up_vector),
orthogonal_camera.field_width,
orthogonal_camera.field_height,
true
);
this.options.cameraMode = "orthographic";
this.emitEvent({ type: "changecameramode", mode: "orthographic" });
}
};
const setPerspectiveCamera = (perspective_camera: IPerspectiveCamera) => {
if (perspective_camera) {
// ===================== AI-CODE-START ======================
// Source: Claude Sonnet 4.5
// Date: 2025-12-03
// Reviewer: roman.mochalov@opendesign.com
// Issue: CLOUD-5997
// Notes: Originally AI-generated, modified manually
const aspectRatio = this.canvas.width / this.canvas.height;
const position = perspective_camera.view_point;
const target = perspective_camera.direction;
const fov = (perspective_camera.field_of_view * Math.PI) / 180;
const dx = target.x - position.x;
const dy = target.y - position.y;
const dz = target.z - position.z;
const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
const fieldHeight = 2 * distance * Math.tan(fov / 2);
const fieldWidth = fieldHeight * aspectRatio;
// ===================== AI-CODE-END ======================
activeView.setView(
getPoint3dAsArray(perspective_camera.view_point),
getPoint3dAsArray(perspective_camera.direction),
getPoint3dAsArray(perspective_camera.up_vector),
fieldWidth,
fieldHeight,
false
);
this.options.cameraMode = "perspective";
this.emitEvent({ type: "changecameramode", mode: "perspective" });
}
};
const setClippingPlanes = (clipping_planes: IClippingPlane[]) => {
if (clipping_planes) {
for (const clipping_plane of clipping_planes) {
const cuttingPlane = new (this.visLib().OdTvPlane)();
cuttingPlane.set(getPoint3dAsArray(clipping_plane.location), getPoint3dAsArray(clipping_plane.direction));
activeView.addCuttingPlane(cuttingPlane);
activeView.setEnableCuttingPlaneFill(true, 0x66, 0x66, 0x66);
}
}
};
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.syncOverlay();
this.setActiveDragger(draggerName);
this.emitEvent({ type: "drawviewpoint", data: viewpoint });
this.update(true);
}
createViewpoint(): IViewpoint {
if (!this.visualizeJs) return {};
const visViewer = this.visViewer();
const activeView = visViewer.activeView;
const getPoint3dFromArray = (array: number[]): IPoint => {
return { x: array[0], y: array[1], z: array[2] };
};
const getOrthogonalCamera = (): IOrthogonalCamera => {
if (!activeView.perspective)
return {
view_point: getPoint3dFromArray(activeView.viewPosition),
direction: getPoint3dFromArray(activeView.viewTarget),
up_vector: getPoint3dFromArray(activeView.upVector),
field_width: activeView.viewFieldWidth,
field_height: activeView.viewFieldHeight,
view_to_world_scale: 1,
};
else return undefined;
};
const getPerspectiveCamera = (): IPerspectiveCamera => {
if (activeView.perspective) {
// ===================== AI-CODE-START ======================
// Source: Claude Sonnet 4.5
// Date: 2025-12-03
// Reviewer: roman.mochalov@opendesign.com
// Issue: CLOUD-5997
// Notes: Originally AI-generated, modified manually
const position = activeView.viewPosition;
const target = activeView.viewTarget;
const fieldHeight = activeView.viewFieldHeight;
const dx = target[0] - position[0];
const dy = target[1] - position[1];
const dz = target[2] - position[2];
const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
const fov = 2 * Math.atan(fieldHeight / (2 * distance));
// ===================== AI-CODE-END ======================
return {
view_point: getPoint3dFromArray(activeView.viewPosition),
direction: getPoint3dFromArray(activeView.viewTarget),
up_vector: getPoint3dFromArray(activeView.upVector),
field_of_view: (fov * 180) / Math.PI,
};
} else return undefined;
};
const getClippingPlanes = (): IClippingPlane[] => {
const clipping_planes = [];
for (let i = 0; i < activeView.numCuttingPlanes(); i++) {
const cuttingPlane = activeView.getCuttingPlane(i);
const clipping_plane = {
location: getPoint3dFromArray(cuttingPlane.getOrigin()),
direction: getPoint3dFromArray(cuttingPlane.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.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.visualizeJs) return { x: position.x, y: position.y, z: 0 };
const activeView = this.visViewer().activeView;
const worldPoint = activeView.transformScreenToWorld(
position.x * window.devicePixelRatio,
position.y * window.devicePixelRatio
);
const result = { x: worldPoint[0], y: worldPoint[1], z: worldPoint[2] };
activeView.delete();
return result;
}
worldToScreen(position: { x: number; y: number; z: number }): { x: number; y: number } {
if (!this.visualizeJs) return { x: position.x, y: position.y };
const activeView = this.visViewer().activeView;
const devicePoint = activeView.transformWorldToScreen(position.x, position.y, position.z);
const result = { x: devicePoint[0] / window.devicePixelRatio, y: devicePoint[1] / window.devicePixelRatio };
activeView.delete();
return result;
}
getScale(): { x: number; y: number; z: number } {
const result = { x: 1.0, y: 1.0, z: 1.0 };
const projMatrix = this.visViewer().activeView.projectionMatrix;
const tolerance = 1.0e-6;
const x = projMatrix.get(0, 0);
if (x > tolerance || x < -tolerance) result.x = 1 / x;
const y = projMatrix.get(1, 1);
if (y > tolerance || y < -tolerance) result.y = 1 / y;
const z = projMatrix.get(2, 2);
if (z > tolerance || z < -tolerance) result.z = 1 / z;
return result;
}
// ICommandService
/**
* Executes the command denoted by the given command. If the command is not found, tries to set active
* dragger with the specified name.
*
* The following commands are available by default:
*
* - `applyModelTransform`
* - `autoTransformAllModelsToCentralPoint`
* - `clearMarkup`
* - `clearSelected`
* - `clearSlices`
* - `createPreview`
* - `explode`
* - `getDefaultViewPositions`
* - `getModels`
* - `getSelected`
* - `hideSelected`
* - `isolateSelected`
* - `regenerateAll`
* - `resetView`
* - `selectModel`
* - `setActiveDragger`
* - `setDefaultViewPosition`
* - `setMarkupColor`
* - `setSelected`
* - `showAll`
* - `zoomToExtents`
* - `zoomToObjects`
* - `zoomToSelected`
*
* To register custom command use the {@link commands.registerCommand}.
*
* @param id - Command ID or dragger name.
* @param args - Parameters passed to the command handler function.
* @returns Returns the result of the command handler function or new active dragger instance. Returns
* `undefined` if neither the command nor the dragger exists.
*/
executeCommand(id: string, ...args: any[]): any {
return commands.executeCommand(id, this, ...args);
}
// VisualizeJS viewer specific
/**
* Adds an empty `Visualize` markup entity to the VisualizeJS overlay.
*/
addMarkupEntity(entityName: string) {
if (!this.visualizeJs) return null;
this.syncOverlay();
const visViewer = this.visViewer();
const model = visViewer.getMarkupModel();
const entityId = model.appendEntity(entityName);
const entityPtr = entityId.openObject();
const color = this.getMarkupColor();
entityPtr.setColor(color.r, color.g, color.b);
entityPtr.setLineWeight(2);
entityPtr.delete();
this.update();
return entityId;
}
/**
* Deprecated since `25.11`. Use {@link IMarkup.getMarkupColor | markup.getMarkupColor()} instead.
*/
getMarkupColor(): { r: number; g: number; b: number } {
console.warn(
"Viewer.getMarkupColor() has been deprecated since 25.11 and will be removed in a future release, use Viewer.markup.getMarkupColor() instead."
);
return this._markup.getMarkupColor();
}
/**
* Deprecated since `25.11`. Use {@link IMarkup.setMarkupColor | markup.setMarkupColor()} instead.
*/
setMarkupColor(r = 255, g = 0, b = 0): void {
console.warn(
"Viewer.setMarkupColor() has been deprecated since 25.11 and will be removed in a future release, use Viewer.markup.setMarkupColor() instead."
);
this._markup.setMarkupColor(r, g, b);
}
/**
* Deprecated since `25.11`. Use {@link IMarkup.colorizeAllMarkup | markup.colorizeAllMarkup()} instead.
*/
colorizeAllMarkup(r = 255, g = 0, b = 0): void {
console.warn(
"Viewer.colorizeAllMarkup() has been deprecated since 25.11 and will be removed in a future release, use Viewer.markup.colorizeAllMarkup() instead."
);
this._markup.colorizeAllMarkup(r, g, b);
}
/**
* Deprecated since `25.11`. Use
* {@link IMarkup.colorizeSelectedMarkups | markup.colorizeSelectedMarkups()} instead.
*/
colorizeSelectedMarkups(r = 255, g = 0, b = 0): void {
this._markup.colorizeSelectedMarkups(r, g, b);
}
private scheduleUpdateAsync(maxScheduleUpdateTimeInMs = 50): Promise<void> {
return new Promise<void>((resolve, reject) => {
setTimeout(() => {
try {
if (this._enableAutoUpdate) {
this.visViewer()?.update(maxScheduleUpdateTimeInMs);
this._activeDragger?.updatePreview?.();
}
this.emitEvent({ type: "update", force: false });
resolve();
} catch (e) {
console.error(e);
reject();
}
}, 0);
});
}
/**
* Updates the viewer asynchronously without locking the user interface. Used to update the viewer
* after changes that require a long rendering time.
*
* Do nothing if the auto-update mode is disabled in the constructor. In this case, register an
* `update` event handler and update the `VisualizeJS` viewer and active dragger manually.
*
* Fires:
*
* - {@link UpdateEvent | update}
*
* @param maxScheduleUpdateTimeInMs - Maximum time for one update, default 30 ms.
* @param maxScheduleUpdateCount - Maximum count of scheduled updates.
*/
async updateAsync(maxScheduleUpdateTimeInMs = 50, maxScheduleUpdateCount = 50): Promise<void> {
if (!this.visualizeJs) return;
this._isRunAsyncUpdate = true;
try {
const device = this.visViewer().getActiveDevice();
for (let iterationCount = 0; !device.isValid() && iterationCount < maxScheduleUpdateCount; iterationCount++) {
await this.scheduleUpdateAsync(maxScheduleUpdateTimeInMs);
}
await this.scheduleUpdateAsync(maxScheduleUpdateTimeInMs);
} catch (e) {
console.error(e);
} finally {
this._isRunAsyncUpdate = false;
}
}
public deviceAutoRegeneration() {
const visViewer = this.visViewer();
const device = visViewer.getActiveDevice();
const coef = device.getOptionDouble(this.visLib().DeviceOptions.kRegenCoef);
if (coef > 1.0) {
visViewer.regenAll();
this.update();
}
}
}