import { w2popup } from "../../../vendor/w2ui";
import type { Object3D, PerspectiveCamera, Scene, Texture, WebGLRenderer } from "three";
import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import type { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import type { ModelItem } from "../../../types";
import type { OperationTree, OperationTreeDB } from "../../../types/OperationTree";
import { P_OPERATION } from "../../../types/OPERATION_BaseClass";
import { downloadFileFromUrl } from "../../../utils/fileDownload";
import { escapeHtml, formatBytes } from "../../../utils/format";
import { notify, notifyError } from "../../../ui/dialogs";
import { api } from "../../../services/api";
import { resolveStorageUrl } from "../../../config/runtime";

type PreviewRuntime = {
  renderer: WebGLRenderer;
  scene: Scene;
  camera: PerspectiveCamera;
  context: PreviewLoadContext;
};

type PreviewLoadContext = {
  controller: AbortController;
  disposed: boolean;
  renderer?: WebGLRenderer;
  scene?: Scene;
  camera?: PerspectiveCamera;
  controls?: OrbitControls;
  environmentMap?: Texture | null;
  modelObject?: Object3D;
  animationId?: number;
  resizeObserver?: ResizeObserver;
};

type ThreeModule = typeof import("three");
type LoadedGltf = Awaited<ReturnType<GLTFLoader["loadAsync"]>>;

type ParseableGltfLoader = GLTFLoader & {
  parseAsync(data: ArrayBuffer | string, path: string): Promise<LoadedGltf>;
};

type PreviewObjectSnapshot = {
  position: ReturnType<Object3D["position"]["clone"]>;
  quaternion: ReturnType<Object3D["quaternion"]["clone"]>;
  visible: boolean;
  parent: Object3D | null;
};

type PreviewPlaybackState = {
  operation: PreviewOperation | null;
  isPlaying: boolean;
  rafId: number | null;
  startedAtMs: number;
  baseTimeMs: number;
  currentTimeMs: number;
  currentFrameIndex: number;
  frameTimesMs: number[];
  totalTimeMs: number;
  objectSnapshots: Map<Object3D, PreviewObjectSnapshot>;
  objectMap: Map<string, Object3D>;
  attachedOriginalParents: Map<Object3D, Object3D | null>;
  rotatingObjects: Map<Object3D, { axis: "x" | "y" | "z"; rate: number }>;
};

export type PreviewBasePoint = {
  x?: number;
  y?: number;
  z?: number;
  rx?: number;
  ry?: number;
  rz?: number;
};

export type PreviewResolvedBasePoint = Required<PreviewBasePoint>;

export type PreviewOperation = OperationTree & {
  parsedCraftPlayData: P_OPERATION | null;
  frameCount: number;
  parseError: string | null;
};

export type PreviewImportScenePayload = {
  model: ModelItem;
  modelUrl: string;
  basePointEnabled: boolean;
  basePoint: PreviewResolvedBasePoint;
  rawOperationTree: string;
  operations: PreviewOperation[];
  selectedOperation: PreviewOperation | null;
  selectedCraftPlayData: P_OPERATION | null;
};

type ParentImportScenePayload = {
  model: Pick<ModelItem, "id" | "name" | "original_filename" | "file_url">;
  modelUrl: string;
  basePointEnabled: boolean;
  basePoint: PreviewResolvedBasePoint;
  rawOperationTree: string;
};

type ParentBasePointMessage = {
  type?: string;
  message?: string;
  payload?: {
    modelId?: string;
    modelName?: string;
    basePoint?: PreviewBasePoint;
  };
};

export type ModelPreviewOptions = {
  onImportScene?: (payload: PreviewImportScenePayload) => Promise<void> | void;
};

const BASE_POINT_SUBSCRIBE_MESSAGE = "DMT_MODEL_LIBRARY_BASE_POINT_SUBSCRIBE";
const BASE_POINT_UNSUBSCRIBE_MESSAGE = "DMT_MODEL_LIBRARY_BASE_POINT_UNSUBSCRIBE";
const BASE_POINT_UPDATE_MESSAGE = "DMT_MODEL_LIBRARY_BASE_POINT_UPDATE";
const BASE_POINT_ERROR_MESSAGE = "DMT_MODEL_LIBRARY_BASE_POINT_ERROR";
const zeroBasePoint: PreviewResolvedBasePoint = {
  x: 0,
  y: 0,
  z: 0,
  rx: 0,
  ry: 0,
  rz: 0
};
const RAD_TO_DEG = 180 / Math.PI;
const DEG_TO_RAD = Math.PI / 180;
const BASE_POINT_DECIMAL_PLACES = 3;
const THUMBNAIL_WIDTH = 512;
const THUMBNAIL_HEIGHT = 384;

const previewEnvironmentUrl = "/DayCityOutdoor.exr";
const DEFAULT_PREVIEW_FRAME_INTERVAL_MS = 20;
const SECOND_MS = 1000;

let runtime: PreviewRuntime | null = null;
let activePreviewContext: PreviewLoadContext | null = null;
let previewEnvironmentTexturePromise: Promise<Texture> | null = null;
let previewPlayback: PreviewPlaybackState = createEmptyPlaybackState();
let basePointBridgeBound = false;
let basePointSubscribed = false;

export function openModelPreview(
  model: ModelItem,
  onThumbnailSaved?: () => Promise<void> | void,
  options: ModelPreviewOptions = {}
) {
  const context = beginPreviewContext();
  const canWrite = model.permissions.write;
  const url = resolveStorageUrl(model.file_url);
  const operations = parseModelOperations(model.operation_tree);
  const popup = w2popup.open({
    title: `模型预览 - ${escapeHtml(model.name)}【${formatBytes(model.file_size)}】`,
    width: 860,
    height: 620,
    modal: true,
    blockPage: false,
    body: `
      <div class="preview-shell">
        <aside class="preview-process-panel">
          <div class="preview-process-section">
            <div class="preview-process-header">
              <strong>工艺播放</strong>
              <span>${operations.length} 项</span>
            </div>
            <ul class="preview-process-tree">
              ${operationListHtml(operations)}
            </ul>
            <div class="preview-process-actions">
              <button id="previewPlayBtn" type="button">播放</button>
              <button id="previewPauseBtn" type="button">暂停</button>
            </div>
            <div id="previewProcessStatus" class="preview-process-status">
              ${operationStatusHtml(operations[0] ?? null)}
            </div>
          </div>
          <div class="preview-scene-section">
            <div class="preview-scene-body">
              <div class="preview-scene-actions">
                <button id="previewImportSceneBtn" class="primary-btn" type="button">导入场景</button>
                <label class="preview-switch">
                  <input id="previewUseBasePoint" type="checkbox" />
                  <span>启用基点</span>
                </label>
              </div>
            
              <div class="preview-basepoint-grid">
                <label><span>X(mm)</span><input name="baseX" type="number" value="0.000" step="0.001" /></label>
                <label><span>Y(mm)</span><input name="baseY" type="number" value="0.000" step="0.001" /></label>
                <label><span>Z(mm)</span><input name="baseZ" type="number" value="0.000" step="0.001" /></label>
                <label><span>RX(°)</span><input name="baseRx" type="number" value="0.000" step="0.001" /></label>
                <label><span>RY(°)</span><input name="baseRy" type="number" value="0.000" step="0.001" /></label>
                <label><span>RZ(°)</span><input name="baseRz" type="number" value="0.000" step="0.001" /></label>
              </div>
            </div>
          </div>
        </aside>
        <div class="preview-canvas-panel">
          <div id="modelPreviewViewport" class="preview-viewport">
            ${previewLoadingHtml("准备加载模型...")}
          </div>
        </div>
      </div>
    `,
    actions: {
      下载() {
        bindDownloadModel(model);
      },
      更新缩略图() {
        if (canWrite) {
          screenshotModel(model.id, onThumbnailSaved).catch((error) => {

          })
        }
      },
      关闭() {
        disposePreview();
        w2popup.close();
      }
    }
  });

  popup.self
    .on("open:after", () => {popup
      console.log(popup)
      bindProcessPlaceholder(operations);
      bindBasePointSelection();
      bindImportScene(model, operations, options.onImportScene);
      if (canWrite) bindThumbnailCapture(model.id, onThumbnailSaved);
      requestAnimationFrame(() => {
        initPreview(url, context).catch((error) => {
          if (!isAbortError(error)) notifyError(error);
        });
      });
    })
    .on("close:after", () => {
      unsubscribeBasePointSelection();
      disposePreview();
    });
}

export function setPreviewBasePoint(basePoint: PreviewBasePoint, enabled = true) {
  const fieldMap: Array<[keyof PreviewResolvedBasePoint, string]> = [
    ["x", "baseX"],
    ["y", "baseY"],
    ["z", "baseZ"],
    ["rx", "baseRx"],
    ["ry", "baseRy"],
    ["rz", "baseRz"]
  ];
  for (const [key, name] of fieldMap) {
    const input = document.querySelector<HTMLInputElement>(`.preview-basepoint-grid input[name="${name}"]`);
    if (input && basePoint[key] !== undefined) {
      input.value = formatBasePointDisplayValue(key, basePoint[key]);
    }
  }

  const checkbox = document.querySelector<HTMLInputElement>("#previewUseBasePoint");
  if (checkbox) {
    checkbox.checked = enabled;
  }
}

export function createImportScenePayload(options: {
  model: ModelItem;
  basePointEnabled?: boolean;
  basePoint?: PreviewBasePoint;
  selectedOperationIndex?: number | null;
}): PreviewImportScenePayload {
  const operations = parseModelOperations(options.model.operation_tree);
  const selectedOperation = options.selectedOperationIndex === null
    ? null
    : operations[options.selectedOperationIndex ?? 0] ?? null;

  return buildImportScenePayload({
    model: options.model,
    operations,
    selectedOperation,
    basePointEnabled: options.basePointEnabled ?? false,
    basePoint: resolveBasePoint(options.basePoint)
  });
}

export function emitImportScenePayload(payload: PreviewImportScenePayload) {
  window.dispatchEvent(new CustomEvent<PreviewImportScenePayload>("dmt:import-scene", { detail: payload }));
  if (window.parent && window.parent !== window) {
    try {
      window.parent.postMessage({
        type: "DMT_MODEL_LIBRARY_IMPORT_SCENE",
        payload: buildParentImportScenePayload(payload)
      }, resolveParentPostMessageOrigin());
    } catch (error) {
      notifyError(error);
    }
  }
}

function buildParentImportScenePayload(payload: PreviewImportScenePayload): ParentImportScenePayload {
  return {
    model: {
      id: payload.model.id,
      name: payload.model.name,
      original_filename: payload.model.original_filename,
      file_url: resolveStorageUrl(payload.model.file_url)
    },
    modelUrl: payload.modelUrl,
    basePointEnabled: payload.basePointEnabled,
    basePoint: payload.basePoint,
    rawOperationTree: payload.rawOperationTree
  };
}

function resolveParentPostMessageOrigin() {
  const origin = resolveParentOrigin();
  return origin === window.location.origin ? "*" : origin;
}

function resolveParentOrigin() {
  try {
    const meta = import.meta as ImportMeta & { env?: Record<string, string | undefined> };
    const configuredOrigin = meta.env?.VITE_DMT_PARENT_ORIGIN;
    if (configuredOrigin) return configuredOrigin;
    if (document.referrer) return new URL(document.referrer).origin;
  } catch {
    return "*";
  }
  return "*";
}

function isAllowedParentOrigin(origin: string) {
  const expectedOrigin = resolveParentOrigin();
  return expectedOrigin === "*" || origin === expectedOrigin || isLocalDmtParentOrigin(origin);
}

function isLocalDmtParentOrigin(origin: string) {
  try {
    const url = new URL(origin);
    const isLocalHost = url.hostname === "localhost" || url.hostname === "127.0.0.1";
    return isLocalHost && url.port === "3000";
  } catch {
    return false;
  }
}

function bindBasePointSelection() {
  bindBasePointBridge();
  const checkbox = document.querySelector<HTMLInputElement>("#previewUseBasePoint");
  checkbox?.addEventListener("change", () => {
    if (checkbox.checked) {
      subscribeBasePointSelection();
    } else {
      unsubscribeBasePointSelection();
    }
  });
  if (checkbox?.checked) subscribeBasePointSelection();
}

function bindBasePointBridge() {
  if (basePointBridgeBound) return;
  basePointBridgeBound = true;
  window.addEventListener("message", (event) => {
    const data = event.data as ParentBasePointMessage;
    if (data?.type !== BASE_POINT_UPDATE_MESSAGE && data?.type !== BASE_POINT_ERROR_MESSAGE) return;
    if (!isAllowedParentOrigin(event.origin)) return;
    if (data.type === BASE_POINT_ERROR_MESSAGE) {
      notify(data.message || "请先在主场景中选择模型或坐标");
      return;
    }
    if (data.payload?.basePoint) {
      setPreviewBasePoint(data.payload.basePoint, true);
    }
  });
}

function subscribeBasePointSelection() {
  if (!window.parent || window.parent === window) return;
  basePointSubscribed = true;
  window.parent.postMessage({
    type: BASE_POINT_SUBSCRIBE_MESSAGE
  }, resolveParentPostMessageOrigin());
}

function unsubscribeBasePointSelection() {
  if (!basePointSubscribed) return;
  basePointSubscribed = false;
  if (!window.parent || window.parent === window) return;
  window.parent.postMessage({
    type: BASE_POINT_UNSUBSCRIBE_MESSAGE
  }, resolveParentPostMessageOrigin());
}

function parseModelOperations(value: string): PreviewOperation[] {
  try {
    const dbValue = JSON.parse(value || "{}") as Partial<OperationTreeDB>;
    const list = JSON.parse(dbValue.OperationTree || "[]") as unknown;
    if (!Array.isArray(list)) return [];
    return list
      .filter((item): item is OperationTree => Boolean(item && typeof item === "object"))
      .map((operation) => {
        const parsed = parseCraftPlayData(operation.CraftPlayData);
        return {
          ...operation,
          parsedCraftPlayData: parsed.data,
          frameCount: parsed.data?.OPERATION.frames.length ?? 0,
          parseError: parsed.error
        };
      });
  } catch {
    return [];
  }
}

function parseCraftPlayData(value?: string | null): { data: P_OPERATION | null; error: string | null } {
  const text = value?.trim();
  if (!text) return { data: null, error: null };
  try {
    const jsonData = parseJsonText(text);
    return { data: P_OPERATION.fromjson(jsonData), error: null };
  } catch (error) {
    return {
      data: null,
      error: error instanceof Error ? error.message : "工艺播放数据解析失败"
    };
  }
}

function parseJsonText(text: string): unknown {
  let value: unknown = text;
  for (let index = 0; index < 2 && typeof value === "string"; index += 1) {
    value = JSON.parse(value);
  }
  return value;
}

function operationListHtml(operations: PreviewOperation[]) {
  if (operations.length === 0) {
    return `<li class="preview-process-empty">暂无工艺数据</li>`;
  }
  return operations.map((operation, index) => {
    const name = operation.CraftName?.trim() || `工艺 ${index + 1}`;
    const id = operation.ID || operation.CraftCode || String(index + 1);
    const title = operation.parseError
      ? `${name} / 播放数据解析失败`
      : `${name} / ${operation.frameCount} 帧`;
    return `
      <li class="${index === 0 ? "is-active" : ""}">
        <button type="button" data-process-id="${escapeHtml(String(id))}" data-process-index="${index}" title="${escapeHtml(title)}">${escapeHtml(name)}</button>
      </li>
    `;
  }).join("");
}

function operationStatusHtml(operation: PreviewOperation | null) {
  if (!operation) return "未选择工艺";
  if (operation.parseError) return `播放数据解析失败：${escapeHtml(operation.parseError)}`;
  if (!operation.parsedCraftPlayData) return "当前工艺暂无播放数据";
  return `已读取 ${operation.frameCount} 帧播放数据`;
}

function previewLoadingHtml(text: string) {
  return `
    <div id="previewLoading" class="preview-loading">
      <div class="preview-loading-box">
        <strong>模型加载中</strong>
        <span id="previewLoadingText">${escapeHtml(text)}</span>
        <div class="preview-loading-track">
          <i id="previewLoadingBar" style="width: 0%"></i>
        </div>
      </div>
    </div>
  `;
}

function updatePreviewLoading(event: ProgressEvent) {
  const text = document.querySelector<HTMLElement>("#previewLoadingText");
  const bar = document.querySelector<HTMLElement>("#previewLoadingBar");
  if (!text || !bar) return;

  if (event.lengthComputable && event.total > 0) {
    const percent = Math.min(100, Math.round((event.loaded / event.total) * 100));
    text.textContent = `${percent}%`;
    bar.style.width = `${percent}%`;
    return;
  }

  text.textContent = `已加载 ${formatBytes(event.loaded)}`;
  bar.style.width = "28%";
}

function updatePreviewLoadingByCount(loaded: number, total: number) {
  const text = document.querySelector<HTMLElement>("#previewLoadingText");
  const bar = document.querySelector<HTMLElement>("#previewLoadingBar");
  if (!text || !bar || total <= 0) return;

  const percent = Math.min(100, Math.round((loaded / total) * 100));
  text.textContent = `资源加载 ${loaded}/${total}`;
  bar.style.width = `${percent}%`;
}

function showPreviewLoadingError(message: string) {
  const loading = document.querySelector<HTMLElement>("#previewLoading");
  if (loading) {
    loading.innerHTML = `<div class="preview-loading-box is-error"><strong>加载失败</strong><span>${escapeHtml(message)}</span></div>`;
  }
}

function hidePreviewLoading() {
  document.querySelector<HTMLElement>("#previewLoading")?.remove();
}

function beginPreviewContext() {
  disposePreview();
  const context: PreviewLoadContext = {
    controller: new AbortController(),
    disposed: false
  };
  activePreviewContext = context;
  return context;
}

function ensurePreviewContext(context: PreviewLoadContext) {
  if (!isPreviewContextActive(context)) {
    throw new DOMException("预览已关闭", "AbortError");
  }
}

function isPreviewContextActive(context: PreviewLoadContext) {
  return !context.disposed && activePreviewContext === context && !context.controller.signal.aborted;
}

function isAbortError(error: unknown) {
  return error instanceof DOMException && error.name === "AbortError";
}

function cleanupPreviewContextResources(context: PreviewLoadContext) {
  stopPreviewPlayback();
  if (context.animationId !== undefined) {
    cancelAnimationFrame(context.animationId);
    context.animationId = undefined;
  }
  context.resizeObserver?.disconnect();
  context.resizeObserver = undefined;
  context.controls?.dispose();
  context.controls = undefined;
  disposeObject3D(context.modelObject);
  context.modelObject = undefined;
  context.environmentMap?.dispose();
  context.environmentMap = null;
  context.renderer?.dispose();
  context.renderer?.domElement.remove();
  context.renderer = undefined;
  if (runtime?.context === context) {
    runtime = null;
  }
}

function disposeObject3D(object?: Object3D) {
  if (!object) return;
  object.traverse((child) => {
    const mesh = child as Object3D & {
      geometry?: { dispose?: () => void };
      material?: unknown;
    };
    mesh.geometry?.dispose?.();
    disposeMaterial(mesh.material);
  });
}

function disposeMaterial(material: unknown) {
  if (Array.isArray(material)) {
    material.forEach(disposeMaterial);
    return;
  }
  if (!material || typeof material !== "object") return;
  const record = material as Record<string, unknown> & { dispose?: () => void };
  for (const value of Object.values(record)) {
    if (value && typeof value === "object" && "isTexture" in value && "dispose" in value) {
      (value as Texture).dispose();
    }
  }
  record.dispose?.();
}

function bindThumbnailCapture(modelId: number, onThumbnailSaved?: () => Promise<void> | void) {
  document.querySelector<HTMLButtonElement>("#previewCaptureThumbBtn")?.addEventListener("click",  () => {
    screenshotModel(modelId, onThumbnailSaved).catch((error) => {

    })
  });
}

async function screenshotModel(modelId: number, onThumbnailSaved?: () => Promise<void> | void) {
  try {
    if (!runtime) {
      throw new Error("模型还未加载完成");
    }
    const { renderer, scene, camera, controls } = runtime.context;
    if (!renderer || !scene || !camera || !controls) {
      throw new Error("模型还未加载完成");
    }
    const transparent = true; // document.querySelector<HTMLInputElement>("#previewTransparentThumb")?.checked ?? false;
    controls.update();
    const oldBackground = scene.background;
    const oldClearAlpha = renderer.getClearAlpha();
    const oldPixelRatio = renderer.getPixelRatio();
    const oldWidth = renderer.domElement.width / oldPixelRatio;
    const oldHeight = renderer.domElement.height / oldPixelRatio;
    const oldAspect = camera.aspect;
    let thumbnail = "";
    try {
      if (transparent) {
        scene.background = null;
        renderer.setClearColor(0x000000, 0);
      }
      renderer.setPixelRatio(1);
      renderer.setSize(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT, false);
      camera.aspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT;
      camera.updateProjectionMatrix();
      renderer.render(scene, camera);
      thumbnail = renderer.domElement.toDataURL("image/png");
    } finally {
      camera.aspect = oldAspect;
      camera.updateProjectionMatrix();
      renderer.setPixelRatio(oldPixelRatio);
      renderer.setSize(oldWidth, oldHeight, false);
      if (transparent) {
        scene.background = oldBackground;
        renderer.setClearAlpha(oldClearAlpha);
      }
      renderer.render(scene, camera);
    }
    await api(`/api/models/${modelId}/thumbnail`, {
      method: "PUT",
      body: JSON.stringify({ thumbnail })
    });
    await onThumbnailSaved?.();
    notify("缩略图已保存");
  } catch (error) {
    notifyError(error);
  }
}

function getSelectedOperation(operations: PreviewOperation[]) {
  const selectedButton = document.querySelector<HTMLButtonElement>(".preview-process-tree li.is-active button");
  const index = Number(selectedButton?.dataset.processIndex ?? 0);
  return operations[index] ?? null;
}

function readPreviewBasePoint(): PreviewResolvedBasePoint {
  return resolveBasePoint({
    x: roundBasePointValue(readNumberInput("baseX")),
    y: roundBasePointValue(readNumberInput("baseY")),
    z: roundBasePointValue(readNumberInput("baseZ")),
    rx: degreesToRadians(readNumberInput("baseRx")),
    ry: degreesToRadians(readNumberInput("baseRy")),
    rz: degreesToRadians(readNumberInput("baseRz"))
  });
}

function readNumberInput(name: string) {
  const value = document.querySelector<HTMLInputElement>(`.preview-basepoint-grid input[name="${name}"]`)?.value ?? "0";
  const numberValue = Number(value);
  return Number.isFinite(numberValue) ? numberValue : 0;
}

function formatBasePointDisplayValue(key: keyof PreviewResolvedBasePoint, value: number) {
  const displayValue = key === "rx" || key === "ry" || key === "rz"
    ? radiansToDegrees(value)
    : value;
  return formatBasePointNumber(displayValue);
}

function radiansToDegrees(value: number) {
  return roundBasePointValue(value * RAD_TO_DEG);
}

function degreesToRadians(value: number) {
  return Number.isFinite(value) ? value * DEG_TO_RAD : 0;
}

function roundBasePointValue(value: number) {
  if (!Number.isFinite(value)) return 0;
  return Number(value.toFixed(BASE_POINT_DECIMAL_PLACES));
}

function formatBasePointNumber(value: number) {
  return roundBasePointValue(value).toFixed(BASE_POINT_DECIMAL_PLACES);
}

function bindImportScene(
  model: ModelItem,
  operations: PreviewOperation[],
  onImportScene?: (payload: PreviewImportScenePayload) => Promise<void> | void
) {
  document.querySelector<HTMLButtonElement>("#previewImportSceneBtn")?.addEventListener("click", async () => {
    try {
      const selectedOperation = getSelectedOperation(operations);
      const payload = buildImportScenePayload({
        model,
        operations,
        selectedOperation,
        basePointEnabled: document.querySelector<HTMLInputElement>("#previewUseBasePoint")?.checked ?? false,
        basePoint: readPreviewBasePoint()
      });

      emitImportScenePayload(payload);
      await onImportScene?.(payload);
      notify("导入场景数据已输出");
    } catch (error) {
      notifyError(error);
    }
  });
}

async function bindDownloadModel(model: ModelItem) {
  try {
    await downloadFileFromUrl(model.file_url, model.original_filename || model.name);
  } catch (error) {
    notifyError(error);
  }
}

function resolveBasePoint(basePoint?: PreviewBasePoint): PreviewResolvedBasePoint {
  return {
    x: basePoint?.x ?? zeroBasePoint.x,
    y: basePoint?.y ?? zeroBasePoint.y,
    z: basePoint?.z ?? zeroBasePoint.z,
    rx: basePoint?.rx ?? zeroBasePoint.rx,
    ry: basePoint?.ry ?? zeroBasePoint.ry,
    rz: basePoint?.rz ?? zeroBasePoint.rz
  };
}

function buildImportScenePayload(options: {
  model: ModelItem;
  operations: PreviewOperation[];
  selectedOperation: PreviewOperation | null;
  basePointEnabled: boolean;
  basePoint: PreviewResolvedBasePoint;
}): PreviewImportScenePayload {
  return {
    model: options.model,
    modelUrl: resolveStorageUrl(options.model.file_url),
    basePointEnabled: options.basePointEnabled,
    basePoint: options.basePoint,
    rawOperationTree: options.model.operation_tree,
    operations: options.operations,
    selectedOperation: options.selectedOperation,
    selectedCraftPlayData: options.selectedOperation?.parsedCraftPlayData ?? null
  };
}

function bindProcessPlaceholder(operations: PreviewOperation[]) {
  const updateStatus = (operation: PreviewOperation | null) => {
    const status = document.querySelector<HTMLDivElement>("#previewProcessStatus");
    if (status) {
      status.innerHTML = operationStatusHtml(operation);
    }
  };

  document.querySelectorAll<HTMLButtonElement>(".preview-process-tree button").forEach((button) => {
    button.addEventListener("click", () => {
      document.querySelectorAll(".preview-process-tree li").forEach((item) => item.classList.remove("is-active"));
      button.closest("li")?.classList.add("is-active");
      const index = Number(button.dataset.processIndex ?? 0);
      stopPreviewPlayback();
      updateStatus(operations[index] ?? null);
    });
  });

  document.querySelector<HTMLButtonElement>("#previewPlayBtn")?.addEventListener("click", () => {
    if (operations.length === 0) {
      notify("当前模型暂无工艺数据");
      return;
    }
    const operation = getSelectedOperation(operations);
    updateStatus(operation);
    if (!operation) {
      notify("请先选择工艺");
      return;
    }
    if (operation.parseError) {
      notifyError(`工艺播放数据解析失败：${operation.parseError}`);
      return;
    }
    if (!operation.parsedCraftPlayData || operation.parsedCraftPlayData.OPERATION.frames.length === 0) {
      notify("当前工艺暂无播放数据");
      return;
    }
    startPreviewPlayback(operation);
  });

  document.querySelector<HTMLButtonElement>("#previewPauseBtn")?.addEventListener("click", () => {
    updateStatus(getSelectedOperation(operations));
    pausePreviewPlayback();
  });
}

function startPreviewPlayback(operation: PreviewOperation) {
  if (!runtime?.context.modelObject) {
    notify("模型还未加载完成");
    return;
  }
  if (!operation.parsedCraftPlayData || operation.parsedCraftPlayData.OPERATION.frames.length === 0) {
    notify("当前工艺暂无播放数据");
    return;
  }

  if (previewPlayback.isPlaying && previewPlayback.operation === operation) {
    return;
  }

  if (previewPlayback.operation === operation && previewPlayback.currentTimeMs > 0 && previewPlayback.currentTimeMs < previewPlayback.totalTimeMs) {
    resumePreviewPlayback();
    return;
  }

  resetPreviewPlaybackScene();
  const frames = operation.parsedCraftPlayData.OPERATION.frames;
  const frameTimesMs = buildPreviewFrameTimes(frames);
  previewPlayback = {
    operation,
    isPlaying: true,
    rafId: null,
    startedAtMs: performance.now(),
    baseTimeMs: 0,
    currentTimeMs: 0,
    currentFrameIndex: -1,
    frameTimesMs,
    totalTimeMs: frameTimesMs[frameTimesMs.length - 1] ?? 0,
    objectSnapshots: capturePreviewObjectSnapshots(runtime.context.modelObject),
    objectMap: buildPreviewObjectMap(runtime.context.modelObject, operation),
    attachedOriginalParents: new Map(),
    rotatingObjects: new Map()
  };
  setPlaybackButtons(true);
  updatePreviewPlaybackStatus(operation, 0);
  previewPlayback.rafId = window.requestAnimationFrame(stepPreviewPlayback);
}

function pausePreviewPlayback() {
  if (!previewPlayback.isPlaying) return;
  refreshPreviewPlaybackClock();
  cancelPreviewPlaybackRaf();
  previewPlayback.isPlaying = false;
  setPlaybackButtons(false);
  updatePreviewPlaybackStatus(previewPlayback.operation, previewPlayback.currentTimeMs);
}

function resumePreviewPlayback() {
  if (!previewPlayback.operation || previewPlayback.isPlaying) return;
  if (previewPlayback.currentTimeMs >= previewPlayback.totalTimeMs) {
    resetPreviewPlaybackScene();
    previewPlayback.currentTimeMs = 0;
    previewPlayback.currentFrameIndex = -1;
    previewPlayback.baseTimeMs = 0;
  }
  previewPlayback.isPlaying = true;
  previewPlayback.startedAtMs = performance.now();
  previewPlayback.baseTimeMs = previewPlayback.currentTimeMs;
  setPlaybackButtons(true);
  previewPlayback.rafId = window.requestAnimationFrame(stepPreviewPlayback);
}

function stopPreviewPlayback() {
  cancelPreviewPlaybackRaf();
  resetPreviewPlaybackScene();
  previewPlayback = createEmptyPlaybackState();
  setPlaybackButtons(false);
}

function stepPreviewPlayback(timestamp: number) {
  if (!previewPlayback.isPlaying || !previewPlayback.operation?.parsedCraftPlayData || !runtime?.context.modelObject) return;

  const nextTimeMs = Math.min(
    previewPlayback.baseTimeMs + Math.max(0, timestamp - previewPlayback.startedAtMs),
    previewPlayback.totalTimeMs
  );
  applyPreviewContinuousRotations(nextTimeMs);
  previewPlayback.currentTimeMs = nextTimeMs;
  const nextFrameIndex = resolvePreviewFrameIndex(previewPlayback.currentTimeMs, previewPlayback.frameTimesMs);
  applyPreviewFramesUntil(nextFrameIndex);
  updatePreviewPlaybackStatus(previewPlayback.operation, previewPlayback.currentTimeMs);

  if (previewPlayback.currentTimeMs >= previewPlayback.totalTimeMs) {
    cancelPreviewPlaybackRaf();
    previewPlayback.isPlaying = false;
    setPlaybackButtons(false);
    return;
  }

  previewPlayback.rafId = window.requestAnimationFrame(stepPreviewPlayback);
}

function refreshPreviewPlaybackClock() {
  if (!previewPlayback.isPlaying) return;
  previewPlayback.currentTimeMs = Math.min(
    previewPlayback.baseTimeMs + Math.max(0, performance.now() - previewPlayback.startedAtMs),
    previewPlayback.totalTimeMs
  );
  previewPlayback.baseTimeMs = previewPlayback.currentTimeMs;
  previewPlayback.startedAtMs = performance.now();
}

function applyPreviewFramesUntil(nextFrameIndex: number) {
  const frames = previewPlayback.operation?.parsedCraftPlayData?.OPERATION.frames ?? [];
  if (nextFrameIndex <= previewPlayback.currentFrameIndex) return;
  for (let index = previewPlayback.currentFrameIndex + 1; index <= nextFrameIndex && index < frames.length; index += 1) {
    applyPreviewFrame(frames[index]);
  }
  previewPlayback.currentFrameIndex = Math.min(nextFrameIndex, frames.length - 1);
}

function applyPreviewFrame(frame: P_OPERATION["OPERATION"]["frames"][number]) {
  for (const attachData of frame.attachs ?? []) {
    if (attachData.IsAttach) {
      applyPreviewAttach(attachData.AttachToModelCode, attachData.List_AttachModel ?? []);
    }
  }
  for (const visibleData of frame.visibles ?? []) {
    const object = findPreviewObject(visibleData.ModelCode);
    if (object) object.visible = parseInteger(visibleData.Visible) === 1;
  }
  for (const craftData of frame.crafts ?? []) {
    if (parseInteger(craftData.CraftValue) === 1) {
      const operation = findPreviewOperation(craftData.CraftCode);
      if (operation?.parsedCraftPlayData) {
        applyPreviewFrame(operation.parsedCraftPlayData.OPERATION.frames[0]);
      }
    }
  }
  for (const rotationData of frame.rotations ?? []) {
    applyPreviewRotation(rotationData.ModelCode, rotationData.Axis, Number(rotationData.Rate));
  }
  for (const moveData of frame.objStates ?? []) {
    applyPreviewObjState(moveData);
  }
  for (const attachData of frame.attachs ?? []) {
    if (!attachData.IsAttach) {
      detachPreviewAttach(attachData.List_AttachModel ?? []);
    }
  }
}

function applyPreviewObjState(moveData: P_OPERATION["OPERATION"]["frames"][number]["objStates"][number]) {
  const object = findPreviewObject(moveData.i);
  if (!object || !isValidFrameTransform(moveData)) return;
  const position = parsePreviewNumbers(moveData.tx, moveData.ty, moveData.tz);
  const quaternion = parsePreviewNumbers(moveData.qx, moveData.qy, moveData.qz, moveData.qw);
  if (!position || !quaternion) return;
  object.position.set(position[0], position[1], position[2]);
  object.quaternion.set(quaternion[0], quaternion[1], quaternion[2], quaternion[3]);
  object.updateMatrixWorld();
}

function applyPreviewRotation(modelCode: string, axisValue: string, rate: number) {
  const object = findPreviewObject(modelCode);
  const axis = normalizeAxis(axisValue);
  if (!object || !axis || !Number.isFinite(rate)) return;
  if (Math.abs(rate) <= 0.000001) {
    previewPlayback.rotatingObjects.delete(object);
    return;
  }
  previewPlayback.rotatingObjects.set(object, { axis, rate });
}

function applyPreviewContinuousRotations(nextTimeMs: number) {
  if (previewPlayback.rotatingObjects.size === 0) return;
  const deltaSeconds = Math.max(0, nextTimeMs - previewPlayback.currentTimeMs) / SECOND_MS;
  for (const [object, rotation] of previewPlayback.rotatingObjects) {
    object.rotation[rotation.axis] += rotation.rate * deltaSeconds;
    object.updateMatrixWorld();
  }
}

function applyPreviewAttach(rootCode: string, attachModels: Array<{ ModelCode: string }>) {
  const root = findPreviewObject(rootCode);
  if (!root) return;
  for (const attachModel of attachModels) {
    const object = findPreviewObject(attachModel.ModelCode);
    if (!object || object === root) continue;
    if (!previewPlayback.attachedOriginalParents.has(object)) {
      previewPlayback.attachedOriginalParents.set(object, object.parent);
    }
    root.attach(object);
  }
}

function detachPreviewAttach(attachModels: Array<{ ModelCode: string }>) {
  for (const attachModel of attachModels) {
    const object = findPreviewObject(attachModel.ModelCode);
    if (!object) continue;
    const originalParent = previewPlayback.attachedOriginalParents.get(object);
    if (originalParent) {
      originalParent.attach(object);
    } else {
      runtime?.scene.attach(object);
    }
    previewPlayback.attachedOriginalParents.delete(object);
  }
}

function resetPreviewPlaybackScene() {
  cancelPreviewPlaybackRaf();
  previewPlayback.rotatingObjects.clear();
  for (const [object, originalParent] of previewPlayback.attachedOriginalParents) {
    if (originalParent) {
      originalParent.attach(object);
    }
  }
  previewPlayback.attachedOriginalParents.clear();
  for (const [object, snapshot] of previewPlayback.objectSnapshots) {
    if (snapshot.parent && object.parent !== snapshot.parent) {
      snapshot.parent.attach(object);
    }
    object.position.copy(snapshot.position);
    object.quaternion.copy(snapshot.quaternion);
    object.visible = snapshot.visible;
    object.updateMatrixWorld();
  }
}

function buildPreviewFrameTimes(frames: P_OPERATION["OPERATION"]["frames"]) {
  return frames.map((_, index) => index * DEFAULT_PREVIEW_FRAME_INTERVAL_MS);
}

function resolvePreviewFrameIndex(currentTimeMs: number, frameTimesMs: number[]) {
  let index = -1;
  for (let i = 0; i < frameTimesMs.length; i += 1) {
    if (currentTimeMs >= frameTimesMs[i]) index = i;
    else break;
  }
  return index;
}

function buildPreviewObjectMap(root: Object3D, operation: PreviewOperation) {
  const objectMap = new Map<string, Object3D>();
  root.traverse((object) => {
    const values = [
      object.userData?.ModelId,
      object.userData?.ModelCode,
      object.userData?.RootId,
      object.userData?.id,
      object.name
    ];
    for (const value of values) {
      const key = normalizePreviewCode(value);
      if (key && !objectMap.has(key)) objectMap.set(key, object);
    }
  });
  const targetCodes = collectPreviewOperationModelCodes(operation);
  if (targetCodes.length > 0 && !targetCodes.some(code => objectMap.has(code))) {
    for (const code of targetCodes) objectMap.set(code, root);
  }
  return objectMap;
}

function capturePreviewObjectSnapshots(root: Object3D) {
  const snapshots = new Map<Object3D, PreviewObjectSnapshot>();
  root.traverse((object) => {
    snapshots.set(object, {
      position: object.position.clone(),
      quaternion: object.quaternion.clone(),
      visible: object.visible,
      parent: object.parent
    });
  });
  return snapshots;
}

function collectPreviewOperationModelCodes(operation: PreviewOperation) {
  const codes = new Set<string>();
  if (operation.ModelCode) codes.add(normalizePreviewCode(operation.ModelCode));
  if (operation.ModelCodeParent) codes.add(normalizePreviewCode(operation.ModelCodeParent));
  for (const frame of operation.parsedCraftPlayData?.OPERATION.frames ?? []) {
    for (const state of frame.objStates ?? []) codes.add(normalizePreviewCode(state.i));
    for (const item of frame.visibles ?? []) codes.add(normalizePreviewCode(item.ModelCode));
    for (const item of frame.rotations ?? []) codes.add(normalizePreviewCode(item.ModelCode));
    for (const item of frame.attachs ?? []) {
      codes.add(normalizePreviewCode(item.AttachToModelCode));
      for (const child of item.List_AttachModel ?? []) codes.add(normalizePreviewCode(child.ModelCode));
    }
  }
  return [...codes].filter(Boolean);
}

function findPreviewObject(modelCode: string) {
  const code = normalizePreviewCode(modelCode);
  return code ? previewPlayback.objectMap.get(code) ?? null : null;
}

function findPreviewOperation(craftCode: string) {
  const code = normalizePreviewCode(craftCode);
  if (!code || !previewPlayback.operation) return null;
  return previewPlayback.operation.CraftCode === code ? previewPlayback.operation : null;
}

function parsePreviewNumbers(...valuesRaw: string[]) {
  const values = valuesRaw.map(Number);
  if (values.some(value => !Number.isFinite(value))) return null;
  return values;
}

function isValidFrameTransform(moveData: P_OPERATION["OPERATION"]["frames"][number]["objStates"][number]) {
  return [moveData.tx, moveData.ty, moveData.tz, moveData.qx, moveData.qy, moveData.qz, moveData.qw]
    .every(value => value !== "NaN" && value !== "");
}

function normalizeAxis(axis: string): "x" | "y" | "z" | null {
  const value = axis.trim().toLowerCase();
  return value === "x" || value === "y" || value === "z" ? value : null;
}

function parseInteger(value: string) {
  const parsed = Number.parseInt(value, 10);
  return Number.isFinite(parsed) ? parsed : 0;
}

function normalizePreviewCode(value: unknown) {
  return String(value ?? "").trim();
}

function updatePreviewPlaybackStatus(operation: PreviewOperation | null, currentTimeMs: number) {
  const status = document.querySelector<HTMLDivElement>("#previewProcessStatus");
  if (!status || !operation) return;
  const total = Math.max(previewPlayback.totalTimeMs, 0);
  const frameText = previewPlayback.currentFrameIndex >= 0
    ? `${previewPlayback.currentFrameIndex + 1}/${operation.frameCount}`
    : `0/${operation.frameCount}`;
  status.innerHTML = `播放 ${frameText}，${formatPlaybackSeconds(currentTimeMs)}/${formatPlaybackSeconds(total)}s`;
}

function setPlaybackButtons(isPlaying: boolean) {
  const playButton = document.querySelector<HTMLButtonElement>("#previewPlayBtn");
  const pauseButton = document.querySelector<HTMLButtonElement>("#previewPauseBtn");
  if (playButton) playButton.textContent = isPlaying ? "播放中" : "播放";
  if (pauseButton) pauseButton.disabled = !isPlaying;
}

function cancelPreviewPlaybackRaf() {
  if (previewPlayback.rafId !== null) {
    window.cancelAnimationFrame(previewPlayback.rafId);
    previewPlayback.rafId = null;
  }
}

function createEmptyPlaybackState(): PreviewPlaybackState {
  return {
    operation: null,
    isPlaying: false,
    rafId: null,
    startedAtMs: 0,
    baseTimeMs: 0,
    currentTimeMs: 0,
    currentFrameIndex: -1,
    frameTimesMs: [],
    totalTimeMs: 0,
    objectSnapshots: new Map(),
    objectMap: new Map(),
    attachedOriginalParents: new Map(),
    rotatingObjects: new Map()
  };
}

function formatPlaybackSeconds(ms: number) {
  return (Math.max(0, ms) / SECOND_MS).toFixed(2);
}

async function initPreview(url: string, context: PreviewLoadContext) {
  ensurePreviewContext(context);
  const viewport = document.querySelector<HTMLDivElement>("#modelPreviewViewport");
  if (!viewport) return;
  viewport.innerHTML = previewLoadingHtml("正在加载 3D 预览引擎...");

  const [THREE, { OrbitControls: OrbitControlsClass }, { GLTFLoader: GLTFLoaderClass }, { EXRLoader: EXRLoaderClass }] = await Promise.all([
    import("three"),
    import("three/examples/jsm/controls/OrbitControls.js"),
    import("three/examples/jsm/loaders/GLTFLoader.js"),
    import("three/examples/jsm/loaders/EXRLoader.js")
  ]);
  ensurePreviewContext(context);

  THREE.Object3D.DEFAULT_UP.set(0, 0, 1);

  const scene = new THREE.Scene();
  scene.background = new THREE.Color(0xf4f7f9);

  const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 1000);
  camera.up.set(0, 0, 1);
  camera.position.set(3, -4, 2.5);

  const renderer = new THREE.WebGLRenderer({
    antialias: true,
    alpha: true,
    preserveDrawingBuffer: true
  });
  ensurePreviewContext(context);
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  renderer.outputColorSpace = THREE.SRGBColorSpace;
  viewport.appendChild(renderer.domElement);
  context.renderer = renderer;

  ensurePreviewContext(context);
  const environmentMap = await loadPreviewEnvironment(THREE, EXRLoaderClass, renderer, scene, context);
  context.environmentMap = environmentMap;
  ensurePreviewContext(context);

  const controls = new OrbitControlsClass(camera, renderer.domElement);
  controls.enableDamping = true;
  context.controls = controls;

  updatePreviewLoadingByText("正在请求模型文件...", 4);
  const loader = new GLTFLoaderClass();
  const gltf = await loadGltfWithProgress(loader, url, context);
  const object = gltf.scene;
  if (!isPreviewContextActive(context)) {
    disposeObject3D(object);
    throw new DOMException("预览已关闭", "AbortError");
  }
  context.modelObject = object;
  scene.add(object);
  fitCameraToObject(THREE, camera, controls, object);
  hidePreviewLoading();
  context.camera = camera;
  context.scene = scene;

  const resize = () => {
    const width = Math.max(viewport.clientWidth, 1);
    const height = Math.max(viewport.clientHeight, 1);
    camera.aspect = width / height;
    camera.updateProjectionMatrix();
    renderer.setSize(width, height, false);
  };

  const resizeObserver = new ResizeObserver(resize);
  resizeObserver.observe(viewport);
  context.resizeObserver = resizeObserver;
  resize();

  const animate = () => {
    controls.update();
    renderer.render(scene, camera);
    if (runtime?.context === context && !context.disposed) {
      context.animationId = requestAnimationFrame(animate);
    }
  };

  runtime = {
    renderer,
    scene,
    camera,
    context
  };
  context.animationId = requestAnimationFrame(animate);
}

