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

This commit is contained in:
wangjian963
2026-06-08 11:20:28 +08:00
43 changed files with 325 additions and 177 deletions

View File

@@ -59,6 +59,7 @@
"qrcodejs2": "^0.0.2", "qrcodejs2": "^0.0.2",
"segmentit": "^2.0.3", "segmentit": "^2.0.3",
"sortablejs": "^1.15.7", "sortablejs": "^1.15.7",
"three": "^0.184.0",
"v-region": "^3.3.0", "v-region": "^3.3.0",
"vue": "^3.5.25", "vue": "^3.5.25",
"vue-area-linkage": "^5.1.0", "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"> <div class="vbar">
<el-radio-group v-model="mode" size="small"> <el-radio-group v-model="mode" size="small">
<el-radio-button value="VR">VR容积</el-radio-button> <el-radio-button value="VR">VR容积</el-radio-button>
<el-radio-button value="MPR">MPR多平</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="MIP">MIP投影</el-radio-button>
</el-radio-group> </el-radio-group>
<el-divider direction="vertical"/> <el-divider direction="vertical"/>
<el-button-group size="small"> <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 v-for="p in presetNames" :key="p" :type="preset===p?'primary':''" @click="setPreset(p)">{{ presetLabels[p] }}</el-button>
</el-button-group> </el-button-group>
<el-divider direction="vertical"/> <el-divider direction="vertical"/>
<el-button size="small" @click="resetView">重置</el-button> <el-button size="small" @click="resetCam">重置视角</el-button>
<span style="margin-left:auto;font-size:11px;color:#888">左键旋转 | 滚轮缩放</span> <span style="margin-left:auto;font-size:11px;color:#888">🖱 左键旋转 | 右键平移 | 滚轮缩放</span>
</div> </div>
<div class="vmain" ref="mainRef"> <div class="vmain" ref="containerRef">
<canvas v-show="mode!=='MPR'" ref="c3d" class="c3d" <canvas ref="canvasRef" class="gl-canvas"/>
@mousedown="md" @mousemove="mm" @mouseup="mu" @mouseleave="mu" <!-- MPR overlay -->
@wheel.prevent="onWheel" @contextmenu.prevent/> <div v-if="mode==='MPR'" class="mpr-overlay">
<div v-if="mode==='MPR'" class="mpr-grid"> <div class="mpr-label">轴位 Axial Z:{{ mprZ }}</div>
<div class="mpr-cell"><div class="mpr-h">轴位 Axial</div><canvas ref="cA" class="mpr-c"/></div> <div class="mpr-label">矢状 Sagittal X:{{ mprX }}</div>
<div class="mpr-cell"><div class="mpr-h">矢状 Sagittal</div><canvas ref="cS" class="mpr-c"/></div> <div class="mpr-label">冠状 Coronal Y:{{ mprY }}</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>
<div class="ov ov-tl"> <!-- Info overlay -->
<div class="ov-tl">
<div>{{ taskData.patientName || '患者' }} | {{ taskData.modality||'CT' }} {{ taskData.bodyPart||'胸部' }}</div> <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>
<div class="ov ov-bl"> <div class="ov-bl">
<div v-if="mode==='MPR'">Z:{{ zs }} Y:{{ ys }} X:{{ xs }}</div> <div>拖拽旋转 | 滚轮缩放 | 右键平移</div>
<div v-else>旋转:{{ (rx*57.3).toFixed(0) }}° 缩放:{{ zm.toFixed(1) }}x</div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <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 props=defineProps({taskData:{type:Object,default:()=>({})}})
const mode=ref('VR'),preset=ref('bone') const containerRef=ref(null)
const mainRef=ref(null),c3d=ref(null),c3d2=ref(null) const canvasRef=ref(null)
const cA=ref(null),cS=ref(null),cC=ref(null) const mode=ref('VR')
const wl=ref(40),ww=ref(400) const preset=ref('bone')
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 presetNames=['bone','soft','lung','angio','skin'] const presetNames=['bone','soft','lung','angio','skin']
const presetLabels={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 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] // ========== Volume Data Generation ==========
const TF={ function genVolume(){
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]], const data=new Float32Array(SZ*SZ*SZ)
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)
for(let z=0;z<SZ;z++)for(let y=0;y<SZ;y++)for(let x=0;x<SZ;x++){ 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 dx=x-32,dy=y-32,dz=z-32
const bd=Math.sqrt((dx/28)**2+(dy/24)**2+(dz/30)**2) 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 if(bd>.88)hu=-100+(Math.random()-.5)*30
else if(bd>.78)hu=45+(Math.random()-.5)*16 else if(bd>.78)hu=45+(Math.random()-.5)*16
else{ 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){ if(hu===-1000){
const hd=Math.sqrt((dx/6)**2+((dy+3)/5)**2+((dz+3)/7)**2) const hd=Math.sqrt((dx/6)**2+((dy+3)/5)**2+((dz+3)/7)**2)
if(hd<1)hu=45+(Math.random()-.5)*10 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}} else{
} const sd=Math.sqrt(dx*dx+(dy+15)**2+dz*dz)
} if(sd<4)hu=350+(Math.random()-.5)*40
} else{
vol[z*SZ*SZ+y*SZ+x]=hu 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
} }
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
} }
} }
} }
const v=Math.min(255,Math.max(0,ra*255)) }
const g=Math.min(255,Math.max(0,ga*255)) // Normalize HU to 0-1 for texture
const b=Math.min(255,Math.max(0,ba*255)) data[z*SZ*SZ+y*SZ+x]=(hu+1000)/2500
// Fill step x step block }
for(let dy2=0;dy2<step&&py+dy2<H;dy2++){ return data
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 // ========== 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){ // ========== VR Shader ==========
if(!cv||!vol||cv.width<1)return const vrVertexShader=`varying vec3 vPosition;void main(){vPosition=position;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`
const ctx=cv.getContext('2d')
const W=cv.width,H=cv.height const vrFragmentShader=`
const id=ctx.createImageData(W,H) uniform sampler3D uVolume;
const d=id.data uniform sampler2D uTransferFunc;
const lo=wl.value-ww.value/2,hi=wl.value+ww.value/2 uniform vec3 uCamPos;
const step=Math.max(1,Math.floor(2)) uniform float uStepSize;
for(let py=0;py<H;py+=step){ varying vec3 vPosition;
for(let px=0;px<W;px+=step){
const nx=(px/W)*SZ,ny=(py/H)*SZ void main(){
let hu vec3 start=vPosition+0.5;
const zi=Math.min(SZ-1,Math.max(0,Math.round(idx))) vec3 dir=normalize(vPosition-uCamPos);
const xi=Math.min(SZ-1,Math.max(0,Math.round(nx))) vec3 end=vec3(0.0);
const yi=Math.min(SZ-1,Math.max(0,Math.round(ny))) if(dir.x>0.0)end.x=(0.5-start.x)/dir.x;
if(axis==='z')hu=vol[zi*SZ*SZ+yi*SZ+xi] else if(dir.x<0.0)end.x=(-0.5-start.x)/dir.x;
else if(axis==='x')hu=vol[yi*SZ*SZ+xi*SZ+zi] if(dir.y>0.0)end.y=(0.5-start.y)/dir.y;
else hu=vol[xi*SZ*SZ+yi*SZ+zi] else if(dir.y<0.0)end.y=(-0.5-start.y)/dir.y;
const v=hu<=lo?0:hu>=hi?255:255*(hu-lo)/(hi-lo) if(dir.z>0.0)end.z=(0.5-start.z)/dir.z;
for(let dy2=0;dy2<step&&py+dy2<H;dy2++){ else if(dir.z<0.0)end.z=(-0.5-start.z)/dir.z;
for(let dx2=0;dx2<step&&px+dx2<W;dx2++){ float tFar=min(min(end.x,end.y),end.z);
const i=((py+dy2)*W+(px+dx2))*4 if(tFar<0.0)discard;
d[i]=v;d[i+1]=v*.95;d[i+2]=v*.9;d[i+3]=255
} 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(){ function animate(){
if(!ready){raf=requestAnimationFrame(frame);return} animId=requestAnimationFrame(animate)
if(mode.value==='MPR'){ if(!renderer||!scene||!camera)return
nextTick(()=>{ controls.update()
if(cA.value)renderSlice(cA.value,'z',zs.value)
if(cS.value)renderSlice(cS.value,'x',xs.value) // Update camera position in shader
if(cC.value)renderSlice(cC.value,'y',ys.value) if(window.__volMat){
if(c3d2.value)renderVR(c3d2.value,false) window.__volMat.uniforms.uCamPos.value.copy(camera.position)
})
}else{
if(c3d.value)renderVR(c3d.value,mode.value==='MIP')
} }
raf=requestAnimationFrame(frame)
renderer.render(scene,camera)
} }
function md(e){dragging=true;lm={x:e.clientX,y:e.clientY}} function resetCam(){
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}} if(camera&&controls){
function mu(){dragging=false} camera.position.set(2,1.5,2)
function onWheel(e){zm.value*=e.deltaY>0?.9:1.1;zm.value=Math.max(.3,Math.min(5,zm.value))} controls.target.set(0,0,0)
controls.update()
}
}
function setPreset(p){ function setPreset(p){
preset.value=p preset.value=p
const m={bone:[400,2500],soft:[40,400],lung:[-600,1500],angio:[300,600],skin:[50,250]} if(window.__volMat){
wl.value=m[p][0];ww.value=m[p][1] window.__volMat.uniforms.uTransferFunc.value=makeTFTexture(p)
}
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)
} }
} }
// Watch mode changes to resize canvases function onResize(){
watch(mode,()=>{nextTick(()=>{resizeAll()})}) 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(()=>{ onMounted(()=>{
genVol()
// Wait for DOM to be fully rendered, then start
nextTick(()=>{ nextTick(()=>{
setTimeout(()=>{ setTimeout(init,100)
resizeAll()
ready=true
frame()
},100)
}) })
window.addEventListener('resize',resizeAll) window.addEventListener('resize',onResize)
}) })
onUnmounted(()=>{ onUnmounted(()=>{
if(raf)cancelAnimationFrame(raf) if(animId)cancelAnimationFrame(animId)
window.removeEventListener('resize',resizeAll) window.removeEventListener('resize',onResize)
ready=false if(renderer)renderer.dispose()
if(controls)controls.dispose()
}) })
</script> </script>
<style scoped> <style scoped>
.viewer-wrap{display:flex;flex-direction:column;height:100%;background:#0a0a1a;color:#fff} .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} .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} .vmain{flex:1;position:relative;overflow:hidden;background:#0a0a1a}
.c3d{width:100%;height:100%;display:block;cursor:crosshair} .gl-canvas{width:100%;height:100%;display:block}
.mpr-grid{position:absolute;inset:0;display:grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;gap:2px} .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}
.mpr-cell{position:relative;background:#000;overflow:hidden} .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-c{width:100%;height:100%;display:block} .mpr-overlay{position:absolute;top:8px;right:8px;z-index:10;pointer-events:none}
.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} .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}
.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> </style>