import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
  Settings2,
  Download,
  RotateCcw,
  RotateCw,
  Rotate3d,
  AlertCircle,
  ChevronDown,
  ChevronUp,
  Eye,
  Lock,
  Maximize2,
  RefreshCcw,
  Save,
  Upload,
  FlipHorizontal2,
  FlipVertical2,
  Move3d,
} from 'lucide-react';
import * as THREE from 'three';
import { DicomFusionVolume, DicomPreview, ModelPose, ModuleStyle, Project, SavedModelPose } from '../types';
import { api, downloadProjectExportBundle, ProjectExportTarget, SegmentationExportMode, SegmentationExportScope } from '../lib/api';

export interface ModelPreviewPayload {
  fileName: string;
  triangleCount: number;
  sampledTriangles: number;
  vertices: number[];
  bounds?: {
    min: { x: number; y: number; z: number };
    max: { x: number; y: number; z: number };
  };
}

export type DisplayLevel = 'standard' | 'fine' | 'ultra' | 'solid';
export type DicomOpacityLevel = 'low' | 'medium' | 'high';
export type MappingDisplayMode = DicomPreview['mode'];
type ModelPoseKey = Exclude<keyof ModelPose, 'flipX' | 'flipY' | 'flipZ'>;
type ModelPoseFlipKey = Extract<keyof ModelPose, 'flipX' | 'flipY' | 'flipZ'>;
type PoseDraftValues = Record<ModelPoseKey, string>;
type AxisKey = 'x' | 'y' | 'z';

interface AxisVector2D {
  dx: number;
  dy: number;
  opacity: number;
}

type AxisProjection = Record<AxisKey, AxisVector2D>;
type WorkspaceLeaveGuard = () => Promise<boolean>;

interface WorkspaceLoadState {
  ready: boolean;
  phase: string;
  loaded: number;
  total: number;
  startedAt: number;
  error: string;
}

const modelPoseKeys: ModelPoseKey[] = ['rotateX', 'rotateY', 'rotateZ', 'translateX', 'translateY', 'translateZ', 'scale'];
const modelPoseFlipOptions: Array<{ key: ModelPoseFlipKey; label: string; axis: string; icon: typeof FlipHorizontal2 }> = [
  { key: 'flipX', label: '镜像 X', axis: 'X', icon: FlipHorizontal2 },
  { key: 'flipY', label: '镜像 Y', axis: 'Y', icon: FlipVertical2 },
  { key: 'flipZ', label: '镜像 Z', axis: 'Z', icon: Move3d },
];

export const displayOptions: Array<{ id: DisplayLevel; label: string; limit: number }> = [
  { id: 'standard', label: '标准', limit: 16000 },
  { id: 'fine', label: '精细', limit: 36000 },
  { id: 'ultra', label: '超精细', limit: 72000 },
  { id: 'solid', label: '实体', limit: 800000 },
];
export const dicomOpacityOptions: Array<{ id: DicomOpacityLevel; label: string; sliceOpacity: number; volumeOpacity: number; boxOpacity: number }> = [
  { id: 'low', label: '低', sliceOpacity: 0.82, volumeOpacity: 0.12, boxOpacity: 0.32 },
  { id: 'medium', label: '中', sliceOpacity: 0.92, volumeOpacity: 0.2, boxOpacity: 0.42 },
  { id: 'high', label: '高', sliceOpacity: 1, volumeOpacity: 0.32, boxOpacity: 0.54 },
];
const mappingDisplayModes: Array<{ id: MappingDisplayMode; label: string }> = [
  { id: 'default', label: '默认' },
  { id: 'bone', label: '骨窗' },
  { id: 'soft', label: '软组织' },
  { id: 'contrast', label: '高对比' },
];
const poseStepConfig: Record<ModelPoseKey, { min: number; max: number; step: number; minus: string; plus: string; quick?: number }> = {
  rotateX: { min: -180, max: 180, step: 1, minus: '-90°', plus: '+90°', quick: 90 },
  rotateY: { min: -180, max: 180, step: 1, minus: '-90°', plus: '+90°', quick: 90 },
  rotateZ: { min: -180, max: 180, step: 1, minus: '-90°', plus: '+90°', quick: 90 },
  translateX: { min: -2, max: 2, step: 0.001, minus: '-0.001', plus: '+0.001' },
  translateY: { min: -2, max: 2, step: 0.001, minus: '-0.001', plus: '+0.001' },
  translateZ: { min: -2, max: 2, step: 0.001, minus: '-0.001', plus: '+0.001' },
  scale: { min: 0.5, max: 3, step: 0.001, minus: '-0.001', plus: '+0.001' },
};
const poseRepeatDelayMs = 240;
const poseRepeatIntervalMs = 55;
const poseRepeatDeltaMultiplier: Partial<Record<ModelPoseKey, number>> = {
  translateX: 5,
  translateY: 5,
  translateZ: 5,
  scale: 5,
};

const defaultModelPose: ModelPose = {
  rotateX: 0,
  rotateY: 0,
  rotateZ: 0,
  translateX: 0,
  translateY: 0,
  translateZ: 0,
  scale: 1,
  flipX: false,
  flipY: false,
  flipZ: false,
};

const defaultSavedPoses: SavedModelPose[] = [
  { id: 'default', name: '默认', pose: defaultModelPose },
  { id: 'top', name: '俯视', pose: { ...defaultModelPose, rotateX: 0, rotateY: 0, rotateZ: 0 } },
  { id: 'side', name: '侧视', pose: { ...defaultModelPose, rotateX: 0, rotateY: 90, rotateZ: 0 } },
];
const exportOptions: Array<{ id: ProjectExportTarget; label: string; description: string }> = [
  { id: 'dicom', label: 'DICOM 原始影像', description: '主影像 NII.GZ' },
  { id: 'stl', label: 'STL 原始模型', description: '原始三维构件' },
  { id: 'pose', label: '位姿数据', description: 'JSON 侧车' },
  { id: 'segmentation', label: '分割影像', description: '同维度 Label Map' },
];
const segmentationScopeOptions: Array<{ id: SegmentationExportScope; label: string; description: string }> = [
  { id: 'visible', label: '可见类别', description: '仅导出当前显示构件' },
  { id: 'all', label: '所有类别', description: '包含隐藏构件' },
];
const segmentationExportModeOptions: Array<{ id: SegmentationExportMode; label: string; description: string }> = [
  { id: 'combined', label: '构件整体导出', description: '生成一个多标签 Label Map' },
  { id: 'separate', label: '构件分别导出', description: '全部构件集中到同一目录' },
];
const moduleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899'];
const fusionBaseExtent = 4.6;
const axisInsetLength = 17;
const defaultAxisProjection: AxisProjection = {
  x: { dx: axisInsetLength, dy: 0, opacity: 0.95 },
  y: { dx: -10, dy: 10, opacity: 0.82 },
  z: { dx: 0, dy: -axisInsetLength, opacity: 0.95 },
};
const dicomPreviewCache = new Map<string, Promise<DicomPreview>>();
const dicomFusionVolumeCache = new Map<string, Promise<DicomFusionVolume>>();
const modelPreviewCache = new Map<string, Promise<ModelPreviewPayload>>();

function rememberRequest<T>(cache: Map<string, Promise<T>>, key: string, loader: () => Promise<T>) {
  const cached = cache.get(key);
  if (cached) {
    return cached;
  }

  const request = loader().catch((error) => {
    cache.delete(key);
    throw error;
  });
  cache.set(key, request);
  return request;
}

export function getCachedDicomPreview(
  projectId: string,
  slice: number,
  plane: DicomPreview['plane'] = 'axial',
  mode: DicomPreview['mode'] = 'default',
) {
  return rememberRequest(
    dicomPreviewCache,
    `${projectId}:${plane}:${mode}:${slice}`,
    () => api.getDicomPreview(projectId, slice, plane, mode),
  );
}

export function getCachedDicomFusionVolume(
  projectId: string,
  start: number,
  end: number,
  mode: DicomPreview['mode'] = 'soft',
) {
  const safeStart = Math.min(start, end);
  const safeEnd = Math.max(start, end);
  return rememberRequest(
    dicomFusionVolumeCache,
    `${projectId}:${mode}:${safeStart}:${safeEnd}`,
    () => api.getDicomFusionVolume(projectId, safeStart, safeEnd, mode),
  );
}

export function getCachedModelPreview(projectId: string, fileName: string, limit: number) {
  const safeLimit = Math.max(1, Math.round(limit));
  return rememberRequest(
    modelPreviewCache,
    `${projectId}:${fileName}:${safeLimit}`,
    () => fetch(`/api/projects/${projectId}/models/${encodeURIComponent(fileName)}/preview?limit=${safeLimit}`)
      .then((response) => {
        if (!response.ok) {
          throw new Error('模型预览数据加载失败');
        }
        return response.json() as Promise<ModelPreviewPayload>;
      }),
  );
}

export function clearCachedProjectAssets(projectId: string) {
  [dicomPreviewCache, dicomFusionVolumeCache, modelPreviewCache].forEach((cache) => {
    [...cache.keys()].forEach((key) => {
      if (key.startsWith(`${projectId}:`)) {
        cache.delete(key);
      }
    });
  });
}

function clamp(value: number, min: number, max: number) {
  return Math.max(min, Math.min(max, value));
}

function getDicomDisplaySliceNumber(sliceIndex: number, totalSlices: number) {
  const total = Math.max(Math.round(totalSlices), 0);
  if (!total) {
    return 0;
  }
  return total - clamp(Math.round(sliceIndex), 0, total - 1);
}

function getDicomDisplayRange(startIndex: number, endIndex: number, totalSlices: number) {
  const first = getDicomDisplaySliceNumber(startIndex, totalSlices);
  const second = getDicomDisplaySliceNumber(endIndex, totalSlices);
  return {
    start: Math.min(first, second),
    end: Math.max(first, second),
  };
}

function getStepPrecision(step: number) {
  if (step >= 1) {
    return 0;
  }

  const text = step.toString();
  if (text.includes('e-')) {
    return Number(text.split('e-')[1] ?? 2);
  }

  return text.split('.')[1]?.length ?? 0;
}

function formatPoseValue(key: ModelPoseKey, value: number) {
  return Number(value).toFixed(getStepPrecision(poseStepConfig[key].step));
}

function formatPoseDraftValues(pose: ModelPose): PoseDraftValues {
  return modelPoseKeys.reduce((accumulator, key) => ({
    ...accumulator,
    [key]: formatPoseValue(key, pose[key]),
  }), {} as PoseDraftValues);
}

function isNinetyDegreeMultiple(value: number) {
  const normalized = ((value % 90) + 90) % 90;
  return Math.min(normalized, 90 - normalized) < 1e-6;
}

function isOrthogonalModelPose(pose: ModelPose) {
  return isNinetyDegreeMultiple(pose.rotateX)
    && isNinetyDegreeMultiple(pose.rotateY)
    && isNinetyDegreeMultiple(pose.rotateZ);
}

