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风格文字叠加
This commit is contained in:
2026-06-08 11:04:46 +08:00
parent bfae31448c
commit 7f2f612e58
43 changed files with 325 additions and 177 deletions

View File

@@ -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",

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View 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"
}
}

View File

@@ -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>