Merge remote-tracking branch 'origin/develop' into develop

This commit is contained in:
wangjian963
2026-06-08 10:41:22 +08:00
3 changed files with 313 additions and 565 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,250 @@
<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">
<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>
<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 class="vmain" ref="mainRef">
<canvas v-show="mode!=='MPR'" ref="c3d" class="c3d"
@mousedown="md" @mousemove="mm" @mouseup="mu" @mouseleave="mu"
@wheel.prevent="onWheel" @contextmenu.prevent/>
<div v-if="mode==='MPR'" class="mpr-grid">
<div class="mpr-cell"><div class="mpr-h">轴位 Axial</div><canvas ref="cA" class="mpr-c"/></div>
<div class="mpr-cell"><div class="mpr-h">矢状 Sagittal</div><canvas ref="cS" class="mpr-c"/></div>
<div class="mpr-cell"><div class="mpr-h">冠状 Coronal</div><canvas ref="cC" class="mpr-c"/></div>
<div class="mpr-cell"><div class="mpr-h">3D预览</div><canvas ref="c3d2" class="mpr-c"/></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 class="ov ov-tl">
<div>{{ taskData.patientName || '患者' }} | {{ taskData.modality||'CT' }} {{ taskData.bodyPart||'胸部' }}</div>
<div>{{ mode }} {{ presetLabels[preset] }} | WL:{{ wl }} WW:{{ ww }}</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 class="ov ov-bl">
<div v-if="mode==='MPR'">Z:{{ zs }} Y:{{ ys }} X:{{ xs }}</div>
<div v-else>旋转:{{ (rx*57.3).toFixed(0) }}° 缩放:{{ zm.toFixed(1) }}x</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>
<!-- 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>
<!-- 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>
</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,nextTick,watch} from 'vue'
const props=defineProps({taskData:{type:Object,default:()=>({})}})
const props = defineProps({
taskId: { type: [Number, String], default: null },
taskData: { type: Object, default: () => ({}) }
})
const mode=ref('VR'),preset=ref('bone')
const mainRef=ref(null),c3d=ref(null),c3d2=ref(null)
const cA=ref(null),cS=ref(null),cC=ref(null)
const wl=ref(40),ww=ref(400)
const rx=ref(0.4),ry=ref(-0.6),rz=ref(0),zm=ref(1.2)
const zs=ref(32),ys=ref(32),xs=ref(32)
const emit = defineEmits(['measurement', 'screenshot'])
const presetNames=['bone','soft','lung','angio','skin']
const presetLabels={bone:'骨骼',soft:'软组织',lung:'肺部',angio:'血管',skin:'皮肤'}
// 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 SZ=64
let vol=null,dragging=false,lm={x:0,y:0},raf=null,ready=false
// 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')
// Slices
const currentSlice = ref(160)
const totalSlices = ref(320)
const axialSlice = ref(160)
const sagittalSlice = ref(256)
const coronalSlice = ref(256)
// 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 functions: [minHU, maxHU, R, G, B, alpha]
const TF={
bone:[[-1000,-500,0,0,0,0],[-500,-100,30,30,50,.1],[-100,100,80,60,60,.3],[100,500,180,170,160,.8],[500,1500,255,255,255,1]],
soft:[[-1000,-200,0,0,0,0],[-200,0,40,40,60,.2],[0,80,120,80,80,.6],[80,300,200,120,100,.9],[300,1500,255,255,255,1]],
lung:[[-1000,-800,0,0,0,0],[-800,-400,20,30,50,.3],[-400,-100,60,80,100,.5],[-100,100,150,100,80,.7],[100,1500,255,255,255,1]],
angio:[[-1000,0,0,0,0,0],[0,100,30,20,20,.1],[100,200,200,50,30,.8],[200,500,255,100,50,.9],[500,1500,255,200,100,1]],
skin:[[-1000,-200,0,0,0,0],[-200,0,50,40,35,.15],[0,60,180,130,110,.8],[60,200,220,170,140,.95],[200,1500,255,255,255,1]]
}
function tfl(hu,n){const s=TF[n]||TF.bone;for(const t of s)if(hu>=t[0]&&hu<=t[1])return t;return null}
// 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 }
]
}
// 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-32,dy=y-32,dz=z-32
const bd=Math.sqrt((dx/28)**2+(dy/24)**2+(dz/30)**2)
let hu=-1000
if(bd<1){
if(bd>.88)hu=-100+(Math.random()-.5)*30
else if(bd>.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 }
function szCanvas(c){
if(!c||!c.parentElement)return
const r=c.parentElement.getBoundingClientRect()
if(r.width<1||r.height<1)return
const dpr=window.devicePixelRatio||1
c.width=Math.floor(r.width*dpr)
c.height=Math.floor(r.height*dpr)
}
// 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
function renderVR(cv,mip){
if(!cv||!vol||cv.width<1)return
const ctx=cv.getContext('2d')
const W=cv.width,H=cv.height
const id=ctx.createImageData(W,H)
const d=id.data
const half=32
const cX=Math.cos(rx.value),sX=Math.sin(rx.value)
const cY=Math.cos(ry.value),sY=Math.sin(ry.value)
const cZ=Math.cos(rz.value),sZ=Math.sin(rz.value)
const tfn=preset.value
const step=Math.max(1,Math.floor(2/(zm.value||1)))
// 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+=step){
for(let px=0;px<W;px+=step){
const nx=((px/W)-.5)*2/zm.value
const ny=((py/H)-.5)*2/zm.value
let r0=nx*half,c0=ny*half,t0=-half*1.5
let t1=r0*cY+t0*sY;t0=-r0*sY+t0*cY;r0=t1
t1=c0*cX-t0*sX;t0=c0*sX+t0*cX;c0=t1
t1=r0*cZ-c0*sZ;c0=r0*sZ+c0*cZ;r0=t1
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
// Rotate Y
let tx = rx * cosY + rz * sinY
let 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
// Rotate Z
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<100&&aa<.95;s++){
const t=s*1.5-half*1.5
const vx=Math.round(r0+t+half),vy=Math.round(c0+half),vz=Math.round(t0+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(mip){
const v=Math.max(0,Math.min(255,(hu+1024)/4))
if(v>ra){ra=v;ga=v*.8;ba=v*.7}
}else{
const st=tfl(hu,tfn)
if(st&&st[5]>.01){
const da=st[5]*1.5*.015
const sh=.7+.3*Math.abs(Math.sin(hu*.01))
ra+=da*st[1]*sh/255;ga+=da*st[2]*sh/255;ba+=da*st[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 v=Math.min(255,Math.max(0,ra*255))
const g=Math.min(255,Math.max(0,ga*255))
const b=Math.min(255,Math.max(0,ba*255))
// Fill step x step block
for(let dy2=0;dy2<step&&py+dy2<H;dy2++){
for(let dx2=0;dx2<step&&px+dx2<W;dx2++){
const i=((py+dy2)*W+(px+dx2))*4
d[i]=v;d[i+1]=g;d[i+2]=b;d[i+3]=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
function renderSlice(cv,axis,idx){
if(!cv||!vol||cv.width<1)return
const ctx=cv.getContext('2d')
const W=cv.width,H=cv.height
const id=ctx.createImageData(W,H)
const d=id.data
const lo=wl.value-ww.value/2,hi=wl.value+ww.value/2
const step=Math.max(1,Math.floor(2))
for(let py=0;py<H;py+=step){
for(let px=0;px<W;px+=step){
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]
const zi=Math.min(SZ-1,Math.max(0,Math.round(idx)))
const xi=Math.min(SZ-1,Math.max(0,Math.round(nx)))
const yi=Math.min(SZ-1,Math.max(0,Math.round(ny)))
if(axis==='z')hu=vol[zi*SZ*SZ+yi*SZ+xi]
else if(axis==='x')hu=vol[yi*SZ*SZ+xi*SZ+zi]
else hu=vol[xi*SZ*SZ+yi*SZ+zi]
const v=hu<=lo?0:hu>=hi?255:255*(hu-lo)/(hi-lo)
for(let dy2=0;dy2<step&&py+dy2<H;dy2++){
for(let dx2=0;dx2<step&&px+dx2<W;dx2++){
const i=((py+dy2)*W+(px+dx2))*4
d[i]=v;d[i+1]=v*.95;d[i+2]=v*.9;d[i+3]=255
}
}
// 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
}
}
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(!ready){raf=requestAnimationFrame(frame);return}
if(mode.value==='MPR'){
nextTick(()=>{
if(cA.value)renderSlice(cA.value,'z',zs.value)
if(cS.value)renderSlice(cS.value,'x',xs.value)
if(cC.value)renderSlice(cC.value,'y',ys.value)
if(c3d2.value)renderVR(c3d2.value,false)
})
} else {
renderVolume(canvas, renderMode.value)
}else{
if(c3d.value)renderVR(c3d.value,mode.value==='MIP')
}
animFrame = requestAnimationFrame(render)
raf=requestAnimationFrame(frame)
}
// Mouse handlers
function onMouseDown(e) {
isDragging = true
lastMouse = { x: e.clientX, y: e.clientY }
function md(e){dragging=true;lm={x:e.clientX,y:e.clientY}}
function mm(e){if(!dragging)return;ry.value+=(e.clientX-lm.x)*.01;rx.value+=(e.clientY-lm.y)*.01;lm={x:e.clientX,y:e.clientY}}
function mu(){dragging=false}
function onWheel(e){zm.value*=e.deltaY>0?.9:1.1;zm.value=Math.max(.3,Math.min(5,zm.value))}
function setPreset(p){
preset.value=p
const m={bone:[400,2500],soft:[40,400],lung:[-600,1500],angio:[300,600],skin:[50,250]}
wl.value=m[p][0];ww.value=m[p][1]
}
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 resetView(){rx.value=.4;ry.value=-.6;rz.value=0;zm.value=1.2}
function resizeAll(){
if(mode.value==='MPR'){
[cA,cS,cC,c3d2].forEach(c=>{if(c.value)szCanvas(c.value)})
}else{
szCanvas(c3d.value)
}
}
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))
}
// Watch mode changes to resize canvases
watch(mode,()=>{nextTick(()=>{resizeAll()})})
function setPreset(name) {
preset.value = name
const p = presets[name]
windowCenter.value = p.windowCenter
windowWidth.value = p.windowWidth
}
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)
}
render()
onMounted(()=>{
genVol()
// Wait for DOM to be fully rendered, then start
nextTick(()=>{
setTimeout(()=>{
resizeAll()
ready=true
frame()
},100)
})
window.addEventListener('resize',resizeAll)
})
onUnmounted(() => {
if (animFrame) cancelAnimationFrame(animFrame)
onUnmounted(()=>{
if(raf)cancelAnimationFrame(raf)
window.removeEventListener('resize',resizeAll)
ready=false
})
</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,.6);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-bl{bottom:8px;left:8px}
</style>