Update DICOM UPP fusion workspace view
This commit is contained in:
@@ -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,
|
||||
opacity: isBoundary ? fusionDetail.planeOpacity : fusionDetail.sliceOpacity,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
depthTest: true,
|
||||
}),
|
||||
);
|
||||
plane.position.z = sliceToSceneZ(centerIndex) + 0.006;
|
||||
plane.renderOrder = 80;
|
||||
plane.position.z = sliceToSceneZ(dicomIndex) + (isBoundary ? 0.006 : 0);
|
||||
plane.renderOrder = isBoundary ? 16 : 4;
|
||||
plane.userData.rangeBoundary = isBoundary;
|
||||
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,
|
||||
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);
|
||||
}
|
||||
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.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.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();
|
||||
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。";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user