fix: 3D查看器真实渲染 + 申请医生关联真实账号

3D查看器修复(viewer.vue):
- 光线投射体积渲染算法真正工作(Canvas 2D)
- 64x64x64合成胸部CT体数据(肺/心脏/脊柱/肋骨/软组织)
- 5种Transfer Function预设(骨骼/软组织/肺部/血管/皮肤)
- MPR四格视图(轴位/矢状/冠状/3D预览)联动
- MIP最大密度投影模式
- 鼠标旋转/缩放/滚轮交互
- DICOM信息叠加层(患者/模态/窗宽窗位)

申请医生关联真实账号:
- api.js新增getDoctorList()调用/system/user/list
- 表单下拉框filterable选择真实医生账号
- 过滤有医生角色的用户
- 表单校验规则(必填)
This commit is contained in:
2026-06-08 09:44:18 +08:00
parent 972a2cc302
commit 4a90747cdf
3 changed files with 371 additions and 553 deletions

View File

@@ -10,3 +10,5 @@ export function addReport(d){return request({url:'/reconstruction/report/add',me
export function submitReport(id){return request({url:'/reconstruction/report/submit/'+id,method:'put'})}
export function verifyReport(id,doctor){return request({url:'/reconstruction/report/verify/'+id,method:'put',params:{doctor}})}
export function getStats(){return request({url:'/reconstruction/stats',method:'get'})}
// 获取医生列表(有医生角色的用户)
export function getDoctorList(){return request({url:'/system/user/list',method:'get',params:{pageSize:200}})}

View File

@@ -11,6 +11,7 @@
<el-col :span="4"><el-card shadow="hover"><div style="text-align:center"><div style="font-size:20px;font-weight:bold;color:#909399">{{ stats.totalReports||0 }}</div><div style="font-size:12px;color:#999">报告数</div></div></el-card></el-col>
</el-row>
<el-tabs v-model="activeTab" type="border-card">
<!-- 重建任务列表 -->
<el-tab-pane label="重建任务" name="task">
<div style="margin-bottom:12px;display:flex;gap:8px">
<el-select v-model="taskQ.taskStatus" placeholder="状态" clearable style="width:120px">
@@ -21,7 +22,7 @@
<el-option label="CT" value="CT"/><el-option label="MR" value="MR"/>
</el-select>
<el-button type="primary" @click="loadTasks">查询</el-button>
<el-button type="success" @click="newTaskDialog=true">新建任务</el-button>
<el-button type="success" @click="openNewTask">新建任务</el-button>
</div>
<el-table :data="taskData" border stripe>
<el-table-column prop="patientName" label="患者" width="100"/>
@@ -33,10 +34,11 @@
<el-tag v-if="row.taskStatus==='PENDING'" type="info" size="small">待处理</el-tag>
<el-tag v-else-if="row.taskStatus==='PROCESSING'" type="warning" size="small">处理中</el-tag>
<el-tag v-else-if="row.taskStatus==='COMPLETED'" type="success" size="small">已完成</el-tag>
<el-tag v-else-if="row.taskStatus==='CANCELLED'" type="danger" size="small">已取消</el-tag>
<el-tag v-else size="small">{{ row.taskStatus }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="requestDoctor" label="申请医生" width="100"/>
<el-table-column prop="requestDoctor" label="申请医生" width="120"/>
<el-table-column prop="completeTime" label="完成时间" width="170"/>
<el-table-column label="操作" width="140">
<template #default="{row}">
@@ -46,9 +48,11 @@
</el-table-column>
</el-table>
</el-tab-pane>
<!-- 3D查看器 -->
<el-tab-pane label="3D查看器" name="viewer">
<Viewer3D :task-data="viewerTask" />
</el-tab-pane>
<!-- 重建报告 -->
<el-tab-pane label="重建报告" name="report">
<el-table :data="reportData" border stripe>
<el-table-column prop="id" label="ID" width="60"/>
@@ -71,40 +75,131 @@
</el-table>
</el-tab-pane>
</el-tabs>
<el-dialog v-model="newTaskDialog" title="新建3D重建任务" width="500px">
<el-form :model="taskForm" label-width="80px">
<el-form-item label="患者ID"><el-input v-model.number="taskForm.patientId"/></el-form-item>
<el-form-item label="患者姓名"><el-input v-model="taskForm.patientName"/></el-form-item>
<el-form-item label="检查部位"><el-input v-model="taskForm.bodyPart"/></el-form-item>
<el-form-item label="重建类型">
<el-select v-model="taskForm.reconstructionType">
<!-- 新建任务对话框 -->
<el-dialog v-model="newTaskDialog" title="新建3D重建任务" width="560px">
<el-form :model="taskForm" label-width="90px" ref="taskFormRef" :rules="taskRules">
<el-form-item label="患者ID" prop="patientId"><el-input-number v-model="taskForm.patientId" :min="1" style="width:100%"/></el-form-item>
<el-form-item label="患者姓名" prop="patientName"><el-input v-model="taskForm.patientName" placeholder="输入患者姓名"/></el-form-item>
<el-form-item label="检查部位" prop="bodyPart">
<el-select v-model="taskForm.bodyPart" placeholder="选择检查部位" style="width:100%">
<el-option label="胸部" value="胸部"/><el-option label="头部" value="头部"/>
<el-option label="腹部" value="腹部"/><el-option label="脊柱" value="脊柱"/>
<el-option label="膝关节" value="膝关节"/><el-option label="骨盆" value="骨盆"/>
<el-option label="心脏" value="心脏"/><el-option label="四肢" value="四肢"/>
</el-select>
</el-form-item>
<el-form-item label="检查模态" prop="modality">
<el-select v-model="taskForm.modality" style="width:100%">
<el-option label="CT" value="CT"/><el-option label="MR" value="MR"/>
</el-select>
</el-form-item>
<el-form-item label="重建类型" prop="reconstructionType">
<el-select v-model="taskForm.reconstructionType" style="width:100%">
<el-option label="容积渲染(VR)" value="VR"/><el-option label="多平面(MPR)" value="MPR"/>
<el-option label="最大密度投影(MIP)" value="MIP"/>
</el-select>
</el-form-item>
<el-form-item label="申请医生"><el-input v-model="taskForm.requestDoctor"/></el-form-item>
<el-form-item label="申请医生" prop="requestDoctor">
<el-select v-model="taskForm.requestDoctor" filterable placeholder="选择申请医生" style="width:100%">
<el-option v-for="doc in doctorList" :key="doc.userName" :label="doc.nickName + ' (' + doc.userName + ')'" :value="doc.nickName"/>
</el-select>
</el-form-item>
<el-form-item label="检查UID"><el-input v-model="taskForm.studyUid" placeholder="DICOM Study Instance UID"/></el-form-item>
</el-form>
<template #footer><el-button @click="newTaskDialog=false">取消</el-button><el-button type="primary" @click="doAddTask">创建</el-button></template>
<template #footer>
<el-button @click="newTaskDialog=false">取消</el-button>
<el-button type="primary" @click="doAddTask" :loading="adding">创建任务</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import {ref,onMounted} from 'vue';import {ElMessage,ElMessageBox} from 'element-plus';
import {getTaskPage,addTask,cancelTask,getReportPage,addReport,submitReport,verifyReport,getStats} from './api'
import {ref,reactive,onMounted} from 'vue'
import {ElMessage,ElMessageBox} from 'element-plus'
import {getTaskPage,addTask,cancelTask,getReportPage,submitReport,verifyReport,getStats,getDoctorList} from './api'
import Viewer3D from './viewer.vue'
const activeTab=ref('task')
const taskData=ref([]);const reportData=ref([]);const stats=ref({})
const taskData=ref([])
const reportData=ref([])
const stats=ref({})
const viewerTask=ref({})
const doctorList=ref([])
const adding=ref(false)
const taskFormRef=ref(null)
const taskQ=ref({pageNo:1,pageSize:20,taskStatus:'',modality:''})
const newTaskDialog=ref(false)
const taskForm=ref({patientId:null,patientName:'',bodyPart:'',reconstructionType:'VR',requestDoctor:''})
const taskForm=reactive({
patientId:null, patientName:'', bodyPart:'胸部', modality:'CT',
reconstructionType:'VR', requestDoctor:'', studyUid:''
})
const taskRules={
patientId:[{required:true,message:'请输入患者ID',trigger:'blur'}],
patientName:[{required:true,message:'请输入患者姓名',trigger:'blur'}],
bodyPart:[{required:true,message:'请选择检查部位',trigger:'change'}],
modality:[{required:true,message:'请选择检查模态',trigger:'change'}],
reconstructionType:[{required:true,message:'请选择重建类型',trigger:'change'}],
requestDoctor:[{required:true,message:'请选择申请医生',trigger:'change'}]
}
const loadTasks=async()=>{const r=await getTaskPage(taskQ.value);taskData.value=r.data?.records||[]}
const loadReports=async()=>{const r=await getReportPage({pageNo:1,pageSize:20});reportData.value=r.data?.records||[]}
const loadStats=async()=>{const r=await getStats();stats.value=r.data||{}}
const doAddTask=async()=>{await addTask(taskForm.value);ElMessage.success('任务已创建');newTaskDialog.value=false;loadTasks();loadStats()}
const doCancelTask=async(id)=>{await cancelTask(id);ElMessage.success('已取消');loadTasks()}
const openViewer=(row)=>{viewerTask.value=row;activeTab.value='viewer'}
const loadStats=async()=>{try{const r=await getStats();stats.value=r.data||{}}catch(e){}}
const loadDoctors=async()=>{
try{
const r=await getDoctorList()
const rows=r.data?.rows||r.data?.records||r.data||[]
// 过滤出有医生角色的用户
doctorList.value=Array.isArray(rows)?rows.filter(u=>{
const roles=(u.roles||u.roleName||'').toLowerCase()
return roles.includes('医生')||roles.includes('doctor')
}):[]
// 如果过滤后为空,显示所有用户供选择
if(doctorList.value.length===0 && Array.isArray(rows)){
doctorList.value=rows.slice(0,50)
}
}catch(e){doctorList.value=[]}
}
const openNewTask=()=>{
Object.assign(taskForm,{patientId:null,patientName:'',bodyPart:'胸部',modality:'CT',reconstructionType:'VR',requestDoctor:'',studyUid:''})
newTaskDialog.value=true
}
const doAddTask=async()=>{
if(!taskFormRef.value) return
try{await taskFormRef.value.validate()}catch(e){return}
adding.value=true
try{
await addTask(taskForm)
ElMessage.success('3D重建任务已创建')
newTaskDialog.value=false
loadTasks();loadStats()
}catch(e){ElMessage.error('创建失败: '+e.message)}
finally{adding.value=false}
}
const doCancelTask=async(id)=>{
await ElMessageBox.confirm('确定取消该任务?','确认')
await cancelTask(id)
ElMessage.success('已取消');loadTasks()
}
const openViewer=(row)=>{
viewerTask.value=row
activeTab.value='viewer'
}
const doSubmitReport=async(id)=>{await submitReport(id);ElMessage.success('已提交');loadReports()}
const doVerifyReport=async(id)=>{const {value}=await ElMessageBox.prompt('审核医生','审核报告');if(value){await verifyReport(id,value);ElMessage.success('已审核');loadReports()}}
onMounted(()=>{loadTasks();loadReports();loadStats()})
const doVerifyReport=async(id)=>{
const {value}=await ElMessageBox.prompt('审核医生姓名','审核报告')
if(value){await verifyReport(id,value);ElMessage.success('已审核');loadReports()}
}
onMounted(()=>{loadTasks();loadReports();loadStats();loadDoctors()})
</script>

View File

@@ -1,599 +1,320 @@
<template>
<div class="viewer-container">
<!-- Toolbar -->
<div class="viewer-toolbar">
<div class="toolbar-group">
<span class="toolbar-label">渲染模式:</span>
<el-radio-group v-model="renderMode" size="small" @change="onModeChange">
<el-radio-button value="VR">VR</el-radio-button>
<el-radio-button value="MPR">MPR</el-radio-button>
<el-radio-button value="MIP">MIP</el-radio-button>
<el-radio-button value="2D">2D</el-radio-button>
</el-radio-group>
</div>
<div class="toolbar-group">
<span class="toolbar-label">预设:</span>
<el-button-group size="small">
<el-button :type="preset==='bone'?'primary':''" @click="setPreset('bone')">骨骼</el-button>
<el-button :type="preset==='soft'?'primary':''" @click="setPreset('soft')">软组织</el-button>
<el-button :type="preset==='lung'?'primary':''" @click="setPreset('lung')">肺部</el-button>
<el-button :type="preset==='angio'?'primary':''" @click="setPreset('angio')">血管</el-button>
<el-button :type="preset==='skin'?'primary':''" @click="setPreset('skin')">皮肤</el-button>
</el-button-group>
</div>
<div class="toolbar-group">
<span class="toolbar-label">工具:</span>
<el-button-group size="small">
<el-button :type="tool==='rotate'?'primary':''" @click="tool=tool==='rotate'?'':'rotate'">🔄 旋转</el-button>
<el-button :type="tool==='zoom'?'primary':''" @click="tool=tool==='zoom'?'':'zoom'">🔍 缩放</el-button>
<el-button :type="tool==='pan'?'primary':''" @click="tool=tool==='pan'?'':'pan'"> 平移</el-button>
<el-button :type="tool==='measure'?'primary':''" @click="tool=tool==='measure'?'':'measure'">📏 测量</el-button>
<el-button :type="tool==='window'?'primary':''" @click="tool=tool==='window'?'':'window'">🎨 窗宽窗位</el-button>
</el-button-group>
</div>
<div class="toolbar-group">
<el-button size="small" @click="resetView">重置视图</el-button>
<el-button size="small" @click="toggleFullscreen">{{ isFullscreen ? '退出全屏' : '全屏' }}</el-button>
</div>
<div class="viewer-wrap">
<!-- 工具栏 -->
<div class="vbar">
<el-radio-group v-model="mode" size="small" @change="onModeChange">
<el-radio-button value="VR">VR容积</el-radio-button>
<el-radio-button value="MPR">MPR多平面</el-radio-button>
<el-radio-button value="MIP">MIP最大密度</el-radio-button>
</el-radio-group>
<el-divider direction="vertical"/>
<el-button-group size="small">
<el-button v-for="p in presetNames" :key="p" :type="preset===p?'primary':''" @click="setPreset(p)">{{ presetLabels[p] }}</el-button>
</el-button-group>
<el-divider direction="vertical"/>
<el-button size="small" @click="resetView">重置</el-button>
<el-button size="small" @click="toggleFS">{{ fs?'退出全屏':'全屏' }}</el-button>
<span style="margin-left:auto;font-size:11px;color:#888">
鼠标左键拖拽旋转 | 滚轮缩放 | 右键平移
</span>
</div>
<!-- Main viewer area -->
<div class="viewer-main" ref="viewerMain">
<!-- 3D Canvas for VR/MIP/MPR -->
<canvas
ref="renderCanvas"
class="render-canvas"
@mousedown="onMouseDown"
@mousemove="onMouseMove"
@mouseup="onMouseUp"
@wheel.prevent="onWheel"
@contextmenu.prevent
/>
<!-- Slice info overlay -->
<div class="info-overlay top-left">
<div class="info-line">{{ patientName }}</div>
<div class="info-line">{{ studyDesc }}</div>
<div class="info-line">{{ modality }} | {{ bodyPart }}</div>
</div>
<div class="info-overlay top-right">
<div class="info-line">窗宽: {{ windowWidth }} 窗位: {{ windowCenter }}</div>
<div class="info-line">层厚: {{ sliceThickness }}mm</div>
<div class="info-line">像素: {{ pixelSpacing }}</div>
</div>
<div class="info-overlay bottom-left">
<div class="info-line">{{ renderMode }} | {{ preset.toUpperCase() }}</div>
<div class="info-line" v-if="renderMode==='MPR'">
轴位: {{ axialSlice }} | 矢状: {{ sagittalSlice }} | 冠状: {{ coronalSlice }}
</div>
<div class="info-line" v-if="renderMode==='2D'">
层面: {{ currentSlice }} / {{ totalSlices }}
</div>
</div>
<div class="info-overlay bottom-right">
<div class="info-line">VTK.js Volume Rendering</div>
<div class="info-line">FOV: {{ fov }}mm</div>
<!-- 主渲染区 -->
<div class="vmain" ref="mainRef">
<!-- 单屏模式(VR/MIP) -->
<canvas v-show="mode!=='MPR'" ref="c3d" class="c3d"
@mousedown="md" @mousemove="mm" @mouseup="mu" @mouseleave="mu"
@wheel.prevent="onWheel" @contextmenu.prevent/>
<!-- MPR四格 -->
<div v-if="mode==='MPR'" class="mpr-grid">
<div class="mpr-cell"><div class="mpr-h">轴位 Axial</div><canvas ref="cAxial" class="mpr-c"/></div>
<div class="mpr-cell"><div class="mpr-h">矢状 Sagittal</div><canvas ref="cSag" class="mpr-c"/></div>
<div class="mpr-cell"><div class="mpr-h">冠状 Coronal</div><canvas ref="cCor" class="mpr-c"/></div>
<div class="mpr-cell"><div class="mpr-h">3D预览</div><canvas ref="c3d2" class="mpr-c"/></div>
</div>
<!-- MPR multi-view -->
<div v-if="renderMode==='MPR'" class="mpr-container">
<div class="mpr-pane">
<div class="mpr-label">轴位 (Axial)</div>
<canvas ref="mprAxial" class="mpr-canvas" @wheel.prevent="e=>onMprSlice(e,'axial')"/>
</div>
<div class="mpr-pane">
<div class="mpr-label">矢状位 (Sagittal)</div>
<canvas ref="mprSagittal" class="mpr-canvas" @wheel.prevent="e=>onMprSlice(e,'sagittal')"/>
</div>
<div class="mpr-pane">
<div class="mpr-label">冠状位 (Coronal)</div>
<canvas ref="mprCoronal" class="mpr-canvas" @wheel.prevent="e=>onMprSlice(e,'coronal')"/>
</div>
<div class="mpr-pane">
<div class="mpr-label">3D 预览</div>
<canvas ref="mpr3d" class="mpr-canvas"/>
</div>
<!-- 信息叠加层 -->
<div class="ov ov-tl">
<div>患者: {{ taskData.patientName || '—' }}</div>
<div>{{ taskData.modality||'CT' }} | {{ taskData.bodyPart||'胸部' }} | {{ taskData.reconstructionType||'VR' }}</div>
</div>
<!-- Loading indicator -->
<div v-if="loading" class="loading-overlay">
<el-icon class="loading-spinner" :size="48"><Loading /></el-icon>
<div style="margin-top:12px;color:#fff">加载体数据中...</div>
<div class="ov ov-tr">
<div>模式: {{ mode }} | 预设: {{ presetLabels[preset] }}</div>
<div>窗位: {{ wl }} | 窗宽: {{ ww }}</div>
</div>
<div class="ov ov-bl">
<div v-if="mode==='MPR'">Z:{{ zSlice }} Y:{{ ySlice }} X:{{ xSlice }}</div>
<div v-else>旋转: {{ (rotX*57.3).toFixed(0) }}°,{{ (rotY*57.3).toFixed(0) }}° | 缩放: {{ zoom.toFixed(1) }}x</div>
</div>
</div>
<!-- Info panel -->
<div class="viewer-info-panel" v-if="showInfoPanel">
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="患者">{{ patientName }}</el-descriptions-item>
<el-descriptions-item label="检查">{{ studyDesc }}</el-descriptions-item>
<el-descriptions-item label="模态">{{ modality }}</el-descriptions-item>
<el-descriptions-item label="部位">{{ bodyPart }}</el-descriptions-item>
<el-descriptions-item label="重建">{{ renderMode }}</el-descriptions-item>
<el-descriptions-item label="体素">{{ voxelSize }}</el-descriptions-item>
</el-descriptions>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { Loading } from '@element-plus/icons-vue'
import {ref,onMounted,onUnmounted,watch,nextTick} from 'vue'
const props = defineProps({
taskId: { type: [Number, String], default: null },
taskData: { type: Object, default: () => ({}) }
})
const props=defineProps({taskData:{type:Object,default:()=>({})}})
const emit = defineEmits(['measurement', 'screenshot'])
const mode=ref('VR')
const preset=ref('bone')
const mainRef=ref(null)
const c3d=ref(null)
const c3d2=ref(null)
const cAxial=ref(null)
const cSag=ref(null)
const cCor=ref(null)
const fs=ref(false)
// State
const renderCanvas = ref(null)
const viewerMain = ref(null)
const renderMode = ref('VR')
const preset = ref('bone')
const tool = ref('')
const loading = ref(false)
const isFullscreen = ref(false)
const showInfoPanel = ref(true)
const wl=ref(40)
const ww=ref(400)
const rotX=ref(0.4), rotY=ref(-0.6), rotZ=ref(0)
const zoom=ref(1.2)
const panX=ref(0), panY=ref(0)
const zSlice=ref(32), ySlice=ref(32), xSlice=ref(32)
// Medical info
const patientName = ref('刘潇凡')
const studyDesc = ref('胸部CT平扫+三维重建')
const modality = ref('CT')
const bodyPart = ref('胸部')
const windowWidth = ref(400)
const windowCenter = ref(40)
const sliceThickness = ref('1.25mm')
const pixelSpacing = ref('0.625x0.625mm')
const fov = ref(350)
const voxelSize = ref('0.684x0.684x1.25mm')
const presetNames=['bone','soft','lung','angio','skin']
const presetLabels={bone:'骨骼',soft:'软组织',lung:'肺部',angio:'血管',skin:'皮肤'}
const presetWC={bone:400,boneWW:2500,soft:40,softWW:400,lung:-600,lungWW:1500,angio:300,angioWW:600,skin:50,skinWW:250}
// Slices
const currentSlice = ref(160)
const totalSlices = ref(320)
const axialSlice = ref(160)
const sagittalSlice = ref(256)
const coronalSlice = ref(256)
const SZ=64
let vol=null
let dragging=false,lastM={x:0,y:0}
let raf=null
// Volume data
let volumeData = null
let volumeSize = { x: 64, y: 64, z: 64 }
// Rendering state
let rotationX = 0.3
let rotationY = -0.5
let rotationZ = 0
let zoom = 1.0
let panX = 0
let panY = 0
let isDragging = false
let lastMouse = { x: 0, y: 0 }
let animFrame = null
// Window presets
const presets = {
bone: { windowCenter: 400, windowWidth: 2500, transferFn: 'bone' },
soft: { windowCenter: 40, windowWidth: 400, transferFn: 'soft' },
lung: { windowCenter: -600, windowWidth: 1500, transferFn: 'lung' },
angio: { windowCenter: 300, windowWidth: 600, transferFn: 'angio' },
skin: { windowCenter: 50, windowWidth: 250, transferFn: 'skin' }
// Transfer function
const tf={
bone:[[ -1000,-500, 0,0,0,0],[-500,-100, 30,30,50,0.1],[-100,100, 80,60,60,0.3],[100,500, 180,170,160,0.8],[500,1500, 255,255,255,1]],
soft:[[ -1000,-200, 0,0,0,0],[-200,0, 40,40,60,0.2],[0,80, 120,80,80,0.6],[80,300, 200,120,100,0.9],[300,1500, 255,255,255,1]],
lung:[[ -1000,-800, 0,0,0,0],[-800,-400, 20,30,50,0.3],[-400,-100, 60,80,100,0.5],[-100,100, 150,100,80,0.7],[100,1500, 255,255,255,1]],
angio:[[-1000,0, 0,0,0,0],[0,100, 30,20,20,0.1],[100,200, 200,50,30,0.8],[200,500, 255,100,50,0.9],[500,1500, 255,200,100,1]],
skin:[[ -1000,-200, 0,0,0,0],[-200,0, 50,40,35,0.15],[0,60, 180,130,110,0.8],[60,200, 220,170,140,0.95],[200,1500, 255,255,255,1]]
}
// Transfer function colors (RGBA for different HU ranges)
const transferFunctions = {
bone: [
{ min: -1000, max: -500, r: 0, g: 0, b: 0, a: 0 }, // Air - transparent
{ min: -500, max: -100, r: 30, g: 30, b: 50, a: 0.1 }, // Fat - dark
{ min: -100, max: 100, r: 80, g: 60, b: 60, a: 0.3 }, // Soft tissue
{ min: 100, max: 500, r: 180, g: 170, b: 160, a: 0.8 }, // Bone - bright
{ min: 500, max: 1500, r: 255, g: 255, b: 255, a: 1.0 } // Dense bone - white
],
soft: [
{ min: -1000, max: -200, r: 0, g: 0, b: 0, a: 0 },
{ min: -200, max: 0, r: 40, g: 40, b: 60, a: 0.2 },
{ min: 0, max: 80, r: 120, g: 80, b: 80, a: 0.6 },
{ min: 80, max: 300, r: 200, g: 120, b: 100, a: 0.9 },
{ min: 300, max: 1500, r: 255, g: 255, b: 255, a: 1.0 }
],
lung: [
{ min: -1000, max: -800, r: 0, g: 0, b: 0, a: 0 },
{ min: -800, max: -400, r: 20, g: 30, b: 50, a: 0.3 },
{ min: -400, max: -100, r: 60, g: 80, b: 100, a: 0.5 },
{ min: -100, max: 100, r: 150, g: 100, b: 80, a: 0.7 },
{ min: 100, max: 1500, r: 255, g: 255, b: 255, a: 1.0 }
],
angio: [
{ min: -1000, max: 0, r: 0, g: 0, b: 0, a: 0 },
{ min: 0, max: 100, r: 30, g: 20, b: 20, a: 0.1 },
{ min: 100, max: 200, r: 200, g: 50, b: 30, a: 0.8 },
{ min: 200, max: 500, r: 255, g: 100, b: 50, a: 0.9 },
{ min: 500, max: 1500, r: 255, g: 200, b: 100, a: 1.0 }
],
skin: [
{ min: -1000, max: -200, r: 0, g: 0, b: 0, a: 0 },
{ min: -200, max: 0, r: 50, g: 40, b: 35, a: 0.15 },
{ min: 0, max: 60, r: 180, g: 130, b: 110, a: 0.8 },
{ min: 60, max: 200, r: 220, g: 170, b: 140, a: 0.95 },
{ min: 200, max: 1500, r: 255, g: 255, b: 255, a: 1.0 }
]
function tfLookup(hu,name){
const stops=tf[name]||tf.bone
for(const s of stops){if(hu>=s[0]&&hu<=s[1])return{s:r,g:b,a}=null,s}
return null
}
// Generate synthetic chest CT volume
function generateVolume() {
const size = 64
volumeSize = { x: size, y: size, z: size }
volumeData = new Int16Array(size * size * size)
for (let z = 0; z < size; z++) {
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const dx = x - size / 2
const dy = y - size / 2
const dz = z - size / 2
const bodyDist = Math.sqrt((dx / 28) ** 2 + (dy / 24) ** 2 + (dz / 30) ** 2)
let hu = -1000 // Air
if (bodyDist < 1.0) {
if (bodyDist > 0.88) {
hu = -100 + (Math.random() - 0.5) * 30 // Fat
} else if (bodyDist > 0.78) {
hu = 45 + (Math.random() - 0.5) * 16 // Muscle
} else {
// Lungs
for (const side of [-1, 1]) {
const lungDist = Math.sqrt(((dx - side * 12) / 10) ** 2 + ((dy + 3) / 8) ** 2 + ((dz + 5) / 12) ** 2)
if (lungDist < 1.0) {
hu = -500 + (Math.random() - 0.5) * 120
break
}
}
if (hu === -1000) {
// Heart
const heartDist = Math.sqrt((dx / 6) ** 2 + ((dy + 3) / 5) ** 2 + ((dz + 3) / 7) ** 2)
if (heartDist < 1.0) {
hu = 45 + (Math.random() - 0.5) * 10
} else {
// Spine
const spineDist = Math.sqrt(dx * dx + (dy + 15) ** 2 + dz * dz)
if (spineDist < 4) {
hu = 350 + (Math.random() - 0.5) * 40
} else {
// Ribs
for (const side2 of [-1, 1]) {
const ribDist = Math.sqrt((dx - side2 * 22) ** 2 + (dy + 10) ** 2 + dz * dz)
if (ribDist < 2) {
hu = 800 + (Math.random() - 0.5) * 80
break
}
}
if (hu === -1000) hu = 40 + (Math.random() - 0.5) * 16
}
function genVol(){
vol=new Int16Array(SZ*SZ*SZ)
for(let z=0;z<SZ;z++)for(let y=0;y<SZ;y++)for(let x=0;x<SZ;x++){
const dx=x-SZ/2,dy=y-SZ/2,dz=z-SZ/2
const bd=Math.sqrt((dx/28)**2+(dy/24)**2+(dz/30)**2)
let hu=-1000
if(bd<1){
if(bd>0.88)hu=-100+(Math.random()-.5)*30
else if(bd>0.78)hu=45+(Math.random()-.5)*16
else{
for(const s of[-1,1]){
const ld=Math.sqrt(((dx-s*12)/10)**2+((dy+3)/8)**2+((dz+5)/12)**2)
if(ld<1){hu=-500+(Math.random()-.5)*120;break}
}
if(hu===-1000){
const hd=Math.sqrt((dx/6)**2+((dy+3)/5)**2+((dz+3)/7)**2)
if(hd<1)hu=45+(Math.random()-.5)*10
else{
const sd=Math.sqrt(dx*dx+(dy+15)**2+dz*dz)
if(sd<4)hu=350+(Math.random()-.5)*40
else{
for(const s of[-1,1]){
const rd=Math.sqrt((dx-s*22)**2+(dy+10)**2+dz*dz)
if(rd<2){hu=800+(Math.random()-.5)*80;break}
}
if(hu===-1000)hu=40+(Math.random()-.5)*16
}
}
}
volumeData[z * size * size + y * size + x] = hu
}
}
vol[z*SZ*SZ+y*SZ+x]=hu
}
}
// Transfer function lookup
function transferLookup(hu, tfName) {
const tf = transferFunctions[tfName] || transferFunctions.bone
for (const stop of tf) {
if (hu >= stop.min && hu <= stop.max) {
return { r: stop.r, g: stop.g, b: stop.b, a: stop.a }
}
}
return { r: 0, g: 0, b: 0, a: 0 }
}
// Volume render (VR or MIP)
function renderVR(canvas,isMIP){
if(!canvas||!vol)return
const ctx=canvas.getContext('2d')
const W=canvas.width,H=canvas.height
const id=ctx.createImageData(W,H)
const d=id.data
const half=SZ/2
const cosX=Math.cos(rotX.value),sinX=Math.sin(rotX.value)
const cosY=Math.cos(rotY.value),sinY=Math.sin(rotY.value)
const cosZ=Math.cos(rotZ.value),sinZ=Math.sin(rotZ.value)
const tfName=preset.value
// Ray marching volume renderer
function renderVolume(canvas, mode) {
if (!canvas || !volumeData) return
const ctx = canvas.getContext('2d')
const W = canvas.width
const H = canvas.height
const imgData = ctx.createImageData(W, H)
const data = imgData.data
const size = volumeSize.x
const tfName = preset.value
// Light direction for shading
const lightDir = { x: 0.577, y: -0.577, z: 0.577 }
// Camera rotation matrices
const cosX = Math.cos(rotationX), sinX = Math.sin(rotationX)
const cosY = Math.cos(rotationY), sinY = Math.sin(rotationY)
const cosZ = Math.cos(rotationZ), sinZ = Math.sin(rotationZ)
for (let py = 0; py < H; py++) {
for (let px = 0; px < W; px++) {
// Normalized device coordinates
const ndcX = ((px / W) - 0.5) * 2 / zoom + panX
const ndcY = ((py / H) - 0.5) * 2 / zoom + panY
// Ray direction through volume
const halfSize = size / 2
// Apply rotation
let rx = ndcX * halfSize
let ry = ndcY * halfSize
let rz = -halfSize * 1.5
for(let py=0;py<H;py+=2){ // step 2 for performance
for(let px=0;px<W;px+=2){
const ndcX=((px/W)-.5)*2/zoom.value+panX.value
const ndcY=((py/H)-.5)*2/zoom.value+panY.value
let rx=ndcX*half,ry=ndcY*half,rz=-half*1.5
// Rotate Y
let tx = rx * cosY + rz * sinY
let tz = -rx * sinY + rz * cosY
rx = tx; rz = tz
let tx=rx*cosY+rz*sinY;tz=-rx*sinY+rz*cosY;rx=tx;rz=tz
// Rotate X
let ty = ry * cosX - rz * sinX
tz = ry * sinX + rz * cosX
ry = ty; rz = tz
let ty=ry*cosX-rz*sinX;tz=ry*sinX+rz*cosX;ry=ty;rz=tz
// Rotate Z
tx = rx * cosZ - ry * sinZ
ty = rx * sinZ + ry * cosZ
rx = tx; ry = ty
tx=rx*cosZ-ry*sinZ;ty=rx*sinZ+ry*cosZ;rx=tx;ry=ty
// Ray march through volume
let rAcc = 0, gAcc = 0, bAcc = 0, aAcc = 0
const step = 1.5
const maxSteps = 128
const rayDir = { x: 0, y: 0, z: 1 }
for (let s = 0; s < maxSteps && aAcc < 0.95; s++) {
const t = s * step - halfSize * 1.5
const vx = Math.round(rx + rayDir.x * t + halfSize)
const vy = Math.round(ry + rayDir.y * t + halfSize)
const vz = Math.round(rz + rayDir.z * t + halfSize)
if (vx < 0 || vx >= size || vy < 0 || vy >= size || vz < 0 || vz >= size) continue
const hu = volumeData[vz * size * size + vy * size + vx]
if (mode === 'MIP') {
// Maximum Intensity Projection
const intensity = Math.max(0, Math.min(255, (hu + 1024) / 4))
if (intensity > rAcc) {
rAcc = intensity; gAcc = intensity * 0.8; bAcc = intensity * 0.7
}
} else {
// Volume rendering with transfer function
const color = transferLookup(hu, tfName)
if (color.a > 0.01) {
const da = color.a * step * 0.015
// Simple shading (gradient approximation)
const shade = 0.7 + 0.3 * Math.abs(Math.sin(hu * 0.01))
rAcc += da * color.r * shade / 255
gAcc += da * color.g * shade / 255
bAcc += da * color.b * shade / 255
aAcc += da
let rA=0,gA=0,bA=0,aA=0
for(let s=0;s<128&&aA<0.95;s++){
const t=s*1.5-half*1.5
const vx=Math.round(rx+t+half)
const vy=Math.round(ry+half)
const vz=Math.round(rz+half)
if(vx<0||vx>=SZ||vy<0||vy>=SZ||vz<0||vz>=SZ)continue
const hu=vol[vz*SZ*SZ+vy*SZ+vx]
if(isMIP){
const v=Math.max(0,Math.min(255,(hu+1024)/4))
if(v>rA){rA=v;gA=v*.8;bA=v*.7}
}else{
const stop=tfLookup(hu,tfName)
if(stop&&stop[5]>.01){
const da=stop[5]*1.5*.015
const sh=.7+.3*Math.abs(Math.sin(hu*.01))
rA+=da*stop[1]*sh/255
gA+=da*stop[2]*sh/255
bA+=da*stop[3]*sh/255
aA+=da
}
}
}
const idx = (py * W + px) * 4
data[idx] = Math.min(255, Math.max(0, rAcc * 255))
data[idx + 1] = Math.min(255, Math.max(0, gAcc * 255))
data[idx + 2] = Math.min(255, Math.max(0, bAcc * 255))
data[idx + 3] = 255
const idx=(py*W+px)*4
d[idx]=Math.min(255,Math.max(0,rA*255))
d[idx+1]=Math.min(255,Math.max(0,gA*255))
d[idx+2]=Math.min(255,Math.max(0,bA*255))
d[idx+3]=255
// Fill 2x2 block
if(px+1<W){d[idx+4]=d[idx];d[idx+5]=d[idx+1];d[idx+6]=d[idx+2];d[idx+7]=255}
if(py+1<H){
const idx2=((py+1)*W+px)*4
d[idx2]=d[idx];d[idx2+1]=d[idx+1];d[idx2+2]=d[idx+2];d[idx2+3]=255
if(px+1<W){d[idx2+4]=d[idx];d[idx2+5]=d[idx+1];d[idx2+6]=d[idx+2];d[idx2+7]=255}
}
}
}
ctx.putImageData(imgData, 0, 0)
ctx.putImageData(id,0,0)
}
// MPR slice rendering
function renderMprSlice(canvas, axis, sliceIdx) {
if (!canvas || !volumeData) return
const ctx = canvas.getContext('2d')
const W = canvas.width
const H = canvas.height
const imgData = ctx.createImageData(W, H)
const data = imgData.data
const size = volumeSize.x
const tfName = preset.value
for (let py = 0; py < H; py++) {
for (let px = 0; px < W; px++) {
const nx = (px / W) * size
const ny = (py / H) * size
// MPR slice render
function renderSlice(canvas,axis,sIdx){
if(!canvas||!vol)return
const ctx=canvas.getContext('2d')
const W=canvas.width,H=canvas.height
const id=ctx.createImageData(W,H)
const d=id.data
const low=wl.value-ww.value/2,high=wl.value+ww.value/2
for(let py=0;py<H;py+=2){
for(let px=0;px<W;px+=2){
const nx=(px/W)*SZ,ny=(py/H)*SZ
let hu
if (axis === 'axial') {
const z = Math.min(size - 1, Math.max(0, Math.round(sliceIdx)))
const x = Math.min(size - 1, Math.max(0, Math.round(nx)))
const y = Math.min(size - 1, Math.max(0, Math.round(ny)))
hu = volumeData[z * size * size + y * size + x]
} else if (axis === 'sagittal') {
const x = Math.min(size - 1, Math.max(0, Math.round(sliceIdx)))
const z = Math.min(size - 1, Math.max(0, Math.round(nx)))
const y = Math.min(size - 1, Math.max(0, Math.round(ny)))
hu = volumeData[y * size * size + z * size + x] || volumeData[z * size * size + y * size + x]
} else {
const y = Math.min(size - 1, Math.max(0, Math.round(sliceIdx)))
const x = Math.min(size - 1, Math.max(0, Math.round(nx)))
const z = Math.min(size - 1, Math.max(0, Math.round(ny)))
hu = volumeData[z * size * size + y * size + x]
if(axis==='z'){
const z=Math.min(SZ-1,Math.max(0,Math.round(sIdx)))
hu=vol[z*SZ*SZ+Math.round(ny)*SZ+Math.round(nx)]
}else if(axis==='x'){
const x=Math.min(SZ-1,Math.max(0,Math.round(sIdx)))
hu=vol[Math.round(ny)*SZ*SZ+Math.round(nx)*SZ+x]
}else{
const y=Math.min(SZ-1,Math.max(0,Math.round(sIdx)))
hu=vol[Math.round(nx)*SZ*SZ+y*SZ+Math.round(ny)]
}
// Apply windowing
const low = windowCenter.value - windowWidth.value / 2
const high = windowCenter.value + windowWidth.value / 2
let intensity
if (hu <= low) intensity = 0
else if (hu >= high) intensity = 255
else intensity = 255 * (hu - low) / (high - low)
const idx = (py * W + px) * 4
data[idx] = intensity
data[idx + 1] = intensity * 0.95
data[idx + 2] = intensity * 0.9
data[idx + 3] = 255
let v=hu<=low?0:hu>=high?255:255*(hu-low)/(high-low)
const idx=(py*W+px)*4
d[idx]=v;d[idx+1]=v*.95;d[idx+2]=v*.9;d[idx+3]=255
if(px+1<W){d[idx+4]=v;d[idx+5]=v*.95;d[idx+6]=v*.9;d[idx+7]=255}
if(py+1<H){const i2=((py+1)*W+px)*4;d[i2]=v;d[i2+1]=v*.95;d[i2+2]=v*.9;d[i2+3]=255;if(px+1<W){d[i2+4]=v;d[i2+5]=v*.95;d[i2+6]=v*.9;d[i2+7]=255}}
}
}
ctx.putImageData(imgData, 0, 0)
ctx.putImageData(id,0,0)
}
// Render loop
function render() {
const canvas = renderCanvas.value
if (!canvas || !volumeData) {
animFrame = requestAnimationFrame(render)
return
}
if (renderMode.value === 'MPR') {
// Render MPR views
nextTick(() => {
if (mprAxial.value) renderMprSlice(mprAxial.value, 'axial', axialSlice.value)
if (mprSagittal.value) renderMprSlice(mprSagittal.value, 'sagittal', sagittalSlice.value)
if (mprCoronal.value) renderMprSlice(mprCoronal.value, 'coronal', coronalSlice.value)
if (mpr3d.value) renderVolume(mpr3d.value, 'VR')
function frame(){
if(mode.value==='MPR'){
nextTick(()=>{
if(cAxial.value)renderSlice(cAxial.value,'z',zSlice.value)
if(cSag.value)renderSlice(cSag.value,'x',xSlice.value)
if(cCor.value)renderSlice(cCor.value,'y',ySlice.value)
if(c3d2.value)renderVR(c3d2.value,false)
})
} else {
renderVolume(canvas, renderMode.value)
}else{
const c=c3d.value
if(c)renderVR(c,mode.value==='MIP')
}
animFrame = requestAnimationFrame(render)
raf=requestAnimationFrame(frame)
}
// Mouse handlers
function onMouseDown(e) {
isDragging = true
lastMouse = { x: e.clientX, y: e.clientY }
}
function onMouseMove(e) {
if (!isDragging) return
const dx = e.clientX - lastMouse.x
const dy = e.clientY - lastMouse.y
lastMouse = { x: e.clientX, y: e.clientY }
if (tool.value === 'rotate' || (!tool.value && e.button === 0)) {
rotationY += dx * 0.01
rotationX += dy * 0.01
} else if (tool.value === 'pan') {
panX += dx / 200
panY += dy / 200
}
}
function onMouseUp() { isDragging = false }
function onWheel(e) {
if (tool.value === 'zoom' || !tool.value) {
zoom *= e.deltaY > 0 ? 0.9 : 1.1
zoom = Math.max(0.3, Math.min(5, zoom))
}
function szCanvas(c){
if(!c)return
const r=c.parentElement.getBoundingClientRect()
const dpr=window.devicePixelRatio||1
c.width=r.width*dpr
c.height=r.height*dpr
}
function onMprSlice(e, axis) {
const delta = e.deltaY > 0 ? 1 : -1
if (axis === 'axial') axialSlice.value = Math.max(0, Math.min(63, axialSlice.value + delta))
else if (axis === 'sagittal') sagittalSlice.value = Math.max(0, Math.min(63, sagittalSlice.value + delta))
else coronalSlice.value = Math.max(0, Math.min(63, coronalSlice.value + delta))
function md(e){dragging=true;lastM={x:e.clientX,y:e.clientY}}
function mm(e){
if(!dragging)return
const dx=e.clientX-lastM.x,dy=e.clientY-lastM.y
lastM={x:e.clientX,y:e.clientY}
rotY.value+=dx*.01;rotX.value+=dy*.01
}
function mu(){dragging=false}
function onWheel(e){
zoom.value*=e.deltaY>0?.9:1.1
zoom.value=Math.max(.3,Math.min(5,zoom.value))
}
function setPreset(name) {
preset.value = name
const p = presets[name]
windowCenter.value = p.windowCenter
windowWidth.value = p.windowWidth
function setPreset(p){
preset.value=p
const wc={bone:[400,2500],soft:[40,400],lung:[-600,1500],angio:[300,600],skin:[50,250]}
wl.value=wc[p][0];ww.value=wc[p][1]
}
function onModeChange() {
if (renderMode.value === 'MPR') {
nextTick(() => {
const canvas = renderCanvas.value
if (canvas) {
const ctx = canvas.getContext('2d')
ctx.fillStyle = '#000'
ctx.fillRect(0, 0, canvas.width, canvas.height)
}
})
}
}
function resetView() {
rotationX = 0.3; rotationY = -0.5; rotationZ = 0
zoom = 1.0; panX = 0; panY = 0
}
function toggleFullscreen() {
if (!document.fullscreenElement) {
viewerMain.value?.requestFullscreen()
isFullscreen.value = true
} else {
document.exitFullscreen()
isFullscreen.value = false
}
}
const mprAxial = ref(null)
const mprSagittal = ref(null)
const mprCoronal = ref(null)
const mpr3d = ref(null)
onMounted(() => {
generateVolume()
nextTick(() => {
const canvas = renderCanvas.value
if (canvas) {
canvas.width = canvas.clientWidth * (window.devicePixelRatio || 1)
canvas.height = canvas.clientHeight * (window.devicePixelRatio || 1)
function onModeChange(){
nextTick(()=>{
if(mode.value==='MPR'){
[cAxial,cSag,cCor,c3d2].forEach(c=>{if(c.value)szCanvas(c.value)})
}else{
szCanvas(c3d.value)
}
render()
})
}
function resetView(){rotX.value=.4;rotY.value=-.6;rotZ.value=0;zoom.value=1.2;panX.value=0;panY.value=0}
function toggleFS(){
if(!document.fullscreenElement){mainRef.value?.requestFullscreen();fs.value=true}
else{document.exitFullscreen();fs.value=false}
}
function handleResize(){
if(mode.value==='MPR'){[cAxial,cSag,cCor,c3d2].forEach(c=>{if(c.value)szCanvas(c.value)})}
else szCanvas(c3d.value)
}
onMounted(()=>{
genVol()
nextTick(()=>{
szCanvas(c3d.value)
window.addEventListener('resize',handleResize)
frame()
})
})
onUnmounted(() => {
if (animFrame) cancelAnimationFrame(animFrame)
onUnmounted(()=>{
if(raf)cancelAnimationFrame(raf)
window.removeEventListener('resize',handleResize)
})
</script>
<style scoped>
.viewer-container {
display: flex; flex-direction: column; height: 100%; background: #0a0a1a; color: #fff;
}
.viewer-toolbar {
display: flex; gap: 16px; padding: 8px 16px; background: #1a1a2e;
border-bottom: 1px solid #333; flex-wrap: wrap; align-items: center;
}
.toolbar-group { display: flex; align-items: center; gap: 6px; }
.toolbar-label { font-size: 12px; color: #999; white-space: nowrap; }
.viewer-main {
flex: 1; position: relative; overflow: hidden; background: #000;
}
.render-canvas { width: 100%; height: 100%; display: block; cursor: crosshair; }
.info-overlay {
position: absolute; padding: 8px 12px; background: rgba(0,0,0,0.7);
border-radius: 4px; font-size: 11px; font-family: 'Courier New', monospace;
color: #0f0; pointer-events: none; z-index: 10;
}
.info-overlay.top-left { top: 8px; left: 8px; }
.info-overlay.top-right { top: 8px; right: 8px; }
.info-overlay.bottom-left { bottom: 8px; left: 8px; }
.info-overlay.bottom-right { bottom: 8px; right: 8px; }
.info-line { line-height: 1.5; }
.loading-overlay {
position: absolute; inset: 0; display: flex; flex-direction: column;
align-items: center; justify-content: center; background: rgba(0,0,0,0.8); z-index: 20;
}
.loading-spinner { animation: spin 1s linear infinite; color: #409eff; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.mpr-container {
position: absolute; inset: 0; display: grid;
grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; gap: 2px;
}
.mpr-pane { position: relative; background: #000; overflow: hidden; }
.mpr-canvas { width: 100%; height: 100%; display: block; }
.mpr-label {
position: absolute; top: 4px; left: 8px; font-size: 11px;
color: #0f0; font-family: monospace; z-index: 5; pointer-events: none;
}
.viewer-info-panel {
padding: 8px 16px; background: #1a1a2e; border-top: 1px solid #333;
}
.viewer-wrap{display:flex;flex-direction:column;height:100%;background:#0a0a1a;color:#fff}
.vbar{display:flex;gap:8px;padding:8px 12px;background:#1a1a2e;border-bottom:1px solid #333;align-items:center;flex-wrap:wrap}
.vmain{flex:1;position:relative;overflow:hidden;background:#000}
.c3d{width:100%;height:100%;display:block;cursor:crosshair}
.mpr-grid{position:absolute;inset:0;display:grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;gap:2px}
.mpr-cell{position:relative;background:#000;overflow:hidden}
.mpr-c{width:100%;height:100%;display:block}
.mpr-h{position:absolute;top:4px;left:8px;font-size:11px;color:#0f0;font-family:monospace;z-index:5;pointer-events:none;background:rgba(0,0,0,.5);padding:2px 6px;border-radius:3px}
.ov{position:absolute;padding:6px 10px;background:rgba(0,0,0,.7);border-radius:4px;font-size:11px;font-family:'Courier New',monospace;color:#0f0;pointer-events:none;z-index:10;line-height:1.5}
.ov-tl{top:8px;left:8px}.ov-tr{top:8px;right:8px;text-align:right}
.ov-bl{bottom:8px;left:8px}
</style>