async function loadPreviewEnvironment(
  THREE: ThreeModule,
  EXRLoaderClass: typeof import("three/examples/jsm/loaders/EXRLoader.js").EXRLoader,
  renderer: WebGLRenderer,
  scene: Scene,
  context: PreviewLoadContext
) {
  try {
    updatePreviewLoadingByText("正在准备环境光...", 2);
    const exrTexture = await getPreviewEnvironmentTexture(EXRLoaderClass);
    ensurePreviewContext(context);
    exrTexture.mapping = THREE.EquirectangularReflectionMapping;
    const pmremGenerator = new THREE.PMREMGenerator(renderer);
    const environmentMap = pmremGenerator.fromEquirectangular(exrTexture).texture;
    scene.environment = environmentMap;
    // scene.background = environmentMap;
    pmremGenerator.dispose();
    return environmentMap;
  } catch (error) {
    if (isAbortError(error)) throw error;
    scene.background = new THREE.Color(0xf4f7f9);
    return null;
  }
}

function getPreviewEnvironmentTexture(
  EXRLoaderClass: typeof import("three/examples/jsm/loaders/EXRLoader.js").EXRLoader
) {
  if (!previewEnvironmentTexturePromise) {
    previewEnvironmentTexturePromise = new EXRLoaderClass()
      .loadAsync(previewEnvironmentUrl)
      .catch((error) => {
        previewEnvironmentTexturePromise = null;
        throw error;
      });
  }
  return previewEnvironmentTexturePromise;
}

