///////////////////////////////////////////////////////////////////////////////
// Copyright (C) 2002-2026, 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-2026 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,
  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 _renderNeeded: boolean;
  private _renderTime: DOMHighResTimeStamp;
  private _enableAutoUpdate: boolean;
  private _maxRegenTime: number;

  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` 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._renderNeeded = false;
    this._renderTime = 0;
    this._enableAutoUpdate = params.enableAutoUpdate ?? true;
    this._maxRegenTime = 0;

    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`. The canvas element must have an `id` attribute, otherwise an exception will be
   *   thrown.
   * @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.length = 0;

    if (this._markup) {
      this._markup.dispose();
      this._markup = undefined;
    }

    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);
  }

  /**
   * 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}
   * - {@link RenderEvent | render}
   *
   * @param force - If `true` updates the viewer immidietly. If a `number` is specified and more than the
   *   given amount of milliseconds has elapsed since the last rendering, the update is performed
   *   immediately as well. Otherwise updates on next animation frame. Default is `false`.
   */
  update(force: boolean | number = false) {
    const time = performance.now();
    if (typeof force === "number" && time - this._renderTime >= force) force = true;

    if (this._enableAutoUpdate) {
      this._renderNeeded = true;
      if (force) this.render(time);
    }

    this.emitEvent({ type: "update", force: !!force });
  }

  // Internal render routines

  render(time?: DOMHighResTimeStamp) {
    if (!this.visualizeJs) 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._maxRegenTime);

    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("Viewer: 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(`Viewer: 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;
    }

    let format = params.format;
    if (!format && file && 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 (${format})`);
    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;
  }

  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.length = 0;

    this.models.forEach((model) => model.dispose());
    this.models.length = 0;

    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 cutting plane fill options

    view.setEnableCuttingPlaneFill(
      options.enableSectionFill,
      options.sectionFillColor.r,
      options.sectionFillColor.g,
      options.sectionFillColor.b
    );

    view.setCuttingPlaneFillPatternEnabled(
      options.enableSectionHatch,
      visLib.CuttingPlaneFillStyle.kSlantRight,
      options.sectionHatchColor.r,
      options.sectionHatchColor.g,
      options.sectionHatchColor.b
    );

    // 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.emitEvent({ type: "clearoverlay" });
    this.update();
  }

  clearSlices(): void {
    if (!this.visualizeJs) return;

    const visViewer = this.visViewer();
    const activeView = visViewer.activeView;
    activeView.removeCuttingPlanes();
    activeView.delete();

    this.emitEvent({ type: "changecuttingplanes" });
    this.emitEvent({ type: "clearslices" });
    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");
  }

  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);
        this._activeDragger = newDragger;
      }
      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);
        }

        this.emitEvent({ type: "changecuttingplanes" });
      }
    };

    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();
    viewpoint.snapshot = { data: this.getSnapshot() };

    this._markup.getViewpoint(viewpoint);

    viewpoint.custom_fields.selection2 = getSelection2();

    this.emitEvent({ type: "createviewpoint", data: viewpoint });

    return viewpoint;
  }

  getSnapshot(type?: string, quality?: number): string {
    return this.executeCommand("getSnapshot", type, quality);
  }

  // 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.markup.getMarkupColor();
    entityPtr.setColor(color.r, color.g, color.b);
    entityPtr.setLineWeight(2);
    entityPtr.delete();

    this.update();

    return entityId;
  }

  private scheduleUpdateAsync(maxScheduleUpdateTimeInMs = 50): Promise<void> {
    return new Promise<void>((resolve, reject) => {
      setTimeout(() => {
        this._maxRegenTime = maxScheduleUpdateTimeInMs;
        try {
          this.update(true);
          resolve();
        } catch (e) {
          console.error("Viewer: Async update error.", e);
          reject();
        } finally {
          this._maxRegenTime = 0;
        }
      }, 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}
   * - {@link RenderEvent | render}
   *
   * @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;

    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("Viewer: Async update error.", e);
    }
  }

  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();
    }
  }
}
