feat: Three.js WebGL 3D体积渲染查看器 - 可拖拽旋转
3D查看器(viewer.vue): - Three.js + WebGL GLSL着色器实时体积渲染 - 128步光线投射(Ray Marching)算法 - 64³胸部CT体数据(肺/心脏/脊柱/肋骨/软组织) - 5种Transfer Function预设(骨骼/软组织/肺部/血管/皮肤) - OrbitControls: 左键旋转/右键平移/滚轮缩放 - VR/MIP模式着色器动态切换 - DICOM信息叠加层 预渲染图片(public/3d-views/): - 胸部/头部/腹部/膝关节 4个体位 - VR/MIP/MPR/窗宽窗位 10种视图 - DICOM风格文字叠加
@@ -59,6 +59,7 @@
|
||||
"qrcodejs2": "^0.0.2",
|
||||
"segmentit": "^2.0.3",
|
||||
"sortablejs": "^1.15.7",
|
||||
"three": "^0.184.0",
|
||||
"v-region": "^3.3.0",
|
||||
"vue": "^3.5.25",
|
||||
"vue-area-linkage": "^5.1.0",
|
||||
|
||||
BIN
healthlink-his-ui/public/3d-views/abdomen/mip_axial.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
healthlink-his-ui/public/3d-views/abdomen/mip_coronal.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
healthlink-his-ui/public/3d-views/abdomen/mip_sagittal.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
healthlink-his-ui/public/3d-views/abdomen/mpr_axial.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
healthlink-his-ui/public/3d-views/abdomen/mpr_coronal.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
healthlink-his-ui/public/3d-views/abdomen/mpr_sagittal.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
healthlink-his-ui/public/3d-views/abdomen/vr.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
healthlink-his-ui/public/3d-views/abdomen/window_bone.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
healthlink-his-ui/public/3d-views/abdomen/window_lung.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
healthlink-his-ui/public/3d-views/abdomen/window_soft.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
healthlink-his-ui/public/3d-views/chest/mip_axial.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
healthlink-his-ui/public/3d-views/chest/mip_coronal.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
healthlink-his-ui/public/3d-views/chest/mip_sagittal.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
healthlink-his-ui/public/3d-views/chest/mpr_axial.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
healthlink-his-ui/public/3d-views/chest/mpr_coronal.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
healthlink-his-ui/public/3d-views/chest/mpr_sagittal.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
healthlink-his-ui/public/3d-views/chest/vr.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
healthlink-his-ui/public/3d-views/chest/window_bone.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
healthlink-his-ui/public/3d-views/chest/window_lung.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
healthlink-his-ui/public/3d-views/chest/window_soft.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
healthlink-his-ui/public/3d-views/head/mip_axial.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
healthlink-his-ui/public/3d-views/head/mip_coronal.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
healthlink-his-ui/public/3d-views/head/mip_sagittal.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
healthlink-his-ui/public/3d-views/head/mpr_axial.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
healthlink-his-ui/public/3d-views/head/mpr_coronal.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
healthlink-his-ui/public/3d-views/head/mpr_sagittal.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
healthlink-his-ui/public/3d-views/head/vr.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
healthlink-his-ui/public/3d-views/head/window_bone.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
healthlink-his-ui/public/3d-views/head/window_lung.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
healthlink-his-ui/public/3d-views/head/window_soft.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
healthlink-his-ui/public/3d-views/knee/mip_axial.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
healthlink-his-ui/public/3d-views/knee/mip_coronal.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
healthlink-his-ui/public/3d-views/knee/mip_sagittal.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
healthlink-his-ui/public/3d-views/knee/mpr_axial.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
healthlink-his-ui/public/3d-views/knee/mpr_coronal.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
healthlink-his-ui/public/3d-views/knee/mpr_sagittal.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
healthlink-his-ui/public/3d-views/knee/vr.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
healthlink-his-ui/public/3d-views/knee/window_bone.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
healthlink-his-ui/public/3d-views/knee/window_lung.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
healthlink-his-ui/public/3d-views/knee/window_soft.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
50
healthlink-his-ui/public/3d-views/views.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"chest": {
|
||||
"vr": "/3d-views/chest/vr.png",
|
||||
"mip_axial": "/3d-views/chest/mip_axial.png",
|
||||
"mip_sagittal": "/3d-views/chest/mip_sagittal.png",
|
||||
"mip_coronal": "/3d-views/chest/mip_coronal.png",
|
||||
"mpr_axial": "/3d-views/chest/mpr_axial.png",
|
||||
"mpr_sagittal": "/3d-views/chest/mpr_sagittal.png",
|
||||
"mpr_coronal": "/3d-views/chest/mpr_coronal.png",
|
||||
"window_bone": "/3d-views/chest/window_bone.png",
|
||||
"window_soft": "/3d-views/chest/window_soft.png",
|
||||
"window_lung": "/3d-views/chest/window_lung.png"
|
||||
},
|
||||
"head": {
|
||||
"vr": "/3d-views/head/vr.png",
|
||||
"mip_axial": "/3d-views/head/mip_axial.png",
|
||||
"mip_sagittal": "/3d-views/head/mip_sagittal.png",
|
||||
"mip_coronal": "/3d-views/head/mip_coronal.png",
|
||||
"mpr_axial": "/3d-views/head/mpr_axial.png",
|
||||
"mpr_sagittal": "/3d-views/head/mpr_sagittal.png",
|
||||
"mpr_coronal": "/3d-views/head/mpr_coronal.png",
|
||||
"window_bone": "/3d-views/head/window_bone.png",
|
||||
"window_soft": "/3d-views/head/window_soft.png",
|
||||
"window_lung": "/3d-views/head/window_lung.png"
|
||||
},
|
||||
"abdomen": {
|
||||
"vr": "/3d-views/abdomen/vr.png",
|
||||
"mip_axial": "/3d-views/abdomen/mip_axial.png",
|
||||
"mip_sagittal": "/3d-views/abdomen/mip_sagittal.png",
|
||||
"mip_coronal": "/3d-views/abdomen/mip_coronal.png",
|
||||
"mpr_axial": "/3d-views/abdomen/mpr_axial.png",
|
||||
"mpr_sagittal": "/3d-views/abdomen/mpr_sagittal.png",
|
||||
"mpr_coronal": "/3d-views/abdomen/mpr_coronal.png",
|
||||
"window_bone": "/3d-views/abdomen/window_bone.png",
|
||||
"window_soft": "/3d-views/abdomen/window_soft.png",
|
||||
"window_lung": "/3d-views/abdomen/window_lung.png"
|
||||
},
|
||||
"knee": {
|
||||
"vr": "/3d-views/knee/vr.png",
|
||||
"mip_axial": "/3d-views/knee/mip_axial.png",
|
||||
"mip_sagittal": "/3d-views/knee/mip_sagittal.png",
|
||||
"mip_coronal": "/3d-views/knee/mip_coronal.png",
|
||||
"mpr_axial": "/3d-views/knee/mpr_axial.png",
|
||||
"mpr_sagittal": "/3d-views/knee/mpr_sagittal.png",
|
||||
"mpr_coronal": "/3d-views/knee/mpr_coronal.png",
|
||||
"window_bone": "/3d-views/knee/window_bone.png",
|
||||
"window_soft": "/3d-views/knee/window_soft.png",
|
||||
"window_lung": "/3d-views/knee/window_lung.png"
|
||||
}
|
||||
}
|
||||
@@ -3,68 +3,58 @@
|
||||
<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-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>
|
||||
<el-button size="small" @click="resetCam">重置视角</el-button>
|
||||
<span style="margin-left:auto;font-size:11px;color:#888">🖱️ 左键旋转 | 右键平移 | 滚轮缩放</span>
|
||||
</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 class="vmain" ref="containerRef">
|
||||
<canvas ref="canvasRef" class="gl-canvas"/>
|
||||
<!-- MPR overlay -->
|
||||
<div v-if="mode==='MPR'" class="mpr-overlay">
|
||||
<div class="mpr-label">轴位 Axial Z:{{ mprZ }}</div>
|
||||
<div class="mpr-label">矢状 Sagittal X:{{ mprX }}</div>
|
||||
<div class="mpr-label">冠状 Coronal Y:{{ mprY }}</div>
|
||||
</div>
|
||||
<div class="ov ov-tl">
|
||||
<!-- Info overlay -->
|
||||
<div class="ov-tl">
|
||||
<div>{{ taskData.patientName || '患者' }} | {{ taskData.modality||'CT' }} {{ taskData.bodyPart||'胸部' }}</div>
|
||||
<div>{{ mode }} {{ presetLabels[preset] }} | WL:{{ wl }} WW:{{ ww }}</div>
|
||||
<div>{{ mode }} | {{ presetLabels[preset] }} | 64³ Volume</div>
|
||||
</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 class="ov-bl">
|
||||
<div>拖拽旋转 | 滚轮缩放 | 右键平移</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {ref,onMounted,onUnmounted,nextTick,watch} from 'vue'
|
||||
import {ref,onMounted,onUnmounted,watch,nextTick} from 'vue'
|
||||
import * as THREE from 'three'
|
||||
import {OrbitControls} from 'three/examples/jsm/controls/OrbitControls.js'
|
||||
|
||||
const props=defineProps({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 containerRef=ref(null)
|
||||
const canvasRef=ref(null)
|
||||
const mode=ref('VR')
|
||||
const preset=ref('bone')
|
||||
const presetNames=['bone','soft','lung','angio','skin']
|
||||
const presetLabels={bone:'骨骼',soft:'软组织',lung:'肺部',angio:'血管',skin:'皮肤'}
|
||||
const mprZ=ref(32),mprX=ref(32),mprY=ref(32)
|
||||
|
||||
let scene,camera,renderer,controls,volMesh,animId
|
||||
const SZ=64
|
||||
let vol=null,dragging=false,lm={x:0,y:0},raf=null,ready=false
|
||||
|
||||
// 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}
|
||||
|
||||
function genVol(){
|
||||
vol=new Int16Array(SZ*SZ*SZ)
|
||||
// ========== Volume Data Generation ==========
|
||||
function genVolume(){
|
||||
const data=new Float32Array(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)
|
||||
@@ -73,178 +63,285 @@ function genVol(){
|
||||
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}}
|
||||
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}}
|
||||
}
|
||||
}
|
||||
}
|
||||
vol[z*SZ*SZ+y*SZ+x]=hu
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)))
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
// Normalize HU to 0-1 for texture
|
||||
data[z*SZ*SZ+y*SZ+x]=(hu+1000)/2500
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// ========== Transfer Function Textures ==========
|
||||
const TF_PRESETS={
|
||||
bone:{stops:[[0,0,0,0,0],[.2,.12,.12,.2,.35],[.4,.32,.24,.24,.6],[.6,.71,.67,.63,.9],[.8,1,1,1,1]]},
|
||||
soft:{stops:[[0,0,0,0,0],[.15,.16,.16,.24,.3],[.32,.47,.31,.31,.7],[.48,.78,.47,.39,.95],[.68,1,1,1,1]]},
|
||||
lung:{stops:[[0,0,0,0,0],[.08,.08,.12,.2,.4],[.24,.24,.32,.4,.6],[.4,.59,.39,.31,.8],[.6,1,1,1,1]]},
|
||||
angio:{stops:[[0,0,0,0,0],[.12,.12,.08,.08,.15],[.28,.78,.2,.12,.85],[.45,1,.39,.2,.95],[.65,1,.78,.39,1]]},
|
||||
skin:{stops:[[0,0,0,0,0],[.12,.2,.16,.14,.2],[.24,.71,.51,.43,.85],[.4,.86,.66,.55,.98],[.6,1,1,1,1]]}
|
||||
}
|
||||
|
||||
function makeTFTexture(presetName){
|
||||
const p=TF_PRESETS[presetName]||TF_PRESETS.bone
|
||||
const size=256
|
||||
const data=new Uint8Array(size*4)
|
||||
for(let i=0;i<size;i++){
|
||||
const t=i/255
|
||||
let r=0,g=0,b=0,a=0
|
||||
const stops=p.stops
|
||||
for(let j=0;j<stops.length-1;j++){
|
||||
if(t>=stops[j][0]&&t<=stops[j+1][0]){
|
||||
const f=(t-stops[j][0])/(stops[j+1][0]-stops[j][0])
|
||||
r=Math.floor((stops[j][1]+(stops[j+1][1]-stops[j][1])*f)*255)
|
||||
g=Math.floor((stops[j][2]+(stops[j+1][2]-stops[j][2])*f)*255)
|
||||
b=Math.floor((stops[j][3]+(stops[j+1][3]-stops[j][3])*f)*255)
|
||||
a=Math.floor((stops[j][4]+(stops[j+1][4]-stops[j][4])*f)*255)
|
||||
break
|
||||
}
|
||||
}
|
||||
data[i*4]=r;data[i*4+1]=g;data[i*4+2]=b;data[i*4+3]=a
|
||||
}
|
||||
ctx.putImageData(id,0,0)
|
||||
const tex=new THREE.DataTexture(data,size,1,THREE.RGBAFormat)
|
||||
tex.needsUpdate=true
|
||||
tex.minFilter=THREE.LinearFilter
|
||||
tex.magFilter=THREE.LinearFilter
|
||||
return tex
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
// ========== VR Shader ==========
|
||||
const vrVertexShader=`varying vec3 vPosition;void main(){vPosition=position;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`
|
||||
|
||||
const vrFragmentShader=`
|
||||
uniform sampler3D uVolume;
|
||||
uniform sampler2D uTransferFunc;
|
||||
uniform vec3 uCamPos;
|
||||
uniform float uStepSize;
|
||||
varying vec3 vPosition;
|
||||
|
||||
void main(){
|
||||
vec3 start=vPosition+0.5;
|
||||
vec3 dir=normalize(vPosition-uCamPos);
|
||||
vec3 end=vec3(0.0);
|
||||
if(dir.x>0.0)end.x=(0.5-start.x)/dir.x;
|
||||
else if(dir.x<0.0)end.x=(-0.5-start.x)/dir.x;
|
||||
if(dir.y>0.0)end.y=(0.5-start.y)/dir.y;
|
||||
else if(dir.y<0.0)end.y=(-0.5-start.y)/dir.y;
|
||||
if(dir.z>0.0)end.z=(0.5-start.z)/dir.z;
|
||||
else if(dir.z<0.0)end.z=(-0.5-start.z)/dir.z;
|
||||
float tFar=min(min(end.x,end.y),end.z);
|
||||
if(tFar<0.0)discard;
|
||||
|
||||
vec4 acc=vec4(0.0);
|
||||
float t=0.0;
|
||||
for(int i=0;i<128;i++){
|
||||
if(t>tFar||acc.a>0.95)break;
|
||||
vec3 pos=start+dir*t;
|
||||
if(pos.x<0.0||pos.x>1.0||pos.y<0.0||pos.y>1.0||pos.z<0.0||pos.z>1.0){t+=uStepSize;continue;}
|
||||
float val=texture(uVolume,pos).r;
|
||||
vec4 sampleColor=texture(uTransferFunc,vec2(val,0.5));
|
||||
float da=sampleColor.a*uStepSize*2.0;
|
||||
acc.rgb+=sampleColor.rgb*da*(1.0-acc.a);
|
||||
acc.a+=da*(1.0-acc.a);
|
||||
t+=uStepSize;
|
||||
}
|
||||
if(acc.a<0.01)discard;
|
||||
gl_FragColor=vec4(acc.rgb,acc.a);
|
||||
}
|
||||
`
|
||||
|
||||
// ========== MIP Shader ==========
|
||||
const mipFragmentShader=`
|
||||
uniform sampler3D uVolume;
|
||||
uniform vec3 uCamPos;
|
||||
uniform float uStepSize;
|
||||
varying vec3 vPosition;
|
||||
|
||||
void main(){
|
||||
vec3 start=vPosition+0.5;
|
||||
vec3 dir=normalize(vPosition-uCamPos);
|
||||
vec3 end=vec3(0.0);
|
||||
if(dir.x>0.0)end.x=(0.5-start.x)/dir.x;else if(dir.x<0.0)end.x=(-0.5-start.x)/dir.x;
|
||||
if(dir.y>0.0)end.y=(0.5-start.y)/dir.y;else if(dir.y<0.0)end.y=(-0.5-start.y)/dir.y;
|
||||
if(dir.z>0.0)end.z=(0.5-start.z)/dir.z;else if(dir.z<0.0)end.z=(-0.5-start.z)/dir.z;
|
||||
float tFar=min(min(end.x,end.y),end.z);
|
||||
if(tFar<0.0)discard;
|
||||
|
||||
float maxVal=0.0;
|
||||
float t=0.0;
|
||||
for(int i=0;i<128;i++){
|
||||
if(t>tFar)break;
|
||||
vec3 pos=start+dir*t;
|
||||
if(pos.x>=0.0&&pos.x<=1.0&&pos.y>=0.0&&pos.y<=1.0&&pos.z>=0.0&&pos.z<=1.0){
|
||||
float val=texture(uVolume,pos).r;
|
||||
maxVal=max(maxVal,val);
|
||||
}
|
||||
t+=uStepSize;
|
||||
}
|
||||
ctx.putImageData(id,0,0)
|
||||
if(maxVal<0.01)discard;
|
||||
float v=maxVal*2500.0-1000.0;
|
||||
float intensity=clamp((v+1000.0)/2000.0,0.0,1.0);
|
||||
gl_FragColor=vec4(vec3(intensity),1.0);
|
||||
}
|
||||
`
|
||||
|
||||
// ========== Setup Three.js ==========
|
||||
function init(){
|
||||
const container=containerRef.value
|
||||
const canvas=canvasRef.value
|
||||
if(!container||!canvas)return
|
||||
|
||||
const w=container.clientWidth
|
||||
const h=container.clientHeight
|
||||
|
||||
// Scene
|
||||
scene=new THREE.Scene()
|
||||
scene.background=new THREE.Color(0x0a0a1a)
|
||||
|
||||
// Camera
|
||||
camera=new THREE.PerspectiveCamera(45,w/h,0.1,100)
|
||||
camera.position.set(2,1.5,2)
|
||||
|
||||
// Renderer
|
||||
renderer=new THREE.WebGLRenderer({canvas,antialias:true})
|
||||
renderer.setSize(w,h)
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio,2))
|
||||
|
||||
// Controls
|
||||
controls=new OrbitControls(camera,canvas)
|
||||
controls.enableDamping=true
|
||||
controls.dampingFactor=0.05
|
||||
controls.rotateSpeed=0.8
|
||||
controls.zoomSpeed=1.2
|
||||
|
||||
// Generate volume
|
||||
const volData=genVolume()
|
||||
const volTex=new THREE.DataTexture(volData,SZ,SZ,SZ,THREE.RedFormat,THREE.FloatType)
|
||||
volTex.needsUpdate=true
|
||||
volTex.minFilter=THREE.LinearFilter
|
||||
volTex.magFilter=THREE.LinearFilter
|
||||
|
||||
// Box geometry for volume
|
||||
const geo=new THREE.BoxGeometry(1,1,1)
|
||||
|
||||
// VR material
|
||||
const tfTex=makeTFTexture('bone')
|
||||
const mat=new THREE.ShaderMaterial({
|
||||
vertexShader:vrVertexShader,
|
||||
fragmentShader:vrFragmentShader,
|
||||
uniforms:{
|
||||
uVolume:{value:volTex},
|
||||
uTransferFunc:{value:tfTex},
|
||||
uCamPos:{value:new THREE.Vector3(2,1.5,2)},
|
||||
uStepSize:{value:0.008}
|
||||
},
|
||||
transparent:true,
|
||||
side:THREE.DoubleSide,
|
||||
depthWrite:false
|
||||
})
|
||||
|
||||
volMesh=new THREE.Mesh(geo,mat)
|
||||
scene.add(volMesh)
|
||||
|
||||
// Lighting
|
||||
const ambient=new THREE.AmbientLight(0xffffff,0.3)
|
||||
scene.add(ambient)
|
||||
const dir=new THREE.DirectionalLight(0xffffff,0.7)
|
||||
dir.position.set(1,1,1)
|
||||
scene.add(dir)
|
||||
|
||||
// Store references for updates
|
||||
window.__volMat=mat
|
||||
window.__volTex=volTex
|
||||
window.__tfTex=tfTex
|
||||
|
||||
animate()
|
||||
}
|
||||
|
||||
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{
|
||||
if(c3d.value)renderVR(c3d.value,mode.value==='MIP')
|
||||
function animate(){
|
||||
animId=requestAnimationFrame(animate)
|
||||
if(!renderer||!scene||!camera)return
|
||||
controls.update()
|
||||
|
||||
// Update camera position in shader
|
||||
if(window.__volMat){
|
||||
window.__volMat.uniforms.uCamPos.value.copy(camera.position)
|
||||
}
|
||||
raf=requestAnimationFrame(frame)
|
||||
|
||||
renderer.render(scene,camera)
|
||||
}
|
||||
|
||||
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 resetCam(){
|
||||
if(camera&&controls){
|
||||
camera.position.set(2,1.5,2)
|
||||
controls.target.set(0,0,0)
|
||||
controls.update()
|
||||
}
|
||||
}
|
||||
|
||||
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 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)
|
||||
if(window.__volMat){
|
||||
window.__volMat.uniforms.uTransferFunc.value=makeTFTexture(p)
|
||||
}
|
||||
}
|
||||
|
||||
// Watch mode changes to resize canvases
|
||||
watch(mode,()=>{nextTick(()=>{resizeAll()})})
|
||||
function onResize(){
|
||||
const container=containerRef.value
|
||||
if(!container||!renderer||!camera)return
|
||||
const w=container.clientWidth
|
||||
const h=container.clientHeight
|
||||
camera.aspect=w/h
|
||||
camera.updateProjectionMatrix()
|
||||
renderer.setSize(w,h)
|
||||
}
|
||||
|
||||
watch(mode,(val)=>{
|
||||
if(!window.__volMat)return
|
||||
if(val==='MIP'){
|
||||
window.__volMat.fragmentShader=mipFragmentShader
|
||||
}else{
|
||||
window.__volMat.fragmentShader=vrFragmentShader
|
||||
}
|
||||
window.__volMat.needsUpdate=true
|
||||
})
|
||||
|
||||
onMounted(()=>{
|
||||
genVol()
|
||||
// Wait for DOM to be fully rendered, then start
|
||||
nextTick(()=>{
|
||||
setTimeout(()=>{
|
||||
resizeAll()
|
||||
ready=true
|
||||
frame()
|
||||
},100)
|
||||
setTimeout(init,100)
|
||||
})
|
||||
window.addEventListener('resize',resizeAll)
|
||||
window.addEventListener('resize',onResize)
|
||||
})
|
||||
|
||||
onUnmounted(()=>{
|
||||
if(raf)cancelAnimationFrame(raf)
|
||||
window.removeEventListener('resize',resizeAll)
|
||||
ready=false
|
||||
if(animId)cancelAnimationFrame(animId)
|
||||
window.removeEventListener('resize',onResize)
|
||||
if(renderer)renderer.dispose()
|
||||
if(controls)controls.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.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}
|
||||
.vmain{flex:1;position:relative;overflow:hidden;background:#0a0a1a}
|
||||
.gl-canvas{width:100%;height:100%;display:block}
|
||||
.ov-tl{position:absolute;top:8px;left:8px;padding:6px 10px;background:rgba(0,0,0,.7);border-radius:4px;font-size:11px;font-family:'Courier New',monospace;color:#0f0;line-height:1.5;pointer-events:none;z-index:10}
|
||||
.ov-bl{position:absolute;bottom:8px;left:8px;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}
|
||||
.mpr-overlay{position:absolute;top:8px;right:8px;z-index:10;pointer-events:none}
|
||||
.mpr-label{font-size:11px;color:#0f0;font-family:monospace;background:rgba(0,0,0,.6);padding:2px 8px;margin-bottom:4px;border-radius:3px}
|
||||
</style>
|
||||
|
||||