async function loadGltfWithProgress(loader: GLTFLoader, url: string, context: PreviewLoadContext) {
  try {
    const buffer = await fetchModelArrayBuffer(url, context);
    ensurePreviewContext(context);
    updatePreviewLoadingByText("正在解析模型...", 96);
    const gltf = await (loader as ParseableGltfLoader).parseAsync(buffer, basePathFromUrl(url));
    ensurePreviewContext(context);
    updatePreviewLoadingByText("模型解析完成", 100);
    return gltf;
  } catch (error) {
    if (!isAbortError(error)) {
      showPreviewLoadingError(error instanceof Error ? error.message : "模型文件加载失败");
    }
    throw error;
  }
}

async function fetchModelArrayBuffer(url: string, context: PreviewLoadContext) {
  const response = await fetch(url, { signal: context.controller.signal });
  ensurePreviewContext(context);
  if (!response.ok) {
    throw new Error(`模型文件请求失败：${response.status}`);
  }

  const total = Number(response.headers.get("content-length") ?? 0);
  if (!response.body) {
    const buffer = await response.arrayBuffer();
    ensurePreviewContext(context);
    updatePreviewLoadingByText(`已加载 ${formatBytes(buffer.byteLength)}`, 92);
    return buffer;
  }

  const reader = response.body.getReader();
  const chunks: Uint8Array[] = [];
  let loaded = 0;

  while (true) {
    ensurePreviewContext(context);
    const { done, value } = await reader.read();
    if (done) break;
    if (!value) continue;
    chunks.push(value);
    loaded += value.byteLength;
    updatePreviewLoadingFromBytes(loaded, total);
  }
  ensurePreviewContext(context);

  const buffer = new Uint8Array(loaded);
  let offset = 0;
  for (const chunk of chunks) {
    buffer.set(chunk, offset);
    offset += chunk.byteLength;
  }
  return buffer.buffer;
}