function getRotatedModelSize(bounds: { min: THREE.Vector3; max: THREE.Vector3 }, pose: ModelPose) {
  const center = new THREE.Vector3().addVectors(bounds.min, bounds.max).multiplyScalar(0.5);
  const rotationMatrix = new THREE.Matrix4().makeRotationFromEuler(new THREE.Euler(
    THREE.MathUtils.degToRad(pose.rotateX),
    THREE.MathUtils.degToRad(pose.rotateY),
    THREE.MathUtils.degToRad(pose.rotateZ),
  ));
  const rotatedBox = new THREE.Box3();
  [bounds.min.x, bounds.max.x].forEach((x) => {
    [bounds.min.y, bounds.max.y].forEach((y) => {
      [bounds.min.z, bounds.max.z].forEach((z) => {
        const point = new THREE.Vector3(x, y, z).sub(center).applyMatrix4(rotationMatrix);
        rotatedBox.expandByPoint(point);
      });
    });
  });
  return rotatedBox.getSize(new THREE.Vector3());
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function normalizePoseValue(input: unknown, fallback: ModelPose = defaultModelPose): ModelPose | null {
  if (!isRecord(input)) {
    return null;
  }

  let hasPoseValue = false;
  const normalized = { ...fallback };
  modelPoseKeys.forEach((key) => {
    const rawValue = input[key];
    const numericValue = typeof rawValue === 'number' ? rawValue : Number(rawValue);
    if (!Number.isFinite(numericValue)) {
      return;
    }

    const limit = poseStepConfig[key];
    normalized[key] = clamp(numericValue, limit.min, limit.max);
    hasPoseValue = true;
  });
  modelPoseFlipOptions.forEach(({ key }) => {
    const rawValue = input[key];
    if (typeof rawValue !== 'boolean') {
      return;
    }
    normalized[key] = rawValue;
    hasPoseValue = true;
  });

  return hasPoseValue ? normalized : null;
}

function normalizeImportedModelPoses(input: unknown): SavedModelPose[] | null {
  if (!Array.isArray(input)) {
    return null;
  }

  const normalized = input
    .map((item, index) => {
      if (!isRecord(item)) {
        return null;
      }

      const pose = normalizePoseValue(item.pose);
      if (!pose) {
        return null;
      }

      const rawId = typeof item.id === 'string' && item.id.trim()
        ? item.id.trim()
        : `imported-pose-${Date.now()}-${index}`;
      const rawName = typeof item.name === 'string' && item.name.trim()
        ? item.name.trim()
        : `导入位姿${index + 1}`;

      return {
        id: rawId.slice(0, 80),
        name: rawName.slice(0, 80),
        pose,
      };
    })
    .filter((item): item is SavedModelPose => Boolean(item));

  if (!normalized.length) {
    return null;
  }

  const deduped = new Map<string, SavedModelPose>();
  normalized.forEach((item) => {
    deduped.set(item.id, item);
  });

  return [...deduped.values()];
}

function mergeImportedModelPoses(imported: SavedModelPose[]) {
  const importedById = new Map(imported.map((pose) => [pose.id, pose]));
  const defaults = defaultSavedPoses.map((pose) => importedById.get(pose.id) ?? pose);
  const custom = imported.filter((pose) => !defaultSavedPoses.some((item) => item.id === pose.id));

  return [...defaults, ...custom];
}

function poseValuesMatch(left: ModelPose, right: ModelPose) {
  return modelPoseKeys.every((key) => Math.abs(left[key] - right[key]) < 1e-6)
    && modelPoseFlipOptions.every(({ key }) => left[key] === right[key]);
}

function stableModuleStyles(styles: Record<string, ModuleStyle>) {
  return Object.keys(styles)
    .sort((left, right) => left.localeCompare(right, 'zh-Hans-CN'))
    .reduce<Record<string, ModuleStyle>>((accumulator, key) => {
      accumulator[key] = styles[key];
      return accumulator;
    }, {});
}

function createWorkspaceSnapshot(input: {
  modelPose: ModelPose;
  segmentationExportScope: SegmentationExportScope;
  moduleStyles: Record<string, ModuleStyle>;
  sliceStart: number;
  sliceEnd: number;
  mappingSlice: number;
  displayLevel: DisplayLevel;
  dicomOpacityLevel: DicomOpacityLevel;
  showBounds: boolean;
  cutEnabled: boolean;
}) {
  return JSON.stringify({
    modelPose: input.modelPose,
    segmentationExportScope: input.segmentationExportScope,
    moduleStyles: stableModuleStyles(input.moduleStyles),
    sliceStart: input.sliceStart,
    sliceEnd: input.sliceEnd,
    mappingSlice: input.mappingSlice,
    displayLevel: input.displayLevel,
    dicomOpacityLevel: input.dicomOpacityLevel,
    showBounds: input.showBounds,
    cutEnabled: input.cutEnabled,
  });
}

function parseImportedPosePayload(payload: unknown) {
  const record = isRecord(payload) ? payload : {};
  const importedModelPoses = normalizeImportedModelPoses(record.modelPoses);
  const activePose = normalizePoseValue(record.activePose)
    ?? normalizePoseValue(record.pose)
    ?? normalizePoseValue(payload)
    ?? importedModelPoses?.[0]?.pose
    ?? null;

  return { activePose, importedModelPoses };
}

function projectModelAxisDirections(camera: THREE.Camera, object: THREE.Object3D): AxisProjection {
  const origin = object.getWorldPosition(new THREE.Vector3());
  const originProjected = origin.clone().project(camera);
  const quaternion = object.getWorldQuaternion(new THREE.Quaternion());
  const axisDirections: Record<AxisKey, THREE.Vector3> = {
    x: new THREE.Vector3(1, 0, 0),
    y: new THREE.Vector3(0, 1, 0),
    z: new THREE.Vector3(0, 0, 1),
  };

  const projectAxis = (direction: THREE.Vector3): AxisVector2D => {
    const end = origin.clone().add(direction.applyQuaternion(quaternion).normalize().multiplyScalar(0.72));
    const endProjected = end.project(camera);
    const dx = endProjected.x - originProjected.x;
    const dy = originProjected.y - endProjected.y;
    const magnitude = Math.hypot(dx, dy);

    if (magnitude < 0.0001) {
      return { dx: 0, dy: -5, opacity: 0.5 };
    }

    return {
      dx: (dx / magnitude) * axisInsetLength,
      dy: (dy / magnitude) * axisInsetLength,
      opacity: endProjected.z < originProjected.z ? 1 : 0.58,
    };
  };

  return {
    x: projectAxis(axisDirections.x),
    y: projectAxis(axisDirections.y),
    z: projectAxis(axisDirections.z),
  };
}

function axisProjectionSignature(projection: AxisProjection) {
  return (['x', 'y', 'z'] as AxisKey[])
    .map((key) => {
      const item = projection[key];
      return `${Math.round(item.dx * 10)},${Math.round(item.dy * 10)},${Math.round(item.opacity * 100)}`;
    })
    .join('|');
}

function createDicomTexture(frame: string, width: number, height: number) {
  const canvas = document.createElement('canvas');
  canvas.width = width;
  canvas.height = height;
  const context = canvas.getContext('2d');
  if (!context) {
    return null;
  }

  const binary = atob(frame);
  const imageData = context.createImageData(width, height);
  for (let index = 0; index < binary.length; index += 1) {
    const value = binary.charCodeAt(index);
    const offset = index * 4;
    imageData.data[offset] = value;
    imageData.data[offset + 1] = value;
    imageData.data[offset + 2] = value;
    imageData.data[offset + 3] = value > 4 ? 235 : 0;
  }
  context.putImageData(imageData, 0, 0);

  const texture = new THREE.CanvasTexture(canvas);
  texture.colorSpace = THREE.SRGBColorSpace;
  texture.minFilter = THREE.LinearFilter;
  texture.magFilter = THREE.LinearFilter;
  texture.needsUpdate = true;
  return texture;
}

function CoordinateAxesInset({ projection }: { projection: AxisProjection }) {
  const origin = { x: 25, y: 31 };
  const axisItems: Array<{ key: AxisKey; label: string; color: string; labelColor: string; markerId: string }> = [
    { key: 'x', label: 'X', color: '#ef4444', labelColor: '#fecaca', markerId: 'fusion-axis-arrow-x' },
    { key: 'y', label: 'Y', color: '#22c55e', labelColor: '#bbf7d0', markerId: 'fusion-axis-arrow-y' },
    { key: 'z', label: 'Z', color: '#38bdf8', labelColor: '#bae6fd', markerId: 'fusion-axis-arrow-z' },
  ];

  return (
    <div
      className="pointer-events-none absolute bottom-3 right-3 z-10 rounded-lg border border-white/10 bg-black/60 p-1.5 shadow-lg backdrop-blur-sm"
      title="当前视角下模型平移 XYZ 方向"
    >
      <svg width="54" height="54" viewBox="0 0 54 54" aria-hidden="true" className="block">
        <defs>
          {axisItems.map((item) => (
            <marker key={item.key} id={item.markerId} markerWidth="5" markerHeight="5" refX="4.3" refY="2.5" orient="auto" markerUnits="strokeWidth">
              <path d="M0,0 L5,2.5 L0,5 Z" fill={item.color} />
            </marker>
          ))}
        </defs>
        <circle cx={origin.x} cy={origin.y} r="2.2" fill="#e5e7eb" />
        {axisItems.map((item) => {
          const vector = projection[item.key];
          const endX = origin.x + vector.dx;
          const endY = origin.y + vector.dy;
          const textAnchor = vector.dx >= 0 ? 'start' : 'end';
          return (
            <g key={item.key} opacity={vector.opacity}>
              <line
                x1={origin.x}
                y1={origin.y}
                x2={endX}
                y2={endY}
                stroke={item.color}
                strokeWidth="2.2"
                strokeLinecap="round"
                markerEnd={`url(#${item.markerId})`}
              />
              <text
                x={endX + (vector.dx >= 0 ? 4 : -4)}
                y={endY + (vector.dy >= 0 ? 6 : -3)}
                fill={item.labelColor}
                fontSize="8"
                fontWeight="700"
                textAnchor={textAnchor}
              >
                {item.label}
              </text>
            </g>
          );
        })}
      </svg>
    </div>
  );
}

export function FusionThreeView({
  project,
  volume,
  modelPose,
  moduleStyles,
  detailLimit,
  solidMode,
  dicomOpacity,
  showBounds,
  cutEnabled,
  cutStart,
  cutEnd,
  viewPreset = 'workspace',
}: {
  project: Project;
  volume: DicomFusionVolume | null;
  modelPose: ModelPose;
  moduleStyles: Record<string, ModuleStyle>;
  detailLimit: number;
  solidMode: boolean;
  dicomOpacity: { sliceOpacity: number; volumeOpacity: number; boxOpacity: number };
  showBounds: boolean;
  cutEnabled: boolean;
  cutStart: number;
  cutEnd: number;
  viewPreset?: 'workspace' | 'libraryResult';
}) {
  const containerRef = useRef<HTMLDivElement | null>(null);
  const modelPoseRef = useRef(modelPose);
  const [status, setStatus] = useState('准备融合 DICOM 与 STL');
  const [loadProgress, setLoadProgress] = useState(0);
  const [webglError, setWebglError] = useState<string | null>(null);
  const [axisProjection, setAxisProjection] = useState<AxisProjection>(defaultAxisProjection);
  const axisProjectionSignatureRef = useRef(axisProjectionSignature(defaultAxisProjection));
  const resetFusionViewRef = useRef<() => void>(() => undefined);

  useEffect(() => {
    modelPoseRef.current = modelPose;
  }, [modelPose]);

  useEffect(() => {
    const container = containerRef.current;
    if (!container || !volume) return;

    container.innerHTML = '';
    setWebglError(null);
    setStatus('正在构建三维融合场景...');
    setLoadProgress(8);
    setAxisProjection(defaultAxisProjection);
    axisProjectionSignatureRef.current = axisProjectionSignature(defaultAxisProjection);

    let disposed = false;
    let animationId = 0;
    const scene = new THREE.Scene();
    scene.background = new THREE.Color('#030712');

    const width = Math.max(container.clientWidth, 1);
    const height = Math.max(container.clientHeight, 1);
    const camera = new THREE.PerspectiveCamera(45, width / height, 0.05, 1000);
    camera.position.set(0, -6.2, 4.6);
    camera.up.set(0, 0, 1);
    camera.lookAt(0, 0, 0);

    let renderer: THREE.WebGLRenderer;
    try {
      renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
    } catch {
      const message = '当前浏览器无法创建 WebGL 三维上下文';
      setStatus(message);
      setLoadProgress(100);
      setWebglError('三维融合视图暂不可用，请检查浏览器硬件加速、显卡驱动或远程桌面图形支持。二维 DICOM 与逆向分割映射功能仍可继续使用。');
      resetFusionViewRef.current = () => undefined;
      return () => {
        container.innerHTML = '';
      };
    }
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
    renderer.setSize(width, height);
    renderer.localClippingEnabled = true;
    container.appendChild(renderer.domElement);

    scene.add(new THREE.AmbientLight(0xffffff, 0.72));
    const keyLight = new THREE.DirectionalLight(0xffffff, 1.1);
    keyLight.position.set(4, -5, 5);
    scene.add(keyLight);
    const fillLight = new THREE.DirectionalLight(0x8fb8ff, 0.55);
    fillLight.position.set(-4, 3, 2);
    scene.add(fillLight);

    const fusionRoot = new THREE.Group();
    const dicomGroup = new THREE.Group();
    const modelPoseGroup = new THREE.Group();
    const modelPivot = new THREE.Group();
    modelPoseGroup.add(modelPivot);
    fusionRoot.add(dicomGroup);
    fusionRoot.add(modelPoseGroup);
    scene.add(fusionRoot);

    const maxPhysical = Math.max(volume.physicalSize.width, volume.physicalSize.height, volume.physicalSize.depth, 1);
    const baseExtent = 4.6;
    const dicomWidth = (volume.physicalSize.width / maxPhysical) * baseExtent;
    const dicomHeight = (volume.physicalSize.height / maxPhysical) * baseExtent;
    const dicomDepth = Math.max((volume.physicalSize.depth / maxPhysical) * baseExtent, 0.18);
    const planeGeometry = new THREE.PlaneGeometry(dicomWidth, dicomHeight);

    const box = new THREE.Mesh(
      new THREE.BoxGeometry(dicomWidth, dicomHeight, dicomDepth),
      new THREE.MeshBasicMaterial({ color: '#020617', transparent: true, opacity: dicomOpacity.boxOpacity, depthWrite: false }),
    );
    dicomGroup.add(box);
    const edges = new THREE.LineSegments(
      new THREE.EdgesGeometry(box.geometry),
      new THREE.LineBasicMaterial({ color: '#38bdf8', transparent: true, opacity: 0.46 }),
    );
    edges.visible = showBounds;
    dicomGroup.add(edges);

    const sliceToZ = (sliceIndex: number) => (
      volume.total <= 1
        ? 0
        : -dicomDepth / 2 + (dicomDepth * clamp(sliceIndex, 0, volume.total - 1)) / (volume.total - 1)
    );
    const cutRangeStart = Math.min(
      clamp(cutStart, 0, volume.total - 1),
      clamp(cutEnd, 0, volume.total - 1),
    );
    const cutRangeEnd = Math.max(
      clamp(cutStart, 0, volume.total - 1),
      clamp(cutEnd, 0, volume.total - 1),
    );
    const lowerCutZ = sliceToZ(cutRangeStart);
    const upperCutZ = sliceToZ(cutRangeEnd);
    const lowerClippingPlane = new THREE.Plane();
    const upperClippingPlane = new THREE.Plane();

    const textures: THREE.Texture[] = [];
    volume.frames.forEach((frame, index) => {
      const texture = createDicomTexture(frame, volume.width, volume.height);
      if (!texture) return;
      textures.push(texture);
      const isLast = index === volume.frames.length - 1;
      const dicomIndex = volume.indices[index] ?? (volume.start + index);
      const material = new THREE.MeshBasicMaterial({
        map: texture,
        transparent: true,
        opacity: isLast ? dicomOpacity.sliceOpacity : dicomOpacity.volumeOpacity,
        side: THREE.DoubleSide,
        depthWrite: false,
      });
      const slicePlane = new THREE.Mesh(planeGeometry, material);
      const z = volume.total <= 1
        ? 0
        : -dicomDepth / 2 + (dicomDepth * dicomIndex) / (volume.total - 1);
      slicePlane.position.set(0, 0, z + (isLast ? 0.006 : 0));
      dicomGroup.add(slicePlane);
    });

    setLoadProgress(42);

    const stlFiles = project.stlFiles ?? [];
    const visibleStlFiles = stlFiles.filter((fileName) => moduleStyles[fileName]?.visible !== false);
    const modelPreviewLimit = solidMode ? Math.max(detailLimit, 800000) : detailLimit;
    let modelBaseScale = 1;
    let loadedModels = 0;
    let failedModels = 0;
    const loadedBounds: Array<{ min: THREE.Vector3; max: THREE.Vector3 }> = [];

    Promise.allSettled(stlFiles.map((fileName, index) => (
      getCachedModelPreview(project.id, fileName, modelPreviewLimit)
        .then((payload) => {
          if (disposed) return;
          const style = moduleStyles[fileName] ?? {
            visible: true,
            color: moduleColors[index % moduleColors.length],
            opacity: 0.72,
            partId: index + 1,
          };
          if (payload.bounds) {
            loadedBounds.push({
              min: new THREE.Vector3(payload.bounds.min.x, payload.bounds.min.y, payload.bounds.min.z),
              max: new THREE.Vector3(payload.bounds.max.x, payload.bounds.max.y, payload.bounds.max.z),
            });
          }
          if (style.visible !== false) {
            const geometry = new THREE.BufferGeometry();
            geometry.setAttribute('position', new THREE.Float32BufferAttribute(payload.vertices, 3));
            geometry.computeVertexNormals();
            const materialOpacity = solidMode ? 1 : style.opacity;
            const material = new THREE.MeshStandardMaterial({
              color: style.color,
              transparent: materialOpacity < 1,
              opacity: materialOpacity,
              depthWrite: materialOpacity >= 1,
              roughness: solidMode ? 0.56 : 0.48,
              metalness: 0.03,
              side: THREE.DoubleSide,
              clippingPlanes: cutEnabled ? [lowerClippingPlane, upperClippingPlane] : [],
              clipIntersection: false,
              clipShadows: true,
            });
            const mesh = new THREE.Mesh(geometry, material);
            modelPivot.add(mesh);
          }
          loadedModels += 1;
          setLoadProgress(42 + Math.round(((loadedModels + failedModels) / Math.max(stlFiles.length, 1)) * 46));
        })
    ))).then(() => {
      if (disposed) return;
      const modelBox = new THREE.Box3();
      if (loadedBounds.length) {
        loadedBounds.forEach((bounds) => {
          modelBox.expandByPoint(bounds.min);
          modelBox.expandByPoint(bounds.max);
        });
      } else {
        modelBox.setFromObject(modelPivot);
      }

      const center = modelBox.getCenter(new THREE.Vector3());
      const size = modelBox.getSize(new THREE.Vector3());
      const maxModelSize = Math.max(size.x, size.y, size.z, 1);
      modelPivot.traverse((object) => {
        if (object instanceof THREE.Mesh) {
          object.geometry.translate(-center.x, -center.y, -center.z);
          object.geometry.computeBoundingBox();
          object.geometry.computeBoundingSphere();
          object.geometry.computeVertexNormals();
        }
      });
      const modelBounds = new THREE.LineSegments(
        new THREE.EdgesGeometry(new THREE.BoxGeometry(size.x, size.y, size.z)),
        new THREE.LineBasicMaterial({ color: '#facc15', transparent: true, opacity: 0.72 }),
      );
      modelBounds.visible = showBounds;
      modelPivot.add(modelBounds);
      modelBaseScale = (Math.max(dicomWidth, dicomHeight, dicomDepth) / maxModelSize) * 0.92;
      modelPoseGroup.position.set(0, 0, 0);
      modelPivot.position.set(0, 0, 0);
      setLoadProgress(100);
      setStatus(visibleStlFiles.length ? '三维融合场景已就绪' : 'DICOM 三维体已就绪，当前没有显示的 STL 构件');
    });

    const defaultRootPose = viewPreset === 'libraryResult' ? {
      rotateX: THREE.MathUtils.degToRad(70),
      rotateY: 0,
      rotateZ: 0,
      translateX: 0,
      translateY: 0.02,
      scale: 0.94,
    } : {
      rotateX: THREE.MathUtils.degToRad(58),
      rotateY: 0,
      rotateZ: THREE.MathUtils.degToRad(-18),
      translateX: 0,
      translateY: 0,
      scale: 1,
    };
    const rootPose = { ...defaultRootPose };
    const dragState = {
      active: false,
      mode: 'rotate' as 'rotate' | 'pan',
      pointerId: 0,
      startX: 0,
      startY: 0,
      root: { ...rootPose },
    };
    resetFusionViewRef.current = () => {
      Object.assign(rootPose, defaultRootPose);
      setStatus('三维融合视角已复位');
    };

    const handlePointerDown = (event: PointerEvent) => {
      dragState.active = true;
      dragState.mode = event.button === 2 || event.shiftKey ? 'pan' : 'rotate';
      dragState.pointerId = event.pointerId;
      dragState.startX = event.clientX;
      dragState.startY = event.clientY;
      dragState.root = { ...rootPose };
      container.setPointerCapture(event.pointerId);
    };
    const handlePointerMove = (event: PointerEvent) => {
      if (!dragState.active || event.pointerId !== dragState.pointerId) return;
      const deltaX = event.clientX - dragState.startX;
      const deltaY = event.clientY - dragState.startY;
      if (dragState.mode === 'pan') {
        rootPose.translateX = dragState.root.translateX + deltaX * 0.006;
        rootPose.translateY = dragState.root.translateY - deltaY * 0.006;
        return;
      }
      rootPose.rotateZ = dragState.root.rotateZ + deltaX * 0.008;
      rootPose.rotateX = dragState.root.rotateX + deltaY * 0.008;
    };
    const stopPointerDrag = (event: PointerEvent) => {
      if (event.pointerId !== dragState.pointerId) return;
      dragState.active = false;
      if (container.hasPointerCapture(event.pointerId)) {
        container.releasePointerCapture(event.pointerId);
      }
    };
    const handleWheel = (event: WheelEvent) => {
      event.preventDefault();
      rootPose.scale = clamp(rootPose.scale - event.deltaY * 0.001, 0.45, 2.2);
    };
    const preventContextMenu = (event: MouseEvent) => event.preventDefault();

    container.addEventListener('pointerdown', handlePointerDown);
    container.addEventListener('pointermove', handlePointerMove);
    container.addEventListener('pointerup', stopPointerDrag);
    container.addEventListener('pointercancel', stopPointerDrag);
    container.addEventListener('wheel', handleWheel, { passive: false });
    container.addEventListener('contextmenu', preventContextMenu);

    const handleResize = () => {
      if (!container.clientWidth || !container.clientHeight) return;
      camera.aspect = container.clientWidth / container.clientHeight;
      camera.updateProjectionMatrix();
      renderer.setSize(container.clientWidth, container.clientHeight);
    };
    window.addEventListener('resize', handleResize);

    const animate = () => {
      if (disposed) return;
      fusionRoot.rotation.set(rootPose.rotateX, rootPose.rotateY, rootPose.rotateZ);
      fusionRoot.position.set(rootPose.translateX, rootPose.translateY, 0);
      fusionRoot.scale.setScalar(rootPose.scale);
      fusionRoot.updateMatrixWorld(true);
      if (cutEnabled) {
        const rootQuaternion = fusionRoot.getWorldQuaternion(new THREE.Quaternion());
        const lowerNormal = new THREE.Vector3(0, 0, 1).applyQuaternion(rootQuaternion).normalize();
        const upperNormal = new THREE.Vector3(0, 0, -1).applyQuaternion(rootQuaternion).normalize();
        const lowerCutPoint = new THREE.Vector3(0, 0, lowerCutZ).applyMatrix4(fusionRoot.matrixWorld);
        const upperCutPoint = new THREE.Vector3(0, 0, upperCutZ).applyMatrix4(fusionRoot.matrixWorld);
        lowerClippingPlane.setFromNormalAndCoplanarPoint(lowerNormal, lowerCutPoint);
        upperClippingPlane.setFromNormalAndCoplanarPoint(upperNormal, upperCutPoint);
      }

      const pose = modelPoseRef.current;
      modelPoseGroup.rotation.set(
        THREE.MathUtils.degToRad(pose.rotateX),
        THREE.MathUtils.degToRad(pose.rotateY),
        THREE.MathUtils.degToRad(pose.rotateZ),
      );
      modelPoseGroup.position.set(
        pose.translateX,
        pose.translateY,
        pose.translateZ,
      );
      const poseScale = modelBaseScale * pose.scale;
      modelPoseGroup.scale.set(
        pose.flipX ? -poseScale : poseScale,
        pose.flipY ? -poseScale : poseScale,
        pose.flipZ ? -poseScale : poseScale,
      );
      modelPoseGroup.updateMatrixWorld(true);
      const nextAxisProjection = projectModelAxisDirections(camera, modelPoseGroup);
      const nextAxisSignature = axisProjectionSignature(nextAxisProjection);
      if (axisProjectionSignatureRef.current !== nextAxisSignature) {
        axisProjectionSignatureRef.current = nextAxisSignature;
        setAxisProjection(nextAxisProjection);
      }
      renderer.render(scene, camera);
      animationId = window.requestAnimationFrame(animate);
    };
    animate();

    return () => {
      disposed = true;
      window.cancelAnimationFrame(animationId);
      window.removeEventListener('resize', handleResize);
      container.removeEventListener('pointerdown', handlePointerDown);
      container.removeEventListener('pointermove', handlePointerMove);
      container.removeEventListener('pointerup', stopPointerDrag);
      container.removeEventListener('pointercancel', stopPointerDrag);
      container.removeEventListener('wheel', handleWheel);
      container.removeEventListener('contextmenu', preventContextMenu);
      textures.forEach((texture) => texture.dispose());
      scene.traverse((object) => {
        if (object instanceof THREE.Mesh) {
          object.geometry.dispose();
          const material = object.material;
          if (Array.isArray(material)) {
            material.forEach((item) => item.dispose());
          } else {
            material.dispose();
          }
        }
      });
      renderer.dispose();
      resetFusionViewRef.current = () => undefined;
      container.innerHTML = '';
    };
  }, [
    project.id,
    project.stlFiles?.join('|'),
    volume,
    JSON.stringify(moduleStyles),
    detailLimit,
    solidMode,
    dicomOpacity.sliceOpacity,
    dicomOpacity.volumeOpacity,
    dicomOpacity.boxOpacity,
    showBounds,
    cutEnabled,
    cutStart,
    cutEnd,
    viewPreset,
  ]);

  const volumeDisplayRange = volume ? getDicomDisplayRange(volume.start, volume.end, volume.total) : null;

  return (
    <div className="relative h-full min-h-[520px] overflow-hidden rounded-3xl border border-slate-800 bg-black shadow-xl">
      <div ref={containerRef} className="absolute inset-0 cursor-grab active:cursor-grabbing" />
      {webglError && (
        <div className="absolute inset-0 z-20 flex items-center justify-center bg-slate-950/92 px-8 text-center">
          <div className="max-w-md rounded-2xl border border-amber-300/20 bg-slate-900/90 p-6 text-amber-50 shadow-2xl">
            <AlertCircle className="mx-auto mb-3 text-amber-300" size={30} />
            <p className="text-sm font-bold">三维融合视图无法启动</p>
            <p className="mt-3 text-xs leading-6 text-amber-100/75">{webglError}</p>
          </div>
        </div>
      )}
      <div className="pointer-events-none absolute left-4 top-4 rounded-xl border border-white/10 bg-black/60 px-3 py-2 text-[10px] font-mono text-white/60">
        {status}
      </div>
      <div className="pointer-events-none absolute right-4 top-4 rounded-xl border border-cyan-400/20 bg-cyan-950/50 px-3 py-2 text-[10px] font-mono text-cyan-100">
        DICOM {volume && volumeDisplayRange ? `${volumeDisplayRange.start}-${volumeDisplayRange.end}/${volume.total}` : '加载中'} · STL {project.modelCount ?? 0}
      </div>
      <button
        onClick={() => resetFusionViewRef.current()}
        className="absolute right-4 top-16 z-10 flex h-8 items-center gap-1.5 rounded-xl border border-white/10 bg-black/60 px-3 text-[10px] font-bold text-white/70 shadow-lg hover:border-cyan-300/30 hover:text-cyan-100"
        title="重置影像与模型融合视角位置"
      >
        <RefreshCcw size={13} />
        位置重置
      </button>
      <CoordinateAxesInset projection={axisProjection} />
      {loadProgress < 100 && (
        <div className="absolute inset-x-10 bottom-8 rounded-xl border border-white/10 bg-black/70 p-3">
          <div className="mb-2 flex items-center justify-between text-[10px] font-bold text-white/70">
            <span>正在融合三维影像与模型</span>
            <span>{loadProgress}%</span>
          </div>
          <div className="h-2 overflow-hidden rounded-full bg-white/10">
            <div className="h-full bg-blue-500 transition-all" style={{ width: `${loadProgress}%` }} />
          </div>
        </div>
      )}
      {!volume && (
        <div className="absolute inset-0 flex items-center justify-center text-xs font-bold text-white/40">
          正在载入 DICOM 三维体...
        </div>
      )}
    </div>
  );
}

function CutSectionPreview({
  project,
  volume,
  modelPose,
  moduleStyles,
  detailLimit,
  cutEnabled,
  cutStart,
  cutEnd,
}: {
  project: Project | null;
  volume: DicomFusionVolume | null;
  modelPose: ModelPose;
  moduleStyles: Record<string, ModuleStyle>;
  detailLimit: number;
  cutEnabled: boolean;
  cutStart: number;
  cutEnd: number;
}) {
  const containerRef = useRef<HTMLDivElement | null>(null);
  const modelPoseRef = useRef(modelPose);
  const [webglError, setWebglError] = useState<string | null>(null);

  useEffect(() => {
    modelPoseRef.current = modelPose;
  }, [modelPose]);

  useEffect(() => {
    const container = containerRef.current;
    if (!container || !project || !volume) return;

    container.innerHTML = '';
    setWebglError(null);
    let disposed = false;
    let animationId = 0;
    const scene = new THREE.Scene();
    scene.background = new THREE.Color('#020617');

    const width = Math.max(container.clientWidth, 1);
    const height = Math.max(container.clientHeight, 1);
    const camera = new THREE.PerspectiveCamera(42, width / height, 0.05, 1000);
    camera.position.set(0, -5.6, 3.4);
    camera.up.set(0, 0, 1);
    camera.lookAt(0, 0, 0);

    let renderer: THREE.WebGLRenderer;
    try {
      renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
    } catch {
      setWebglError('当前浏览器无法创建 WebGL 三维上下文，STL 切面三维预览暂不可用。');
      return () => {
        container.innerHTML = '';
      };
    }
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
    renderer.setSize(width, height);
    renderer.localClippingEnabled = true;
    container.appendChild(renderer.domElement);

    scene.add(new THREE.AmbientLight(0xffffff, 0.78));
    const keyLight = new THREE.DirectionalLight(0xffffff, 1.25);
    keyLight.position.set(3, -4, 5);
    scene.add(keyLight);
    const rimLight = new THREE.DirectionalLight(0x93c5fd, 0.72);
    rimLight.position.set(-4, 3, 2);
    scene.add(rimLight);

    const fusionRoot = new THREE.Group();
    const modelPoseGroup = new THREE.Group();
    const modelPivot = new THREE.Group();
    modelPoseGroup.add(modelPivot);
    fusionRoot.add(modelPoseGroup);
    scene.add(fusionRoot);

    const maxPhysical = Math.max(volume.physicalSize.width, volume.physicalSize.height, volume.physicalSize.depth, 1);
    const baseExtent = 4.4;
    const dicomWidth = (volume.physicalSize.width / maxPhysical) * baseExtent;
    const dicomHeight = (volume.physicalSize.height / maxPhysical) * baseExtent;
    const dicomDepth = Math.max((volume.physicalSize.depth / maxPhysical) * baseExtent, 0.18);
    const sliceToZ = (sliceIndex: number) => (
      volume.total <= 1
        ? 0
        : -dicomDepth / 2 + (dicomDepth * clamp(sliceIndex, 0, volume.total - 1)) / (volume.total - 1)
    );
    const cutRangeStart = Math.min(
      clamp(cutStart, 0, volume.total - 1),
      clamp(cutEnd, 0, volume.total - 1),
    );
    const cutRangeEnd = Math.max(
      clamp(cutStart, 0, volume.total - 1),
      clamp(cutEnd, 0, volume.total - 1),
    );
    const lowerCutZ = sliceToZ(cutRangeStart);
    const upperCutZ = sliceToZ(cutRangeEnd);
    const lowerClippingPlane = new THREE.Plane();
    const upperClippingPlane = new THREE.Plane();

    let modelBaseScale = 1;
    let loadedModels = 0;
    let failedModels = 0;
    const loadedBounds: Array<{ min: THREE.Vector3; max: THREE.Vector3 }> = [];
    const stlFiles = (project.stlFiles ?? []).filter((fileName) => moduleStyles[fileName]?.visible !== false);

    Promise.allSettled(stlFiles.map((fileName, index) => (
      getCachedModelPreview(project.id, fileName, Math.max(detailLimit, 200000))
        .then((payload) => {
          if (disposed) return;
          const geometry = new THREE.BufferGeometry();
          geometry.setAttribute('position', new THREE.Float32BufferAttribute(payload.vertices, 3));
          geometry.computeVertexNormals();
          const style = moduleStyles[fileName] ?? {
            visible: true,
            color: moduleColors[index % moduleColors.length],
            opacity: 1,
            partId: index + 1,
          };
          const material = new THREE.MeshStandardMaterial({
            color: style.color,
            roughness: 0.5,
            metalness: 0.04,
            side: THREE.DoubleSide,
            clippingPlanes: cutEnabled ? [lowerClippingPlane, upperClippingPlane] : [],
            clipIntersection: false,
            clipShadows: true,
          });
          modelPivot.add(new THREE.Mesh(geometry, material));
          if (payload.bounds) {
            loadedBounds.push({
              min: new THREE.Vector3(payload.bounds.min.x, payload.bounds.min.y, payload.bounds.min.z),
              max: new THREE.Vector3(payload.bounds.max.x, payload.bounds.max.y, payload.bounds.max.z),
            });
          }
          loadedModels += 1;
        })
        .catch(() => {
          failedModels += 1;
        })
    ))).then(() => {
      if (disposed || (loadedModels + failedModels === 0)) return;
      const modelBox = new THREE.Box3();
      if (loadedBounds.length) {
        loadedBounds.forEach((bounds) => {
          modelBox.expandByPoint(bounds.min);
          modelBox.expandByPoint(bounds.max);
        });
      } else {
        modelBox.setFromObject(modelPivot);
      }
      const center = modelBox.getCenter(new THREE.Vector3());
      const size = modelBox.getSize(new THREE.Vector3());
      const maxModelSize = Math.max(size.x, size.y, size.z, 1);
      modelPivot.traverse((object) => {
        if (object instanceof THREE.Mesh) {
          object.geometry.translate(-center.x, -center.y, -center.z);
          object.geometry.computeBoundingBox();
          object.geometry.computeBoundingSphere();
          object.geometry.computeVertexNormals();
        }
      });
      modelBaseScale = (Math.max(dicomWidth, dicomHeight, dicomDepth) / maxModelSize) * 0.98;
      modelPoseGroup.position.set(0, 0, 0);
      modelPivot.position.set(0, 0, 0);
    });

    const rootPose = {
      rotateX: THREE.MathUtils.degToRad(58),
      rotateY: 0,
      rotateZ: THREE.MathUtils.degToRad(-18),
      translateX: 0,
      translateY: 0,
      scale: 1,
    };
    const dragState = {
      active: false,
      mode: 'rotate' as 'rotate' | 'pan',
      pointerId: 0,
      startX: 0,
      startY: 0,
      root: { ...rootPose },
    };

    const handlePointerDown = (event: PointerEvent) => {
      dragState.active = true;
      dragState.mode = event.button === 2 || event.shiftKey ? 'pan' : 'rotate';
      dragState.pointerId = event.pointerId;
      dragState.startX = event.clientX;
      dragState.startY = event.clientY;
      dragState.root = { ...rootPose };
      container.setPointerCapture(event.pointerId);
    };
    const handlePointerMove = (event: PointerEvent) => {
      if (!dragState.active || event.pointerId !== dragState.pointerId) return;
      const deltaX = event.clientX - dragState.startX;
      const deltaY = event.clientY - dragState.startY;
      if (dragState.mode === 'pan') {
        rootPose.translateX = dragState.root.translateX + deltaX * 0.006;
        rootPose.translateY = dragState.root.translateY - deltaY * 0.006;
        return;
      }
      rootPose.rotateZ = dragState.root.rotateZ + deltaX * 0.008;
      rootPose.rotateX = dragState.root.rotateX + deltaY * 0.008;
    };
    const stopPointerDrag = (event: PointerEvent) => {
      if (event.pointerId !== dragState.pointerId) return;
      dragState.active = false;
      if (container.hasPointerCapture(event.pointerId)) {
        container.releasePointerCapture(event.pointerId);
      }
    };
    const handleWheel = (event: WheelEvent) => {
      event.preventDefault();
      rootPose.scale = clamp(rootPose.scale - event.deltaY * 0.001, 0.55, 2.4);
    };
    const preventContextMenu = (event: MouseEvent) => event.preventDefault();
    const handleResize = () => {
      if (!container.clientWidth || !container.clientHeight) return;
      camera.aspect = container.clientWidth / container.clientHeight;
      camera.updateProjectionMatrix();
      renderer.setSize(container.clientWidth, container.clientHeight);
    };

    container.addEventListener('pointerdown', handlePointerDown);
    container.addEventListener('pointermove', handlePointerMove);
    container.addEventListener('pointerup', stopPointerDrag);
    container.addEventListener('pointercancel', stopPointerDrag);
    container.addEventListener('wheel', handleWheel, { passive: false });
    container.addEventListener('contextmenu', preventContextMenu);
    window.addEventListener('resize', handleResize);

    const animate = () => {
      if (disposed) return;
      fusionRoot.rotation.set(rootPose.rotateX, rootPose.rotateY, rootPose.rotateZ);
      fusionRoot.position.set(rootPose.translateX, rootPose.translateY, 0);
      fusionRoot.scale.setScalar(rootPose.scale);
      if (cutEnabled) {
        fusionRoot.updateMatrixWorld(true);
        const rootQuaternion = fusionRoot.getWorldQuaternion(new THREE.Quaternion());
        const lowerNormal = new THREE.Vector3(0, 0, 1).applyQuaternion(rootQuaternion).normalize();
        const upperNormal = new THREE.Vector3(0, 0, -1).applyQuaternion(rootQuaternion).normalize();
        const lowerCutPoint = new THREE.Vector3(0, 0, lowerCutZ).applyMatrix4(fusionRoot.matrixWorld);
        const upperCutPoint = new THREE.Vector3(0, 0, upperCutZ).applyMatrix4(fusionRoot.matrixWorld);
        lowerClippingPlane.setFromNormalAndCoplanarPoint(lowerNormal, lowerCutPoint);
        upperClippingPlane.setFromNormalAndCoplanarPoint(upperNormal, upperCutPoint);
      }

      const pose = modelPoseRef.current;
      modelPoseGroup.rotation.set(
        THREE.MathUtils.degToRad(pose.rotateX),
        THREE.MathUtils.degToRad(pose.rotateY),
        THREE.MathUtils.degToRad(pose.rotateZ),
      );
      modelPoseGroup.position.set(pose.translateX, pose.translateY, pose.translateZ);
      const poseScale = modelBaseScale * pose.scale;
      modelPoseGroup.scale.set(
        pose.flipX ? -poseScale : poseScale,
        pose.flipY ? -poseScale : poseScale,
        pose.flipZ ? -poseScale : poseScale,
      );
      renderer.render(scene, camera);
      animationId = window.requestAnimationFrame(animate);
    };
    animate();

    return () => {
      disposed = true;
      window.cancelAnimationFrame(animationId);
      window.removeEventListener('resize', handleResize);
      container.removeEventListener('pointerdown', handlePointerDown);
      container.removeEventListener('pointermove', handlePointerMove);
      container.removeEventListener('pointerup', stopPointerDrag);
      container.removeEventListener('pointercancel', stopPointerDrag);
      container.removeEventListener('wheel', handleWheel);
      container.removeEventListener('contextmenu', preventContextMenu);
      scene.traverse((object) => {
        if (object instanceof THREE.Mesh) {
          object.geometry.dispose();
          const material = object.material;
          if (Array.isArray(material)) {
            material.forEach((item) => item.dispose());
          } else {
            material.dispose();
          }
        }
      });
      renderer.dispose();
      container.innerHTML = '';
    };
  }, [
    project?.id,
    project?.stlFiles?.join('|'),
    volume,
    JSON.stringify(moduleStyles),
    detailLimit,
    cutEnabled,
    cutStart,
    cutEnd,
  ]);

  return (
    <div className="relative h-full min-h-[420px] overflow-hidden rounded-3xl border border-slate-800 bg-slate-950 shadow-2xl">
      <div ref={containerRef} className="absolute inset-0 cursor-grab active:cursor-grabbing" />
      {webglError && (
        <div className="absolute inset-0 flex items-center justify-center px-6 text-center">
          <div className="rounded-2xl border border-amber-300/20 bg-slate-900/90 p-5 text-xs font-bold leading-6 text-amber-100">
            {webglError}
          </div>
        </div>
      )}
      {(!project || !volume) && (
        <div className="absolute inset-0 flex items-center justify-center text-xs font-bold text-white/40">
          正在载入 STL 切面...
        </div>
      )}
    </div>
  );
}

interface ModelBounds {
  min: { x: number; y: number; z: number };
  max: { x: number; y: number; z: number };
}

interface Point2D {
  x: number;
  y: number;
}

interface Point3D {
  x: number;
  y: number;
  z: number;
}

interface ModelSceneMetrics {
  center: Point3D;
  modelBaseScale: number;
  modelPivotOffsetZ: number;
  dicomWidth: number;
  dicomHeight: number;
  dicomDepth: number;
}

interface PlaneSegment {
  a: Point2D;
  b: Point2D;
}

export interface OverlayStats {
  activeModules: number;
  filledPixels: number;
  segmentCount: number;
  modules: Array<{
    fileName: string;
    name: string;
    color: string;
    opacity: number;
    partId: number;
    segmentCount: number;
    filledPixels: number;
  }>;
}

function getPayloadBounds(payload: ModelPreviewPayload): ModelBounds | null {
  if (payload.bounds) {
    return payload.bounds;
  }

  if (payload.vertices.length < 3) {
    return null;
  }

  const bounds: ModelBounds = {
    min: { x: Infinity, y: Infinity, z: Infinity },
    max: { x: -Infinity, y: -Infinity, z: -Infinity },
  };

  for (let index = 0; index < payload.vertices.length; index += 3) {
    const x = payload.vertices[index];
    const y = payload.vertices[index + 1];
    const z = payload.vertices[index + 2];
    bounds.min.x = Math.min(bounds.min.x, x);
    bounds.min.y = Math.min(bounds.min.y, y);
    bounds.min.z = Math.min(bounds.min.z, z);
    bounds.max.x = Math.max(bounds.max.x, x);
    bounds.max.y = Math.max(bounds.max.y, y);
    bounds.max.z = Math.max(bounds.max.z, z);
  }

  return Number.isFinite(bounds.min.x) ? bounds : null;
}

function getGlobalModelBounds(files: string[], previews: Record<string, ModelPreviewPayload>) {
  const bounds: ModelBounds = {
    min: { x: Infinity, y: Infinity, z: Infinity },
    max: { x: -Infinity, y: -Infinity, z: -Infinity },
  };
  let hasBounds = false;

  files.forEach((fileName) => {
    const payloadBounds = previews[fileName] ? getPayloadBounds(previews[fileName]) : null;
    if (!payloadBounds) {
      return;
    }
    hasBounds = true;
    bounds.min.x = Math.min(bounds.min.x, payloadBounds.min.x);
    bounds.min.y = Math.min(bounds.min.y, payloadBounds.min.y);
    bounds.min.z = Math.min(bounds.min.z, payloadBounds.min.z);
    bounds.max.x = Math.max(bounds.max.x, payloadBounds.max.x);
    bounds.max.y = Math.max(bounds.max.y, payloadBounds.max.y);
    bounds.max.z = Math.max(bounds.max.z, payloadBounds.max.z);
  });

  return hasBounds ? bounds : null;
}

function getPreviewPhysicalSize(preview: DicomPreview) {
  const columnSpacing = preview.spacing?.displayX ?? preview.spacing?.column ?? 1;
  const rowSpacing = preview.spacing?.displayY ?? preview.spacing?.row ?? 1;
  const width = preview.physicalSize?.width ?? preview.width * columnSpacing;
  const height = preview.physicalSize?.height ?? preview.height * rowSpacing;

  return {
    width: Math.max(width, 0.001),
    height: Math.max(height, 0.001),
    columnSpacing: Math.max(columnSpacing, 0.001),
    rowSpacing: Math.max(rowSpacing, 0.001),
    sliceSpacing: Math.max(preview.spacing?.slice ?? 1, 0.001),
  };
}

function getFovCanvasSize(preview: DicomPreview) {
  const physical = getPreviewPhysicalSize(preview);
  const unit = Math.max(0.001, Math.min(physical.columnSpacing, physical.rowSpacing));
  const rawWidth = Math.max(1, Math.round(physical.width / unit));
  const rawHeight = Math.max(1, Math.round(physical.height / unit));
  const maxDimension = 960;
  const scale = Math.min(1, maxDimension / Math.max(rawWidth, rawHeight));

  return {
    width: Math.max(1, Math.round(rawWidth * scale)),
    height: Math.max(1, Math.round(rawHeight * scale)),
  };
}

function getModelSceneMetrics(
  files: string[],
  previews: Record<string, ModelPreviewPayload>,
  preview: DicomPreview,
  totalSlices: number,
): ModelSceneMetrics | null {
  const globalBounds = getGlobalModelBounds(files, previews);
  if (!globalBounds) {
    return null;
  }

  const spanX = Math.max(globalBounds.max.x - globalBounds.min.x, 0.001);
  const spanY = Math.max(globalBounds.max.y - globalBounds.min.y, 0.001);
  const spanZ = Math.max(globalBounds.max.z - globalBounds.min.z, 0.001);
  const maxModelSize = Math.max(spanX, spanY, spanZ, 1);
  const physical = getPreviewPhysicalSize(preview);
  const physicalDepth = Math.max(totalSlices, 1) * physical.sliceSpacing;
  const maxPhysical = Math.max(physical.width, physical.height, physicalDepth, 1);
  const dicomWidth = (physical.width / maxPhysical) * fusionBaseExtent;
  const dicomHeight = (physical.height / maxPhysical) * fusionBaseExtent;
  const dicomDepth = Math.max((physicalDepth / maxPhysical) * fusionBaseExtent, 0.18);
  const modelBaseScale = (Math.max(dicomWidth, dicomHeight, dicomDepth) / maxModelSize) * 0.92;

  return {
    center: {
      x: (globalBounds.min.x + globalBounds.max.x) / 2,
      y: (globalBounds.min.y + globalBounds.max.y) / 2,
      z: (globalBounds.min.z + globalBounds.max.z) / 2,
    },
    modelBaseScale,
    modelPivotOffsetZ: dicomDepth * 0.08,
    dicomWidth,
    dicomHeight,
    dicomDepth,
  };
}

function transformPointForPose(x: number, y: number, z: number, metrics: ModelSceneMetrics, pose: ModelPose): Point3D {
  const scalar = metrics.modelBaseScale * pose.scale;
  let px = (x - metrics.center.x) * scalar * (pose.flipX ? -1 : 1);
  let py = (y - metrics.center.y) * scalar * (pose.flipY ? -1 : 1);
  let pz = (z - metrics.center.z) * scalar * (pose.flipZ ? -1 : 1);
  pz += metrics.modelPivotOffsetZ * scalar;

  const rotateX = THREE.MathUtils.degToRad(pose.rotateX);
  const rotateY = THREE.MathUtils.degToRad(pose.rotateY);
  const rotateZ = THREE.MathUtils.degToRad(pose.rotateZ);
  const cosX = Math.cos(rotateX);
  const sinX = Math.sin(rotateX);
  const cosY = Math.cos(rotateY);
  const sinY = Math.sin(rotateY);
  const cosZ = Math.cos(rotateZ);
  const sinZ = Math.sin(rotateZ);

  const afterX = {
    x: px,
    y: py * cosX - pz * sinX,
    z: py * sinX + pz * cosX,
  };
  const afterY = {
    x: afterX.x * cosY + afterX.z * sinY,
    y: afterX.y,
    z: -afterX.x * sinY + afterX.z * cosY,
  };
  px = afterY.x * cosZ - afterY.y * sinZ;
  py = afterY.x * sinZ + afterY.y * cosZ;
  pz = afterY.z;

  return {
    x: px + pose.translateX,
    y: py + pose.translateY,
    z: pz + pose.translateZ,
  };
}

function intersectEdgeWithPlane(start: Point3D, end: Point3D, targetZ: number): Point2D | null {
  const epsilon = 1e-5;
  const startDistance = start.z - targetZ;
  const endDistance = end.z - targetZ;

  if (Math.abs(startDistance) <= epsilon && Math.abs(endDistance) <= epsilon) {
    return null;
  }

  if (Math.abs(startDistance) <= epsilon) {
    return { x: start.x, y: start.y };
  }

  if (Math.abs(endDistance) <= epsilon) {
    return { x: end.x, y: end.y };
  }

  if ((startDistance > 0 && endDistance > 0) || (startDistance < 0 && endDistance < 0)) {
    return null;
  }

  const t = startDistance / (startDistance - endDistance);
  return {
    x: start.x + (end.x - start.x) * t,
    y: start.y + (end.y - start.y) * t,
  };
}

function pointDistanceSquared(a: Point2D, b: Point2D) {
  const dx = a.x - b.x;
  const dy = a.y - b.y;
  return dx * dx + dy * dy;
}

function intersectTriangleWithPlane(a: Point3D, b: Point3D, c: Point3D, targetZ: number): PlaneSegment | null {
  const intersections = [
    intersectEdgeWithPlane(a, b, targetZ),
    intersectEdgeWithPlane(b, c, targetZ),
    intersectEdgeWithPlane(c, a, targetZ),
  ].filter((point): point is Point2D => Boolean(point));

  const uniquePoints: Point2D[] = [];
  intersections.forEach((point) => {
    const exists = uniquePoints.some((current) => pointDistanceSquared(current, point) < 1e-8);
    if (!exists) {
      uniquePoints.push(point);
    }
  });

  if (uniquePoints.length < 2) {
    return null;
  }

  let segment: PlaneSegment = { a: uniquePoints[0], b: uniquePoints[1] };
  let maxDistance = pointDistanceSquared(segment.a, segment.b);
  for (let first = 0; first < uniquePoints.length; first += 1) {
    for (let second = first + 1; second < uniquePoints.length; second += 1) {
      const distance = pointDistanceSquared(uniquePoints[first], uniquePoints[second]);
      if (distance > maxDistance) {
        maxDistance = distance;
        segment = { a: uniquePoints[first], b: uniquePoints[second] };
      }
    }
  }

  return maxDistance > 1e-8 ? segment : null;
}

function parseHexColor(color: string) {
  const normalized = color.replace('#', '').trim();
  const value = normalized.length === 3
    ? normalized.split('').map((item) => item + item).join('')
    : normalized.padEnd(6, '0').slice(0, 6);
  const parsed = Number.parseInt(value, 16);

  if (!Number.isFinite(parsed)) {
    return { r: 59, g: 130, b: 246 };
  }

  return {
    r: (parsed >> 16) & 255,
    g: (parsed >> 8) & 255,
    b: parsed & 255,
  };
}

function fillInternalMaskHoles(
  maskData: ImageData,
  width: number,
  height: number,
  rgb: { r: number; g: number; b: number },
  alpha: number,
) {
  const outside = new Uint8Array(width * height);
  const stack: number[] = [];
  const pushIfEmpty = (x: number, y: number) => {
    if (x < 0 || x >= width || y < 0 || y >= height) {
      return;
    }

    const index = y * width + x;
    if (outside[index] || maskData.data[index * 4 + 3] > 0) {
      return;
    }

    outside[index] = 1;
    stack.push(index);
  };

  for (let x = 0; x < width; x += 1) {
    pushIfEmpty(x, 0);
    pushIfEmpty(x, height - 1);
  }
  for (let y = 0; y < height; y += 1) {
    pushIfEmpty(0, y);
    pushIfEmpty(width - 1, y);
  }

  while (stack.length) {
    const index = stack.pop();
    if (index === undefined) {
      continue;
    }

    const x = index % width;
    const y = Math.floor(index / width);
    pushIfEmpty(x + 1, y);
    pushIfEmpty(x - 1, y);
    pushIfEmpty(x, y + 1);
    pushIfEmpty(x, y - 1);
  }

  let patchedPixels = 0;
  for (let index = 0; index < outside.length; index += 1) {
    const offset = index * 4;
    if (!outside[index] && maskData.data[offset + 3] === 0) {
      maskData.data[offset] = rgb.r;
      maskData.data[offset + 1] = rgb.g;
      maskData.data[offset + 2] = rgb.b;
      maskData.data[offset + 3] = alpha;
      patchedPixels += 1;
    }
  }

  return patchedPixels;
}

function addSegmentIntersectionsToRows(rows: number[][], width: number, height: number, segment: PlaneSegment) {
  const { a, b } = segment;
  if (!Number.isFinite(a.x) || !Number.isFinite(a.y) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) {
    return;
  }

  const deltaY = b.y - a.y;
  if (Math.abs(deltaY) < 0.01) {
    return;
  }

  const minY = Math.max(0, Math.floor(Math.min(a.y, b.y)));
  const maxY = Math.min(height - 1, Math.ceil(Math.max(a.y, b.y)));
  for (let row = minY; row <= maxY; row += 1) {
    const sampleY = row + 0.5;
    const crosses = (sampleY >= a.y && sampleY < b.y) || (sampleY >= b.y && sampleY < a.y);
    if (!crosses) {
      continue;
    }

    const t = (sampleY - a.y) / deltaY;
    const x = a.x + (b.x - a.x) * t;
    if (Number.isFinite(x)) {
      rows[row].push(x);
    }
  }
}

function groupPlaneSegmentsByConnectivity(segments: PlaneSegment[], tolerance = 1.35) {
  if (segments.length <= 1) {
    return segments.length ? [segments] : [];
  }

  const parents = segments.map((_, index) => index);
  const find = (index: number): number => {
    if (parents[index] !== index) {
      parents[index] = find(parents[index]);
    }
    return parents[index];
  };
  const union = (left: number, right: number) => {
    const leftRoot = find(left);
    const rightRoot = find(right);
    if (leftRoot !== rightRoot) {
      parents[rightRoot] = leftRoot;
    }
  };
  const buckets = new Map<string, Array<{ x: number; y: number; index: number }>>();
  const cellSize = Math.max(tolerance, 0.1);
  const toleranceSquared = tolerance * tolerance;
  const cellKey = (x: number, y: number) => `${x},${y}`;

  segments.forEach((segment, index) => {
    [segment.a, segment.b].forEach((point) => {
      if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) {
        return;
      }

      const cellX = Math.floor(point.x / cellSize);
      const cellY = Math.floor(point.y / cellSize);
      for (let dx = -1; dx <= 1; dx += 1) {
        for (let dy = -1; dy <= 1; dy += 1) {
          const candidates = buckets.get(cellKey(cellX + dx, cellY + dy));
          candidates?.forEach((candidate) => {
            const distanceSquared = (candidate.x - point.x) ** 2 + (candidate.y - point.y) ** 2;
            if (distanceSquared <= toleranceSquared) {
              union(index, candidate.index);
            }
          });
        }
      }

      const key = cellKey(cellX, cellY);
      const bucket = buckets.get(key) ?? [];
      bucket.push({ x: point.x, y: point.y, index });
      buckets.set(key, bucket);
    });
  });

  const groups = new Map<number, PlaneSegment[]>();
  segments.forEach((segment, index) => {
    const root = find(index);
    const group = groups.get(root) ?? [];
    group.push(segment);
    groups.set(root, group);
  });

  return [...groups.values()].sort((left, right) => right.length - left.length);
}

