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; type ModelPoseFlipKey = Extract; type PoseDraftValues = Record; type AxisKey = 'x' | 'y' | 'z'; interface AxisVector2D { dx: number; dy: number; opacity: number; } type AxisProjection = Record; type WorkspaceLeaveGuard = () => Promise; 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 = { 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> = { 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>(); const dicomFusionVolumeCache = new Map>(); const modelPreviewCache = new Map>(); function rememberRequest(cache: Map>, key: string, loader: () => Promise) { 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; }), ); } 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 { 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(); 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) { return Object.keys(styles) .sort((left, right) => left.localeCompare(right, 'zh-Hans-CN')) .reduce>((accumulator, key) => { accumulator[key] = styles[key]; return accumulator; }, {}); } function createWorkspaceSnapshot(input: { modelPose: ModelPose; segmentationExportScope: SegmentationExportScope; moduleStyles: Record; 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 = { 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 (
); } 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; 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(null); const modelPoseRef = useRef(modelPose); const [status, setStatus] = useState('准备融合 DICOM 与 STL'); const [loadProgress, setLoadProgress] = useState(0); const [webglError, setWebglError] = useState(null); const [axisProjection, setAxisProjection] = useState(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 (
{webglError && (

三维融合视图无法启动

{webglError}

)}
{status}
DICOM {volume && volumeDisplayRange ? `${volumeDisplayRange.start}-${volumeDisplayRange.end}/${volume.total}` : '加载中'} · STL {project.modelCount ?? 0}
{loadProgress < 100 && (
正在融合三维影像与模型 {loadProgress}%
)} {!volume && (
正在载入 DICOM 三维体...
)}
); } function CutSectionPreview({ project, volume, modelPose, moduleStyles, detailLimit, cutEnabled, cutStart, cutEnd, }: { project: Project | null; volume: DicomFusionVolume | null; modelPose: ModelPose; moduleStyles: Record; detailLimit: number; cutEnabled: boolean; cutStart: number; cutEnd: number; }) { const containerRef = useRef(null); const modelPoseRef = useRef(modelPose); const [webglError, setWebglError] = useState(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 (
{webglError && (
{webglError}
)} {(!project || !volume) && (
正在载入 STL 切面...
)}
); } 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) { 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, 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>(); 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(); 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(); 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, metricFiles: string[], metricPreviews: Record, moduleStyles: Record, 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; 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(null); const overlayCanvasRef = useRef(null); const mappingViewportRef = useRef(null); const [dicomPreview, setDicomPreview] = useState(null); const [modelPreviews, setModelPreviews] = useState>({}); const [metricPreviews, setMetricPreviews] = useState>({}); const [metricPreviewsLoaded, setMetricPreviewsLoaded] = useState(false); const [dicomStatus, setDicomStatus] = useState('等待 DICOM 切片'); const [overlayStatus, setOverlayStatus] = useState('等待 STL 映射'); const [overlayStats, setOverlayStats] = useState({ 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 = {}; 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 = {}; 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) => { 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) => { 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) => { 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 (
构件层级加载中 {overlayLoadState.loaded}/{overlayLoadState.total}
{overlayLoadState.phase || overlayStatus} {percent}%
); }; const renderOverlaySummary = (placement: 'bottom' | 'side') => (
Overlay Label Map {overlayStats.activeModules}/{visibleModuleCount} 构件 · {overlayStats.segmentCount} 边 · {overlayStats.filledPixels} px
{overlayStats.modules.length ? (
{overlayStats.modules.map((item) => (
{item.name} ID {item.partId} {item.segmentCount} 边 {item.filledPixels} px
))}
) : (
当前切片暂无可见构件
)}
); if (isLibraryVariant) { return (
逆向分割映射视图
{toolbar}
{dicomPreview ? (
) : (
{dicomStatus}
)} {renderOverlayLoadProgress('dark')}

DICOM 切片位置

{displaySliceNumber} / {Math.max(totalSlices, 1)}

{activeOverlayPlacement === 'bottom' && renderOverlaySummary('bottom')}
); } return (
Base DICOM Overlay Label Map Z {displaySliceNumber}/{Math.max(totalSlices, 1)}
{dicomPreview ? (
) : (
{dicomStatus}
)} {renderOverlayLoadProgress('dark')}
Overlay Label Map {overlayStats.activeModules}/{visibleModuleCount} 构件 · {overlayStats.segmentCount} 边 · {overlayStats.filledPixels} px
{overlayStats.modules.length ? (
{overlayStats.modules.map((item) => (
{item.name} ID {item.partId} {item.segmentCount} 边 {item.filledPixels} px
))}
) : (
当前切片暂无可见构件
)}
); } 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(defaultModelPose); const [poseValueDrafts, setPoseValueDrafts] = useState(() => formatPoseDraftValues(defaultModelPose)); const [focusedPoseInput, setFocusedPoseInput] = useState(null); const [poseImportStatus, setPoseImportStatus] = useState(''); const [displayLevel, setDisplayLevel] = useState('standard'); const [dicomOpacityLevel, setDicomOpacityLevel] = useState('low'); const [mappingDisplayMode, setMappingDisplayMode] = useState('soft'); const [mappingRotation, setMappingRotation] = useState(0); const [showBounds, setShowBounds] = useState(true); const [cutEnabled, setCutEnabled] = useState(false); const [moduleStyles, setModuleStyles] = useState>({}); const [savedPoses, setSavedPoses] = useState(defaultSavedPoses); const [selectedPoseId, setSelectedPoseId] = useState('default'); const [showExportMenu, setShowExportMenu] = useState(false); const [exportSelection, setExportSelection] = useState>({ dicom: false, segmentation: true, pose: true, stl: false, }); const [segmentationExportScope, setSegmentationExportScope] = useState('visible'); const [segmentationExportMode, setSegmentationExportMode] = useState('combined'); const [project, setProject] = useState(null); const [fusionVolume, setFusionVolume] = useState(null); const [fusionError, setFusionError] = useState(''); const [saveStatus, setSaveStatus] = useState(''); const [exporting, setExporting] = useState(false); const [stretchingAxis, setStretchingAxis] = useState(null); const modelBoundsCacheRef = useRef(new Map()); const [workspaceLoadState, setWorkspaceLoadState] = useState({ 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(null); const visualToolbarScrollRef = useRef(null); const saveToastTimerRef = useRef(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 => ({ 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) => { 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 = {}; (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, 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); }; 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); } 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) => { 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) => { 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> = [ 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 (

项目已锁定

请先在项目库点击“解锁项目”,再进入逆向工作区继续修改位姿和分割结果。

项目:{project.name}

锁定时间:{project.lockedAt ? new Date(project.lockedAt).toLocaleString('zh-CN') : '未记录'}

锁定结果:{project.lockedPoseSnapshotPath ?? '项目数据/锁定结果'}

); } if (!workspaceLoadState.ready) { return (

Reverse Workspace

正在载入完整逆向工作区

DICOM 三维体、STL 构件预览和二维映射网格准备完成后再显示工作区。

{Math.max(0, Math.min(100, workspaceProgress))}%
当前阶段 {workspaceLoadState.phase}
加载进度 {workspaceLoadState.loaded} / {workspaceLoadState.total}
加载速度 {workspaceLoadSpeed.toFixed(1)} 项/秒
{workspaceLoadState.error && (
{workspaceLoadState.error}
)}
); } return (
{saveStatus && ( <>
{saveStatus}
)}
{project && (
当前项目:{project.name} DICOM {project.dicomCount} STL {project.modelCount ?? 0}
)} {!project &&

配准 DICOM 影像与三维模型,生成像素映射关系

}
{showExportMenu && (

导出内容

{exportOptions.map((option) => ( ))}
{exportSelection.segmentation && (

分割类别范围

附带 labels.json
{segmentationScopeOptions.map((option) => ( ))}

分割导出方式

{segmentationExportModeOptions.map((option) => ( ))}
)}
)}

影像与模型融合视角

Layer: {displaySliceRange.start}-{displaySliceRange.end}/{project?.dicomCount ?? 0}
自动拉伸 {(['x', 'y', 'z'] as AxisKey[]).map((axis) => ( ))}
{project ? (
) : (
正在载入项目...
)} {fusionError && (
{fusionError}
)}

DICOM 切片范围

{displaySliceRange.start} - {displaySliceRange.end} / {project?.dicomCount ?? 0}
setSliceStart(Number(event.target.value))} className="dicom-range-input" style={{ zIndex: safeSliceStart >= safeSliceEnd ? 5 : 4 }} /> setSliceEnd(Number(event.target.value))} className="dicom-range-input" style={{ zIndex: safeSliceStart >= safeSliceEnd ? 4 : 5 }} />
起点 {getDicomDisplaySliceNumber(safeSliceStart, project?.dicomCount ?? 0)} 范围 终点 {getDicomDisplaySliceNumber(safeSliceEnd, project?.dicomCount ?? 0)}

可视化工具栏

模型显示

{displayOptions.map((option) => ( ))}

融合显示

{dicomOpacityOptions.map((option) => ( ))}

模型切分

按 DICOM 切片范围 {displaySliceRange.start}-{displaySliceRange.end} 保留模型中间区域

模型位姿

{selectedPoseId !== 'default' && selectedPoseId !== 'custom' && ( 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 && (

{poseImportStatus}

)}
{modelPoseFlipOptions.map((item) => { const Icon = item.icon; const enabled = modelPose[item.key]; return ( ); })}
{[ { 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) => (
{item.label} updateModelPose({ [item.key]: Number(event.target.value) })} className="accent-blue-600" /> 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 && (
)}
))}

构件层级

{project?.stlFiles?.length ?? 0}
{(project?.stlFiles ?? []).map((fileName, index) => { const style = moduleStyles[fileName] ?? { visible: true, color: moduleColors[index % moduleColors.length], opacity: 0.72, partId: index + 1, }; return (
updateModuleStyle(fileName, { color: event.target.value })} className="h-7 w-7 shrink-0 rounded border border-white bg-white p-0.5" title="模型颜色" />

{fileName.replace(/\.stl$/i, '')}

透明度 updateModuleStyle(fileName, { opacity: Number(event.target.value) })} className="min-w-0 flex-1 accent-blue-600" /> {Math.round(style.opacity * 100)}%
); })}
{mappingDisplayModes.map((mode) => ( ))}
)} />
); }