4319 lines
164 KiB
TypeScript
4319 lines
164 KiB
TypeScript
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||
import {
|
||
Settings2,
|
||
Download,
|
||
RotateCcw,
|
||
RotateCw,
|
||
Rotate3d,
|
||
AlertCircle,
|
||
ChevronDown,
|
||
ChevronUp,
|
||
Eye,
|
||
Lock,
|
||
Maximize2,
|
||
RefreshCcw,
|
||
Save,
|
||
Upload,
|
||
FlipHorizontal2,
|
||
FlipVertical2,
|
||
Move3d,
|
||
} from 'lucide-react';
|
||
import * as THREE from 'three';
|
||
import { DicomFusionVolume, DicomPreview, ModelPose, ModuleStyle, Project, SavedModelPose } from '../types';
|
||
import { api, downloadProjectExportBundle, ProjectExportTarget, SegmentationExportMode, SegmentationExportScope } from '../lib/api';
|
||
|
||
export interface ModelPreviewPayload {
|
||
fileName: string;
|
||
triangleCount: number;
|
||
sampledTriangles: number;
|
||
vertices: number[];
|
||
bounds?: {
|
||
min: { x: number; y: number; z: number };
|
||
max: { x: number; y: number; z: number };
|
||
};
|
||
}
|
||
|
||
export type DisplayLevel = 'standard' | 'fine' | 'ultra' | 'solid';
|
||
export type DicomOpacityLevel = 'low' | 'medium' | 'high';
|
||
export type MappingDisplayMode = DicomPreview['mode'];
|
||
type ModelPoseKey = Exclude<keyof ModelPose, 'flipX' | 'flipY' | 'flipZ'>;
|
||
type ModelPoseFlipKey = Extract<keyof ModelPose, 'flipX' | 'flipY' | 'flipZ'>;
|
||
type PoseDraftValues = Record<ModelPoseKey, string>;
|
||
type AxisKey = 'x' | 'y' | 'z';
|
||
|
||
interface AxisVector2D {
|
||
dx: number;
|
||
dy: number;
|
||
opacity: number;
|
||
}
|
||
|
||
type AxisProjection = Record<AxisKey, AxisVector2D>;
|
||
type WorkspaceLeaveGuard = () => Promise<boolean>;
|
||
|
||
interface WorkspaceLoadState {
|
||
ready: boolean;
|
||
phase: string;
|
||
loaded: number;
|
||
total: number;
|
||
startedAt: number;
|
||
error: string;
|
||
}
|
||
|
||
const modelPoseKeys: ModelPoseKey[] = ['rotateX', 'rotateY', 'rotateZ', 'translateX', 'translateY', 'translateZ', 'scale'];
|
||
const modelPoseFlipOptions: Array<{ key: ModelPoseFlipKey; label: string; axis: string; icon: typeof FlipHorizontal2 }> = [
|
||
{ key: 'flipX', label: '镜像 X', axis: 'X', icon: FlipHorizontal2 },
|
||
{ key: 'flipY', label: '镜像 Y', axis: 'Y', icon: FlipVertical2 },
|
||
{ key: 'flipZ', label: '镜像 Z', axis: 'Z', icon: Move3d },
|
||
];
|
||
|
||
export const displayOptions: Array<{ id: DisplayLevel; label: string; limit: number }> = [
|
||
{ id: 'standard', label: '标准', limit: 16000 },
|
||
{ id: 'fine', label: '精细', limit: 36000 },
|
||
{ id: 'ultra', label: '超精细', limit: 72000 },
|
||
{ id: 'solid', label: '实体', limit: 800000 },
|
||
];
|
||
export const dicomOpacityOptions: Array<{ id: DicomOpacityLevel; label: string; sliceOpacity: number; volumeOpacity: number; boxOpacity: number }> = [
|
||
{ id: 'low', label: '低', sliceOpacity: 0.82, volumeOpacity: 0.12, boxOpacity: 0.32 },
|
||
{ id: 'medium', label: '中', sliceOpacity: 0.92, volumeOpacity: 0.2, boxOpacity: 0.42 },
|
||
{ id: 'high', label: '高', sliceOpacity: 1, volumeOpacity: 0.32, boxOpacity: 0.54 },
|
||
];
|
||
const mappingDisplayModes: Array<{ id: MappingDisplayMode; label: string }> = [
|
||
{ id: 'default', label: '默认' },
|
||
{ id: 'bone', label: '骨窗' },
|
||
{ id: 'soft', label: '软组织' },
|
||
{ id: 'contrast', label: '高对比' },
|
||
];
|
||
const poseStepConfig: Record<ModelPoseKey, { min: number; max: number; step: number; minus: string; plus: string; quick?: number }> = {
|
||
rotateX: { min: -180, max: 180, step: 1, minus: '-90°', plus: '+90°', quick: 90 },
|
||
rotateY: { min: -180, max: 180, step: 1, minus: '-90°', plus: '+90°', quick: 90 },
|
||
rotateZ: { min: -180, max: 180, step: 1, minus: '-90°', plus: '+90°', quick: 90 },
|
||
translateX: { min: -2, max: 2, step: 0.001, minus: '-0.001', plus: '+0.001' },
|
||
translateY: { min: -2, max: 2, step: 0.001, minus: '-0.001', plus: '+0.001' },
|
||
translateZ: { min: -2, max: 2, step: 0.001, minus: '-0.001', plus: '+0.001' },
|
||
scale: { min: 0.5, max: 3, step: 0.001, minus: '-0.001', plus: '+0.001' },
|
||
};
|
||
const poseRepeatDelayMs = 240;
|
||
const poseRepeatIntervalMs = 55;
|
||
const poseRepeatDeltaMultiplier: Partial<Record<ModelPoseKey, number>> = {
|
||
translateX: 5,
|
||
translateY: 5,
|
||
translateZ: 5,
|
||
scale: 5,
|
||
};
|
||
|
||
const defaultModelPose: ModelPose = {
|
||
rotateX: 0,
|
||
rotateY: 0,
|
||
rotateZ: 0,
|
||
translateX: 0,
|
||
translateY: 0,
|
||
translateZ: 0,
|
||
scale: 1,
|
||
flipX: false,
|
||
flipY: false,
|
||
flipZ: false,
|
||
};
|
||
|
||
const defaultSavedPoses: SavedModelPose[] = [
|
||
{ id: 'default', name: '默认', pose: defaultModelPose },
|
||
{ id: 'top', name: '俯视', pose: { ...defaultModelPose, rotateX: 0, rotateY: 0, rotateZ: 0 } },
|
||
{ id: 'side', name: '侧视', pose: { ...defaultModelPose, rotateX: 0, rotateY: 90, rotateZ: 0 } },
|
||
];
|
||
const exportOptions: Array<{ id: ProjectExportTarget; label: string; description: string }> = [
|
||
{ id: 'dicom', label: 'DICOM 原始影像', description: '主影像 NII.GZ' },
|
||
{ id: 'stl', label: 'STL 原始模型', description: '原始三维构件' },
|
||
{ id: 'pose', label: '位姿数据', description: 'JSON 侧车' },
|
||
{ id: 'segmentation', label: '分割影像', description: '同维度 Label Map' },
|
||
];
|
||
const segmentationScopeOptions: Array<{ id: SegmentationExportScope; label: string; description: string }> = [
|
||
{ id: 'visible', label: '可见类别', description: '仅导出当前显示构件' },
|
||
{ id: 'all', label: '所有类别', description: '包含隐藏构件' },
|
||
];
|
||
const segmentationExportModeOptions: Array<{ id: SegmentationExportMode; label: string; description: string }> = [
|
||
{ id: 'combined', label: '构件整体导出', description: '生成一个多标签 Label Map' },
|
||
{ id: 'separate', label: '构件分别导出', description: '全部构件集中到同一目录' },
|
||
];
|
||
const moduleColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316', '#64748b', '#ec4899'];
|
||
const fusionBaseExtent = 4.6;
|
||
const axisInsetLength = 17;
|
||
const defaultAxisProjection: AxisProjection = {
|
||
x: { dx: axisInsetLength, dy: 0, opacity: 0.95 },
|
||
y: { dx: -10, dy: 10, opacity: 0.82 },
|
||
z: { dx: 0, dy: -axisInsetLength, opacity: 0.95 },
|
||
};
|
||
const dicomPreviewCache = new Map<string, Promise<DicomPreview>>();
|
||
const dicomFusionVolumeCache = new Map<string, Promise<DicomFusionVolume>>();
|
||
const modelPreviewCache = new Map<string, Promise<ModelPreviewPayload>>();
|
||
|
||
function rememberRequest<T>(cache: Map<string, Promise<T>>, key: string, loader: () => Promise<T>) {
|
||
const cached = cache.get(key);
|
||
if (cached) {
|
||
return cached;
|
||
}
|
||
|
||
const request = loader().catch((error) => {
|
||
cache.delete(key);
|
||
throw error;
|
||
});
|
||
cache.set(key, request);
|
||
return request;
|
||
}
|
||
|
||
export function getCachedDicomPreview(
|
||
projectId: string,
|
||
slice: number,
|
||
plane: DicomPreview['plane'] = 'axial',
|
||
mode: DicomPreview['mode'] = 'default',
|
||
) {
|
||
return rememberRequest(
|
||
dicomPreviewCache,
|
||
`${projectId}:${plane}:${mode}:${slice}`,
|
||
() => api.getDicomPreview(projectId, slice, plane, mode),
|
||
);
|
||
}
|
||
|
||
export function getCachedDicomFusionVolume(
|
||
projectId: string,
|
||
start: number,
|
||
end: number,
|
||
mode: DicomPreview['mode'] = 'soft',
|
||
) {
|
||
const safeStart = Math.min(start, end);
|
||
const safeEnd = Math.max(start, end);
|
||
return rememberRequest(
|
||
dicomFusionVolumeCache,
|
||
`${projectId}:${mode}:${safeStart}:${safeEnd}`,
|
||
() => api.getDicomFusionVolume(projectId, safeStart, safeEnd, mode),
|
||
);
|
||
}
|
||
|
||
export function getCachedModelPreview(projectId: string, fileName: string, limit: number) {
|
||
const safeLimit = Math.max(1, Math.round(limit));
|
||
return rememberRequest(
|
||
modelPreviewCache,
|
||
`${projectId}:${fileName}:${safeLimit}`,
|
||
() => fetch(`/api/projects/${projectId}/models/${encodeURIComponent(fileName)}/preview?limit=${safeLimit}`)
|
||
.then((response) => {
|
||
if (!response.ok) {
|
||
throw new Error('模型预览数据加载失败');
|
||
}
|
||
return response.json() as Promise<ModelPreviewPayload>;
|
||
}),
|
||
);
|
||
}
|
||
|
||
export function clearCachedProjectAssets(projectId: string) {
|
||
[dicomPreviewCache, dicomFusionVolumeCache, modelPreviewCache].forEach((cache) => {
|
||
[...cache.keys()].forEach((key) => {
|
||
if (key.startsWith(`${projectId}:`)) {
|
||
cache.delete(key);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function clamp(value: number, min: number, max: number) {
|
||
return Math.max(min, Math.min(max, value));
|
||
}
|
||
|
||
function getDicomDisplaySliceNumber(sliceIndex: number, totalSlices: number) {
|
||
const total = Math.max(Math.round(totalSlices), 0);
|
||
if (!total) {
|
||
return 0;
|
||
}
|
||
return total - clamp(Math.round(sliceIndex), 0, total - 1);
|
||
}
|
||
|
||
function getDicomDisplayRange(startIndex: number, endIndex: number, totalSlices: number) {
|
||
const first = getDicomDisplaySliceNumber(startIndex, totalSlices);
|
||
const second = getDicomDisplaySliceNumber(endIndex, totalSlices);
|
||
return {
|
||
start: Math.min(first, second),
|
||
end: Math.max(first, second),
|
||
};
|
||
}
|
||
|
||
function getStepPrecision(step: number) {
|
||
if (step >= 1) {
|
||
return 0;
|
||
}
|
||
|
||
const text = step.toString();
|
||
if (text.includes('e-')) {
|
||
return Number(text.split('e-')[1] ?? 2);
|
||
}
|
||
|
||
return text.split('.')[1]?.length ?? 0;
|
||
}
|
||
|
||
function formatPoseValue(key: ModelPoseKey, value: number) {
|
||
return Number(value).toFixed(getStepPrecision(poseStepConfig[key].step));
|
||
}
|
||
|
||
function formatPoseDraftValues(pose: ModelPose): PoseDraftValues {
|
||
return modelPoseKeys.reduce((accumulator, key) => ({
|
||
...accumulator,
|
||
[key]: formatPoseValue(key, pose[key]),
|
||
}), {} as PoseDraftValues);
|
||
}
|
||
|
||
function isNinetyDegreeMultiple(value: number) {
|
||
const normalized = ((value % 90) + 90) % 90;
|
||
return Math.min(normalized, 90 - normalized) < 1e-6;
|
||
}
|
||
|
||
function isOrthogonalModelPose(pose: ModelPose) {
|
||
return isNinetyDegreeMultiple(pose.rotateX)
|
||
&& isNinetyDegreeMultiple(pose.rotateY)
|
||
&& isNinetyDegreeMultiple(pose.rotateZ);
|
||
}
|
||
|
||
function getRotatedModelSize(bounds: { min: THREE.Vector3; max: THREE.Vector3 }, pose: ModelPose) {
|
||
const center = new THREE.Vector3().addVectors(bounds.min, bounds.max).multiplyScalar(0.5);
|
||
const rotationMatrix = new THREE.Matrix4().makeRotationFromEuler(new THREE.Euler(
|
||
THREE.MathUtils.degToRad(pose.rotateX),
|
||
THREE.MathUtils.degToRad(pose.rotateY),
|
||
THREE.MathUtils.degToRad(pose.rotateZ),
|
||
));
|
||
const rotatedBox = new THREE.Box3();
|
||
[bounds.min.x, bounds.max.x].forEach((x) => {
|
||
[bounds.min.y, bounds.max.y].forEach((y) => {
|
||
[bounds.min.z, bounds.max.z].forEach((z) => {
|
||
const point = new THREE.Vector3(x, y, z).sub(center).applyMatrix4(rotationMatrix);
|
||
rotatedBox.expandByPoint(point);
|
||
});
|
||
});
|
||
});
|
||
return rotatedBox.getSize(new THREE.Vector3());
|
||
}
|
||
|
||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||
}
|
||
|
||
function normalizePoseValue(input: unknown, fallback: ModelPose = defaultModelPose): ModelPose | null {
|
||
if (!isRecord(input)) {
|
||
return null;
|
||
}
|
||
|
||
let hasPoseValue = false;
|
||
const normalized = { ...fallback };
|
||
modelPoseKeys.forEach((key) => {
|
||
const rawValue = input[key];
|
||
const numericValue = typeof rawValue === 'number' ? rawValue : Number(rawValue);
|
||
if (!Number.isFinite(numericValue)) {
|
||
return;
|
||
}
|
||
|
||
const limit = poseStepConfig[key];
|
||
normalized[key] = clamp(numericValue, limit.min, limit.max);
|
||
hasPoseValue = true;
|
||
});
|
||
modelPoseFlipOptions.forEach(({ key }) => {
|
||
const rawValue = input[key];
|
||
if (typeof rawValue !== 'boolean') {
|
||
return;
|
||
}
|
||
normalized[key] = rawValue;
|
||
hasPoseValue = true;
|
||
});
|
||
|
||
return hasPoseValue ? normalized : null;
|
||
}
|
||
|
||
function normalizeImportedModelPoses(input: unknown): SavedModelPose[] | null {
|
||
if (!Array.isArray(input)) {
|
||
return null;
|
||
}
|
||
|
||
const normalized = input
|
||
.map((item, index) => {
|
||
if (!isRecord(item)) {
|
||
return null;
|
||
}
|
||
|
||
const pose = normalizePoseValue(item.pose);
|
||
if (!pose) {
|
||
return null;
|
||
}
|
||
|
||
const rawId = typeof item.id === 'string' && item.id.trim()
|
||
? item.id.trim()
|
||
: `imported-pose-${Date.now()}-${index}`;
|
||
const rawName = typeof item.name === 'string' && item.name.trim()
|
||
? item.name.trim()
|
||
: `导入位姿${index + 1}`;
|
||
|
||
return {
|
||
id: rawId.slice(0, 80),
|
||
name: rawName.slice(0, 80),
|
||
pose,
|
||
};
|
||
})
|
||
.filter((item): item is SavedModelPose => Boolean(item));
|
||
|
||
if (!normalized.length) {
|
||
return null;
|
||
}
|
||
|
||
const deduped = new Map<string, SavedModelPose>();
|
||
normalized.forEach((item) => {
|
||
deduped.set(item.id, item);
|
||
});
|
||
|
||
return [...deduped.values()];
|
||
}
|
||
|
||
function mergeImportedModelPoses(imported: SavedModelPose[]) {
|
||
const importedById = new Map(imported.map((pose) => [pose.id, pose]));
|
||
const defaults = defaultSavedPoses.map((pose) => importedById.get(pose.id) ?? pose);
|
||
const custom = imported.filter((pose) => !defaultSavedPoses.some((item) => item.id === pose.id));
|
||
|
||
return [...defaults, ...custom];
|
||
}
|
||
|
||
function poseValuesMatch(left: ModelPose, right: ModelPose) {
|
||
return modelPoseKeys.every((key) => Math.abs(left[key] - right[key]) < 1e-6)
|
||
&& modelPoseFlipOptions.every(({ key }) => left[key] === right[key]);
|
||
}
|
||
|
||
function stableModuleStyles(styles: Record<string, ModuleStyle>) {
|
||
return Object.keys(styles)
|
||
.sort((left, right) => left.localeCompare(right, 'zh-Hans-CN'))
|
||
.reduce<Record<string, ModuleStyle>>((accumulator, key) => {
|
||
accumulator[key] = styles[key];
|
||
return accumulator;
|
||
}, {});
|
||
}
|
||
|
||
function createWorkspaceSnapshot(input: {
|
||
modelPose: ModelPose;
|
||
segmentationExportScope: SegmentationExportScope;
|
||
moduleStyles: Record<string, ModuleStyle>;
|
||
sliceStart: number;
|
||
sliceEnd: number;
|
||
mappingSlice: number;
|
||
displayLevel: DisplayLevel;
|
||
dicomOpacityLevel: DicomOpacityLevel;
|
||
showBounds: boolean;
|
||
cutEnabled: boolean;
|
||
}) {
|
||
return JSON.stringify({
|
||
modelPose: input.modelPose,
|
||
segmentationExportScope: input.segmentationExportScope,
|
||
moduleStyles: stableModuleStyles(input.moduleStyles),
|
||
sliceStart: input.sliceStart,
|
||
sliceEnd: input.sliceEnd,
|
||
mappingSlice: input.mappingSlice,
|
||
displayLevel: input.displayLevel,
|
||
dicomOpacityLevel: input.dicomOpacityLevel,
|
||
showBounds: input.showBounds,
|
||
cutEnabled: input.cutEnabled,
|
||
});
|
||
}
|
||
|
||
function parseImportedPosePayload(payload: unknown) {
|
||
const record = isRecord(payload) ? payload : {};
|
||
const importedModelPoses = normalizeImportedModelPoses(record.modelPoses);
|
||
const activePose = normalizePoseValue(record.activePose)
|
||
?? normalizePoseValue(record.pose)
|
||
?? normalizePoseValue(payload)
|
||
?? importedModelPoses?.[0]?.pose
|
||
?? null;
|
||
|
||
return { activePose, importedModelPoses };
|
||
}
|
||
|
||
function projectModelAxisDirections(camera: THREE.Camera, object: THREE.Object3D): AxisProjection {
|
||
const origin = object.getWorldPosition(new THREE.Vector3());
|
||
const originProjected = origin.clone().project(camera);
|
||
const quaternion = object.getWorldQuaternion(new THREE.Quaternion());
|
||
const axisDirections: Record<AxisKey, THREE.Vector3> = {
|
||
x: new THREE.Vector3(1, 0, 0),
|
||
y: new THREE.Vector3(0, 1, 0),
|
||
z: new THREE.Vector3(0, 0, 1),
|
||
};
|
||
|
||
const projectAxis = (direction: THREE.Vector3): AxisVector2D => {
|
||
const end = origin.clone().add(direction.applyQuaternion(quaternion).normalize().multiplyScalar(0.72));
|
||
const endProjected = end.project(camera);
|
||
const dx = endProjected.x - originProjected.x;
|
||
const dy = originProjected.y - endProjected.y;
|
||
const magnitude = Math.hypot(dx, dy);
|
||
|
||
if (magnitude < 0.0001) {
|
||
return { dx: 0, dy: -5, opacity: 0.5 };
|
||
}
|
||
|
||
return {
|
||
dx: (dx / magnitude) * axisInsetLength,
|
||
dy: (dy / magnitude) * axisInsetLength,
|
||
opacity: endProjected.z < originProjected.z ? 1 : 0.58,
|
||
};
|
||
};
|
||
|
||
return {
|
||
x: projectAxis(axisDirections.x),
|
||
y: projectAxis(axisDirections.y),
|
||
z: projectAxis(axisDirections.z),
|
||
};
|
||
}
|
||
|
||
function axisProjectionSignature(projection: AxisProjection) {
|
||
return (['x', 'y', 'z'] as AxisKey[])
|
||
.map((key) => {
|
||
const item = projection[key];
|
||
return `${Math.round(item.dx * 10)},${Math.round(item.dy * 10)},${Math.round(item.opacity * 100)}`;
|
||
})
|
||
.join('|');
|
||
}
|
||
|
||
function createDicomTexture(frame: string, width: number, height: number) {
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
const context = canvas.getContext('2d');
|
||
if (!context) {
|
||
return null;
|
||
}
|
||
|
||
const binary = atob(frame);
|
||
const imageData = context.createImageData(width, height);
|
||
for (let index = 0; index < binary.length; index += 1) {
|
||
const value = binary.charCodeAt(index);
|
||
const offset = index * 4;
|
||
imageData.data[offset] = value;
|
||
imageData.data[offset + 1] = value;
|
||
imageData.data[offset + 2] = value;
|
||
imageData.data[offset + 3] = value > 4 ? 235 : 0;
|
||
}
|
||
context.putImageData(imageData, 0, 0);
|
||
|
||
const texture = new THREE.CanvasTexture(canvas);
|
||
texture.colorSpace = THREE.SRGBColorSpace;
|
||
texture.minFilter = THREE.LinearFilter;
|
||
texture.magFilter = THREE.LinearFilter;
|
||
texture.needsUpdate = true;
|
||
return texture;
|
||
}
|
||
|
||
function CoordinateAxesInset({ projection }: { projection: AxisProjection }) {
|
||
const origin = { x: 25, y: 31 };
|
||
const axisItems: Array<{ key: AxisKey; label: string; color: string; labelColor: string; markerId: string }> = [
|
||
{ key: 'x', label: 'X', color: '#ef4444', labelColor: '#fecaca', markerId: 'fusion-axis-arrow-x' },
|
||
{ key: 'y', label: 'Y', color: '#22c55e', labelColor: '#bbf7d0', markerId: 'fusion-axis-arrow-y' },
|
||
{ key: 'z', label: 'Z', color: '#38bdf8', labelColor: '#bae6fd', markerId: 'fusion-axis-arrow-z' },
|
||
];
|
||
|
||
return (
|
||
<div
|
||
className="pointer-events-none absolute bottom-3 right-3 z-10 rounded-lg border border-white/10 bg-black/60 p-1.5 shadow-lg backdrop-blur-sm"
|
||
title="当前视角下模型平移 XYZ 方向"
|
||
>
|
||
<svg width="54" height="54" viewBox="0 0 54 54" aria-hidden="true" className="block">
|
||
<defs>
|
||
{axisItems.map((item) => (
|
||
<marker key={item.key} id={item.markerId} markerWidth="5" markerHeight="5" refX="4.3" refY="2.5" orient="auto" markerUnits="strokeWidth">
|
||
<path d="M0,0 L5,2.5 L0,5 Z" fill={item.color} />
|
||
</marker>
|
||
))}
|
||
</defs>
|
||
<circle cx={origin.x} cy={origin.y} r="2.2" fill="#e5e7eb" />
|
||
{axisItems.map((item) => {
|
||
const vector = projection[item.key];
|
||
const endX = origin.x + vector.dx;
|
||
const endY = origin.y + vector.dy;
|
||
const textAnchor = vector.dx >= 0 ? 'start' : 'end';
|
||
return (
|
||
<g key={item.key} opacity={vector.opacity}>
|
||
<line
|
||
x1={origin.x}
|
||
y1={origin.y}
|
||
x2={endX}
|
||
y2={endY}
|
||
stroke={item.color}
|
||
strokeWidth="2.2"
|
||
strokeLinecap="round"
|
||
markerEnd={`url(#${item.markerId})`}
|
||
/>
|
||
<text
|
||
x={endX + (vector.dx >= 0 ? 4 : -4)}
|
||
y={endY + (vector.dy >= 0 ? 6 : -3)}
|
||
fill={item.labelColor}
|
||
fontSize="8"
|
||
fontWeight="700"
|
||
textAnchor={textAnchor}
|
||
>
|
||
{item.label}
|
||
</text>
|
||
</g>
|
||
);
|
||
})}
|
||
</svg>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function FusionThreeView({
|
||
project,
|
||
volume,
|
||
modelPose,
|
||
moduleStyles,
|
||
detailLimit,
|
||
solidMode,
|
||
dicomOpacity,
|
||
showBounds,
|
||
cutEnabled,
|
||
cutStart,
|
||
cutEnd,
|
||
viewPreset = 'workspace',
|
||
}: {
|
||
project: Project;
|
||
volume: DicomFusionVolume | null;
|
||
modelPose: ModelPose;
|
||
moduleStyles: Record<string, ModuleStyle>;
|
||
detailLimit: number;
|
||
solidMode: boolean;
|
||
dicomOpacity: { sliceOpacity: number; volumeOpacity: number; boxOpacity: number };
|
||
showBounds: boolean;
|
||
cutEnabled: boolean;
|
||
cutStart: number;
|
||
cutEnd: number;
|
||
viewPreset?: 'workspace' | 'libraryResult';
|
||
}) {
|
||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||
const modelPoseRef = useRef(modelPose);
|
||
const [status, setStatus] = useState('准备融合 DICOM 与 STL');
|
||
const [loadProgress, setLoadProgress] = useState(0);
|
||
const [webglError, setWebglError] = useState<string | null>(null);
|
||
const [axisProjection, setAxisProjection] = useState<AxisProjection>(defaultAxisProjection);
|
||
const axisProjectionSignatureRef = useRef(axisProjectionSignature(defaultAxisProjection));
|
||
const resetFusionViewRef = useRef<() => void>(() => undefined);
|
||
|
||
useEffect(() => {
|
||
modelPoseRef.current = modelPose;
|
||
}, [modelPose]);
|
||
|
||
useEffect(() => {
|
||
const container = containerRef.current;
|
||
if (!container || !volume) return;
|
||
|
||
container.innerHTML = '';
|
||
setWebglError(null);
|
||
setStatus('正在构建三维融合场景...');
|
||
setLoadProgress(8);
|
||
setAxisProjection(defaultAxisProjection);
|
||
axisProjectionSignatureRef.current = axisProjectionSignature(defaultAxisProjection);
|
||
|
||
let disposed = false;
|
||
let animationId = 0;
|
||
const scene = new THREE.Scene();
|
||
scene.background = new THREE.Color('#030712');
|
||
|
||
const width = Math.max(container.clientWidth, 1);
|
||
const height = Math.max(container.clientHeight, 1);
|
||
const camera = new THREE.PerspectiveCamera(45, width / height, 0.05, 1000);
|
||
camera.position.set(0, -6.2, 4.6);
|
||
camera.up.set(0, 0, 1);
|
||
camera.lookAt(0, 0, 0);
|
||
|
||
let renderer: THREE.WebGLRenderer;
|
||
try {
|
||
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||
} catch {
|
||
const message = '当前浏览器无法创建 WebGL 三维上下文';
|
||
setStatus(message);
|
||
setLoadProgress(100);
|
||
setWebglError('三维融合视图暂不可用,请检查浏览器硬件加速、显卡驱动或远程桌面图形支持。二维 DICOM 与逆向分割映射功能仍可继续使用。');
|
||
resetFusionViewRef.current = () => undefined;
|
||
return () => {
|
||
container.innerHTML = '';
|
||
};
|
||
}
|
||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||
renderer.setSize(width, height);
|
||
renderer.localClippingEnabled = true;
|
||
container.appendChild(renderer.domElement);
|
||
|
||
scene.add(new THREE.AmbientLight(0xffffff, 0.72));
|
||
const keyLight = new THREE.DirectionalLight(0xffffff, 1.1);
|
||
keyLight.position.set(4, -5, 5);
|
||
scene.add(keyLight);
|
||
const fillLight = new THREE.DirectionalLight(0x8fb8ff, 0.55);
|
||
fillLight.position.set(-4, 3, 2);
|
||
scene.add(fillLight);
|
||
|
||
const fusionRoot = new THREE.Group();
|
||
const dicomGroup = new THREE.Group();
|
||
const modelPoseGroup = new THREE.Group();
|
||
const modelPivot = new THREE.Group();
|
||
modelPoseGroup.add(modelPivot);
|
||
fusionRoot.add(dicomGroup);
|
||
fusionRoot.add(modelPoseGroup);
|
||
scene.add(fusionRoot);
|
||
|
||
const maxPhysical = Math.max(volume.physicalSize.width, volume.physicalSize.height, volume.physicalSize.depth, 1);
|
||
const baseExtent = 4.6;
|
||
const dicomWidth = (volume.physicalSize.width / maxPhysical) * baseExtent;
|
||
const dicomHeight = (volume.physicalSize.height / maxPhysical) * baseExtent;
|
||
const dicomDepth = Math.max((volume.physicalSize.depth / maxPhysical) * baseExtent, 0.18);
|
||
const planeGeometry = new THREE.PlaneGeometry(dicomWidth, dicomHeight);
|
||
|
||
const box = new THREE.Mesh(
|
||
new THREE.BoxGeometry(dicomWidth, dicomHeight, dicomDepth),
|
||
new THREE.MeshBasicMaterial({ color: '#020617', transparent: true, opacity: dicomOpacity.boxOpacity, depthWrite: false }),
|
||
);
|
||
dicomGroup.add(box);
|
||
const edges = new THREE.LineSegments(
|
||
new THREE.EdgesGeometry(box.geometry),
|
||
new THREE.LineBasicMaterial({ color: '#38bdf8', transparent: true, opacity: 0.46 }),
|
||
);
|
||
edges.visible = showBounds;
|
||
dicomGroup.add(edges);
|
||
|
||
const sliceToZ = (sliceIndex: number) => (
|
||
volume.total <= 1
|
||
? 0
|
||
: -dicomDepth / 2 + (dicomDepth * clamp(sliceIndex, 0, volume.total - 1)) / (volume.total - 1)
|
||
);
|
||
const cutRangeStart = Math.min(
|
||
clamp(cutStart, 0, volume.total - 1),
|
||
clamp(cutEnd, 0, volume.total - 1),
|
||
);
|
||
const cutRangeEnd = Math.max(
|
||
clamp(cutStart, 0, volume.total - 1),
|
||
clamp(cutEnd, 0, volume.total - 1),
|
||
);
|
||
const lowerCutZ = sliceToZ(cutRangeStart);
|
||
const upperCutZ = sliceToZ(cutRangeEnd);
|
||
const lowerClippingPlane = new THREE.Plane();
|
||
const upperClippingPlane = new THREE.Plane();
|
||
|
||
const textures: THREE.Texture[] = [];
|
||
volume.frames.forEach((frame, index) => {
|
||
const texture = createDicomTexture(frame, volume.width, volume.height);
|
||
if (!texture) return;
|
||
textures.push(texture);
|
||
const isLast = index === volume.frames.length - 1;
|
||
const dicomIndex = volume.indices[index] ?? (volume.start + index);
|
||
const material = new THREE.MeshBasicMaterial({
|
||
map: texture,
|
||
transparent: true,
|
||
opacity: isLast ? dicomOpacity.sliceOpacity : dicomOpacity.volumeOpacity,
|
||
side: THREE.DoubleSide,
|
||
depthWrite: false,
|
||
});
|
||
const slicePlane = new THREE.Mesh(planeGeometry, material);
|
||
const z = volume.total <= 1
|
||
? 0
|
||
: -dicomDepth / 2 + (dicomDepth * dicomIndex) / (volume.total - 1);
|
||
slicePlane.position.set(0, 0, z + (isLast ? 0.006 : 0));
|
||
dicomGroup.add(slicePlane);
|
||
});
|
||
|
||
setLoadProgress(42);
|
||
|
||
const stlFiles = project.stlFiles ?? [];
|
||
const visibleStlFiles = stlFiles.filter((fileName) => moduleStyles[fileName]?.visible !== false);
|
||
const modelPreviewLimit = solidMode ? Math.max(detailLimit, 800000) : detailLimit;
|
||
let modelBaseScale = 1;
|
||
let loadedModels = 0;
|
||
let failedModels = 0;
|
||
const loadedBounds: Array<{ min: THREE.Vector3; max: THREE.Vector3 }> = [];
|
||
|
||
Promise.allSettled(stlFiles.map((fileName, index) => (
|
||
getCachedModelPreview(project.id, fileName, modelPreviewLimit)
|
||
.then((payload) => {
|
||
if (disposed) return;
|
||
const style = moduleStyles[fileName] ?? {
|
||
visible: true,
|
||
color: moduleColors[index % moduleColors.length],
|
||
opacity: 0.72,
|
||
partId: index + 1,
|
||
};
|
||
if (payload.bounds) {
|
||
loadedBounds.push({
|
||
min: new THREE.Vector3(payload.bounds.min.x, payload.bounds.min.y, payload.bounds.min.z),
|
||
max: new THREE.Vector3(payload.bounds.max.x, payload.bounds.max.y, payload.bounds.max.z),
|
||
});
|
||
}
|
||
if (style.visible !== false) {
|
||
const geometry = new THREE.BufferGeometry();
|
||
geometry.setAttribute('position', new THREE.Float32BufferAttribute(payload.vertices, 3));
|
||
geometry.computeVertexNormals();
|
||
const materialOpacity = solidMode ? 1 : style.opacity;
|
||
const material = new THREE.MeshStandardMaterial({
|
||
color: style.color,
|
||
transparent: materialOpacity < 1,
|
||
opacity: materialOpacity,
|
||
depthWrite: materialOpacity >= 1,
|
||
roughness: solidMode ? 0.56 : 0.48,
|
||
metalness: 0.03,
|
||
side: THREE.DoubleSide,
|
||
clippingPlanes: cutEnabled ? [lowerClippingPlane, upperClippingPlane] : [],
|
||
clipIntersection: false,
|
||
clipShadows: true,
|
||
});
|
||
const mesh = new THREE.Mesh(geometry, material);
|
||
modelPivot.add(mesh);
|
||
}
|
||
loadedModels += 1;
|
||
setLoadProgress(42 + Math.round(((loadedModels + failedModels) / Math.max(stlFiles.length, 1)) * 46));
|
||
})
|
||
))).then(() => {
|
||
if (disposed) return;
|
||
const modelBox = new THREE.Box3();
|
||
if (loadedBounds.length) {
|
||
loadedBounds.forEach((bounds) => {
|
||
modelBox.expandByPoint(bounds.min);
|
||
modelBox.expandByPoint(bounds.max);
|
||
});
|
||
} else {
|
||
modelBox.setFromObject(modelPivot);
|
||
}
|
||
|
||
const center = modelBox.getCenter(new THREE.Vector3());
|
||
const size = modelBox.getSize(new THREE.Vector3());
|
||
const maxModelSize = Math.max(size.x, size.y, size.z, 1);
|
||
modelPivot.traverse((object) => {
|
||
if (object instanceof THREE.Mesh) {
|
||
object.geometry.translate(-center.x, -center.y, -center.z);
|
||
object.geometry.computeBoundingBox();
|
||
object.geometry.computeBoundingSphere();
|
||
object.geometry.computeVertexNormals();
|
||
}
|
||
});
|
||
const modelBounds = new THREE.LineSegments(
|
||
new THREE.EdgesGeometry(new THREE.BoxGeometry(size.x, size.y, size.z)),
|
||
new THREE.LineBasicMaterial({ color: '#facc15', transparent: true, opacity: 0.72 }),
|
||
);
|
||
modelBounds.visible = showBounds;
|
||
modelPivot.add(modelBounds);
|
||
modelBaseScale = (Math.max(dicomWidth, dicomHeight, dicomDepth) / maxModelSize) * 0.92;
|
||
modelPoseGroup.position.set(0, 0, 0);
|
||
modelPivot.position.set(0, 0, 0);
|
||
setLoadProgress(100);
|
||
setStatus(visibleStlFiles.length ? '三维融合场景已就绪' : 'DICOM 三维体已就绪,当前没有显示的 STL 构件');
|
||
});
|
||
|
||
const defaultRootPose = viewPreset === 'libraryResult' ? {
|
||
rotateX: THREE.MathUtils.degToRad(70),
|
||
rotateY: 0,
|
||
rotateZ: 0,
|
||
translateX: 0,
|
||
translateY: 0.02,
|
||
scale: 0.94,
|
||
} : {
|
||
rotateX: THREE.MathUtils.degToRad(58),
|
||
rotateY: 0,
|
||
rotateZ: THREE.MathUtils.degToRad(-18),
|
||
translateX: 0,
|
||
translateY: 0,
|
||
scale: 1,
|
||
};
|
||
const rootPose = { ...defaultRootPose };
|
||
const dragState = {
|
||
active: false,
|
||
mode: 'rotate' as 'rotate' | 'pan',
|
||
pointerId: 0,
|
||
startX: 0,
|
||
startY: 0,
|
||
root: { ...rootPose },
|
||
};
|
||
resetFusionViewRef.current = () => {
|
||
Object.assign(rootPose, defaultRootPose);
|
||
setStatus('三维融合视角已复位');
|
||
};
|
||
|
||
const handlePointerDown = (event: PointerEvent) => {
|
||
dragState.active = true;
|
||
dragState.mode = event.button === 2 || event.shiftKey ? 'pan' : 'rotate';
|
||
dragState.pointerId = event.pointerId;
|
||
dragState.startX = event.clientX;
|
||
dragState.startY = event.clientY;
|
||
dragState.root = { ...rootPose };
|
||
container.setPointerCapture(event.pointerId);
|
||
};
|
||
const handlePointerMove = (event: PointerEvent) => {
|
||
if (!dragState.active || event.pointerId !== dragState.pointerId) return;
|
||
const deltaX = event.clientX - dragState.startX;
|
||
const deltaY = event.clientY - dragState.startY;
|
||
if (dragState.mode === 'pan') {
|
||
rootPose.translateX = dragState.root.translateX + deltaX * 0.006;
|
||
rootPose.translateY = dragState.root.translateY - deltaY * 0.006;
|
||
return;
|
||
}
|
||
rootPose.rotateZ = dragState.root.rotateZ + deltaX * 0.008;
|
||
rootPose.rotateX = dragState.root.rotateX + deltaY * 0.008;
|
||
};
|
||
const stopPointerDrag = (event: PointerEvent) => {
|
||
if (event.pointerId !== dragState.pointerId) return;
|
||
dragState.active = false;
|
||
if (container.hasPointerCapture(event.pointerId)) {
|
||
container.releasePointerCapture(event.pointerId);
|
||
}
|
||
};
|
||
const handleWheel = (event: WheelEvent) => {
|
||
event.preventDefault();
|
||
rootPose.scale = clamp(rootPose.scale - event.deltaY * 0.001, 0.45, 2.2);
|
||
};
|
||
const preventContextMenu = (event: MouseEvent) => event.preventDefault();
|
||
|
||
container.addEventListener('pointerdown', handlePointerDown);
|
||
container.addEventListener('pointermove', handlePointerMove);
|
||
container.addEventListener('pointerup', stopPointerDrag);
|
||
container.addEventListener('pointercancel', stopPointerDrag);
|
||
container.addEventListener('wheel', handleWheel, { passive: false });
|
||
container.addEventListener('contextmenu', preventContextMenu);
|
||
|
||
const handleResize = () => {
|
||
if (!container.clientWidth || !container.clientHeight) return;
|
||
camera.aspect = container.clientWidth / container.clientHeight;
|
||
camera.updateProjectionMatrix();
|
||
renderer.setSize(container.clientWidth, container.clientHeight);
|
||
};
|
||
window.addEventListener('resize', handleResize);
|
||
|
||
const animate = () => {
|
||
if (disposed) return;
|
||
fusionRoot.rotation.set(rootPose.rotateX, rootPose.rotateY, rootPose.rotateZ);
|
||
fusionRoot.position.set(rootPose.translateX, rootPose.translateY, 0);
|
||
fusionRoot.scale.setScalar(rootPose.scale);
|
||
fusionRoot.updateMatrixWorld(true);
|
||
if (cutEnabled) {
|
||
const rootQuaternion = fusionRoot.getWorldQuaternion(new THREE.Quaternion());
|
||
const lowerNormal = new THREE.Vector3(0, 0, 1).applyQuaternion(rootQuaternion).normalize();
|
||
const upperNormal = new THREE.Vector3(0, 0, -1).applyQuaternion(rootQuaternion).normalize();
|
||
const lowerCutPoint = new THREE.Vector3(0, 0, lowerCutZ).applyMatrix4(fusionRoot.matrixWorld);
|
||
const upperCutPoint = new THREE.Vector3(0, 0, upperCutZ).applyMatrix4(fusionRoot.matrixWorld);
|
||
lowerClippingPlane.setFromNormalAndCoplanarPoint(lowerNormal, lowerCutPoint);
|
||
upperClippingPlane.setFromNormalAndCoplanarPoint(upperNormal, upperCutPoint);
|
||
}
|
||
|
||
const pose = modelPoseRef.current;
|
||
modelPoseGroup.rotation.set(
|
||
THREE.MathUtils.degToRad(pose.rotateX),
|
||
THREE.MathUtils.degToRad(pose.rotateY),
|
||
THREE.MathUtils.degToRad(pose.rotateZ),
|
||
);
|
||
modelPoseGroup.position.set(
|
||
pose.translateX,
|
||
pose.translateY,
|
||
pose.translateZ,
|
||
);
|
||
const poseScale = modelBaseScale * pose.scale;
|
||
modelPoseGroup.scale.set(
|
||
pose.flipX ? -poseScale : poseScale,
|
||
pose.flipY ? -poseScale : poseScale,
|
||
pose.flipZ ? -poseScale : poseScale,
|
||
);
|
||
modelPoseGroup.updateMatrixWorld(true);
|
||
const nextAxisProjection = projectModelAxisDirections(camera, modelPoseGroup);
|
||
const nextAxisSignature = axisProjectionSignature(nextAxisProjection);
|
||
if (axisProjectionSignatureRef.current !== nextAxisSignature) {
|
||
axisProjectionSignatureRef.current = nextAxisSignature;
|
||
setAxisProjection(nextAxisProjection);
|
||
}
|
||
renderer.render(scene, camera);
|
||
animationId = window.requestAnimationFrame(animate);
|
||
};
|
||
animate();
|
||
|
||
return () => {
|
||
disposed = true;
|
||
window.cancelAnimationFrame(animationId);
|
||
window.removeEventListener('resize', handleResize);
|
||
container.removeEventListener('pointerdown', handlePointerDown);
|
||
container.removeEventListener('pointermove', handlePointerMove);
|
||
container.removeEventListener('pointerup', stopPointerDrag);
|
||
container.removeEventListener('pointercancel', stopPointerDrag);
|
||
container.removeEventListener('wheel', handleWheel);
|
||
container.removeEventListener('contextmenu', preventContextMenu);
|
||
textures.forEach((texture) => texture.dispose());
|
||
scene.traverse((object) => {
|
||
if (object instanceof THREE.Mesh) {
|
||
object.geometry.dispose();
|
||
const material = object.material;
|
||
if (Array.isArray(material)) {
|
||
material.forEach((item) => item.dispose());
|
||
} else {
|
||
material.dispose();
|
||
}
|
||
}
|
||
});
|
||
renderer.dispose();
|
||
resetFusionViewRef.current = () => undefined;
|
||
container.innerHTML = '';
|
||
};
|
||
}, [
|
||
project.id,
|
||
project.stlFiles?.join('|'),
|
||
volume,
|
||
JSON.stringify(moduleStyles),
|
||
detailLimit,
|
||
solidMode,
|
||
dicomOpacity.sliceOpacity,
|
||
dicomOpacity.volumeOpacity,
|
||
dicomOpacity.boxOpacity,
|
||
showBounds,
|
||
cutEnabled,
|
||
cutStart,
|
||
cutEnd,
|
||
viewPreset,
|
||
]);
|
||
|
||
const volumeDisplayRange = volume ? getDicomDisplayRange(volume.start, volume.end, volume.total) : null;
|
||
|
||
return (
|
||
<div className="relative h-full min-h-[520px] overflow-hidden rounded-3xl border border-slate-800 bg-black shadow-xl">
|
||
<div ref={containerRef} className="absolute inset-0 cursor-grab active:cursor-grabbing" />
|
||
{webglError && (
|
||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-slate-950/92 px-8 text-center">
|
||
<div className="max-w-md rounded-2xl border border-amber-300/20 bg-slate-900/90 p-6 text-amber-50 shadow-2xl">
|
||
<AlertCircle className="mx-auto mb-3 text-amber-300" size={30} />
|
||
<p className="text-sm font-bold">三维融合视图无法启动</p>
|
||
<p className="mt-3 text-xs leading-6 text-amber-100/75">{webglError}</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="pointer-events-none absolute left-4 top-4 rounded-xl border border-white/10 bg-black/60 px-3 py-2 text-[10px] font-mono text-white/60">
|
||
{status}
|
||
</div>
|
||
<div className="pointer-events-none absolute right-4 top-4 rounded-xl border border-cyan-400/20 bg-cyan-950/50 px-3 py-2 text-[10px] font-mono text-cyan-100">
|
||
DICOM {volume && volumeDisplayRange ? `${volumeDisplayRange.start}-${volumeDisplayRange.end}/${volume.total}` : '加载中'} · STL {project.modelCount ?? 0}
|
||
</div>
|
||
<button
|
||
onClick={() => resetFusionViewRef.current()}
|
||
className="absolute right-4 top-16 z-10 flex h-8 items-center gap-1.5 rounded-xl border border-white/10 bg-black/60 px-3 text-[10px] font-bold text-white/70 shadow-lg hover:border-cyan-300/30 hover:text-cyan-100"
|
||
title="重置影像与模型融合视角位置"
|
||
>
|
||
<RefreshCcw size={13} />
|
||
位置重置
|
||
</button>
|
||
<CoordinateAxesInset projection={axisProjection} />
|
||
{loadProgress < 100 && (
|
||
<div className="absolute inset-x-10 bottom-8 rounded-xl border border-white/10 bg-black/70 p-3">
|
||
<div className="mb-2 flex items-center justify-between text-[10px] font-bold text-white/70">
|
||
<span>正在融合三维影像与模型</span>
|
||
<span>{loadProgress}%</span>
|
||
</div>
|
||
<div className="h-2 overflow-hidden rounded-full bg-white/10">
|
||
<div className="h-full bg-blue-500 transition-all" style={{ width: `${loadProgress}%` }} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
{!volume && (
|
||
<div className="absolute inset-0 flex items-center justify-center text-xs font-bold text-white/40">
|
||
正在载入 DICOM 三维体...
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CutSectionPreview({
|
||
project,
|
||
volume,
|
||
modelPose,
|
||
moduleStyles,
|
||
detailLimit,
|
||
cutEnabled,
|
||
cutStart,
|
||
cutEnd,
|
||
}: {
|
||
project: Project | null;
|
||
volume: DicomFusionVolume | null;
|
||
modelPose: ModelPose;
|
||
moduleStyles: Record<string, ModuleStyle>;
|
||
detailLimit: number;
|
||
cutEnabled: boolean;
|
||
cutStart: number;
|
||
cutEnd: number;
|
||
}) {
|
||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||
const modelPoseRef = useRef(modelPose);
|
||
const [webglError, setWebglError] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
modelPoseRef.current = modelPose;
|
||
}, [modelPose]);
|
||
|
||
useEffect(() => {
|
||
const container = containerRef.current;
|
||
if (!container || !project || !volume) return;
|
||
|
||
container.innerHTML = '';
|
||
setWebglError(null);
|
||
let disposed = false;
|
||
let animationId = 0;
|
||
const scene = new THREE.Scene();
|
||
scene.background = new THREE.Color('#020617');
|
||
|
||
const width = Math.max(container.clientWidth, 1);
|
||
const height = Math.max(container.clientHeight, 1);
|
||
const camera = new THREE.PerspectiveCamera(42, width / height, 0.05, 1000);
|
||
camera.position.set(0, -5.6, 3.4);
|
||
camera.up.set(0, 0, 1);
|
||
camera.lookAt(0, 0, 0);
|
||
|
||
let renderer: THREE.WebGLRenderer;
|
||
try {
|
||
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||
} catch {
|
||
setWebglError('当前浏览器无法创建 WebGL 三维上下文,STL 切面三维预览暂不可用。');
|
||
return () => {
|
||
container.innerHTML = '';
|
||
};
|
||
}
|
||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||
renderer.setSize(width, height);
|
||
renderer.localClippingEnabled = true;
|
||
container.appendChild(renderer.domElement);
|
||
|
||
scene.add(new THREE.AmbientLight(0xffffff, 0.78));
|
||
const keyLight = new THREE.DirectionalLight(0xffffff, 1.25);
|
||
keyLight.position.set(3, -4, 5);
|
||
scene.add(keyLight);
|
||
const rimLight = new THREE.DirectionalLight(0x93c5fd, 0.72);
|
||
rimLight.position.set(-4, 3, 2);
|
||
scene.add(rimLight);
|
||
|
||
const fusionRoot = new THREE.Group();
|
||
const modelPoseGroup = new THREE.Group();
|
||
const modelPivot = new THREE.Group();
|
||
modelPoseGroup.add(modelPivot);
|
||
fusionRoot.add(modelPoseGroup);
|
||
scene.add(fusionRoot);
|
||
|
||
const maxPhysical = Math.max(volume.physicalSize.width, volume.physicalSize.height, volume.physicalSize.depth, 1);
|
||
const baseExtent = 4.4;
|
||
const dicomWidth = (volume.physicalSize.width / maxPhysical) * baseExtent;
|
||
const dicomHeight = (volume.physicalSize.height / maxPhysical) * baseExtent;
|
||
const dicomDepth = Math.max((volume.physicalSize.depth / maxPhysical) * baseExtent, 0.18);
|
||
const sliceToZ = (sliceIndex: number) => (
|
||
volume.total <= 1
|
||
? 0
|
||
: -dicomDepth / 2 + (dicomDepth * clamp(sliceIndex, 0, volume.total - 1)) / (volume.total - 1)
|
||
);
|
||
const cutRangeStart = Math.min(
|
||
clamp(cutStart, 0, volume.total - 1),
|
||
clamp(cutEnd, 0, volume.total - 1),
|
||
);
|
||
const cutRangeEnd = Math.max(
|
||
clamp(cutStart, 0, volume.total - 1),
|
||
clamp(cutEnd, 0, volume.total - 1),
|
||
);
|
||
const lowerCutZ = sliceToZ(cutRangeStart);
|
||
const upperCutZ = sliceToZ(cutRangeEnd);
|
||
const lowerClippingPlane = new THREE.Plane();
|
||
const upperClippingPlane = new THREE.Plane();
|
||
|
||
let modelBaseScale = 1;
|
||
let loadedModels = 0;
|
||
let failedModels = 0;
|
||
const loadedBounds: Array<{ min: THREE.Vector3; max: THREE.Vector3 }> = [];
|
||
const stlFiles = (project.stlFiles ?? []).filter((fileName) => moduleStyles[fileName]?.visible !== false);
|
||
|
||
Promise.allSettled(stlFiles.map((fileName, index) => (
|
||
getCachedModelPreview(project.id, fileName, Math.max(detailLimit, 200000))
|
||
.then((payload) => {
|
||
if (disposed) return;
|
||
const geometry = new THREE.BufferGeometry();
|
||
geometry.setAttribute('position', new THREE.Float32BufferAttribute(payload.vertices, 3));
|
||
geometry.computeVertexNormals();
|
||
const style = moduleStyles[fileName] ?? {
|
||
visible: true,
|
||
color: moduleColors[index % moduleColors.length],
|
||
opacity: 1,
|
||
partId: index + 1,
|
||
};
|
||
const material = new THREE.MeshStandardMaterial({
|
||
color: style.color,
|
||
roughness: 0.5,
|
||
metalness: 0.04,
|
||
side: THREE.DoubleSide,
|
||
clippingPlanes: cutEnabled ? [lowerClippingPlane, upperClippingPlane] : [],
|
||
clipIntersection: false,
|
||
clipShadows: true,
|
||
});
|
||
modelPivot.add(new THREE.Mesh(geometry, material));
|
||
if (payload.bounds) {
|
||
loadedBounds.push({
|
||
min: new THREE.Vector3(payload.bounds.min.x, payload.bounds.min.y, payload.bounds.min.z),
|
||
max: new THREE.Vector3(payload.bounds.max.x, payload.bounds.max.y, payload.bounds.max.z),
|
||
});
|
||
}
|
||
loadedModels += 1;
|
||
})
|
||
.catch(() => {
|
||
failedModels += 1;
|
||
})
|
||
))).then(() => {
|
||
if (disposed || (loadedModels + failedModels === 0)) return;
|
||
const modelBox = new THREE.Box3();
|
||
if (loadedBounds.length) {
|
||
loadedBounds.forEach((bounds) => {
|
||
modelBox.expandByPoint(bounds.min);
|
||
modelBox.expandByPoint(bounds.max);
|
||
});
|
||
} else {
|
||
modelBox.setFromObject(modelPivot);
|
||
}
|
||
const center = modelBox.getCenter(new THREE.Vector3());
|
||
const size = modelBox.getSize(new THREE.Vector3());
|
||
const maxModelSize = Math.max(size.x, size.y, size.z, 1);
|
||
modelPivot.traverse((object) => {
|
||
if (object instanceof THREE.Mesh) {
|
||
object.geometry.translate(-center.x, -center.y, -center.z);
|
||
object.geometry.computeBoundingBox();
|
||
object.geometry.computeBoundingSphere();
|
||
object.geometry.computeVertexNormals();
|
||
}
|
||
});
|
||
modelBaseScale = (Math.max(dicomWidth, dicomHeight, dicomDepth) / maxModelSize) * 0.98;
|
||
modelPoseGroup.position.set(0, 0, 0);
|
||
modelPivot.position.set(0, 0, 0);
|
||
});
|
||
|
||
const rootPose = {
|
||
rotateX: THREE.MathUtils.degToRad(58),
|
||
rotateY: 0,
|
||
rotateZ: THREE.MathUtils.degToRad(-18),
|
||
translateX: 0,
|
||
translateY: 0,
|
||
scale: 1,
|
||
};
|
||
const dragState = {
|
||
active: false,
|
||
mode: 'rotate' as 'rotate' | 'pan',
|
||
pointerId: 0,
|
||
startX: 0,
|
||
startY: 0,
|
||
root: { ...rootPose },
|
||
};
|
||
|
||
const handlePointerDown = (event: PointerEvent) => {
|
||
dragState.active = true;
|
||
dragState.mode = event.button === 2 || event.shiftKey ? 'pan' : 'rotate';
|
||
dragState.pointerId = event.pointerId;
|
||
dragState.startX = event.clientX;
|
||
dragState.startY = event.clientY;
|
||
dragState.root = { ...rootPose };
|
||
container.setPointerCapture(event.pointerId);
|
||
};
|
||
const handlePointerMove = (event: PointerEvent) => {
|
||
if (!dragState.active || event.pointerId !== dragState.pointerId) return;
|
||
const deltaX = event.clientX - dragState.startX;
|
||
const deltaY = event.clientY - dragState.startY;
|
||
if (dragState.mode === 'pan') {
|
||
rootPose.translateX = dragState.root.translateX + deltaX * 0.006;
|
||
rootPose.translateY = dragState.root.translateY - deltaY * 0.006;
|
||
return;
|
||
}
|
||
rootPose.rotateZ = dragState.root.rotateZ + deltaX * 0.008;
|
||
rootPose.rotateX = dragState.root.rotateX + deltaY * 0.008;
|
||
};
|
||
const stopPointerDrag = (event: PointerEvent) => {
|
||
if (event.pointerId !== dragState.pointerId) return;
|
||
dragState.active = false;
|
||
if (container.hasPointerCapture(event.pointerId)) {
|
||
container.releasePointerCapture(event.pointerId);
|
||
}
|
||
};
|
||
const handleWheel = (event: WheelEvent) => {
|
||
event.preventDefault();
|
||
rootPose.scale = clamp(rootPose.scale - event.deltaY * 0.001, 0.55, 2.4);
|
||
};
|
||
const preventContextMenu = (event: MouseEvent) => event.preventDefault();
|
||
const handleResize = () => {
|
||
if (!container.clientWidth || !container.clientHeight) return;
|
||
camera.aspect = container.clientWidth / container.clientHeight;
|
||
camera.updateProjectionMatrix();
|
||
renderer.setSize(container.clientWidth, container.clientHeight);
|
||
};
|
||
|
||
container.addEventListener('pointerdown', handlePointerDown);
|
||
container.addEventListener('pointermove', handlePointerMove);
|
||
container.addEventListener('pointerup', stopPointerDrag);
|
||
container.addEventListener('pointercancel', stopPointerDrag);
|
||
container.addEventListener('wheel', handleWheel, { passive: false });
|
||
container.addEventListener('contextmenu', preventContextMenu);
|
||
window.addEventListener('resize', handleResize);
|
||
|
||
const animate = () => {
|
||
if (disposed) return;
|
||
fusionRoot.rotation.set(rootPose.rotateX, rootPose.rotateY, rootPose.rotateZ);
|
||
fusionRoot.position.set(rootPose.translateX, rootPose.translateY, 0);
|
||
fusionRoot.scale.setScalar(rootPose.scale);
|
||
if (cutEnabled) {
|
||
fusionRoot.updateMatrixWorld(true);
|
||
const rootQuaternion = fusionRoot.getWorldQuaternion(new THREE.Quaternion());
|
||
const lowerNormal = new THREE.Vector3(0, 0, 1).applyQuaternion(rootQuaternion).normalize();
|
||
const upperNormal = new THREE.Vector3(0, 0, -1).applyQuaternion(rootQuaternion).normalize();
|
||
const lowerCutPoint = new THREE.Vector3(0, 0, lowerCutZ).applyMatrix4(fusionRoot.matrixWorld);
|
||
const upperCutPoint = new THREE.Vector3(0, 0, upperCutZ).applyMatrix4(fusionRoot.matrixWorld);
|
||
lowerClippingPlane.setFromNormalAndCoplanarPoint(lowerNormal, lowerCutPoint);
|
||
upperClippingPlane.setFromNormalAndCoplanarPoint(upperNormal, upperCutPoint);
|
||
}
|
||
|
||
const pose = modelPoseRef.current;
|
||
modelPoseGroup.rotation.set(
|
||
THREE.MathUtils.degToRad(pose.rotateX),
|
||
THREE.MathUtils.degToRad(pose.rotateY),
|
||
THREE.MathUtils.degToRad(pose.rotateZ),
|
||
);
|
||
modelPoseGroup.position.set(pose.translateX, pose.translateY, pose.translateZ);
|
||
const poseScale = modelBaseScale * pose.scale;
|
||
modelPoseGroup.scale.set(
|
||
pose.flipX ? -poseScale : poseScale,
|
||
pose.flipY ? -poseScale : poseScale,
|
||
pose.flipZ ? -poseScale : poseScale,
|
||
);
|
||
renderer.render(scene, camera);
|
||
animationId = window.requestAnimationFrame(animate);
|
||
};
|
||
animate();
|
||
|
||
return () => {
|
||
disposed = true;
|
||
window.cancelAnimationFrame(animationId);
|
||
window.removeEventListener('resize', handleResize);
|
||
container.removeEventListener('pointerdown', handlePointerDown);
|
||
container.removeEventListener('pointermove', handlePointerMove);
|
||
container.removeEventListener('pointerup', stopPointerDrag);
|
||
container.removeEventListener('pointercancel', stopPointerDrag);
|
||
container.removeEventListener('wheel', handleWheel);
|
||
container.removeEventListener('contextmenu', preventContextMenu);
|
||
scene.traverse((object) => {
|
||
if (object instanceof THREE.Mesh) {
|
||
object.geometry.dispose();
|
||
const material = object.material;
|
||
if (Array.isArray(material)) {
|
||
material.forEach((item) => item.dispose());
|
||
} else {
|
||
material.dispose();
|
||
}
|
||
}
|
||
});
|
||
renderer.dispose();
|
||
container.innerHTML = '';
|
||
};
|
||
}, [
|
||
project?.id,
|
||
project?.stlFiles?.join('|'),
|
||
volume,
|
||
JSON.stringify(moduleStyles),
|
||
detailLimit,
|
||
cutEnabled,
|
||
cutStart,
|
||
cutEnd,
|
||
]);
|
||
|
||
return (
|
||
<div className="relative h-full min-h-[420px] overflow-hidden rounded-3xl border border-slate-800 bg-slate-950 shadow-2xl">
|
||
<div ref={containerRef} className="absolute inset-0 cursor-grab active:cursor-grabbing" />
|
||
{webglError && (
|
||
<div className="absolute inset-0 flex items-center justify-center px-6 text-center">
|
||
<div className="rounded-2xl border border-amber-300/20 bg-slate-900/90 p-5 text-xs font-bold leading-6 text-amber-100">
|
||
{webglError}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{(!project || !volume) && (
|
||
<div className="absolute inset-0 flex items-center justify-center text-xs font-bold text-white/40">
|
||
正在载入 STL 切面...
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface ModelBounds {
|
||
min: { x: number; y: number; z: number };
|
||
max: { x: number; y: number; z: number };
|
||
}
|
||
|
||
interface Point2D {
|
||
x: number;
|
||
y: number;
|
||
}
|
||
|
||
interface Point3D {
|
||
x: number;
|
||
y: number;
|
||
z: number;
|
||
}
|
||
|
||
interface ModelSceneMetrics {
|
||
center: Point3D;
|
||
modelBaseScale: number;
|
||
modelPivotOffsetZ: number;
|
||
dicomWidth: number;
|
||
dicomHeight: number;
|
||
dicomDepth: number;
|
||
}
|
||
|
||
interface PlaneSegment {
|
||
a: Point2D;
|
||
b: Point2D;
|
||
}
|
||
|
||
export interface OverlayStats {
|
||
activeModules: number;
|
||
filledPixels: number;
|
||
segmentCount: number;
|
||
modules: Array<{
|
||
fileName: string;
|
||
name: string;
|
||
color: string;
|
||
opacity: number;
|
||
partId: number;
|
||
segmentCount: number;
|
||
filledPixels: number;
|
||
}>;
|
||
}
|
||
|
||
function getPayloadBounds(payload: ModelPreviewPayload): ModelBounds | null {
|
||
if (payload.bounds) {
|
||
return payload.bounds;
|
||
}
|
||
|
||
if (payload.vertices.length < 3) {
|
||
return null;
|
||
}
|
||
|
||
const bounds: ModelBounds = {
|
||
min: { x: Infinity, y: Infinity, z: Infinity },
|
||
max: { x: -Infinity, y: -Infinity, z: -Infinity },
|
||
};
|
||
|
||
for (let index = 0; index < payload.vertices.length; index += 3) {
|
||
const x = payload.vertices[index];
|
||
const y = payload.vertices[index + 1];
|
||
const z = payload.vertices[index + 2];
|
||
bounds.min.x = Math.min(bounds.min.x, x);
|
||
bounds.min.y = Math.min(bounds.min.y, y);
|
||
bounds.min.z = Math.min(bounds.min.z, z);
|
||
bounds.max.x = Math.max(bounds.max.x, x);
|
||
bounds.max.y = Math.max(bounds.max.y, y);
|
||
bounds.max.z = Math.max(bounds.max.z, z);
|
||
}
|
||
|
||
return Number.isFinite(bounds.min.x) ? bounds : null;
|
||
}
|
||
|
||
function getGlobalModelBounds(files: string[], previews: Record<string, ModelPreviewPayload>) {
|
||
const bounds: ModelBounds = {
|
||
min: { x: Infinity, y: Infinity, z: Infinity },
|
||
max: { x: -Infinity, y: -Infinity, z: -Infinity },
|
||
};
|
||
let hasBounds = false;
|
||
|
||
files.forEach((fileName) => {
|
||
const payloadBounds = previews[fileName] ? getPayloadBounds(previews[fileName]) : null;
|
||
if (!payloadBounds) {
|
||
return;
|
||
}
|
||
hasBounds = true;
|
||
bounds.min.x = Math.min(bounds.min.x, payloadBounds.min.x);
|
||
bounds.min.y = Math.min(bounds.min.y, payloadBounds.min.y);
|
||
bounds.min.z = Math.min(bounds.min.z, payloadBounds.min.z);
|
||
bounds.max.x = Math.max(bounds.max.x, payloadBounds.max.x);
|
||
bounds.max.y = Math.max(bounds.max.y, payloadBounds.max.y);
|
||
bounds.max.z = Math.max(bounds.max.z, payloadBounds.max.z);
|
||
});
|
||
|
||
return hasBounds ? bounds : null;
|
||
}
|
||
|
||
function getPreviewPhysicalSize(preview: DicomPreview) {
|
||
const columnSpacing = preview.spacing?.displayX ?? preview.spacing?.column ?? 1;
|
||
const rowSpacing = preview.spacing?.displayY ?? preview.spacing?.row ?? 1;
|
||
const width = preview.physicalSize?.width ?? preview.width * columnSpacing;
|
||
const height = preview.physicalSize?.height ?? preview.height * rowSpacing;
|
||
|
||
return {
|
||
width: Math.max(width, 0.001),
|
||
height: Math.max(height, 0.001),
|
||
columnSpacing: Math.max(columnSpacing, 0.001),
|
||
rowSpacing: Math.max(rowSpacing, 0.001),
|
||
sliceSpacing: Math.max(preview.spacing?.slice ?? 1, 0.001),
|
||
};
|
||
}
|
||
|
||
function getFovCanvasSize(preview: DicomPreview) {
|
||
const physical = getPreviewPhysicalSize(preview);
|
||
const unit = Math.max(0.001, Math.min(physical.columnSpacing, physical.rowSpacing));
|
||
const rawWidth = Math.max(1, Math.round(physical.width / unit));
|
||
const rawHeight = Math.max(1, Math.round(physical.height / unit));
|
||
const maxDimension = 960;
|
||
const scale = Math.min(1, maxDimension / Math.max(rawWidth, rawHeight));
|
||
|
||
return {
|
||
width: Math.max(1, Math.round(rawWidth * scale)),
|
||
height: Math.max(1, Math.round(rawHeight * scale)),
|
||
};
|
||
}
|
||
|
||
function getModelSceneMetrics(
|
||
files: string[],
|
||
previews: Record<string, ModelPreviewPayload>,
|
||
preview: DicomPreview,
|
||
totalSlices: number,
|
||
): ModelSceneMetrics | null {
|
||
const globalBounds = getGlobalModelBounds(files, previews);
|
||
if (!globalBounds) {
|
||
return null;
|
||
}
|
||
|
||
const spanX = Math.max(globalBounds.max.x - globalBounds.min.x, 0.001);
|
||
const spanY = Math.max(globalBounds.max.y - globalBounds.min.y, 0.001);
|
||
const spanZ = Math.max(globalBounds.max.z - globalBounds.min.z, 0.001);
|
||
const maxModelSize = Math.max(spanX, spanY, spanZ, 1);
|
||
const physical = getPreviewPhysicalSize(preview);
|
||
const physicalDepth = Math.max(totalSlices, 1) * physical.sliceSpacing;
|
||
const maxPhysical = Math.max(physical.width, physical.height, physicalDepth, 1);
|
||
const dicomWidth = (physical.width / maxPhysical) * fusionBaseExtent;
|
||
const dicomHeight = (physical.height / maxPhysical) * fusionBaseExtent;
|
||
const dicomDepth = Math.max((physicalDepth / maxPhysical) * fusionBaseExtent, 0.18);
|
||
const modelBaseScale = (Math.max(dicomWidth, dicomHeight, dicomDepth) / maxModelSize) * 0.92;
|
||
|
||
return {
|
||
center: {
|
||
x: (globalBounds.min.x + globalBounds.max.x) / 2,
|
||
y: (globalBounds.min.y + globalBounds.max.y) / 2,
|
||
z: (globalBounds.min.z + globalBounds.max.z) / 2,
|
||
},
|
||
modelBaseScale,
|
||
modelPivotOffsetZ: dicomDepth * 0.08,
|
||
dicomWidth,
|
||
dicomHeight,
|
||
dicomDepth,
|
||
};
|
||
}
|
||
|
||
function transformPointForPose(x: number, y: number, z: number, metrics: ModelSceneMetrics, pose: ModelPose): Point3D {
|
||
const scalar = metrics.modelBaseScale * pose.scale;
|
||
let px = (x - metrics.center.x) * scalar * (pose.flipX ? -1 : 1);
|
||
let py = (y - metrics.center.y) * scalar * (pose.flipY ? -1 : 1);
|
||
let pz = (z - metrics.center.z) * scalar * (pose.flipZ ? -1 : 1);
|
||
pz += metrics.modelPivotOffsetZ * scalar;
|
||
|
||
const rotateX = THREE.MathUtils.degToRad(pose.rotateX);
|
||
const rotateY = THREE.MathUtils.degToRad(pose.rotateY);
|
||
const rotateZ = THREE.MathUtils.degToRad(pose.rotateZ);
|
||
const cosX = Math.cos(rotateX);
|
||
const sinX = Math.sin(rotateX);
|
||
const cosY = Math.cos(rotateY);
|
||
const sinY = Math.sin(rotateY);
|
||
const cosZ = Math.cos(rotateZ);
|
||
const sinZ = Math.sin(rotateZ);
|
||
|
||
const afterX = {
|
||
x: px,
|
||
y: py * cosX - pz * sinX,
|
||
z: py * sinX + pz * cosX,
|
||
};
|
||
const afterY = {
|
||
x: afterX.x * cosY + afterX.z * sinY,
|
||
y: afterX.y,
|
||
z: -afterX.x * sinY + afterX.z * cosY,
|
||
};
|
||
px = afterY.x * cosZ - afterY.y * sinZ;
|
||
py = afterY.x * sinZ + afterY.y * cosZ;
|
||
pz = afterY.z;
|
||
|
||
return {
|
||
x: px + pose.translateX,
|
||
y: py + pose.translateY,
|
||
z: pz + pose.translateZ,
|
||
};
|
||
}
|
||
|
||
function intersectEdgeWithPlane(start: Point3D, end: Point3D, targetZ: number): Point2D | null {
|
||
const epsilon = 1e-5;
|
||
const startDistance = start.z - targetZ;
|
||
const endDistance = end.z - targetZ;
|
||
|
||
if (Math.abs(startDistance) <= epsilon && Math.abs(endDistance) <= epsilon) {
|
||
return null;
|
||
}
|
||
|
||
if (Math.abs(startDistance) <= epsilon) {
|
||
return { x: start.x, y: start.y };
|
||
}
|
||
|
||
if (Math.abs(endDistance) <= epsilon) {
|
||
return { x: end.x, y: end.y };
|
||
}
|
||
|
||
if ((startDistance > 0 && endDistance > 0) || (startDistance < 0 && endDistance < 0)) {
|
||
return null;
|
||
}
|
||
|
||
const t = startDistance / (startDistance - endDistance);
|
||
return {
|
||
x: start.x + (end.x - start.x) * t,
|
||
y: start.y + (end.y - start.y) * t,
|
||
};
|
||
}
|
||
|
||
function pointDistanceSquared(a: Point2D, b: Point2D) {
|
||
const dx = a.x - b.x;
|
||
const dy = a.y - b.y;
|
||
return dx * dx + dy * dy;
|
||
}
|
||
|
||
function intersectTriangleWithPlane(a: Point3D, b: Point3D, c: Point3D, targetZ: number): PlaneSegment | null {
|
||
const intersections = [
|
||
intersectEdgeWithPlane(a, b, targetZ),
|
||
intersectEdgeWithPlane(b, c, targetZ),
|
||
intersectEdgeWithPlane(c, a, targetZ),
|
||
].filter((point): point is Point2D => Boolean(point));
|
||
|
||
const uniquePoints: Point2D[] = [];
|
||
intersections.forEach((point) => {
|
||
const exists = uniquePoints.some((current) => pointDistanceSquared(current, point) < 1e-8);
|
||
if (!exists) {
|
||
uniquePoints.push(point);
|
||
}
|
||
});
|
||
|
||
if (uniquePoints.length < 2) {
|
||
return null;
|
||
}
|
||
|
||
let segment: PlaneSegment = { a: uniquePoints[0], b: uniquePoints[1] };
|
||
let maxDistance = pointDistanceSquared(segment.a, segment.b);
|
||
for (let first = 0; first < uniquePoints.length; first += 1) {
|
||
for (let second = first + 1; second < uniquePoints.length; second += 1) {
|
||
const distance = pointDistanceSquared(uniquePoints[first], uniquePoints[second]);
|
||
if (distance > maxDistance) {
|
||
maxDistance = distance;
|
||
segment = { a: uniquePoints[first], b: uniquePoints[second] };
|
||
}
|
||
}
|
||
}
|
||
|
||
return maxDistance > 1e-8 ? segment : null;
|
||
}
|
||
|
||
function parseHexColor(color: string) {
|
||
const normalized = color.replace('#', '').trim();
|
||
const value = normalized.length === 3
|
||
? normalized.split('').map((item) => item + item).join('')
|
||
: normalized.padEnd(6, '0').slice(0, 6);
|
||
const parsed = Number.parseInt(value, 16);
|
||
|
||
if (!Number.isFinite(parsed)) {
|
||
return { r: 59, g: 130, b: 246 };
|
||
}
|
||
|
||
return {
|
||
r: (parsed >> 16) & 255,
|
||
g: (parsed >> 8) & 255,
|
||
b: parsed & 255,
|
||
};
|
||
}
|
||
|
||
function fillInternalMaskHoles(
|
||
maskData: ImageData,
|
||
width: number,
|
||
height: number,
|
||
rgb: { r: number; g: number; b: number },
|
||
alpha: number,
|
||
) {
|
||
const outside = new Uint8Array(width * height);
|
||
const stack: number[] = [];
|
||
const pushIfEmpty = (x: number, y: number) => {
|
||
if (x < 0 || x >= width || y < 0 || y >= height) {
|
||
return;
|
||
}
|
||
|
||
const index = y * width + x;
|
||
if (outside[index] || maskData.data[index * 4 + 3] > 0) {
|
||
return;
|
||
}
|
||
|
||
outside[index] = 1;
|
||
stack.push(index);
|
||
};
|
||
|
||
for (let x = 0; x < width; x += 1) {
|
||
pushIfEmpty(x, 0);
|
||
pushIfEmpty(x, height - 1);
|
||
}
|
||
for (let y = 0; y < height; y += 1) {
|
||
pushIfEmpty(0, y);
|
||
pushIfEmpty(width - 1, y);
|
||
}
|
||
|
||
while (stack.length) {
|
||
const index = stack.pop();
|
||
if (index === undefined) {
|
||
continue;
|
||
}
|
||
|
||
const x = index % width;
|
||
const y = Math.floor(index / width);
|
||
pushIfEmpty(x + 1, y);
|
||
pushIfEmpty(x - 1, y);
|
||
pushIfEmpty(x, y + 1);
|
||
pushIfEmpty(x, y - 1);
|
||
}
|
||
|
||
let patchedPixels = 0;
|
||
for (let index = 0; index < outside.length; index += 1) {
|
||
const offset = index * 4;
|
||
if (!outside[index] && maskData.data[offset + 3] === 0) {
|
||
maskData.data[offset] = rgb.r;
|
||
maskData.data[offset + 1] = rgb.g;
|
||
maskData.data[offset + 2] = rgb.b;
|
||
maskData.data[offset + 3] = alpha;
|
||
patchedPixels += 1;
|
||
}
|
||
}
|
||
|
||
return patchedPixels;
|
||
}
|
||
|
||
function addSegmentIntersectionsToRows(rows: number[][], width: number, height: number, segment: PlaneSegment) {
|
||
const { a, b } = segment;
|
||
if (!Number.isFinite(a.x) || !Number.isFinite(a.y) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) {
|
||
return;
|
||
}
|
||
|
||
const deltaY = b.y - a.y;
|
||
if (Math.abs(deltaY) < 0.01) {
|
||
return;
|
||
}
|
||
|
||
const minY = Math.max(0, Math.floor(Math.min(a.y, b.y)));
|
||
const maxY = Math.min(height - 1, Math.ceil(Math.max(a.y, b.y)));
|
||
for (let row = minY; row <= maxY; row += 1) {
|
||
const sampleY = row + 0.5;
|
||
const crosses = (sampleY >= a.y && sampleY < b.y) || (sampleY >= b.y && sampleY < a.y);
|
||
if (!crosses) {
|
||
continue;
|
||
}
|
||
|
||
const t = (sampleY - a.y) / deltaY;
|
||
const x = a.x + (b.x - a.x) * t;
|
||
if (Number.isFinite(x)) {
|
||
rows[row].push(x);
|
||
}
|
||
}
|
||
}
|
||
|
||
function groupPlaneSegmentsByConnectivity(segments: PlaneSegment[], tolerance = 1.35) {
|
||
if (segments.length <= 1) {
|
||
return segments.length ? [segments] : [];
|
||
}
|
||
|
||
const parents = segments.map((_, index) => index);
|
||
const find = (index: number): number => {
|
||
if (parents[index] !== index) {
|
||
parents[index] = find(parents[index]);
|
||
}
|
||
return parents[index];
|
||
};
|
||
const union = (left: number, right: number) => {
|
||
const leftRoot = find(left);
|
||
const rightRoot = find(right);
|
||
if (leftRoot !== rightRoot) {
|
||
parents[rightRoot] = leftRoot;
|
||
}
|
||
};
|
||
const buckets = new Map<string, Array<{ x: number; y: number; index: number }>>();
|
||
const cellSize = Math.max(tolerance, 0.1);
|
||
const toleranceSquared = tolerance * tolerance;
|
||
const cellKey = (x: number, y: number) => `${x},${y}`;
|
||
|
||
segments.forEach((segment, index) => {
|
||
[segment.a, segment.b].forEach((point) => {
|
||
if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) {
|
||
return;
|
||
}
|
||
|
||
const cellX = Math.floor(point.x / cellSize);
|
||
const cellY = Math.floor(point.y / cellSize);
|
||
for (let dx = -1; dx <= 1; dx += 1) {
|
||
for (let dy = -1; dy <= 1; dy += 1) {
|
||
const candidates = buckets.get(cellKey(cellX + dx, cellY + dy));
|
||
candidates?.forEach((candidate) => {
|
||
const distanceSquared = (candidate.x - point.x) ** 2 + (candidate.y - point.y) ** 2;
|
||
if (distanceSquared <= toleranceSquared) {
|
||
union(index, candidate.index);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
const key = cellKey(cellX, cellY);
|
||
const bucket = buckets.get(key) ?? [];
|
||
bucket.push({ x: point.x, y: point.y, index });
|
||
buckets.set(key, bucket);
|
||
});
|
||
});
|
||
|
||
const groups = new Map<number, PlaneSegment[]>();
|
||
segments.forEach((segment, index) => {
|
||
const root = find(index);
|
||
const group = groups.get(root) ?? [];
|
||
group.push(segment);
|
||
groups.set(root, group);
|
||
});
|
||
|
||
return [...groups.values()].sort((left, right) => right.length - left.length);
|
||
}
|
||
|
||
function closeSmallMaskGaps(
|
||
maskData: ImageData,
|
||
width: number,
|
||
height: number,
|
||
rgb: { r: number; g: number; b: number },
|
||
alpha: number,
|
||
maxGap = 2,
|
||
) {
|
||
const toFill = new Set<number>();
|
||
const hasPixel = (x: number, y: number) => maskData.data[(y * width + x) * 4 + 3] > 0;
|
||
const mark = (x: number, y: number) => {
|
||
if (x >= 0 && x < width && y >= 0 && y < height && !hasPixel(x, y)) {
|
||
toFill.add(y * width + x);
|
||
}
|
||
};
|
||
|
||
for (let y = 0; y < height; y += 1) {
|
||
let lastFilled = -1;
|
||
for (let x = 0; x < width; x += 1) {
|
||
if (!hasPixel(x, y)) {
|
||
continue;
|
||
}
|
||
const gap = x - lastFilled - 1;
|
||
if (lastFilled >= 0 && gap > 0 && gap <= maxGap) {
|
||
for (let fillX = lastFilled + 1; fillX < x; fillX += 1) {
|
||
mark(fillX, y);
|
||
}
|
||
}
|
||
lastFilled = x;
|
||
}
|
||
}
|
||
|
||
for (let x = 0; x < width; x += 1) {
|
||
let lastFilled = -1;
|
||
for (let y = 0; y < height; y += 1) {
|
||
if (!hasPixel(x, y)) {
|
||
continue;
|
||
}
|
||
const gap = y - lastFilled - 1;
|
||
if (lastFilled >= 0 && gap > 0 && gap <= maxGap) {
|
||
for (let fillY = lastFilled + 1; fillY < y; fillY += 1) {
|
||
mark(x, fillY);
|
||
}
|
||
}
|
||
lastFilled = y;
|
||
}
|
||
}
|
||
|
||
toFill.forEach((index) => {
|
||
const offset = index * 4;
|
||
maskData.data[offset] = rgb.r;
|
||
maskData.data[offset + 1] = rgb.g;
|
||
maskData.data[offset + 2] = rgb.b;
|
||
maskData.data[offset + 3] = alpha;
|
||
});
|
||
|
||
return toFill.size;
|
||
}
|
||
|
||
function solidStrokeRadius(width: number, height: number) {
|
||
return Math.max(2.2, Math.min(5.5, Math.max(width, height) * 0.006));
|
||
}
|
||
|
||
function paintMaskPixel(
|
||
maskData: ImageData,
|
||
width: number,
|
||
height: number,
|
||
x: number,
|
||
y: number,
|
||
rgb: { r: number; g: number; b: number },
|
||
alpha: number,
|
||
) {
|
||
if (x < 0 || x >= width || y < 0 || y >= height) {
|
||
return 0;
|
||
}
|
||
|
||
const offset = (y * width + x) * 4;
|
||
if (maskData.data[offset + 3] > 0) {
|
||
return 0;
|
||
}
|
||
|
||
maskData.data[offset] = rgb.r;
|
||
maskData.data[offset + 1] = rgb.g;
|
||
maskData.data[offset + 2] = rgb.b;
|
||
maskData.data[offset + 3] = alpha;
|
||
return 1;
|
||
}
|
||
|
||
function fillSegmentCapsulesIntoMask(
|
||
maskData: ImageData,
|
||
width: number,
|
||
height: number,
|
||
segments: PlaneSegment[],
|
||
rgb: { r: number; g: number; b: number },
|
||
alpha: number,
|
||
radius: number,
|
||
) {
|
||
let paintedPixels = 0;
|
||
const radiusSquared = radius * radius;
|
||
|
||
segments.forEach(({ a, b }) => {
|
||
if (!Number.isFinite(a.x) || !Number.isFinite(a.y) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) {
|
||
return;
|
||
}
|
||
|
||
const dx = b.x - a.x;
|
||
const dy = b.y - a.y;
|
||
const lengthSquared = dx * dx + dy * dy;
|
||
const minX = clamp(Math.floor(Math.min(a.x, b.x) - radius), 0, width - 1);
|
||
const maxX = clamp(Math.ceil(Math.max(a.x, b.x) + radius), 0, width - 1);
|
||
const minY = clamp(Math.floor(Math.min(a.y, b.y) - radius), 0, height - 1);
|
||
const maxY = clamp(Math.ceil(Math.max(a.y, b.y) + radius), 0, height - 1);
|
||
|
||
for (let y = minY; y <= maxY; y += 1) {
|
||
for (let x = minX; x <= maxX; x += 1) {
|
||
const px = x + 0.5;
|
||
const py = y + 0.5;
|
||
const t = lengthSquared <= 1e-6
|
||
? 0
|
||
: clamp(((px - a.x) * dx + (py - a.y) * dy) / lengthSquared, 0, 1);
|
||
const closestX = a.x + dx * t;
|
||
const closestY = a.y + dy * t;
|
||
const distanceSquared = (px - closestX) ** 2 + (py - closestY) ** 2;
|
||
if (distanceSquared <= radiusSquared) {
|
||
paintedPixels += paintMaskPixel(maskData, width, height, x, y, rgb, alpha);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
return paintedPixels;
|
||
}
|
||
|
||
function drawFallbackClosedRegion(
|
||
context: CanvasRenderingContext2D,
|
||
width: number,
|
||
height: number,
|
||
segments: PlaneSegment[],
|
||
color: string,
|
||
opacity: number,
|
||
) {
|
||
const points = segments.flatMap((segment) => [segment.a, segment.b])
|
||
.filter((point) => (
|
||
Number.isFinite(point.x)
|
||
&& Number.isFinite(point.y)
|
||
&& point.x >= -width
|
||
&& point.x <= width * 2
|
||
&& point.y >= -height
|
||
&& point.y <= height * 2
|
||
));
|
||
|
||
if (points.length < 3) {
|
||
return 0;
|
||
}
|
||
|
||
const uniquePoints: Point2D[] = [];
|
||
points.forEach((point) => {
|
||
if (!uniquePoints.some((current) => pointDistanceSquared(current, point) < 1e-6)) {
|
||
uniquePoints.push(point);
|
||
}
|
||
});
|
||
if (uniquePoints.length < 3) {
|
||
return 0;
|
||
}
|
||
|
||
const sorted = [...uniquePoints].sort((left, right) => (
|
||
Math.abs(left.x - right.x) > 1e-6 ? left.x - right.x : left.y - right.y
|
||
));
|
||
const cross = (origin: Point2D, a: Point2D, b: Point2D) => (
|
||
(a.x - origin.x) * (b.y - origin.y) - (a.y - origin.y) * (b.x - origin.x)
|
||
);
|
||
const lower: Point2D[] = [];
|
||
sorted.forEach((point) => {
|
||
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], point) <= 0) {
|
||
lower.pop();
|
||
}
|
||
lower.push(point);
|
||
});
|
||
const upper: Point2D[] = [];
|
||
[...sorted].reverse().forEach((point) => {
|
||
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], point) <= 0) {
|
||
upper.pop();
|
||
}
|
||
upper.push(point);
|
||
});
|
||
const hull = [...lower.slice(0, -1), ...upper.slice(0, -1)];
|
||
const ordered = hull.length >= 3 ? hull : uniquePoints;
|
||
|
||
context.save();
|
||
context.globalAlpha = clamp(opacity, 0.1, 1) * 0.62;
|
||
context.fillStyle = color;
|
||
context.beginPath();
|
||
ordered.forEach((point, index) => {
|
||
if (index === 0) {
|
||
context.moveTo(point.x, point.y);
|
||
return;
|
||
}
|
||
context.lineTo(point.x, point.y);
|
||
});
|
||
context.closePath();
|
||
context.fill();
|
||
context.restore();
|
||
|
||
return Math.max(1, Math.round(ordered.length / 2));
|
||
}
|
||
|
||
function fillSegmentsAsSolidMask(
|
||
context: CanvasRenderingContext2D,
|
||
width: number,
|
||
height: number,
|
||
segments: PlaneSegment[],
|
||
color: string,
|
||
opacity: number,
|
||
) {
|
||
if (!segments.length) {
|
||
return 0;
|
||
}
|
||
|
||
const rgb = parseHexColor(color);
|
||
const alpha = Math.round(clamp(opacity, 0.1, 1) * 190);
|
||
const maskCanvas = document.createElement('canvas');
|
||
maskCanvas.width = width;
|
||
maskCanvas.height = height;
|
||
const maskContext = maskCanvas.getContext('2d');
|
||
if (!maskContext) {
|
||
return 0;
|
||
}
|
||
const maskData = maskContext.createImageData(width, height);
|
||
let filledPixels = 0;
|
||
const radius = solidStrokeRadius(width, height);
|
||
const groups = groupPlaneSegmentsByConnectivity(segments, radius * 1.15);
|
||
const fallbackGroups: PlaneSegment[][] = [];
|
||
|
||
groups.forEach((group) => {
|
||
const rows: number[][] = Array.from({ length: height }, () => []);
|
||
group.forEach((segment) => addSegmentIntersectionsToRows(rows, width, height, segment));
|
||
let groupPixels = 0;
|
||
|
||
rows.forEach((intersections, row) => {
|
||
if (intersections.length < 2) {
|
||
return;
|
||
}
|
||
|
||
intersections.sort((left, right) => left - right);
|
||
const cleaned: number[] = [];
|
||
intersections.forEach((x) => {
|
||
const previous = cleaned[cleaned.length - 1];
|
||
if (previous === undefined || Math.abs(previous - x) > 0.35) {
|
||
cleaned.push(x);
|
||
}
|
||
});
|
||
|
||
for (let index = 0; index + 1 < cleaned.length; index += 2) {
|
||
const rawStartX = cleaned[index];
|
||
const rawEndX = cleaned[index + 1];
|
||
if (rawEndX < 0 || rawStartX > width - 1) {
|
||
continue;
|
||
}
|
||
|
||
const startX = clamp(Math.ceil(rawStartX), 0, width - 1);
|
||
const endX = clamp(Math.floor(rawEndX), 0, width - 1);
|
||
if (endX < startX) {
|
||
continue;
|
||
}
|
||
|
||
for (let x = startX; x <= endX; x += 1) {
|
||
const offset = (row * width + x) * 4;
|
||
if (maskData.data[offset + 3] === 0) {
|
||
maskData.data[offset] = rgb.r;
|
||
maskData.data[offset + 1] = rgb.g;
|
||
maskData.data[offset + 2] = rgb.b;
|
||
maskData.data[offset + 3] = alpha;
|
||
groupPixels += 1;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
groupPixels += fillSegmentCapsulesIntoMask(maskData, width, height, group, rgb, alpha, radius);
|
||
|
||
filledPixels += groupPixels;
|
||
if (groupPixels < Math.max(20, Math.round(group.length * 0.5)) && group.length >= 3) {
|
||
fallbackGroups.push(group);
|
||
}
|
||
});
|
||
|
||
filledPixels += closeSmallMaskGaps(maskData, width, height, rgb, alpha, 3);
|
||
filledPixels += fillInternalMaskHoles(maskData, width, height, rgb, alpha);
|
||
maskContext.putImageData(maskData, 0, 0);
|
||
context.drawImage(maskCanvas, 0, 0);
|
||
fallbackGroups.forEach((group) => {
|
||
filledPixels += drawFallbackClosedRegion(context, width, height, group, color, opacity);
|
||
});
|
||
|
||
if (filledPixels === 0) {
|
||
context.save();
|
||
context.globalAlpha = clamp(opacity, 0.1, 1) * 0.42;
|
||
context.strokeStyle = color;
|
||
context.lineWidth = Math.max(0.8, Math.max(width, height) * 0.0012);
|
||
context.lineCap = 'round';
|
||
context.lineJoin = 'round';
|
||
context.beginPath();
|
||
segments.forEach((segment) => {
|
||
context.moveTo(segment.a.x, segment.a.y);
|
||
context.lineTo(segment.b.x, segment.b.y);
|
||
});
|
||
context.stroke();
|
||
context.restore();
|
||
}
|
||
|
||
return filledPixels;
|
||
}
|
||
|
||
function drawDicomBaseLayer(canvas: HTMLCanvasElement, preview: DicomPreview) {
|
||
const fovCanvas = getFovCanvasSize(preview);
|
||
canvas.width = fovCanvas.width;
|
||
canvas.height = fovCanvas.height;
|
||
const context = canvas.getContext('2d');
|
||
if (!context) {
|
||
return;
|
||
}
|
||
|
||
const binary = atob(preview.pixels);
|
||
const imageData = context.createImageData(fovCanvas.width, fovCanvas.height);
|
||
for (let y = 0; y < fovCanvas.height; y += 1) {
|
||
const sourceY = Math.min(preview.height - 1, Math.floor((y / fovCanvas.height) * preview.height));
|
||
for (let x = 0; x < fovCanvas.width; x += 1) {
|
||
const sourceX = Math.min(preview.width - 1, Math.floor((x / fovCanvas.width) * preview.width));
|
||
const value = binary.charCodeAt(sourceY * preview.width + sourceX);
|
||
const offset = (y * fovCanvas.width + x) * 4;
|
||
imageData.data[offset] = value;
|
||
imageData.data[offset + 1] = value;
|
||
imageData.data[offset + 2] = value;
|
||
imageData.data[offset + 3] = 255;
|
||
}
|
||
}
|
||
context.putImageData(imageData, 0, 0);
|
||
}
|
||
|
||
function drawVoxelOverlayLayer(
|
||
canvas: HTMLCanvasElement,
|
||
preview: DicomPreview,
|
||
files: string[],
|
||
previews: Record<string, ModelPreviewPayload>,
|
||
metricFiles: string[],
|
||
metricPreviews: Record<string, ModelPreviewPayload>,
|
||
moduleStyles: Record<string, ModuleStyle>,
|
||
modelPose: ModelPose,
|
||
slice: number,
|
||
totalSlices: number,
|
||
): OverlayStats {
|
||
const fovCanvas = getFovCanvasSize(preview);
|
||
canvas.width = fovCanvas.width;
|
||
canvas.height = fovCanvas.height;
|
||
const context = canvas.getContext('2d');
|
||
if (!context) {
|
||
return { activeModules: 0, filledPixels: 0, segmentCount: 0, modules: [] };
|
||
}
|
||
|
||
context.clearRect(0, 0, fovCanvas.width, fovCanvas.height);
|
||
const metrics = getModelSceneMetrics(metricFiles, metricPreviews, preview, totalSlices)
|
||
?? getModelSceneMetrics(files, previews, preview, totalSlices);
|
||
if (!metrics) {
|
||
return { activeModules: 0, filledPixels: 0, segmentCount: 0, modules: [] };
|
||
}
|
||
|
||
const safeSlice = clamp(slice, 0, Math.max(totalSlices - 1, 0));
|
||
const targetZ = totalSlices <= 1
|
||
? 0
|
||
: -metrics.dicomDepth / 2 + (metrics.dicomDepth * safeSlice) / (totalSlices - 1);
|
||
const mapPoint = (point: Point2D): Point2D => ({
|
||
x: ((point.x + metrics.dicomWidth / 2) / metrics.dicomWidth) * fovCanvas.width,
|
||
y: fovCanvas.height - ((point.y + metrics.dicomHeight / 2) / metrics.dicomHeight) * fovCanvas.height,
|
||
});
|
||
let activeModules = 0;
|
||
let filledPixels = 0;
|
||
let segmentCount = 0;
|
||
const modules: OverlayStats['modules'] = [];
|
||
|
||
files.forEach((fileName, index) => {
|
||
const payload = previews[fileName];
|
||
const style = moduleStyles[fileName] ?? {
|
||
visible: true,
|
||
color: moduleColors[index % moduleColors.length],
|
||
opacity: 0.72,
|
||
partId: index + 1,
|
||
};
|
||
|
||
if (!payload || style.visible === false) {
|
||
return;
|
||
}
|
||
|
||
const segments: PlaneSegment[] = [];
|
||
|
||
for (let vertexIndex = 0; vertexIndex < payload.vertices.length; vertexIndex += 9) {
|
||
const a = transformPointForPose(
|
||
payload.vertices[vertexIndex],
|
||
payload.vertices[vertexIndex + 1],
|
||
payload.vertices[vertexIndex + 2],
|
||
metrics,
|
||
modelPose,
|
||
);
|
||
const b = transformPointForPose(
|
||
payload.vertices[vertexIndex + 3],
|
||
payload.vertices[vertexIndex + 4],
|
||
payload.vertices[vertexIndex + 5],
|
||
metrics,
|
||
modelPose,
|
||
);
|
||
const c = transformPointForPose(
|
||
payload.vertices[vertexIndex + 6],
|
||
payload.vertices[vertexIndex + 7],
|
||
payload.vertices[vertexIndex + 8],
|
||
metrics,
|
||
modelPose,
|
||
);
|
||
const segment = intersectTriangleWithPlane(a, b, c, targetZ);
|
||
|
||
if (segment) {
|
||
segments.push({
|
||
a: mapPoint(segment.a),
|
||
b: mapPoint(segment.b),
|
||
});
|
||
}
|
||
}
|
||
|
||
const modulePixels = fillSegmentsAsSolidMask(context, fovCanvas.width, fovCanvas.height, segments, style.color, style.opacity);
|
||
if (segments.length > 0 || modulePixels > 0) {
|
||
activeModules += 1;
|
||
modules.push({
|
||
fileName,
|
||
name: fileName.replace(/\.stl$/i, ''),
|
||
color: style.color,
|
||
opacity: style.opacity,
|
||
partId: style.partId,
|
||
segmentCount: segments.length,
|
||
filledPixels: modulePixels,
|
||
});
|
||
}
|
||
filledPixels += modulePixels;
|
||
segmentCount += segments.length;
|
||
});
|
||
|
||
return { activeModules, filledPixels, segmentCount, modules };
|
||
}
|
||
|
||
export function VoxelizationMappingView({
|
||
project,
|
||
moduleStyles,
|
||
modelPose,
|
||
detailLimit,
|
||
slice,
|
||
totalSlices,
|
||
onSliceChange,
|
||
displayMode,
|
||
rotation,
|
||
variant = 'workspace',
|
||
toolbar,
|
||
overlayPlacement,
|
||
onOverlayStatsChange,
|
||
}: {
|
||
project: Project | null;
|
||
moduleStyles: Record<string, ModuleStyle>;
|
||
modelPose: ModelPose;
|
||
detailLimit: number;
|
||
slice: number;
|
||
totalSlices: number;
|
||
onSliceChange: (slice: number) => void;
|
||
displayMode: MappingDisplayMode;
|
||
rotation: number;
|
||
variant?: 'workspace' | 'library';
|
||
toolbar?: React.ReactNode;
|
||
overlayPlacement?: 'bottom' | 'side' | 'none';
|
||
onOverlayStatsChange?: (stats: OverlayStats, visibleModuleCount: number) => void;
|
||
}) {
|
||
const baseCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||
const overlayCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||
const mappingViewportRef = useRef<HTMLDivElement | null>(null);
|
||
const [dicomPreview, setDicomPreview] = useState<DicomPreview | null>(null);
|
||
const [modelPreviews, setModelPreviews] = useState<Record<string, ModelPreviewPayload>>({});
|
||
const [metricPreviews, setMetricPreviews] = useState<Record<string, ModelPreviewPayload>>({});
|
||
const [metricPreviewsLoaded, setMetricPreviewsLoaded] = useState(false);
|
||
const [dicomStatus, setDicomStatus] = useState('等待 DICOM 切片');
|
||
const [overlayStatus, setOverlayStatus] = useState('等待 STL 映射');
|
||
const [overlayStats, setOverlayStats] = useState<OverlayStats>({ activeModules: 0, filledPixels: 0, segmentCount: 0, modules: [] });
|
||
const [overlayLoadState, setOverlayLoadState] = useState({ loading: false, loaded: 0, total: 0, phase: '' });
|
||
const [mappingViewport, setMappingViewport] = useState({ scale: 1, offsetX: 0, offsetY: 0 });
|
||
const mappingPanRef = useRef({
|
||
active: false,
|
||
pointerId: 0,
|
||
startX: 0,
|
||
startY: 0,
|
||
offsetX: 0,
|
||
offsetY: 0,
|
||
});
|
||
const maxSlice = Math.max(totalSlices - 1, 0);
|
||
const safeSlice = clamp(slice, 0, maxSlice);
|
||
const stlFiles = project?.stlFiles ?? [];
|
||
const stlFileSignature = stlFiles.join('|');
|
||
const visibleStlFiles = stlFiles.filter((fileName) => moduleStyles[fileName]?.visible !== false);
|
||
const visibleStlFileSignature = visibleStlFiles.join('|');
|
||
const visibleModuleCount = visibleStlFiles.length;
|
||
const metricPreviewsReady = !stlFiles.length || metricPreviewsLoaded;
|
||
const isLibraryVariant = variant === 'library';
|
||
const activeOverlayPlacement = overlayPlacement ?? (isLibraryVariant ? 'side' : 'bottom');
|
||
|
||
useEffect(() => {
|
||
onOverlayStatsChange?.(overlayStats, visibleModuleCount);
|
||
}, [onOverlayStatsChange, overlayStats, visibleModuleCount]);
|
||
|
||
useEffect(() => {
|
||
if (!project?.dicomCount) {
|
||
setDicomPreview(null);
|
||
setDicomStatus('没有可显示的 DICOM 切片');
|
||
return;
|
||
}
|
||
|
||
let disposed = false;
|
||
setDicomStatus('正在载入 DICOM Base Layer...');
|
||
getCachedDicomPreview(project.id, safeSlice, 'axial', displayMode)
|
||
.then((preview) => {
|
||
if (disposed) return;
|
||
setDicomPreview(preview);
|
||
setDicomStatus('DICOM Base Layer 已就绪');
|
||
})
|
||
.catch((error) => {
|
||
if (disposed) return;
|
||
setDicomPreview(null);
|
||
setDicomStatus(error instanceof Error ? error.message : 'DICOM 切片载入失败');
|
||
});
|
||
|
||
return () => {
|
||
disposed = true;
|
||
};
|
||
}, [project?.id, project?.dicomCount, safeSlice, displayMode]);
|
||
|
||
useEffect(() => {
|
||
if (!project || !stlFiles.length) {
|
||
setMetricPreviews({});
|
||
setMetricPreviewsLoaded(true);
|
||
return;
|
||
}
|
||
|
||
let disposed = false;
|
||
setMetricPreviews({});
|
||
setMetricPreviewsLoaded(false);
|
||
Promise.allSettled(stlFiles.map((fileName) => (
|
||
getCachedModelPreview(project.id, fileName, 1000)
|
||
.then((payload) => ({ fileName, payload }))
|
||
))).then((results) => {
|
||
if (disposed) return;
|
||
const nextPreviews: Record<string, ModelPreviewPayload> = {};
|
||
results.forEach((result) => {
|
||
if (result.status === 'fulfilled') {
|
||
nextPreviews[result.value.fileName] = result.value.payload;
|
||
}
|
||
});
|
||
setMetricPreviews(nextPreviews);
|
||
setMetricPreviewsLoaded(true);
|
||
});
|
||
|
||
return () => {
|
||
disposed = true;
|
||
};
|
||
}, [project?.id, stlFileSignature]);
|
||
|
||
useEffect(() => {
|
||
if (!project || !visibleStlFiles.length) {
|
||
setModelPreviews({});
|
||
setOverlayStats({ activeModules: 0, filledPixels: 0, segmentCount: 0, modules: [] });
|
||
setOverlayLoadState({ loading: false, loaded: 0, total: 0, phase: '' });
|
||
setOverlayStatus(stlFiles.length ? '当前没有可见 STL 构件' : '当前项目没有 STL 构件');
|
||
return;
|
||
}
|
||
|
||
let disposed = false;
|
||
let loaded = 0;
|
||
const total = visibleStlFiles.length;
|
||
const previewLimit = Math.max(detailLimit, 800000);
|
||
const updateLoadProgress = (phase: string) => {
|
||
if (!disposed) {
|
||
setOverlayLoadState({ loading: true, loaded, total, phase });
|
||
}
|
||
};
|
||
|
||
setModelPreviews({});
|
||
setOverlayStats({ activeModules: 0, filledPixels: 0, segmentCount: 0, modules: [] });
|
||
setOverlayLoadState({ loading: true, loaded: 0, total, phase: '正在载入可见构件' });
|
||
setOverlayStatus('正在载入可见 STL 构件层级...');
|
||
Promise.allSettled(visibleStlFiles.map((fileName) => (
|
||
getCachedModelPreview(project.id, fileName, previewLimit)
|
||
.then((payload) => {
|
||
loaded += 1;
|
||
updateLoadProgress(fileName.replace(/\.stl$/i, ''));
|
||
return { fileName, payload };
|
||
})
|
||
.catch((error) => {
|
||
loaded += 1;
|
||
updateLoadProgress(`${fileName.replace(/\.stl$/i, '')} 载入失败`);
|
||
throw error;
|
||
})
|
||
))).then((results) => {
|
||
if (disposed) return;
|
||
const nextPreviews: Record<string, ModelPreviewPayload> = {};
|
||
results.forEach((result) => {
|
||
if (result.status === 'fulfilled') {
|
||
nextPreviews[result.value.fileName] = result.value.payload;
|
||
}
|
||
});
|
||
setModelPreviews(nextPreviews);
|
||
setOverlayLoadState({ loading: false, loaded: total, total, phase: '' });
|
||
setOverlayStatus(Object.keys(nextPreviews).length ? 'Overlay Label Map 已就绪' : 'Overlay Layer 无可用 STL 数据');
|
||
});
|
||
|
||
return () => {
|
||
disposed = true;
|
||
};
|
||
}, [project?.id, stlFileSignature, visibleStlFileSignature, detailLimit]);
|
||
|
||
useEffect(() => {
|
||
const canvas = baseCanvasRef.current;
|
||
if (!canvas || !dicomPreview) {
|
||
return;
|
||
}
|
||
drawDicomBaseLayer(canvas, dicomPreview);
|
||
}, [dicomPreview]);
|
||
|
||
useEffect(() => {
|
||
const canvas = overlayCanvasRef.current;
|
||
if (!canvas || !dicomPreview) {
|
||
return;
|
||
}
|
||
if (!metricPreviewsReady) {
|
||
const context = canvas.getContext('2d');
|
||
context?.clearRect(0, 0, canvas.width, canvas.height);
|
||
setOverlayStats({ activeModules: 0, filledPixels: 0, segmentCount: 0, modules: [] });
|
||
setOverlayStatus('正在计算全局模型边界...');
|
||
return;
|
||
}
|
||
const frame = window.requestAnimationFrame(() => {
|
||
const stats = drawVoxelOverlayLayer(
|
||
canvas,
|
||
dicomPreview,
|
||
visibleStlFiles,
|
||
modelPreviews,
|
||
stlFiles,
|
||
metricPreviews,
|
||
moduleStyles,
|
||
modelPose,
|
||
safeSlice,
|
||
Math.max(totalSlices, 1),
|
||
);
|
||
setOverlayStats(stats);
|
||
});
|
||
|
||
return () => window.cancelAnimationFrame(frame);
|
||
}, [
|
||
dicomPreview,
|
||
stlFileSignature,
|
||
visibleStlFileSignature,
|
||
modelPreviews,
|
||
metricPreviews,
|
||
metricPreviewsReady,
|
||
JSON.stringify(moduleStyles),
|
||
modelPose.rotateX,
|
||
modelPose.rotateY,
|
||
modelPose.rotateZ,
|
||
modelPose.translateX,
|
||
modelPose.translateY,
|
||
modelPose.translateZ,
|
||
modelPose.scale,
|
||
modelPose.flipX,
|
||
modelPose.flipY,
|
||
modelPose.flipZ,
|
||
safeSlice,
|
||
totalSlices,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
const element = mappingViewportRef.current;
|
||
if (!element) {
|
||
return;
|
||
}
|
||
|
||
const handleWheel = (event: WheelEvent) => {
|
||
event.preventDefault();
|
||
const scaleFactor = event.deltaY > 0 ? 0.9 : 1.1;
|
||
setMappingViewport((current) => ({
|
||
...current,
|
||
scale: clamp(current.scale * scaleFactor, 0.45, 6),
|
||
}));
|
||
};
|
||
|
||
element.addEventListener('wheel', handleWheel, { passive: false });
|
||
return () => {
|
||
element.removeEventListener('wheel', handleWheel);
|
||
};
|
||
}, [isLibraryVariant]);
|
||
|
||
const stepSlice = (delta: number) => {
|
||
onSliceChange(clamp(safeSlice + delta, 0, maxSlice));
|
||
};
|
||
const sliderSliceValue = safeSlice;
|
||
const displaySliceNumber = getDicomDisplaySliceNumber(safeSlice, Math.max(totalSlices, 1));
|
||
const resetMappingViewport = () => {
|
||
setMappingViewport({ scale: 1, offsetX: 0, offsetY: 0 });
|
||
};
|
||
const handleMappingPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||
if (event.button !== 0) {
|
||
return;
|
||
}
|
||
mappingPanRef.current = {
|
||
active: true,
|
||
pointerId: event.pointerId,
|
||
startX: event.clientX,
|
||
startY: event.clientY,
|
||
offsetX: mappingViewport.offsetX,
|
||
offsetY: mappingViewport.offsetY,
|
||
};
|
||
event.currentTarget.setPointerCapture(event.pointerId);
|
||
};
|
||
const handleMappingPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
||
const dragState = mappingPanRef.current;
|
||
if (!dragState.active || dragState.pointerId !== event.pointerId) {
|
||
return;
|
||
}
|
||
setMappingViewport((current) => ({
|
||
...current,
|
||
offsetX: dragState.offsetX + event.clientX - dragState.startX,
|
||
offsetY: dragState.offsetY + event.clientY - dragState.startY,
|
||
}));
|
||
};
|
||
const stopMappingPointerDrag = (event: React.PointerEvent<HTMLDivElement>) => {
|
||
const dragState = mappingPanRef.current;
|
||
if (!dragState.active || dragState.pointerId !== event.pointerId) {
|
||
return;
|
||
}
|
||
mappingPanRef.current = { ...dragState, active: false };
|
||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||
}
|
||
};
|
||
const renderOverlayLoadProgress = (tone: 'dark' | 'light') => {
|
||
if (!overlayLoadState.loading || !overlayLoadState.total) {
|
||
return null;
|
||
}
|
||
|
||
const percent = Math.round((overlayLoadState.loaded / overlayLoadState.total) * 100);
|
||
const isDark = tone === 'dark';
|
||
return (
|
||
<div className={`pointer-events-none absolute left-4 top-4 z-20 w-64 rounded-xl border px-3 py-2 shadow-lg backdrop-blur-md ${
|
||
isDark
|
||
? 'border-white/10 bg-black/70 text-white'
|
||
: 'border-slate-200 bg-white/90 text-slate-700'
|
||
}`}>
|
||
<div className="flex items-center justify-between gap-3 text-[10px] font-bold">
|
||
<span className={`min-w-0 truncate ${isDark ? 'text-cyan-100' : 'text-cyan-700'}`}>
|
||
构件层级加载中
|
||
</span>
|
||
<span className={`font-mono ${isDark ? 'text-white/70' : 'text-slate-500'}`}>
|
||
{overlayLoadState.loaded}/{overlayLoadState.total}
|
||
</span>
|
||
</div>
|
||
<div className={`mt-2 h-1.5 overflow-hidden rounded-full ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}>
|
||
<div
|
||
className="h-full rounded-full bg-cyan-400 transition-[width] duration-200"
|
||
style={{ width: `${percent}%` }}
|
||
/>
|
||
</div>
|
||
<div className={`mt-1 flex items-center justify-between gap-3 text-[9px] font-bold ${isDark ? 'text-white/45' : 'text-slate-400'}`}>
|
||
<span className="min-w-0 truncate">{overlayLoadState.phase || overlayStatus}</span>
|
||
<span className="font-mono">{percent}%</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
const renderOverlaySummary = (placement: 'bottom' | 'side') => (
|
||
<div className={`${placement === 'side' ? 'w-full rounded-2xl border border-white/10 bg-black/40 p-2' : 'border-t border-white/10 bg-[#030712] px-4 py-3'}`}>
|
||
<div className={`mb-2 flex gap-2 text-[10px] font-bold text-white/60 ${placement === 'side' ? 'flex-col' : 'items-center justify-between'}`}>
|
||
<span className="truncate">Overlay Label Map</span>
|
||
<span className="font-mono text-cyan-100">
|
||
{overlayStats.activeModules}/{visibleModuleCount} 构件 · {overlayStats.segmentCount} 边 · {overlayStats.filledPixels} px
|
||
</span>
|
||
</div>
|
||
<div className={`${placement === 'side' ? 'max-h-44' : 'max-h-24'} overflow-auto pr-1`}>
|
||
{overlayStats.modules.length ? (
|
||
<div className={`grid gap-1.5 ${placement === 'side' ? 'grid-cols-1' : 'grid-cols-2 xl:grid-cols-3'}`}>
|
||
{overlayStats.modules.map((item) => (
|
||
<div key={item.fileName} className="grid grid-cols-[10px_1fr_auto] items-center gap-1 rounded-md border border-white/10 bg-white/5 px-1.5 py-1 text-[8px] font-bold text-white/65">
|
||
<span className="h-2 w-2 rounded-sm border border-white/30" style={{ backgroundColor: item.color, opacity: item.opacity }} />
|
||
<span className="min-w-0 truncate">{item.name}</span>
|
||
<span className="font-mono text-cyan-100">ID {item.partId}</span>
|
||
<span className="col-start-2 font-mono text-white/35">{item.segmentCount} 边</span>
|
||
<span className="font-mono text-white/35">{item.filledPixels} px</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="rounded-lg border border-white/10 bg-white/5 px-2 py-1.5 text-[9px] font-bold text-white/35">
|
||
当前切片暂无可见构件
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
if (isLibraryVariant) {
|
||
return (
|
||
<div className="relative flex h-full min-h-[560px] flex-col overflow-hidden rounded-3xl border border-slate-800 bg-black shadow-xl">
|
||
<div className="flex items-center justify-between gap-3 border-b border-white/10 bg-[#030712] px-4 py-3">
|
||
<div className="min-w-0 rounded-xl border border-white/10 bg-black/60 px-3 py-2 text-[11px] font-bold text-white/70">
|
||
逆向分割映射视图
|
||
</div>
|
||
<div className="flex min-w-0 flex-wrap items-center justify-end gap-1.5">
|
||
{toolbar}
|
||
<button
|
||
onClick={resetMappingViewport}
|
||
className="flex h-8 shrink-0 items-center gap-1.5 rounded-xl border border-white/10 bg-black/60 px-3 text-[10px] font-bold text-white/70 shadow-lg hover:border-cyan-300/30 hover:text-cyan-100"
|
||
title="重置逆向分割映射视图位置"
|
||
>
|
||
<RefreshCcw size={13} />
|
||
位置重置
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={`grid min-h-0 flex-1 ${activeOverlayPlacement === 'side' ? 'grid-cols-[minmax(0,1fr)_188px]' : 'grid-cols-[minmax(0,1fr)_56px]'} bg-black`}>
|
||
<div
|
||
className="flex min-h-0 flex-col"
|
||
>
|
||
<div
|
||
ref={mappingViewportRef}
|
||
className={`relative min-h-0 flex-1 touch-none overflow-hidden bg-black ${mappingPanRef.current.active ? 'cursor-grabbing' : 'cursor-grab'}`}
|
||
onPointerDown={handleMappingPointerDown}
|
||
onPointerMove={handleMappingPointerMove}
|
||
onPointerUp={stopMappingPointerDrag}
|
||
onPointerCancel={stopMappingPointerDrag}
|
||
>
|
||
{dicomPreview ? (
|
||
<div
|
||
className="absolute inset-0 flex items-center justify-center"
|
||
style={{
|
||
transform: `translate3d(${mappingViewport.offsetX}px, ${mappingViewport.offsetY}px, 0) rotate(${rotation}deg) scale(${mappingViewport.scale})`,
|
||
transformOrigin: 'center center',
|
||
}}
|
||
>
|
||
<canvas ref={baseCanvasRef} className="absolute inset-0 h-full w-full object-contain" />
|
||
<canvas ref={overlayCanvasRef} className="absolute inset-0 h-full w-full object-contain" />
|
||
</div>
|
||
) : (
|
||
<div className="absolute inset-0 flex items-center justify-center px-8 text-center text-xs font-bold text-white/40">
|
||
{dicomStatus}
|
||
</div>
|
||
)}
|
||
{renderOverlayLoadProgress('dark')}
|
||
<div className="pointer-events-none absolute bottom-4 right-4 rounded-xl border border-white/10 bg-black/70 px-3 py-2 text-right shadow-lg">
|
||
<p className="text-[9px] font-bold text-white/45">DICOM 切片位置</p>
|
||
<p className="mt-1 font-mono text-[12px] font-bold text-cyan-100">
|
||
{displaySliceNumber} / {Math.max(totalSlices, 1)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
{activeOverlayPlacement === 'bottom' && renderOverlaySummary('bottom')}
|
||
</div>
|
||
|
||
<aside className="flex min-h-0 flex-col items-center gap-3 border-l border-white/10 bg-[#0f172a] px-2 py-5">
|
||
<div className="relative min-h-[220px] w-8 flex-1">
|
||
<div className="absolute inset-y-0 left-1/2 w-1.5 -translate-x-1/2 rounded-full bg-white/10" />
|
||
<input
|
||
type="range"
|
||
min="0"
|
||
max={maxSlice}
|
||
value={sliderSliceValue}
|
||
onChange={(event) => onSliceChange(Number(event.target.value))}
|
||
className="mapping-slice-dark-vertical-input"
|
||
aria-label="项目库逆向分割映射视图切片导航"
|
||
/>
|
||
</div>
|
||
{activeOverlayPlacement === 'side' && renderOverlaySummary('side')}
|
||
</aside>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="relative flex h-full min-h-[520px] flex-col overflow-hidden rounded-3xl border border-slate-100 bg-white shadow-sm">
|
||
<div className="flex items-center justify-between gap-3 border-b border-slate-100 bg-white px-4 py-3">
|
||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||
<span className="rounded-lg border border-slate-200 bg-slate-50 px-2.5 py-1 text-[9px] font-bold uppercase tracking-widest text-slate-600">
|
||
Base DICOM
|
||
</span>
|
||
<span className="rounded-lg border border-cyan-200 bg-cyan-50 px-2.5 py-1 text-[9px] font-bold uppercase tracking-widest text-cyan-700">
|
||
Overlay Label Map
|
||
</span>
|
||
<span className="rounded-lg bg-slate-100 px-2.5 py-1 text-[9px] font-mono font-bold text-slate-500">
|
||
Z {displaySliceNumber}/{Math.max(totalSlices, 1)}
|
||
</span>
|
||
</div>
|
||
<button
|
||
onClick={resetMappingViewport}
|
||
className="flex h-8 shrink-0 items-center gap-1.5 rounded-xl border border-slate-200 bg-white px-3 text-[10px] font-bold text-slate-600 shadow-sm hover:border-cyan-200 hover:bg-cyan-50 hover:text-cyan-700"
|
||
title="重置逆向分割映射视图位置"
|
||
>
|
||
<RefreshCcw size={13} />
|
||
位置重置
|
||
</button>
|
||
</div>
|
||
|
||
<div className="grid min-h-0 flex-1 grid-cols-[minmax(0,1fr)_110px]">
|
||
<div className="flex min-h-0 flex-col">
|
||
<div
|
||
ref={mappingViewportRef}
|
||
className={`relative min-h-0 flex-1 touch-none overflow-hidden bg-black ${mappingPanRef.current.active ? 'cursor-grabbing' : 'cursor-grab'}`}
|
||
onPointerDown={handleMappingPointerDown}
|
||
onPointerMove={handleMappingPointerMove}
|
||
onPointerUp={stopMappingPointerDrag}
|
||
onPointerCancel={stopMappingPointerDrag}
|
||
>
|
||
{dicomPreview ? (
|
||
<div
|
||
className="absolute inset-0 flex items-center justify-center"
|
||
style={{
|
||
transform: `translate3d(${mappingViewport.offsetX}px, ${mappingViewport.offsetY}px, 0) rotate(${rotation}deg) scale(${mappingViewport.scale})`,
|
||
transformOrigin: 'center center',
|
||
}}
|
||
>
|
||
<canvas ref={baseCanvasRef} className="absolute inset-0 h-full w-full object-contain" />
|
||
<canvas ref={overlayCanvasRef} className="absolute inset-0 h-full w-full object-contain" />
|
||
</div>
|
||
) : (
|
||
<div className="absolute inset-0 flex items-center justify-center px-8 text-center text-xs font-bold text-white/40">
|
||
{dicomStatus}
|
||
</div>
|
||
)}
|
||
{renderOverlayLoadProgress('dark')}
|
||
</div>
|
||
|
||
<div className="border-t border-slate-100 bg-white px-4 py-3">
|
||
<div className="mb-2 flex items-center justify-between gap-3 text-[10px] font-bold text-slate-600">
|
||
<span className="truncate">Overlay Label Map</span>
|
||
<span className="font-mono text-cyan-700">
|
||
{overlayStats.activeModules}/{visibleModuleCount} 构件 · {overlayStats.segmentCount} 边 · {overlayStats.filledPixels} px
|
||
</span>
|
||
</div>
|
||
<div className="max-h-24 overflow-auto pr-1">
|
||
{overlayStats.modules.length ? (
|
||
<div className="grid grid-cols-2 gap-1.5 xl:grid-cols-3">
|
||
{overlayStats.modules.map((item) => (
|
||
<div key={item.fileName} className="grid grid-cols-[10px_1fr_auto] items-center gap-1 rounded-md border border-slate-100 bg-slate-50 px-1.5 py-1 text-[8px] font-bold text-slate-600">
|
||
<span className="h-2 w-2 rounded-sm border border-white" style={{ backgroundColor: item.color, opacity: item.opacity }} />
|
||
<span className="min-w-0 truncate">{item.name}</span>
|
||
<span className="font-mono text-cyan-700">ID {item.partId}</span>
|
||
<span className="col-start-2 font-mono text-slate-400">{item.segmentCount} 边</span>
|
||
<span className="font-mono text-slate-400">{item.filledPixels} px</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="rounded-lg border border-slate-100 bg-slate-50 px-2 py-1.5 text-[9px] font-bold text-slate-400">
|
||
当前切片暂无可见构件
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<aside className="flex min-h-0 flex-col items-center gap-3 border-l border-slate-100 bg-white px-3 py-4">
|
||
<div className="w-full rounded-2xl border border-slate-100 bg-white px-2 py-3 text-center shadow-sm">
|
||
<p className="text-[10px] font-bold text-slate-700">DICOM 切片位置</p>
|
||
<span className="mt-1 block font-mono text-[10px] font-bold text-blue-600">
|
||
{displaySliceNumber} / {Math.max(totalSlices, 1)}
|
||
</span>
|
||
</div>
|
||
<button
|
||
onClick={() => stepSlice(1)}
|
||
disabled={safeSlice >= maxSlice}
|
||
className="flex h-8 w-8 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 shadow-sm hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 disabled:opacity-35"
|
||
title="上一层"
|
||
>
|
||
<ChevronUp size={16} />
|
||
</button>
|
||
<div className="relative min-h-[240px] w-10 flex-1">
|
||
<div className="absolute inset-y-0 left-1/2 w-2 -translate-x-1/2 rounded-full bg-slate-200" />
|
||
<input
|
||
type="range"
|
||
min="0"
|
||
max={maxSlice}
|
||
value={sliderSliceValue}
|
||
onChange={(event) => onSliceChange(Number(event.target.value))}
|
||
className="mapping-slice-vertical-input"
|
||
aria-label="逆向分割映射视图切片导航"
|
||
/>
|
||
</div>
|
||
<button
|
||
onClick={() => stepSlice(-1)}
|
||
disabled={safeSlice <= 0}
|
||
className="flex h-8 w-8 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 shadow-sm hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 disabled:opacity-35"
|
||
title="下一层"
|
||
>
|
||
<ChevronDown size={16} />
|
||
</button>
|
||
<div className="grid w-full grid-cols-1 gap-1 text-center text-[9px] font-bold text-slate-500">
|
||
<span>顶层 1</span>
|
||
<span className="text-blue-600">当前 {displaySliceNumber}</span>
|
||
<span>底层 {Math.max(totalSlices, 1)}</span>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function ReverseWorkspace({
|
||
projectId,
|
||
onLeaveGuardChange,
|
||
}: {
|
||
projectId: string;
|
||
onLeaveGuardChange?: (handler: WorkspaceLeaveGuard | null) => void;
|
||
}) {
|
||
const [sliceStart, setSliceStart] = useState(0);
|
||
const [sliceEnd, setSliceEnd] = useState(49);
|
||
const [mappingSlice, setMappingSlice] = useState(0);
|
||
const [modelPose, setModelPose] = useState<ModelPose>(defaultModelPose);
|
||
const [poseValueDrafts, setPoseValueDrafts] = useState<PoseDraftValues>(() => formatPoseDraftValues(defaultModelPose));
|
||
const [focusedPoseInput, setFocusedPoseInput] = useState<ModelPoseKey | null>(null);
|
||
const [poseImportStatus, setPoseImportStatus] = useState('');
|
||
const [displayLevel, setDisplayLevel] = useState<DisplayLevel>('standard');
|
||
const [dicomOpacityLevel, setDicomOpacityLevel] = useState<DicomOpacityLevel>('low');
|
||
const [mappingDisplayMode, setMappingDisplayMode] = useState<MappingDisplayMode>('soft');
|
||
const [mappingRotation, setMappingRotation] = useState(0);
|
||
const [showBounds, setShowBounds] = useState(true);
|
||
const [cutEnabled, setCutEnabled] = useState(false);
|
||
const [moduleStyles, setModuleStyles] = useState<Record<string, ModuleStyle>>({});
|
||
const [savedPoses, setSavedPoses] = useState<SavedModelPose[]>(defaultSavedPoses);
|
||
const [selectedPoseId, setSelectedPoseId] = useState('default');
|
||
const [showExportMenu, setShowExportMenu] = useState(false);
|
||
const [exportSelection, setExportSelection] = useState<Record<ProjectExportTarget, boolean>>({
|
||
dicom: false,
|
||
segmentation: true,
|
||
pose: true,
|
||
stl: false,
|
||
});
|
||
const [segmentationExportScope, setSegmentationExportScope] = useState<SegmentationExportScope>('visible');
|
||
const [segmentationExportMode, setSegmentationExportMode] = useState<SegmentationExportMode>('combined');
|
||
const [project, setProject] = useState<Project | null>(null);
|
||
const [fusionVolume, setFusionVolume] = useState<DicomFusionVolume | null>(null);
|
||
const [fusionError, setFusionError] = useState('');
|
||
const [saveStatus, setSaveStatus] = useState('');
|
||
const [exporting, setExporting] = useState(false);
|
||
const [stretchingAxis, setStretchingAxis] = useState<AxisKey | null>(null);
|
||
const modelBoundsCacheRef = useRef(new Map<string, { min: THREE.Vector3; max: THREE.Vector3 }>());
|
||
const [workspaceLoadState, setWorkspaceLoadState] = useState<WorkspaceLoadState>({
|
||
ready: false,
|
||
phase: '正在读取项目配置...',
|
||
loaded: 0,
|
||
total: 1,
|
||
startedAt: Date.now(),
|
||
error: '',
|
||
});
|
||
const workspaceLoadProjectRef = useRef('');
|
||
const poseRepeatRef = useRef<{ timeout: number | null; interval: number | null }>({ timeout: null, interval: null });
|
||
const poseImportInputRef = useRef<HTMLInputElement | null>(null);
|
||
const visualToolbarScrollRef = useRef<HTMLDivElement | null>(null);
|
||
const saveToastTimerRef = useRef<number | null>(null);
|
||
const savedWorkspaceSnapshotRef = useRef('');
|
||
const initialZStretchRef = useRef<{ projectId: string; pending: boolean }>({ projectId: '', pending: false });
|
||
|
||
const handleExportSelected = async () => {
|
||
const selectedItems = exportOptions
|
||
.filter((option) => exportSelection[option.id])
|
||
.map((option) => option.id);
|
||
if (!selectedItems.length) {
|
||
setFusionError('请至少选择一个导出内容');
|
||
return;
|
||
}
|
||
|
||
setExporting(true);
|
||
setFusionError('');
|
||
try {
|
||
await downloadProjectExportBundle(projectId, selectedItems, 'nii.gz', {
|
||
pose: modelPose,
|
||
segmentationScope: segmentationExportScope,
|
||
segmentationExportMode,
|
||
moduleStyles,
|
||
});
|
||
window.setTimeout(() => setExporting(false), 900);
|
||
setShowExportMenu(false);
|
||
} catch (error) {
|
||
setFusionError(error instanceof Error ? error.message : '导出失败');
|
||
setExporting(false);
|
||
}
|
||
};
|
||
|
||
const getCurrentWorkspaceSnapshot = useCallback(() => createWorkspaceSnapshot({
|
||
modelPose,
|
||
segmentationExportScope,
|
||
moduleStyles,
|
||
sliceStart,
|
||
sliceEnd,
|
||
mappingSlice,
|
||
displayLevel,
|
||
dicomOpacityLevel,
|
||
showBounds,
|
||
cutEnabled,
|
||
}), [
|
||
modelPose,
|
||
segmentationExportScope,
|
||
moduleStyles,
|
||
sliceStart,
|
||
sliceEnd,
|
||
mappingSlice,
|
||
displayLevel,
|
||
dicomOpacityLevel,
|
||
showBounds,
|
||
cutEnabled,
|
||
]);
|
||
|
||
const handleSaveSegmentationResult = useCallback(async (options: { showToast?: boolean } = {}) => {
|
||
if (!project) {
|
||
return false;
|
||
}
|
||
|
||
setFusionError('');
|
||
setSaveStatus('');
|
||
try {
|
||
const updated = await api.saveProjectSegmentationResult(project.id, {
|
||
name: '逆向分割结果',
|
||
pose: modelPose,
|
||
segmentationScope: segmentationExportScope,
|
||
moduleStyles,
|
||
sliceStart: clamp(sliceStart, 0, Math.max(project.dicomCount - 1, 0)),
|
||
sliceEnd: clamp(sliceEnd, 0, Math.max(project.dicomCount - 1, 0)),
|
||
mappingSlice: clamp(mappingSlice, 0, Math.max(project.dicomCount - 1, 0)),
|
||
displayLevel,
|
||
dicomOpacityLevel,
|
||
showBounds,
|
||
cutEnabled,
|
||
});
|
||
setProject(updated);
|
||
savedWorkspaceSnapshotRef.current = getCurrentWorkspaceSnapshot();
|
||
if (options.showToast !== false) {
|
||
setSaveStatus('已保存至项目库的分割结果区域');
|
||
}
|
||
return true;
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : '保存至项目库失败';
|
||
setFusionError(message);
|
||
if (options.showToast === false) {
|
||
window.alert(message);
|
||
}
|
||
return false;
|
||
}
|
||
}, [
|
||
project,
|
||
modelPose,
|
||
segmentationExportScope,
|
||
moduleStyles,
|
||
sliceStart,
|
||
sliceEnd,
|
||
mappingSlice,
|
||
displayLevel,
|
||
dicomOpacityLevel,
|
||
showBounds,
|
||
cutEnabled,
|
||
getCurrentWorkspaceSnapshot,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
if (!saveStatus) {
|
||
return undefined;
|
||
}
|
||
|
||
if (saveToastTimerRef.current !== null) {
|
||
window.clearTimeout(saveToastTimerRef.current);
|
||
}
|
||
saveToastTimerRef.current = window.setTimeout(() => {
|
||
setSaveStatus('');
|
||
saveToastTimerRef.current = null;
|
||
}, 2600);
|
||
|
||
return () => {
|
||
if (saveToastTimerRef.current !== null) {
|
||
window.clearTimeout(saveToastTimerRef.current);
|
||
saveToastTimerRef.current = null;
|
||
}
|
||
};
|
||
}, [saveStatus]);
|
||
|
||
useEffect(() => {
|
||
if (!onLeaveGuardChange) {
|
||
return undefined;
|
||
}
|
||
|
||
onLeaveGuardChange(async () => {
|
||
if (!project) {
|
||
return true;
|
||
}
|
||
if (project.locked) {
|
||
return true;
|
||
}
|
||
if (savedWorkspaceSnapshotRef.current === getCurrentWorkspaceSnapshot()) {
|
||
return true;
|
||
}
|
||
const shouldSave = window.confirm('是否保存当前结果至项目库? 确定:保存后退出。取消:直接退出,不保存当前结果。');
|
||
if (!shouldSave) {
|
||
return true;
|
||
}
|
||
return handleSaveSegmentationResult({ showToast: false });
|
||
});
|
||
|
||
return () => onLeaveGuardChange(null);
|
||
}, [getCurrentWorkspaceSnapshot, handleSaveSegmentationResult, onLeaveGuardChange, project]);
|
||
|
||
const makeDefaultModuleStyle = (index: number, fallback?: Partial<ModuleStyle>): ModuleStyle => ({
|
||
visible: fallback?.visible ?? true,
|
||
color: fallback?.color ?? moduleColors[index % moduleColors.length],
|
||
opacity: fallback?.opacity ?? 0.72,
|
||
partId: clamp(Math.round(fallback?.partId ?? index + 1), 1, 255),
|
||
});
|
||
|
||
const commitModuleStyles = (next: Record<string, ModuleStyle>) => {
|
||
setModuleStyles(next);
|
||
if (!project) {
|
||
return;
|
||
}
|
||
api.updateProjectModuleStyles(project.id, next)
|
||
.then((updated) => {
|
||
setProject(updated);
|
||
})
|
||
.catch(() => {
|
||
setFusionError('构件样式保存失败,请稍后重试');
|
||
});
|
||
};
|
||
|
||
const loadFusionVolume = async (start: number, end: number) => {
|
||
if (!project?.dicomCount) return null;
|
||
const maxSliceValue = Math.max(project.dicomCount - 1, 0);
|
||
const safeA = clamp(start, 0, maxSliceValue);
|
||
const safeB = clamp(end, 0, maxSliceValue);
|
||
const safeStart = Math.min(safeA, safeB);
|
||
const rangeEnd = Math.max(safeA, safeB);
|
||
const volumePayload = await getCachedDicomFusionVolume(project.id, safeStart, rangeEnd, 'soft');
|
||
return volumePayload;
|
||
};
|
||
|
||
const loadGlobalModelBounds = async () => {
|
||
if (!project) {
|
||
return null;
|
||
}
|
||
const modelFiles = project.stlFiles ?? [];
|
||
const cacheKey = `${project.id}:global:${modelFiles.join('|')}`;
|
||
const cached = modelBoundsCacheRef.current.get(cacheKey);
|
||
if (cached) {
|
||
return cached;
|
||
}
|
||
|
||
const modelBox = new THREE.Box3();
|
||
const results = await Promise.allSettled(modelFiles.map((fileName) => (
|
||
getCachedModelPreview(project.id, fileName, 1000)
|
||
)));
|
||
results.forEach((result) => {
|
||
if (result.status !== 'fulfilled' || !result.value.bounds) {
|
||
return;
|
||
}
|
||
modelBox.expandByPoint(new THREE.Vector3(result.value.bounds.min.x, result.value.bounds.min.y, result.value.bounds.min.z));
|
||
modelBox.expandByPoint(new THREE.Vector3(result.value.bounds.max.x, result.value.bounds.max.y, result.value.bounds.max.z));
|
||
});
|
||
|
||
if (modelBox.isEmpty()) {
|
||
return null;
|
||
}
|
||
const bounds = { min: modelBox.min.clone(), max: modelBox.max.clone() };
|
||
modelBoundsCacheRef.current.set(cacheKey, bounds);
|
||
return bounds;
|
||
};
|
||
|
||
const applyModelStretchByAxis = async (axis: AxisKey, options: { silentInitial?: boolean } = {}) => {
|
||
if (!project || !fusionVolume) {
|
||
setFusionError('请等待 DICOM 与 STL 数据加载完成后再拉伸模型');
|
||
return;
|
||
}
|
||
if (!isOrthogonalModelPose(modelPose)) {
|
||
setPoseImportStatus('');
|
||
setFusionError('模型拉伸仅在旋转 X/Y/Z 均为 90° 的整数倍时可用');
|
||
return;
|
||
}
|
||
|
||
setStretchingAxis(axis);
|
||
setFusionError('');
|
||
try {
|
||
const bounds = await loadGlobalModelBounds();
|
||
if (!bounds) {
|
||
throw new Error('未获取到 STL 构件边界');
|
||
}
|
||
const rawSize = new THREE.Vector3().subVectors(bounds.max, bounds.min);
|
||
const rotatedSize = getRotatedModelSize(bounds, modelPose);
|
||
const maxModelSize = Math.max(rawSize.x, rawSize.y, rawSize.z, 1);
|
||
const maxPhysical = Math.max(
|
||
fusionVolume.physicalSize.width,
|
||
fusionVolume.physicalSize.height,
|
||
fusionVolume.physicalSize.depth,
|
||
1,
|
||
);
|
||
const baseExtent = 4.6;
|
||
const dicomSize = {
|
||
x: (fusionVolume.physicalSize.width / maxPhysical) * baseExtent,
|
||
y: (fusionVolume.physicalSize.height / maxPhysical) * baseExtent,
|
||
z: Math.max((fusionVolume.physicalSize.depth / maxPhysical) * baseExtent, 0.18),
|
||
};
|
||
const baseScale = (Math.max(dicomSize.x, dicomSize.y, dicomSize.z) / maxModelSize) * 0.92;
|
||
const rotatedAxisSize = Math.max(rotatedSize[axis], 1e-6);
|
||
const axisFitScale = dicomSize[axis] / (rotatedAxisSize * baseScale);
|
||
const containmentScale = Math.min(
|
||
dicomSize.x / (Math.max(rotatedSize.x, 1e-6) * baseScale),
|
||
dicomSize.y / (Math.max(rotatedSize.y, 1e-6) * baseScale),
|
||
dicomSize.z / (Math.max(rotatedSize.z, 1e-6) * baseScale),
|
||
);
|
||
const nextScale = clampPoseValue('scale', Math.min(axisFitScale, containmentScale));
|
||
const nextPose = { ...modelPose, scale: nextScale };
|
||
updateModelPose({ scale: nextScale }, { markCustom: !options.silentInitial, keepStatus: true });
|
||
setPoseImportStatus('');
|
||
if (options.silentInitial) {
|
||
savedWorkspaceSnapshotRef.current = createWorkspaceSnapshot({
|
||
modelPose: nextPose,
|
||
segmentationExportScope,
|
||
moduleStyles,
|
||
sliceStart,
|
||
sliceEnd,
|
||
mappingSlice,
|
||
displayLevel,
|
||
dicomOpacityLevel,
|
||
showBounds,
|
||
cutEnabled,
|
||
});
|
||
}
|
||
} catch (error) {
|
||
setFusionError(error instanceof Error ? error.message : '模型自动拉伸失败');
|
||
} finally {
|
||
setStretchingAxis(null);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
workspaceLoadProjectRef.current = '';
|
||
setWorkspaceLoadState({
|
||
ready: false,
|
||
phase: '正在读取项目配置...',
|
||
loaded: 0,
|
||
total: 1,
|
||
startedAt: Date.now(),
|
||
error: '',
|
||
});
|
||
api.getProject(projectId).then((item) => {
|
||
setProject(item);
|
||
if (item.locked) {
|
||
setFusionVolume(null);
|
||
setWorkspaceLoadState({
|
||
ready: false,
|
||
phase: '项目已锁定',
|
||
loaded: 1,
|
||
total: 1,
|
||
startedAt: Date.now(),
|
||
error: '项目已锁定,请在项目库解锁后再进入逆向工作区。',
|
||
});
|
||
savedWorkspaceSnapshotRef.current = '';
|
||
return;
|
||
}
|
||
const maxIndex = Math.max((item.dicomCount || 1) - 1, 0);
|
||
const latestResult = item.segmentationResults?.[item.segmentationResults.length - 1];
|
||
const restoredSliceStart = clamp(latestResult?.sliceStart ?? 0, 0, maxIndex);
|
||
const restoredSliceEnd = clamp(latestResult?.sliceEnd ?? maxIndex, 0, maxIndex);
|
||
const restoredMappingSlice = clamp(latestResult?.mappingSlice ?? restoredSliceEnd, 0, maxIndex);
|
||
setSliceStart(restoredSliceStart);
|
||
setSliceEnd(restoredSliceEnd);
|
||
setMappingSlice(restoredMappingSlice);
|
||
const nextPoses = (item.modelPoses?.length ? item.modelPoses : defaultSavedPoses).map((pose) => ({
|
||
...pose,
|
||
pose: normalizePoseValue(pose.pose) ?? defaultModelPose,
|
||
}));
|
||
const preferredPose = nextPoses.find((pose) => pose.id === 'default') ?? nextPoses[0];
|
||
const restoredPose = normalizePoseValue(latestResult?.pose)
|
||
?? normalizePoseValue(preferredPose?.pose)
|
||
?? defaultModelPose;
|
||
initialZStretchRef.current = { projectId: item.id, pending: !latestResult };
|
||
setModelPose(restoredPose);
|
||
setPoseValueDrafts(formatPoseDraftValues(restoredPose));
|
||
const nextStyles: Record<string, ModuleStyle> = {};
|
||
(item.stlFiles ?? []).forEach((fileName, index) => {
|
||
nextStyles[fileName] = makeDefaultModuleStyle(index, {
|
||
...(latestResult?.moduleStyles?.[fileName] ?? {}),
|
||
...(item.moduleStyles?.[fileName] ?? {}),
|
||
});
|
||
});
|
||
setModuleStyles(nextStyles);
|
||
setSavedPoses(nextPoses);
|
||
setSelectedPoseId(latestResult ? 'reverse-result' : preferredPose?.id ?? 'default');
|
||
setSegmentationExportScope(latestResult?.segmentationScope ?? 'visible');
|
||
setDisplayLevel(latestResult?.displayLevel ?? 'standard');
|
||
setDicomOpacityLevel(latestResult?.dicomOpacityLevel ?? 'low');
|
||
setMappingDisplayMode('soft');
|
||
setMappingRotation(0);
|
||
setShowBounds(latestResult?.showBounds ?? true);
|
||
setCutEnabled(latestResult?.cutEnabled ?? false);
|
||
savedWorkspaceSnapshotRef.current = createWorkspaceSnapshot({
|
||
modelPose: restoredPose,
|
||
segmentationExportScope: latestResult?.segmentationScope ?? 'visible',
|
||
moduleStyles: nextStyles,
|
||
sliceStart: restoredSliceStart,
|
||
sliceEnd: restoredSliceEnd,
|
||
mappingSlice: restoredMappingSlice,
|
||
displayLevel: latestResult?.displayLevel ?? 'standard',
|
||
dicomOpacityLevel: latestResult?.dicomOpacityLevel ?? 'low',
|
||
showBounds: latestResult?.showBounds ?? true,
|
||
cutEnabled: latestResult?.cutEnabled ?? false,
|
||
});
|
||
}).catch(() => {
|
||
setProject(null);
|
||
setFusionVolume(null);
|
||
setWorkspaceLoadState({
|
||
ready: false,
|
||
phase: '项目配置读取失败',
|
||
loaded: 0,
|
||
total: 1,
|
||
startedAt: Date.now(),
|
||
error: '项目配置读取失败',
|
||
});
|
||
savedWorkspaceSnapshotRef.current = '';
|
||
});
|
||
}, [projectId]);
|
||
|
||
useEffect(() => {
|
||
if (!project?.dicomCount) return;
|
||
const maxSlice = Math.max(project.dicomCount - 1, 0);
|
||
const safeStart = clamp(sliceStart, 0, maxSlice);
|
||
const safeEnd = clamp(sliceEnd, 0, maxSlice);
|
||
const timer = window.setTimeout(() => {
|
||
setFusionError('');
|
||
loadFusionVolume(safeStart, safeEnd)
|
||
.then(setFusionVolume)
|
||
.catch((error) => {
|
||
setFusionVolume(null);
|
||
setFusionError(error instanceof Error ? error.message : 'DICOM 融合体加载失败');
|
||
});
|
||
}, 180);
|
||
return () => window.clearTimeout(timer);
|
||
}, [project?.id, project?.dicomCount, sliceStart, sliceEnd]);
|
||
|
||
useEffect(() => () => {
|
||
if (poseRepeatRef.current.timeout !== null) {
|
||
window.clearTimeout(poseRepeatRef.current.timeout);
|
||
}
|
||
if (poseRepeatRef.current.interval !== null) {
|
||
window.clearInterval(poseRepeatRef.current.interval);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
setPoseValueDrafts((current) => {
|
||
const next = { ...current };
|
||
modelPoseKeys.forEach((key) => {
|
||
if (focusedPoseInput !== key) {
|
||
next[key] = formatPoseValue(key, modelPose[key]);
|
||
}
|
||
});
|
||
return next;
|
||
});
|
||
}, [
|
||
focusedPoseInput,
|
||
modelPose.rotateX,
|
||
modelPose.rotateY,
|
||
modelPose.rotateZ,
|
||
modelPose.translateX,
|
||
modelPose.translateY,
|
||
modelPose.translateZ,
|
||
modelPose.scale,
|
||
]);
|
||
|
||
const clampPoseValue = (key: ModelPoseKey, value: number) => {
|
||
const limit = poseStepConfig[key];
|
||
const precision = getStepPrecision(limit.step);
|
||
return Number(clamp(value, limit.min, limit.max).toFixed(precision));
|
||
};
|
||
|
||
const updateModelPose = (partial: Partial<ModelPose>, options: { markCustom?: boolean; keepStatus?: boolean } = {}) => {
|
||
setModelPose((current) => {
|
||
const next = { ...current };
|
||
modelPoseKeys.forEach((key) => {
|
||
const value = partial[key];
|
||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||
next[key] = clampPoseValue(key, value);
|
||
}
|
||
});
|
||
return next;
|
||
});
|
||
if (options.markCustom !== false) {
|
||
setSelectedPoseId('custom');
|
||
}
|
||
if (!options.keepStatus) {
|
||
setPoseImportStatus('');
|
||
}
|
||
};
|
||
|
||
const restoreVisualToolbarScroll = (scrollTop: number | null) => {
|
||
if (scrollTop === null) {
|
||
return;
|
||
}
|
||
window.requestAnimationFrame(() => {
|
||
if (visualToolbarScrollRef.current) {
|
||
visualToolbarScrollRef.current.scrollTop = scrollTop;
|
||
}
|
||
});
|
||
};
|
||
|
||
const nudgeModelPose = (key: ModelPoseKey, delta: number) => {
|
||
const scrollTop = visualToolbarScrollRef.current?.scrollTop ?? null;
|
||
setModelPose((current) => ({
|
||
...current,
|
||
[key]: clampPoseValue(key, current[key] + delta),
|
||
}));
|
||
setSelectedPoseId('custom');
|
||
setPoseImportStatus('');
|
||
restoreVisualToolbarScroll(scrollTop);
|
||
};
|
||
|
||
const handlePoseInputChange = (key: ModelPoseKey, value: string) => {
|
||
setPoseValueDrafts((current) => ({ ...current, [key]: value }));
|
||
if (!value.trim()) {
|
||
return;
|
||
}
|
||
|
||
const numericValue = Number(value);
|
||
if (!Number.isFinite(numericValue)) {
|
||
return;
|
||
}
|
||
|
||
updateModelPose({ [key]: numericValue } as Partial<ModelPose>);
|
||
};
|
||
|
||
const commitPoseInputValue = (key: ModelPoseKey) => {
|
||
const draftValue = poseValueDrafts[key];
|
||
const numericValue = draftValue.trim() ? Number(draftValue) : NaN;
|
||
if (Number.isFinite(numericValue)) {
|
||
const nextValue = clampPoseValue(key, numericValue);
|
||
if (Math.abs(nextValue - modelPose[key]) > 1e-9) {
|
||
updateModelPose({ [key]: nextValue } as Partial<ModelPose>);
|
||
}
|
||
setPoseValueDrafts((current) => ({ ...current, [key]: formatPoseValue(key, nextValue) }));
|
||
} else {
|
||
setPoseValueDrafts((current) => ({ ...current, [key]: formatPoseValue(key, modelPose[key]) }));
|
||
}
|
||
setFocusedPoseInput(null);
|
||
};
|
||
|
||
const stopPoseRepeat = () => {
|
||
if (poseRepeatRef.current.timeout !== null) {
|
||
window.clearTimeout(poseRepeatRef.current.timeout);
|
||
poseRepeatRef.current.timeout = null;
|
||
}
|
||
if (poseRepeatRef.current.interval !== null) {
|
||
window.clearInterval(poseRepeatRef.current.interval);
|
||
poseRepeatRef.current.interval = null;
|
||
}
|
||
};
|
||
|
||
const startPoseRepeat = (key: ModelPoseKey, delta: number) => {
|
||
stopPoseRepeat();
|
||
const repeatDelta = delta * (poseRepeatDeltaMultiplier[key] ?? 1);
|
||
poseRepeatRef.current.timeout = window.setTimeout(() => {
|
||
nudgeModelPose(key, repeatDelta);
|
||
poseRepeatRef.current.interval = window.setInterval(() => nudgeModelPose(key, repeatDelta), poseRepeatIntervalMs);
|
||
}, poseRepeatDelayMs);
|
||
};
|
||
|
||
const resetRotationPose = () => {
|
||
setModelPose((current) => ({
|
||
...current,
|
||
rotateX: 0,
|
||
rotateY: 0,
|
||
rotateZ: 0,
|
||
}));
|
||
setSelectedPoseId('custom');
|
||
setPoseImportStatus('');
|
||
};
|
||
|
||
const resetTransformPose = () => {
|
||
setModelPose((current) => ({
|
||
...current,
|
||
translateX: 0,
|
||
translateY: 0,
|
||
translateZ: 0,
|
||
scale: 1,
|
||
}));
|
||
setSelectedPoseId('custom');
|
||
setPoseImportStatus('');
|
||
};
|
||
|
||
const toggleModelFlip = (key: ModelPoseFlipKey) => {
|
||
const scrollTop = visualToolbarScrollRef.current?.scrollTop ?? null;
|
||
setModelPose((current) => ({
|
||
...current,
|
||
[key]: !current[key],
|
||
}));
|
||
setSelectedPoseId('custom');
|
||
setPoseImportStatus('');
|
||
restoreVisualToolbarScroll(scrollTop);
|
||
};
|
||
|
||
const resetModelFlipPose = () => {
|
||
const scrollTop = visualToolbarScrollRef.current?.scrollTop ?? null;
|
||
setModelPose((current) => ({
|
||
...current,
|
||
flipX: false,
|
||
flipY: false,
|
||
flipZ: false,
|
||
}));
|
||
setSelectedPoseId('custom');
|
||
setPoseImportStatus('');
|
||
restoreVisualToolbarScroll(scrollTop);
|
||
};
|
||
|
||
const allModulesVisible = Boolean(project?.stlFiles?.length) && (project?.stlFiles ?? []).every((fileName) => moduleStyles[fileName]?.visible !== false);
|
||
|
||
const toggleAllModules = () => {
|
||
const stlFiles = project?.stlFiles ?? [];
|
||
const nextVisible = !allModulesVisible;
|
||
const next = { ...moduleStyles };
|
||
stlFiles.forEach((fileName, index) => {
|
||
next[fileName] = makeDefaultModuleStyle(index, {
|
||
...(next[fileName] ?? project?.moduleStyles?.[fileName]),
|
||
visible: nextVisible,
|
||
});
|
||
});
|
||
commitModuleStyles(next);
|
||
};
|
||
|
||
const updateModuleStyle = (fileName: string, partial: Partial<ModuleStyle>) => {
|
||
const stlFiles = project?.stlFiles ?? [];
|
||
const index = Math.max(0, stlFiles.indexOf(fileName));
|
||
const next = {
|
||
...moduleStyles,
|
||
[fileName]: makeDefaultModuleStyle(index, {
|
||
...(moduleStyles[fileName] ?? project?.moduleStyles?.[fileName]),
|
||
...partial,
|
||
}),
|
||
};
|
||
commitModuleStyles(next);
|
||
};
|
||
|
||
const updateModulePartId = (fileName: string, value: number) => {
|
||
updateModuleStyle(fileName, { partId: clamp(Math.round(Number.isFinite(value) ? value : 1), 1, 255) });
|
||
};
|
||
|
||
const commitSavedPoses = (next: SavedModelPose[]) => {
|
||
setSavedPoses(next);
|
||
if (!project) {
|
||
return;
|
||
}
|
||
api.updateProjectModelPoses(project.id, next)
|
||
.then((updated) => {
|
||
setProject(updated);
|
||
setSavedPoses(updated.modelPoses?.length ? updated.modelPoses : next);
|
||
})
|
||
.catch(() => {
|
||
setFusionError('位姿保存失败,请稍后重试');
|
||
});
|
||
};
|
||
|
||
const saveCurrentPose = () => {
|
||
const nextPose = {
|
||
id: `pose-${Date.now()}`,
|
||
name: `位姿${savedPoses.length - 2}`,
|
||
pose: { ...modelPose },
|
||
};
|
||
commitSavedPoses([...savedPoses, nextPose]);
|
||
setSelectedPoseId(nextPose.id);
|
||
};
|
||
|
||
const renamePose = (poseId: string, name: string) => {
|
||
if (poseId === 'default') return;
|
||
const nextName = name.trim();
|
||
commitSavedPoses(savedPoses.map((item) => (
|
||
item.id === poseId ? { ...item, name: nextName || item.name } : item
|
||
)));
|
||
};
|
||
|
||
const selectPose = (poseId: string) => {
|
||
const selected = savedPoses.find((item) => item.id === poseId);
|
||
if (!selected) return;
|
||
setSelectedPoseId(poseId);
|
||
setModelPose(selected.pose);
|
||
setPoseImportStatus('');
|
||
};
|
||
|
||
const handleImportPoseFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = event.target.files?.[0];
|
||
event.target.value = '';
|
||
if (!file) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const payload = JSON.parse(await file.text()) as unknown;
|
||
const { activePose, importedModelPoses } = parseImportedPosePayload(payload);
|
||
if (!activePose && !importedModelPoses?.length) {
|
||
throw new Error('未找到可用位姿数据');
|
||
}
|
||
|
||
const nextSavedPoses = importedModelPoses?.length
|
||
? mergeImportedModelPoses(importedModelPoses)
|
||
: savedPoses;
|
||
if (importedModelPoses?.length) {
|
||
commitSavedPoses(nextSavedPoses);
|
||
}
|
||
|
||
if (activePose) {
|
||
setModelPose(activePose);
|
||
setPoseValueDrafts(formatPoseDraftValues(activePose));
|
||
const matchedPose = nextSavedPoses.find((item) => poseValuesMatch(item.pose, activePose));
|
||
setSelectedPoseId(matchedPose?.id ?? 'custom');
|
||
}
|
||
|
||
setFusionError('');
|
||
setPoseImportStatus(importedModelPoses?.length ? '位姿数据已导入并保存' : '当前位姿已导入');
|
||
} catch (error) {
|
||
setPoseImportStatus('');
|
||
setFusionError(error instanceof Error ? `位姿导入失败:${error.message}` : '位姿导入失败');
|
||
}
|
||
};
|
||
|
||
const maxSlice = Math.max((project?.dicomCount ?? 1) - 1, 0);
|
||
const safeSliceStart = clamp(sliceStart, 0, maxSlice);
|
||
const safeSliceEnd = clamp(sliceEnd, 0, maxSlice);
|
||
const safeMappingSlice = clamp(mappingSlice, 0, maxSlice);
|
||
const displayStart = Math.min(safeSliceStart, safeSliceEnd);
|
||
const displayEnd = Math.max(safeSliceStart, safeSliceEnd);
|
||
const displaySliceRange = getDicomDisplayRange(displayStart, displayEnd, project?.dicomCount ?? 0);
|
||
const rangeStartPercent = maxSlice > 0 ? (displayStart / maxSlice) * 100 : 0;
|
||
const rangeEndPercent = maxSlice > 0 ? (displayEnd / maxSlice) * 100 : 0;
|
||
const selectedDisplay = displayOptions.find((item) => item.id === displayLevel) ?? displayOptions[0];
|
||
const selectedDicomOpacity = dicomOpacityOptions.find((item) => item.id === dicomOpacityLevel) ?? dicomOpacityOptions[0];
|
||
const stretchEnabled = Boolean(project && fusionVolume && isOrthogonalModelPose(modelPose));
|
||
const workspaceProgress = workspaceLoadState.total > 0
|
||
? Math.round((workspaceLoadState.loaded / workspaceLoadState.total) * 100)
|
||
: 0;
|
||
const workspaceElapsedSeconds = Math.max((Date.now() - workspaceLoadState.startedAt) / 1000, 0.1);
|
||
const workspaceLoadSpeed = workspaceLoadState.loaded / workspaceElapsedSeconds;
|
||
|
||
useEffect(() => {
|
||
if (!project?.dicomCount || project.locked) {
|
||
return undefined;
|
||
}
|
||
if (workspaceLoadProjectRef.current === project.id) {
|
||
return undefined;
|
||
}
|
||
|
||
let cancelled = false;
|
||
const stlFilesForLoad = project.stlFiles ?? [];
|
||
const fusionStart = Math.min(displayStart, displayEnd);
|
||
const fusionEnd = Math.max(displayStart, displayEnd);
|
||
const previewLimit = selectedDisplay.limit;
|
||
const mappingPreviewLimit = Math.max(previewLimit, 800000);
|
||
const total = 2 + stlFilesForLoad.length * 2;
|
||
const startedAt = Date.now();
|
||
let loaded = 0;
|
||
|
||
const updateLoadState = (phase: string, error = '') => {
|
||
if (cancelled) {
|
||
return;
|
||
}
|
||
setWorkspaceLoadState({
|
||
ready: false,
|
||
phase,
|
||
loaded,
|
||
total,
|
||
startedAt,
|
||
error,
|
||
});
|
||
};
|
||
const markLoaded = (phase: string) => {
|
||
loaded += 1;
|
||
updateLoadState(phase);
|
||
};
|
||
|
||
updateLoadState('正在载入 DICOM 三维体与 STL 构件预览...');
|
||
|
||
const tasks: Array<Promise<unknown>> = [
|
||
getCachedDicomFusionVolume(project.id, fusionStart, fusionEnd, 'soft')
|
||
.then((volume) => {
|
||
if (!cancelled) {
|
||
setFusionVolume(volume);
|
||
}
|
||
markLoaded('DICOM 三维融合体已载入');
|
||
}),
|
||
getCachedDicomPreview(project.id, safeMappingSlice, 'axial', mappingDisplayMode)
|
||
.then(() => markLoaded('DICOM 切片预览已载入')),
|
||
...stlFilesForLoad.map((fileName) => (
|
||
getCachedModelPreview(project.id, fileName, previewLimit)
|
||
.then(() => markLoaded(`三维模型预览已缓存:${fileName.replace(/\.stl$/i, '')}`))
|
||
)),
|
||
...stlFilesForLoad.map((fileName) => (
|
||
getCachedModelPreview(project.id, fileName, mappingPreviewLimit)
|
||
.then(() => markLoaded(`二维映射网格已缓存:${fileName.replace(/\.stl$/i, '')}`))
|
||
)),
|
||
];
|
||
|
||
Promise.allSettled(tasks).then((results) => {
|
||
if (cancelled) {
|
||
return;
|
||
}
|
||
const fusionFailed = results[0]?.status === 'rejected';
|
||
if (fusionFailed) {
|
||
setFusionVolume(null);
|
||
setWorkspaceLoadState({
|
||
ready: false,
|
||
phase: 'DICOM 三维融合体载入失败',
|
||
loaded,
|
||
total,
|
||
startedAt,
|
||
error: 'DICOM 三维融合体载入失败,请检查数据或刷新页面重试。',
|
||
});
|
||
return;
|
||
}
|
||
workspaceLoadProjectRef.current = project.id;
|
||
setWorkspaceLoadState({
|
||
ready: true,
|
||
phase: '逆向工作区已就绪',
|
||
loaded: total,
|
||
total,
|
||
startedAt,
|
||
error: '',
|
||
});
|
||
});
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [
|
||
project?.id,
|
||
project?.dicomCount,
|
||
project?.locked,
|
||
project?.stlFiles?.join('|'),
|
||
displayStart,
|
||
displayEnd,
|
||
safeMappingSlice,
|
||
selectedDisplay.limit,
|
||
mappingDisplayMode,
|
||
]);
|
||
|
||
useEffect(() => {
|
||
if (!project || !fusionVolume || !workspaceLoadState.ready) {
|
||
return;
|
||
}
|
||
const stretchState = initialZStretchRef.current;
|
||
if (stretchState.projectId !== project.id || !stretchState.pending || !isOrthogonalModelPose(modelPose)) {
|
||
return;
|
||
}
|
||
initialZStretchRef.current = { projectId: project.id, pending: false };
|
||
void applyModelStretchByAxis('z', { silentInitial: true });
|
||
}, [
|
||
project?.id,
|
||
fusionVolume,
|
||
workspaceLoadState.ready,
|
||
modelPose.rotateX,
|
||
modelPose.rotateY,
|
||
modelPose.rotateZ,
|
||
]);
|
||
|
||
if (project?.locked) {
|
||
return (
|
||
<div className="flex h-full min-h-0 items-center justify-center overflow-hidden pr-2">
|
||
<div className="w-full max-w-2xl rounded-3xl border border-amber-100 bg-white p-8 text-center shadow-sm">
|
||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-amber-50 text-amber-600">
|
||
<Lock size={24} />
|
||
</div>
|
||
<h2 className="mt-5 text-2xl font-black text-slate-900">项目已锁定</h2>
|
||
<p className="mx-auto mt-3 max-w-lg text-sm font-semibold leading-6 text-slate-500">
|
||
请先在项目库点击“解锁项目”,再进入逆向工作区继续修改位姿和分割结果。
|
||
</p>
|
||
<div className="mt-6 rounded-2xl bg-slate-50 px-4 py-3 text-left text-xs font-bold text-slate-500">
|
||
<p>项目:{project.name}</p>
|
||
<p className="mt-1">锁定时间:{project.lockedAt ? new Date(project.lockedAt).toLocaleString('zh-CN') : '未记录'}</p>
|
||
<p className="mt-1">锁定结果:{project.lockedPoseSnapshotPath ?? '项目数据/锁定结果'}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!workspaceLoadState.ready) {
|
||
return (
|
||
<div className="flex h-full min-h-0 items-center justify-center overflow-hidden pr-2">
|
||
<div className="w-full max-w-3xl rounded-3xl border border-slate-100 bg-white p-8 shadow-sm">
|
||
<div className="mb-6 flex items-start justify-between gap-4">
|
||
<div>
|
||
<p className="text-xs font-bold uppercase tracking-[0.24em] text-blue-500">Reverse Workspace</p>
|
||
<h2 className="mt-2 text-2xl font-black text-slate-900">正在载入完整逆向工作区</h2>
|
||
<p className="mt-2 text-sm font-semibold text-slate-500">
|
||
DICOM 三维体、STL 构件预览和二维映射网格准备完成后再显示工作区。
|
||
</p>
|
||
</div>
|
||
<span className="rounded-2xl bg-slate-950 px-4 py-2 font-mono text-sm font-bold text-cyan-100">
|
||
{Math.max(0, Math.min(100, workspaceProgress))}%
|
||
</span>
|
||
</div>
|
||
<div className="h-3 overflow-hidden rounded-full bg-slate-100">
|
||
<div
|
||
className="h-full rounded-full bg-blue-600 transition-all duration-300"
|
||
style={{ width: `${Math.max(4, Math.min(100, workspaceProgress))}%` }}
|
||
/>
|
||
</div>
|
||
<div className="mt-5 grid gap-3 text-xs font-bold text-slate-500 sm:grid-cols-3">
|
||
<div className="rounded-2xl bg-slate-50 px-4 py-3">
|
||
<span className="block text-[10px] uppercase tracking-widest text-slate-400">当前阶段</span>
|
||
<span className="mt-1 block truncate text-slate-700">{workspaceLoadState.phase}</span>
|
||
</div>
|
||
<div className="rounded-2xl bg-slate-50 px-4 py-3">
|
||
<span className="block text-[10px] uppercase tracking-widest text-slate-400">加载进度</span>
|
||
<span className="mt-1 block font-mono text-slate-700">
|
||
{workspaceLoadState.loaded} / {workspaceLoadState.total}
|
||
</span>
|
||
</div>
|
||
<div className="rounded-2xl bg-slate-50 px-4 py-3">
|
||
<span className="block text-[10px] uppercase tracking-widest text-slate-400">加载速度</span>
|
||
<span className="mt-1 block font-mono text-slate-700">
|
||
{workspaceLoadSpeed.toFixed(1)} 项/秒
|
||
</span>
|
||
</div>
|
||
</div>
|
||
{workspaceLoadState.error && (
|
||
<div className="mt-5 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-xs font-bold text-amber-700">
|
||
{workspaceLoadState.error}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="h-full min-h-0 overflow-y-auto pr-2 flex flex-col gap-6">
|
||
{saveStatus && (
|
||
<>
|
||
<style>
|
||
{`@keyframes reverse-result-toast { 0% { opacity: 0; transform: translate(-50%, -10px); } 14% { opacity: 1; transform: translate(-50%, 0); } 72% { opacity: 1; transform: translate(-50%, 0); } 100% { opacity: 0; transform: translate(-50%, -10px); } }`}
|
||
</style>
|
||
<div
|
||
className="fixed left-1/2 top-20 z-50 rounded-2xl border border-cyan-200 bg-white px-5 py-3 text-sm font-bold text-cyan-700 shadow-2xl shadow-cyan-950/10"
|
||
style={{ animation: 'reverse-result-toast 2.6s ease forwards' }}
|
||
>
|
||
{saveStatus}
|
||
</div>
|
||
</>
|
||
)}
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
{project && (
|
||
<div className="flex flex-wrap gap-3 text-sm font-bold">
|
||
<span className="rounded-xl bg-blue-50 px-4 py-2 text-blue-700">当前项目:{project.name}</span>
|
||
<span className="rounded-xl bg-slate-100 px-4 py-2 text-slate-700">DICOM {project.dicomCount}</span>
|
||
<span className="rounded-xl bg-slate-100 px-4 py-2 text-slate-700">STL {project.modelCount ?? 0}</span>
|
||
</div>
|
||
)}
|
||
{!project && <p className="text-sm text-slate-500">配准 DICOM 影像与三维模型,生成像素映射关系</p>}
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => void handleSaveSegmentationResult()}
|
||
disabled={!project}
|
||
className="bg-cyan-600 text-white px-5 py-2.5 rounded-xl text-sm font-semibold hover:bg-cyan-700 transition-all shadow-lg flex items-center gap-2 disabled:opacity-50"
|
||
>
|
||
<Save size={18} />
|
||
保存至项目库
|
||
</button>
|
||
<div className="relative">
|
||
<button
|
||
onClick={() => setShowExportMenu((value) => !value)}
|
||
disabled={exporting}
|
||
className="bg-emerald-600 text-white px-5 py-2.5 rounded-xl text-sm font-semibold hover:bg-emerald-700 transition-all shadow-lg flex items-center gap-2 disabled:opacity-50"
|
||
>
|
||
<Download size={18} />
|
||
{exporting ? '正在导出' : '导出项目及结果'}
|
||
</button>
|
||
{showExportMenu && (
|
||
<div className="absolute right-0 top-12 z-30 w-72 rounded-2xl border border-slate-200 bg-white p-3 text-xs shadow-2xl">
|
||
<div className="mb-2 flex items-center justify-between">
|
||
<p className="font-bold text-slate-700">导出内容</p>
|
||
<button
|
||
onClick={() => setExportSelection({ dicom: true, segmentation: true, pose: true, stl: true })}
|
||
className="text-[10px] font-bold text-emerald-600 hover:text-emerald-700"
|
||
>
|
||
全选
|
||
</button>
|
||
</div>
|
||
<div className="space-y-2">
|
||
{exportOptions.map((option) => (
|
||
<label key={option.id} className="flex items-center gap-3 rounded-xl bg-slate-50 px-3 py-2 font-bold text-slate-600">
|
||
<input
|
||
type="checkbox"
|
||
checked={exportSelection[option.id]}
|
||
onChange={(event) => setExportSelection((current) => ({ ...current, [option.id]: event.target.checked }))}
|
||
className="accent-emerald-600"
|
||
/>
|
||
<span className="min-w-0 flex-1">
|
||
<span className="block">{option.label}</span>
|
||
<span className="block text-[10px] text-slate-400">{option.description}</span>
|
||
</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
{exportSelection.segmentation && (
|
||
<div className="mt-3 rounded-xl border border-emerald-100 bg-emerald-50/70 p-2">
|
||
<div className="mb-2 flex items-center justify-between gap-2">
|
||
<p className="text-[10px] font-bold text-emerald-800">分割类别范围</p>
|
||
<span className="text-[9px] font-bold text-emerald-600">附带 labels.json</span>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-1.5">
|
||
{segmentationScopeOptions.map((option) => (
|
||
<button
|
||
key={option.id}
|
||
onClick={() => setSegmentationExportScope(option.id)}
|
||
className={`rounded-lg px-2 py-1.5 text-left transition ${
|
||
segmentationExportScope === option.id
|
||
? 'bg-emerald-600 text-white shadow-sm'
|
||
: 'bg-white text-emerald-700 hover:bg-emerald-100'
|
||
}`}
|
||
>
|
||
<span className="block text-[10px] font-bold">{option.label}</span>
|
||
<span className={`block text-[9px] ${segmentationExportScope === option.id ? 'text-emerald-50' : 'text-emerald-500'}`}>
|
||
{option.description}
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="mt-2 border-t border-emerald-100 pt-2">
|
||
<p className="mb-2 text-[10px] font-bold text-emerald-800">分割导出方式</p>
|
||
<div className="grid grid-cols-2 gap-1.5">
|
||
{segmentationExportModeOptions.map((option) => (
|
||
<button
|
||
key={option.id}
|
||
onClick={() => setSegmentationExportMode(option.id)}
|
||
className={`rounded-lg px-2 py-1.5 text-left transition ${
|
||
segmentationExportMode === option.id
|
||
? 'bg-slate-900 text-white shadow-sm'
|
||
: 'bg-white text-slate-600 hover:bg-emerald-100'
|
||
}`}
|
||
>
|
||
<span className="block text-[10px] font-bold">{option.label}</span>
|
||
<span className={`block text-[9px] ${segmentationExportMode === option.id ? 'text-slate-200' : 'text-slate-400'}`}>
|
||
{option.description}
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<button
|
||
onClick={handleExportSelected}
|
||
disabled={exporting}
|
||
className="mt-3 flex h-9 w-full items-center justify-center rounded-xl bg-slate-900 text-[11px] font-bold text-white hover:bg-black disabled:opacity-50"
|
||
>
|
||
导出所选压缩包
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="min-h-[840px] flex-1 grid grid-cols-1 items-stretch gap-5 xl:grid-cols-[minmax(300px,0.82fr)_minmax(350px,0.98fr)_minmax(520px,1.32fr)] 2xl:grid-cols-[minmax(330px,0.8fr)_minmax(390px,1fr)_minmax(620px,1.45fr)]">
|
||
<div className="min-h-0 flex flex-col gap-4">
|
||
<div className="px-2 flex flex-wrap items-center justify-between gap-2 shrink-0">
|
||
<h3 className="font-bold text-slate-700 flex items-center gap-2">
|
||
<Rotate3d size={18} className="text-blue-500" />
|
||
影像与模型融合视角
|
||
</h3>
|
||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||
<span className="text-[10px] font-mono text-slate-400">
|
||
Layer: {displaySliceRange.start}-{displaySliceRange.end}/{project?.dicomCount ?? 0}
|
||
</span>
|
||
<div className="flex items-center gap-1 rounded-xl bg-slate-100 p-1">
|
||
<span className="hidden items-center gap-1 px-1 text-[9px] font-bold text-slate-400 2xl:flex">
|
||
<Maximize2 size={11} />
|
||
自动拉伸
|
||
</span>
|
||
{(['x', 'y', 'z'] as AxisKey[]).map((axis) => (
|
||
<button
|
||
key={axis}
|
||
onClick={() => void applyModelStretchByAxis(axis)}
|
||
disabled={!stretchEnabled || stretchingAxis !== null}
|
||
className={`rounded-lg px-2 py-1 text-[9px] font-bold transition ${
|
||
stretchEnabled && stretchingAxis === null
|
||
? 'bg-white text-blue-600 shadow-sm hover:text-blue-700'
|
||
: 'cursor-not-allowed text-slate-300'
|
||
}`}
|
||
title={stretchEnabled ? `按 ${axis.toUpperCase()} 方向自动等比例拉伸模型` : '仅当旋转 X/Y/Z 均为 90° 的整数倍时可用'}
|
||
>
|
||
{stretchingAxis === axis ? '...' : `${axis.toUpperCase()}拉伸`}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{project ? (
|
||
<div className="min-h-0 flex-1">
|
||
<FusionThreeView
|
||
project={project}
|
||
volume={fusionVolume}
|
||
modelPose={modelPose}
|
||
moduleStyles={moduleStyles}
|
||
detailLimit={selectedDisplay.limit}
|
||
solidMode={displayLevel === 'solid'}
|
||
dicomOpacity={selectedDicomOpacity}
|
||
showBounds={showBounds}
|
||
cutEnabled={cutEnabled}
|
||
cutStart={displayStart}
|
||
cutEnd={displayEnd}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div className="flex-1 rounded-3xl border border-slate-100 bg-white flex items-center justify-center text-sm text-slate-400">
|
||
正在载入项目...
|
||
</div>
|
||
)}
|
||
|
||
{fusionError && (
|
||
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-xs font-bold text-amber-700 flex items-center gap-2">
|
||
<AlertCircle size={16} />
|
||
{fusionError}
|
||
</div>
|
||
)}
|
||
|
||
<div className="rounded-2xl border border-slate-100 bg-white p-4 shadow-sm">
|
||
<div className="mb-3 flex items-center justify-between">
|
||
<p className="text-xs font-bold text-slate-700">DICOM 切片范围</p>
|
||
<span className="text-[10px] font-mono text-blue-600">
|
||
{displaySliceRange.start} - {displaySliceRange.end} / {project?.dicomCount ?? 0}
|
||
</span>
|
||
</div>
|
||
<div className="py-1">
|
||
<div className="relative h-10">
|
||
<div className="absolute inset-x-0 top-1/2 h-2 -translate-y-1/2 rounded-full bg-slate-200" />
|
||
<div
|
||
className="absolute top-1/2 h-2 -translate-y-1/2 rounded-full bg-blue-600"
|
||
style={{
|
||
left: `${rangeStartPercent}%`,
|
||
right: `${100 - rangeEndPercent}%`,
|
||
}}
|
||
/>
|
||
<input
|
||
type="range"
|
||
aria-label="DICOM 切片范围起点"
|
||
min="0"
|
||
max={maxSlice}
|
||
value={safeSliceStart}
|
||
onChange={(event) => setSliceStart(Number(event.target.value))}
|
||
className="dicom-range-input"
|
||
style={{ zIndex: safeSliceStart >= safeSliceEnd ? 5 : 4 }}
|
||
/>
|
||
<input
|
||
type="range"
|
||
aria-label="DICOM 切片范围终点"
|
||
min="0"
|
||
max={maxSlice}
|
||
value={safeSliceEnd}
|
||
onChange={(event) => setSliceEnd(Number(event.target.value))}
|
||
className="dicom-range-input"
|
||
style={{ zIndex: safeSliceStart >= safeSliceEnd ? 4 : 5 }}
|
||
/>
|
||
</div>
|
||
<div className="mt-1 grid grid-cols-3 text-[10px] font-bold text-slate-500">
|
||
<span>起点 {getDicomDisplaySliceNumber(safeSliceStart, project?.dicomCount ?? 0)}</span>
|
||
<span className="text-center text-blue-600">范围</span>
|
||
<span className="text-right">终点 {getDicomDisplaySliceNumber(safeSliceEnd, project?.dicomCount ?? 0)}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="min-h-0 flex flex-col gap-4 overflow-hidden">
|
||
<div className="px-2 shrink-0">
|
||
<h3 className="font-bold text-slate-700 flex items-center gap-2">
|
||
<Settings2 size={18} className="text-emerald-500" />
|
||
可视化工具栏
|
||
</h3>
|
||
</div>
|
||
|
||
<div className="flex-1 bg-white rounded-3xl border border-slate-100 shadow-sm overflow-hidden flex flex-col p-4 gap-4">
|
||
<div ref={visualToolbarScrollRef} className="flex-1 overflow-auto space-y-4 pr-1">
|
||
<div>
|
||
<p className="mb-2 text-[10px] font-bold uppercase tracking-widest text-slate-400">模型显示</p>
|
||
<div className="grid grid-cols-2 gap-1 rounded-xl bg-slate-100 p-1">
|
||
{displayOptions.map((option) => (
|
||
<button
|
||
key={option.id}
|
||
onClick={() => setDisplayLevel(option.id)}
|
||
className={`rounded-lg px-2 py-1.5 text-[10px] font-bold transition-all ${
|
||
displayLevel === option.id ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
|
||
}`}
|
||
>
|
||
{option.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<p className="mb-2 text-[10px] font-bold uppercase tracking-widest text-slate-400">融合显示</p>
|
||
<div className="grid grid-cols-3 gap-1 rounded-xl bg-slate-100 p-1">
|
||
{dicomOpacityOptions.map((option) => (
|
||
<button
|
||
key={option.id}
|
||
onClick={() => setDicomOpacityLevel(option.id)}
|
||
className={`rounded-lg px-2 py-1.5 text-[10px] font-bold transition-all ${
|
||
dicomOpacityLevel === option.id ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
|
||
}`}
|
||
>
|
||
DICOM {option.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<label className="mt-2 flex items-center justify-between rounded-lg bg-slate-50 px-2 py-1.5 text-[10px] font-bold text-slate-500">
|
||
显示 DICOM/模型边界
|
||
<input
|
||
type="checkbox"
|
||
checked={showBounds}
|
||
onChange={(event) => setShowBounds(event.target.checked)}
|
||
className="accent-blue-600"
|
||
/>
|
||
</label>
|
||
</div>
|
||
|
||
<div>
|
||
<div className="mb-2 flex items-center justify-between">
|
||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">模型切分</p>
|
||
<label className="flex items-center gap-1 text-[10px] font-bold text-slate-500">
|
||
启用
|
||
<input
|
||
type="checkbox"
|
||
checked={cutEnabled}
|
||
onChange={(event) => setCutEnabled(event.target.checked)}
|
||
className="accent-orange-500"
|
||
/>
|
||
</label>
|
||
</div>
|
||
<p className="rounded-lg bg-orange-50 px-2 py-2 text-[10px] font-bold leading-5 text-orange-700">
|
||
按 DICOM 切片范围 {displaySliceRange.start}-{displaySliceRange.end} 保留模型中间区域
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<div className="mb-2 flex items-center justify-between">
|
||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">模型位姿</p>
|
||
<div className="flex items-center gap-2">
|
||
<button onClick={saveCurrentPose} className="flex items-center gap-1 text-[10px] font-bold text-blue-600 hover:text-blue-700">
|
||
<Save size={12} />
|
||
保存
|
||
</button>
|
||
<button onClick={() => poseImportInputRef.current?.click()} className="flex items-center gap-1 text-[10px] font-bold text-emerald-600 hover:text-emerald-700">
|
||
<Upload size={12} />
|
||
导入
|
||
</button>
|
||
<input
|
||
ref={poseImportInputRef}
|
||
type="file"
|
||
accept="application/json,.json"
|
||
onChange={handleImportPoseFile}
|
||
className="hidden"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<select
|
||
value={selectedPoseId}
|
||
onChange={(event) => selectPose(event.target.value)}
|
||
className="mb-2 h-8 w-full rounded-lg border border-slate-200 bg-white px-2 text-[10px] font-bold text-slate-600 outline-none focus:border-blue-400"
|
||
>
|
||
{selectedPoseId === 'custom' && <option value="custom">当前未保存位姿</option>}
|
||
{savedPoses.map((item) => (
|
||
<option key={item.id} value={item.id}>{item.name}</option>
|
||
))}
|
||
</select>
|
||
{selectedPoseId !== 'default' && selectedPoseId !== 'custom' && (
|
||
<input
|
||
value={savedPoses.find((item) => item.id === selectedPoseId)?.name ?? ''}
|
||
onChange={(event) => renamePose(selectedPoseId, event.target.value)}
|
||
className="mb-2 h-8 w-full rounded-lg border border-slate-200 bg-white px-2 text-[10px] font-bold text-slate-600 outline-none focus:border-blue-400"
|
||
placeholder="位姿名称"
|
||
/>
|
||
)}
|
||
{poseImportStatus && (
|
||
<p className="mb-2 rounded-lg bg-emerald-50 px-2 py-1.5 text-[10px] font-bold text-emerald-700">
|
||
{poseImportStatus}
|
||
</p>
|
||
)}
|
||
<div className="grid grid-cols-3 gap-2">
|
||
<button
|
||
onClick={resetRotationPose}
|
||
className="h-8 rounded-lg bg-blue-50 text-[10px] font-bold text-blue-600 hover:bg-blue-100"
|
||
>
|
||
重置旋转位姿
|
||
</button>
|
||
<button
|
||
onClick={resetTransformPose}
|
||
className="h-8 rounded-lg bg-blue-50 text-[10px] font-bold text-blue-600 hover:bg-blue-100"
|
||
>
|
||
重置平移缩放位姿
|
||
</button>
|
||
<button
|
||
onClick={resetModelFlipPose}
|
||
className="h-8 rounded-lg bg-blue-50 text-[10px] font-bold text-blue-600 hover:bg-blue-100"
|
||
>
|
||
重置镜像位姿
|
||
</button>
|
||
</div>
|
||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||
{modelPoseFlipOptions.map((item) => {
|
||
const Icon = item.icon;
|
||
const enabled = modelPose[item.key];
|
||
return (
|
||
<button
|
||
key={item.key}
|
||
onClick={() => toggleModelFlip(item.key)}
|
||
className={`flex h-8 items-center justify-center gap-1.5 rounded-lg border text-[10px] font-bold transition ${
|
||
enabled
|
||
? 'border-emerald-200 bg-emerald-600 text-white shadow-sm'
|
||
: 'border-slate-100 bg-white text-slate-500 shadow-sm hover:border-emerald-200 hover:bg-emerald-50 hover:text-emerald-700'
|
||
}`}
|
||
title={`以模型中心沿 ${item.axis} 轴镜像翻转`}
|
||
>
|
||
<Icon size={13} />
|
||
{item.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
<div className="mt-3 space-y-2">
|
||
{[
|
||
{ key: 'rotateX' as const, label: '旋转 X', value: modelPose.rotateX },
|
||
{ key: 'rotateY' as const, label: '旋转 Y', value: modelPose.rotateY },
|
||
{ key: 'rotateZ' as const, label: '旋转 Z', value: modelPose.rotateZ },
|
||
{ key: 'translateX' as const, label: '平移 X', value: modelPose.translateX },
|
||
{ key: 'translateY' as const, label: '平移 Y', value: modelPose.translateY },
|
||
{ key: 'translateZ' as const, label: '平移 Z', value: modelPose.translateZ },
|
||
{ key: 'scale' as const, label: '缩放', value: modelPose.scale },
|
||
].map((item) => (
|
||
<div key={item.key} className="grid grid-cols-[44px_28px_1fr_28px_72px] items-center gap-2 text-[10px] font-bold text-slate-500">
|
||
<span>{item.label}</span>
|
||
<button
|
||
onPointerDown={() => {
|
||
startPoseRepeat(item.key, -poseStepConfig[item.key].step);
|
||
}}
|
||
onPointerUp={stopPoseRepeat}
|
||
onPointerLeave={stopPoseRepeat}
|
||
onPointerCancel={stopPoseRepeat}
|
||
onClick={() => nudgeModelPose(item.key, -poseStepConfig[item.key].step)}
|
||
className="h-6 touch-none rounded-md border border-slate-100 bg-white text-[9px] font-bold text-slate-500 shadow-sm hover:bg-blue-50 hover:text-blue-600"
|
||
title={`单击移动最低刻度,长按连续移动。${poseStepConfig[item.key].minus}`}
|
||
>
|
||
-
|
||
</button>
|
||
<input
|
||
type="range"
|
||
min={poseStepConfig[item.key].min}
|
||
max={poseStepConfig[item.key].max}
|
||
step={poseStepConfig[item.key].step}
|
||
value={item.value}
|
||
onChange={(event) => updateModelPose({ [item.key]: Number(event.target.value) })}
|
||
className="accent-blue-600"
|
||
/>
|
||
<button
|
||
onPointerDown={() => {
|
||
startPoseRepeat(item.key, poseStepConfig[item.key].step);
|
||
}}
|
||
onPointerUp={stopPoseRepeat}
|
||
onPointerLeave={stopPoseRepeat}
|
||
onPointerCancel={stopPoseRepeat}
|
||
onClick={() => nudgeModelPose(item.key, poseStepConfig[item.key].step)}
|
||
className="h-6 touch-none rounded-md border border-slate-100 bg-white text-[9px] font-bold text-slate-500 shadow-sm hover:bg-blue-50 hover:text-blue-600"
|
||
title={`单击移动最低刻度,长按连续移动。${poseStepConfig[item.key].plus}`}
|
||
>
|
||
+
|
||
</button>
|
||
<input
|
||
type="number"
|
||
min={poseStepConfig[item.key].min}
|
||
max={poseStepConfig[item.key].max}
|
||
step={poseStepConfig[item.key].step}
|
||
value={poseValueDrafts[item.key]}
|
||
onFocus={() => setFocusedPoseInput(item.key)}
|
||
onChange={(event) => handlePoseInputChange(item.key, event.target.value)}
|
||
onBlur={() => commitPoseInputValue(item.key)}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Enter') {
|
||
event.currentTarget.blur();
|
||
}
|
||
}}
|
||
className="h-7 min-w-0 rounded-md border border-slate-200 bg-white px-1.5 text-right font-mono text-[10px] font-bold text-slate-600 outline-none focus:border-blue-400 focus:bg-blue-50/40"
|
||
/>
|
||
{poseStepConfig[item.key].quick && (
|
||
<div className="col-start-2 col-span-3 grid grid-cols-2 gap-1">
|
||
<button
|
||
onClick={() => nudgeModelPose(item.key, -(poseStepConfig[item.key].quick ?? 0))}
|
||
className="h-6 rounded-md bg-slate-50 text-[9px] font-bold text-slate-500 hover:bg-blue-50 hover:text-blue-600"
|
||
>
|
||
-90°
|
||
</button>
|
||
<button
|
||
onClick={() => nudgeModelPose(item.key, poseStepConfig[item.key].quick ?? 0)}
|
||
className="h-6 rounded-md bg-slate-50 text-[9px] font-bold text-slate-500 hover:bg-blue-50 hover:text-blue-600"
|
||
>
|
||
+90°
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<div className="mb-2 flex items-center justify-between">
|
||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">构件层级</p>
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-[10px] font-mono text-slate-400">{project?.stlFiles?.length ?? 0}</span>
|
||
<button
|
||
onClick={toggleAllModules}
|
||
disabled={!project?.stlFiles?.length}
|
||
className={`rounded p-1 transition ${allModulesVisible ? 'text-blue-500' : 'text-slate-300'} hover:bg-white disabled:opacity-40`}
|
||
title={allModulesVisible ? '隐藏所有构件' : '显示所有构件'}
|
||
>
|
||
<Eye size={14} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="space-y-2">
|
||
{(project?.stlFiles ?? []).map((fileName, index) => {
|
||
const style = moduleStyles[fileName] ?? {
|
||
visible: true,
|
||
color: moduleColors[index % moduleColors.length],
|
||
opacity: 0.72,
|
||
partId: index + 1,
|
||
};
|
||
return (
|
||
<div key={fileName} className={`rounded-xl bg-slate-50 p-2 ${!style.visible ? 'opacity-50' : ''}`}>
|
||
<div className="mb-2 flex items-center gap-2">
|
||
<input
|
||
type="color"
|
||
value={style.color}
|
||
onChange={(event) => updateModuleStyle(fileName, { color: event.target.value })}
|
||
className="h-7 w-7 shrink-0 rounded border border-white bg-white p-0.5"
|
||
title="模型颜色"
|
||
/>
|
||
<div className="min-w-0 flex-1">
|
||
<p className="truncate text-[10px] font-bold text-slate-700">{fileName.replace(/\.stl$/i, '')}</p>
|
||
<label className="mt-1 flex items-center gap-1 text-[9px] font-bold text-slate-400">
|
||
ID
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
max="255"
|
||
value={style.partId}
|
||
onChange={(event) => updateModulePartId(fileName, Number(event.target.value))}
|
||
className="h-5 w-12 rounded border border-slate-200 bg-white px-1 font-mono text-slate-600"
|
||
/>
|
||
</label>
|
||
</div>
|
||
<button
|
||
onClick={() => updateModuleStyle(fileName, { visible: !style.visible })}
|
||
className={`rounded p-1 ${style.visible ? 'text-blue-500' : 'text-slate-300'} hover:bg-white`}
|
||
title={style.visible ? '隐藏构件' : '显示构件'}
|
||
>
|
||
<Eye size={14} />
|
||
</button>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-[9px] text-slate-400">透明度</span>
|
||
<input
|
||
type="range"
|
||
min="0.1"
|
||
max="1"
|
||
step="0.05"
|
||
value={style.opacity}
|
||
onChange={(event) => updateModuleStyle(fileName, { opacity: Number(event.target.value) })}
|
||
className="min-w-0 flex-1 accent-blue-600"
|
||
/>
|
||
<span className="w-7 text-right text-[9px] text-slate-400">{Math.round(style.opacity * 100)}%</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="min-h-0 flex flex-col gap-4 overflow-hidden">
|
||
<div className="min-h-0 flex-1">
|
||
<VoxelizationMappingView
|
||
project={project}
|
||
moduleStyles={moduleStyles}
|
||
modelPose={modelPose}
|
||
detailLimit={selectedDisplay.limit}
|
||
slice={safeMappingSlice}
|
||
totalSlices={project?.dicomCount ?? 0}
|
||
onSliceChange={setMappingSlice}
|
||
displayMode={mappingDisplayMode}
|
||
rotation={mappingRotation}
|
||
variant="library"
|
||
overlayPlacement="bottom"
|
||
toolbar={(
|
||
<>
|
||
<div className="flex rounded-xl bg-white/10 p-1">
|
||
{mappingDisplayModes.map((mode) => (
|
||
<button
|
||
key={mode.id}
|
||
onClick={() => setMappingDisplayMode(mode.id)}
|
||
className={`rounded-lg px-2 py-1 text-[10px] font-bold transition ${
|
||
mappingDisplayMode === mode.id ? 'bg-white text-cyan-700 shadow-sm' : 'text-white/55 hover:text-white'
|
||
}`}
|
||
>
|
||
{mode.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<button
|
||
onClick={() => setMappingRotation((value) => (value + 270) % 360)}
|
||
className="flex h-8 w-8 items-center justify-center rounded-xl border border-white/10 bg-black/60 text-white/65 hover:border-cyan-300/30 hover:text-cyan-100"
|
||
title="左转 90°"
|
||
>
|
||
<RotateCcw size={14} />
|
||
</button>
|
||
<button
|
||
onClick={() => setMappingRotation((value) => (value + 90) % 360)}
|
||
className="flex h-8 w-8 items-center justify-center rounded-xl border border-white/10 bg-black/60 text-white/65 hover:border-cyan-300/30 hover:text-cyan-100"
|
||
title="右转 90°"
|
||
>
|
||
<RotateCw size={14} />
|
||
</button>
|
||
</>
|
||
)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|