Files
REVOXELSEG_DICOM/WebSite/src/components/ReverseWorkspace.tsx

4319 lines
164 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}