function closeSmallMaskGaps(
  maskData: ImageData,
  width: number,
  height: number,
  rgb: { r: number; g: number; b: number },
  alpha: number,
  maxGap = 2,
) {
  const toFill = new Set<number>();
  const hasPixel = (x: number, y: number) => maskData.data[(y * width + x) * 4 + 3] > 0;
  const mark = (x: number, y: number) => {
    if (x >= 0 && x < width && y >= 0 && y < height && !hasPixel(x, y)) {
      toFill.add(y * width + x);
    }
  };

  for (let y = 0; y < height; y += 1) {
    let lastFilled = -1;
    for (let x = 0; x < width; x += 1) {
      if (!hasPixel(x, y)) {
        continue;
      }
      const gap = x - lastFilled - 1;
      if (lastFilled >= 0 && gap > 0 && gap <= maxGap) {
        for (let fillX = lastFilled + 1; fillX < x; fillX += 1) {
          mark(fillX, y);
        }
      }
      lastFilled = x;
    }
  }

  for (let x = 0; x < width; x += 1) {
    let lastFilled = -1;
    for (let y = 0; y < height; y += 1) {
      if (!hasPixel(x, y)) {
        continue;
      }
      const gap = y - lastFilled - 1;
      if (lastFilled >= 0 && gap > 0 && gap <= maxGap) {
        for (let fillY = lastFilled + 1; fillY < y; fillY += 1) {
          mark(x, fillY);
        }
      }
      lastFilled = y;
    }
  }

  toFill.forEach((index) => {
    const offset = index * 4;
    maskData.data[offset] = rgb.r;
    maskData.data[offset + 1] = rgb.g;
    maskData.data[offset + 2] = rgb.b;
    maskData.data[offset + 3] = alpha;
  });

  return toFill.size;
}