function updatePreviewLoadingFromBytes(loaded: number, total: number) {
  if (total > 0) {
    updatePreviewLoadingByText(`${Math.round((loaded / total) * 100)}% (${formatBytes(loaded)} / ${formatBytes(total)})`, (loaded / total) * 92);
    return;
  }
  updatePreviewLoadingByText(`已加载 ${formatBytes(loaded)}`, 28);
}

function updatePreviewLoadingByText(message: string, percent: number) {
  const text = document.querySelector<HTMLElement>("#previewLoadingText");
  const bar = document.querySelector<HTMLElement>("#previewLoadingBar");
  if (!text || !bar) return;
  text.textContent = message;
  bar.style.width = `${Math.min(100, Math.max(0, Math.round(percent)))}%`;
}

function basePathFromUrl(url: string) {
  try {
    return new URL(".", url).href;
  } catch {
    return url.slice(0, url.lastIndexOf("/") + 1);
  }
}

function fitCameraToObject(THREE: ThreeModule, camera: PerspectiveCamera, controls: OrbitControls, object: Object3D) {
  const box = new THREE.Box3().setFromObject(object);
  const size = box.getSize(new THREE.Vector3());
  const center = box.getCenter(new THREE.Vector3());
  const maxSize = Math.max(size.x, size.y, size.z) || 1;
  const distance = maxSize / (2 * Math.tan((camera.fov * Math.PI) / 360));

  camera.up.set(0, 0, 1);
  camera.position.copy(center).add(new THREE.Vector3(distance * 0.9, -distance * 1.15, distance * 0.65));
  camera.near = Math.max(distance / 100, 0.01);
  camera.far = distance * 100;
  camera.updateProjectionMatrix();

  controls.target.copy(center);
  controls.minDistance = distance / 8;
  controls.maxDistance = distance * 8;
  controls.update();
}

function disposePreview() {
  const context = activePreviewContext ?? runtime?.context ?? null;
  if (context) {
    context.disposed = true;
    context.controller.abort();
    cleanupPreviewContextResources(context);
    if (activePreviewContext === context) {
      activePreviewContext = null;
    }
  }
  runtime = null;
}
