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

This commit is contained in:
2026-06-08 09:35:01 +08:00
13 changed files with 763 additions and 898091 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 46 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 71 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,91 @@
{
"PatientName": "刘潇凡",
"PatientID": "PN0000000006",
"StudyDate": "20260606",
"Modality": "CT",
"BodyPart": "胸部",
"SliceThickness": "1.25mm",
"PixelSpacing": "0.625x0.625mm",
"ImageSize": "512x512",
"NumberOfSlices": "320",
"StudyInstanceUID": "1.2.840.113619.2.55.3.12345678",
"ReconstructionType": "VR/MPR/MIP",
"WindowCenter": "40",
"WindowWidth": "400",
"BitsAllocated": "16",
"BitsStored": "12",
"PixelRepresentation": "0"
"patientInfo": {
"patientName": "刘潇凡",
"patientID": "PN0000000006",
"birthDate": "2007-04-29",
"sex": "M",
"age": "19Y"
},
"studyInfo": {
"studyDate": "2026-06-06",
"studyTime": "14:30:22",
"studyDescription": "胸部CT平扫+三维重建",
"studyInstanceUID": "1.2.840.113619.2.55.3.12345678",
"accessionNumber": "CT20260606001"
},
"seriesInfo": {
"modality": "CT",
"bodyPartExamined": "CHEST",
"institutionName": "广西医科大学第一附属医院",
"stationName": "CT-SOMATOM_FORCE",
"manufacturer": "SIEMENS",
"model": "SOMATOM Force",
"softwareVersion": "syngo CT VA48A"
},
"imageParams": {
"rows": 512,
"columns": 512,
"sliceThickness": 1.25,
"pixelSpacing": [
0.625,
0.625
],
"kvp": 120,
"mas": 200,
"rotationTime": 0.5,
"pitch": 0.9,
"reconstructionKernel": "B31f",
"windowCenter": 40,
"windowWidth": 400,
"rescaleIntercept": -1024,
"rescaleSlope": 1,
"bitsAllocated": 16,
"bitsStored": 12,
"numberOfImages": 320,
"imageType": [
"DERIVED",
"SECONDARY",
"MPR"
]
},
"reconstructionParams": {
"algorithm": "Feldkamp-Davis-Kress (FDK)",
"reconType": [
"VR",
"MPR",
"MIP"
],
"sliceRange": "5.0mm - 350.0mm",
"fieldOfView": 350,
"matrixSize": [
512,
512
],
"voxelSize": [
0.684,
0.684,
1.25
]
},
"clinicalFindings": {
"lungVolumes": {
"left": "1650ml",
"right": "1820ml",
"total": "3470ml"
},
"heartVolume": "485ml",
"mediastinalStructures": "正常",
"pleuralSpace": "未见积液",
"lesions": [
{
"location": "右肺上叶(S1)",
"size": "8.5mm x 7.2mm",
"density": "实性",
"shape": "类圆形",
"margin": "光滑",
"bradsCategory": "3类",
"recommendation": "3个月后复查"
}
]
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 44 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 19 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -28,14 +28,20 @@
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@kitware/vtk.js": "^36.2.0",
"@vue/shared": "^3.5.25",
"@vueup/vue-quill": "^1.5.1",
"@vueuse/core": "^14.3.0",
"axios": "^1.16.1",
"china-division": "^2.7.0",
"cornerstone-core": "^2.6.1",
"cornerstone-math": "^0.1.10",
"cornerstone-tools": "^6.0.10",
"cornerstone-wado-image-loader": "^4.13.2",
"d3": "^7.9.0",
"dayjs": "^1.11.19",
"decimal.js": "^10.5.0",
"dicom-parser": "^1.8.21",
"echarts": "^5.6.0",
"element-china-area-data": "^6.1.0",
"element-plus": "^2.14.1",

View File

@@ -18,7 +18,7 @@
<el-option label="已完成" value="COMPLETED"/>
</el-select>
<el-select v-model="taskQ.modality" placeholder="模态" clearable style="width:100px">
<el-option label="CT" value="CT"/><el-option label="MRI" value="MRI"/>
<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>
@@ -40,41 +40,14 @@
<el-table-column prop="completeTime" label="完成时间" width="170"/>
<el-table-column label="操作" width="140">
<template #default="{row}">
<el-button v-if="row.taskStatus==='COMPLETED'" type="primary" link size="small" @click="viewResults(row.id)">查看结果</el-button>
<el-button v-if="row.taskStatus==='COMPLETED'" type="primary" link size="small" @click="openViewer(row)">3D查看</el-button>
<el-button v-if="row.taskStatus==='PENDING'" type="danger" link size="small" @click="doCancelTask(row.id)">取消</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="3D查看器" name="viewer">
<div style="text-align:center;padding:40px;background:#1a1a2e;border-radius:8px;color:#fff">
<div style="font-size:24px;margin-bottom:16px">3D影像查看器</div>
<div style="color:#999;margin-bottom:24px">
基于 Cornerstone.js + VTK.js 的医学影像3D重建查看器<br>
支持: 容积渲染(VR) / 多平面重建(MPR) / 最大密度投影(MIP)
</div>
<div style="display:flex;gap:12px;justify-content:center">
<el-button type="primary" @click="viewerMode='VR'">容积渲染(VR)</el-button>
<el-button type="success" @click="viewerMode='MPR'">多平面(MPR)</el-button>
<el-button type="warning" @click="viewerMode='MIP'">最大密度(MIP)</el-button>
</div>
<div style="margin-top:16px;color:#666;font-size:12px">
当前模式: {{ viewerMode }} | 需要Cornerstone.js + VTK.js依赖
</div>
<div style="margin-top:24px;display:flex;gap:8px;justify-content:center;flex-wrap:wrap">
<el-button @click="renderPreset='bone'" :type="renderPreset==='bone'?'primary':''">骨骼预设</el-button>
<el-button @click="renderPreset='soft'" :type="renderPreset==='soft'?'primary':''">软组织预设</el-button>
<el-button @click="renderPreset='lung'" :type="renderPreset==='lung'?'primary':''">肺部预设</el-button>
<el-button @click="renderPreset='angio'" :type="renderPreset==='angio'?'primary':''">血管预设</el-button>
<el-button @click="renderPreset='skin'" :type="renderPreset==='skin'?'primary':''">皮肤预设</el-button>
</div>
<div style="margin-top:16px;display:flex;gap:8px;justify-content:center">
<el-button @click="measureTool='length'" :type="measureTool==='length'?'primary':''">📏 距离</el-button>
<el-button @click="measureTool='angle'" :type="measureTool==='angle'?'primary':''">📐 角度</el-button>
<el-button @click="measureTool='area'" :type="measureTool==='area'?'primary':''"> 面积</el-button>
<el-button @click="measureTool='ct'" :type="measureTool==='ct'?'primary':''">🔍 CT值</el-button>
</div>
</div>
<Viewer3D :task-data="viewerTask" />
</el-tab-pane>
<el-tab-pane label="重建报告" name="report">
<el-table :data="reportData" border stripe>
@@ -102,39 +75,35 @@
<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="就诊ID"><el-input v-model.number="taskForm.encounterId"/></el-form-item>
<el-form-item label="检查UID"><el-input v-model="taskForm.studyUid"/></el-form-item>
<el-form-item label="模态">
<el-select v-model="taskForm.modality"><el-option label="CT" value="CT"/><el-option label="MRI" value="MRI"/></el-select>
</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-option label="容积渲染(VR)" value="VR"/><el-option label="多平面(MPR)" value="MPR"/>
<el-option label="最大密度投影(MIP)" value="MIP"/><el-option label="VR+MPR" value="VR+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>
<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">创建</el-button></template>
</el-dialog>
</div>
</template>
<script setup>
import {ref,onMounted} from 'vue';import {ElMessage,ElMessageBox} from 'element-plus';
import {getTaskPage,addTask,cancelTask,getResults,getReportPage,addReport,submitReport,verifyReport,getStats} from './api'
import {getTaskPage,addTask,cancelTask,getReportPage,addReport,submitReport,verifyReport,getStats} from './api'
import Viewer3D from './viewer.vue'
const activeTab=ref('task')
const taskData=ref([]);const reportData=ref([]);const stats=ref({})
const viewerTask=ref({})
const taskQ=ref({pageNo:1,pageSize:20,taskStatus:'',modality:''})
const newTaskDialog=ref(false)
const taskForm=ref({patientId:null,patientName:'',encounterId:null,studyUid:'',modality:'CT',bodyPart:'',reconstructionType:'VR',requestDoctor:''})
const viewerMode=ref('VR');const renderPreset=ref('bone');const measureTool=ref('')
const taskForm=ref({patientId:null,patientName:'',bodyPart:'',reconstructionType:'VR',requestDoctor:''})
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 viewResults=async(taskId)=>{activeTab.value='viewer';ElMessage.info('请在3D查看器中查看结果(需要集成Cornerstone.js)')}
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()})

View File

@@ -0,0 +1,599 @@
<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>
<!-- 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>
<!-- 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'
const props = defineProps({
taskId: { type: [Number, String], default: null },
taskData: { type: Object, default: () => ({}) }
})
const emit = defineEmits(['measurement', 'screenshot'])
// 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)
// 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 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
}
}
}
}
}
volumeData[z * size * size + y * size + 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 }
}
// 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
// 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
}
}
}
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
}
}
ctx.putImageData(imgData, 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
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]
}
// 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)
}
// 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')
})
} else {
renderVolume(canvas, renderMode.value)
}
animFrame = requestAnimationFrame(render)
}
// 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 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 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()
})
})
onUnmounted(() => {
if (animFrame) cancelAnimationFrame(animFrame)
})
</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;
}
</style>