function solidStrokeRadius(width: number, height: number) {
  return Math.max(2.2, Math.min(5.5, Math.max(width, height) * 0.006));
}

function paintMaskPixel(
  maskData: ImageData,
  width: number,
  height: number,
  x: number,
  y: number,
  rgb: { r: number; g: number; b: number },
  alpha: number,
) {
  if (x < 0 || x >= width || y < 0 || y >= height) {
    return 0;
  }

  const offset = (y * width + x) * 4;
  if (maskData.data[offset + 3] > 0) {
    return 0;
  }

  maskData.data[offset] = rgb.r;
  maskData.data[offset + 1] = rgb.g;
  maskData.data[offset + 2] = rgb.b;
  maskData.data[offset + 3] = alpha;
  return 1;
}

function fillSegmentCapsulesIntoMask(
  maskData: ImageData,
  width: number,
  height: number,
  segments: PlaneSegment[],
  rgb: { r: number; g: number; b: number },
  alpha: number,
  radius: number,
) {
  let paintedPixels = 0;
  const radiusSquared = radius * radius;

  segments.forEach(({ a, b }) => {
    if (!Number.isFinite(a.x) || !Number.isFinite(a.y) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) {
      return;
    }

    const dx = b.x - a.x;
    const dy = b.y - a.y;
    const lengthSquared = dx * dx + dy * dy;
    const minX = clamp(Math.floor(Math.min(a.x, b.x) - radius), 0, width - 1);
    const maxX = clamp(Math.ceil(Math.max(a.x, b.x) + radius), 0, width - 1);
    const minY = clamp(Math.floor(Math.min(a.y, b.y) - radius), 0, height - 1);
    const maxY = clamp(Math.ceil(Math.max(a.y, b.y) + radius), 0, height - 1);

    for (let y = minY; y <= maxY; y += 1) {
      for (let x = minX; x <= maxX; x += 1) {
        const px = x + 0.5;
        const py = y + 0.5;
        const t = lengthSquared <= 1e-6
          ? 0
          : clamp(((px - a.x) * dx + (py - a.y) * dy) / lengthSquared, 0, 1);
        const closestX = a.x + dx * t;
        const closestY = a.y + dy * t;
        const distanceSquared = (px - closestX) ** 2 + (py - closestY) ** 2;
        if (distanceSquared <= radiusSquared) {
          paintedPixels += paintMaskPixel(maskData, width, height, x, y, rgb, alpha);
        }
      }
    }
  });

  return paintedPixels;
}

function drawFallbackClosedRegion(
  context: CanvasRenderingContext2D,
  width: number,
  height: number,
  segments: PlaneSegment[],
  color: string,
  opacity: number,
) {
  const points = segments.flatMap((segment) => [segment.a, segment.b])
    .filter((point) => (
      Number.isFinite(point.x)
      && Number.isFinite(point.y)
      && point.x >= -width
      && point.x <= width * 2
      && point.y >= -height
      && point.y <= height * 2
    ));

  if (points.length < 3) {
    return 0;
  }

  const uniquePoints: Point2D[] = [];
  points.forEach((point) => {
    if (!uniquePoints.some((current) => pointDistanceSquared(current, point) < 1e-6)) {
      uniquePoints.push(point);
    }
  });
  if (uniquePoints.length < 3) {
    return 0;
  }

  const sorted = [...uniquePoints].sort((left, right) => (
    Math.abs(left.x - right.x) > 1e-6 ? left.x - right.x : left.y - right.y
  ));
  const cross = (origin: Point2D, a: Point2D, b: Point2D) => (
    (a.x - origin.x) * (b.y - origin.y) - (a.y - origin.y) * (b.x - origin.x)
  );
  const lower: Point2D[] = [];
  sorted.forEach((point) => {
    while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], point) <= 0) {
      lower.pop();
    }
    lower.push(point);
  });
  const upper: Point2D[] = [];
  [...sorted].reverse().forEach((point) => {
    while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], point) <= 0) {
      upper.pop();
    }
    upper.push(point);
  });
  const hull = [...lower.slice(0, -1), ...upper.slice(0, -1)];
  const ordered = hull.length >= 3 ? hull : uniquePoints;

  context.save();
  context.globalAlpha = clamp(opacity, 0.1, 1) * 0.62;
  context.fillStyle = color;
  context.beginPath();
  ordered.forEach((point, index) => {
    if (index === 0) {
      context.moveTo(point.x, point.y);
      return;
    }
    context.lineTo(point.x, point.y);
  });
  context.closePath();
  context.fill();
  context.restore();

  return Math.max(1, Math.round(ordered.length / 2));
}

function fillSegmentsAsSolidMask(
  context: CanvasRenderingContext2D,
  width: number,
  height: number,
  segments: PlaneSegment[],
  color: string,
  opacity: number,
) {
  if (!segments.length) {
    return 0;
  }

  const rgb = parseHexColor(color);
  const alpha = Math.round(clamp(opacity, 0.1, 1) * 190);
  const maskCanvas = document.createElement('canvas');
  maskCanvas.width = width;
  maskCanvas.height = height;
  const maskContext = maskCanvas.getContext('2d');
  if (!maskContext) {
    return 0;
  }
  const maskData = maskContext.createImageData(width, height);
  let filledPixels = 0;
  const radius = solidStrokeRadius(width, height);
  const groups = groupPlaneSegmentsByConnectivity(segments, radius * 1.15);
  const fallbackGroups: PlaneSegment[][] = [];

  groups.forEach((group) => {
    const rows: number[][] = Array.from({ length: height }, () => []);
    group.forEach((segment) => addSegmentIntersectionsToRows(rows, width, height, segment));
    let groupPixels = 0;

    rows.forEach((intersections, row) => {
      if (intersections.length < 2) {
        return;
      }

      intersections.sort((left, right) => left - right);
      const cleaned: number[] = [];
      intersections.forEach((x) => {
        const previous = cleaned[cleaned.length - 1];
        if (previous === undefined || Math.abs(previous - x) > 0.35) {
          cleaned.push(x);
        }
      });

      for (let index = 0; index + 1 < cleaned.length; index += 2) {
        const rawStartX = cleaned[index];
        const rawEndX = cleaned[index + 1];
        if (rawEndX < 0 || rawStartX > width - 1) {
          continue;
        }

        const startX = clamp(Math.ceil(rawStartX), 0, width - 1);
        const endX = clamp(Math.floor(rawEndX), 0, width - 1);
        if (endX < startX) {
          continue;
        }

        for (let x = startX; x <= endX; x += 1) {
          const offset = (row * width + x) * 4;
          if (maskData.data[offset + 3] === 0) {
            maskData.data[offset] = rgb.r;
            maskData.data[offset + 1] = rgb.g;
            maskData.data[offset + 2] = rgb.b;
            maskData.data[offset + 3] = alpha;
            groupPixels += 1;
          }
        }
      }
    });
    groupPixels += fillSegmentCapsulesIntoMask(maskData, width, height, group, rgb, alpha, radius);

    filledPixels += groupPixels;
    if (groupPixels < Math.max(20, Math.round(group.length * 0.5)) && group.length >= 3) {
      fallbackGroups.push(group);
    }
  });

  filledPixels += closeSmallMaskGaps(maskData, width, height, rgb, alpha, 3);
  filledPixels += fillInternalMaskHoles(maskData, width, height, rgb, alpha);
  maskContext.putImageData(maskData, 0, 0);
  context.drawImage(maskCanvas, 0, 0);
  fallbackGroups.forEach((group) => {
    filledPixels += drawFallbackClosedRegion(context, width, height, group, color, opacity);
  });

  if (filledPixels === 0) {
    context.save();
    context.globalAlpha = clamp(opacity, 0.1, 1) * 0.42;
    context.strokeStyle = color;
    context.lineWidth = Math.max(0.8, Math.max(width, height) * 0.0012);
    context.lineCap = 'round';
    context.lineJoin = 'round';
    context.beginPath();
    segments.forEach((segment) => {
      context.moveTo(segment.a.x, segment.a.y);
      context.lineTo(segment.b.x, segment.b.y);
    });
    context.stroke();
    context.restore();
  }

  return filledPixels;
}

function drawDicomBaseLayer(canvas: HTMLCanvasElement, preview: DicomPreview) {
  const fovCanvas = getFovCanvasSize(preview);
  canvas.width = fovCanvas.width;
  canvas.height = fovCanvas.height;
  const context = canvas.getContext('2d');
  if (!context) {
    return;
  }

  const binary = atob(preview.pixels);
  const imageData = context.createImageData(fovCanvas.width, fovCanvas.height);
  for (let y = 0; y < fovCanvas.height; y += 1) {
    const sourceY = Math.min(preview.height - 1, Math.floor((y / fovCanvas.height) * preview.height));
    for (let x = 0; x < fovCanvas.width; x += 1) {
      const sourceX = Math.min(preview.width - 1, Math.floor((x / fovCanvas.width) * preview.width));
      const value = binary.charCodeAt(sourceY * preview.width + sourceX);
      const offset = (y * fovCanvas.width + x) * 4;
      imageData.data[offset] = value;
      imageData.data[offset + 1] = value;
      imageData.data[offset + 2] = value;
      imageData.data[offset + 3] = 255;
    }
  }
  context.putImageData(imageData, 0, 0);
}

function drawVoxelOverlayLayer(
  canvas: HTMLCanvasElement,
  preview: DicomPreview,
  files: string[],
  previews: Record<string, ModelPreviewPayload>,
  metricFiles: string[],
  metricPreviews: Record<string, ModelPreviewPayload>,
  moduleStyles: Record<string, ModuleStyle>,
  modelPose: ModelPose,
  slice: number,
  totalSlices: number,
): OverlayStats {
  const fovCanvas = getFovCanvasSize(preview);
  canvas.width = fovCanvas.width;
  canvas.height = fovCanvas.height;
  const context = canvas.getContext('2d');
  if (!context) {
    return { activeModules: 0, filledPixels: 0, segmentCount: 0, modules: [] };
  }

  context.clearRect(0, 0, fovCanvas.width, fovCanvas.height);
  const metrics = getModelSceneMetrics(metricFiles, metricPreviews, preview, totalSlices)
    ?? getModelSceneMetrics(files, previews, preview, totalSlices);
  if (!metrics) {
    return { activeModules: 0, filledPixels: 0, segmentCount: 0, modules: [] };
  }

  const safeSlice = clamp(slice, 0, Math.max(totalSlices - 1, 0));
  const targetZ = totalSlices <= 1
    ? 0
    : -metrics.dicomDepth / 2 + (metrics.dicomDepth * safeSlice) / (totalSlices - 1);
  const mapPoint = (point: Point2D): Point2D => ({
    x: ((point.x + metrics.dicomWidth / 2) / metrics.dicomWidth) * fovCanvas.width,
    y: fovCanvas.height - ((point.y + metrics.dicomHeight / 2) / metrics.dicomHeight) * fovCanvas.height,
  });
  let activeModules = 0;
  let filledPixels = 0;
  let segmentCount = 0;
  const modules: OverlayStats['modules'] = [];

  files.forEach((fileName, index) => {
    const payload = previews[fileName];
    const style = moduleStyles[fileName] ?? {
      visible: true,
      color: moduleColors[index % moduleColors.length],
      opacity: 0.72,
      partId: index + 1,
    };

    if (!payload || style.visible === false) {
      return;
    }

    const segments: PlaneSegment[] = [];

    for (let vertexIndex = 0; vertexIndex < payload.vertices.length; vertexIndex += 9) {
      const a = transformPointForPose(
        payload.vertices[vertexIndex],
        payload.vertices[vertexIndex + 1],
        payload.vertices[vertexIndex + 2],
        metrics,
        modelPose,
      );
      const b = transformPointForPose(
        payload.vertices[vertexIndex + 3],
        payload.vertices[vertexIndex + 4],
        payload.vertices[vertexIndex + 5],
        metrics,
        modelPose,
      );
      const c = transformPointForPose(
        payload.vertices[vertexIndex + 6],
        payload.vertices[vertexIndex + 7],
        payload.vertices[vertexIndex + 8],
        metrics,
        modelPose,
      );
      const segment = intersectTriangleWithPlane(a, b, c, targetZ);

      if (segment) {
        segments.push({
          a: mapPoint(segment.a),
          b: mapPoint(segment.b),
        });
      }
    }

    const modulePixels = fillSegmentsAsSolidMask(context, fovCanvas.width, fovCanvas.height, segments, style.color, style.opacity);
    if (segments.length > 0 || modulePixels > 0) {
      activeModules += 1;
      modules.push({
        fileName,
        name: fileName.replace(/\.stl$/i, ''),
        color: style.color,
        opacity: style.opacity,
        partId: style.partId,
        segmentCount: segments.length,
        filledPixels: modulePixels,
      });
    }
    filledPixels += modulePixels;
    segmentCount += segments.length;
  });

  return { activeModules, filledPixels, segmentCount, modules };
}

