///////////////////////////////////////////////////////////////////////////////
// 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,
  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 _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._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 aspect = width / height;

    this.camera = new PerspectiveCamera(45, aspect, 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.toneMapping = LinearToneMapping;
    this.renderer.outputColorSpace = LinearSRGBColorSpace;

    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();
    this.render(this._renderTime);

    if (typeof onProgress === "function")
      onProgress(new ProgressEvent("progress", { lengthComputable: true, loaded: 1, total: 1 }));

    this.emitEvent({ type: "initializeprogress", data: 1, loaded: 1, total: 1 });
    this.emitEvent({ type: "initialize" });

    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 aspect = width / height;

    if (camera.isPerspectiveCamera) {
      camera.aspect = aspect;
      camera.updateProjectionMatrix();
    }
    if (camera.isOrthographicCamera) {
      camera.left = camera.bottom * aspect;
      camera.right = camera.top * aspect;
      camera.updateProjectionMatrix();
    }

    this.renderer.setSize(width, height, updateStyle);
    this.composer.setSize(width, height);

    this.update(true);
    this.emitEvent({ type: "resize", width, height });
  }

  update(force = false): void {
    this._renderNeeded = true;
    if (force) this.render();
    this.emitEvent({ type: "update", data: 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 });

    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.update(true);

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

    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 aspect = 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 * aspect;
        camera.right = camera.top * aspect;
        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.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 aspect = rendererSize.x / rendererSize.y;

        const camera = new PerspectiveCamera();
        camera.fov = perspective_camera.field_of_view;
        camera.aspect = aspect;
        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.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.setActiveDragger(draggerName);
    this.emitEvent({ type: "drawviewpoint", data: viewpoint });
    this.update();
  }

  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
  // ===================== AI-CODE-START ======================
  // Source: Claude Sonnet 4.5
  // Date: 2025-11-25
  // Reviewer: vitaly.ivanov@opendesign.com
  // Issue: CLOUD-5990
  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;

    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 };
    } else {
      const point = new Vector3(x, y, -1);
      point.unproject(this.camera);

      return { x: point.x, y: point.y, z: point.z };
    }
  }
  // ===================== AI-CODE-END ======================

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