2026-05-20-21-25-19 项目库结果视图与加载缓存优化
This commit is contained in:
@@ -26,6 +26,9 @@ import { api, downloadDicomArchive, downloadProjectExportBundle, ProjectExportTa
|
|||||||
import {
|
import {
|
||||||
FusionThreeView,
|
FusionThreeView,
|
||||||
VoxelizationMappingView,
|
VoxelizationMappingView,
|
||||||
|
getCachedDicomFusionVolume,
|
||||||
|
getCachedDicomPreview,
|
||||||
|
getCachedModelPreview,
|
||||||
dicomOpacityOptions as reverseDicomOpacityOptions,
|
dicomOpacityOptions as reverseDicomOpacityOptions,
|
||||||
displayOptions as reverseDisplayOptions,
|
displayOptions as reverseDisplayOptions,
|
||||||
} from './ReverseWorkspace';
|
} from './ReverseWorkspace';
|
||||||
@@ -433,11 +436,7 @@ function NativeStlViewer({
|
|||||||
|
|
||||||
Promise.allSettled(
|
Promise.allSettled(
|
||||||
visibleFiles.map((fileName) =>
|
visibleFiles.map((fileName) =>
|
||||||
fetch(`/api/projects/${projectId}/models/${encodeURIComponent(fileName)}/preview?limit=3500`)
|
getCachedModelPreview(projectId, fileName, 3500)
|
||||||
.then((response) => {
|
|
||||||
if (!response.ok) throw new Error('模型预览数据加载失败');
|
|
||||||
return response.json() as Promise<ModelPreviewPayload>;
|
|
||||||
})
|
|
||||||
.then((payload) => ({
|
.then((payload) => ({
|
||||||
payload,
|
payload,
|
||||||
style: styles[fileName] ?? { color: '#3b82f6', opacity: 0.72, visible: true, partId: 1 },
|
style: styles[fileName] ?? { color: '#3b82f6', opacity: 0.72, visible: true, partId: 1 },
|
||||||
@@ -490,13 +489,7 @@ function NativeStlViewer({
|
|||||||
const loadedBounds: Array<{ min: THREE.Vector3; max: THREE.Vector3 }> = [];
|
const loadedBounds: Array<{ min: THREE.Vector3; max: THREE.Vector3 }> = [];
|
||||||
|
|
||||||
visibleFiles.forEach((fileName) => {
|
visibleFiles.forEach((fileName) => {
|
||||||
fetch(`/api/projects/${projectId}/models/${encodeURIComponent(fileName)}/preview?limit=${detailLimit}`)
|
getCachedModelPreview(projectId, fileName, detailLimit)
|
||||||
.then((response) => {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('模型预览数据加载失败');
|
|
||||||
}
|
|
||||||
return response.json() as Promise<ModelPreviewPayload>;
|
|
||||||
})
|
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
const geometry = new THREE.BufferGeometry();
|
const geometry = new THREE.BufferGeometry();
|
||||||
@@ -697,11 +690,11 @@ export default function ProjectLibrary({
|
|||||||
preloadedProjectIdsRef.current.add(project.id);
|
preloadedProjectIdsRef.current.add(project.id);
|
||||||
const maxSlice = Math.max((project.dicomCount || 1) - 1, 0);
|
const maxSlice = Math.max((project.dicomCount || 1) - 1, 0);
|
||||||
if (project.dicomCount > 0) {
|
if (project.dicomCount > 0) {
|
||||||
void api.getDicomPreview(project.id, maxSlice, 'axial', 'default').catch(() => undefined);
|
void getCachedDicomPreview(project.id, maxSlice, 'axial', 'default').catch(() => undefined);
|
||||||
void api.getDicomFusionVolume(project.id, maxSlice, maxSlice, 'soft').catch(() => undefined);
|
void getCachedDicomFusionVolume(project.id, maxSlice, maxSlice, 'soft').catch(() => undefined);
|
||||||
}
|
}
|
||||||
(project.stlFiles ?? []).slice(0, 3).forEach((fileName) => {
|
(project.stlFiles ?? []).slice(0, 3).forEach((fileName) => {
|
||||||
void fetch(`/api/projects/${project.id}/models/${encodeURIComponent(fileName)}/preview?limit=3500`).catch(() => undefined);
|
void getCachedModelPreview(project.id, fileName, 3500).catch(() => undefined);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -860,7 +853,7 @@ export default function ProjectLibrary({
|
|||||||
dicomRequestRef.current = requestId;
|
dicomRequestRef.current = requestId;
|
||||||
setDicomError('');
|
setDicomError('');
|
||||||
setIsSliceChanging(true);
|
setIsSliceChanging(true);
|
||||||
api.getDicomPreview(selectedProject.id, sliceIndex, plane, displayMode)
|
getCachedDicomPreview(selectedProject.id, sliceIndex, plane, displayMode)
|
||||||
.then((preview) => {
|
.then((preview) => {
|
||||||
if (!cancelled && requestId === dicomRequestRef.current) {
|
if (!cancelled && requestId === dicomRequestRef.current) {
|
||||||
setDicomPreview(preview);
|
setDicomPreview(preview);
|
||||||
@@ -891,7 +884,7 @@ export default function ProjectLibrary({
|
|||||||
const start = Math.min(resultCutStart, resultCutEnd);
|
const start = Math.min(resultCutStart, resultCutEnd);
|
||||||
const end = Math.max(resultCutStart, resultCutEnd);
|
const end = Math.max(resultCutStart, resultCutEnd);
|
||||||
setResultFusionError('');
|
setResultFusionError('');
|
||||||
api.getDicomFusionVolume(selectedProject.id, start, end, 'soft')
|
getCachedDicomFusionVolume(selectedProject.id, start, end, 'soft')
|
||||||
.then((volume) => {
|
.then((volume) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setResultFusionVolume(volume);
|
setResultFusionVolume(volume);
|
||||||
@@ -1566,8 +1559,8 @@ export default function ProjectLibrary({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{viewMode === 'mask' && (
|
{viewMode === 'mask' && (
|
||||||
<div className="h-full grid grid-cols-1 gap-6 2xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_340px]">
|
<div className="grid h-full min-h-0 grid-cols-1 items-stretch gap-6 2xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_340px]">
|
||||||
<div className="min-h-[560px]">
|
<div className="flex min-h-[620px] min-w-0 flex-col">
|
||||||
{latestSegmentationResult ? (
|
{latestSegmentationResult ? (
|
||||||
<FusionThreeView
|
<FusionThreeView
|
||||||
project={selectedProject}
|
project={selectedProject}
|
||||||
@@ -1581,9 +1574,10 @@ export default function ProjectLibrary({
|
|||||||
cutEnabled={latestSegmentationResult.cutEnabled ?? false}
|
cutEnabled={latestSegmentationResult.cutEnabled ?? false}
|
||||||
cutStart={resultCutStart}
|
cutStart={resultCutStart}
|
||||||
cutEnd={resultCutEnd}
|
cutEnd={resultCutEnd}
|
||||||
|
viewPreset="libraryResult"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full min-h-[560px] items-center justify-center rounded-3xl border border-dashed border-slate-200 bg-slate-950 px-8 text-center text-sm font-bold text-white/35">
|
<div className="flex h-full min-h-[620px] items-center justify-center rounded-3xl border border-dashed border-slate-200 bg-slate-950 px-8 text-center text-sm font-bold text-white/35">
|
||||||
暂无保存结果,请在逆向工作区保存当前映射。
|
暂无保存结果,请在逆向工作区保存当前映射。
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1594,42 +1588,7 @@ export default function ProjectLibrary({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-h-[560px]">
|
<div className="flex min-h-[620px] min-w-0 flex-col">
|
||||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<h3 className="flex items-center gap-2 font-bold text-slate-700">
|
|
||||||
<Layers size={18} className="text-cyan-500" />
|
|
||||||
逆向分割映射视图
|
|
||||||
</h3>
|
|
||||||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
|
||||||
<div className="flex rounded-xl bg-slate-100 p-1">
|
|
||||||
{displayModes.map((mode) => (
|
|
||||||
<button
|
|
||||||
key={mode.id}
|
|
||||||
onClick={() => setResultDisplayMode(mode.id)}
|
|
||||||
className={`rounded-lg px-2 py-1 text-[10px] font-bold transition ${
|
|
||||||
resultDisplayMode === mode.id ? 'bg-white text-cyan-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{mode.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setResultRotation((value) => (value + 270) % 360)}
|
|
||||||
className="flex h-7 w-7 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-500 hover:text-cyan-600"
|
|
||||||
title="左转 90°"
|
|
||||||
>
|
|
||||||
<RotateCcw size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setResultRotation((value) => (value + 90) % 360)}
|
|
||||||
className="flex h-7 w-7 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-500 hover:text-cyan-600"
|
|
||||||
title="右转 90°"
|
|
||||||
>
|
|
||||||
<RotateCw size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{latestSegmentationResult ? (
|
{latestSegmentationResult ? (
|
||||||
<VoxelizationMappingView
|
<VoxelizationMappingView
|
||||||
project={selectedProject}
|
project={selectedProject}
|
||||||
@@ -1641,9 +1600,41 @@ export default function ProjectLibrary({
|
|||||||
onSliceChange={setResultPreviewSlice}
|
onSliceChange={setResultPreviewSlice}
|
||||||
displayMode={resultDisplayMode}
|
displayMode={resultDisplayMode}
|
||||||
rotation={resultRotation}
|
rotation={resultRotation}
|
||||||
|
variant="library"
|
||||||
|
toolbar={(
|
||||||
|
<>
|
||||||
|
<div className="flex rounded-xl bg-white/10 p-1">
|
||||||
|
{displayModes.map((mode) => (
|
||||||
|
<button
|
||||||
|
key={mode.id}
|
||||||
|
onClick={() => setResultDisplayMode(mode.id)}
|
||||||
|
className={`rounded-lg px-2 py-1 text-[10px] font-bold transition ${
|
||||||
|
resultDisplayMode === mode.id ? 'bg-white text-cyan-700 shadow-sm' : 'text-white/55 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{mode.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setResultRotation((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={() => setResultRotation((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 className="flex h-full min-h-[520px] items-center justify-center rounded-3xl border border-dashed border-slate-200 bg-slate-950 px-8 text-center text-sm font-bold text-white/35">
|
<div className="flex h-full min-h-[620px] items-center justify-center rounded-3xl border border-dashed border-slate-200 bg-slate-950 px-8 text-center text-sm font-bold text-white/35">
|
||||||
暂无逆向分割映射视图。
|
暂无逆向分割映射视图。
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import * as THREE from 'three';
|
|||||||
import { DicomFusionVolume, DicomPreview, ModelPose, ModuleStyle, Project, SavedModelPose } from '../types';
|
import { DicomFusionVolume, DicomPreview, ModelPose, ModuleStyle, Project, SavedModelPose } from '../types';
|
||||||
import { api, downloadProjectExportBundle, ProjectExportTarget, SegmentationExportScope } from '../lib/api';
|
import { api, downloadProjectExportBundle, ProjectExportTarget, SegmentationExportScope } from '../lib/api';
|
||||||
|
|
||||||
interface ModelPreviewPayload {
|
export interface ModelPreviewPayload {
|
||||||
fileName: string;
|
fileName: string;
|
||||||
triangleCount: number;
|
triangleCount: number;
|
||||||
sampledTriangles: number;
|
sampledTriangles: number;
|
||||||
@@ -46,6 +46,15 @@ interface AxisVector2D {
|
|||||||
type AxisProjection = Record<AxisKey, AxisVector2D>;
|
type AxisProjection = Record<AxisKey, AxisVector2D>;
|
||||||
type WorkspaceLeaveGuard = () => Promise<boolean>;
|
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 modelPoseKeys: ModelPoseKey[] = ['rotateX', 'rotateY', 'rotateZ', 'translateX', 'translateY', 'translateZ', 'scale'];
|
||||||
|
|
||||||
export const displayOptions: Array<{ id: DisplayLevel; label: string; limit: number }> = [
|
export const displayOptions: Array<{ id: DisplayLevel; label: string; limit: number }> = [
|
||||||
@@ -108,6 +117,66 @@ const defaultAxisProjection: AxisProjection = {
|
|||||||
y: { dx: -10, dy: 10, opacity: 0.82 },
|
y: { dx: -10, dy: 10, opacity: 0.82 },
|
||||||
z: { dx: 0, dy: -axisInsetLength, opacity: 0.95 },
|
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>;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function clamp(value: number, min: number, max: number) {
|
function clamp(value: number, min: number, max: number) {
|
||||||
return Math.max(min, Math.min(max, value));
|
return Math.max(min, Math.min(max, value));
|
||||||
@@ -437,6 +506,7 @@ export function FusionThreeView({
|
|||||||
cutEnabled,
|
cutEnabled,
|
||||||
cutStart,
|
cutStart,
|
||||||
cutEnd,
|
cutEnd,
|
||||||
|
viewPreset = 'workspace',
|
||||||
}: {
|
}: {
|
||||||
project: Project;
|
project: Project;
|
||||||
volume: DicomFusionVolume | null;
|
volume: DicomFusionVolume | null;
|
||||||
@@ -449,6 +519,7 @@ export function FusionThreeView({
|
|||||||
cutEnabled: boolean;
|
cutEnabled: boolean;
|
||||||
cutStart: number;
|
cutStart: number;
|
||||||
cutEnd: number;
|
cutEnd: number;
|
||||||
|
viewPreset?: 'workspace' | 'libraryResult';
|
||||||
}) {
|
}) {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const modelPoseRef = useRef(modelPose);
|
const modelPoseRef = useRef(modelPose);
|
||||||
@@ -576,11 +647,7 @@ export function FusionThreeView({
|
|||||||
const loadedBounds: Array<{ min: THREE.Vector3; max: THREE.Vector3 }> = [];
|
const loadedBounds: Array<{ min: THREE.Vector3; max: THREE.Vector3 }> = [];
|
||||||
|
|
||||||
Promise.allSettled(stlFiles.map((fileName, index) => (
|
Promise.allSettled(stlFiles.map((fileName, index) => (
|
||||||
fetch(`/api/projects/${project.id}/models/${encodeURIComponent(fileName)}/preview?limit=${detailLimit}`)
|
getCachedModelPreview(project.id, fileName, detailLimit)
|
||||||
.then((response) => {
|
|
||||||
if (!response.ok) throw new Error('模型预览加载失败');
|
|
||||||
return response.json() as Promise<ModelPreviewPayload>;
|
|
||||||
})
|
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
const style = moduleStyles[fileName] ?? {
|
const style = moduleStyles[fileName] ?? {
|
||||||
@@ -653,7 +720,14 @@ export function FusionThreeView({
|
|||||||
setStatus(visibleStlFiles.length ? '三维融合场景已就绪' : 'DICOM 三维体已就绪,当前没有显示的 STL 构件');
|
setStatus(visibleStlFiles.length ? '三维融合场景已就绪' : 'DICOM 三维体已就绪,当前没有显示的 STL 构件');
|
||||||
});
|
});
|
||||||
|
|
||||||
const defaultRootPose = {
|
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),
|
rotateX: THREE.MathUtils.degToRad(58),
|
||||||
rotateY: 0,
|
rotateY: 0,
|
||||||
rotateZ: THREE.MathUtils.degToRad(-18),
|
rotateZ: THREE.MathUtils.degToRad(-18),
|
||||||
@@ -804,6 +878,7 @@ export function FusionThreeView({
|
|||||||
cutEnabled,
|
cutEnabled,
|
||||||
cutStart,
|
cutStart,
|
||||||
cutEnd,
|
cutEnd,
|
||||||
|
viewPreset,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -938,11 +1013,7 @@ function CutSectionPreview({
|
|||||||
const stlFiles = (project.stlFiles ?? []).filter((fileName) => moduleStyles[fileName]?.visible !== false);
|
const stlFiles = (project.stlFiles ?? []).filter((fileName) => moduleStyles[fileName]?.visible !== false);
|
||||||
|
|
||||||
Promise.allSettled(stlFiles.map((fileName, index) => (
|
Promise.allSettled(stlFiles.map((fileName, index) => (
|
||||||
fetch(`/api/projects/${project.id}/models/${encodeURIComponent(fileName)}/preview?limit=${Math.max(detailLimit, 200000)}`)
|
getCachedModelPreview(project.id, fileName, Math.max(detailLimit, 200000))
|
||||||
.then((response) => {
|
|
||||||
if (!response.ok) throw new Error('模型切面加载失败');
|
|
||||||
return response.json() as Promise<ModelPreviewPayload>;
|
|
||||||
})
|
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
const geometry = new THREE.BufferGeometry();
|
const geometry = new THREE.BufferGeometry();
|
||||||
@@ -1794,6 +1865,8 @@ export function VoxelizationMappingView({
|
|||||||
onSliceChange,
|
onSliceChange,
|
||||||
displayMode,
|
displayMode,
|
||||||
rotation,
|
rotation,
|
||||||
|
variant = 'workspace',
|
||||||
|
toolbar,
|
||||||
}: {
|
}: {
|
||||||
project: Project | null;
|
project: Project | null;
|
||||||
moduleStyles: Record<string, ModuleStyle>;
|
moduleStyles: Record<string, ModuleStyle>;
|
||||||
@@ -1804,6 +1877,8 @@ export function VoxelizationMappingView({
|
|||||||
onSliceChange: (slice: number) => void;
|
onSliceChange: (slice: number) => void;
|
||||||
displayMode: MappingDisplayMode;
|
displayMode: MappingDisplayMode;
|
||||||
rotation: number;
|
rotation: number;
|
||||||
|
variant?: 'workspace' | 'library';
|
||||||
|
toolbar?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const baseCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
const baseCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
const overlayCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
const overlayCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
@@ -1825,6 +1900,7 @@ export function VoxelizationMappingView({
|
|||||||
const safeSlice = clamp(slice, 0, maxSlice);
|
const safeSlice = clamp(slice, 0, maxSlice);
|
||||||
const stlFiles = project?.stlFiles ?? [];
|
const stlFiles = project?.stlFiles ?? [];
|
||||||
const visibleModuleCount = stlFiles.filter((fileName) => moduleStyles[fileName]?.visible !== false).length;
|
const visibleModuleCount = stlFiles.filter((fileName) => moduleStyles[fileName]?.visible !== false).length;
|
||||||
|
const isLibraryVariant = variant === 'library';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!project?.dicomCount) {
|
if (!project?.dicomCount) {
|
||||||
@@ -1835,7 +1911,7 @@ export function VoxelizationMappingView({
|
|||||||
|
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
setDicomStatus('正在载入 DICOM Base Layer...');
|
setDicomStatus('正在载入 DICOM Base Layer...');
|
||||||
api.getDicomPreview(project.id, safeSlice, 'axial', displayMode)
|
getCachedDicomPreview(project.id, safeSlice, 'axial', displayMode)
|
||||||
.then((preview) => {
|
.then((preview) => {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
setDicomPreview(preview);
|
setDicomPreview(preview);
|
||||||
@@ -1862,13 +1938,7 @@ export function VoxelizationMappingView({
|
|||||||
let disposed = false;
|
let disposed = false;
|
||||||
setOverlayStatus('正在载入 STL 构件层级...');
|
setOverlayStatus('正在载入 STL 构件层级...');
|
||||||
Promise.allSettled(stlFiles.map((fileName) => (
|
Promise.allSettled(stlFiles.map((fileName) => (
|
||||||
fetch(`/api/projects/${project.id}/models/${encodeURIComponent(fileName)}/preview?limit=${Math.max(detailLimit, 200000)}`)
|
getCachedModelPreview(project.id, fileName, Math.max(detailLimit, 200000))
|
||||||
.then((response) => {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('STL 构件预览加载失败');
|
|
||||||
}
|
|
||||||
return response.json() as Promise<ModelPreviewPayload>;
|
|
||||||
})
|
|
||||||
.then((payload) => ({ fileName, payload }))
|
.then((payload) => ({ fileName, payload }))
|
||||||
))).then((results) => {
|
))).then((results) => {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
@@ -1982,6 +2052,82 @@ export function VoxelizationMappingView({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 grid-cols-[minmax(0,1fr)_56px] bg-black">
|
||||||
|
<div
|
||||||
|
className={`relative min-h-0 overflow-hidden bg-black ${mappingPanRef.current.active ? 'cursor-grabbing' : 'cursor-grab'}`}
|
||||||
|
onWheel={handleMappingWheel}
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
<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">
|
||||||
|
{safeSlice + 1} / {Math.max(totalSlices, 1)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="flex min-h-0 flex-col items-center border-l border-white/10 bg-[#0f172a] px-2 py-5">
|
||||||
|
<div className="relative min-h-[280px] 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" />
|
||||||
|
<div
|
||||||
|
className="absolute bottom-0 left-1/2 w-1.5 -translate-x-1/2 rounded-full bg-cyan-400"
|
||||||
|
style={{ height: `${slicePercent}%` }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max={maxSlice}
|
||||||
|
value={safeSlice}
|
||||||
|
onChange={(event) => onSliceChange(Number(event.target.value))}
|
||||||
|
className="mapping-slice-dark-vertical-input"
|
||||||
|
aria-label="项目库逆向分割映射视图切片导航"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
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="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 items-center justify-between gap-3 border-b border-slate-100 bg-white px-4 py-3">
|
||||||
@@ -2150,8 +2296,16 @@ export default function ReverseWorkspace({
|
|||||||
const [saveStatus, setSaveStatus] = useState('');
|
const [saveStatus, setSaveStatus] = useState('');
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
const [stretchingAxis, setStretchingAxis] = useState<AxisKey | null>(null);
|
const [stretchingAxis, setStretchingAxis] = useState<AxisKey | null>(null);
|
||||||
const fusionVolumeCacheRef = useRef(new Map<string, DicomFusionVolume>());
|
|
||||||
const modelBoundsCacheRef = useRef(new Map<string, { min: THREE.Vector3; max: THREE.Vector3 }>());
|
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 poseRepeatRef = useRef<{ timeout: number | null; interval: number | null }>({ timeout: null, interval: null });
|
||||||
const poseImportInputRef = useRef<HTMLInputElement | null>(null);
|
const poseImportInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const saveToastTimerRef = useRef<number | null>(null);
|
const saveToastTimerRef = useRef<number | null>(null);
|
||||||
@@ -2319,23 +2473,14 @@ export default function ReverseWorkspace({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFusionCacheKey = (projectIdValue: string, start: number, end: number, mode = 'soft') => `${projectIdValue}:${mode}:${start}:${end}`;
|
const loadFusionVolume = async (start: number, end: number) => {
|
||||||
|
|
||||||
const loadFusionVolume = async (start: number, end: number, useCache = true) => {
|
|
||||||
if (!project?.dicomCount) return null;
|
if (!project?.dicomCount) return null;
|
||||||
const maxSliceValue = Math.max(project.dicomCount - 1, 0);
|
const maxSliceValue = Math.max(project.dicomCount - 1, 0);
|
||||||
const safeA = clamp(start, 0, maxSliceValue);
|
const safeA = clamp(start, 0, maxSliceValue);
|
||||||
const safeB = clamp(end, 0, maxSliceValue);
|
const safeB = clamp(end, 0, maxSliceValue);
|
||||||
const safeStart = Math.min(safeA, safeB);
|
const safeStart = Math.min(safeA, safeB);
|
||||||
const rangeEnd = Math.max(safeA, safeB);
|
const rangeEnd = Math.max(safeA, safeB);
|
||||||
const cacheKey = getFusionCacheKey(project.id, safeStart, rangeEnd);
|
const volumePayload = await getCachedDicomFusionVolume(project.id, safeStart, rangeEnd, 'soft');
|
||||||
const cached = fusionVolumeCacheRef.current.get(cacheKey);
|
|
||||||
if (useCache && cached) {
|
|
||||||
setFusionVolume(cached);
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
const volumePayload = await api.getDicomFusionVolume(project.id, safeStart, rangeEnd, 'soft');
|
|
||||||
fusionVolumeCacheRef.current.set(cacheKey, volumePayload);
|
|
||||||
return volumePayload;
|
return volumePayload;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2352,13 +2497,7 @@ export default function ReverseWorkspace({
|
|||||||
|
|
||||||
const modelBox = new THREE.Box3();
|
const modelBox = new THREE.Box3();
|
||||||
const results = await Promise.allSettled(visibleFiles.map((fileName) => (
|
const results = await Promise.allSettled(visibleFiles.map((fileName) => (
|
||||||
fetch(`/api/projects/${project.id}/models/${encodeURIComponent(fileName)}/preview?limit=1000`)
|
getCachedModelPreview(project.id, fileName, 1000)
|
||||||
.then((response) => {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('模型边界载入失败');
|
|
||||||
}
|
|
||||||
return response.json() as Promise<ModelPreviewPayload>;
|
|
||||||
})
|
|
||||||
)));
|
)));
|
||||||
results.forEach((result) => {
|
results.forEach((result) => {
|
||||||
if (result.status !== 'fulfilled' || !result.value.bounds) {
|
if (result.status !== 'fulfilled' || !result.value.bounds) {
|
||||||
@@ -2422,6 +2561,15 @@ export default function ReverseWorkspace({
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
workspaceLoadProjectRef.current = '';
|
||||||
|
setWorkspaceLoadState({
|
||||||
|
ready: false,
|
||||||
|
phase: '正在读取项目配置...',
|
||||||
|
loaded: 0,
|
||||||
|
total: 1,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
error: '',
|
||||||
|
});
|
||||||
api.getProject(projectId).then((item) => {
|
api.getProject(projectId).then((item) => {
|
||||||
setProject(item);
|
setProject(item);
|
||||||
const maxIndex = Math.max((item.dicomCount || 1) - 1, 0);
|
const maxIndex = Math.max((item.dicomCount || 1) - 1, 0);
|
||||||
@@ -2466,6 +2614,14 @@ export default function ReverseWorkspace({
|
|||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
setProject(null);
|
setProject(null);
|
||||||
setFusionVolume(null);
|
setFusionVolume(null);
|
||||||
|
setWorkspaceLoadState({
|
||||||
|
ready: false,
|
||||||
|
phase: '项目配置读取失败',
|
||||||
|
loaded: 0,
|
||||||
|
total: 1,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
error: '项目配置读取失败',
|
||||||
|
});
|
||||||
savedWorkspaceSnapshotRef.current = '';
|
savedWorkspaceSnapshotRef.current = '';
|
||||||
});
|
});
|
||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
@@ -2722,6 +2878,161 @@ export default function ReverseWorkspace({
|
|||||||
const selectedDisplay = displayOptions.find((item) => item.id === displayLevel) ?? displayOptions[0];
|
const selectedDisplay = displayOptions.find((item) => item.id === displayLevel) ?? displayOptions[0];
|
||||||
const selectedDicomOpacity = dicomOpacityOptions.find((item) => item.id === dicomOpacityLevel) ?? dicomOpacityOptions[0];
|
const selectedDicomOpacity = dicomOpacityOptions.find((item) => item.id === dicomOpacityLevel) ?? dicomOpacityOptions[0];
|
||||||
const stretchEnabled = Boolean(project && fusionVolume && isOrthogonalModelPose(modelPose));
|
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) {
|
||||||
|
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, 200000);
|
||||||
|
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?.stlFiles?.join('|'),
|
||||||
|
displayStart,
|
||||||
|
displayEnd,
|
||||||
|
safeMappingSlice,
|
||||||
|
selectedDisplay.limit,
|
||||||
|
mappingDisplayMode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="h-full min-h-0 overflow-y-auto pr-2 flex flex-col gap-6">
|
<div className="h-full min-h-0 overflow-y-auto pr-2 flex flex-col gap-6">
|
||||||
|
|||||||
@@ -174,3 +174,61 @@
|
|||||||
.mapping-slice-vertical-input:active::-moz-range-thumb {
|
.mapping-slice-vertical-input:active::-moz-range-thumb {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mapping-slice-dark-vertical-input {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
height: 100%;
|
||||||
|
inset: 0;
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
direction: rtl;
|
||||||
|
writing-mode: vertical-rl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-slice-dark-vertical-input:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-slice-dark-vertical-input::-webkit-slider-runnable-track {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-slice-dark-vertical-input::-webkit-slider-thumb {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
background: #22d3ee;
|
||||||
|
border: 3px solid #0f172a;
|
||||||
|
border-radius: 9999px;
|
||||||
|
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.16), 0 8px 18px rgba(8, 47, 73, 0.45);
|
||||||
|
cursor: grab;
|
||||||
|
height: 20px;
|
||||||
|
width: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-slice-dark-vertical-input::-moz-range-track {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-slice-dark-vertical-input::-moz-range-thumb {
|
||||||
|
background: #22d3ee;
|
||||||
|
border: 3px solid #0f172a;
|
||||||
|
border-radius: 9999px;
|
||||||
|
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.16), 0 8px 18px rgba(8, 47, 73, 0.45);
|
||||||
|
cursor: grab;
|
||||||
|
height: 14px;
|
||||||
|
width: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-slice-dark-vertical-input:active::-webkit-slider-thumb {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-slice-dark-vertical-input:active::-moz-range-thumb {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|||||||
58
工程分析/实现方案-2026-05-20-21-25-19.md
Normal file
58
工程分析/实现方案-2026-05-20-21-25-19.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# 实现方案-2026-05-20-21-25-19
|
||||||
|
|
||||||
|
## 实现方案文档路径
|
||||||
|
|
||||||
|
`工程分析/实现方案-2026-05-20-21-25-19.md`
|
||||||
|
|
||||||
|
## 修改目标
|
||||||
|
|
||||||
|
1. 优化项目库“逆向分割结果”中的三维融合视图默认/复位视角,使复位后呈现用户示意的正向融合观察效果。
|
||||||
|
2. 为 `VoxelizationMappingView` 增加项目库紧凑展示模式,去掉无意义标签,把窗宽按钮、复位按钮、视图标题、DICOM 切片位置和竖向导航重新布局。
|
||||||
|
3. 调整项目库结果区左右面板高度与底部对齐。
|
||||||
|
4. 为逆向工作区增加加载中页面,展示加载进度、加载速度和当前阶段,数据未就绪时不显示完整工作区。
|
||||||
|
5. 增加 DICOM 融合体与 STL 预览数据的内存缓存,减少跨模块返回后的重复加载。
|
||||||
|
|
||||||
|
## 涉及路径
|
||||||
|
|
||||||
|
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||||
|
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||||
|
- `WebSite/src/index.css`
|
||||||
|
- `工程分析/经验记录.md`
|
||||||
|
|
||||||
|
## 技术路线
|
||||||
|
|
||||||
|
- 在 `ReverseWorkspace.tsx` 中增加模块级缓存 Map,用于缓存 DICOM fusion volume、STL preview、映射视图预览数据。
|
||||||
|
- 为 `FusionThreeView` 增加 `variant` 或复位视角参数,项目库复用同一组件但使用较适合结果复核的默认视角。
|
||||||
|
- 为 `VoxelizationMappingView` 增加 `variant="workspace" | "library"`,项目库模式采用黑色画布、精简标题和窄导航栏;工作区保持完整操作信息。
|
||||||
|
- 用加载指标组合逆向工作区所需数据状态,未完成时显示独立加载卡片。
|
||||||
|
- 调整项目库逆向结果布局为等高网格,并保证左右图容器底部一致。
|
||||||
|
|
||||||
|
## 执行步骤
|
||||||
|
|
||||||
|
1. 阅读现有 `ProjectLibrary.tsx`、`ReverseWorkspace.tsx` 的视图复用和数据加载逻辑。
|
||||||
|
2. 写入本次需求、实现、测试方案文档。
|
||||||
|
3. 修改融合视图与映射视图组件参数,补齐紧凑模式和复位视角。
|
||||||
|
4. 增加数据缓存与加载页,避免跨页面返回时重复拉取大数据。
|
||||||
|
5. 调整项目库结果区布局和控件位置。
|
||||||
|
6. 执行类型检查、构建、服务重启与健康检查。
|
||||||
|
7. 追加经验记录并提交推送到 Gitea。
|
||||||
|
|
||||||
|
## 兼容性与回滚方案
|
||||||
|
|
||||||
|
- 新增 UI 变体保持默认值为工作区模式,避免影响已有调用。
|
||||||
|
- 缓存仅保存在浏览器内存中,刷新页面会自然清空;若出现旧数据问题,可删除缓存分支回退为原请求逻辑。
|
||||||
|
- 如果紧凑模式影响项目库结果展示,可只回退 `variant="library"` 调用,不影响逆向工作区主流程。
|
||||||
|
|
||||||
|
## 预计文件变更
|
||||||
|
|
||||||
|
- 修改 `ReverseWorkspace.tsx`:组件参数、缓存、加载页、视图布局。
|
||||||
|
- 修改 `ProjectLibrary.tsx`:调用参数、布局对齐、控件位置。
|
||||||
|
- 视需要修改 `index.css`:竖向导航样式微调。
|
||||||
|
- 新增本次三份工程分析文档,并追加经验记录。
|
||||||
|
|
||||||
|
## 提交与部署策略
|
||||||
|
|
||||||
|
- 只暂存本次修改的源码和本次工程分析文档。
|
||||||
|
- commit message 使用 `2026-05-20-21-25-19 项目库结果视图与加载缓存优化`。
|
||||||
|
- 推送到 `http://192.168.31.5:5002/admin/REVOXELSEG_DICOM.git` 的 `main` 分支。
|
||||||
|
- 使用 `tmux` 会话 `revoxelseg-dicom` 重新部署并验证。
|
||||||
61
工程分析/测试方案-2026-05-20-21-25-19.md
Normal file
61
工程分析/测试方案-2026-05-20-21-25-19.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# 测试方案-2026-05-20-21-25-19
|
||||||
|
|
||||||
|
## 测试方案文档路径
|
||||||
|
|
||||||
|
`工程分析/测试方案-2026-05-20-21-25-19.md`
|
||||||
|
|
||||||
|
## 静态检查
|
||||||
|
|
||||||
|
- 在 `WebSite/` 执行 `npm run lint`,确认 TypeScript 类型检查通过。
|
||||||
|
|
||||||
|
## 构建检查
|
||||||
|
|
||||||
|
- 在 `WebSite/` 执行 `npm run build`,确认 Vite 生产构建通过。
|
||||||
|
|
||||||
|
## 关键业务场景验证
|
||||||
|
|
||||||
|
- 项目库进入“逆向分割结果”,确认左右两张图底部对齐。
|
||||||
|
- 点击项目库三维融合视角“位置重置”,确认出现“三维融合视角已复位”类反馈,视角呈现正向融合观察效果。
|
||||||
|
- 项目库逆向分割映射视图不再显示“逆向分割映射视图”标题行,不再显示 `Base DICOM`、`Overlay Label Map` 标签。
|
||||||
|
- 项目库映射视图同一行显示窗宽按钮和位置重置按钮。
|
||||||
|
- 项目库映射视图黑色画布左上显示“逆向分割映射视图”,右下显示 DICOM 切片位置,右侧只保留竖向进度条。
|
||||||
|
- 逆向工作区首次进入时,在数据加载完成前显示加载进度、速度和阶段;完成后进入完整工作区。
|
||||||
|
- 从逆向工作区切到其他模块再返回,确认已加载数据复用缓存,减少重复加载等待。
|
||||||
|
|
||||||
|
## 医学影像数据相关边界验证
|
||||||
|
|
||||||
|
- DICOM 切片范围为 300 层时,右侧竖向进度条顶/底/当前值显示正确。
|
||||||
|
- 切片切换后,DICOM 底图和 Overlay Label Map 仍同步更新。
|
||||||
|
- 位置重置只改变浏览视口,不改变模型位姿与导出参数。
|
||||||
|
|
||||||
|
## 部署验证
|
||||||
|
|
||||||
|
- 使用 `tmux` 会话 `revoxelseg-dicom` 启动:
|
||||||
|
- `cd WebSite`
|
||||||
|
- `npm run serve -- --host 0.0.0.0 --port 4000`
|
||||||
|
- 验证:
|
||||||
|
- `http://127.0.0.1:4000/api/health`
|
||||||
|
- `http://127.0.0.1:4000/`
|
||||||
|
|
||||||
|
## Git/Gitea 备份验证
|
||||||
|
|
||||||
|
- 检查 `git status --short`,确认只暂存本次相关文件。
|
||||||
|
- commit message 包含 `2026-05-20-21-25-19` 与简要描述。
|
||||||
|
- 推送到 Gitea 后确认远端更新成功。
|
||||||
|
|
||||||
|
## 风险与回归关注点
|
||||||
|
|
||||||
|
- 项目库紧凑变体不能影响逆向工作区完整编辑态。
|
||||||
|
- 缓存不能导致项目切换后显示上一个项目的 DICOM/STL 数据。
|
||||||
|
- 加载页不能在部分非关键数据失败时永久阻塞用户进入工作区。
|
||||||
|
|
||||||
|
## 执行结果
|
||||||
|
|
||||||
|
- `npm run lint`:通过。
|
||||||
|
- `npm run build`:通过,仅保留 Vite 大 chunk 既有提示。
|
||||||
|
- 重新部署:已重启 `tmux` 会话 `revoxelseg-dicom`,服务监听 `0.0.0.0:4000`。
|
||||||
|
- `curl -fsS http://127.0.0.1:4000/api/health`:通过,返回 `ok: true`。
|
||||||
|
- `curl -I -fsS http://127.0.0.1:4000/`:通过,返回 `HTTP/1.1 200 OK`。
|
||||||
|
- DICOM/项目数据接口抽查:`/api/projects`、`/api/projects/head-ct-demo/dicom-fusion-volume`、`/api/projects/head-ct-demo/dicom-preview` 均有有效响应。
|
||||||
|
- 页面级截图:使用系统 Chrome 生成 `/tmp/revoxelseg-home.png`,页面可渲染;因当前应用启动时会自动退出登录,未使用无交互截图验证登录后的三维画布。
|
||||||
|
- 备注:尝试临时安装 `playwright-core` 做交互截图验证,但安装过程无输出且超时,已终止,未产生仓库文件变更。
|
||||||
18
工程分析/经验记录.md
18
工程分析/经验记录.md
@@ -1333,3 +1333,21 @@ C. 解决问题方案
|
|||||||
D. 后续如何避免问题
|
D. 后续如何避免问题
|
||||||
|
|
||||||
新增影像查看交互时必须先判断它属于“浏览视口”还是“结果位姿”。浏览视口状态默认不进入保存和导出;只有会改变模型与 DICOM 空间关系的参数才进入 `modelPose`、保存快照和导出数据。
|
新增影像查看交互时必须先判断它属于“浏览视口”还是“结果位姿”。浏览视口状态默认不进入保存和导出;只有会改变模型与 DICOM 空间关系的参数才进入 `modelPose`、保存快照和导出数据。
|
||||||
|
|
||||||
|
## 2026-05-20-21-25-19 结果复核视图与加载缓存要拆分展示形态
|
||||||
|
|
||||||
|
A. 具体问题
|
||||||
|
|
||||||
|
用户要求项目库“逆向分割结果”复用真实三维融合和二维映射效果,但项目库是复核场景,不需要工作区里的完整编辑控件;同时逆向工作区和项目库在跨模块返回时重复载入 DICOM/STL 预览,等待感明显。
|
||||||
|
|
||||||
|
B. 产生问题原因
|
||||||
|
|
||||||
|
此前 `FusionThreeView` 和 `VoxelizationMappingView` 虽然已复用到项目库,但组件只有一种工作区展示形态,项目库只能带着 Base/Overlay 标签、统计面板和较宽导航一起展示。DICOM 预览、DICOM fusion volume 和 STL preview 的缓存也在组件或页面局部,页面卸载后缓存随之丢失。
|
||||||
|
|
||||||
|
C. 解决问题方案
|
||||||
|
|
||||||
|
将只读预览数据缓存提升为模块级缓存函数,项目库、逆向工作区和 STL 查看器统一使用 `getCachedDicomPreview`、`getCachedDicomFusionVolume`、`getCachedModelPreview`。`FusionThreeView` 增加项目库复核视角预设,复位后呈现更正向的三维融合观察角度;`VoxelizationMappingView` 增加 `library` 紧凑变体,把窗宽、旋转和位置重置放在同一黑色工具行,画布内只保留视图名、DICOM 切片位置和窄竖向进度条。逆向工作区首次进入前增加加载页,显示进度、阶段和速度。
|
||||||
|
|
||||||
|
D. 后续如何避免问题
|
||||||
|
|
||||||
|
同一个医学视图组件被复用到“编辑工作区”和“项目库复核”时,应优先通过 `variant` 或视角预设区分展示密度,而不是复制第二套近似实现。大体积医学预览数据应使用按项目、切片、窗宽、文件和采样精度组成的缓存 key,避免跨项目串数据;加载页只阻塞首次必要数据,不把普通浏览视口变化误判为需要重新进入全屏加载。
|
||||||
|
|||||||
50
工程分析/需求分析-2026-05-20-21-25-19.md
Normal file
50
工程分析/需求分析-2026-05-20-21-25-19.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# 需求分析-2026-05-20-21-25-19
|
||||||
|
|
||||||
|
## 开始时间
|
||||||
|
|
||||||
|
2026-05-20-21-25-19
|
||||||
|
|
||||||
|
## 原始需求摘要
|
||||||
|
|
||||||
|
用户要求继续优化项目库与逆向工作区的逆向分割结果查看体验:项目库的三维融合视角复位后应呈现指定观察角度;项目库右侧逆向分割映射视图需更紧凑,删除无意义标题行与 Base/Overlay 标签,将窗宽按钮和位置重置整合在同一行,黑色影像区左上显示视图名、右下显示 DICOM 切片位置,右侧仅保留一个进度条;项目库逆向分割结果左右图底部对齐;逆向工作区加载未完成前不显示半成品工作区,而显示进度、速度等加载反馈;从逆向工作区或项目库切换到其他模块再返回时,应减轻重复加载。
|
||||||
|
|
||||||
|
## 业务目标
|
||||||
|
|
||||||
|
- 让项目库中的逆向分割结果更接近保存结果复核视图,减少控件占用面积。
|
||||||
|
- 让项目库左右两张结果图在视觉底线和布局高度上对齐,便于横向比较。
|
||||||
|
- 避免用户在数据未加载完时看到半成品工作区,降低误解。
|
||||||
|
- 对大体积 DICOM/STL 预览数据做页面级缓存,减少跨模块返回时的重复请求和等待。
|
||||||
|
|
||||||
|
## 输入与输出
|
||||||
|
|
||||||
|
- 输入:项目库最新逆向分割结果、DICOM 融合体数据、STL 预览数据、构件样式、切片位置和窗宽模式。
|
||||||
|
- 输出:项目库紧凑版逆向分割结果视图、逆向工作区加载页、复用缓存后的三维融合视图和二维映射视图。
|
||||||
|
|
||||||
|
## 影响范围
|
||||||
|
|
||||||
|
- `WebSite/src/components/ProjectLibrary.tsx`
|
||||||
|
- `WebSite/src/components/ReverseWorkspace.tsx`
|
||||||
|
- `WebSite/src/index.css`
|
||||||
|
- `工程分析/经验记录.md`
|
||||||
|
- 本次需求/实现/测试方案文档
|
||||||
|
|
||||||
|
## 关键约束
|
||||||
|
|
||||||
|
- 医学影像主画布不能被无意义标签遮挡。
|
||||||
|
- 位置重置应只重置浏览视角,不修改模型位姿或导出结果。
|
||||||
|
- 缓存只能缓存只读预览数据,不应缓存用户保存状态导致脏状态误判。
|
||||||
|
- 项目库和逆向工作区复用同一真实视图组件时,必须允许项目库使用更紧凑的 UI 变体。
|
||||||
|
- 提交时不能混入当前工作树已有的历史删除和软著资料。
|
||||||
|
|
||||||
|
## 风险点
|
||||||
|
|
||||||
|
- Three.js 组件缓存与 React 生命周期若处理不当,切换页面后可能出现旧场景未更新或重复加载。
|
||||||
|
- 右侧竖向进度条如果在紧凑布局中宽度过大,会继续挤压 DICOM 画布。
|
||||||
|
- 加载页若依赖过严,可能在部分 STL 失败时一直不进入工作区。
|
||||||
|
- 项目库复用工作区组件时,需要避免把工作区专用操作暴露到项目库。
|
||||||
|
|
||||||
|
## 默认假设
|
||||||
|
|
||||||
|
- “减轻重复加载”优先通过浏览器内存级缓存完成,暂不引入 IndexedDB 或后端缓存。
|
||||||
|
- 加载完成标准以 DICOM 融合体加载完成、STL 预览基础数据可用为主;个别 STL 预览失败时显示错误但不永久阻塞页面。
|
||||||
|
- 项目库逆向分割映射视图可以使用紧凑变体,逆向工作区保留更完整的编辑态信息。
|
||||||
Reference in New Issue
Block a user