export function VoxelizationMappingView({
  project,
  moduleStyles,
  modelPose,
  detailLimit,
  slice,
  totalSlices,
  onSliceChange,
  displayMode,
  rotation,
  variant = 'workspace',
  toolbar,
  overlayPlacement,
  onOverlayStatsChange,
}: {
  project: Project | null;
  moduleStyles: Record<string, ModuleStyle>;
  modelPose: ModelPose;
  detailLimit: number;
  slice: number;
  totalSlices: number;
  onSliceChange: (slice: number) => void;
  displayMode: MappingDisplayMode;
  rotation: number;
  variant?: 'workspace' | 'library';
  toolbar?: React.ReactNode;
  overlayPlacement?: 'bottom' | 'side' | 'none';
  onOverlayStatsChange?: (stats: OverlayStats, visibleModuleCount: number) => void;
}) {
  const baseCanvasRef = useRef<HTMLCanvasElement | null>(null);
  const overlayCanvasRef = useRef<HTMLCanvasElement | null>(null);
  const mappingViewportRef = useRef<HTMLDivElement | null>(null);
  const [dicomPreview, setDicomPreview] = useState<DicomPreview | null>(null);
  const [modelPreviews, setModelPreviews] = useState<Record<string, ModelPreviewPayload>>({});
  const [metricPreviews, setMetricPreviews] = useState<Record<string, ModelPreviewPayload>>({});
  const [metricPreviewsLoaded, setMetricPreviewsLoaded] = useState(false);
  const [dicomStatus, setDicomStatus] = useState('等待 DICOM 切片');
  const [overlayStatus, setOverlayStatus] = useState('等待 STL 映射');
  const [overlayStats, setOverlayStats] = useState<OverlayStats>({ activeModules: 0, filledPixels: 0, segmentCount: 0, modules: [] });
  const [overlayLoadState, setOverlayLoadState] = useState({ loading: false, loaded: 0, total: 0, phase: '' });
  const [mappingViewport, setMappingViewport] = useState({ scale: 1, offsetX: 0, offsetY: 0 });
  const mappingPanRef = useRef({
    active: false,
    pointerId: 0,
    startX: 0,
    startY: 0,
    offsetX: 0,
    offsetY: 0,
  });
  const maxSlice = Math.max(totalSlices - 1, 0);
  const safeSlice = clamp(slice, 0, maxSlice);
  const stlFiles = project?.stlFiles ?? [];
  const stlFileSignature = stlFiles.join('|');
  const visibleStlFiles = stlFiles.filter((fileName) => moduleStyles[fileName]?.visible !== false);
  const visibleStlFileSignature = visibleStlFiles.join('|');
  const visibleModuleCount = visibleStlFiles.length;
  const metricPreviewsReady = !stlFiles.length || metricPreviewsLoaded;
  const isLibraryVariant = variant === 'library';
  const activeOverlayPlacement = overlayPlacement ?? (isLibraryVariant ? 'side' : 'bottom');

  useEffect(() => {
    onOverlayStatsChange?.(overlayStats, visibleModuleCount);
  }, [onOverlayStatsChange, overlayStats, visibleModuleCount]);

  useEffect(() => {
    if (!project?.dicomCount) {
      setDicomPreview(null);
      setDicomStatus('没有可显示的 DICOM 切片');
      return;
    }

    let disposed = false;
    setDicomStatus('正在载入 DICOM Base Layer...');
    getCachedDicomPreview(project.id, safeSlice, 'axial', displayMode)
      .then((preview) => {
        if (disposed) return;
        setDicomPreview(preview);
        setDicomStatus('DICOM Base Layer 已就绪');
      })
      .catch((error) => {
        if (disposed) return;
        setDicomPreview(null);
        setDicomStatus(error instanceof Error ? error.message : 'DICOM 切片载入失败');
      });

    return () => {
      disposed = true;
    };
  }, [project?.id, project?.dicomCount, safeSlice, displayMode]);

  useEffect(() => {
    if (!project || !stlFiles.length) {
      setMetricPreviews({});
      setMetricPreviewsLoaded(true);
      return;
    }

    let disposed = false;
    setMetricPreviews({});
    setMetricPreviewsLoaded(false);
    Promise.allSettled(stlFiles.map((fileName) => (
      getCachedModelPreview(project.id, fileName, 1000)
        .then((payload) => ({ fileName, payload }))
    ))).then((results) => {
      if (disposed) return;
      const nextPreviews: Record<string, ModelPreviewPayload> = {};
      results.forEach((result) => {
        if (result.status === 'fulfilled') {
          nextPreviews[result.value.fileName] = result.value.payload;
        }
      });
      setMetricPreviews(nextPreviews);
      setMetricPreviewsLoaded(true);
    });

    return () => {
      disposed = true;
    };
  }, [project?.id, stlFileSignature]);

  useEffect(() => {
    if (!project || !visibleStlFiles.length) {
      setModelPreviews({});
      setOverlayStats({ activeModules: 0, filledPixels: 0, segmentCount: 0, modules: [] });
      setOverlayLoadState({ loading: false, loaded: 0, total: 0, phase: '' });
      setOverlayStatus(stlFiles.length ? '当前没有可见 STL 构件' : '当前项目没有 STL 构件');
      return;
    }

    let disposed = false;
    let loaded = 0;
    const total = visibleStlFiles.length;
    const previewLimit = Math.max(detailLimit, 800000);
    const updateLoadProgress = (phase: string) => {
      if (!disposed) {
        setOverlayLoadState({ loading: true, loaded, total, phase });
      }
    };

    setModelPreviews({});
    setOverlayStats({ activeModules: 0, filledPixels: 0, segmentCount: 0, modules: [] });
    setOverlayLoadState({ loading: true, loaded: 0, total, phase: '正在载入可见构件' });
    setOverlayStatus('正在载入可见 STL 构件层级...');
    Promise.allSettled(visibleStlFiles.map((fileName) => (
      getCachedModelPreview(project.id, fileName, previewLimit)
        .then((payload) => {
          loaded += 1;
          updateLoadProgress(fileName.replace(/\.stl$/i, ''));
          return { fileName, payload };
        })
        .catch((error) => {
          loaded += 1;
          updateLoadProgress(`${fileName.replace(/\.stl$/i, '')} 载入失败`);
          throw error;
        })
    ))).then((results) => {
      if (disposed) return;
      const nextPreviews: Record<string, ModelPreviewPayload> = {};
      results.forEach((result) => {
        if (result.status === 'fulfilled') {
          nextPreviews[result.value.fileName] = result.value.payload;
        }
      });
      setModelPreviews(nextPreviews);
      setOverlayLoadState({ loading: false, loaded: total, total, phase: '' });
      setOverlayStatus(Object.keys(nextPreviews).length ? 'Overlay Label Map 已就绪' : 'Overlay Layer 无可用 STL 数据');
    });

    return () => {
      disposed = true;
    };
  }, [project?.id, stlFileSignature, visibleStlFileSignature, detailLimit]);

  useEffect(() => {
    const canvas = baseCanvasRef.current;
    if (!canvas || !dicomPreview) {
      return;
    }
    drawDicomBaseLayer(canvas, dicomPreview);
  }, [dicomPreview]);

  useEffect(() => {
    const canvas = overlayCanvasRef.current;
    if (!canvas || !dicomPreview) {
      return;
    }
    if (!metricPreviewsReady) {
      const context = canvas.getContext('2d');
      context?.clearRect(0, 0, canvas.width, canvas.height);
      setOverlayStats({ activeModules: 0, filledPixels: 0, segmentCount: 0, modules: [] });
      setOverlayStatus('正在计算全局模型边界...');
      return;
    }
    const frame = window.requestAnimationFrame(() => {
      const stats = drawVoxelOverlayLayer(
        canvas,
        dicomPreview,
        visibleStlFiles,
        modelPreviews,
        stlFiles,
        metricPreviews,
        moduleStyles,
        modelPose,
        safeSlice,
        Math.max(totalSlices, 1),
      );
      setOverlayStats(stats);
    });

    return () => window.cancelAnimationFrame(frame);
  }, [
    dicomPreview,
    stlFileSignature,
    visibleStlFileSignature,
    modelPreviews,
    metricPreviews,
    metricPreviewsReady,
    JSON.stringify(moduleStyles),
    modelPose.rotateX,
    modelPose.rotateY,
    modelPose.rotateZ,
    modelPose.translateX,
    modelPose.translateY,
    modelPose.translateZ,
    modelPose.scale,
    modelPose.flipX,
    modelPose.flipY,
    modelPose.flipZ,
    safeSlice,
    totalSlices,
  ]);

  useEffect(() => {
    const element = mappingViewportRef.current;
    if (!element) {
      return;
    }

    const handleWheel = (event: WheelEvent) => {
      event.preventDefault();
      const scaleFactor = event.deltaY > 0 ? 0.9 : 1.1;
      setMappingViewport((current) => ({
        ...current,
        scale: clamp(current.scale * scaleFactor, 0.45, 6),
      }));
    };

    element.addEventListener('wheel', handleWheel, { passive: false });
    return () => {
      element.removeEventListener('wheel', handleWheel);
    };
  }, [isLibraryVariant]);

  const stepSlice = (delta: number) => {
    onSliceChange(clamp(safeSlice + delta, 0, maxSlice));
  };
  const sliderSliceValue = safeSlice;
  const displaySliceNumber = getDicomDisplaySliceNumber(safeSlice, Math.max(totalSlices, 1));
  const resetMappingViewport = () => {
    setMappingViewport({ scale: 1, offsetX: 0, offsetY: 0 });
  };
  const handleMappingPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
    if (event.button !== 0) {
      return;
    }
    mappingPanRef.current = {
      active: true,
      pointerId: event.pointerId,
      startX: event.clientX,
      startY: event.clientY,
      offsetX: mappingViewport.offsetX,
      offsetY: mappingViewport.offsetY,
    };
    event.currentTarget.setPointerCapture(event.pointerId);
  };
  const handleMappingPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
    const dragState = mappingPanRef.current;
    if (!dragState.active || dragState.pointerId !== event.pointerId) {
      return;
    }
    setMappingViewport((current) => ({
      ...current,
      offsetX: dragState.offsetX + event.clientX - dragState.startX,
      offsetY: dragState.offsetY + event.clientY - dragState.startY,
    }));
  };
  const stopMappingPointerDrag = (event: React.PointerEvent<HTMLDivElement>) => {
    const dragState = mappingPanRef.current;
    if (!dragState.active || dragState.pointerId !== event.pointerId) {
      return;
    }
    mappingPanRef.current = { ...dragState, active: false };
    if (event.currentTarget.hasPointerCapture(event.pointerId)) {
      event.currentTarget.releasePointerCapture(event.pointerId);
    }
  };
  const renderOverlayLoadProgress = (tone: 'dark' | 'light') => {
    if (!overlayLoadState.loading || !overlayLoadState.total) {
      return null;
    }

    const percent = Math.round((overlayLoadState.loaded / overlayLoadState.total) * 100);
    const isDark = tone === 'dark';
    return (
      <div className={`pointer-events-none absolute left-4 top-4 z-20 w-64 rounded-xl border px-3 py-2 shadow-lg backdrop-blur-md ${
        isDark
          ? 'border-white/10 bg-black/70 text-white'
          : 'border-slate-200 bg-white/90 text-slate-700'
      }`}>
        <div className="flex items-center justify-between gap-3 text-[10px] font-bold">
          <span className={`min-w-0 truncate ${isDark ? 'text-cyan-100' : 'text-cyan-700'}`}>
            构件层级加载中
          </span>
          <span className={`font-mono ${isDark ? 'text-white/70' : 'text-slate-500'}`}>
            {overlayLoadState.loaded}/{overlayLoadState.total}
          </span>
        </div>
        <div className={`mt-2 h-1.5 overflow-hidden rounded-full ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}>
          <div
            className="h-full rounded-full bg-cyan-400 transition-[width] duration-200"
            style={{ width: `${percent}%` }}
          />
        </div>
        <div className={`mt-1 flex items-center justify-between gap-3 text-[9px] font-bold ${isDark ? 'text-white/45' : 'text-slate-400'}`}>
          <span className="min-w-0 truncate">{overlayLoadState.phase || overlayStatus}</span>
          <span className="font-mono">{percent}%</span>
        </div>
      </div>
    );
  };
  const renderOverlaySummary = (placement: 'bottom' | 'side') => (
    <div className={`${placement === 'side' ? 'w-full rounded-2xl border border-white/10 bg-black/40 p-2' : 'border-t border-white/10 bg-[#030712] px-4 py-3'}`}>
      <div className={`mb-2 flex gap-2 text-[10px] font-bold text-white/60 ${placement === 'side' ? 'flex-col' : 'items-center justify-between'}`}>
        <span className="truncate">Overlay Label Map</span>
        <span className="font-mono text-cyan-100">
          {overlayStats.activeModules}/{visibleModuleCount} 构件 · {overlayStats.segmentCount} 边 · {overlayStats.filledPixels} px
        </span>
      </div>
      <div className={`${placement === 'side' ? 'max-h-44' : 'max-h-24'} overflow-auto pr-1`}>
        {overlayStats.modules.length ? (
          <div className={`grid gap-1.5 ${placement === 'side' ? 'grid-cols-1' : 'grid-cols-2 xl:grid-cols-3'}`}>
            {overlayStats.modules.map((item) => (
              <div key={item.fileName} className="grid grid-cols-[10px_1fr_auto] items-center gap-1 rounded-md border border-white/10 bg-white/5 px-1.5 py-1 text-[8px] font-bold text-white/65">
                <span className="h-2 w-2 rounded-sm border border-white/30" style={{ backgroundColor: item.color, opacity: item.opacity }} />
                <span className="min-w-0 truncate">{item.name}</span>
                <span className="font-mono text-cyan-100">ID {item.partId}</span>
                <span className="col-start-2 font-mono text-white/35">{item.segmentCount} 边</span>
                <span className="font-mono text-white/35">{item.filledPixels} px</span>
              </div>
            ))}
          </div>
        ) : (
          <div className="rounded-lg border border-white/10 bg-white/5 px-2 py-1.5 text-[9px] font-bold text-white/35">
            当前切片暂无可见构件
          </div>
        )}
      </div>
    </div>
  );

  if (isLibraryVariant) {
    return (
      <div className="relative flex h-full min-h-[560px] flex-col overflow-hidden rounded-3xl border border-slate-800 bg-black shadow-xl">
        <div className="flex items-center justify-between gap-3 border-b border-white/10 bg-[#030712] px-4 py-3">
          <div className="min-w-0 rounded-xl border border-white/10 bg-black/60 px-3 py-2 text-[11px] font-bold text-white/70">
            逆向分割映射视图
          </div>
          <div className="flex min-w-0 flex-wrap items-center justify-end gap-1.5">
            {toolbar}
            <button
              onClick={resetMappingViewport}
              className="flex h-8 shrink-0 items-center gap-1.5 rounded-xl border border-white/10 bg-black/60 px-3 text-[10px] font-bold text-white/70 shadow-lg hover:border-cyan-300/30 hover:text-cyan-100"
              title="重置逆向分割映射视图位置"
            >
              <RefreshCcw size={13} />
              位置重置
            </button>
          </div>
        </div>

        <div className={`grid min-h-0 flex-1 ${activeOverlayPlacement === 'side' ? 'grid-cols-[minmax(0,1fr)_188px]' : 'grid-cols-[minmax(0,1fr)_56px]'} bg-black`}>
          <div
            className="flex min-h-0 flex-col"
          >
            <div
              ref={mappingViewportRef}
              className={`relative min-h-0 flex-1 touch-none overflow-hidden bg-black ${mappingPanRef.current.active ? 'cursor-grabbing' : 'cursor-grab'}`}
              onPointerDown={handleMappingPointerDown}
              onPointerMove={handleMappingPointerMove}
              onPointerUp={stopMappingPointerDrag}
              onPointerCancel={stopMappingPointerDrag}
            >
              {dicomPreview ? (
                <div
                  className="absolute inset-0 flex items-center justify-center"
                  style={{
                    transform: `translate3d(${mappingViewport.offsetX}px, ${mappingViewport.offsetY}px, 0) rotate(${rotation}deg) scale(${mappingViewport.scale})`,
                    transformOrigin: 'center center',
                  }}
                >
                  <canvas ref={baseCanvasRef} className="absolute inset-0 h-full w-full object-contain" />
                  <canvas ref={overlayCanvasRef} className="absolute inset-0 h-full w-full object-contain" />
                </div>
              ) : (
                <div className="absolute inset-0 flex items-center justify-center px-8 text-center text-xs font-bold text-white/40">
                  {dicomStatus}
                </div>
              )}
              {renderOverlayLoadProgress('dark')}
              <div className="pointer-events-none absolute bottom-4 right-4 rounded-xl border border-white/10 bg-black/70 px-3 py-2 text-right shadow-lg">
                <p className="text-[9px] font-bold text-white/45">DICOM 切片位置</p>
                <p className="mt-1 font-mono text-[12px] font-bold text-cyan-100">
                  {displaySliceNumber} / {Math.max(totalSlices, 1)}
                </p>
              </div>
            </div>
            {activeOverlayPlacement === 'bottom' && renderOverlaySummary('bottom')}
          </div>

          <aside className="flex min-h-0 flex-col items-center gap-3 border-l border-white/10 bg-[#0f172a] px-2 py-5">
            <div className="relative min-h-[220px] w-8 flex-1">
              <div className="absolute inset-y-0 left-1/2 w-1.5 -translate-x-1/2 rounded-full bg-white/10" />
              <input
                type="range"
                min="0"
                max={maxSlice}
                value={sliderSliceValue}
                onChange={(event) => onSliceChange(Number(event.target.value))}
                className="mapping-slice-dark-vertical-input"
                aria-label="项目库逆向分割映射视图切片导航"
              />
            </div>
            {activeOverlayPlacement === 'side' && renderOverlaySummary('side')}
          </aside>
        </div>
      </div>
    );
  }

  return (
    <div className="relative flex h-full min-h-[520px] flex-col overflow-hidden rounded-3xl border border-slate-100 bg-white shadow-sm">
      <div className="flex items-center justify-between gap-3 border-b border-slate-100 bg-white px-4 py-3">
        <div className="flex min-w-0 flex-wrap items-center gap-2">
          <span className="rounded-lg border border-slate-200 bg-slate-50 px-2.5 py-1 text-[9px] font-bold uppercase tracking-widest text-slate-600">
            Base DICOM
          </span>
          <span className="rounded-lg border border-cyan-200 bg-cyan-50 px-2.5 py-1 text-[9px] font-bold uppercase tracking-widest text-cyan-700">
            Overlay Label Map
          </span>
          <span className="rounded-lg bg-slate-100 px-2.5 py-1 text-[9px] font-mono font-bold text-slate-500">
            Z {displaySliceNumber}/{Math.max(totalSlices, 1)}
          </span>
        </div>
        <button
          onClick={resetMappingViewport}
          className="flex h-8 shrink-0 items-center gap-1.5 rounded-xl border border-slate-200 bg-white px-3 text-[10px] font-bold text-slate-600 shadow-sm hover:border-cyan-200 hover:bg-cyan-50 hover:text-cyan-700"
          title="重置逆向分割映射视图位置"
        >
          <RefreshCcw size={13} />
          位置重置
        </button>
      </div>

      <div className="grid min-h-0 flex-1 grid-cols-[minmax(0,1fr)_110px]">
        <div className="flex min-h-0 flex-col">
          <div
            ref={mappingViewportRef}
            className={`relative min-h-0 flex-1 touch-none overflow-hidden bg-black ${mappingPanRef.current.active ? 'cursor-grabbing' : 'cursor-grab'}`}
            onPointerDown={handleMappingPointerDown}
            onPointerMove={handleMappingPointerMove}
            onPointerUp={stopMappingPointerDrag}
            onPointerCancel={stopMappingPointerDrag}
          >
            {dicomPreview ? (
              <div
                className="absolute inset-0 flex items-center justify-center"
                style={{
                  transform: `translate3d(${mappingViewport.offsetX}px, ${mappingViewport.offsetY}px, 0) rotate(${rotation}deg) scale(${mappingViewport.scale})`,
                  transformOrigin: 'center center',
                }}
              >
                <canvas ref={baseCanvasRef} className="absolute inset-0 h-full w-full object-contain" />
                <canvas ref={overlayCanvasRef} className="absolute inset-0 h-full w-full object-contain" />
              </div>
            ) : (
              <div className="absolute inset-0 flex items-center justify-center px-8 text-center text-xs font-bold text-white/40">
                {dicomStatus}
              </div>
            )}
            {renderOverlayLoadProgress('dark')}
          </div>

          <div className="border-t border-slate-100 bg-white px-4 py-3">
            <div className="mb-2 flex items-center justify-between gap-3 text-[10px] font-bold text-slate-600">
              <span className="truncate">Overlay Label Map</span>
              <span className="font-mono text-cyan-700">
                {overlayStats.activeModules}/{visibleModuleCount} 构件 · {overlayStats.segmentCount} 边 · {overlayStats.filledPixels} px
              </span>
            </div>
            <div className="max-h-24 overflow-auto pr-1">
              {overlayStats.modules.length ? (
                <div className="grid grid-cols-2 gap-1.5 xl:grid-cols-3">
                  {overlayStats.modules.map((item) => (
                    <div key={item.fileName} className="grid grid-cols-[10px_1fr_auto] items-center gap-1 rounded-md border border-slate-100 bg-slate-50 px-1.5 py-1 text-[8px] font-bold text-slate-600">
                      <span className="h-2 w-2 rounded-sm border border-white" style={{ backgroundColor: item.color, opacity: item.opacity }} />
                      <span className="min-w-0 truncate">{item.name}</span>
                      <span className="font-mono text-cyan-700">ID {item.partId}</span>
                      <span className="col-start-2 font-mono text-slate-400">{item.segmentCount} 边</span>
                      <span className="font-mono text-slate-400">{item.filledPixels} px</span>
                    </div>
                  ))}
                </div>
              ) : (
                <div className="rounded-lg border border-slate-100 bg-slate-50 px-2 py-1.5 text-[9px] font-bold text-slate-400">
                  当前切片暂无可见构件
                </div>
              )}
            </div>
          </div>
        </div>

        <aside className="flex min-h-0 flex-col items-center gap-3 border-l border-slate-100 bg-white px-3 py-4">
          <div className="w-full rounded-2xl border border-slate-100 bg-white px-2 py-3 text-center shadow-sm">
            <p className="text-[10px] font-bold text-slate-700">DICOM 切片位置</p>
            <span className="mt-1 block font-mono text-[10px] font-bold text-blue-600">
              {displaySliceNumber} / {Math.max(totalSlices, 1)}
            </span>
          </div>
          <button
            onClick={() => stepSlice(1)}
            disabled={safeSlice >= maxSlice}
            className="flex h-8 w-8 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 shadow-sm hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 disabled:opacity-35"
            title="上一层"
          >
            <ChevronUp size={16} />
          </button>
          <div className="relative min-h-[240px] w-10 flex-1">
            <div className="absolute inset-y-0 left-1/2 w-2 -translate-x-1/2 rounded-full bg-slate-200" />
            <input
              type="range"
              min="0"
              max={maxSlice}
              value={sliderSliceValue}
              onChange={(event) => onSliceChange(Number(event.target.value))}
              className="mapping-slice-vertical-input"
              aria-label="逆向分割映射视图切片导航"
            />
          </div>
          <button
            onClick={() => stepSlice(-1)}
            disabled={safeSlice <= 0}
            className="flex h-8 w-8 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 shadow-sm hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 disabled:opacity-35"
            title="下一层"
          >
            <ChevronDown size={16} />
          </button>
          <div className="grid w-full grid-cols-1 gap-1 text-center text-[9px] font-bold text-slate-500">
            <span>顶层 1</span>
            <span className="text-blue-600">当前 {displaySliceNumber}</span>
            <span>底层 {Math.max(totalSlices, 1)}</span>
          </div>
        </aside>
      </div>
    </div>
  );
}

export default function ReverseWorkspace({
  projectId,
  onLeaveGuardChange,
}: {
  projectId: string;
  onLeaveGuardChange?: (handler: WorkspaceLeaveGuard | null) => void;
}) {
  const [sliceStart, setSliceStart] = useState(0);
  const [sliceEnd, setSliceEnd] = useState(49);
  const [mappingSlice, setMappingSlice] = useState(0);
  const [modelPose, setModelPose] = useState<ModelPose>(defaultModelPose);
  const [poseValueDrafts, setPoseValueDrafts] = useState<PoseDraftValues>(() => formatPoseDraftValues(defaultModelPose));
  const [focusedPoseInput, setFocusedPoseInput] = useState<ModelPoseKey | null>(null);
  const [poseImportStatus, setPoseImportStatus] = useState('');
  const [displayLevel, setDisplayLevel] = useState<DisplayLevel>('standard');
  const [dicomOpacityLevel, setDicomOpacityLevel] = useState<DicomOpacityLevel>('low');
  const [mappingDisplayMode, setMappingDisplayMode] = useState<MappingDisplayMode>('soft');
  const [mappingRotation, setMappingRotation] = useState(0);
  const [showBounds, setShowBounds] = useState(true);
  const [cutEnabled, setCutEnabled] = useState(false);
  const [moduleStyles, setModuleStyles] = useState<Record<string, ModuleStyle>>({});
  const [savedPoses, setSavedPoses] = useState<SavedModelPose[]>(defaultSavedPoses);
  const [selectedPoseId, setSelectedPoseId] = useState('default');
  const [showExportMenu, setShowExportMenu] = useState(false);
  const [exportSelection, setExportSelection] = useState<Record<ProjectExportTarget, boolean>>({
    dicom: false,
    segmentation: true,
    pose: true,
    stl: false,
  });
  const [segmentationExportScope, setSegmentationExportScope] = useState<SegmentationExportScope>('visible');
  const [segmentationExportMode, setSegmentationExportMode] = useState<SegmentationExportMode>('combined');
  const [project, setProject] = useState<Project | null>(null);
  const [fusionVolume, setFusionVolume] = useState<DicomFusionVolume | null>(null);
  const [fusionError, setFusionError] = useState('');
  const [saveStatus, setSaveStatus] = useState('');
  const [exporting, setExporting] = useState(false);
  const [stretchingAxis, setStretchingAxis] = useState<AxisKey | null>(null);
  const modelBoundsCacheRef = useRef(new Map<string, { min: THREE.Vector3; max: THREE.Vector3 }>());
  const [workspaceLoadState, setWorkspaceLoadState] = useState<WorkspaceLoadState>({
    ready: false,
    phase: '正在读取项目配置...',
    loaded: 0,
    total: 1,
    startedAt: Date.now(),
    error: '',
  });
  const workspaceLoadProjectRef = useRef('');
  const poseRepeatRef = useRef<{ timeout: number | null; interval: number | null }>({ timeout: null, interval: null });
  const poseImportInputRef = useRef<HTMLInputElement | null>(null);
  const visualToolbarScrollRef = useRef<HTMLDivElement | null>(null);
  const saveToastTimerRef = useRef<number | null>(null);
  const savedWorkspaceSnapshotRef = useRef('');
  const initialZStretchRef = useRef<{ projectId: string; pending: boolean }>({ projectId: '', pending: false });

  const handleExportSelected = async () => {
    const selectedItems = exportOptions
      .filter((option) => exportSelection[option.id])
      .map((option) => option.id);
    if (!selectedItems.length) {
      setFusionError('请至少选择一个导出内容');
      return;
    }

    setExporting(true);
    setFusionError('');
    try {
      await downloadProjectExportBundle(projectId, selectedItems, 'nii.gz', {
        pose: modelPose,
        segmentationScope: segmentationExportScope,
        segmentationExportMode,
        moduleStyles,
      });
      window.setTimeout(() => setExporting(false), 900);
      setShowExportMenu(false);
    } catch (error) {
      setFusionError(error instanceof Error ? error.message : '导出失败');
      setExporting(false);
    }
  };

  const getCurrentWorkspaceSnapshot = useCallback(() => createWorkspaceSnapshot({
    modelPose,
    segmentationExportScope,
    moduleStyles,
    sliceStart,
    sliceEnd,
    mappingSlice,
    displayLevel,
    dicomOpacityLevel,
    showBounds,
    cutEnabled,
  }), [
    modelPose,
    segmentationExportScope,
    moduleStyles,
    sliceStart,
    sliceEnd,
    mappingSlice,
    displayLevel,
    dicomOpacityLevel,
    showBounds,
    cutEnabled,
  ]);

  const handleSaveSegmentationResult = useCallback(async (options: { showToast?: boolean } = {}) => {
    if (!project) {
      return false;
    }

    setFusionError('');
    setSaveStatus('');
    try {
      const updated = await api.saveProjectSegmentationResult(project.id, {
        name: '逆向分割结果',
        pose: modelPose,
        segmentationScope: segmentationExportScope,
        moduleStyles,
        sliceStart: clamp(sliceStart, 0, Math.max(project.dicomCount - 1, 0)),
        sliceEnd: clamp(sliceEnd, 0, Math.max(project.dicomCount - 1, 0)),
        mappingSlice: clamp(mappingSlice, 0, Math.max(project.dicomCount - 1, 0)),
        displayLevel,
        dicomOpacityLevel,
        showBounds,
        cutEnabled,
      });
      setProject(updated);
      savedWorkspaceSnapshotRef.current = getCurrentWorkspaceSnapshot();
      if (options.showToast !== false) {
        setSaveStatus('已保存至项目库的分割结果区域');
      }
      return true;
    } catch (error) {
      const message = error instanceof Error ? error.message : '保存至项目库失败';
      setFusionError(message);
      if (options.showToast === false) {
        window.alert(message);
      }
      return false;
    }
  }, [
    project,
    modelPose,
    segmentationExportScope,
    moduleStyles,
    sliceStart,
    sliceEnd,
    mappingSlice,
    displayLevel,
    dicomOpacityLevel,
    showBounds,
    cutEnabled,
    getCurrentWorkspaceSnapshot,
  ]);

  useEffect(() => {
    if (!saveStatus) {
      return undefined;
    }

    if (saveToastTimerRef.current !== null) {
      window.clearTimeout(saveToastTimerRef.current);
    }
    saveToastTimerRef.current = window.setTimeout(() => {
      setSaveStatus('');
      saveToastTimerRef.current = null;
    }, 2600);

    return () => {
      if (saveToastTimerRef.current !== null) {
        window.clearTimeout(saveToastTimerRef.current);
        saveToastTimerRef.current = null;
      }
    };
  }, [saveStatus]);

  useEffect(() => {
    if (!onLeaveGuardChange) {
      return undefined;
    }

    onLeaveGuardChange(async () => {
      if (!project) {
        return true;
      }
      if (project.locked) {
        return true;
      }
      if (savedWorkspaceSnapshotRef.current === getCurrentWorkspaceSnapshot()) {
        return true;
      }
      const shouldSave = window.confirm('是否保存当前结果至项目库？ 确定：保存后退出。取消：直接退出，不保存当前结果。');
      if (!shouldSave) {
        return true;
      }
      return handleSaveSegmentationResult({ showToast: false });
    });

    return () => onLeaveGuardChange(null);
  }, [getCurrentWorkspaceSnapshot, handleSaveSegmentationResult, onLeaveGuardChange, project]);

  const makeDefaultModuleStyle = (index: number, fallback?: Partial<ModuleStyle>): ModuleStyle => ({
    visible: fallback?.visible ?? true,
    color: fallback?.color ?? moduleColors[index % moduleColors.length],
    opacity: fallback?.opacity ?? 0.72,
    partId: clamp(Math.round(fallback?.partId ?? index + 1), 1, 255),
  });

  const commitModuleStyles = (next: Record<string, ModuleStyle>) => {
    setModuleStyles(next);
    if (!project) {
      return;
    }
    api.updateProjectModuleStyles(project.id, next)
      .then((updated) => {
        setProject(updated);
      })
      .catch(() => {
        setFusionError('构件样式保存失败，请稍后重试');
      });
  };

  const loadFusionVolume = async (start: number, end: number) => {
    if (!project?.dicomCount) return null;
    const maxSliceValue = Math.max(project.dicomCount - 1, 0);
    const safeA = clamp(start, 0, maxSliceValue);
    const safeB = clamp(end, 0, maxSliceValue);
    const safeStart = Math.min(safeA, safeB);
    const rangeEnd = Math.max(safeA, safeB);
    const volumePayload = await getCachedDicomFusionVolume(project.id, safeStart, rangeEnd, 'soft');
    return volumePayload;
  };

  const loadGlobalModelBounds = async () => {
    if (!project) {
      return null;
    }
    const modelFiles = project.stlFiles ?? [];
    const cacheKey = `${project.id}:global:${modelFiles.join('|')}`;
    const cached = modelBoundsCacheRef.current.get(cacheKey);
    if (cached) {
      return cached;
    }

    const modelBox = new THREE.Box3();
    const results = await Promise.allSettled(modelFiles.map((fileName) => (
      getCachedModelPreview(project.id, fileName, 1000)
    )));
    results.forEach((result) => {
      if (result.status !== 'fulfilled' || !result.value.bounds) {
        return;
      }
      modelBox.expandByPoint(new THREE.Vector3(result.value.bounds.min.x, result.value.bounds.min.y, result.value.bounds.min.z));
      modelBox.expandByPoint(new THREE.Vector3(result.value.bounds.max.x, result.value.bounds.max.y, result.value.bounds.max.z));
    });

    if (modelBox.isEmpty()) {
      return null;
    }
    const bounds = { min: modelBox.min.clone(), max: modelBox.max.clone() };
    modelBoundsCacheRef.current.set(cacheKey, bounds);
    return bounds;
  };

  const applyModelStretchByAxis = async (axis: AxisKey, options: { silentInitial?: boolean } = {}) => {
    if (!project || !fusionVolume) {
      setFusionError('请等待 DICOM 与 STL 数据加载完成后再拉伸模型');
      return;
    }
    if (!isOrthogonalModelPose(modelPose)) {
      setPoseImportStatus('');
      setFusionError('模型拉伸仅在旋转 X/Y/Z 均为 90° 的整数倍时可用');
      return;
    }

    setStretchingAxis(axis);
    setFusionError('');
    try {
      const bounds = await loadGlobalModelBounds();
      if (!bounds) {
        throw new Error('未获取到 STL 构件边界');
      }
      const rawSize = new THREE.Vector3().subVectors(bounds.max, bounds.min);
      const rotatedSize = getRotatedModelSize(bounds, modelPose);
      const maxModelSize = Math.max(rawSize.x, rawSize.y, rawSize.z, 1);
      const maxPhysical = Math.max(
        fusionVolume.physicalSize.width,
        fusionVolume.physicalSize.height,
        fusionVolume.physicalSize.depth,
        1,
      );
      const baseExtent = 4.6;
      const dicomSize = {
        x: (fusionVolume.physicalSize.width / maxPhysical) * baseExtent,
        y: (fusionVolume.physicalSize.height / maxPhysical) * baseExtent,
        z: Math.max((fusionVolume.physicalSize.depth / maxPhysical) * baseExtent, 0.18),
      };
      const baseScale = (Math.max(dicomSize.x, dicomSize.y, dicomSize.z) / maxModelSize) * 0.92;
      const rotatedAxisSize = Math.max(rotatedSize[axis], 1e-6);
      const axisFitScale = dicomSize[axis] / (rotatedAxisSize * baseScale);
      const containmentScale = Math.min(
        dicomSize.x / (Math.max(rotatedSize.x, 1e-6) * baseScale),
        dicomSize.y / (Math.max(rotatedSize.y, 1e-6) * baseScale),
        dicomSize.z / (Math.max(rotatedSize.z, 1e-6) * baseScale),
      );
      const nextScale = clampPoseValue('scale', Math.min(axisFitScale, containmentScale));
      const nextPose = { ...modelPose, scale: nextScale };
      updateModelPose({ scale: nextScale }, { markCustom: !options.silentInitial, keepStatus: true });
      setPoseImportStatus('');
      if (options.silentInitial) {
        savedWorkspaceSnapshotRef.current = createWorkspaceSnapshot({
          modelPose: nextPose,
          segmentationExportScope,
          moduleStyles,
          sliceStart,
          sliceEnd,
          mappingSlice,
          displayLevel,
          dicomOpacityLevel,
          showBounds,
          cutEnabled,
        });
      }
    } catch (error) {
      setFusionError(error instanceof Error ? error.message : '模型自动拉伸失败');
    } finally {
      setStretchingAxis(null);
    }
  };

  useEffect(() => {
    workspaceLoadProjectRef.current = '';
    setWorkspaceLoadState({
      ready: false,
      phase: '正在读取项目配置...',
      loaded: 0,
      total: 1,
      startedAt: Date.now(),
      error: '',
    });
    api.getProject(projectId).then((item) => {
      setProject(item);
      if (item.locked) {
        setFusionVolume(null);
        setWorkspaceLoadState({
          ready: false,
          phase: '项目已锁定',
          loaded: 1,
          total: 1,
          startedAt: Date.now(),
          error: '项目已锁定，请在项目库解锁后再进入逆向工作区。',
        });
        savedWorkspaceSnapshotRef.current = '';
        return;
      }
      const maxIndex = Math.max((item.dicomCount || 1) - 1, 0);
      const latestResult = item.segmentationResults?.[item.segmentationResults.length - 1];
      const restoredSliceStart = clamp(latestResult?.sliceStart ?? 0, 0, maxIndex);
      const restoredSliceEnd = clamp(latestResult?.sliceEnd ?? maxIndex, 0, maxIndex);
      const restoredMappingSlice = clamp(latestResult?.mappingSlice ?? restoredSliceEnd, 0, maxIndex);
      setSliceStart(restoredSliceStart);
      setSliceEnd(restoredSliceEnd);
      setMappingSlice(restoredMappingSlice);
      const nextPoses = (item.modelPoses?.length ? item.modelPoses : defaultSavedPoses).map((pose) => ({
        ...pose,
        pose: normalizePoseValue(pose.pose) ?? defaultModelPose,
      }));
      const preferredPose = nextPoses.find((pose) => pose.id === 'default') ?? nextPoses[0];
      const restoredPose = normalizePoseValue(latestResult?.pose)
        ?? normalizePoseValue(preferredPose?.pose)
        ?? defaultModelPose;
      initialZStretchRef.current = { projectId: item.id, pending: !latestResult };
      setModelPose(restoredPose);
      setPoseValueDrafts(formatPoseDraftValues(restoredPose));
      const nextStyles: Record<string, ModuleStyle> = {};
      (item.stlFiles ?? []).forEach((fileName, index) => {
        nextStyles[fileName] = makeDefaultModuleStyle(index, {
          ...(latestResult?.moduleStyles?.[fileName] ?? {}),
          ...(item.moduleStyles?.[fileName] ?? {}),
        });
      });
      setModuleStyles(nextStyles);
      setSavedPoses(nextPoses);
      setSelectedPoseId(latestResult ? 'reverse-result' : preferredPose?.id ?? 'default');
      setSegmentationExportScope(latestResult?.segmentationScope ?? 'visible');
      setDisplayLevel(latestResult?.displayLevel ?? 'standard');
      setDicomOpacityLevel(latestResult?.dicomOpacityLevel ?? 'low');
      setMappingDisplayMode('soft');
      setMappingRotation(0);
      setShowBounds(latestResult?.showBounds ?? true);
      setCutEnabled(latestResult?.cutEnabled ?? false);
      savedWorkspaceSnapshotRef.current = createWorkspaceSnapshot({
        modelPose: restoredPose,
        segmentationExportScope: latestResult?.segmentationScope ?? 'visible',
        moduleStyles: nextStyles,
        sliceStart: restoredSliceStart,
        sliceEnd: restoredSliceEnd,
        mappingSlice: restoredMappingSlice,
        displayLevel: latestResult?.displayLevel ?? 'standard',
        dicomOpacityLevel: latestResult?.dicomOpacityLevel ?? 'low',
        showBounds: latestResult?.showBounds ?? true,
        cutEnabled: latestResult?.cutEnabled ?? false,
      });
    }).catch(() => {
      setProject(null);
      setFusionVolume(null);
      setWorkspaceLoadState({
        ready: false,
        phase: '项目配置读取失败',
        loaded: 0,
        total: 1,
        startedAt: Date.now(),
        error: '项目配置读取失败',
      });
      savedWorkspaceSnapshotRef.current = '';
    });
  }, [projectId]);

  useEffect(() => {
    if (!project?.dicomCount) return;
    const maxSlice = Math.max(project.dicomCount - 1, 0);
    const safeStart = clamp(sliceStart, 0, maxSlice);
    const safeEnd = clamp(sliceEnd, 0, maxSlice);
    const timer = window.setTimeout(() => {
      setFusionError('');
      loadFusionVolume(safeStart, safeEnd)
        .then(setFusionVolume)
        .catch((error) => {
          setFusionVolume(null);
          setFusionError(error instanceof Error ? error.message : 'DICOM 融合体加载失败');
        });
    }, 180);
    return () => window.clearTimeout(timer);
  }, [project?.id, project?.dicomCount, sliceStart, sliceEnd]);

  useEffect(() => () => {
    if (poseRepeatRef.current.timeout !== null) {
      window.clearTimeout(poseRepeatRef.current.timeout);
    }
    if (poseRepeatRef.current.interval !== null) {
      window.clearInterval(poseRepeatRef.current.interval);
    }
  }, []);

  useEffect(() => {
    setPoseValueDrafts((current) => {
      const next = { ...current };
      modelPoseKeys.forEach((key) => {
        if (focusedPoseInput !== key) {
          next[key] = formatPoseValue(key, modelPose[key]);
        }
      });
      return next;
    });
  }, [
    focusedPoseInput,
    modelPose.rotateX,
    modelPose.rotateY,
    modelPose.rotateZ,
    modelPose.translateX,
    modelPose.translateY,
    modelPose.translateZ,
    modelPose.scale,
  ]);

  const clampPoseValue = (key: ModelPoseKey, value: number) => {
    const limit = poseStepConfig[key];
    const precision = getStepPrecision(limit.step);
    return Number(clamp(value, limit.min, limit.max).toFixed(precision));
  };

  const updateModelPose = (partial: Partial<ModelPose>, options: { markCustom?: boolean; keepStatus?: boolean } = {}) => {
    setModelPose((current) => {
      const next = { ...current };
      modelPoseKeys.forEach((key) => {
        const value = partial[key];
        if (typeof value === 'number' && Number.isFinite(value)) {
          next[key] = clampPoseValue(key, value);
        }
      });
      return next;
    });
    if (options.markCustom !== false) {
      setSelectedPoseId('custom');
    }
    if (!options.keepStatus) {
      setPoseImportStatus('');
    }
  };

  const restoreVisualToolbarScroll = (scrollTop: number | null) => {
    if (scrollTop === null) {
      return;
    }
    window.requestAnimationFrame(() => {
      if (visualToolbarScrollRef.current) {
        visualToolbarScrollRef.current.scrollTop = scrollTop;
      }
    });
  };

  const nudgeModelPose = (key: ModelPoseKey, delta: number) => {
    const scrollTop = visualToolbarScrollRef.current?.scrollTop ?? null;
    setModelPose((current) => ({
      ...current,
      [key]: clampPoseValue(key, current[key] + delta),
    }));
    setSelectedPoseId('custom');
    setPoseImportStatus('');
    restoreVisualToolbarScroll(scrollTop);
  };

  const handlePoseInputChange = (key: ModelPoseKey, value: string) => {
    setPoseValueDrafts((current) => ({ ...current, [key]: value }));
    if (!value.trim()) {
      return;
    }

    const numericValue = Number(value);
    if (!Number.isFinite(numericValue)) {
      return;
    }

    updateModelPose({ [key]: numericValue } as Partial<ModelPose>);
  };

  const commitPoseInputValue = (key: ModelPoseKey) => {
    const draftValue = poseValueDrafts[key];
    const numericValue = draftValue.trim() ? Number(draftValue) : NaN;
    if (Number.isFinite(numericValue)) {
      const nextValue = clampPoseValue(key, numericValue);
      if (Math.abs(nextValue - modelPose[key]) > 1e-9) {
        updateModelPose({ [key]: nextValue } as Partial<ModelPose>);
      }
      setPoseValueDrafts((current) => ({ ...current, [key]: formatPoseValue(key, nextValue) }));
    } else {
      setPoseValueDrafts((current) => ({ ...current, [key]: formatPoseValue(key, modelPose[key]) }));
    }
    setFocusedPoseInput(null);
  };

  const stopPoseRepeat = () => {
    if (poseRepeatRef.current.timeout !== null) {
      window.clearTimeout(poseRepeatRef.current.timeout);
      poseRepeatRef.current.timeout = null;
    }
    if (poseRepeatRef.current.interval !== null) {
      window.clearInterval(poseRepeatRef.current.interval);
      poseRepeatRef.current.interval = null;
    }
  };

  const startPoseRepeat = (key: ModelPoseKey, delta: number) => {
    stopPoseRepeat();
    const repeatDelta = delta * (poseRepeatDeltaMultiplier[key] ?? 1);
    poseRepeatRef.current.timeout = window.setTimeout(() => {
      nudgeModelPose(key, repeatDelta);
      poseRepeatRef.current.interval = window.setInterval(() => nudgeModelPose(key, repeatDelta), poseRepeatIntervalMs);
    }, poseRepeatDelayMs);
  };

  const resetRotationPose = () => {
    setModelPose((current) => ({
      ...current,
      rotateX: 0,
      rotateY: 0,
      rotateZ: 0,
    }));
    setSelectedPoseId('custom');
    setPoseImportStatus('');
  };

  const resetTransformPose = () => {
    setModelPose((current) => ({
      ...current,
      translateX: 0,
      translateY: 0,
      translateZ: 0,
      scale: 1,
    }));
    setSelectedPoseId('custom');
    setPoseImportStatus('');
  };

  const toggleModelFlip = (key: ModelPoseFlipKey) => {
    const scrollTop = visualToolbarScrollRef.current?.scrollTop ?? null;
    setModelPose((current) => ({
      ...current,
      [key]: !current[key],
    }));
    setSelectedPoseId('custom');
    setPoseImportStatus('');
    restoreVisualToolbarScroll(scrollTop);
  };

  const resetModelFlipPose = () => {
    const scrollTop = visualToolbarScrollRef.current?.scrollTop ?? null;
    setModelPose((current) => ({
      ...current,
      flipX: false,
      flipY: false,
      flipZ: false,
    }));
    setSelectedPoseId('custom');
    setPoseImportStatus('');
    restoreVisualToolbarScroll(scrollTop);
  };

  const allModulesVisible = Boolean(project?.stlFiles?.length) && (project?.stlFiles ?? []).every((fileName) => moduleStyles[fileName]?.visible !== false);

  const toggleAllModules = () => {
    const stlFiles = project?.stlFiles ?? [];
    const nextVisible = !allModulesVisible;
    const next = { ...moduleStyles };
    stlFiles.forEach((fileName, index) => {
      next[fileName] = makeDefaultModuleStyle(index, {
        ...(next[fileName] ?? project?.moduleStyles?.[fileName]),
        visible: nextVisible,
      });
    });
    commitModuleStyles(next);
  };

  const updateModuleStyle = (fileName: string, partial: Partial<ModuleStyle>) => {
    const stlFiles = project?.stlFiles ?? [];
    const index = Math.max(0, stlFiles.indexOf(fileName));
    const next = {
      ...moduleStyles,
      [fileName]: makeDefaultModuleStyle(index, {
        ...(moduleStyles[fileName] ?? project?.moduleStyles?.[fileName]),
        ...partial,
      }),
    };
    commitModuleStyles(next);
  };

  const updateModulePartId = (fileName: string, value: number) => {
    updateModuleStyle(fileName, { partId: clamp(Math.round(Number.isFinite(value) ? value : 1), 1, 255) });
  };

  const commitSavedPoses = (next: SavedModelPose[]) => {
    setSavedPoses(next);
    if (!project) {
      return;
    }
    api.updateProjectModelPoses(project.id, next)
      .then((updated) => {
        setProject(updated);
        setSavedPoses(updated.modelPoses?.length ? updated.modelPoses : next);
      })
      .catch(() => {
        setFusionError('位姿保存失败，请稍后重试');
      });
  };

  const saveCurrentPose = () => {
    const nextPose = {
      id: `pose-${Date.now()}`,
      name: `位姿${savedPoses.length - 2}`,
      pose: { ...modelPose },
    };
    commitSavedPoses([...savedPoses, nextPose]);
    setSelectedPoseId(nextPose.id);
  };

  const renamePose = (poseId: string, name: string) => {
    if (poseId === 'default') return;
    const nextName = name.trim();
    commitSavedPoses(savedPoses.map((item) => (
      item.id === poseId ? { ...item, name: nextName || item.name } : item
    )));
  };

  const selectPose = (poseId: string) => {
    const selected = savedPoses.find((item) => item.id === poseId);
    if (!selected) return;
    setSelectedPoseId(poseId);
    setModelPose(selected.pose);
    setPoseImportStatus('');
  };

  const handleImportPoseFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
    const file = event.target.files?.[0];
    event.target.value = '';
    if (!file) {
      return;
    }

    try {
      const payload = JSON.parse(await file.text()) as unknown;
      const { activePose, importedModelPoses } = parseImportedPosePayload(payload);
      if (!activePose && !importedModelPoses?.length) {
        throw new Error('未找到可用位姿数据');
      }

      const nextSavedPoses = importedModelPoses?.length
        ? mergeImportedModelPoses(importedModelPoses)
        : savedPoses;
      if (importedModelPoses?.length) {
        commitSavedPoses(nextSavedPoses);
      }

      if (activePose) {
        setModelPose(activePose);
        setPoseValueDrafts(formatPoseDraftValues(activePose));
        const matchedPose = nextSavedPoses.find((item) => poseValuesMatch(item.pose, activePose));
        setSelectedPoseId(matchedPose?.id ?? 'custom');
      }

      setFusionError('');
      setPoseImportStatus(importedModelPoses?.length ? '位姿数据已导入并保存' : '当前位姿已导入');
    } catch (error) {
      setPoseImportStatus('');
      setFusionError(error instanceof Error ? `位姿导入失败：${error.message}` : '位姿导入失败');
    }
  };

  const maxSlice = Math.max((project?.dicomCount ?? 1) - 1, 0);
  const safeSliceStart = clamp(sliceStart, 0, maxSlice);
  const safeSliceEnd = clamp(sliceEnd, 0, maxSlice);
  const safeMappingSlice = clamp(mappingSlice, 0, maxSlice);
  const displayStart = Math.min(safeSliceStart, safeSliceEnd);
  const displayEnd = Math.max(safeSliceStart, safeSliceEnd);
  const displaySliceRange = getDicomDisplayRange(displayStart, displayEnd, project?.dicomCount ?? 0);
  const rangeStartPercent = maxSlice > 0 ? (displayStart / maxSlice) * 100 : 0;
  const rangeEndPercent = maxSlice > 0 ? (displayEnd / maxSlice) * 100 : 0;
  const selectedDisplay = displayOptions.find((item) => item.id === displayLevel) ?? displayOptions[0];
  const selectedDicomOpacity = dicomOpacityOptions.find((item) => item.id === dicomOpacityLevel) ?? dicomOpacityOptions[0];
  const stretchEnabled = Boolean(project && fusionVolume && isOrthogonalModelPose(modelPose));
  const workspaceProgress = workspaceLoadState.total > 0
    ? Math.round((workspaceLoadState.loaded / workspaceLoadState.total) * 100)
    : 0;
  const workspaceElapsedSeconds = Math.max((Date.now() - workspaceLoadState.startedAt) / 1000, 0.1);
  const workspaceLoadSpeed = workspaceLoadState.loaded / workspaceElapsedSeconds;

  useEffect(() => {
    if (!project?.dicomCount || project.locked) {
      return undefined;
    }
    if (workspaceLoadProjectRef.current === project.id) {
      return undefined;
    }

    let cancelled = false;
    const stlFilesForLoad = project.stlFiles ?? [];
    const fusionStart = Math.min(displayStart, displayEnd);
    const fusionEnd = Math.max(displayStart, displayEnd);
    const previewLimit = selectedDisplay.limit;
    const mappingPreviewLimit = Math.max(previewLimit, 800000);
    const total = 2 + stlFilesForLoad.length * 2;
    const startedAt = Date.now();
    let loaded = 0;

    const updateLoadState = (phase: string, error = '') => {
      if (cancelled) {
        return;
      }
      setWorkspaceLoadState({
        ready: false,
        phase,
        loaded,
        total,
        startedAt,
        error,
      });
    };
    const markLoaded = (phase: string) => {
      loaded += 1;
      updateLoadState(phase);
    };

    updateLoadState('正在载入 DICOM 三维体与 STL 构件预览...');

    const tasks: Array<Promise<unknown>> = [
      getCachedDicomFusionVolume(project.id, fusionStart, fusionEnd, 'soft')
        .then((volume) => {
          if (!cancelled) {
            setFusionVolume(volume);
          }
          markLoaded('DICOM 三维融合体已载入');
        }),
      getCachedDicomPreview(project.id, safeMappingSlice, 'axial', mappingDisplayMode)
        .then(() => markLoaded('DICOM 切片预览已载入')),
      ...stlFilesForLoad.map((fileName) => (
        getCachedModelPreview(project.id, fileName, previewLimit)
          .then(() => markLoaded(`三维模型预览已缓存：${fileName.replace(/\.stl$/i, '')}`))
      )),
      ...stlFilesForLoad.map((fileName) => (
        getCachedModelPreview(project.id, fileName, mappingPreviewLimit)
          .then(() => markLoaded(`二维映射网格已缓存：${fileName.replace(/\.stl$/i, '')}`))
      )),
    ];

    Promise.allSettled(tasks).then((results) => {
      if (cancelled) {
        return;
      }
      const fusionFailed = results[0]?.status === 'rejected';
      if (fusionFailed) {
        setFusionVolume(null);
        setWorkspaceLoadState({
          ready: false,
          phase: 'DICOM 三维融合体载入失败',
          loaded,
          total,
          startedAt,
          error: 'DICOM 三维融合体载入失败，请检查数据或刷新页面重试。',
        });
        return;
      }
      workspaceLoadProjectRef.current = project.id;
      setWorkspaceLoadState({
        ready: true,
        phase: '逆向工作区已就绪',
        loaded: total,
        total,
        startedAt,
        error: '',
      });
    });

    return () => {
      cancelled = true;
    };
  }, [
    project?.id,
    project?.dicomCount,
    project?.locked,
    project?.stlFiles?.join('|'),
    displayStart,
    displayEnd,
    safeMappingSlice,
    selectedDisplay.limit,
    mappingDisplayMode,
  ]);

  useEffect(() => {
    if (!project || !fusionVolume || !workspaceLoadState.ready) {
      return;
    }
    const stretchState = initialZStretchRef.current;
    if (stretchState.projectId !== project.id || !stretchState.pending || !isOrthogonalModelPose(modelPose)) {
      return;
    }
    initialZStretchRef.current = { projectId: project.id, pending: false };
    void applyModelStretchByAxis('z', { silentInitial: true });
  }, [
    project?.id,
    fusionVolume,
    workspaceLoadState.ready,
    modelPose.rotateX,
    modelPose.rotateY,
    modelPose.rotateZ,
  ]);

  if (project?.locked) {
    return (
      <div className="flex h-full min-h-0 items-center justify-center overflow-hidden pr-2">
        <div className="w-full max-w-2xl rounded-3xl border border-amber-100 bg-white p-8 text-center shadow-sm">
          <div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-amber-50 text-amber-600">
            <Lock size={24} />
          </div>
          <h2 className="mt-5 text-2xl font-black text-slate-900">项目已锁定</h2>
          <p className="mx-auto mt-3 max-w-lg text-sm font-semibold leading-6 text-slate-500">
            请先在项目库点击“解锁项目”，再进入逆向工作区继续修改位姿和分割结果。
          </p>
          <div className="mt-6 rounded-2xl bg-slate-50 px-4 py-3 text-left text-xs font-bold text-slate-500">
            <p>项目：{project.name}</p>
            <p className="mt-1">锁定时间：{project.lockedAt ? new Date(project.lockedAt).toLocaleString('zh-CN') : '未记录'}</p>
            <p className="mt-1">锁定结果：{project.lockedPoseSnapshotPath ?? '项目数据/锁定结果'}</p>
          </div>
        </div>
      </div>
    );
  }

  if (!workspaceLoadState.ready) {
    return (
      <div className="flex h-full min-h-0 items-center justify-center overflow-hidden pr-2">
        <div className="w-full max-w-3xl rounded-3xl border border-slate-100 bg-white p-8 shadow-sm">
          <div className="mb-6 flex items-start justify-between gap-4">
            <div>
              <p className="text-xs font-bold uppercase tracking-[0.24em] text-blue-500">Reverse Workspace</p>
              <h2 className="mt-2 text-2xl font-black text-slate-900">正在载入完整逆向工作区</h2>
              <p className="mt-2 text-sm font-semibold text-slate-500">
                DICOM 三维体、STL 构件预览和二维映射网格准备完成后再显示工作区。
              </p>
            </div>
            <span className="rounded-2xl bg-slate-950 px-4 py-2 font-mono text-sm font-bold text-cyan-100">
              {Math.max(0, Math.min(100, workspaceProgress))}%
            </span>
          </div>
          <div className="h-3 overflow-hidden rounded-full bg-slate-100">
            <div
              className="h-full rounded-full bg-blue-600 transition-all duration-300"
              style={{ width: `${Math.max(4, Math.min(100, workspaceProgress))}%` }}
            />
          </div>
          <div className="mt-5 grid gap-3 text-xs font-bold text-slate-500 sm:grid-cols-3">
            <div className="rounded-2xl bg-slate-50 px-4 py-3">
              <span className="block text-[10px] uppercase tracking-widest text-slate-400">当前阶段</span>
              <span className="mt-1 block truncate text-slate-700">{workspaceLoadState.phase}</span>
            </div>
            <div className="rounded-2xl bg-slate-50 px-4 py-3">
              <span className="block text-[10px] uppercase tracking-widest text-slate-400">加载进度</span>
              <span className="mt-1 block font-mono text-slate-700">
                {workspaceLoadState.loaded} / {workspaceLoadState.total}
              </span>
            </div>
            <div className="rounded-2xl bg-slate-50 px-4 py-3">
              <span className="block text-[10px] uppercase tracking-widest text-slate-400">加载速度</span>
              <span className="mt-1 block font-mono text-slate-700">
                {workspaceLoadSpeed.toFixed(1)} 项/秒
              </span>
            </div>
          </div>
          {workspaceLoadState.error && (
            <div className="mt-5 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-xs font-bold text-amber-700">
              {workspaceLoadState.error}
            </div>
          )}
        </div>
      </div>
    );
  }

  return (
    <div className="h-full min-h-0 overflow-y-auto pr-2 flex flex-col gap-6">
      {saveStatus && (
        <>
          <style>
            {`@keyframes reverse-result-toast { 0% { opacity: 0; transform: translate(-50%, -10px); } 14% { opacity: 1; transform: translate(-50%, 0); } 72% { opacity: 1; transform: translate(-50%, 0); } 100% { opacity: 0; transform: translate(-50%, -10px); } }`}
          </style>
          <div
            className="fixed left-1/2 top-20 z-50 rounded-2xl border border-cyan-200 bg-white px-5 py-3 text-sm font-bold text-cyan-700 shadow-2xl shadow-cyan-950/10"
            style={{ animation: 'reverse-result-toast 2.6s ease forwards' }}
          >
            {saveStatus}
          </div>
        </>
      )}
      <div className="flex items-center justify-between">
        <div>
          {project && (
            <div className="flex flex-wrap gap-3 text-sm font-bold">
              <span className="rounded-xl bg-blue-50 px-4 py-2 text-blue-700">当前项目：{project.name}</span>
              <span className="rounded-xl bg-slate-100 px-4 py-2 text-slate-700">DICOM {project.dicomCount}</span>
              <span className="rounded-xl bg-slate-100 px-4 py-2 text-slate-700">STL {project.modelCount ?? 0}</span>
            </div>
          )}
          {!project && <p className="text-sm text-slate-500">配准 DICOM 影像与三维模型，生成像素映射关系</p>}
        </div>
        <div className="flex gap-2">
          <button
            onClick={() => void handleSaveSegmentationResult()}
            disabled={!project}
            className="bg-cyan-600 text-white px-5 py-2.5 rounded-xl text-sm font-semibold hover:bg-cyan-700 transition-all shadow-lg flex items-center gap-2 disabled:opacity-50"
          >
            <Save size={18} />
            保存至项目库
          </button>
          <div className="relative">
            <button
              onClick={() => setShowExportMenu((value) => !value)}
              disabled={exporting}
              className="bg-emerald-600 text-white px-5 py-2.5 rounded-xl text-sm font-semibold hover:bg-emerald-700 transition-all shadow-lg flex items-center gap-2 disabled:opacity-50"
            >
              <Download size={18} />
              {exporting ? '正在导出' : '导出项目及结果'}
            </button>
            {showExportMenu && (
              <div className="absolute right-0 top-12 z-30 w-72 rounded-2xl border border-slate-200 bg-white p-3 text-xs shadow-2xl">
                <div className="mb-2 flex items-center justify-between">
                  <p className="font-bold text-slate-700">导出内容</p>
                  <button
                    onClick={() => setExportSelection({ dicom: true, segmentation: true, pose: true, stl: true })}
                    className="text-[10px] font-bold text-emerald-600 hover:text-emerald-700"
                  >
                    全选
                  </button>
                </div>
                <div className="space-y-2">
                  {exportOptions.map((option) => (
                    <label key={option.id} className="flex items-center gap-3 rounded-xl bg-slate-50 px-3 py-2 font-bold text-slate-600">
                      <input
                        type="checkbox"
                        checked={exportSelection[option.id]}
                        onChange={(event) => setExportSelection((current) => ({ ...current, [option.id]: event.target.checked }))}
                        className="accent-emerald-600"
                      />
                      <span className="min-w-0 flex-1">
                        <span className="block">{option.label}</span>
                        <span className="block text-[10px] text-slate-400">{option.description}</span>
                      </span>
                    </label>
                  ))}
                </div>
                {exportSelection.segmentation && (
                  <div className="mt-3 rounded-xl border border-emerald-100 bg-emerald-50/70 p-2">
                    <div className="mb-2 flex items-center justify-between gap-2">
                      <p className="text-[10px] font-bold text-emerald-800">分割类别范围</p>
                      <span className="text-[9px] font-bold text-emerald-600">附带 labels.json</span>
                    </div>
                    <div className="grid grid-cols-2 gap-1.5">
                      {segmentationScopeOptions.map((option) => (
                        <button
                          key={option.id}
                          onClick={() => setSegmentationExportScope(option.id)}
                          className={`rounded-lg px-2 py-1.5 text-left transition ${
                            segmentationExportScope === option.id
                              ? 'bg-emerald-600 text-white shadow-sm'
                              : 'bg-white text-emerald-700 hover:bg-emerald-100'
                          }`}
                        >
                          <span className="block text-[10px] font-bold">{option.label}</span>
                          <span className={`block text-[9px] ${segmentationExportScope === option.id ? 'text-emerald-50' : 'text-emerald-500'}`}>
                            {option.description}
                          </span>
                        </button>
                      ))}
                    </div>
                    <div className="mt-2 border-t border-emerald-100 pt-2">
                      <p className="mb-2 text-[10px] font-bold text-emerald-800">分割导出方式</p>
                      <div className="grid grid-cols-2 gap-1.5">
                        {segmentationExportModeOptions.map((option) => (
                          <button
                            key={option.id}
                            onClick={() => setSegmentationExportMode(option.id)}
                            className={`rounded-lg px-2 py-1.5 text-left transition ${
                              segmentationExportMode === option.id
                                ? 'bg-slate-900 text-white shadow-sm'
                                : 'bg-white text-slate-600 hover:bg-emerald-100'
                            }`}
                          >
                            <span className="block text-[10px] font-bold">{option.label}</span>
                            <span className={`block text-[9px] ${segmentationExportMode === option.id ? 'text-slate-200' : 'text-slate-400'}`}>
                              {option.description}
                            </span>
                          </button>
                        ))}
                      </div>
                    </div>
                  </div>
                )}
                <button
                  onClick={handleExportSelected}
                  disabled={exporting}
                  className="mt-3 flex h-9 w-full items-center justify-center rounded-xl bg-slate-900 text-[11px] font-bold text-white hover:bg-black disabled:opacity-50"
                >
                  导出所选压缩包
                </button>
              </div>
            )}
          </div>
        </div>
      </div>

      <div className="min-h-[840px] flex-1 grid grid-cols-1 items-stretch gap-5 xl:grid-cols-[minmax(300px,0.82fr)_minmax(350px,0.98fr)_minmax(520px,1.32fr)] 2xl:grid-cols-[minmax(330px,0.8fr)_minmax(390px,1fr)_minmax(620px,1.45fr)]">
        <div className="min-h-0 flex flex-col gap-4">
          <div className="px-2 flex flex-wrap items-center justify-between gap-2 shrink-0">
            <h3 className="font-bold text-slate-700 flex items-center gap-2">
              <Rotate3d size={18} className="text-blue-500" />
              影像与模型融合视角
            </h3>
            <div className="flex flex-wrap items-center justify-end gap-1.5">
              <span className="text-[10px] font-mono text-slate-400">
                Layer: {displaySliceRange.start}-{displaySliceRange.end}/{project?.dicomCount ?? 0}
              </span>
              <div className="flex items-center gap-1 rounded-xl bg-slate-100 p-1">
                <span className="hidden items-center gap-1 px-1 text-[9px] font-bold text-slate-400 2xl:flex">
                  <Maximize2 size={11} />
                  自动拉伸
                </span>
                {(['x', 'y', 'z'] as AxisKey[]).map((axis) => (
                  <button
                    key={axis}
                    onClick={() => void applyModelStretchByAxis(axis)}
                    disabled={!stretchEnabled || stretchingAxis !== null}
                    className={`rounded-lg px-2 py-1 text-[9px] font-bold transition ${
                      stretchEnabled && stretchingAxis === null
                        ? 'bg-white text-blue-600 shadow-sm hover:text-blue-700'
                        : 'cursor-not-allowed text-slate-300'
                    }`}
                    title={stretchEnabled ? `按 ${axis.toUpperCase()} 方向自动等比例拉伸模型` : '仅当旋转 X/Y/Z 均为 90° 的整数倍时可用'}
                  >
                    {stretchingAxis === axis ? '...' : `${axis.toUpperCase()}拉伸`}
                  </button>
                ))}
              </div>
            </div>
          </div>

          {project ? (
            <div className="min-h-0 flex-1">
              <FusionThreeView
                project={project}
                volume={fusionVolume}
                modelPose={modelPose}
                moduleStyles={moduleStyles}
                detailLimit={selectedDisplay.limit}
                solidMode={displayLevel === 'solid'}
                dicomOpacity={selectedDicomOpacity}
                showBounds={showBounds}
                cutEnabled={cutEnabled}
                cutStart={displayStart}
                cutEnd={displayEnd}
              />
            </div>
          ) : (
            <div className="flex-1 rounded-3xl border border-slate-100 bg-white flex items-center justify-center text-sm text-slate-400">
              正在载入项目...
            </div>
          )}

          {fusionError && (
            <div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-xs font-bold text-amber-700 flex items-center gap-2">
              <AlertCircle size={16} />
              {fusionError}
            </div>
          )}

          <div className="rounded-2xl border border-slate-100 bg-white p-4 shadow-sm">
            <div className="mb-3 flex items-center justify-between">
              <p className="text-xs font-bold text-slate-700">DICOM 切片范围</p>
              <span className="text-[10px] font-mono text-blue-600">
                {displaySliceRange.start} - {displaySliceRange.end} / {project?.dicomCount ?? 0}
              </span>
            </div>
            <div className="py-1">
              <div className="relative h-10">
                <div className="absolute inset-x-0 top-1/2 h-2 -translate-y-1/2 rounded-full bg-slate-200" />
                <div
                  className="absolute top-1/2 h-2 -translate-y-1/2 rounded-full bg-blue-600"
                  style={{
                    left: `${rangeStartPercent}%`,
                    right: `${100 - rangeEndPercent}%`,
                  }}
                />
                <input
                  type="range"
                  aria-label="DICOM 切片范围起点"
                  min="0"
                  max={maxSlice}
                  value={safeSliceStart}
                  onChange={(event) => setSliceStart(Number(event.target.value))}
                  className="dicom-range-input"
                  style={{ zIndex: safeSliceStart >= safeSliceEnd ? 5 : 4 }}
                />
                <input
                  type="range"
                  aria-label="DICOM 切片范围终点"
                  min="0"
                  max={maxSlice}
                  value={safeSliceEnd}
                  onChange={(event) => setSliceEnd(Number(event.target.value))}
                  className="dicom-range-input"
                  style={{ zIndex: safeSliceStart >= safeSliceEnd ? 4 : 5 }}
                />
              </div>
              <div className="mt-1 grid grid-cols-3 text-[10px] font-bold text-slate-500">
                <span>起点 {getDicomDisplaySliceNumber(safeSliceStart, project?.dicomCount ?? 0)}</span>
                <span className="text-center text-blue-600">范围</span>
                <span className="text-right">终点 {getDicomDisplaySliceNumber(safeSliceEnd, project?.dicomCount ?? 0)}</span>
              </div>
            </div>
          </div>
        </div>

        <div className="min-h-0 flex flex-col gap-4 overflow-hidden">
          <div className="px-2 shrink-0">
            <h3 className="font-bold text-slate-700 flex items-center gap-2">
              <Settings2 size={18} className="text-emerald-500" />
              可视化工具栏
            </h3>
          </div>

          <div className="flex-1 bg-white rounded-3xl border border-slate-100 shadow-sm overflow-hidden flex flex-col p-4 gap-4">
            <div ref={visualToolbarScrollRef} className="flex-1 overflow-auto space-y-4 pr-1">
              <div>
                <p className="mb-2 text-[10px] font-bold uppercase tracking-widest text-slate-400">模型显示</p>
                <div className="grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
                  {displayOptions.map((option) => (
                    <button
                      key={option.id}
                      onClick={() => setDisplayLevel(option.id)}
                      className={`rounded-lg px-2 py-1.5 text-[10px] font-bold transition-all ${
                        displayLevel === option.id ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
                      }`}
                    >
                      {option.label}
                    </button>
                  ))}
                </div>
              </div>

              <div>
                <p className="mb-2 text-[10px] font-bold uppercase tracking-widest text-slate-400">融合显示</p>
                <div className="grid grid-cols-3 gap-1 rounded-xl bg-slate-100 p-1">
                  {dicomOpacityOptions.map((option) => (
                    <button
                      key={option.id}
                      onClick={() => setDicomOpacityLevel(option.id)}
                      className={`rounded-lg px-2 py-1.5 text-[10px] font-bold transition-all ${
                        dicomOpacityLevel === option.id ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
                      }`}
                    >
                      DICOM {option.label}
                    </button>
                  ))}
                </div>
                <label className="mt-2 flex items-center justify-between rounded-lg bg-slate-50 px-2 py-1.5 text-[10px] font-bold text-slate-500">
                  显示 DICOM/模型边界
                  <input
                    type="checkbox"
                    checked={showBounds}
                    onChange={(event) => setShowBounds(event.target.checked)}
                    className="accent-blue-600"
                  />
                </label>
              </div>

              <div>
                <div className="mb-2 flex items-center justify-between">
                  <p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">模型切分</p>
                  <label className="flex items-center gap-1 text-[10px] font-bold text-slate-500">
                    启用
                    <input
                      type="checkbox"
                      checked={cutEnabled}
                      onChange={(event) => setCutEnabled(event.target.checked)}
                      className="accent-orange-500"
                    />
                  </label>
                </div>
                <p className="rounded-lg bg-orange-50 px-2 py-2 text-[10px] font-bold leading-5 text-orange-700">
                  按 DICOM 切片范围 {displaySliceRange.start}-{displaySliceRange.end} 保留模型中间区域
                </p>
              </div>

              <div>
                <div className="mb-2 flex items-center justify-between">
                  <p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">模型位姿</p>
                  <div className="flex items-center gap-2">
                    <button onClick={saveCurrentPose} className="flex items-center gap-1 text-[10px] font-bold text-blue-600 hover:text-blue-700">
                      <Save size={12} />
                      保存
                    </button>
                    <button onClick={() => poseImportInputRef.current?.click()} className="flex items-center gap-1 text-[10px] font-bold text-emerald-600 hover:text-emerald-700">
                      <Upload size={12} />
                      导入
                    </button>
                    <input
                      ref={poseImportInputRef}
                      type="file"
                      accept="application/json,.json"
                      onChange={handleImportPoseFile}
                      className="hidden"
                    />
                  </div>
                </div>
                <select
                  value={selectedPoseId}
                  onChange={(event) => selectPose(event.target.value)}
                  className="mb-2 h-8 w-full rounded-lg border border-slate-200 bg-white px-2 text-[10px] font-bold text-slate-600 outline-none focus:border-blue-400"
                >
                  {selectedPoseId === 'custom' && <option value="custom">当前未保存位姿</option>}
                  {savedPoses.map((item) => (
                    <option key={item.id} value={item.id}>{item.name}</option>
                  ))}
                </select>
                {selectedPoseId !== 'default' && selectedPoseId !== 'custom' && (
                  <input
                    value={savedPoses.find((item) => item.id === selectedPoseId)?.name ?? ''}
                    onChange={(event) => renamePose(selectedPoseId, event.target.value)}
                    className="mb-2 h-8 w-full rounded-lg border border-slate-200 bg-white px-2 text-[10px] font-bold text-slate-600 outline-none focus:border-blue-400"
                    placeholder="位姿名称"
                  />
                )}
                {poseImportStatus && (
                  <p className="mb-2 rounded-lg bg-emerald-50 px-2 py-1.5 text-[10px] font-bold text-emerald-700">
                    {poseImportStatus}
                  </p>
                )}
                <div className="grid grid-cols-3 gap-2">
                  <button
                    onClick={resetRotationPose}
                    className="h-8 rounded-lg bg-blue-50 text-[10px] font-bold text-blue-600 hover:bg-blue-100"
                  >
                    重置旋转位姿
                  </button>
                  <button
                    onClick={resetTransformPose}
                    className="h-8 rounded-lg bg-blue-50 text-[10px] font-bold text-blue-600 hover:bg-blue-100"
                  >
                    重置平移缩放位姿
                  </button>
                  <button
                    onClick={resetModelFlipPose}
                    className="h-8 rounded-lg bg-blue-50 text-[10px] font-bold text-blue-600 hover:bg-blue-100"
                  >
                    重置镜像位姿
                  </button>
                </div>
                <div className="mt-3 grid grid-cols-3 gap-2">
                  {modelPoseFlipOptions.map((item) => {
                    const Icon = item.icon;
                    const enabled = modelPose[item.key];
                    return (
                      <button
                        key={item.key}
                        onClick={() => toggleModelFlip(item.key)}
                        className={`flex h-8 items-center justify-center gap-1.5 rounded-lg border text-[10px] font-bold transition ${
                          enabled
                            ? 'border-emerald-200 bg-emerald-600 text-white shadow-sm'
                            : 'border-slate-100 bg-white text-slate-500 shadow-sm hover:border-emerald-200 hover:bg-emerald-50 hover:text-emerald-700'
                        }`}
                        title={`以模型中心沿 ${item.axis} 轴镜像翻转`}
                      >
                        <Icon size={13} />
                        {item.label}
                      </button>
                    );
                  })}
                </div>
                <div className="mt-3 space-y-2">
                  {[
                    { key: 'rotateX' as const, label: '旋转 X', value: modelPose.rotateX },
                    { key: 'rotateY' as const, label: '旋转 Y', value: modelPose.rotateY },
                    { key: 'rotateZ' as const, label: '旋转 Z', value: modelPose.rotateZ },
                    { key: 'translateX' as const, label: '平移 X', value: modelPose.translateX },
                    { key: 'translateY' as const, label: '平移 Y', value: modelPose.translateY },
                    { key: 'translateZ' as const, label: '平移 Z', value: modelPose.translateZ },
                    { key: 'scale' as const, label: '缩放', value: modelPose.scale },
                  ].map((item) => (
                    <div key={item.key} className="grid grid-cols-[44px_28px_1fr_28px_72px] items-center gap-2 text-[10px] font-bold text-slate-500">
                      <span>{item.label}</span>
                      <button
                        onPointerDown={() => {
                          startPoseRepeat(item.key, -poseStepConfig[item.key].step);
                        }}
                        onPointerUp={stopPoseRepeat}
                        onPointerLeave={stopPoseRepeat}
                        onPointerCancel={stopPoseRepeat}
                        onClick={() => nudgeModelPose(item.key, -poseStepConfig[item.key].step)}
                        className="h-6 touch-none rounded-md border border-slate-100 bg-white text-[9px] font-bold text-slate-500 shadow-sm hover:bg-blue-50 hover:text-blue-600"
                        title={`单击移动最低刻度，长按连续移动。${poseStepConfig[item.key].minus}`}
                      >
                        -
                      </button>
                      <input
                        type="range"
                        min={poseStepConfig[item.key].min}
                        max={poseStepConfig[item.key].max}
                        step={poseStepConfig[item.key].step}
                        value={item.value}
                        onChange={(event) => updateModelPose({ [item.key]: Number(event.target.value) })}
                        className="accent-blue-600"
                      />
                      <button
                        onPointerDown={() => {
                          startPoseRepeat(item.key, poseStepConfig[item.key].step);
                        }}
                        onPointerUp={stopPoseRepeat}
                        onPointerLeave={stopPoseRepeat}
                        onPointerCancel={stopPoseRepeat}
                        onClick={() => nudgeModelPose(item.key, poseStepConfig[item.key].step)}
                        className="h-6 touch-none rounded-md border border-slate-100 bg-white text-[9px] font-bold text-slate-500 shadow-sm hover:bg-blue-50 hover:text-blue-600"
                        title={`单击移动最低刻度，长按连续移动。${poseStepConfig[item.key].plus}`}
                      >
                        +
                      </button>
                      <input
                        type="number"
                        min={poseStepConfig[item.key].min}
                        max={poseStepConfig[item.key].max}
                        step={poseStepConfig[item.key].step}
                        value={poseValueDrafts[item.key]}
                        onFocus={() => setFocusedPoseInput(item.key)}
                        onChange={(event) => handlePoseInputChange(item.key, event.target.value)}
                        onBlur={() => commitPoseInputValue(item.key)}
                        onKeyDown={(event) => {
                          if (event.key === 'Enter') {
                            event.currentTarget.blur();
                          }
                        }}
                        className="h-7 min-w-0 rounded-md border border-slate-200 bg-white px-1.5 text-right font-mono text-[10px] font-bold text-slate-600 outline-none focus:border-blue-400 focus:bg-blue-50/40"
                      />
                      {poseStepConfig[item.key].quick && (
                        <div className="col-start-2 col-span-3 grid grid-cols-2 gap-1">
                          <button
                            onClick={() => nudgeModelPose(item.key, -(poseStepConfig[item.key].quick ?? 0))}
                            className="h-6 rounded-md bg-slate-50 text-[9px] font-bold text-slate-500 hover:bg-blue-50 hover:text-blue-600"
                          >
                            -90°
                          </button>
                          <button
                            onClick={() => nudgeModelPose(item.key, poseStepConfig[item.key].quick ?? 0)}
                            className="h-6 rounded-md bg-slate-50 text-[9px] font-bold text-slate-500 hover:bg-blue-50 hover:text-blue-600"
                          >
                            +90°
                          </button>
                        </div>
                      )}
                    </div>
                  ))}
                </div>
              </div>

              <div>
                <div className="mb-2 flex items-center justify-between">
                  <p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">构件层级</p>
                  <div className="flex items-center gap-2">
                    <span className="text-[10px] font-mono text-slate-400">{project?.stlFiles?.length ?? 0}</span>
                    <button
                      onClick={toggleAllModules}
                      disabled={!project?.stlFiles?.length}
                      className={`rounded p-1 transition ${allModulesVisible ? 'text-blue-500' : 'text-slate-300'} hover:bg-white disabled:opacity-40`}
                      title={allModulesVisible ? '隐藏所有构件' : '显示所有构件'}
                    >
                      <Eye size={14} />
                    </button>
                  </div>
                </div>
                <div className="space-y-2">
                  {(project?.stlFiles ?? []).map((fileName, index) => {
                    const style = moduleStyles[fileName] ?? {
                      visible: true,
                      color: moduleColors[index % moduleColors.length],
                      opacity: 0.72,
                      partId: index + 1,
                    };
                    return (
                      <div key={fileName} className={`rounded-xl bg-slate-50 p-2 ${!style.visible ? 'opacity-50' : ''}`}>
                        <div className="mb-2 flex items-center gap-2">
                          <input
                            type="color"
                            value={style.color}
                            onChange={(event) => updateModuleStyle(fileName, { color: event.target.value })}
                            className="h-7 w-7 shrink-0 rounded border border-white bg-white p-0.5"
                            title="模型颜色"
                          />
                          <div className="min-w-0 flex-1">
                            <p className="truncate text-[10px] font-bold text-slate-700">{fileName.replace(/\.stl$/i, '')}</p>
                            <label className="mt-1 flex items-center gap-1 text-[9px] font-bold text-slate-400">
                              ID
                              <input
                                type="number"
                                min="1"
                                max="255"
                                value={style.partId}
                                onChange={(event) => updateModulePartId(fileName, Number(event.target.value))}
                                className="h-5 w-12 rounded border border-slate-200 bg-white px-1 font-mono text-slate-600"
                              />
                            </label>
                          </div>
                          <button
                            onClick={() => updateModuleStyle(fileName, { visible: !style.visible })}
                            className={`rounded p-1 ${style.visible ? 'text-blue-500' : 'text-slate-300'} hover:bg-white`}
                            title={style.visible ? '隐藏构件' : '显示构件'}
                          >
                            <Eye size={14} />
                          </button>
                        </div>
                        <div className="flex items-center gap-2">
                          <span className="text-[9px] text-slate-400">透明度</span>
                          <input
                            type="range"
                            min="0.1"
                            max="1"
                            step="0.05"
                            value={style.opacity}
                            onChange={(event) => updateModuleStyle(fileName, { opacity: Number(event.target.value) })}
                            className="min-w-0 flex-1 accent-blue-600"
                          />
                          <span className="w-7 text-right text-[9px] text-slate-400">{Math.round(style.opacity * 100)}%</span>
                        </div>
                      </div>
                    );
                  })}
                </div>
              </div>
            </div>
          </div>
        </div>

        <div className="min-h-0 flex flex-col gap-4 overflow-hidden">
          <div className="min-h-0 flex-1">
            <VoxelizationMappingView
              project={project}
              moduleStyles={moduleStyles}
              modelPose={modelPose}
              detailLimit={selectedDisplay.limit}
              slice={safeMappingSlice}
              totalSlices={project?.dicomCount ?? 0}
              onSliceChange={setMappingSlice}
              displayMode={mappingDisplayMode}
              rotation={mappingRotation}
              variant="library"
              overlayPlacement="bottom"
              toolbar={(
                <>
                  <div className="flex rounded-xl bg-white/10 p-1">
                    {mappingDisplayModes.map((mode) => (
                      <button
                        key={mode.id}
                        onClick={() => setMappingDisplayMode(mode.id)}
                        className={`rounded-lg px-2 py-1 text-[10px] font-bold transition ${
                          mappingDisplayMode === mode.id ? 'bg-white text-cyan-700 shadow-sm' : 'text-white/55 hover:text-white'
                        }`}
                      >
                        {mode.label}
                      </button>
                    ))}
                  </div>
                  <button
                    onClick={() => setMappingRotation((value) => (value + 270) % 360)}
                    className="flex h-8 w-8 items-center justify-center rounded-xl border border-white/10 bg-black/60 text-white/65 hover:border-cyan-300/30 hover:text-cyan-100"
                    title="左转 90°"
                  >
                    <RotateCcw size={14} />
                  </button>
                  <button
                    onClick={() => setMappingRotation((value) => (value + 90) % 360)}
                    className="flex h-8 w-8 items-center justify-center rounded-xl border border-white/10 bg-black/60 text-white/65 hover:border-cyan-300/30 hover:text-cyan-100"
                    title="右转 90°"
                  >
                    <RotateCw size={14} />
                  </button>
                </>
              )}
            />
          </div>
        </div>
      </div>
    </div>
  );
}
