@@ -1,64 +1,418 @@
import React , { useRef , useState , useEffect } from 'react' ;
import React , { useEffect , useRef, useState } from 'react' ;
import { motion } from 'motion/react' ;
import {
Dices ,
Settings2 ,
Maximize2 ,
Download ,
Layers ,
Move ,
import {
Dices ,
Settings2 ,
Maximize2 ,
Download ,
Layers ,
Rotate3d ,
CheckCircle2 ,
AlertCircle ,
FileJson ,
Plus ,
Play
Play ,
} from 'lucide-react' ;
import { DicomPreview , MaskMapping , Project } from '../types ' ;
import * as THREE from 'three ' ;
import { DicomFusionVolume , MaskMapping , Project } from '../types' ;
import { api , downloadMask } from '../lib/api' ;
function FusionDicomCanvas ( { preview } : { preview : DicomPreview } ) {
const canvasRef = useRef < HTMLCanvasElement | null > ( null ) ;
interface ModelPose {
rotateX : number ;
rotateY : number ;
rotateZ : number ;
translateX : number ;
translateY : number ;
translateZ : number ;
scale : number ;
}
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 } ;
} ;
}
const defaultModelPose : ModelPose = {
rotateX : 0 ,
rotateY : 0 ,
rotateZ : 0 ,
translateX : 0 ,
translateY : 0 ,
translateZ : 0 ,
scale : 1 ,
} ;
const moduleColors = [ '#3b82f6' , '#22c55e' , '#f59e0b' , '#ef4444' , '#8b5cf6' , '#14b8a6' , '#f97316' , '#64748b' , '#ec4899' ] ;
function clamp ( value : number , min : number , max : number ) {
return Math . max ( min , Math . min ( max , value ) ) ;
}
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 FusionThreeView ( {
project ,
volume ,
modelPose ,
onModelPoseChange ,
} : {
project : Project ;
volume : DicomFusionVolume | null ;
modelPose : ModelPose ;
onModelPoseChange : React.Dispatch < React.SetStateAction < ModelPose > > ;
} ) {
const containerRef = useRef < HTMLDivElement | null > ( null ) ;
const modelPoseRef = useRef ( modelPose ) ;
const onModelPoseChangeRef = useRef ( onModelPoseChange ) ;
const [ status , setStatus ] = useState ( '准备融合 DICOM 与 STL' ) ;
const [ loadProgress , setLoadProgress ] = useState ( 0 ) ;
useEffect ( ( ) = > {
const canvas = canvasRef . current ;
const context = canvas ? . getContext ( '2d' ) ;
if ( ! canvas || ! context ) return ;
modelPoseRef . current = modelPose ;
} , [ modelPose ] ) ;
const binary = atob ( preview . pixels ) ;
const imageData = context . createImageData ( preview . width , preview . height ) ;
for ( let i = 0 ; i < binary . length ; i += 1 ) {
const value = binary . charCodeAt ( i ) ;
const offset = i * 4 ;
imageData . data [ offset ] = value ;
imageData . data [ offset + 1 ] = va lue ;
imageData . data [ offset + 2 ] = value ;
imageData . data [ offset + 3 ] = 255 ;
}
context . putImageData ( imageData , 0 , 0 ) ;
} , [ preview ] ) ;
useEffect ( ( ) = > {
onModelPoseChangeRef . current = onModelPoseChange ;
} , [ onModelPoseChange ] ) ;
useEffect ( ( ) = > {
const container = containerRef . current ;
if ( ! container || ! vo lume ) return ;
container . innerHTML = '' ;
setStatus ( '正在构建三维融合场景...' ) ;
setLoadProgress ( 8 ) ;
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 ) ;
const renderer = new THREE . WebGLRenderer ( { antialias : true , alpha : true } ) ;
renderer . setPixelRatio ( Math . min ( window . devicePixelRatio , 2 ) ) ;
renderer . setSize ( width , height ) ;
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 : 0.32 , 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 } ) ,
) ;
dicomGroup . add ( edges ) ;
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 material = new THREE . MeshBasicMaterial ( {
map : texture ,
transparent : true ,
opacity : isLast ? 0.82 : 0.12 ,
side : THREE.DoubleSide ,
depthWrite : false ,
} ) ;
const slicePlane = new THREE . Mesh ( planeGeometry , material ) ;
const z = volume . frames . length <= 1
? 0
: - dicomDepth / 2 + ( dicomDepth * index ) / ( volume . frames . length - 1 ) ;
slicePlane . position . set ( 0 , 0 , isLast ? dicomDepth / 2 + 0.006 : z ) ;
dicomGroup . add ( slicePlane ) ;
} ) ;
setLoadProgress ( 42 ) ;
const stlFiles = project . stlFiles ? ? [ ] ;
let modelBaseScale = 1 ;
let loadedModels = 0 ;
let failedModels = 0 ;
const loadedBounds : Array < { min : THREE.Vector3 ; max : THREE.Vector3 } > = [ ] ;
Promise . allSettled ( stlFiles . map ( ( fileName , index ) = > (
fetch ( ` /api/projects/ ${ project . id } /models/ ${ encodeURIComponent ( fileName ) } /preview?limit=72000 ` )
. then ( ( response ) = > {
if ( ! response . ok ) throw new Error ( '模型预览加载失败' ) ;
return response . json ( ) as Promise < ModelPreviewPayload > ;
} )
. then ( ( payload ) = > {
if ( disposed ) return ;
const geometry = new THREE . BufferGeometry ( ) ;
geometry . setAttribute ( 'position' , new THREE . Float32BufferAttribute ( payload . vertices , 3 ) ) ;
geometry . computeVertexNormals ( ) ;
const material = new THREE . MeshStandardMaterial ( {
color : moduleColors [ index % moduleColors . length ] ,
transparent : true ,
opacity : 0.72 ,
roughness : 0.48 ,
metalness : 0.03 ,
side : THREE.DoubleSide ,
} ) ;
const mesh = new THREE . Mesh ( geometry , material ) ;
modelPivot . add ( mesh ) ;
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 ;
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 ( ) ;
}
} ) ;
modelBaseScale = ( Math . max ( dicomWidth , dicomHeight , dicomDepth ) / maxModelSize ) * 0.92 ;
modelPoseGroup . position . set ( 0 , 0 , dicomDepth * 0.08 ) ;
setLoadProgress ( 100 ) ;
setStatus ( stlFiles . length ? '三维融合场景已就绪' : 'DICOM 三维体已就绪,当前项目没有 STL' ) ;
} ) ;
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.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 ) ;
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 ,
dicomDepth * 0.08 + pose . translateZ ,
) ;
modelPoseGroup . scale . setScalar ( modelBaseScale * pose . scale ) ;
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 ( ) ;
container . innerHTML = '' ;
} ;
} , [ project . id , project . stlFiles ? . join ( '|' ) , volume ] ) ;
return (
< canvas
ref = { canvasRef }
width = { preview . width }
height = { preview . height }
className = "absolute inset-0 h-full w-full object-contain opacity-80"
/ >
< 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" / >
< 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 ? ` ${ volume . start + 1 } - ${ volume . end + 1 } / ${ volume . total } ` : '加载中' } · STL { project . modelCount ? ? 0 }
< / div >
{ 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 >
) ;
}
export default function ReverseWorkspace ( { projectId } : { projectId : string } ) {
const [ slice , setSlice ] = useState ( 5 0) ;
const [ sliceStart , setSliceStart ] = useState ( 0 ) ;
const [ sliceEnd , setSliceEnd ] = useState ( 49 ) ;
const [ modelPose , setModelPose ] = useState < ModelPose > ( defaultModelPose ) ;
const [ isRegistering , setIsRegistering ] = useState ( false ) ;
const [ progress , setProgress ] = useState ( 0 ) ;
const [ offset , setOffset ] = useState < [ number , number , number ] > ( [ 0 , 0 , 0 ] ) ;
const [ project , setProject ] = useState < Project | null > ( null ) ;
const [ fusionPreview , setFusionPreview ] = useState < DicomPreview | null > ( null ) ;
const [ fusionVolume , setFusionVolume ] = useState < DicomFusionVolume | null > ( null ) ;
const [ fusionError , setFusionError ] = useState ( '' ) ;
const [ exporting , setExporting ] = useState ( false ) ;
const [ exportMessage , setExportMessage ] = useState ( '准备就绪' ) ;
const [ mappings , setMappings ] = useState < MaskMapping [ ] > ( [
const [ mappings ] = useState < MaskMapping [ ] > ( [
{ className : '骨样组织' , color : '#ff4d4f' , maskId : 1 } ,
{ className : '神经根' , color : '#52c41a' , maskId : 2 } ,
{ className : '血管' , color : '#1890ff' , maskId : 3 } ,
@@ -85,32 +439,55 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
useEffect ( ( ) = > {
api . getProject ( projectId ) . then ( ( item ) = > {
setProject ( item ) ;
const middleSlice = Math . floor ( ( item . dicomCount || 1 ) / 2 ) ;
setSlice( middleSlice ) ;
return api . getDicomPreview ( item . id , middleSlice , 'axial' , 'soft' ) ;
} ) . then ( setFusionPreview ) . catch ( ( ) = > {
const end = Math . min ( 49 , Math . max ( ( item . dicomCount || 1 ) - 1 , 0 ) ) ;
setSliceStart ( 0 ) ;
setSliceEnd ( end ) ;
setModelPose ( defaultModelPose ) ;
} ) . catch ( ( ) = > {
setProject ( null ) ;
setFusionPreview ( null ) ;
setFusionVolume ( null ) ;
} ) ;
} , [ projectId ] ) ;
useEffect ( ( ) = > {
if ( ! project ? . dicomCount ) return ;
const maxSlice = Math . max ( project . dicomCount - 1 , 0 ) ;
const safeStart = clamp ( Math . min ( sliceStart , sliceEnd ) , 0 , maxSlice ) ;
const safeEnd = clamp ( Math . max ( sliceStart , sliceEnd ) , safeStart , maxSlice ) ;
const timer = window . setTimeout ( ( ) = > {
api . getDicomPreview ( project . id , slice , 'axial' , 'soft' ) . then ( setFusionPreview ) . catch ( ( ) = > setFusionPreview ( null ) ) ;
setFusionError ( '' ) ;
api . getDicomFusionVolume ( project . id , safeStart , safeEnd , 'soft' )
. then ( setFusionVolume )
. catch ( ( error ) = > {
setFusionVolume ( null ) ;
setFusionError ( error instanceof Error ? error . message : 'DICOM 融合体加载失败' ) ;
} ) ;
} , 180 ) ;
return ( ) = > window . clearTimeout ( timer ) ;
} , [ project ? . id , slice ] ) ;
} , [ project ? . id , project ? . dicomCount , sliceStart , sliceEnd ] ) ;
useEffect ( ( ) = > {
if ( isRegistering && progress < 100 ) {
const timer = setTimeout ( ( ) = > setProgress ( p = > p + 2 ) , 50 ) ;
const timer = setTimeout ( ( ) = > setProgress ( ( value ) = > value + 2 ) , 50 ) ;
return ( ) = > clearTimeout ( timer ) ;
} else if ( progress >= 100 ) {
}
if ( progress >= 100 ) {
setIsRegistering ( false ) ;
}
return undefined ;
} , [ isRegistering , progress ] ) ;
const updateModelPose = ( partial : Partial < ModelPose > ) = > {
setModelPose ( ( current ) = > ( {
. . . current ,
. . . partial ,
} ) ) ;
} ;
const maxSlice = Math . max ( ( project ? . dicomCount ? ? 1 ) - 1 , 0 ) ;
const displayStart = Math . min ( sliceStart , sliceEnd ) ;
const displayEnd = Math . max ( sliceStart , sliceEnd ) ;
return (
< div className = "h-full flex flex-col gap-6" >
< div className = "flex items-center justify-between" >
@@ -125,7 +502,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
{ ! project && < p className = "text-sm text-slate-500" > 配 准 DICOM 影 像 与 三 维 模 型 , 生 成 像 素 映 射 关 系 < / p > }
< / div >
< div className = "flex gap-2" >
< button
< button
onClick = { handleStartRegistration }
disabled = { isRegistering }
className = "bg-indigo-600 text-white px-5 py-2.5 rounded-xl text-sm font-semibold hover:bg-indigo-700 transition-all shadow-lg flex items-center gap-2 disabled:opacity-50"
@@ -147,98 +524,146 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
< / div >
< div className = "flex-1 grid grid-cols-1 lg:grid-cols-12 gap-6 overflow-hidden" >
{ /* Left Column: Image Fusion (4/12) */ }
< div className = "lg:col-span-4 flex flex-col gap-4 overflow-hidden" >
< div className = "lg:col-span-7 flex flex-col gap-4 overflow-hidden" >
< div className = "px-2 flex items-center justify-between shrink-0" >
< h3 className = "font-bold text-slate-700 flex items-center gap-2" >
< Rotate3d size = { 18 } className = "text-blue-500" / >
影 像 与 模 型 融 合 视 角
< / h3 >
< span className = "text-[10px] font-mono text-slate-400" > Layer : { slice + 1 } / { project ? . dicomCount ? ? 0 } < / span >
< span className = "text-[10px] font-mono text-slate-400" >
Layer : { displayStart + 1 } - { displayEnd + 1 } / { project ? . dicomCount ? ? 0 }
< / span >
< / div >
< div className = "flex-1 bg-black rounded-3xl overflow-hidden relative border border-slate-800 shadow-xl group" >
< div className = "absolute inset-0 z-0 flex items-center justify-center p-8" >
< div className = "relative aspect-square w-full max-w-[460px] overflow-hidden rounded-2xl border border-white/10 bg-black" >
{ fusionPreview ? (
< FusionDicomCanvas preview = { fusionPreview } / >
) : (
< div className = "absolute inset-0 flex items-center justify-center text-[10px] font-mono text-white/40" > 正 在 载 入 DICOM . . . < / div >
) }
< div
className = "absolute left-1/2 top-1/2 h-[58%] w-[58%] -translate-x-1/2 -translate-y-1/2 rounded-[46%_54%_44%_56%] border-2 border-blue-400/90 bg-blue-500/20 shadow-[0_0_40px_rgba(59,130,246,0.35)]"
style = { { transform : ` translate(calc(-50% + ${ offset [ 0 ] * 5 } px), -50%) ` } }
/ >
< div className = "absolute left-1/2 top-1/2 h-[64%] w-[64%] -translate-x-1/2 -translate-y-1/2 rounded-[52%_48%_57%_43%] border border-emerald-300/70 bg-emerald-400/10" / >
< div className = "absolute inset-x-0 top-1/2 h-px bg-cyan-400/25" / >
< div className = "absolute inset-y-0 left-1/2 w-px bg-cyan-400/25" / >
< / div >
< / div >
< div className = "absolute left-4 top-4 z-20 rounded-xl bg-black/60 px-3 py-2 text-[10px] font-mono text-white/50" >
DICOM 与 STL 已 等 比 例 归 一 化 并 中 心 对 齐
< / div >
< div className = "absolute bottom-4 left-4 z-20 pointer-events-none" >
< div className = "pointer-events-auto bg-black/60 backdrop-blur-md border border-white/10 p-3 rounded-xl w-48 space-y-3" >
< div className = "flex items-center justify-between" >
< span className = "text-[9px] font-bold text-white uppercase opacity-60" > 微 调 < / span >
< Settings2 size = { 10 } className = "text-blue-400" / >
< / div >
< input
type = "range" min = "-5" max = "5" step = "0.1"
value = { offset [ 0 ] }
onChange = { ( e ) = > setOffset ( [ Number ( e . target . value ) , offset [ 1 ] , offset [ 2 ] ] ) }
className = "w-full h-1 bg-white/20 rounded-lg appearance-none accent-blue-500"
/ >
< div className = "flex items-center justify-between" >
< span className = "text-[9px] font-bold text-white uppercase opacity-60" > 切 片 < / span >
< span className = "text-[9px] text-blue-300" > { slice + 1 } / { project ? . dicomCount ? ? 0 } < / span >
< / div >
{ project ? (
< FusionThreeView
project = { project }
volume = { fusionVolume }
modelPose = { modelPose }
onModelPoseChange = { setModelPose }
/ >
) : (
< 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 = "grid grid-cols-1 xl:grid-cols-2 gap-4" >
< 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" >
{ displayStart + 1 } - { displayEnd + 1 }
< / span >
< / div >
< div className = "space-y-3" >
< label className = "grid grid-cols-[52px_1fr_42px] items-center gap-2 text-[10px] font-bold text-slate-500" >
起 点
< input
type = "range"
min = "0"
max = { Math . max ( ( project ? . dicomCount ? ? 1 ) - 1 , 0 ) }
value = { slice }
onChange = { ( e ) = > setSlice ( Number ( e . target . value ) ) }
className = "w-full h-1 bg-white/20 rounded-lg appearance-none accent-blue-5 00"
max = { maxSlice }
value = { sliceStart }
onChange = { ( event ) = > setSliceStart ( Number ( event . target . value ) ) }
className = "accent-blue-6 00"
/ >
< / div >
< span className = "text-right font-mono" > { sliceStart + 1 } < / span >
< / label >
< label className = "grid grid-cols-[52px_1fr_42px] items-center gap-2 text-[10px] font-bold text-slate-500" >
终 点
< input
type = "range"
min = "0"
max = { maxSlice }
value = { sliceEnd }
onChange = { ( event ) = > setSliceEnd ( Number ( event . target . value ) ) }
className = "accent-blue-600"
/ >
< span className = "text-right font-mono" > { sliceEnd + 1 } < / span >
< / label >
< / div >
< p className = "mt-3 text-[10px] leading-5 text-slate-400" >
DICOM 以 黑 色 体 数 据 长 方 体 显 示 , 表 面 贴 附 当 前 范 围 的 最 后 一 张 CT 切 片 。
< / p >
< / 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" > 模 型 位 姿 < / p >
< button
onClick = { ( ) = > setModelPose ( defaultModelPose ) }
className = "text-[10px] font-bold text-blue-600 hover:text-blue-700"
>
重 置 模 型 位 姿
< / button >
< / div >
< div className = "space-y-2" >
{ [
{ 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 } ,
] . map ( ( item ) = > (
< label key = { item . key } className = "grid grid-cols-[52px_1fr_42px] items-center gap-2 text-[10px] font-bold text-slate-500" >
{ item . label }
< input
type = "range"
min = { item . min }
max = { item . max }
step = { item . step }
value = { item . value }
onChange = { ( event ) = > updateModelPose ( { [ item . key ] : Number ( event . target . value ) } ) }
className = "accent-blue-600"
/ >
< span className = "text-right font-mono" > { Number ( item . value ) . toFixed ( item . step < 1 ? 2 : 0 ) } < / span >
< / label >
) ) }
< / div >
< / div >
< / div >
< / div >
{ /* Middle Column: Mask Selection (3/12) */ }
< div className = "lg:col-span-3 flex flex-col gap-4 overflow-hidden" >
< div className = "lg:col-span-2 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" >
< Layers size = { 18 } className = "text-emerald-500" / >
分 割 Mask 选 择
分 割 Mask
< / 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 className = "flex-1 overflow-auto space-y-2 pr-1" >
{ mappings . map ( ( m , i ) = > (
< button
key = { i }
{ mappings . map ( ( mapping , index ) = > (
< button
key = { mapping . maskId }
className = { ` w-full flex flex-col gap-2 p-3 rounded-xl border transition-all text-left group ${
i === 0 ? 'bg-blue-50 border-blue-200' : 'bg-slate-50 border-transparent hover:border-slate-200'
index === 0 ? 'bg-blue-50 border-blue-200' : 'bg-slate-50 border-transparent hover:border-slate-200'
} ` }
>
< div className = "flex items-center justify-between" >
< div className = "flex items-center gap-2" >
< div className = "w-3 h-3 rounded-full" style = { { backgroundColor : m.color } } / >
< span className = "text-xs font-bold text-slate-700" > { m . className } < / span >
< div className = "w-3 h-3 rounded-full" style = { { backgroundColor : mapping .color } } / >
< span className = "text-xs font-bold text-slate-700" > { mapping . className } < / span >
< / div >
{ i === 0 && < CheckCircle2 size = { 14 } className = "text-blue-500" / > }
{ index === 0 && < CheckCircle2 size = { 14 } className = "text-blue-500" / > }
< / div >
< div className = "flex items-center justify-between text-[10px] text-slate-500 font-mono" >
< span > ID : { m . maskId } < / span >
< span > ID : { mapping . maskId } < / span >
< span className = "font-bold text-emerald-600" > Conf : 98 % < / span >
< / div >
< / button >
) ) }
< button className = "w-full py-3 border-2 border-dashed border-slate-100 rounded-xl text-slate-400 flex items-center justify-center hover:bg-slate-50 transition-all" >
< Plus size = { 18 } / >
< / button >
@@ -256,93 +681,77 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
< / div >
< / div >
{ /* Right Column: Mask Image Display (5/12) */ }
< div className = "lg:col-span-5 flex flex-col gap-4 overflow-hidden" >
< div className = "lg:col-span-3 flex flex-col gap-4 overflow-hidden" >
< div className = "px-2 flex items-center justify-between shrink-0" >
< h3 className = "font-bold text-slate-700 flex items-center gap-2" >
< Play size = { 18 } className = "text-blue-500" / >
分 割 Mask 图 片 展 示
Mask 展 示
< / h3 >
< div className = "flex gap-2" >
< button
onClick = { ( ) = > handleExport ( 'nii' ) }
disabled = { exporting }
className = "bg-slate-100 hover:bg-slate-200 text-slate-700 px-3 py-1 rounded-lg text-[10px] font-bold transition-all border border-slate-200 flex items-center gap-1 disabled:opacity-50"
>
< Download size = { 12 } / >
NII ( 单 帧 )
< / button >
< button
onClick = { ( ) = > handleExport ( 'nii.gz' ) }
disabled = { exporting }
className = "bg-slate-900 hover:bg-black text-white px-3 py-1 rounded-lg text-[10px] font-bold transition-all flex items-center gap-1 shadow-lg disabled:opacity-50"
>
< Download size = { 12 } / >
NII . GZ ( 全 量 )
< / button >
< button
onClick = { ( ) = > handleExport ( 'nii' ) }
disabled = { exporting }
className = "bg-slate-100 hover:bg-slate-200 text-slate-700 px-3 py-1 rounded-lg text-[10px] font-bold transition-all border border-slate-200 flex items-center gap-1 disabled:opacity-50"
>
< Download size = { 12 } / >
NII
< / button >
< button
onClick = { ( ) = > handleExport ( 'nii.gz' ) }
disabled = { exporting }
className = "bg-slate-900 hover:bg-black text-white px-3 py-1 rounded-lg text-[10px] font-bold transition-all flex items-center gap-1 shadow-lg disabled:opacity-50"
>
< Download size = { 12 } / >
NII . GZ
< / button >
< / div >
< / div >
< div className = "flex-1 bg-slate-900 rounded-3xl border border-slate-800 shadow-2xl relative overflow-hidden flex items-center justify-center" >
{ /* The actual Mask result visualization */ }
< div className = "relative w-72 h-72" >
{ /* Base DICOM context (faint) */ }
< div className = "absolute inset-0 opacity-10 blur-xl bg-white rounded-full translate-x-4 translate-y-4" / >
{ /* Mask Layers */ }
{ mappings . map ( ( m , i ) = > (
< motion.div
key = { i }
initial = { { scale : 0.8 , opacity : 0 } }
animate = { { scale : 1 , opacity : 0.8 } }
transition = { { delay : i * 0.2 } }
className = "absolute inset-0 border-2"
style = { {
borderColor : m.color ,
borderRadius : i === 0 ? '30% 70% 70% 30% / 30% 30% 70% 70%' : '60% 40% 30% 70% / 60% 30% 70% 40%' ,
background : ` ${ m . color } 20 ` ,
boxShadow : ` inset 0 0 20px ${ m . color } 40 ` ,
transform : ` rotate( ${ i * 45 + slice } deg) scale( ${ 1 - i * 0.1 } ) `
} }
/ >
) ) }
< div className = "absolute inset-0 flex items-center justify-center pointer-events-none" >
< div className = "w-full h-0.5 bg-blue-500/20 absolute" / >
< div className = "h-full w-0.5 bg-blue-500/20 absolute" / >
< / div >
< div className = "relative w-64 h-64" >
< div className = "absolute inset-0 opacity-10 blur-xl bg-white rounded-full translate-x-4 translate-y-4" / >
{ mappings . map ( ( mapping , index ) = > (
< motion.div
key = { mapping . maskId }
initial = { { scale : 0.8 , opacity : 0 } }
animate = { { scale : 1 , opacity : 0.8 } }
transition = { { delay : index * 0.2 } }
className = "absolute inset-0 border-2"
style = { {
borderColor : mapping.color ,
borderRadius : index === 0 ? '30% 70% 70% 30% / 30% 30% 70% 70%' : '60% 40% 30% 70% / 60% 30% 70% 40%' ,
background : ` ${ mapping . color } 20 ` ,
boxShadow : ` inset 0 0 20px ${ mapping . color } 40 ` ,
transform : ` rotate( ${ index * 45 + displayStart } deg) scale( ${ 1 - index * 0.1 } ) ` ,
} }
/ >
) ) }
< div className = "absolute inset-0 flex items-center justify-center pointer-events-none" >
< div className = "w-full h-0.5 bg-blue-500/20 absolute" / >
< div className = "h-full w-0.5 bg-blue-500/20 absolute" / >
< / div >
< / div >
< div className = "absolute top-4 left-4 z-20 flex gap-2" >
< span className = "px-2 py-1 bg-blue-600/20 border border-blue-500/30 text-blue-400 text-[9px] font-bold rounded uppercase" > Inferred Mask < / span >
< span className = "px-2 py-1 bg-emerald-600/20 border border-emerald-500/30 text-emerald-400 text-[9px] font-bold rounded uppercase" > Verified < / span >
< span className = "px-2 py-1 bg-blue-600/20 border border-blue-500/30 text-blue-400 text-[9px] font-bold rounded uppercase" > Inferred Mask < / span >
< span className = "px-2 py-1 bg-emerald-600/20 border border-emerald-500/30 text-emerald-400 text-[9px] font-bold rounded uppercase" > Verified < / span >
< / div >
< div className = "absolute bottom-4 right-4" >
< button className = "p-2 bg-white/5 hover:bg-white/10 text-white/50 rounded-lg backdrop-blur-sm transition-all" >
< Maximize2 size = { 16 } / >
< / button >
< / div >
{ /* Legend Overlay */ }
< div className = "absolute top-4 right-4 flex flex-col gap-1 items-end" >
{ mappings . map ( ( m , i ) = > (
< div key = { i } className = "flex items-center gap-2" >
< span className = "text-[9px] text-white/40 font-mono italic" > # { m . maskId } < / span >
< div className = "w-2 h-2 rounded-full" style = { { backgroundColor : m.color } } / >
< / div >
) ) }
< button className = "p-2 bg-white/5 hover:bg-white/10 text-white/50 rounded-lg backdrop-blur-sm transition-all" >
< Maximize2 size = { 16 } / >
< / button >
< / div >
< / div >
< div className = "h-16 shrink-0 bg-white rounded-2xl border border-slate-100 shadow-sm flex items-center justify-between px-6" >
< div className = "flex flex-col" >
< span className = "text-[10px] font-bold text-slate-400 uppercase tracking-widest" > 导 出 进 度 < / span >
< span className = "text-xs font-bold text-slate-700" > { exportMessage } , 包 含 { mappings . length } 个 标 注 层 级 < / span >
< / div >
< div className = "w-3 2 bg-slate-100 h-1.5 rounded-full overflow-hidden" >
< div className = "bg-blue-600 h-full w-[100%] " / >
< / div >
< div className = "flex flex-col" >
< span className = "text-[10px] font-bold text-slate-400 uppercase tracking-widest" > 导 出 进 度 < / span >
< span className = "text-xs font-bold text-slate-700" > { exportMessage } , 包 含 { mappings . length } 个 标 注 层 级 < / span >
< / div >
< div className = "w-24 bg-slate-100 h-1.5 rounded-full overflow-hidden" >
< div className = "bg-blue-600 h-full w-full " / >
< / div >
< / div >
< / div >
< / div >