From 4ba85eba6e0435a0bed539f33e94005056a34d2d Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Fri, 8 May 2026 01:29:58 +0800 Subject: [PATCH] =?UTF-8?q?2026-05-08-01-19-42=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E8=9E=8D=E5=90=88=E8=A7=86=E8=A7=92=E5=92=8C=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E4=BD=8D=E5=A7=BF=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WebSite/src/components/ProjectLibrary.tsx | 8 +- WebSite/src/components/ReverseWorkspace.tsx | 393 ++++++++++++++++++-- WebSite/src/components/Sidebar.tsx | 3 +- 工程分析/实现方案-2026-05-08-01-19-42.md | 108 ++++++ 工程分析/测试方案-2026-05-08-01-19-42.md | 58 +++ 工程分析/经验记录.md | 18 + 工程分析/需求分析-2026-05-08-01-19-42.md | 56 +++ 7 files changed, 609 insertions(+), 35 deletions(-) create mode 100644 工程分析/实现方案-2026-05-08-01-19-42.md create mode 100644 工程分析/测试方案-2026-05-08-01-19-42.md create mode 100644 工程分析/需求分析-2026-05-08-01-19-42.md diff --git a/WebSite/src/components/ProjectLibrary.tsx b/WebSite/src/components/ProjectLibrary.tsx index 92d579a..56abc62 100644 --- a/WebSite/src/components/ProjectLibrary.tsx +++ b/WebSite/src/components/ProjectLibrary.tsx @@ -1111,7 +1111,7 @@ export default function ProjectLibrary({
{viewMode === 'dicom' && ( -
+
{/* Left: DICOM Viewer */}
@@ -1270,8 +1270,8 @@ export default function ProjectLibrary({
{/* Right: Sub-module List */} -
-
+
+

模型显示

@@ -1360,7 +1360,7 @@ export default function ProjectLibrary({
-
+
{stlFiles.map((fileName, i) => { const name = fileName.replace(/\.stl$/i, ''); const style = moduleStyles[fileName] ?? { visible: true, color: defaultModuleColors[i % defaultModuleColors.length], opacity: 0.72, partId: i + 1 }; diff --git a/WebSite/src/components/ReverseWorkspace.tsx b/WebSite/src/components/ReverseWorkspace.tsx index ac098b0..37ef66e 100644 --- a/WebSite/src/components/ReverseWorkspace.tsx +++ b/WebSite/src/components/ReverseWorkspace.tsx @@ -37,6 +37,8 @@ interface ModelPreviewPayload { } type DisplayLevel = 'standard' | 'fine' | 'ultra' | 'solid'; +type DicomOpacityLevel = 'low' | 'medium' | 'high'; +type ModelPoseKey = keyof ModelPose; const displayOptions: Array<{ id: DisplayLevel; label: string; limit: number }> = [ { id: 'standard', label: '标准', limit: 16000 }, @@ -44,6 +46,20 @@ const displayOptions: Array<{ id: DisplayLevel; label: string; limit: number }> { id: 'ultra', label: '超精细', limit: 72000 }, { id: 'solid', label: '实体', limit: 200000 }, ]; +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 poseStepConfig: Record = { + rotateX: { min: -180, max: 180, step: 1, minus: '-90°', plus: '+90°', quick: 90 }, + rotateY: { min: -180, max: 180, step: 1, minus: '-90°', plus: '+90°', quick: 90 }, + rotateZ: { min: -180, max: 180, step: 1, minus: '-90°', plus: '+90°', quick: 90 }, + translateX: { min: -2, max: 2, step: 0.05, minus: '-X', plus: '+X' }, + translateY: { min: -2, max: 2, step: 0.05, minus: '-Y', plus: '+Y' }, + translateZ: { min: -2, max: 2, step: 0.05, minus: '-Z', plus: '+Z' }, + scale: { min: 0.5, max: 2, step: 0.05, minus: '-S', plus: '+S' }, +}; const defaultModelPose: ModelPose = { rotateX: 0, @@ -97,6 +113,10 @@ function FusionThreeView({ moduleStyles, detailLimit, solidMode, + dicomOpacity, + showBounds, + cutEnabled, + cutSlice, }: { project: Project; volume: DicomFusionVolume | null; @@ -104,6 +124,10 @@ function FusionThreeView({ moduleStyles: Record; detailLimit: number; solidMode: boolean; + dicomOpacity: { sliceOpacity: number; volumeOpacity: number; boxOpacity: number }; + showBounds: boolean; + cutEnabled: boolean; + cutSlice: number; }) { const containerRef = useRef(null); const modelPoseRef = useRef(modelPose); @@ -137,6 +161,7 @@ function FusionThreeView({ const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); 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)); @@ -165,15 +190,34 @@ function FusionThreeView({ const box = new THREE.Mesh( new THREE.BoxGeometry(dicomWidth, dicomHeight, dicomDepth), - new THREE.MeshBasicMaterial({ color: '#020617', transparent: true, opacity: 0.32, depthWrite: false }), + 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 cutZ = volume.total <= 1 + ? 0 + : -dicomDepth / 2 + (dicomDepth * clamp(cutSlice, 0, volume.total - 1)) / (volume.total - 1); + const clippingPlane = new THREE.Plane(new THREE.Vector3(0, 0, -1), cutZ); + const cutPlane = new THREE.Mesh( + new THREE.PlaneGeometry(dicomWidth, dicomHeight), + new THREE.MeshBasicMaterial({ + color: '#f97316', + transparent: true, + opacity: cutEnabled ? 0.24 : 0, + side: THREE.DoubleSide, + depthWrite: false, + }), + ); + cutPlane.position.set(0, 0, cutZ); + cutPlane.visible = cutEnabled; + dicomGroup.add(cutPlane); + const textures: THREE.Texture[] = []; volume.frames.forEach((frame, index) => { const texture = createDicomTexture(frame, volume.width, volume.height); @@ -183,7 +227,7 @@ function FusionThreeView({ const material = new THREE.MeshBasicMaterial({ map: texture, transparent: true, - opacity: isLast ? 0.82 : 0.12, + opacity: isLast ? dicomOpacity.sliceOpacity : dicomOpacity.volumeOpacity, side: THREE.DoubleSide, depthWrite: false, }); @@ -227,6 +271,8 @@ function FusionThreeView({ roughness: solidMode ? 0.56 : 0.48, metalness: 0.03, side: THREE.DoubleSide, + clippingPlanes: cutEnabled ? [clippingPlane] : [], + clipShadows: true, }); const mesh = new THREE.Mesh(geometry, material); modelPivot.add(mesh); @@ -262,8 +308,15 @@ function FusionThreeView({ 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, dicomDepth * 0.08); + modelPoseGroup.position.set(0, 0, 0); + modelPivot.position.set(0, 0, dicomDepth * 0.08); setLoadProgress(100); setStatus(stlFiles.length ? '三维融合场景已就绪' : 'DICOM 三维体已就绪,当前项目没有 STL'); }); @@ -339,6 +392,12 @@ function FusionThreeView({ 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 cutNormal = new THREE.Vector3(0, 0, -1).applyQuaternion(fusionRoot.getWorldQuaternion(new THREE.Quaternion())).normalize(); + const cutPoint = new THREE.Vector3(0, 0, cutZ).applyMatrix4(fusionRoot.matrixWorld); + clippingPlane.setFromNormalAndCoplanarPoint(cutNormal, cutPoint); + } const pose = modelPoseRef.current; modelPoseGroup.rotation.set( @@ -349,7 +408,7 @@ function FusionThreeView({ modelPoseGroup.position.set( pose.translateX, pose.translateY, - dicomDepth * 0.08 + pose.translateZ, + pose.translateZ, ); modelPoseGroup.scale.setScalar(modelBaseScale * pose.scale); renderer.render(scene, camera); @@ -382,7 +441,20 @@ function FusionThreeView({ renderer.dispose(); container.innerHTML = ''; }; - }, [project.id, project.stlFiles?.join('|'), volume, JSON.stringify(moduleStyles), detailLimit, solidMode]); + }, [ + project.id, + project.stlFiles?.join('|'), + volume, + JSON.stringify(moduleStyles), + detailLimit, + solidMode, + dicomOpacity.sliceOpacity, + dicomOpacity.volumeOpacity, + dicomOpacity.boxOpacity, + showBounds, + cutEnabled, + cutSlice, + ]); return (
@@ -417,6 +489,10 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { const [sliceEnd, setSliceEnd] = useState(49); const [modelPose, setModelPose] = useState(defaultModelPose); const [displayLevel, setDisplayLevel] = useState('standard'); + const [dicomOpacityLevel, setDicomOpacityLevel] = useState('low'); + const [showBounds, setShowBounds] = useState(true); + const [cutEnabled, setCutEnabled] = useState(false); + const [cutSlice, setCutSlice] = useState(0); const [moduleStyles, setModuleStyles] = useState>({}); const [savedPoses, setSavedPoses] = useState>([ { id: 'default', name: '默认', pose: defaultModelPose }, @@ -431,6 +507,9 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { const [fusionError, setFusionError] = useState(''); const [exporting, setExporting] = useState(false); const [exportMessage, setExportMessage] = useState('准备就绪'); + const [preloadMessage, setPreloadMessage] = useState('缓存空闲'); + const fusionVolumeCacheRef = useRef(new Map()); + const poseRepeatRef = useRef<{ timeout: number | null; interval: number | null }>({ timeout: null, interval: null }); const [mappings] = useState([ { className: '骨样组织', color: '#ff4d4f', maskId: 1 }, @@ -477,11 +556,30 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { }); }; + const getFusionCacheKey = (projectIdValue: string, end: number, mode = 'soft') => `${projectIdValue}:${mode}:0:${end}`; + + const loadFusionVolume = async (end: number, useCache = true) => { + if (!project?.dicomCount) return null; + const maxSliceValue = Math.max(project.dicomCount - 1, 0); + const safeEnd = clamp(end, 0, maxSliceValue); + const cacheKey = getFusionCacheKey(project.id, safeEnd); + const cached = fusionVolumeCacheRef.current.get(cacheKey); + if (useCache && cached) { + setFusionVolume(cached); + setPreloadMessage(`已使用缓存点位 ${safeEnd + 1}`); + return cached; + } + const volumePayload = await api.getDicomFusionVolume(project.id, 0, safeEnd, 'soft'); + fusionVolumeCacheRef.current.set(cacheKey, volumePayload); + return volumePayload; + }; + useEffect(() => { api.getProject(projectId).then((item) => { setProject(item); const end = Math.min(49, Math.max((item.dicomCount || 1) - 1, 0)); setSliceEnd(end); + setCutSlice(end); setModelPose(defaultModelPose); const nextStyles: Record = {}; (item.stlFiles ?? []).forEach((fileName, index) => { @@ -497,11 +595,10 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { useEffect(() => { if (!project?.dicomCount) return; const maxSlice = Math.max(project.dicomCount - 1, 0); - const safeStart = 0; const safeEnd = clamp(sliceEnd, 0, maxSlice); const timer = window.setTimeout(() => { setFusionError(''); - api.getDicomFusionVolume(project.id, safeStart, safeEnd, 'soft') + loadFusionVolume(safeEnd) .then(setFusionVolume) .catch((error) => { setFusionVolume(null); @@ -511,6 +608,15 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { return () => window.clearTimeout(timer); }, [project?.id, project?.dicomCount, sliceEnd]); + useEffect(() => () => { + if (poseRepeatRef.current.timeout !== null) { + window.clearTimeout(poseRepeatRef.current.timeout); + } + if (poseRepeatRef.current.interval !== null) { + window.clearInterval(poseRepeatRef.current.interval); + } + }, []); + useEffect(() => { if (isRegistering && progress < 100) { const timer = setTimeout(() => setProgress((value) => value + 2), 50); @@ -530,6 +636,59 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { setSelectedPoseId('custom'); }; + const clampPoseValue = (key: ModelPoseKey, value: number) => { + const limit = poseStepConfig[key]; + return clamp(value, limit.min, limit.max); + }; + + const nudgeModelPose = (key: ModelPoseKey, delta: number) => { + setModelPose((current) => ({ + ...current, + [key]: clampPoseValue(key, current[key] + delta), + })); + setSelectedPoseId('custom'); + }; + + 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(); + poseRepeatRef.current.timeout = window.setTimeout(() => { + nudgeModelPose(key, delta); + poseRepeatRef.current.interval = window.setInterval(() => nudgeModelPose(key, delta), 90); + }, 360); + }; + + const resetRotationPose = () => { + setModelPose((current) => ({ + ...current, + rotateX: 0, + rotateY: 0, + rotateZ: 0, + })); + setSelectedPoseId('custom'); + }; + + const resetTransformPose = () => { + setModelPose((current) => ({ + ...current, + translateX: 0, + translateY: 0, + translateZ: 0, + scale: 1, + })); + setSelectedPoseId('custom'); + }; + const updateModuleStyle = (fileName: string, partial: Partial) => { const stlFiles = project?.stlFiles ?? []; const index = Math.max(0, stlFiles.indexOf(fileName)); @@ -557,6 +716,14 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { setSelectedPoseId(nextPose.id); }; + const renamePose = (poseId: string, name: string) => { + if (poseId === 'default') return; + const nextName = name.trim(); + setSavedPoses((current) => current.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; @@ -568,6 +735,30 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { const displayStart = 0; const displayEnd = clamp(sliceEnd, 0, maxSlice); const selectedDisplay = displayOptions.find((item) => item.id === displayLevel) ?? displayOptions[0]; + const selectedDicomOpacity = dicomOpacityOptions.find((item) => item.id === dicomOpacityLevel) ?? dicomOpacityOptions[0]; + const preloadPoints = [0.2, 0.4, 0.6, 0.8, 1].map((ratio) => clamp(Math.max(0, Math.round((project?.dicomCount ?? 1) * ratio) - 1), 0, maxSlice)); + + const preloadFusionPoint = async (end: number) => { + if (!project) return; + const safeEnd = clamp(end, 0, maxSlice); + setPreloadMessage(`正在预存第 ${safeEnd + 1} 张...`); + try { + await loadFusionVolume(safeEnd, false); + setPreloadMessage(`已预存第 ${safeEnd + 1} 张`); + } catch (error) { + setPreloadMessage(error instanceof Error ? error.message : '预存失败'); + } + }; + + const preloadAllFusionPoints = async () => { + setPreloadMessage('正在预存五个点位...'); + try { + await Promise.all(preloadPoints.map((point) => loadFusionVolume(point, true))); + setPreloadMessage('五个点位已预存'); + } catch (error) { + setPreloadMessage(error instanceof Error ? error.message : '五点预存失败'); + } + }; return (
@@ -624,6 +815,10 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { moduleStyles={moduleStyles} detailLimit={selectedDisplay.limit} solidMode={displayLevel === 'solid'} + dicomOpacity={selectedDicomOpacity} + showBounds={showBounds} + cutEnabled={cutEnabled} + cutSlice={cutSlice} /> ) : (
@@ -660,6 +855,32 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {

默认从第 1 张开始显示,滑条控制融合体使用到第几张切片。

+
+ {preloadPoints.map((point, index) => { + const cached = project ? fusionVolumeCacheRef.current.has(getFusionCacheKey(project.id, point)) : false; + return ( + + ); + })} + + {preloadMessage} +
@@ -690,6 +911,59 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
+
+

融合显示

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

模型切分

+ +
+ +
+

模型位姿

@@ -708,38 +982,97 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) { ))} - + {selectedPoseId !== 'default' && selectedPoseId !== 'custom' && ( + item.id === selectedPoseId)?.name ?? ''} + onChange={(event) => renamePose(selectedPoseId, event.target.value)} + className="mb-2 h-8 w-full rounded-lg border border-slate-200 bg-white px-2 text-[10px] font-bold text-slate-600 outline-none focus:border-blue-400" + placeholder="位姿名称" + /> + )} +
+ + +
{[ - { key: 'rotateX' as const, label: '旋转 X', min: -180, max: 180, step: 1, value: modelPose.rotateX }, - { key: 'rotateY' as const, label: '旋转 Y', min: -180, max: 180, step: 1, value: modelPose.rotateY }, - { key: 'rotateZ' as const, label: '旋转 Z', min: -180, max: 180, step: 1, value: modelPose.rotateZ }, - { key: 'translateX' as const, label: '平移 X', min: -2, max: 2, step: 0.05, value: modelPose.translateX }, - { key: 'translateY' as const, label: '平移 Y', min: -2, max: 2, step: 0.05, value: modelPose.translateY }, - { key: 'translateZ' as const, label: '平移 Z', min: -2, max: 2, step: 0.05, value: modelPose.translateZ }, - { key: 'scale' as const, label: '缩放', min: 0.5, max: 2, step: 0.05, value: modelPose.scale }, + { 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) => ( -
diff --git a/WebSite/src/components/Sidebar.tsx b/WebSite/src/components/Sidebar.tsx index 5d44640..d41e7e9 100644 --- a/WebSite/src/components/Sidebar.tsx +++ b/WebSite/src/components/Sidebar.tsx @@ -4,6 +4,7 @@ import { BarChart3, FolderRoot, Box, + Workflow, Settings, LogOut, ChevronLeft, @@ -32,7 +33,7 @@ export default function Sidebar({ { id: ViewType.OVERVIEW, icon: BarChart3, label: '总体概况' }, { id: ViewType.PROJECTS, icon: FolderRoot, label: '项目库' }, { id: ViewType.MODELS, icon: Box, label: '模型库' }, - { id: ViewType.WORKSPACE, icon: Box, label: '逆向工作区' }, + { id: ViewType.WORKSPACE, icon: Workflow, label: '逆向工作区' }, { id: ViewType.SYSTEM, icon: Settings, label: '系统管理工作区' }, ]; diff --git a/工程分析/实现方案-2026-05-08-01-19-42.md b/工程分析/实现方案-2026-05-08-01-19-42.md new file mode 100644 index 0000000..6acb2f6 --- /dev/null +++ b/工程分析/实现方案-2026-05-08-01-19-42.md @@ -0,0 +1,108 @@ +# 实现方案 - 2026-05-08-01-19-42 + +## 修改目标 + +1. 修复模型库大缩放时构件层级不可见或不可滚动的问题。 +2. 更换逆向工作区侧栏图标,避免与模型库重复。 +3. 优化逆向工作区模型位姿控制:拆分重置按钮、增加 ±90° 旋转、位姿重命名、单击最低刻度、长按连续移动。 +4. 为融合视角增加 DICOM 体数据缓存与五个预存点位。 +5. 在融合视角显示 DICOM 边界框、模型边界框,并以 DICOM 中心作为模型旋转中心。 +6. 增加 DICOM 透明度三档控制。 +7. 增加基于 DICOM 帧的模型切分预览,显示切割面。 + +## 涉及路径 + +- `WebSite/src/components/Sidebar.tsx` +- `WebSite/src/components/ProjectLibrary.tsx` +- `WebSite/src/components/ReverseWorkspace.tsx` +- `WebSite/src/lib/api.ts` +- `WebSite/src/types.ts` +- `WebSite/server.ts` +- `工程分析/经验记录.md` + +## 技术路线 + +### 模型库滚动 + +- 调整项目库 3D 模型页右侧栏为 `min-h-0` + 内部滚动结构。 +- 构件层级列表占用剩余高度,标题和控制区固定,防止浏览器放大后构件列表被挤出。 + +### 侧栏图标 + +- 侧栏中模型库继续使用 `Box`,逆向工作区改用 `Workflow` 或 `ScanLine` 类图标,保证含义和视觉不同。 + +### 位姿控制 + +- 在逆向工作区可视化工具栏中替换 `重置默认位姿` 为: + - `重置旋转位姿` + - `重置平移缩放位姿` +- 旋转 X/Y/Z 增加 `-90°` 和 `+90°` 按钮。 +- 平移和缩放增加正负最小步进按钮。 +- 单击按钮只移动一个最低刻度;鼠标或触摸长按时启动定时器连续移动。 +- 保存位姿列表中允许重命名非默认位姿,默认位姿保持不可改名。 + +### DICOM 缓存与预存点 + +- 前端维护 `fusionVolumeCacheRef`,以 `projectId/mode/end` 作为 key 缓存融合体数据。 +- DICOM 切片范围显示五个点位按钮,分别映射 20%、40%、60%、80%、100%。 +- 点击预存点位时优先从缓存读取;缓存未命中则请求接口并写入缓存。 +- 增加“预存五点”按钮,后台并发预取五个点位。 + +### 边界框与旋转中心 + +- FusionThreeView 增加 DICOM BoxHelper/LineSegments 边界和模型边界。 +- 模型 STL 顶点以自身中心归一化后,整体 group 的 pivot 固定在 DICOM 体中心。 +- 旋转、缩放围绕 DICOM 中心进行,平移作为模型相对 DICOM 中心的偏移。 + +### DICOM 透明度 + +- 新增 `dicomOpacityLevel`,提供低/中/高三档。 +- 传入 FusionThreeView,控制 DICOM 体素切片和切片纹理材质透明度。 + +### 模型切分 + +- 新增切割开关、切割帧滑条和切割面显示。 +- Three.js 中使用 DICOM 切片帧在 Z 方向的相对位置作为切割平面。 +- 通过 local clipping plane 对模型材质裁剪,并显示半透明切割面。 + +## 数据流或交互流程 + +1. 进入逆向工作区后读取项目与 `moduleStyles`。 +2. Fusion 视角按当前切片范围加载体数据,先查前端缓存,未命中再请求后端。 +3. 用户点击五个点位或预存按钮,前端将对应 `end` 的 DICOM volume 写入缓存。 +4. 用户调整模型位姿,模型围绕 DICOM 中心旋转缩放,并实时更新边界框。 +5. 用户启用模型切分并选择 DICOM 帧,Three.js 使用对应切割平面裁剪模型并显示切割面。 + +## 兼容性与回滚方案 + +- 缓存仅存在浏览器内存中,不改变后端数据结构。 +- 新增控制项都保留默认值,旧项目数据可直接使用。 +- 若切割平面对部分浏览器性能影响较大,可关闭切割功能回到原始渲染。 +- 回滚本次 commit 即可恢复。 + +## 预计文件变更 + +- `Sidebar.tsx`:调整逆向工作区图标。 +- `ProjectLibrary.tsx`:修复右侧构件层级滚动布局。 +- `ReverseWorkspace.tsx`:增加缓存、预存点、位姿控制、边界框、透明度和切割功能。 +- `api.ts/types.ts/server.ts`:如需支持缓存辅助或类型扩展则同步调整。 + +## 人工审核状态 + +用户已声明本次不需要二次人工确认,按默认执行确认规则直接执行。 + +## 执行记录 + +- 已将左侧逆向工作区图标从 `Box` 改为 `Workflow`,与模型库图标区分。 +- 已将模型库 3D 模型右侧栏改为整栏滚动结构,解决浏览器放大后构件层级下方内容不可见的问题。 +- 已在逆向工作区模型位姿中增加: + - `重置旋转位姿` + - `重置平移缩放位姿` + - X/Y/Z 的 ±90° 快捷旋转 + - 旋转/平移/缩放的最低刻度单击步进与长按连续步进 + - 非默认位姿重命名输入 +- 已在融合视角中增加 DICOM 透明度低/中/高三档。 +- 已在融合视角中增加 DICOM 边界框和模型边界框显示开关。 +- 已将模型旋转中心改为以 DICOM 体中心为 pivot,模型自身偏移放入子节点,避免旋转时围绕模型偏移点运动。 +- 已增加 DICOM 切片范围五个点位和 `预存五点` 功能,前端使用内存 Map 缓存融合体数据。 +- 已增加模型切分开关、切割帧滑条、橙色切割面显示,并通过 Three.js clipping plane 裁剪模型。 diff --git a/工程分析/测试方案-2026-05-08-01-19-42.md b/工程分析/测试方案-2026-05-08-01-19-42.md new file mode 100644 index 0000000..d9e07a4 --- /dev/null +++ b/工程分析/测试方案-2026-05-08-01-19-42.md @@ -0,0 +1,58 @@ +# 测试方案 - 2026-05-08-01-19-42 + +## 静态检查 + +- 执行 `npm run lint`,确认 TypeScript 类型检查通过。 +- 执行 `npm run build`,确认生产构建通过。 +- 执行 `git diff --check`,确认无空白错误。 + +## 集成验证 + +- `GET /api/projects/head-ct-demo` 正常返回项目与构件样式。 +- `GET /api/projects/head-ct-demo/dicom-fusion-volume?start=0&end=&mode=soft` 正常返回体数据。 +- 本地服务重新部署后 `curl -I http://127.0.0.1:4000/` 返回 200。 + +## 关键业务场景验证 + +1. 模型库在放大或小高度场景下,右侧构件层级仍可滚动查看。 +2. 左侧逆向工作区图标与模型库图标不同。 +3. 逆向工作区模型位姿支持: + - 重置旋转位姿。 + - 重置平移缩放位姿。 + - X/Y/Z ±90° 快捷旋转。 + - 非默认位姿重命名。 + - 单击最低刻度移动,长按连续移动。 +4. DICOM 切片范围提供五个预存点位,并能触发缓存加载。 +5. 融合视角中可看到 DICOM 边界框、模型边界框。 +6. 模型旋转中心围绕 DICOM 体中心。 +7. DICOM 透明度三档切换可见。 +8. 模型切分开关、帧选择和切割面显示可用。 + +## 医学影像数据相关边界验证 + +- 切片范围点位不超过 DICOM 总帧数。 +- 切割帧范围限制在 `1~dicomCount`。 +- DICOM 体数据缓存只缓存当前项目和当前模式下的数据,避免混用其他项目数据。 + +## 回归风险 + +- Three.js clipping plane 和边界框可能影响渲染性能。 +- 预存五点会产生多次融合体请求,需要确认不会阻塞主界面交互。 +- 模型旋转中心改为 DICOM 中心后,旧的模型位姿视觉效果会发生变化。 + +## 人工审核状态 + +用户已声明本次不需要二次人工确认,按默认执行确认规则直接执行。 + +## 执行结果 + +- `npm run lint`:通过。 +- `npm run build`:通过,仅保留 Vite 大 chunk 体积提醒。 +- `git diff --check`:通过。 +- 重新部署:已通过 `tmux` 重启 `revoxelseg-dicom` 服务,运行在 `http://0.0.0.0:4000/`。 +- `curl -I http://127.0.0.1:4000/`:返回 `HTTP/1.1 200 OK`。 +- `GET /api/projects/head-ct-demo/dicom-fusion-volume?start=0&end=59&mode=soft`:正常返回 DICOM 融合体数据。 +- 代码静态检索确认: + - `重置默认位姿` 已移除。 + - `Workflow` 已用于逆向工作区侧栏图标。 + - `预存五点`、`模型切分`、`dicomOpacity`、`showBounds` 相关控制已写入逆向工作区。 diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index ecc99d7..53aa774 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.md @@ -685,3 +685,21 @@ C. 解决问题方案 D. 后续如何避免问题 凡是需要跨页面、跨浏览器一致的配置项,优先放入后端项目状态;前端可以保留本地 state 提升响应速度,但必须通过 API 持久化并从项目数据恢复。 + +## 2026-05-08-01-19-42 融合视角缓存、边界与切割控制 + +A. 具体问题 + +融合视角在切片范围变化时需要反复请求 DICOM 体数据;模型旋转中心带有模型偏移,极端旋转后容易看起来脱离 DICOM;同时缺少 DICOM/模型边界、透明度档位和沿 DICOM 帧切割模型的操作入口。 + +B. 产生问题原因 + +前一版融合视图以单次加载为主,没有前端缓存点位;模型居中后又把模型组整体设置了 Z 偏移,使旋转 pivot 不完全等同于 DICOM 体中心;三维辅助元素只显示了 DICOM 边线,没有完整的模型边界和切割平面。 + +C. 解决问题方案 + +在前端使用 `Map` 按项目、模式和切片终点缓存 `DicomFusionVolume`;DICOM 切片范围增加五个预存点位和一键预存;Three.js 中将模型旋转组保持在 DICOM 原点,把模型自身偏移放入子节点;增加 DICOM 边界、模型边界、DICOM 透明度三档、切割帧滑条和 clipping plane 切割面。 + +D. 后续如何避免问题 + +涉及 DICOM 与 STL 配准的旋转、缩放、切割操作,应优先明确坐标系和 pivot 来源,默认以 DICOM 体中心为基准;高频切片请求应先设计缓存键和预取策略,避免 UI 操作直接触发重复网络与纹理构建。 diff --git a/工程分析/需求分析-2026-05-08-01-19-42.md b/工程分析/需求分析-2026-05-08-01-19-42.md new file mode 100644 index 0000000..af04981 --- /dev/null +++ b/工程分析/需求分析-2026-05-08-01-19-42.md @@ -0,0 +1,56 @@ +# 需求分析 - 2026-05-08-01-19-42 + +## 原始需求摘要 + +本次需要继续完善模型库与逆向工作区的三维融合可视化能力: + +1. 模型库界面放大后右侧构件层级内容被挤出或不可见,需要解决滚动与布局问题。 +2. 左侧逆向工作区图标需要更换,避免与模型库图标重复。 +3. 逆向工作区中 `重置默认位姿` 改为 `重置旋转位姿`、`重置平移缩放位姿`,旋转 X/Y/Z 增加 ±90° 快捷旋转。 +4. 模型位姿中除默认位姿外,支持位姿改名。 +5. 影像与模型融合视角增加缓存/预存能力,DICOM 切片范围可选择五个点位预存信息,加速可视化。 +6. 融合视角同时显示 DICOM 体数据矩形边界和模型矩形边界。 +7. 模型旋转中心改为患者 DICOM 影像中心,避免模型旋转后脱离 DICOM。 +8. 可视化工具栏中旋转、平移缩放单击只移动最低刻度,长按才连续移动。 +9. DICOM 三维影像透明度增加两档可选。 +10. 增加模型切分功能:可沿一个 DICOM 帧切割模型,并显示切割面。 + +## 业务目标 + +- 提升大缩放或小视口下的模型库可用性。 +- 让逆向工作区和模型库在导航语义上更清晰。 +- 增强模型位姿操作的可控性、可保存性和可解释性。 +- 加快融合视角切片范围变化时的数据加载速度。 +- 增强 DICOM 与 STL 的空间对比能力,为后续配准和模型切分提供基础。 + +## 输入与输出 + +- 输入: + - 用户在模型库查看构件层级。 + - 用户调整逆向工作区模型位姿、保存/重命名位姿、切片范围、DICOM 透明度、切割帧。 +- 输出: + - 模型库右侧构件层级在放大后仍可滚动查看。 + - 逆向工作区左侧图标与模型库不同。 + - 逆向工作区可进行更细粒度和长按连续的位姿调节。 + - 融合视角显示 DICOM 边界、模型边界、DICOM 透明度档位、预存点位、切割面。 + +## 影响范围 + +- `WebSite/src/components/Sidebar.tsx` +- `WebSite/src/components/ProjectLibrary.tsx` +- `WebSite/src/components/ReverseWorkspace.tsx` +- `WebSite/src/lib/api.ts` +- `WebSite/src/types.ts` +- `WebSite/server.ts` +- `工程分析/经验记录.md` + +## 风险点 + +- 三维融合视图已包含 DICOM 体数据、STL 模型和位姿控制,继续加入边界框、切割面和缓存后需要避免 Three.js 对象重复泄漏。 +- DICOM 预存点位如果一次预取过多,可能增加服务端与浏览器内存压力。 +- 旋转中心改为 DICOM 中心后,模型本身的居中逻辑要与 DICOM 坐标系保持一致,不能再次叠加偏移。 +- 频繁保存位姿名称或构件样式时,需要保持前端状态和后端项目状态一致。 + +## 待确认问题 + +用户已明确本次需求分析、实现方案、测试方案、执行修改均不需要二次人工确认,因此按默认执行确认规则直接实施。