Update DICOM UPP fusion workspace view

This commit is contained in:
Codex
2026-05-30 10:37:44 +08:00
parent 86e25d0a51
commit bdd0db2a4c
3 changed files with 422 additions and 170 deletions

View File

@@ -8,8 +8,7 @@ const BODY_LABELS = {
head_neck: "头颈部", head_neck: "头颈部",
chest: "胸部", chest: "胸部",
upper_abdomen: "上腹部", upper_abdomen: "上腹部",
lower_abdomen: "腹部", abdomen_pelvis: "腹部",
pelvis: "盆腔",
}; };
const DEFAULT_POSE = { const DEFAULT_POSE = {
@@ -40,6 +39,13 @@ const POSE_CONTROLS = [
const STL_COLORS = [0x18d8c9, 0x3f82ff, 0xf5b84b, 0xf87171, 0x9bd76f, 0xd1a6ff, 0x66e0ff, 0xff9f7a]; const STL_COLORS = [0x18d8c9, 0x3f82ff, 0xf5b84b, 0xf87171, 0x9bd76f, 0xd1a6ff, 0x66e0ff, 0xff9f7a];
const CASE_PAGE_SIZE = 20; const CASE_PAGE_SIZE = 20;
const FUSION_BASE_EXTENT = 4.6;
const AXIS_INSET_LENGTH = 17;
const DEFAULT_AXIS_PROJECTION = {
x: { dx: AXIS_INSET_LENGTH, dy: 0, opacity: 0.95 },
y: { dx: -10, dy: 10, opacity: 0.82 },
z: { dx: 0, dy: -AXIS_INSET_LENGTH, opacity: 0.95 },
};
const POSE_SIGNATURE_KEYS = [ const POSE_SIGNATURE_KEYS = [
"rotateX", "rotateX",
"rotateY", "rotateY",
@@ -84,8 +90,8 @@ const state = {
pose: { ...DEFAULT_POSE }, pose: { ...DEFAULT_POSE },
windowMode: "default", windowMode: "default",
fusionMode: "fusion", fusionMode: "fusion",
fusionDetail: "low", fusionDetail: "high",
modelDetail: "standard", modelDetail: "ultra",
mappingMode: "result", mappingMode: "result",
sliceIndex: 0, sliceIndex: 0,
sliceRangeStart: 0, sliceRangeStart: 0,
@@ -127,6 +133,7 @@ const state = {
mappingVersion: 0, mappingVersion: 0,
resizeObserver: null, resizeObserver: null,
resizeScene: null, resizeScene: null,
axisProjectionSignature: "",
nudgeTimer: null, nudgeTimer: null,
nudgeDelayTimer: null, nudgeDelayTimer: null,
}; };
@@ -147,6 +154,29 @@ function clamp(value, min, max) {
return Math.max(min, Math.min(max, value)); return Math.max(min, Math.min(max, value));
} }
function getDicomDisplaySliceNumber(sliceIndex, totalSlices) {
const total = Math.max(Math.round(Number(totalSlices) || 0), 0);
if (!total) return 0;
return total - clamp(Math.round(Number(sliceIndex) || 0), 0, total - 1);
}
function getDicomDisplayRange(startIndex, endIndex, totalSlices) {
const first = getDicomDisplaySliceNumber(startIndex, totalSlices);
const second = getDicomDisplaySliceNumber(endIndex, totalSlices);
return {
start: Math.min(first, second),
end: Math.max(first, second),
};
}
function setFusionWebglError(message = "") {
const overlay = $("fusionWebglError");
if (!overlay) return;
overlay.classList.toggle("hidden", !message);
const detail = overlay.querySelector("p");
if (detail && message) detail.textContent = message;
}
function setTopLoading(visible) { function setTopLoading(visible) {
$("topLoading").classList.toggle("hidden", !visible); $("topLoading").classList.toggle("hidden", !visible);
} }
@@ -277,9 +307,11 @@ async function bootstrap() {
loginVisible(false); loginVisible(false);
try { try {
initScene(); initScene();
setFusionWebglError("");
} catch (error) { } catch (error) {
initGeometryFallback(); initGeometryFallback();
$("fusionStatus").textContent = `WebGL 初始化失败:${error.message}`; $("fusionStatus").textContent = `WebGL 初始化失败:${error.message}`;
setFusionWebglError("三维融合视图暂不可用,请检查浏览器硬件加速、显卡驱动或远程桌面图形支持。二维 DICOM 与映射功能仍可继续使用。");
console.warn(error); console.warn(error);
} }
buildPoseControls(); buildPoseControls();
@@ -601,8 +633,8 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") {
const savedTransform = { ...(registration.transform || {}), scaleX: 1, scaleY: 1, scaleZ: 1 }; const savedTransform = { ...(registration.transform || {}), scaleX: 1, scaleY: 1, scaleZ: 1 };
const keepPreviousPose = switchingModelOnly; const keepPreviousPose = switchingModelOnly;
state.pose = keepPreviousPose ? { ...DEFAULT_POSE, ...previousPose } : { ...DEFAULT_POSE, ...savedTransform }; state.pose = keepPreviousPose ? { ...DEFAULT_POSE, ...previousPose } : { ...DEFAULT_POSE, ...savedTransform };
state.fusionDetail = state.fusionDetail || "low"; state.fusionDetail = registration.model_reference?.fusion_detail || state.fusionDetail || "high";
state.modelDetail = registration.model_reference?.model_detail || state.modelDetail || "standard"; state.modelDetail = registration.model_reference?.model_detail || state.modelDetail || "ultra";
state.autoResult = null; state.autoResult = null;
state.lastAutoPose = null; state.lastAutoPose = null;
state.sliceIndex = 0; state.sliceIndex = 0;
@@ -1006,6 +1038,28 @@ function renderPoseControls() {
button.classList.toggle("active", Boolean(state.pose[button.dataset.flip])); button.classList.toggle("active", Boolean(state.pose[button.dataset.flip]));
}); });
renderActiveHeader(); renderActiveHeader();
updateStretchButtons();
}
function isRightAngleRotation(value) {
const normalized = ((Number(value) || 0) % 90 + 90) % 90;
return normalized < 0.001 || Math.abs(normalized - 90) < 0.001;
}
function canStretchByAxis() {
return ["rotateX", "rotateY", "rotateZ"].every((key) => isRightAngleRotation(state.pose[key]));
}
function updateStretchButtons() {
const enabled = canStretchByAxis();
["x", "y", "z"].forEach((axis) => {
const button = $(`stretch${axis.toUpperCase()}Btn`);
if (!button) return;
button.disabled = !enabled;
button.title = enabled
? `${axis.toUpperCase()} 方向自动等比例拉伸模型`
: "仅当旋转 X/Y/Z 均为 0 或 90° 的倍数时可用";
});
} }
function updateSliceControl() { function updateSliceControl() {
@@ -1070,14 +1124,17 @@ function updateDicomPreview() {
function initScene() { function initScene() {
if (state.sceneReady) return; if (state.sceneReady) return;
const viewport = $("fusionViewport"); const viewport = $("fusionViewport");
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }); const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setClearColor(0x000000, 1); renderer.setClearColor(0x030712, 1);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.localClippingEnabled = true;
viewport.appendChild(renderer.domElement); viewport.appendChild(renderer.domElement);
const scene = new THREE.Scene(); const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(38, 1, 0.01, 1000); scene.background = new THREE.Color(0x030712);
camera.position.set(0, -8.5, 4.4);
const camera = new THREE.PerspectiveCamera(45, 1, 0.05, 1000);
camera.position.set(0, -6.2, 4.6);
camera.up.set(0, 0, 1); camera.up.set(0, 0, 1);
camera.lookAt(0, 0, 0); camera.lookAt(0, 0, 0);
@@ -1085,21 +1142,22 @@ function initScene() {
controls.enabled = false; controls.enabled = false;
controls.target.set(0, 0, 0); controls.target.set(0, 0, 0);
const ambient = new THREE.AmbientLight(0xffffff, 0.82); const ambient = new THREE.AmbientLight(0xffffff, 0.72);
const directional = new THREE.DirectionalLight(0xffffff, 1.8); const keyLight = new THREE.DirectionalLight(0xffffff, 1.1);
directional.position.set(3.5, -4, 6); keyLight.position.set(4, -5, 5);
scene.add(ambient, directional); const fillLight = new THREE.DirectionalLight(0x8fb8ff, 0.55);
fillLight.position.set(-4, 3, 2);
scene.add(ambient, keyLight, fillLight);
const dicomGroup = new THREE.Group(); const dicomGroup = new THREE.Group();
const modelGroup = new THREE.Group(); const modelGroup = new THREE.Group();
const rawModelGroup = new THREE.Group(); const rawModelGroup = new THREE.Group();
const axisGroup = createSceneAxes();
const fusionRoot = new THREE.Group(); const fusionRoot = new THREE.Group();
modelGroup.add(rawModelGroup); modelGroup.add(rawModelGroup);
fusionRoot.add(dicomGroup, modelGroup, axisGroup); fusionRoot.add(dicomGroup, modelGroup);
scene.add(fusionRoot); scene.add(fusionRoot);
Object.assign(state, { renderer, camera, scene, controls, fusionRoot, dicomGroup, modelGroup, rawModelGroup, axisGroup, sceneReady: true }); Object.assign(state, { renderer, camera, scene, controls, fusionRoot, dicomGroup, modelGroup, rawModelGroup, axisGroup: null, sceneReady: true });
const resize = () => { const resize = () => {
const rect = viewport.getBoundingClientRect(); const rect = viewport.getBoundingClientRect();
@@ -1116,10 +1174,81 @@ function initScene() {
const animate = () => { const animate = () => {
requestAnimationFrame(animate); requestAnimationFrame(animate);
applySceneViewPose(); applySceneViewPose();
updateFusionAxisInset();
renderer.render(scene, camera); renderer.render(scene, camera);
}; };
animate(); animate();
wireFusionSceneDrag(renderer.domElement); wireFusionSceneDrag(renderer.domElement);
updateFusionAxisInset(DEFAULT_AXIS_PROJECTION);
}
function axisProjectionSignature(projection) {
return ["x", "y", "z"]
.map((key) => {
const item = projection[key] || DEFAULT_AXIS_PROJECTION[key];
return `${Math.round(item.dx * 10)},${Math.round(item.dy * 10)},${Math.round(item.opacity * 100)}`;
})
.join("|");
}
function projectModelAxisDirections(camera, object) {
const origin = object.getWorldPosition(new THREE.Vector3());
const originProjected = origin.clone().project(camera);
const quaternion = object.getWorldQuaternion(new THREE.Quaternion());
const axisDirections = {
x: new THREE.Vector3(1, 0, 0),
y: new THREE.Vector3(0, 1, 0),
z: new THREE.Vector3(0, 0, 1),
};
const projectAxis = (direction) => {
const end = origin.clone().add(direction.applyQuaternion(quaternion).normalize().multiplyScalar(0.72));
const endProjected = end.project(camera);
const dx = endProjected.x - originProjected.x;
const dy = originProjected.y - endProjected.y;
const magnitude = Math.hypot(dx, dy);
if (magnitude < 0.0001) return { dx: 0, dy: -5, opacity: 0.5 };
return {
dx: (dx / magnitude) * AXIS_INSET_LENGTH,
dy: (dy / magnitude) * AXIS_INSET_LENGTH,
opacity: endProjected.z < originProjected.z ? 1 : 0.58,
};
};
return {
x: projectAxis(axisDirections.x),
y: projectAxis(axisDirections.y),
z: projectAxis(axisDirections.z),
};
}
function updateFusionAxisInset(projection = null) {
if (!projection) {
if (!state.camera || !state.modelGroup) projection = DEFAULT_AXIS_PROJECTION;
else {
state.fusionRoot?.updateMatrixWorld(true);
state.modelGroup.updateMatrixWorld(true);
projection = projectModelAxisDirections(state.camera, state.modelGroup);
}
}
const signature = axisProjectionSignature(projection);
if (state.axisProjectionSignature === signature) return;
state.axisProjectionSignature = signature;
const origin = { x: 25, y: 31 };
const items = {
x: { line: $("fusionAxisXLine"), text: $("fusionAxisXText"), group: $("fusionAxisX") },
y: { line: $("fusionAxisYLine"), text: $("fusionAxisYText"), group: $("fusionAxisY") },
z: { line: $("fusionAxisZLine"), text: $("fusionAxisZText"), group: $("fusionAxisZ") },
};
Object.entries(items).forEach(([key, item]) => {
const vector = projection[key] || DEFAULT_AXIS_PROJECTION[key];
const endX = origin.x + vector.dx;
const endY = origin.y + vector.dy;
item.group?.setAttribute("opacity", String(vector.opacity));
item.line?.setAttribute("x2", String(endX));
item.line?.setAttribute("y2", String(endY));
item.text?.setAttribute("x", String(endX + (vector.dx >= 0 ? 4 : -4)));
item.text?.setAttribute("y", String(endY + (vector.dy >= 0 ? 6 : -3)));
item.text?.setAttribute("text-anchor", vector.dx >= 0 ? "start" : "end");
});
} }
function createAxisLabel(text, color) { function createAxisLabel(text, color) {
@@ -1168,7 +1297,7 @@ function initGeometryFallback() {
const rawModelGroup = new THREE.Group(); const rawModelGroup = new THREE.Group();
modelGroup.add(rawModelGroup); modelGroup.add(rawModelGroup);
fusionRoot.add(dicomGroup, modelGroup); fusionRoot.add(dicomGroup, modelGroup);
Object.assign(state, { fusionRoot, dicomGroup, modelGroup, rawModelGroup }); Object.assign(state, { fusionRoot, dicomGroup, modelGroup, rawModelGroup, axisGroup: null });
} }
function applySceneViewPose() { function applySceneViewPose() {
@@ -1257,6 +1386,7 @@ function clearFusion() {
state.modelLocalBounds = null; state.modelLocalBounds = null;
$("fusionMeta").textContent = "DICOM - · STL -"; $("fusionMeta").textContent = "DICOM - · STL -";
$("fusionStatus").textContent = "等待选择 DICOM 与 STL"; $("fusionStatus").textContent = "等待选择 DICOM 与 STL";
updateFusionAxisInset(DEFAULT_AXIS_PROJECTION);
updateSliceControl(); updateSliceControl();
resetMappingCanvas(); resetMappingCanvas();
} }
@@ -1274,17 +1404,20 @@ function updateFusionMeta() {
return; return;
} }
const volume = state.volumeMeta; const volume = state.volumeMeta;
$("fusionMeta").textContent = volume if (!volume) {
? `DICOM ${volume.start + 1}-${volume.end + 1}/${volume.total} · STL ${selectedCount}` $("fusionMeta").textContent = `DICOM - · STL ${selectedCount}`;
: `DICOM - · STL ${selectedCount}`; return;
}
const displayRange = getDicomDisplayRange(volume.start, volume.end, volume.total);
$("fusionMeta").textContent = `DICOM ${displayRange.start}-${displayRange.end}/${volume.total} · STL ${selectedCount}`;
} }
function fusionDetailSettings() { function fusionDetailSettings() {
return { return {
low: { planeOpacity: 1, sliceOpacity: 0.045, slicePlanes: 24, pointOpacity: 0, pointSize: 0.012, pointGrid: 72, pointThreshold: 36, textureSize: 384 }, low: { planeOpacity: 0.42, sliceOpacity: 0.045, boxOpacity: 0.2, slicePlanes: 24, pointOpacity: 0, pointSize: 0.012, pointGrid: 72, pointThreshold: 36, textureSize: 384 },
medium: { planeOpacity: 1, sliceOpacity: 0.07, slicePlanes: 48, pointOpacity: 0, pointSize: 0.014, pointGrid: 88, pointThreshold: 34, textureSize: 640 }, medium: { planeOpacity: 0.56, sliceOpacity: 0.075, boxOpacity: 0.26, slicePlanes: 48, pointOpacity: 0, pointSize: 0.014, pointGrid: 88, pointThreshold: 34, textureSize: 640 },
high: { planeOpacity: 1, sliceOpacity: 0.11, slicePlanes: 72, pointOpacity: 0, pointSize: 0.015, pointGrid: 104, pointThreshold: 32, textureSize: 1024 }, high: { planeOpacity: 0.68, sliceOpacity: 0.11, boxOpacity: 0.32, slicePlanes: 72, pointOpacity: 0, pointSize: 0.015, pointGrid: 104, pointThreshold: 32, textureSize: 1024 },
}[state.fusionDetail] || { planeOpacity: 1, sliceOpacity: 0.045, slicePlanes: 24, pointOpacity: 0, pointSize: 0.012, pointGrid: 72, pointThreshold: 36, textureSize: 384 }; }[state.fusionDetail] || { planeOpacity: 0.42, sliceOpacity: 0.045, boxOpacity: 0.2, slicePlanes: 24, pointOpacity: 0, pointSize: 0.012, pointGrid: 72, pointThreshold: 36, textureSize: 384 };
} }
function modelDetailSettings() { function modelDetailSettings() {
@@ -1311,7 +1444,7 @@ function applyFusionDetail() {
} }
state.dicomSlicePlanes.forEach((plane) => { state.dicomSlicePlanes.forEach((plane) => {
if (!plane.material) return; if (!plane.material) return;
plane.material.opacity = detail.sliceOpacity; plane.material.opacity = plane.userData?.rangeBoundary ? detail.planeOpacity : detail.sliceOpacity;
plane.material.needsUpdate = true; plane.material.needsUpdate = true;
}); });
} }
@@ -1358,6 +1491,7 @@ async function loadFusion() {
clearFusion(); clearFusion();
return; return;
} }
$("fusionStatus").textContent = "正在构建三维融合场景...";
setFusionLoading(true, "正在读取 DICOM 切片范围", 8); setFusionLoading(true, "正在读取 DICOM 切片范围", 8);
updateSliceControl(); updateSliceControl();
try { try {
@@ -1415,6 +1549,38 @@ async function loadTexture(url) {
}); });
} }
async function loadDicomFusionTexture(url) {
const image = await new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = url;
});
const width = image.naturalWidth || image.width || 1;
const height = image.naturalHeight || image.height || 1;
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d", { willReadFrequently: true });
context.drawImage(image, 0, 0, width, height);
const imageData = context.getImageData(0, 0, width, height);
for (let index = 0; index < imageData.data.length; index += 4) {
const value = imageData.data[index];
imageData.data[index + 1] = value;
imageData.data[index + 2] = value;
imageData.data[index + 3] = value > 4 ? Math.min(235, 42 + Math.round(value * 0.76)) : 0;
}
context.putImageData(imageData, 0, 0);
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.generateMipmaps = false;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.anisotropy = Math.min(4, state.renderer?.capabilities?.getMaxAnisotropy?.() || 1);
texture.needsUpdate = true;
return texture;
}
async function loadImageData(url, maxSize = 256) { async function loadImageData(url, maxSize = 256) {
const image = await new Promise((resolve, reject) => { const image = await new Promise((resolve, reject) => {
const img = new Image(); const img = new Image();
@@ -1932,7 +2098,7 @@ async function buildDicomVolume(volume) {
state.dicomSlicePlanes = []; state.dicomSlicePlanes = [];
const physical = volume.physicalSize || { width: 1, height: 1, depth: 1 }; const physical = volume.physicalSize || { width: 1, height: 1, depth: 1 };
const maxPhysical = Math.max(physical.width || 1, physical.height || 1, physical.depth || 1, 1); const maxPhysical = Math.max(physical.width || 1, physical.height || 1, physical.depth || 1, 1);
const sceneScale = 4.8 / maxPhysical; const sceneScale = FUSION_BASE_EXTENT / maxPhysical;
const width = (physical.width || 1) * sceneScale; const width = (physical.width || 1) * sceneScale;
const height = (physical.height || 1) * sceneScale; const height = (physical.height || 1) * sceneScale;
const depth = Math.max((physical.depth || 1) * sceneScale, 0.18); const depth = Math.max((physical.depth || 1) * sceneScale, 0.18);
@@ -1940,13 +2106,10 @@ async function buildDicomVolume(volume) {
const total = Math.max(1, Number(volume.total || 1)); const total = Math.max(1, Number(volume.total || 1));
const indices = volume.indices || []; const indices = volume.indices || [];
const frames = volume.frames || []; const frames = volume.frames || [];
const centerPosition = indices.length const fusionDetail = fusionDetailSettings();
? indices.reduce((bestIndex, item, index) => { const rangeStart = clamp(Number(volume.start ?? indices[0] ?? centerIndex) || 0, 0, total - 1);
const bestDistance = Math.abs(Number(indices[bestIndex] ?? centerIndex) - centerIndex); const rangeEnd = clamp(Number(volume.end ?? indices[indices.length - 1] ?? centerIndex) || 0, 0, total - 1);
const distance = Math.abs(Number(item ?? centerIndex) - centerIndex); const boundaryIndices = new Set([Math.min(rangeStart, rangeEnd), Math.max(rangeStart, rangeEnd)]);
return distance < bestDistance ? index : bestIndex;
}, 0)
: 0;
state.volumeScene = { state.volumeScene = {
width, width,
height, height,
@@ -1960,7 +2123,7 @@ async function buildDicomVolume(volume) {
const boxMesh = new THREE.Mesh( const boxMesh = new THREE.Mesh(
new THREE.BoxGeometry(width, height, depth), new THREE.BoxGeometry(width, height, depth),
new THREE.MeshBasicMaterial({ color: 0x020617, transparent: true, opacity: 0.045, depthWrite: false }), new THREE.MeshBasicMaterial({ color: 0x020617, transparent: true, opacity: fusionDetail.boxOpacity, depthWrite: false }),
); );
state.dicomGroup.add(boxMesh); state.dicomGroup.add(boxMesh);
const edges = new THREE.LineSegments( const edges = new THREE.LineSegments(
@@ -1969,58 +2132,28 @@ async function buildDicomVolume(volume) {
); );
state.dicomGroup.add(edges); state.dicomGroup.add(edges);
const localStart = Number(volume.start ?? indices[0] ?? centerIndex); const planeGeometry = new THREE.PlaneGeometry(width, height);
const localEnd = Number(volume.end ?? indices[indices.length - 1] ?? centerIndex); for (let frameIndex = 0; frameIndex < frames.length; frameIndex += 1) {
const rangeDepth = Math.max(0.015, Math.abs(sliceToSceneZ(localEnd) - sliceToSceneZ(localStart))); const dicomIndex = clamp(Number(indices[frameIndex] ?? centerIndex) || 0, 0, total - 1);
const rangeBox = new THREE.LineSegments( const isBoundary = boundaryIndices.has(dicomIndex) || frames.length === 1;
new THREE.EdgesGeometry(new THREE.BoxGeometry(width * 0.94, height * 0.94, rangeDepth)), const texture = await loadDicomFusionTexture(frames[frameIndex]);
new THREE.LineBasicMaterial({ color: 0x38bdf8, transparent: true, opacity: 0.24 }),
);
rangeBox.position.z = (sliceToSceneZ(localStart) + sliceToSceneZ(localEnd)) / 2;
state.dicomGroup.add(rangeBox);
const centerFrame = (volume.frames || [])[centerPosition];
const fusionDetail = fusionDetailSettings();
if (centerFrame) {
const texture = await loadTexture(centerFrame);
const plane = new THREE.Mesh( const plane = new THREE.Mesh(
new THREE.PlaneGeometry(width, height), planeGeometry,
new THREE.MeshBasicMaterial({ new THREE.MeshBasicMaterial({
map: texture, map: texture,
transparent: true, transparent: true,
opacity: fusionDetail.planeOpacity, opacity: isBoundary ? fusionDetail.planeOpacity : fusionDetail.sliceOpacity,
side: THREE.DoubleSide, side: THREE.DoubleSide,
depthWrite: false, depthWrite: false,
depthTest: false, depthTest: true,
}), }),
); );
plane.position.z = sliceToSceneZ(centerIndex) + 0.006; plane.position.z = sliceToSceneZ(dicomIndex) + (isBoundary ? 0.006 : 0);
plane.renderOrder = 80; plane.renderOrder = isBoundary ? 16 : 4;
plane.userData.rangeBoundary = isBoundary;
state.dicomGroup.add(plane); state.dicomGroup.add(plane);
state.dicomPlane = plane; state.dicomSlicePlanes.push(plane);
} if (isBoundary && !state.dicomPlane) state.dicomPlane = plane;
if (frames.length > 1) {
const planeStep = Math.max(1, Math.ceil(frames.length / Math.max(1, fusionDetail.slicePlanes)));
for (let frameIndex = 0; frameIndex < frames.length; frameIndex += planeStep) {
if (frameIndex === centerPosition) continue;
const texture = await loadTexture(frames[frameIndex]);
const plane = new THREE.Mesh(
new THREE.PlaneGeometry(width, height),
new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
opacity: fusionDetail.sliceOpacity,
side: THREE.DoubleSide,
depthWrite: false,
depthTest: true,
}),
);
plane.position.z = sliceToSceneZ(Number(indices[frameIndex] ?? centerIndex));
plane.renderOrder = 4;
state.dicomGroup.add(plane);
state.dicomSlicePlanes.push(plane);
}
} }
if (fusionDetail.pointOpacity > 0) { if (fusionDetail.pointOpacity > 0) {
@@ -2076,7 +2209,7 @@ function updateBaseModelScaleFromBounds() {
const maxModelSize = Math.max(Number(size.x) || 0, Number(size.y) || 0, Number(size.z) || 0, 1); const maxModelSize = Math.max(Number(size.x) || 0, Number(size.y) || 0, Number(size.z) || 0, 1);
const volumeSize = state.volumeScene const volumeSize = state.volumeScene
? Math.max(state.volumeScene.width, state.volumeScene.height, state.volumeScene.depth, 1) ? Math.max(state.volumeScene.width, state.volumeScene.height, state.volumeScene.depth, 1)
: 4.8; : FUSION_BASE_EXTENT;
state.baseModelScale = (volumeSize / maxModelSize) * 0.92; state.baseModelScale = (volumeSize / maxModelSize) * 0.92;
} }
@@ -2088,10 +2221,7 @@ function addModelBoundsFrame(size) {
new THREE.LineBasicMaterial({ new THREE.LineBasicMaterial({
color: 0xfacc15, color: 0xfacc15,
transparent: true, transparent: true,
opacity: 0.96, opacity: 0.72,
depthTest: false,
depthWrite: false,
toneMapped: false,
}), }),
); );
frame.name = "model-bounds"; frame.name = "model-bounds";
@@ -2244,41 +2374,19 @@ function applyPose(poseInput = state.pose) {
function fitCamera() { function fitCamera() {
if (!state.camera) return; if (!state.camera) return;
applySceneViewPose(); applySceneViewPose();
const box = new THREE.Box3(); state.camera.position.set(0, -6.2, 4.6);
if (state.dicomGroup?.visible !== false) box.expandByObject(state.dicomGroup); state.camera.up.set(0, 0, 1);
const modelBox = visibleModelBox(); state.camera.lookAt(0, 0, 0);
if (modelBox) box.union(modelBox); state.controls?.target.set(0, 0, 0);
if (box.isEmpty()) {
state.camera.position.set(0, -8.5, 4.4);
state.camera.lookAt(0, 0, 0);
state.controls?.target.set(0, 0, 0);
state.controls?.update();
return;
}
const size = new THREE.Vector3();
const center = new THREE.Vector3();
box.getSize(size);
box.getCenter(center);
const distance = Math.max(size.x, size.y, size.z, 1) * 1.8;
state.camera.position.set(center.x, center.y - distance, center.z + distance * 0.45);
state.camera.lookAt(center);
state.controls?.target.copy(center);
if (state.axisGroup) {
const axisScale = Math.max(size.x, size.y, size.z, 1) / 5.5;
state.axisGroup.scale.setScalar(axisScale);
state.axisGroup.position.set(
center.x - size.x * 0.46,
center.y - size.y * 0.46,
center.z - size.z * 0.42,
);
}
state.controls?.update(); state.controls?.update();
updateFusionAxisInset(DEFAULT_AXIS_PROJECTION);
} }
function resetSceneView() { function resetSceneView() {
state.viewPose = { rotateX: 1.012, rotateZ: -0.314, translateX: 0, translateY: 0, scale: 1 }; state.viewPose = { rotateX: 1.012, rotateZ: -0.314, translateX: 0, translateY: 0, scale: 1 };
applySceneViewPose(); applySceneViewPose();
fitCamera(); fitCamera();
$("fusionStatus").textContent = state.volumeMeta ? "三维融合视角已复位" : "等待选择 DICOM 与 STL";
} }
function resetPose(kind) { function resetPose(kind) {
@@ -2297,6 +2405,13 @@ function resetPose(kind) {
} }
function stretchAxis(axis) { function stretchAxis(axis) {
if (!canStretchByAxis()) {
const message = "仅当旋转 X/Y/Z 均为 0 或 90° 的倍数时可使用 XYZ 拉伸。";
setAutoState("XYZ 拉伸不可用", "warn");
$("autoResult").textContent = message;
updateStretchButtons();
return;
}
const modelBox = visibleModelBox(); const modelBox = visibleModelBox();
if (!modelBox || !state.dicomSceneBox) { if (!modelBox || !state.dicomSceneBox) {
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。"; $("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";

View File

@@ -119,8 +119,7 @@
<button class="filter" data-part="head_neck">头颈部</button> <button class="filter" data-part="head_neck">头颈部</button>
<button class="filter" data-part="chest">胸部</button> <button class="filter" data-part="chest">胸部</button>
<button class="filter" data-part="upper_abdomen">上腹部</button> <button class="filter" data-part="upper_abdomen">上腹部</button>
<button class="filter" data-part="lower_abdomen">腹部</button> <button class="filter" data-part="abdomen_pelvis"></button>
<button class="filter" data-part="pelvis">盆腔</button>
</div> </div>
<div class="model-filter"> <div class="model-filter">
<button class="filter active" data-model-filter="">全部模型</button> <button class="filter active" data-model-filter="">全部模型</button>
@@ -163,9 +162,9 @@
<section class="tool-pane" data-tool-pane="models"> <section class="tool-pane" data-tool-pane="models">
<div class="display-segmented" id="modelDetailControls"> <div class="display-segmented" id="modelDetailControls">
<button class="active" data-model-detail="standard" type="button">标准</button> <button data-model-detail="standard" type="button">标准</button>
<button data-model-detail="fine" type="button">精细</button> <button data-model-detail="fine" type="button">精细</button>
<button data-model-detail="ultra" type="button">超精细</button> <button class="active" data-model-detail="ultra" type="button">超精细</button>
<button data-model-detail="solid" type="button">实体</button> <button data-model-detail="solid" type="button">实体</button>
</div> </div>
<div class="tool-section-title compact-title"> <div class="tool-section-title compact-title">
@@ -283,28 +282,56 @@
<button id="stretchXBtn" class="ghost-btn">X拉伸</button> <button id="stretchXBtn" class="ghost-btn">X拉伸</button>
<button id="stretchYBtn" class="ghost-btn">Y拉伸</button> <button id="stretchYBtn" class="ghost-btn">Y拉伸</button>
<button id="stretchZBtn" class="ghost-btn">Z拉伸</button> <button id="stretchZBtn" class="ghost-btn">Z拉伸</button>
<button id="fitBtn" class="ghost-btn">视角复位</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="fusion-stage"> <div class="fusion-stage">
<div id="fusionViewport"></div> <div id="fusionViewport" aria-label="DICOM 与 STL 三维融合视图"></div>
<div class="fusion-hud left" id="fusionStatus">等待选择 DICOM 与 STL</div> <div id="fusionWebglError" class="fusion-webgl-error hidden">
<div class="fusion-hud right" id="fusionMeta">DICOM - · STL -</div> <div>
<div class="fusion-axis-inset" aria-hidden="true"> <strong>三维融合视图无法启动</strong>
<svg viewBox="0 0 76 76" role="img"> <p>请检查浏览器硬件加速、显卡驱动或远程桌面图形支持。二维 DICOM 与映射功能仍可继续使用。</p>
<line x1="25" y1="56" x2="58" y2="56" class="axis-x" /> </div>
<line x1="25" y1="56" x2="36" y2="34" class="axis-y" /> </div>
<line x1="25" y1="56" x2="25" y2="20" class="axis-z" /> <div class="fusion-hud fusion-status" id="fusionStatus">等待选择 DICOM 与 STL</div>
<circle cx="25" cy="56" r="3" /> <div class="fusion-hud fusion-meta" id="fusionMeta">DICOM - · STL -</div>
<text x="64" y="60">X</text> <button id="fitBtn" class="fusion-reset-btn" type="button" title="重置影像与模型融合视角位置">
<text x="39" y="31">Y</text> <svg viewBox="0 0 24 24" aria-hidden="true">
<text x="21" y="16">Z</text> <path d="M3 12a9 9 0 0 1 15.2-6.5L21 8.3M21 4v4.3h-4.3M21 12a9 9 0 0 1-15.2 6.5L3 15.7M3 20v-4.3h4.3" />
</svg>
位置重置
</button>
<div class="fusion-axis-inset" title="当前视角下模型平移 XYZ 方向" aria-hidden="true">
<svg width="54" height="54" viewBox="0 0 54 54">
<defs>
<marker id="fusion-axis-arrow-x" markerWidth="5" markerHeight="5" refX="4.3" refY="2.5" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L5,2.5 L0,5 Z" fill="#ef4444" />
</marker>
<marker id="fusion-axis-arrow-y" markerWidth="5" markerHeight="5" refX="4.3" refY="2.5" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L5,2.5 L0,5 Z" fill="#22c55e" />
</marker>
<marker id="fusion-axis-arrow-z" markerWidth="5" markerHeight="5" refX="4.3" refY="2.5" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L5,2.5 L0,5 Z" fill="#38bdf8" />
</marker>
</defs>
<circle cx="25" cy="31" r="2.2" />
<g id="fusionAxisX">
<line id="fusionAxisXLine" x1="25" y1="31" x2="42" y2="31" class="axis-x" marker-end="url(#fusion-axis-arrow-x)" />
<text id="fusionAxisXText" x="46" y="28" class="axis-x-text" text-anchor="start">X</text>
</g>
<g id="fusionAxisY">
<line id="fusionAxisYLine" x1="25" y1="31" x2="15" y2="41" class="axis-y" marker-end="url(#fusion-axis-arrow-y)" />
<text id="fusionAxisYText" x="11" y="47" class="axis-y-text" text-anchor="end">Y</text>
</g>
<g id="fusionAxisZ">
<line id="fusionAxisZLine" x1="25" y1="31" x2="25" y2="14" class="axis-z" marker-end="url(#fusion-axis-arrow-z)" />
<text id="fusionAxisZText" x="29" y="11" class="axis-z-text" text-anchor="start">Z</text>
</g>
</svg> </svg>
</div> </div>
<div id="fusionLoading" class="fusion-loading hidden"> <div id="fusionLoading" class="fusion-loading hidden">
<span>正在构建融合视图</span> <span>正在融合三维影像与模型</span>
<div><i id="fusionProgressFill"></i></div> <div><i id="fusionProgressFill"></i></div>
<em id="fusionProgressText">0%</em> <em id="fusionProgressText">0%</em>
</div> </div>

View File

@@ -1018,6 +1018,10 @@ button {
opacity: 0.62; opacity: 0.62;
} }
.viewer-tools .ghost-btn:disabled {
cursor: not-allowed;
}
.ghost-btn.accent { .ghost-btn.accent {
border-color: rgba(25, 214, 195, 0.56); border-color: rgba(25, 214, 195, 0.56);
color: #bffbf2; color: #bffbf2;
@@ -1087,13 +1091,25 @@ button {
.fusion-stage { .fusion-stage {
position: relative; position: relative;
min-height: 0; height: 100%;
min-height: 520px;
margin: 12px 12px 0;
overflow: hidden;
border: 1px solid #1e293b;
border-radius: 24px;
background: #000; background: #000;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.48), 0 8px 10px -6px rgba(0, 0, 0, 0.36);
} }
#fusionViewport { #fusionViewport {
position: absolute; position: absolute;
inset: 0; inset: 0;
cursor: grab;
touch-action: none;
}
#fusionViewport:active {
cursor: grabbing;
} }
#fusionViewport canvas { #fusionViewport canvas {
@@ -1104,99 +1120,190 @@ button {
.fusion-hud { .fusion-hud {
position: absolute; position: absolute;
z-index: 4; top: 16px;
z-index: 10;
max-width: 46%; max-width: 46%;
overflow: hidden; overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px; border-radius: 12px;
background: rgba(0, 0, 0, 0.58); background: rgba(0, 0, 0, 0.6);
color: rgba(255, 255, 255, 0.72); color: rgba(255, 255, 255, 0.62);
font-size: 11px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 10px;
font-weight: 800; font-weight: 800;
line-height: 1.2;
padding: 8px 10px; padding: 8px 10px;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.fusion-hud.left { .fusion-status {
top: 14px; left: 16px;
left: 14px;
} }
.fusion-hud.right { .fusion-meta {
top: 14px; right: 16px;
right: 14px; border-color: rgba(34, 211, 238, 0.22);
background: rgba(8, 47, 73, 0.5);
color: #cffafe;
}
.fusion-reset-btn {
position: absolute;
top: 64px;
right: 16px;
z-index: 11;
height: 32px;
display: flex;
align-items: center;
gap: 7px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
background: rgba(0, 0, 0, 0.6);
color: rgba(255, 255, 255, 0.72);
padding: 0 12px;
font-size: 10px;
font-weight: 900;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.3);
}
.fusion-reset-btn:hover {
border-color: rgba(103, 232, 249, 0.34);
color: #cffafe;
}
.fusion-reset-btn svg {
width: 13px;
height: 13px;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 2;
} }
.fusion-axis-inset { .fusion-axis-inset {
position: absolute; position: absolute;
right: 18px; right: 12px;
bottom: 18px; bottom: 12px;
z-index: 4; z-index: 10;
width: 76px; width: 66px;
height: 76px; height: 66px;
border: 1px solid rgba(148, 163, 184, 0.18); border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 14px; border-radius: 9px;
background: rgba(0, 0, 0, 0.58); background: rgba(0, 0, 0, 0.6);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28); padding: 6px;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.32);
pointer-events: none;
} }
.fusion-axis-inset svg { .fusion-axis-inset svg {
display: block; display: block;
width: 100%; width: 54px;
height: 100%; height: 54px;
} }
.fusion-axis-inset line { .fusion-axis-inset line {
stroke-width: 3; stroke-width: 2.2;
stroke-linecap: round; stroke-linecap: round;
} }
.fusion-axis-inset circle { .fusion-axis-inset circle {
fill: #f87171; fill: #e5e7eb;
} }
.fusion-axis-inset text { .fusion-axis-inset text {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11px; font-size: 8px;
font-weight: 950; font-weight: 800;
fill: #e8f1fb;
} }
.fusion-axis-inset .axis-x { .fusion-axis-inset .axis-x {
stroke: #f87171; stroke: #ef4444;
} }
.fusion-axis-inset .axis-y { .fusion-axis-inset .axis-y {
stroke: #4ade80; stroke: #22c55e;
} }
.fusion-axis-inset .axis-z { .fusion-axis-inset .axis-z {
stroke: #60a5fa; stroke: #38bdf8;
}
.fusion-axis-inset .axis-x-text {
fill: #fecaca;
}
.fusion-axis-inset .axis-y-text {
fill: #bbf7d0;
}
.fusion-axis-inset .axis-z-text {
fill: #bae6fd;
}
.fusion-webgl-error {
position: absolute;
inset: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
background: rgba(2, 6, 23, 0.92);
padding: 32px;
text-align: center;
}
.fusion-webgl-error > div {
max-width: 420px;
border: 1px solid rgba(252, 211, 77, 0.2);
border-radius: 16px;
background: rgba(15, 23, 42, 0.9);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.38);
color: #fef3c7;
padding: 22px;
}
.fusion-webgl-error strong {
display: block;
font-size: 14px;
font-weight: 950;
}
.fusion-webgl-error p {
margin: 12px 0 0;
color: rgba(254, 243, 199, 0.78);
font-size: 12px;
font-weight: 700;
line-height: 1.8;
} }
.fusion-loading { .fusion-loading {
position: absolute; position: absolute;
left: 40px; left: 40px;
right: 40px; right: 40px;
bottom: 24px; bottom: 32px;
z-index: 5; z-index: 12;
border: 1px solid rgba(255, 255, 255, 0.12); border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 13px; border-radius: 12px;
background: rgba(0, 0, 0, 0.68); background: rgba(0, 0, 0, 0.7);
padding: 12px; padding: 12px;
} }
.fusion-loading span { .fusion-loading span {
display: block; display: inline-block;
max-width: calc(100% - 54px);
margin-bottom: 8px; margin-bottom: 8px;
overflow: hidden;
color: rgba(255, 255, 255, 0.72); color: rgba(255, 255, 255, 0.72);
font-size: 12px; font-size: 10px;
font-weight: 900; font-weight: 900;
text-overflow: ellipsis;
white-space: nowrap;
} }
.fusion-loading div { .fusion-loading div {
height: 4px; height: 8px;
overflow: hidden; overflow: hidden;
border-radius: 999px; border-radius: 999px;
background: rgba(255, 255, 255, 0.12); background: rgba(255, 255, 255, 0.12);
@@ -1212,7 +1319,7 @@ button {
.fusion-loading i { .fusion-loading i {
width: 0; width: 0;
background: linear-gradient(90deg, var(--cyan), var(--blue)); background: #3b82f6;
transition: width 0.18s ease; transition: width 0.18s ease;
} }
@@ -1227,10 +1334,13 @@ button {
} }
.fusion-loading em { .fusion-loading em {
position: absolute;
top: 12px;
right: 12px;
display: block; display: block;
margin-top: 6px; margin: 0;
color: #c8f7ff; color: rgba(255, 255, 255, 0.72);
font-size: 11px; font-size: 10px;
font-style: normal; font-style: normal;
font-weight: 900; font-weight: 900;
text-align: right; text-align: right;