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: "头颈部",
chest: "胸部",
upper_abdomen: "上腹部",
lower_abdomen: "腹部",
pelvis: "盆腔",
abdomen_pelvis: "腹部",
};
const DEFAULT_POSE = {
@@ -40,6 +39,13 @@ const POSE_CONTROLS = [
const STL_COLORS = [0x18d8c9, 0x3f82ff, 0xf5b84b, 0xf87171, 0x9bd76f, 0xd1a6ff, 0x66e0ff, 0xff9f7a];
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 = [
"rotateX",
"rotateY",
@@ -84,8 +90,8 @@ const state = {
pose: { ...DEFAULT_POSE },
windowMode: "default",
fusionMode: "fusion",
fusionDetail: "low",
modelDetail: "standard",
fusionDetail: "high",
modelDetail: "ultra",
mappingMode: "result",
sliceIndex: 0,
sliceRangeStart: 0,
@@ -127,6 +133,7 @@ const state = {
mappingVersion: 0,
resizeObserver: null,
resizeScene: null,
axisProjectionSignature: "",
nudgeTimer: null,
nudgeDelayTimer: null,
};
@@ -147,6 +154,29 @@ function clamp(value, min, max) {
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) {
$("topLoading").classList.toggle("hidden", !visible);
}
@@ -277,9 +307,11 @@ async function bootstrap() {
loginVisible(false);
try {
initScene();
setFusionWebglError("");
} catch (error) {
initGeometryFallback();
$("fusionStatus").textContent = `WebGL 初始化失败:${error.message}`;
setFusionWebglError("三维融合视图暂不可用,请检查浏览器硬件加速、显卡驱动或远程桌面图形支持。二维 DICOM 与映射功能仍可继续使用。");
console.warn(error);
}
buildPoseControls();
@@ -601,8 +633,8 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") {
const savedTransform = { ...(registration.transform || {}), scaleX: 1, scaleY: 1, scaleZ: 1 };
const keepPreviousPose = switchingModelOnly;
state.pose = keepPreviousPose ? { ...DEFAULT_POSE, ...previousPose } : { ...DEFAULT_POSE, ...savedTransform };
state.fusionDetail = state.fusionDetail || "low";
state.modelDetail = registration.model_reference?.model_detail || state.modelDetail || "standard";
state.fusionDetail = registration.model_reference?.fusion_detail || state.fusionDetail || "high";
state.modelDetail = registration.model_reference?.model_detail || state.modelDetail || "ultra";
state.autoResult = null;
state.lastAutoPose = null;
state.sliceIndex = 0;
@@ -1006,6 +1038,28 @@ function renderPoseControls() {
button.classList.toggle("active", Boolean(state.pose[button.dataset.flip]));
});
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() {
@@ -1070,14 +1124,17 @@ function updateDicomPreview() {
function initScene() {
if (state.sceneReady) return;
const viewport = $("fusionViewport");
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
renderer.setClearColor(0x000000, 1);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setClearColor(0x030712, 1);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.localClippingEnabled = true;
viewport.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(38, 1, 0.01, 1000);
camera.position.set(0, -8.5, 4.4);
scene.background = new THREE.Color(0x030712);
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.lookAt(0, 0, 0);
@@ -1085,21 +1142,22 @@ function initScene() {
controls.enabled = false;
controls.target.set(0, 0, 0);
const ambient = new THREE.AmbientLight(0xffffff, 0.82);
const directional = new THREE.DirectionalLight(0xffffff, 1.8);
directional.position.set(3.5, -4, 6);
scene.add(ambient, directional);
const ambient = new THREE.AmbientLight(0xffffff, 0.72);
const keyLight = new THREE.DirectionalLight(0xffffff, 1.1);
keyLight.position.set(4, -5, 5);
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 modelGroup = new THREE.Group();
const rawModelGroup = new THREE.Group();
const axisGroup = createSceneAxes();
const fusionRoot = new THREE.Group();
modelGroup.add(rawModelGroup);
fusionRoot.add(dicomGroup, modelGroup, axisGroup);
fusionRoot.add(dicomGroup, modelGroup);
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 rect = viewport.getBoundingClientRect();
@@ -1116,10 +1174,81 @@ function initScene() {
const animate = () => {
requestAnimationFrame(animate);
applySceneViewPose();
updateFusionAxisInset();
renderer.render(scene, camera);
};
animate();
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) {
@@ -1168,7 +1297,7 @@ function initGeometryFallback() {
const rawModelGroup = new THREE.Group();
modelGroup.add(rawModelGroup);
fusionRoot.add(dicomGroup, modelGroup);
Object.assign(state, { fusionRoot, dicomGroup, modelGroup, rawModelGroup });
Object.assign(state, { fusionRoot, dicomGroup, modelGroup, rawModelGroup, axisGroup: null });
}
function applySceneViewPose() {
@@ -1257,6 +1386,7 @@ function clearFusion() {
state.modelLocalBounds = null;
$("fusionMeta").textContent = "DICOM - · STL -";
$("fusionStatus").textContent = "等待选择 DICOM 与 STL";
updateFusionAxisInset(DEFAULT_AXIS_PROJECTION);
updateSliceControl();
resetMappingCanvas();
}
@@ -1274,17 +1404,20 @@ function updateFusionMeta() {
return;
}
const volume = state.volumeMeta;
$("fusionMeta").textContent = volume
? `DICOM ${volume.start + 1}-${volume.end + 1}/${volume.total} · STL ${selectedCount}`
: `DICOM - · STL ${selectedCount}`;
if (!volume) {
$("fusionMeta").textContent = `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() {
return {
low: { planeOpacity: 1, sliceOpacity: 0.045, 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 },
high: { planeOpacity: 1, sliceOpacity: 0.11, 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 };
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: 0.56, sliceOpacity: 0.075, boxOpacity: 0.26, slicePlanes: 48, pointOpacity: 0, pointSize: 0.014, pointGrid: 88, pointThreshold: 34, textureSize: 640 },
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: 0.42, sliceOpacity: 0.045, boxOpacity: 0.2, slicePlanes: 24, pointOpacity: 0, pointSize: 0.012, pointGrid: 72, pointThreshold: 36, textureSize: 384 };
}
function modelDetailSettings() {
@@ -1311,7 +1444,7 @@ function applyFusionDetail() {
}
state.dicomSlicePlanes.forEach((plane) => {
if (!plane.material) return;
plane.material.opacity = detail.sliceOpacity;
plane.material.opacity = plane.userData?.rangeBoundary ? detail.planeOpacity : detail.sliceOpacity;
plane.material.needsUpdate = true;
});
}
@@ -1358,6 +1491,7 @@ async function loadFusion() {
clearFusion();
return;
}
$("fusionStatus").textContent = "正在构建三维融合场景...";
setFusionLoading(true, "正在读取 DICOM 切片范围", 8);
updateSliceControl();
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) {
const image = await new Promise((resolve, reject) => {
const img = new Image();
@@ -1932,7 +2098,7 @@ async function buildDicomVolume(volume) {
state.dicomSlicePlanes = [];
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 sceneScale = 4.8 / maxPhysical;
const sceneScale = FUSION_BASE_EXTENT / maxPhysical;
const width = (physical.width || 1) * sceneScale;
const height = (physical.height || 1) * sceneScale;
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 indices = volume.indices || [];
const frames = volume.frames || [];
const centerPosition = indices.length
? indices.reduce((bestIndex, item, index) => {
const bestDistance = Math.abs(Number(indices[bestIndex] ?? centerIndex) - centerIndex);
const distance = Math.abs(Number(item ?? centerIndex) - centerIndex);
return distance < bestDistance ? index : bestIndex;
}, 0)
: 0;
const fusionDetail = fusionDetailSettings();
const rangeStart = clamp(Number(volume.start ?? indices[0] ?? centerIndex) || 0, 0, total - 1);
const rangeEnd = clamp(Number(volume.end ?? indices[indices.length - 1] ?? centerIndex) || 0, 0, total - 1);
const boundaryIndices = new Set([Math.min(rangeStart, rangeEnd), Math.max(rangeStart, rangeEnd)]);
state.volumeScene = {
width,
height,
@@ -1960,7 +2123,7 @@ async function buildDicomVolume(volume) {
const boxMesh = new THREE.Mesh(
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);
const edges = new THREE.LineSegments(
@@ -1969,58 +2132,28 @@ async function buildDicomVolume(volume) {
);
state.dicomGroup.add(edges);
const localStart = Number(volume.start ?? indices[0] ?? centerIndex);
const localEnd = Number(volume.end ?? indices[indices.length - 1] ?? centerIndex);
const rangeDepth = Math.max(0.015, Math.abs(sliceToSceneZ(localEnd) - sliceToSceneZ(localStart)));
const rangeBox = new THREE.LineSegments(
new THREE.EdgesGeometry(new THREE.BoxGeometry(width * 0.94, height * 0.94, rangeDepth)),
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 planeGeometry = new THREE.PlaneGeometry(width, height);
for (let frameIndex = 0; frameIndex < frames.length; frameIndex += 1) {
const dicomIndex = clamp(Number(indices[frameIndex] ?? centerIndex) || 0, 0, total - 1);
const isBoundary = boundaryIndices.has(dicomIndex) || frames.length === 1;
const texture = await loadDicomFusionTexture(frames[frameIndex]);
const plane = new THREE.Mesh(
new THREE.PlaneGeometry(width, height),
planeGeometry,
new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
opacity: fusionDetail.planeOpacity,
side: THREE.DoubleSide,
depthWrite: false,
depthTest: false,
}),
);
plane.position.z = sliceToSceneZ(centerIndex) + 0.006;
plane.renderOrder = 80;
state.dicomGroup.add(plane);
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,
opacity: isBoundary ? fusionDetail.planeOpacity : fusionDetail.sliceOpacity,
side: THREE.DoubleSide,
depthWrite: false,
depthTest: true,
}),
);
plane.position.z = sliceToSceneZ(Number(indices[frameIndex] ?? centerIndex));
plane.renderOrder = 4;
plane.position.z = sliceToSceneZ(dicomIndex) + (isBoundary ? 0.006 : 0);
plane.renderOrder = isBoundary ? 16 : 4;
plane.userData.rangeBoundary = isBoundary;
state.dicomGroup.add(plane);
state.dicomSlicePlanes.push(plane);
}
if (isBoundary && !state.dicomPlane) state.dicomPlane = plane;
}
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 volumeSize = state.volumeScene
? Math.max(state.volumeScene.width, state.volumeScene.height, state.volumeScene.depth, 1)
: 4.8;
: FUSION_BASE_EXTENT;
state.baseModelScale = (volumeSize / maxModelSize) * 0.92;
}
@@ -2088,10 +2221,7 @@ function addModelBoundsFrame(size) {
new THREE.LineBasicMaterial({
color: 0xfacc15,
transparent: true,
opacity: 0.96,
depthTest: false,
depthWrite: false,
toneMapped: false,
opacity: 0.72,
}),
);
frame.name = "model-bounds";
@@ -2244,41 +2374,19 @@ function applyPose(poseInput = state.pose) {
function fitCamera() {
if (!state.camera) return;
applySceneViewPose();
const box = new THREE.Box3();
if (state.dicomGroup?.visible !== false) box.expandByObject(state.dicomGroup);
const modelBox = visibleModelBox();
if (modelBox) box.union(modelBox);
if (box.isEmpty()) {
state.camera.position.set(0, -8.5, 4.4);
state.camera.position.set(0, -6.2, 4.6);
state.camera.up.set(0, 0, 1);
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();
updateFusionAxisInset(DEFAULT_AXIS_PROJECTION);
}
function resetSceneView() {
state.viewPose = { rotateX: 1.012, rotateZ: -0.314, translateX: 0, translateY: 0, scale: 1 };
applySceneViewPose();
fitCamera();
$("fusionStatus").textContent = state.volumeMeta ? "三维融合视角已复位" : "等待选择 DICOM 与 STL";
}
function resetPose(kind) {
@@ -2297,6 +2405,13 @@ function resetPose(kind) {
}
function stretchAxis(axis) {
if (!canStretchByAxis()) {
const message = "仅当旋转 X/Y/Z 均为 0 或 90° 的倍数时可使用 XYZ 拉伸。";
setAutoState("XYZ 拉伸不可用", "warn");
$("autoResult").textContent = message;
updateStretchButtons();
return;
}
const modelBox = visibleModelBox();
if (!modelBox || !state.dicomSceneBox) {
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";

View File

@@ -119,8 +119,7 @@
<button class="filter" data-part="head_neck">头颈部</button>
<button class="filter" data-part="chest">胸部</button>
<button class="filter" data-part="upper_abdomen">上腹部</button>
<button class="filter" data-part="lower_abdomen">腹部</button>
<button class="filter" data-part="pelvis">盆腔</button>
<button class="filter" data-part="abdomen_pelvis"></button>
</div>
<div class="model-filter">
<button class="filter active" data-model-filter="">全部模型</button>
@@ -163,9 +162,9 @@
<section class="tool-pane" data-tool-pane="models">
<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="ultra" type="button">超精细</button>
<button class="active" data-model-detail="ultra" type="button">超精细</button>
<button data-model-detail="solid" type="button">实体</button>
</div>
<div class="tool-section-title compact-title">
@@ -283,28 +282,56 @@
<button id="stretchXBtn" class="ghost-btn">X拉伸</button>
<button id="stretchYBtn" class="ghost-btn">Y拉伸</button>
<button id="stretchZBtn" class="ghost-btn">Z拉伸</button>
<button id="fitBtn" class="ghost-btn">视角复位</button>
</div>
</div>
</div>
</div>
<div class="fusion-stage">
<div id="fusionViewport"></div>
<div class="fusion-hud left" id="fusionStatus">等待选择 DICOM 与 STL</div>
<div class="fusion-hud right" id="fusionMeta">DICOM - · STL -</div>
<div class="fusion-axis-inset" aria-hidden="true">
<svg viewBox="0 0 76 76" role="img">
<line x1="25" y1="56" x2="58" y2="56" class="axis-x" />
<line x1="25" y1="56" x2="36" y2="34" class="axis-y" />
<line x1="25" y1="56" x2="25" y2="20" class="axis-z" />
<circle cx="25" cy="56" r="3" />
<text x="64" y="60">X</text>
<text x="39" y="31">Y</text>
<text x="21" y="16">Z</text>
<div id="fusionViewport" aria-label="DICOM 与 STL 三维融合视图"></div>
<div id="fusionWebglError" class="fusion-webgl-error hidden">
<div>
<strong>三维融合视图无法启动</strong>
<p>请检查浏览器硬件加速、显卡驱动或远程桌面图形支持。二维 DICOM 与映射功能仍可继续使用。</p>
</div>
</div>
<div class="fusion-hud fusion-status" id="fusionStatus">等待选择 DICOM 与 STL</div>
<div class="fusion-hud fusion-meta" id="fusionMeta">DICOM - · STL -</div>
<button id="fitBtn" class="fusion-reset-btn" type="button" title="重置影像与模型融合视角位置">
<svg viewBox="0 0 24 24" aria-hidden="true">
<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>
</div>
<div id="fusionLoading" class="fusion-loading hidden">
<span>正在构建融合视图</span>
<span>正在融合三维影像与模型</span>
<div><i id="fusionProgressFill"></i></div>
<em id="fusionProgressText">0%</em>
</div>

View File

@@ -1018,6 +1018,10 @@ button {
opacity: 0.62;
}
.viewer-tools .ghost-btn:disabled {
cursor: not-allowed;
}
.ghost-btn.accent {
border-color: rgba(25, 214, 195, 0.56);
color: #bffbf2;
@@ -1087,13 +1091,25 @@ button {
.fusion-stage {
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;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.48), 0 8px 10px -6px rgba(0, 0, 0, 0.36);
}
#fusionViewport {
position: absolute;
inset: 0;
cursor: grab;
touch-action: none;
}
#fusionViewport:active {
cursor: grabbing;
}
#fusionViewport canvas {
@@ -1104,99 +1120,190 @@ button {
.fusion-hud {
position: absolute;
z-index: 4;
top: 16px;
z-index: 10;
max-width: 46%;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
background: rgba(0, 0, 0, 0.58);
color: rgba(255, 255, 255, 0.72);
font-size: 11px;
background: rgba(0, 0, 0, 0.6);
color: rgba(255, 255, 255, 0.62);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 10px;
font-weight: 800;
line-height: 1.2;
padding: 8px 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.fusion-hud.left {
top: 14px;
left: 14px;
.fusion-status {
left: 16px;
}
.fusion-hud.right {
top: 14px;
right: 14px;
.fusion-meta {
right: 16px;
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 {
position: absolute;
right: 18px;
bottom: 18px;
z-index: 4;
width: 76px;
height: 76px;
right: 12px;
bottom: 12px;
z-index: 10;
width: 66px;
height: 66px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 14px;
background: rgba(0, 0, 0, 0.58);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28);
border-radius: 9px;
background: rgba(0, 0, 0, 0.6);
padding: 6px;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.32);
pointer-events: none;
}
.fusion-axis-inset svg {
display: block;
width: 100%;
height: 100%;
width: 54px;
height: 54px;
}
.fusion-axis-inset line {
stroke-width: 3;
stroke-width: 2.2;
stroke-linecap: round;
}
.fusion-axis-inset circle {
fill: #f87171;
fill: #e5e7eb;
}
.fusion-axis-inset text {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11px;
font-weight: 950;
fill: #e8f1fb;
font-size: 8px;
font-weight: 800;
}
.fusion-axis-inset .axis-x {
stroke: #f87171;
stroke: #ef4444;
}
.fusion-axis-inset .axis-y {
stroke: #4ade80;
stroke: #22c55e;
}
.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 {
position: absolute;
left: 40px;
right: 40px;
bottom: 24px;
z-index: 5;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 13px;
background: rgba(0, 0, 0, 0.68);
bottom: 32px;
z-index: 12;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
background: rgba(0, 0, 0, 0.7);
padding: 12px;
}
.fusion-loading span {
display: block;
display: inline-block;
max-width: calc(100% - 54px);
margin-bottom: 8px;
overflow: hidden;
color: rgba(255, 255, 255, 0.72);
font-size: 12px;
font-size: 10px;
font-weight: 900;
text-overflow: ellipsis;
white-space: nowrap;
}
.fusion-loading div {
height: 4px;
height: 8px;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
@@ -1212,7 +1319,7 @@ button {
.fusion-loading i {
width: 0;
background: linear-gradient(90deg, var(--cyan), var(--blue));
background: #3b82f6;
transition: width 0.18s ease;
}
@@ -1227,10 +1334,13 @@ button {
}
.fusion-loading em {
position: absolute;
top: 12px;
right: 12px;
display: block;
margin-top: 6px;
color: #c8f7ff;
font-size: 11px;
margin: 0;
color: rgba(255, 255, 255, 0.72);
font-size: 10px;
font-style: normal;
font-weight: 900;
text-align: right;